diff --git a/.wxt/types/i18n.d.ts b/.wxt/types/i18n.d.ts index 68efc50..e1e768f 100644 --- a/.wxt/types/i18n.d.ts +++ b/.wxt/types/i18n.d.ts @@ -104,6 +104,14 @@ declare module "wxt/browser" { substitutions?: string | string[], options?: GetMessageOptions, ): string; + /** + * "汇报问题" + */ + getMessage( + messageName: "popupReportIssue", + substitutions?: string | string[], + options?: GetMessageOptions, + ): string; /** * "通用" */ @@ -601,10 +609,10 @@ declare module "wxt/browser" { options?: GetMessageOptions, ): string; /** - * "半透明" + * "左边线" */ getMessage( - messageName: "styleFade", + messageName: "styleBorder", substitutions?: string | string[], options?: GetMessageOptions, ): string; @@ -1288,8 +1296,72 @@ declare module "wxt/browser" { substitutions?: string | string[], options?: GetMessageOptions, ): string; + /** + * "更新内容" + */ + getMessage( + messageName: "changelogTitle", + substitutions?: string | string[], + options?: GetMessageOptions, + ): string; + /** + * "新功能" + */ + getMessage( + messageName: "changelogGroupFeature", + substitutions?: string | string[], + options?: GetMessageOptions, + ): string; + /** + * "改进" + */ + getMessage( + messageName: "changelogGroupImprove", + substitutions?: string | string[], + options?: GetMessageOptions, + ): string; + /** + * "修复" + */ + getMessage( + messageName: "changelogGroupFix", + substitutions?: string | string[], + options?: GetMessageOptions, + ): string; + /** + * "在 GitHub 上 Star" + */ + getMessage( + messageName: "changelogGithub", + substitutions?: string | string[], + options?: GetMessageOptions, + ): string; + /** + * "获取最新更新,欢迎关注:" + */ + getMessage( + messageName: "changelogSocialDesc", + substitutions?: string | string[], + options?: GetMessageOptions, + ): string; + /** + * "知道了" + */ + getMessage( + messageName: "changelogOk", + substitutions?: string | string[], + options?: GetMessageOptions, + ): string; + /** + * "关闭" + */ + getMessage( + messageName: "changelogClose", + substitutions?: string | string[], + options?: GetMessageOptions, + ): string; getMessage( - messageName: "@@extension_id" | "@@ui_locale" | "@@bidi_dir" | "@@bidi_reversed_dir" | "@@bidi_start_edge" | "@@bidi_end_edge" | "extName" | "extDesc" | "translate" | "settings" | "navGeneral" | "navEngines" | "navAppearance" | "navHotkeys" | "navSites" | "navAdvanced" | "secGeneralDesc" | "cardSwitch" | "rowEnabled" | "cardLangs" | "rowFrom" | "rowTo" | "cardDefaultMode" | "rowDisplayMode" | "rowParaDisplayMode" | "modeBilingual" | "modeTranslationOnly" | "modeFollow" | "cardFloatingUi" | "rowFloatingBall" | "rowParagraphBtn" | "btnResetBallPos" | "toastBallPosReset" | "ballPosColHostname" | "ballPosColPosition" | "btnDelete" | "ballPosListEmpty" | "ballPosDefaultTag" | "secEnginesDesc" | "cardPriority" | "cardDisabled" | "cardDisabledDesc" | "cardDisabledEmpty" | "btnEnable" | "btnDisable" | "badgePrimary" | "engineLastOne" | "keyLabelSuffix" | "keyPlaceholder" | "modelLabel" | "btnTest" | "btnClear" | "testing" | "testOk" | "testOkUsage" | "keyInvalid" | "keyRequired" | "keyCleared" | "keySaved" | "keyClearedToast" | "netError" | "descOpenai" | "descDeepl" | "descGemini" | "secAppearanceDesc" | "cardStylePreset" | "rowPreset" | "styleDefault" | "styleDim" | "styleUnderline" | "styleBold" | "styleItalic" | "styleFade" | "previewOrigin" | "previewTrans" | "cardCustomCss" | "cssNoSelector" | "cssNoUrl" | "secHotkeysDesc" | "cardBindings" | "recordTitle" | "recording" | "actionToggleTranslate" | "actionToggleMode" | "actionTranslateParagraph" | "actionToggleExtension" | "conflictReserved" | "conflictDuplicate" | "needModifier" | "secSitesDesc" | "cardListMode" | "rowMode" | "siteBlacklist" | "siteWhitelist" | "cardDomains" | "btnAdd" | "secAdvancedDesc" | "cardPerf" | "rowConcurrency" | "rowUseCache" | "cacheEntries" | "cacheUnknown" | "btnClearCache" | "cardConfig" | "configDesc" | "btnExport" | "btnImport" | "btnReset" | "toastCacheCleared" | "toastExported" | "toastImported" | "toastImportFail" | "toastReset" | "confirmReset" | "optionsLoadFail" | "popupStatus" | "popupMaster" | "popupTranslateThis" | "btnTranslate" | "popupEngine" | "popupLangs" | "popupDisplayMode" | "popupStyle" | "hintDisabled" | "hintNoElements" | "hintCantTranslate" | "hintSaveFail" | "hintInitFail" | "ctxTranslateSelection" | "ballGlyph" | "paraBtnGlyph" | "paraBtnLabel" | "paraBtnRestoreGlyph" | "paraBtnRestoreLabel" | "toastExtOn" | "toastExtOff" | "toastAllEnginesFail" | "toastTranslateFail" | "toastRenderRejected" | "toastAllRejected" | "toastNotTranslatable" | "toastSiteBlocked" | "welcomeTagline" | "welcomeReady" | "welcomeReadyDesc" | "welcomeHotkeys" | "welcomeHotkeysDesc" | "welcomeTargetLang" | "welcomeTargetLangDesc" | "welcomeInteractions" | "welcomeBall" | "welcomeBallDesc" | "welcomeParaBtn" | "welcomeParaBtnDesc" | "welcomeSelection" | "welcomeSelectionDesc" | "welcomeOpenSettings" | "welcomeClose", + messageName: "@@extension_id" | "@@ui_locale" | "@@bidi_dir" | "@@bidi_reversed_dir" | "@@bidi_start_edge" | "@@bidi_end_edge" | "extName" | "extDesc" | "translate" | "settings" | "popupReportIssue" | "navGeneral" | "navEngines" | "navAppearance" | "navHotkeys" | "navSites" | "navAdvanced" | "secGeneralDesc" | "cardSwitch" | "rowEnabled" | "cardLangs" | "rowFrom" | "rowTo" | "cardDefaultMode" | "rowDisplayMode" | "rowParaDisplayMode" | "modeBilingual" | "modeTranslationOnly" | "modeFollow" | "cardFloatingUi" | "rowFloatingBall" | "rowParagraphBtn" | "btnResetBallPos" | "toastBallPosReset" | "ballPosColHostname" | "ballPosColPosition" | "btnDelete" | "ballPosListEmpty" | "ballPosDefaultTag" | "secEnginesDesc" | "cardPriority" | "cardDisabled" | "cardDisabledDesc" | "cardDisabledEmpty" | "btnEnable" | "btnDisable" | "badgePrimary" | "engineLastOne" | "keyLabelSuffix" | "keyPlaceholder" | "modelLabel" | "btnTest" | "btnClear" | "testing" | "testOk" | "testOkUsage" | "keyInvalid" | "keyRequired" | "keyCleared" | "keySaved" | "keyClearedToast" | "netError" | "descOpenai" | "descDeepl" | "descGemini" | "secAppearanceDesc" | "cardStylePreset" | "rowPreset" | "styleDefault" | "styleDim" | "styleUnderline" | "styleBold" | "styleItalic" | "styleBorder" | "previewOrigin" | "previewTrans" | "cardCustomCss" | "cssNoSelector" | "cssNoUrl" | "secHotkeysDesc" | "cardBindings" | "recordTitle" | "recording" | "actionToggleTranslate" | "actionToggleMode" | "actionTranslateParagraph" | "actionToggleExtension" | "conflictReserved" | "conflictDuplicate" | "needModifier" | "secSitesDesc" | "cardListMode" | "rowMode" | "siteBlacklist" | "siteWhitelist" | "cardDomains" | "btnAdd" | "secAdvancedDesc" | "cardPerf" | "rowConcurrency" | "rowUseCache" | "cacheEntries" | "cacheUnknown" | "btnClearCache" | "cardConfig" | "configDesc" | "btnExport" | "btnImport" | "btnReset" | "toastCacheCleared" | "toastExported" | "toastImported" | "toastImportFail" | "toastReset" | "confirmReset" | "optionsLoadFail" | "popupStatus" | "popupMaster" | "popupTranslateThis" | "btnTranslate" | "popupEngine" | "popupLangs" | "popupDisplayMode" | "popupStyle" | "hintDisabled" | "hintNoElements" | "hintCantTranslate" | "hintSaveFail" | "hintInitFail" | "ctxTranslateSelection" | "ballGlyph" | "paraBtnGlyph" | "paraBtnLabel" | "paraBtnRestoreGlyph" | "paraBtnRestoreLabel" | "toastExtOn" | "toastExtOff" | "toastAllEnginesFail" | "toastTranslateFail" | "toastRenderRejected" | "toastAllRejected" | "toastNotTranslatable" | "toastSiteBlocked" | "welcomeTagline" | "welcomeReady" | "welcomeReadyDesc" | "welcomeHotkeys" | "welcomeHotkeysDesc" | "welcomeTargetLang" | "welcomeTargetLangDesc" | "welcomeInteractions" | "welcomeBall" | "welcomeBallDesc" | "welcomeParaBtn" | "welcomeParaBtnDesc" | "welcomeSelection" | "welcomeSelectionDesc" | "welcomeOpenSettings" | "welcomeClose" | "changelogTitle" | "changelogGroupFeature" | "changelogGroupImprove" | "changelogGroupFix" | "changelogGithub" | "changelogSocialDesc" | "changelogOk" | "changelogClose", substitutions?: string | string[], options?: GetMessageOptions, ): string; diff --git a/CONTEXT.md b/CONTEXT.md index 97ac47f..1ced40d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -9,9 +9,14 @@ | **逐段翻译** | 逐段翻译 / Translate on hover | `showParagraphBtn`(设置项)、`translateOne()`(翻译入口)、`createParaBtn()`(悬停按钮注入) | | 全页翻译 | 翻译整页 | `togglePage()` | | 划词翻译 | 翻译选中文本 | `translateSelection()` | +| **更新提示** | 更新内容 / What's New | `src/changelog/`(数据与渲染)、`pt-changelog`(storage key) | **逐段翻译**(2026-08-15 定名):设置 → 悬浮 UI → 「逐段翻译」开关。语义 = 光标悬停在文字上时出现翻译按钮,点击仅翻译该段。关闭即解绑悬停监听(不再检测),即时生效无需刷新。此前文案「段落悬停按钮」因与用户语言对不上导致「找不到开关」,已统一改名;`welcomeParaBtnDesc` 描述文案未改(功能说明,不含名称)。 +**更新提示**(2026-09-03 定名):扩展更新到新的上架版本后,用户下次打开新页面时在页面内弹出的变更说明。语义 = 只告知本次上架版本改了什么;引导教学是首装欢迎页的职责,两者共存不合并。UI 标题作「更新内容」,对话与 issue 中称「更新提示」,代码一律 `changelog`。 + +**上架版本 / 内部版本**(2026-09-03 定名):**内部版本**指每个 issue 修复 PR 都会 bump 的 `package.json` 末位版本号,变动频繁,绝大多数从未离开仓库;**上架版本**指真正传到扩展商店、用户能装到的版本。二者共用同一个版本号字段,区分方式见 ADR-0002。 + ## 约定 - 每个 issue 修复 PR 一并 bump `package.json` 最末位版本号 diff --git a/README.md b/README.md index 61a72f0..e7cccb3 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ pnpm dev | [docs/phases/](docs/phases/) | 9 份阶段实施手册,含代码骨架、取舍理由、验收标准 | | [docs/DoD-report/](docs/DoD-report/) | 各阶段 DoD 验收报告 | | [docs/TESTING.md](docs/TESTING.md) | 自动化测试体系:分层策略、性能与内存、异常与边界、隐私与合规 | +| [docs/adr/](docs/adr/) | 架构决策记录:难以逆转、且不写下来会被误当作随手之举的取舍 | | [store/](store/) | 上架材料 | ## 约定 diff --git a/docs/adr/0001-changelog-in-page-modal.md b/docs/adr/0001-changelog-in-page-modal.md new file mode 100644 index 0000000..6471207 --- /dev/null +++ b/docs/adr/0001-changelog-in-page-modal.md @@ -0,0 +1,15 @@ +# 更新提示走页内模态,首装欢迎页保持独立标签页 + +扩展更新由浏览器静默完成,往往发生在用户不在场时。若沿用首装欢迎页的做法在 `onInstalled` 里 `chrome.tabs.create`,用户回到浏览器时看到的是一个来历不明的标签页,最容易被顺手关掉。因此更新提示改由 content script 在页面内以 shadow DOM 模态呈现,在 `document_end` 触发 —— 页面刚加载完、用户尚未开始阅读的那一刻,到达率更高而打扰更小。首装场景不同:用户刚点完安装正在等反馈,此时开标签页符合预期,故欢迎页维持原样。 + +## 备选方案 + +- **独立标签页** —— 可复用 [entrypoints/welcome/](../../entrypoints/welcome/) 的全套基础设施(HTML 入口、`data-i18n`、`tokens.css`),且 `onInstalled` 只触发一次,没有并发问题。因到达率低而放弃。 +- **popup 内展示** —— 最克制,但用户不主动点工具栏图标就永远看不到,与告知变更的目的相悖。 + +## 影响 + +- 需要仲裁「只弹一次」:多个标签页的 content script 会并发启动,都会读到未读标记。 +- 扩展更新前已打开的旧标签页里 content script 是孤儿(见 [entrypoints/background.ts](../../entrypoints/background.ts) 中 #166 注释),background 无法在更新那一刻主动推送,必须由新 content script 反向拉取。更新提示因此天然延迟到用户下次打开新页面 —— 这是接受的行为而非缺陷。 +- content script 体积敏感。弹窗经动态 `import()` 引入,但 MV3 的 content script 打包为 IIFE 单文件,动态 import 并不会分包 —— 代码仍在主 bundle 内(实测 content.js 53.2 kB → 61.3 kB)。保留动态形式是为了推迟执行时机,以及日后 content script 支持 ESM 时能自动分出去。若这 8 kB 成为负担,可改为经 `web_accessible_resources` 注入独立脚本。 +- 站点名单中被禁用翻译的站点不弹:用户拉黑一个站点的预期是「这个扩展在这里别出现」,而不只是别翻译。 diff --git a/docs/adr/0002-changelog-data-as-release-truth.md b/docs/adr/0002-changelog-data-as-release-truth.md new file mode 100644 index 0000000..77e751b --- /dev/null +++ b/docs/adr/0002-changelog-data-as-release-truth.md @@ -0,0 +1,14 @@ +# changelog 数据即上架版本的唯一真相 + +本项目约定每个 issue 修复 PR 都 bump `package.json` 末位版本号,内部版本变动极频繁,而只有阶段性完成才上传扩展商店 —— 需要一个东西回答「当前这个版本号该不该弹更新提示」。决定不建 `CHANGELOG.md`、不打 git tag,改由 `src/changelog/` 数据文件中出现的版本号来定义哪些是上架版本:写了条目就弹,没写就不弹。这把「哪个版本值得打扰用户」与「你写没写更新说明」合并成同一个动作,不可能出现弹了却没内容、或有内容却没弹。 + +## 备选方案 + +- **git tag 标记上架点** —— 需要额外流程,且 tag 与弹窗内容仍要人工对齐,并未消除不一致的可能。 +- **CHANGELOG.md 构建时解析** —— 要写并维护 Markdown 解析器,而 Markdown 结构不稳定;三语文案在 Markdown 里也难以表达。 + +## 影响 + +- `manifest.version` 与 changelog 条目的版本号字面不等时静默不弹,且无任何报错。构建期校验因此是必需项而非可选项。校验挂在 `pnpm zip`(打包上架)而非 `pnpm build`(日常开发构建) —— 日常构建时当前版本本就是没有条目的内部版本,无条件失败会让 `pnpm build` 永远红灯。与既有的 `.github/scripts/check-commit-conventions.sh` 同属「不靠人工把关」的一类。 +- 开发模式下 `package.json` 的版本可能恰好命中某条已写好的条目,故另需 `import.meta.env.DEV` 跳过,否则 `pnpm dev` 每次热重载都会弹。 +- 历史条目会长期累积在数据文件中,需要定期归档。 diff --git a/docs/testing/unit/changelog/claim.test.ts b/docs/testing/unit/changelog/claim.test.ts new file mode 100644 index 0000000..867f38f --- /dev/null +++ b/docs/testing/unit/changelog/claim.test.ts @@ -0,0 +1,68 @@ +/** + * changelog/claim.ts — 更新提示显示权仲裁 单元测试 + * + * 扩展更新后,用户打开的每个新页面都会有一个 content script 启动并问 + * 「该我弹吗」。它们互不知情,各自读存储会同时读到「未读」,于是同时 + * 弹出。仲裁把判定收到 background(单实例单线程)串行执行。 + * + * 「并发申请只有一个拿到」是本文件的核心用例 —— 去掉串行化后它会立刻 + * 变红,而其余用例照常通过。 + */ +import { describe, test, expect, beforeEach, vi } from 'vitest'; +import { resetStorage } from '~/docs/testing/setup'; + +describe('claimShow', () => { + beforeEach(() => { + resetStorage(); + // claim.ts 持有模块级串行队列,每个用例需要干净的实例 + vi.resetModules(); + }); + + test('首次申请 → 拿到显示权', async () => { + const { claimShow } = await import('~/src/changelog/claim'); + expect(await claimShow('2.1.0')).toBe(true); + }); + + test('同版本第二次申请 → 拒绝', async () => { + const { claimShow } = await import('~/src/changelog/claim'); + await claimShow('2.1.0'); + expect(await claimShow('2.1.0')).toBe(false); + }); + + test('多标签页并发申请 → 只有一个拿到', async () => { + const { claimShow } = await import('~/src/changelog/claim'); + const results = await Promise.all([ + claimShow('2.1.0'), + claimShow('2.1.0'), + claimShow('2.1.0'), + claimShow('2.1.0'), + ]); + expect(results.filter(Boolean)).toHaveLength(1); + }); + + test('不同版本各自独立', async () => { + const { claimShow } = await import('~/src/changelog/claim'); + await claimShow('2.1.0'); + expect(await claimShow('2.2.0')).toBe(true); + }); +}); + +describe('首装闸门', () => { + beforeEach(() => { + resetStorage(); + vi.resetModules(); + }); + + test('置位后一律拒绝发放 —— 新用户不会看到更新提示', async () => { + const { claimShow, markFreshInstall } = await import('~/src/changelog/claim'); + markFreshInstall(); + expect(await claimShow('2.1.0')).toBe(false); + }); + + test('置位早于 markSeen 落盘也生效 —— 消除异步写入的竞态窗口', async () => { + const { claimShow, markFreshInstall } = await import('~/src/changelog/claim'); + // 不调用 markSeen,模拟「storage 尚未写完」的那一瞬 + markFreshInstall(); + expect(await claimShow('2.1.0')).toBe(false); + }); +}); diff --git a/docs/testing/unit/changelog/data.test.ts b/docs/testing/unit/changelog/data.test.ts new file mode 100644 index 0000000..6a2feb6 --- /dev/null +++ b/docs/testing/unit/changelog/data.test.ts @@ -0,0 +1,46 @@ +/** + * changelog/data.ts — 上架版本查询 单元测试 + * + * ADR-0002:changelog 数据即上架版本的唯一真相 —— 数据里写了条目的 + * 版本就是上架版本,会弹更新提示;没写就不弹。查询必须字面相等, + * 不做 semver 范围匹配,否则「2.1」会意外命中「2.1.0」的条目。 + */ +import { describe, test, expect } from 'vitest'; +import { findEntry, type ChangelogEntry } from '~/src/changelog/data'; + +/** 测试用数据 —— 不依赖真实 CHANGELOG,避免每次上架都要改测试 */ +const ENTRIES: ChangelogEntry[] = [ + { + version: '2.1.0', + groups: [ + { + type: 'fix', + items: [ + { + title: { zh_CN: '长对话导出', zh_TW: '長對話匯出', en: 'Long chat export' }, + desc: { zh_CN: '不再遗漏', zh_TW: '不再遺漏', en: 'No longer drops messages' }, + }, + ], + }, + ], + }, + { version: '2.0.65', groups: [] }, +]; + +describe('findEntry', () => { + test('版本有条目 → 返回该条目', () => { + expect(findEntry('2.1.0', ENTRIES)?.version).toBe('2.1.0'); + }); + + test('版本无条目 → undefined(内部版本不弹)', () => { + expect(findEntry('2.0.66', ENTRIES)).toBeUndefined(); + }); + + test('字面相等 —— 「2.1」不命中「2.1.0」', () => { + expect(findEntry('2.1', ENTRIES)).toBeUndefined(); + }); + + test('空数据 → undefined', () => { + expect(findEntry('2.1.0', [])).toBeUndefined(); + }); +}); diff --git a/docs/testing/unit/changelog/decide.test.ts b/docs/testing/unit/changelog/decide.test.ts new file mode 100644 index 0000000..1a8b25d --- /dev/null +++ b/docs/testing/unit/changelog/decide.test.ts @@ -0,0 +1,56 @@ +/** + * changelog/decide.ts — 「该不该弹更新提示」判定 单元测试 + * + * 纯函数,所有输入显式传入(版本、开发模式、是否主框架、站点是否被拉黑)。 + * 四条拦截规则各有一个用例,外加一条优先级用例:开发模式必须压过其他 + * 一切,否则 `pnpm dev` 每次热重载都弹。 + * + * 已读判定不在此处 —— 它必须与标记已读成对原子执行,测试在 claim.test.ts。 + */ +import { describe, test, expect } from 'vitest'; +import { decideShow } from '~/src/changelog/decide'; +import type { ChangelogEntry } from '~/src/changelog/data'; + +const ENTRIES: ChangelogEntry[] = [{ version: '2.1.0', groups: [] }]; + +/** 一切正常、应当弹出的基线输入 */ +const BASE = { + version: '2.1.0', + isDev: false, + isMainFrame: true, + siteBlocked: false, + entries: ENTRIES, +}; + +describe('decideShow', () => { + test('条件齐备 → 弹,并带回要渲染的条目', () => { + const d = decideShow(BASE); + expect(d.show).toBe(true); + if (d.show) expect(d.entry.version).toBe('2.1.0'); + }); + + test('开发模式不弹', () => { + const d = decideShow({ ...BASE, isDev: true }); + expect(d).toEqual({ show: false, reason: 'dev' }); + }); + + test('iframe 内不弹', () => { + const d = decideShow({ ...BASE, isMainFrame: false }); + expect(d).toEqual({ show: false, reason: 'sub-frame' }); + }); + + test('站点被拉黑不弹', () => { + const d = decideShow({ ...BASE, siteBlocked: true }); + expect(d).toEqual({ show: false, reason: 'site-blocked' }); + }); + + test('内部版本(无条目)不弹', () => { + const d = decideShow({ ...BASE, version: '2.0.66' }); + expect(d).toEqual({ show: false, reason: 'no-entry' }); + }); + + test('开发模式压过其余一切条件', () => { + const d = decideShow({ ...BASE, isDev: true, siteBlocked: false }); + expect(d).toEqual({ show: false, reason: 'dev' }); + }); +}); diff --git a/docs/testing/unit/changelog/locale.test.ts b/docs/testing/unit/changelog/locale.test.ts new file mode 100644 index 0000000..72220f2 --- /dev/null +++ b/docs/testing/unit/changelog/locale.test.ts @@ -0,0 +1,44 @@ +/** + * changelog/locale.ts — 变更条目的语言选择 单元测试 + * + * 骨架文案走 chrome.i18n(浏览器按 default_locale 回退),条目文案走 + * 这里。两者的回退目标必须一致,否则未支持语言的用户会看到「标题中文 + * + 条目英文」的混搭。manifest 的 default_locale 是 zh_CN,故此处 + * 未知语言同样回退 zh_CN。 + */ +import { describe, test, expect } from 'vitest'; +import { pickLocale } from '~/src/changelog/locale'; + +describe('pickLocale', () => { + test('简体中文', () => { + expect(pickLocale('zh-CN')).toBe('zh_CN'); + }); + + test('繁体地区一律归 zh_TW', () => { + expect(pickLocale('zh-TW')).toBe('zh_TW'); + expect(pickLocale('zh-HK')).toBe('zh_TW'); + expect(pickLocale('zh-MO')).toBe('zh_TW'); + }); + + test('无地区的 zh 与新加坡华语归简体', () => { + expect(pickLocale('zh')).toBe('zh_CN'); + expect(pickLocale('zh-SG')).toBe('zh_CN'); + }); + + test('英语各地区归 en', () => { + expect(pickLocale('en')).toBe('en'); + expect(pickLocale('en-US')).toBe('en'); + expect(pickLocale('en-GB')).toBe('en'); + }); + + test('未支持语言回退 zh_CN —— 与 manifest default_locale 一致', () => { + expect(pickLocale('ja')).toBe('zh_CN'); + expect(pickLocale('de-DE')).toBe('zh_CN'); + expect(pickLocale('')).toBe('zh_CN'); + }); + + test('大小写不敏感', () => { + expect(pickLocale('ZH-tw')).toBe('zh_TW'); + expect(pickLocale('EN-us')).toBe('en'); + }); +}); diff --git a/docs/testing/unit/changelog/state.test.ts b/docs/testing/unit/changelog/state.test.ts new file mode 100644 index 0000000..9b621cf --- /dev/null +++ b/docs/testing/unit/changelog/state.test.ts @@ -0,0 +1,45 @@ +/** + * changelog/state.ts — 更新提示的已读状态 单元测试 + * + * 已读状态存 sync 的独立 key `pt-changelog`,不进 `pt-settings` —— + * 它不是设置(用户不会在 options 页里改它),且混进去会被 + * settings-import 的配置导出带走,别人导入配置会连「你看过哪些更新」 + * 一起继承。 + * + * 判定口径(第四轮共识):显示出来即算已读,点 X 关掉也算。 + */ +import { describe, test, expect, beforeEach } from 'vitest'; +import { resetStorage, syncStoreSnapshot } from '~/docs/testing/setup'; +import { hasSeen, markSeen } from '~/src/changelog/state'; + +describe('hasSeen / markSeen', () => { + beforeEach(() => { + resetStorage(); + }); + + test('空存储 → 未读', async () => { + expect(await hasSeen('2.1.0')).toBe(false); + }); + + test('markSeen 后 → 已读', async () => { + await markSeen('2.1.0'); + expect(await hasSeen('2.1.0')).toBe(true); + }); + + test('只对被标记的那个版本算已读', async () => { + await markSeen('2.1.0'); + expect(await hasSeen('2.2.0')).toBe(false); + }); + + test('写入 sync 的独立 key,不碰 pt-settings', async () => { + await markSeen('2.1.0'); + const snap = syncStoreSnapshot(); + expect(snap['pt-changelog']).toBeDefined(); + expect(snap['pt-settings']).toBeUndefined(); + }); + + test('存储里是脏数据 → 判未读而不是抛错', async () => { + await chrome.storage.sync.set({ 'pt-changelog': 'not-an-object' }); + expect(await hasSeen('2.1.0')).toBe(false); + }); +}); diff --git a/docs/testing/unit/dom/renderer.test.ts b/docs/testing/unit/dom/renderer.test.ts index fae179b..92a0a75 100644 --- a/docs/testing/unit/dom/renderer.test.ts +++ b/docs/testing/unit/dom/renderer.test.ts @@ -157,7 +157,7 @@ describe('applyStyle', () => { }); test('不会残留旧样式类', () => { - document.documentElement.classList.add('pt-style-fade'); + document.documentElement.classList.add('pt-style-border'); document.documentElement.classList.add('pt-style-bold'); applyStyle('dim'); const styleClasses = [...document.documentElement.classList].filter((c) => diff --git a/docs/testing/unit/storage/style-migration.test.ts b/docs/testing/unit/storage/style-migration.test.ts new file mode 100644 index 0000000..0bc8355 --- /dev/null +++ b/docs/testing/unit/storage/style-migration.test.ts @@ -0,0 +1,49 @@ +/** + * storage — 译文样式 id 迁移 单元测试 + * + * 默认样式改为纯半透明后,原本的 'fade'(纯半透明)与新的 'default' + * 效果重合,故删除 'fade';原 default 的黄铜色左边线改名 'border' + * 挪到列表底部。 + * + * 已经选过 'fade' 的老用户,存储里躺着一个不再存在的 id。不迁移的话 + * applyStyle 会挂上没有任何 CSS 规则的 .pt-style-fade —— 译文变成完全 + * 不透明,用户会觉得「我明明选了半透明」。迁到 'default' 后观感不变。 + */ +import { describe, test, expect, beforeEach, vi } from 'vitest'; +import { resetStorage } from '~/docs/testing/setup'; + +describe('译文样式 id 迁移', () => { + beforeEach(() => { + resetStorage(); + vi.resetModules(); + }); + + test("旧的 'fade' 迁到 'default' —— 老用户观感不变", async () => { + await chrome.storage.sync.set({ 'pt-settings': { style: 'fade' } }); + const { settingsReady } = await import('~/src/storage/settings'); + expect((await settingsReady()).style).toBe('default'); + }); + + test("'border' 是有效 id,原样保留", async () => { + await chrome.storage.sync.set({ 'pt-settings': { style: 'border' } }); + const { settingsReady } = await import('~/src/storage/settings'); + expect((await settingsReady()).style).toBe('border'); + }); + + test("'default' 与其余预设不受影响", async () => { + await chrome.storage.sync.set({ 'pt-settings': { style: 'italic' } }); + const { settingsReady } = await import('~/src/storage/settings'); + expect((await settingsReady()).style).toBe('italic'); + }); + + test('无法识别的样式回落默认,而不是挂一个没有规则的类名', async () => { + await chrome.storage.sync.set({ 'pt-settings': { style: 'nonsense' } }); + const { settingsReady } = await import('~/src/storage/settings'); + expect((await settingsReady()).style).toBe('default'); + }); + + test('空存储 → 默认样式', async () => { + const { settingsReady } = await import('~/src/storage/settings'); + expect((await settingsReady()).style).toBe('default'); + }); +}); diff --git a/docs/testing/unit/styles/custom.test.ts b/docs/testing/unit/styles/custom.test.ts index 787b1cc..26f2868 100644 --- a/docs/testing/unit/styles/custom.test.ts +++ b/docs/testing/unit/styles/custom.test.ts @@ -72,7 +72,7 @@ describe('applyCustomCss', () => { const styleEl = document.getElementById('pt-custom-style'); expect(styleEl).not.toBeNull(); expect(styleEl!.tagName).toBe('STYLE'); - // #168: 双类选择器(0,2,0)与预设 .pt-style-fade .pt-trans 同级, + // #168: 双类选择器(0,2,0)与预设 .pt-style-border .pt-trans 同级, // 自定义 opacity 等属性才能覆盖预设 expect(styleEl!.textContent).toContain('.pt-trans.pt-trans'); expect(styleEl!.textContent).toContain('color: #555'); diff --git a/docs/testing/vitest.config.ts b/docs/testing/vitest.config.ts index 13ea3fe..7192a74 100644 --- a/docs/testing/vitest.config.ts +++ b/docs/testing/vitest.config.ts @@ -28,6 +28,12 @@ export default defineConfig({ 'src/styles/**', 'src/queue/**', 'src/runtime/**', + 'src/changelog/**', + ], + exclude: [ + // modal.ts 是纯 DOM 渲染与 shadow 挂载,与 src/ui/** 同类, + // 本就不在统计范围;changelog 的其余模块都是纯逻辑,全部有单测 + 'src/changelog/modal.ts', ], // #134:门槛键必须是 glob(裸目录键不匹配任何文件,门槛从未生效)。 // 值取当前实际覆盖率的现实下限(再低会无声失效,再高会立即红灯)。 @@ -40,6 +46,7 @@ export default defineConfig({ 'src/styles/**': { lines: 100 }, 'src/queue/**': { lines: 100 }, 'src/runtime/**': { lines: 98 }, + 'src/changelog/**': { lines: 90 }, }, // 覆盖率报告也写到 logs 目录 reportsDirectory: `${LOGS_DIR}/coverage`, diff --git a/entrypoints/background.ts b/entrypoints/background.ts index 40f958e..d41c2d3 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -8,6 +8,8 @@ import { route } from '~/src/engines/router'; import { EngineError } from '~/src/engines/types'; import { ensureE2EMock, applyE2EMock, getE2EMockStats } from '~/src/engines/e2e-mock'; import { initContextMenu } from '~/src/ui/context-menu'; +import { claimShow, markFreshInstall } from '~/src/changelog/claim'; +import { markSeen } from '~/src/changelog/state'; /** 将浏览器 UI 语言映射到 LANG_LIST 中可用的目标语言码 */ function deriveTargetLanguage(uiLang: string): string { @@ -49,6 +51,16 @@ export default defineBackground(() => { initContextMenu().catch(() => {}); if (details.reason === 'install') { + // 首装用户没有「更新」可看 —— 更新提示只服务老用户(ADR-0001), + // 新用户看到的是 welcome 页。 + // + // 两道闸:先同步置位内存标志(立即生效,先于任何 claim 消息抵达), + // 再异步落盘。只靠落盘会留下竞态窗口 —— 详见 claim.ts 的说明。 + markFreshInstall(); + markSeen(chrome.runtime.getManifest().version).catch((e) => + console.error('[PT] 首装标记更新提示已读失败:', e), + ); + // 根据浏览器 UI 语言推导默认目标语言 settingsReady() .then(async () => { @@ -93,6 +105,23 @@ export default defineBackground(() => { } }); + // 更新提示的显示权仲裁(ADR-0001)—— + // 扩展更新后每个新页面的 content script 都会来问,串行判定只放行一个, + // 避免同时打开多个标签页时弹出多个弹窗 + chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg?.type !== 'pt:changelog-claim') return; + // 跨进程载荷不做类型假设 —— 字段不对就当没收到,不去写存储 + if (typeof msg.version !== 'string' || !msg.version) return; + + claimShow(msg.version) + .then((granted) => sendResponse({ ok: true, granted })) + .catch((e) => { + console.error('[PT] 更新提示仲裁失败:', e); + sendResponse({ ok: false, granted: false }); + }); + return true; // 异步响应 + }); + // 健康检查(E2E 测试用于验证消息通道就绪) chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (msg?.type === 'pt:ping') { diff --git a/entrypoints/content.ts b/entrypoints/content.ts index d86a42a..207134e 100644 --- a/entrypoints/content.ts +++ b/entrypoints/content.ts @@ -43,6 +43,9 @@ import { } from '~/src/storage/settings'; import type { Settings } from '~/src/storage/schema'; import { tf } from '~/src/i18n'; +import { isSiteBlocked } from '~/src/dom/site-filter'; +import { decideShow } from '~/src/changelog/decide'; +import { clearSeen } from '~/src/changelog/state'; // pre 判定统一收敛:#117。pre 内单元(.pt-chunk / 纯文本 pre)保留硬换行, // pre 外折叠空白;两处采集/渲染路径共用同一判定,避免只改一处导致行为分叉。 @@ -191,6 +194,55 @@ export default defineContentScript({ } applySettings(s); + /** + * 更新提示(ADR-0001)—— 扩展更新到写有 changelog 条目的上架版本后, + * 用户下次打开新页面时在页内弹出。 + * + * 分两步:子框架 / 开发构建 / 被拉黑站点 / 内部版本在本地就排除, + * 不去打扰 SW;剩下的才向 background 申请显示权,多标签页并发时 + * 只有一个拿到(见 changelog/claim.ts)。 + * + * 不 await —— 更新提示与翻译功能互不依赖,不该让它拖慢内容脚本启动。 + */ + async function maybeShowChangelog(ns: Settings): Promise { + const decision = decideShow({ + version: chrome.runtime.getManifest().version, + isDev: import.meta.env.DEV, + isMainFrame, + siteBlocked: isSiteBlocked(location.hostname, ns.siteList), + }); + if (!decision.show) return; + + let granted = false; + try { + const res: unknown = await chrome.runtime.sendMessage({ + type: 'pt:changelog-claim', + version: decision.entry.version, + }); + granted = (res as { granted?: boolean } | null)?.granted === true; + } catch { + // SW 未就绪或扩展上下文失效 —— 这次不弹,下次页面加载再说 + return; + } + if (!granted) return; + + // 动态 import 的形式与代价见 ADR-0001(MV3 下并不会真的分包) + try { + const { showChangelog } = await import('~/src/changelog/modal'); + showChangelog(decision.entry); + } catch (e) { + // 显示权是在 background 发放时就标记已读的(并发仲裁的需要)。 + // 走到这里意味着标记了却没显示成功,不回滚的话这个上架版本 + // 会被永久跳过,用户再也看不到本次更新说明。 + await clearSeen(decision.entry.version).catch(() => {}); + throw e; + } + } + + void maybeShowChangelog(s).catch((e) => + console.error('[PT] 更新提示失败:', e), + ); + // ── 翻译全页 ── // #329: 内容脚本不再持有翻译态 —— 在飞标志、翻译态查询、还原编排、 // 状态推送、观察器启停全部在编排模块(开关入口)。这里只保留: diff --git a/entrypoints/options/index.html b/entrypoints/options/index.html index eb49b27..e425b90 100644 --- a/entrypoints/options/index.html +++ b/entrypoints/options/index.html @@ -120,7 +120,7 @@

外观

- +
diff --git a/entrypoints/options/sections/appearance.ts b/entrypoints/options/sections/appearance.ts index 8df057e..1685c4f 100644 --- a/entrypoints/options/sections/appearance.ts +++ b/entrypoints/options/sections/appearance.ts @@ -56,7 +56,7 @@ export function initAppearance(): void { // ---- Live preview ---- /** - * presets.css 用的是祖先-后代选择器(`.pt-style-fade .pt-trans`), + * presets.css 用的是祖先-后代选择器(`.pt-style-border .pt-trans`), * 所以 pt-style-* 必须加在预览**容器**上而不是 .pt-trans 自身 —— * 元素不是自己的祖先,加在自己身上永远匹配不上。 * 这与 renderer.ts 把类名加在文档根上是同一套规则。 diff --git a/entrypoints/popup/index.html b/entrypoints/popup/index.html index 99c49cc..f3054ea 100644 --- a/entrypoints/popup/index.html +++ b/entrypoints/popup/index.html @@ -71,7 +71,7 @@ - +
@@ -81,7 +81,10 @@ diff --git a/entrypoints/popup/main.ts b/entrypoints/popup/main.ts index 5602e9a..6500d8b 100644 --- a/entrypoints/popup/main.ts +++ b/entrypoints/popup/main.ts @@ -22,6 +22,10 @@ const toSelect = document.getElementById('pt-to-select') as HTMLSelectElement; const modeSelect = document.getElementById('pt-mode-select') as HTMLSelectElement; const styleSelect = document.getElementById('pt-style-select') as HTMLSelectElement; const settingsBtn = document.getElementById('pt-settings-btn')!; +const reportBtn = document.getElementById('pt-report-btn')!; + +/** 汇报问题的落点。GitHub 的新建 issue 页,带模板选择。 */ +const ISSUE_URL = 'https://github.com/Teeeeeeeerry/Parallel-Translation/issues/new'; // 头部标识与扩展图标、悬浮球同源(src/ui/logo.ts)。标记框 32px, // 走 compact 字形 —— regular 在这个尺寸下笔画会糊在一起。 @@ -189,6 +193,14 @@ async function init(): Promise { settingsBtn.addEventListener('click', () => { chrome.runtime.openOptionsPage(); }); + + // 汇报问题 —— 新标签页打开 GitHub issue。popup 会在失焦时关闭, + // 用 window.open 会连 popup 一起没掉,故走 tabs.create + reportBtn.addEventListener('click', () => { + chrome.tabs.create({ url: ISSUE_URL }).catch((e) => + console.error('[PT] 打开 issue 页失败:', e), + ); + }); } document.addEventListener('DOMContentLoaded', () => { diff --git a/package.json b/package.json index 162e4e5..bb5024b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parallel-translation", - "version": "2.0.65", + "version": "2.0.67", "description": "对照式网页翻译浏览器扩展", "private": true, "type": "module", @@ -14,10 +14,11 @@ "build": "wxt build", "build:firefox": "wxt build -b firefox", "build:edge": "wxt build -b edge", - "zip": "wxt zip", - "zip:firefox": "wxt zip -b firefox", - "zip:edge": "wxt zip -b edge", + "zip": "pnpm check:changelog && wxt zip", + "zip:firefox": "pnpm check:changelog && wxt zip -b firefox", + "zip:edge": "pnpm check:changelog && wxt zip -b edge", "typecheck": "tsc --noEmit", + "check:changelog": "npx tsx scripts/check-changelog.ts", "test": "vitest run --config docs/testing/vitest.config.ts", "test:watch": "vitest --config docs/testing/vitest.config.ts", "test:coverage": "vitest run --config docs/testing/vitest.config.ts --coverage", diff --git a/public/_locales/en/messages.json b/public/_locales/en/messages.json index 8f5ed30..675782f 100644 --- a/public/_locales/en/messages.json +++ b/public/_locales/en/messages.json @@ -3,6 +3,7 @@ "extDesc": { "message": "Bilingual web page translation extension" }, "translate": { "message": "Translate Page" }, "settings": { "message": "Settings" }, + "popupReportIssue": { "message": "Report issue" }, "navGeneral": { "message": "General" }, "navEngines": { "message": "Engines" }, @@ -81,7 +82,7 @@ "styleUnderline": { "message": "Underline" }, "styleBold": { "message": "Bold" }, "styleItalic": { "message": "Italic" }, - "styleFade": { "message": "Faded" }, + "styleBorder": { "message": "Left border" }, "previewOrigin": { "message": "The quick brown fox jumps over the lazy dog." }, "previewTrans": { "message": "敏捷的棕色狐狸跃过那只懒狗。" }, "cardCustomCss": { "message": "Custom CSS" }, @@ -182,5 +183,14 @@ "welcomeSelection": { "message": "Selection & context menu" }, "welcomeSelectionDesc": { "message": "Select text, right-click, and choose \"Translate selected text\" to translate the selection" }, "welcomeOpenSettings": { "message": "Open full settings" }, - "welcomeClose": { "message": "Get started" } + "welcomeClose": { "message": "Get started" }, + + "changelogTitle": { "message": "What's New" }, + "changelogGroupFeature": { "message": "New" }, + "changelogGroupImprove": { "message": "Improved" }, + "changelogGroupFix": { "message": "Fixed" }, + "changelogGithub": { "message": "Star on GitHub" }, + "changelogSocialDesc": { "message": "Follow along for the latest updates:" }, + "changelogOk": { "message": "Got it" }, + "changelogClose": { "message": "Close" } } diff --git a/public/_locales/zh_CN/messages.json b/public/_locales/zh_CN/messages.json index 1e04258..4aa4030 100644 --- a/public/_locales/zh_CN/messages.json +++ b/public/_locales/zh_CN/messages.json @@ -3,6 +3,7 @@ "extDesc": { "message": "对照式网页翻译扩展" }, "translate": { "message": "翻译此页" }, "settings": { "message": "设置" }, + "popupReportIssue": { "message": "汇报问题" }, "navGeneral": { "message": "通用" }, "navEngines": { "message": "引擎" }, @@ -81,7 +82,7 @@ "styleUnderline": { "message": "实线下划线" }, "styleBold": { "message": "加粗" }, "styleItalic": { "message": "斜体" }, - "styleFade": { "message": "半透明" }, + "styleBorder": { "message": "左边线" }, "previewOrigin": { "message": "The quick brown fox jumps over the lazy dog." }, "previewTrans": { "message": "敏捷的棕色狐狸跃过那只懒狗。" }, "cardCustomCss": { "message": "自定义 CSS" }, @@ -182,5 +183,14 @@ "welcomeSelection": { "message": "划词与右键翻译" }, "welcomeSelectionDesc": { "message": "选中文本后右键,点击“翻译所选文本”即可翻译选中内容" }, "welcomeOpenSettings": { "message": "打开完整设置" }, - "welcomeClose": { "message": "开始使用" } + "welcomeClose": { "message": "开始使用" }, + + "changelogTitle": { "message": "更新内容" }, + "changelogGroupFeature": { "message": "新功能" }, + "changelogGroupImprove": { "message": "改进" }, + "changelogGroupFix": { "message": "修复" }, + "changelogGithub": { "message": "在 GitHub 上 Star" }, + "changelogSocialDesc": { "message": "获取最新更新,欢迎关注:" }, + "changelogOk": { "message": "知道了" }, + "changelogClose": { "message": "关闭" } } diff --git a/public/_locales/zh_TW/messages.json b/public/_locales/zh_TW/messages.json index fd096cb..28fc27f 100644 --- a/public/_locales/zh_TW/messages.json +++ b/public/_locales/zh_TW/messages.json @@ -3,6 +3,7 @@ "extDesc": { "message": "對照式網頁翻譯擴充功能" }, "translate": { "message": "翻譯此頁" }, "settings": { "message": "設定" }, + "popupReportIssue": { "message": "回報問題" }, "navGeneral": { "message": "一般" }, "navEngines": { "message": "引擎" }, @@ -81,7 +82,7 @@ "styleUnderline": { "message": "實線底線" }, "styleBold": { "message": "粗體" }, "styleItalic": { "message": "斜體" }, - "styleFade": { "message": "半透明" }, + "styleBorder": { "message": "左邊線" }, "previewOrigin": { "message": "The quick brown fox jumps over the lazy dog." }, "previewTrans": { "message": "敏捷的棕色狐狸躍過那隻懶狗。" }, "cardCustomCss": { "message": "自訂 CSS" }, @@ -182,5 +183,14 @@ "welcomeSelection": { "message": "選取文字與右鍵翻譯" }, "welcomeSelectionDesc": { "message": "選取文字後按右鍵,點擊“翻譯所選文字”即可翻譯選取內容" }, "welcomeOpenSettings": { "message": "開啟完整設定" }, - "welcomeClose": { "message": "開始使用" } + "welcomeClose": { "message": "開始使用" }, + + "changelogTitle": { "message": "更新內容" }, + "changelogGroupFeature": { "message": "新功能" }, + "changelogGroupImprove": { "message": "改進" }, + "changelogGroupFix": { "message": "修復" }, + "changelogGithub": { "message": "在 GitHub 上 Star" }, + "changelogSocialDesc": { "message": "取得最新更新,歡迎追蹤:" }, + "changelogOk": { "message": "知道了" }, + "changelogClose": { "message": "關閉" } } diff --git a/scripts/check-changelog.ts b/scripts/check-changelog.ts new file mode 100644 index 0000000..477af37 --- /dev/null +++ b/scripts/check-changelog.ts @@ -0,0 +1,33 @@ +/** + * 上架前校验:package.json 的版本号必须在 changelog 数据里有对应条目。 + * + * ADR-0002 把「哪些版本值得弹更新提示」绑定到「你写没写更新说明」。 + * 代价是两者版本号字面不等时静默不弹,且没有任何报错 —— 等上架后才 + * 发现来不及了,所以这个校验是必需项而非可选项。 + * + * 挂在 `pnpm zip`(打包上架)而非 `pnpm build`(日常开发构建):日常构建 + * 时当前版本本就是没有条目的内部版本,无条件失败会让 build 永远红灯。 + */ + +import { readFileSync } from 'node:fs'; +import { CHANGELOG, findEntry } from '../src/changelog/data'; + +const pkgUrl = new URL('../package.json', import.meta.url); +const pkg = JSON.parse(readFileSync(pkgUrl, 'utf-8')) as { version: string }; +const { version } = pkg; + +if (!findEntry(version)) { + const known = CHANGELOG.map((e) => e.version).join(', ') || '(空)'; + console.error( + `\n[check-changelog] 上架校验失败\n\n` + + ` package.json 版本: ${version}\n` + + ` changelog 已有条目: ${known}\n\n` + + ` 这个版本没有更新说明,装到用户机器上不会弹出更新提示。\n` + + ` 上架前请在 src/changelog/data.ts 的 CHANGELOG 顶部加一条\n` + + ` version 为 "${version}" 的条目(三语文案齐备),或把 package.json\n` + + ` 的版本号改成你真正要上架的那个。\n`, + ); + process.exit(1); +} + +console.log(`[check-changelog] 通过 —— ${version} 有对应的更新说明`); diff --git a/src/changelog/claim.ts b/src/changelog/claim.ts new file mode 100644 index 0000000..882c481 --- /dev/null +++ b/src/changelog/claim.ts @@ -0,0 +1,54 @@ +// 更新提示的显示权仲裁(background 侧)。 +// +// 扩展更新后,用户打开的每个新页面都会有一个 content script 启动并来问 +// 「该我弹吗」。它们彼此不知情,各自读存储会同时读到「未读」,于是同时 +// 弹出多个弹窗。Service Worker 是单实例单线程的,把「查已读 → 标已读」 +// 这一对操作收到这里串行执行,第一个来问的拿到显示权并立即标记, +// 后来者读到的就是已读。 +// +// SW 被回收时队列随内存消失,但那之前 markSeen 已经落到 storage, +// 重建后的实例第一次查询即返回已读 —— 回收不会导致重复弹出。 + +import { hasSeen, markSeen } from './state'; + +/** + * 串行队列。只用于排队,不关心每次的结果与异常 —— + * 某次申请失败不该卡住后续申请。 + */ +let queue: Promise = Promise.resolve(); + +/** + * 首装闸门。 + * + * 首装靠 markSeen 挡住更新提示,但那是异步的:storage 还没写完时, + * content script 的 claim 会读到「未读」而拿到显示权,新用户于是在 + * 装完打开的第一个页面上看到一个「更新内容」弹窗 —— 全屏遮罩还会挡住 + * 页面。窗口很窄,但机器负载一变就会翻转,属于查起来很痛的偶发问题。 + * + * onInstalled 的监听器同步置位此标志,而 SW 是单线程的,同步部分必然 + * 跑在任何 claim 消息之前,窗口因此归零。SW 回收后标志丢失,但那时 + * markSeen 早已落盘,storage 判定接手。 + */ +let freshInstall = false; + +/** 标记本次为首次安装 —— 必须在 onInstalled 里同步调用。 */ +export function markFreshInstall(): void { + freshInstall = true; +} + +/** + * 申请显示权。返回 true 表示调用方应当弹出更新提示, + * 且该版本已被标记为已读(共识:显示出来即算已读)。 + */ +export function claimShow(version: string): Promise { + // 首装一律不发放:更新提示只服务老用户,新用户看到的是欢迎页 + if (freshInstall) return Promise.resolve(false); + + const result = queue.then(async () => { + if (await hasSeen(version)) return false; + await markSeen(version); + return true; + }); + queue = result.catch(() => {}); + return result; +} diff --git a/src/changelog/data.ts b/src/changelog/data.ts new file mode 100644 index 0000000..496be47 --- /dev/null +++ b/src/changelog/data.ts @@ -0,0 +1,218 @@ +// 更新提示的变更数据 —— ADR-0002:此文件即上架版本的唯一真相。 +// +// 这里写了条目的版本号就是「上架版本」,扩展更新到该版本后会向用户 +// 弹出更新提示;没写条目的版本是「内部版本」(每个 issue 修复 PR 都会 +// bump package.json 末位),静默升级、不打扰用户。 +// +// 新增一条的时机:准备上架前,把 package.json 版本定到上架版本号, +// 再在 CHANGELOG 顶部加一条同版本号的条目。两者字面不等时 +// `pnpm zip` 会直接失败(scripts/check-changelog.ts),不靠人工把关。 + +export type LocaleId = 'zh_CN' | 'zh_TW' | 'en'; + +/** + * 三语文案。changelog 是结构化数据而非 UI 文案,故不进 _locales —— + * 扁平 key-value 表达不了「分组 → 条目」的结构,且历史版本的 key + * 会永久淤积在 messages.json 里。 + */ +export type I18nText = Record; + +/** 变更分组。渲染顺序固定为 feature → improve → fix。 */ +export type ChangeType = 'feature' | 'improve' | 'fix'; + +export interface ChangeItem { + title: I18nText; + desc: I18nText; +} + +export interface ChangeGroup { + type: ChangeType; + items: ChangeItem[]; +} + +export interface ChangelogEntry { + /** 上架版本号,须与 manifest.version 字面相等 */ + version: string; + groups: ChangeGroup[]; +} + +/** 分组渲染顺序 —— 新功能在前,修复在后。 */ +export const GROUP_ORDER: readonly ChangeType[] = ['feature', 'improve', 'fix']; + +export const CHANGELOG: readonly ChangelogEntry[] = [ + { + version: '2.0.67', + groups: [ + { + type: 'feature', + items: [ + { + title: { + zh_CN: '对照阅读', + zh_TW: '對照閱讀', + en: 'Side-by-side reading', + }, + desc: { + zh_CN: '原文与译文并排呈现,读外文不必在两个界面之间来回切换。也可以切换成只看译文,或者只翻译某一段。', + zh_TW: '原文與譯文並排呈現,讀外文不必在兩個介面之間來回切換。也可以切換成只看譯文,或者只翻譯某一段。', + en: 'The original and the translation sit together, so you never jump between two windows. You can switch to translation-only, or translate just one paragraph.', + }, + }, + { + title: { + zh_CN: '六个触发入口', + zh_TW: '六個觸發入口', + en: 'Six ways to start', + }, + desc: { + zh_CN: '悬浮球、工具栏图标、快捷键、鼠标悬停逐段翻译、选中文字后右键、按住修饰键拖光标 —— 挑顺手的用。', + zh_TW: '懸浮球、工具列圖示、快速鍵、滑鼠停留逐段翻譯、選取文字後按右鍵、按住修飾鍵拖曳游標 —— 挑順手的用。', + en: 'Floating button, toolbar icon, keyboard shortcut, hovering a paragraph, right-clicking a selection, or dragging the cursor with a modifier key. Pick whichever suits you.', + }, + }, + { + title: { + zh_CN: '多引擎与自动切换', + zh_TW: '多引擎與自動切換', + en: 'Engines with failover', + }, + desc: { + zh_CN: 'Google 与 Bing 免 key 开箱即用;也可自带 API key 接入 OpenAI、DeepL、Gemini。引擎按你排的优先级顺序故障切换,不支持目标语言的自动跳过。', + zh_TW: 'Google 與 Bing 免金鑰開箱即用;也可自備 API 金鑰接上 OpenAI、DeepL、Gemini。引擎會依你排的優先順序容錯切換,不支援目標語言的會自動略過。', + en: 'Google and Bing work out of the box with no API key. Bring your own key for OpenAI, DeepL or Gemini. Engines fail over in the order you set, and any that lack your target language are skipped.', + }, + }, + { + title: { + zh_CN: '更新提示', + zh_TW: '更新提示', + en: 'Release notes', + }, + desc: { + zh_CN: '就是你正在看的这个。扩展更新后,下次打开网页时告诉你改了什么,看过一次就不再出现。', + zh_TW: '就是你正在看的這個。擴充功能更新後,下次開啟網頁時告訴你改了什麼,看過一次就不再出現。', + en: 'This panel. After an update, the next page you open tells you what changed. It shows once and never again.', + }, + }, + { + title: { + zh_CN: '汇报问题', + zh_TW: '回報問題', + en: 'Report an issue', + }, + desc: { + zh_CN: '工具栏面板底部新增入口,点一下直达 GitHub 提问页,不用再自己翻仓库地址。', + zh_TW: '工具列面板底部新增入口,點一下直達 GitHub 提問頁,不用再自己翻儲存庫網址。', + en: 'A new button at the bottom of the toolbar panel takes you straight to the GitHub issue form, so you no longer have to hunt down the repository.', + }, + }, + ], + }, + { + type: 'improve', + items: [ + { + title: { + zh_CN: '网页适配', + zh_TW: '網頁相容性', + en: 'Page coverage', + }, + desc: { + zh_CN: '穿透 shadow DOM 与同源 iframe;无限滚动和单页应用路由切换出的新内容会自动补翻。数字与非正文区域在采集阶段就被滤掉,不消耗翻译额度。', + zh_TW: '可穿透 shadow DOM 與同源 iframe;無限捲動和單頁應用切換路由後出現的新內容會自動補翻。數字與非內文區域在擷取階段就被濾掉,不消耗翻譯額度。', + en: 'Reaches into shadow DOM and same-origin iframes. Content from infinite scroll and SPA navigation is translated as it appears. Numbers and non-article areas are filtered out before any request, so they cost you nothing.', + }, + }, + { + title: { + zh_CN: '译文样式可调', + zh_TW: '譯文樣式可調', + en: 'Adjustable styling', + }, + desc: { + zh_CN: '六种预设样式(弱化显示、下划线、加粗、斜体、左边线等),也可以自己写 CSS。样式只作用于译文,改不动原网页。', + zh_TW: '六種預設樣式(淡化顯示、底線、粗體、斜體、左邊線等),也可以自己寫 CSS。樣式只作用於譯文,動不了原網頁。', + en: 'Six presets — dimmed, underlined, bold, italic, left-bordered — plus your own CSS if you want it. Styling touches only the translation, never the page itself.', + }, + }, + { + title: { + zh_CN: '最小权限', + zh_TW: '最小權限', + en: 'Minimal permissions', + }, + desc: { + zh_CN: '只申请存储与右键菜单两项权限,网络请求仅限你选用的翻译服务端点。不收集任何个人信息,无分析、无埋点。', + zh_TW: '只申請儲存與右鍵選單兩項權限,網路請求僅限你選用的翻譯服務端點。不蒐集任何個人資訊,無分析、無追蹤。', + en: 'Only storage and context menus are requested. Network requests go solely to the translation service you picked. No tracking, no analytics, no personal data collected.', + }, + }, + { + title: { + zh_CN: '默认译文样式', + zh_TW: '預設譯文樣式', + en: 'Default translation style', + }, + desc: { + zh_CN: '默认改为半透明,压低译文存在感、不打断原文的阅读节奏。原先那套黄铜色左边线保留为独立选项「左边线」,排在样式列表末尾,想要的话随时选回来。', + zh_TW: '預設改為半透明,壓低譯文存在感、不打斷原文的閱讀節奏。原先帶黃銅色邊線的樣式保留為獨立選項「左邊線」,排在樣式清單末尾,想要的話隨時選回來。', + en: 'The default is now simply translucent, so translations stay out of the way as you read. The old brass left-border look survives as its own option, "Left border", at the bottom of the style list.', + }, + }, + ], + }, + { + type: 'fix', + items: [ + { + title: { + zh_CN: '不再自动冒出译文', + zh_TW: '不再自動冒出譯文', + en: 'No more uninvited translations', + }, + desc: { + zh_CN: '在单页应用上,网站自己刷新内容时会误触发整页翻译,哪怕你根本没点过翻译。', + zh_TW: '在單頁應用上,網站自己更新內容時會誤觸發整頁翻譯,哪怕你根本沒點過翻譯。', + en: 'On single-page apps, the site refreshing its own content could kick off a full-page translation you never asked for.', + }, + }, + { + title: { + zh_CN: '折叠内容不再漏翻', + zh_TW: '摺疊內容不再漏翻', + en: 'Collapsed content no longer skipped', + }, + desc: { + zh_CN: 'shadow DOM 里初次不可见的内容(折叠区、展开面板)在展开后不会被翻译,现在能正常补上。', + zh_TW: 'shadow DOM 裡初次不可見的內容(摺疊區、展開面板)在展開後不會被翻譯,現在能正常補上。', + en: 'Content hidden inside shadow DOM — collapsed sections, expandable panels — stayed untranslated after you opened it. It now fills in properly.', + }, + }, + { + title: { + zh_CN: '导入配置不再残留', + zh_TW: '匯入設定不再殘留', + en: 'Clean config import', + }, + desc: { + zh_CN: '导入配置文件时,本机原有的自定义模型名会留下来。现在导入即整体替换,没写到的项回到默认值。', + zh_TW: '匯入設定檔時,本機原有的自訂模型名稱會留下來。現在匯入即整體取代,沒寫到的項目回到預設值。', + en: 'Importing a config left your old custom model names behind. Import now replaces wholesale, with anything unset falling back to its default.', + }, + }, + ], + }, + ], + }, +]; + +/** + * 查上架版本的条目。字面相等,不做 semver 范围匹配 —— + * 「2.1」不命中「2.1.0」,免得版本号少写一位时静默弹出别的版本的内容。 + */ +export function findEntry( + version: string, + entries: readonly ChangelogEntry[] = CHANGELOG, +): ChangelogEntry | undefined { + return entries.find((e) => e.version === version); +} diff --git a/src/changelog/decide.ts b/src/changelog/decide.ts new file mode 100644 index 0000000..4ae6459 --- /dev/null +++ b/src/changelog/decide.ts @@ -0,0 +1,47 @@ +// 「该不该弹更新提示」的判定 —— 纯函数,输入全部显式传入。 +// +// 把判定从 content script 的副作用里摘出来单测,是因为它有五条互相 +// 独立的拦截规则,而在真实环境里逐条复现(装扩展、改版本、拉黑站点、 +// 开 iframe)代价过高。 +// +// 返回原因而非裸 boolean,与 orchestration/orchestrator.ts 的 Admission +// 同一风格:调用方拿到 entry 可直接渲染,不必再查一次数据。 + +import { findEntry, type ChangelogEntry } from './data'; + +export interface ShowInput { + /** manifest.version —— 当前运行的版本 */ + version: string; + /** 开发构建。`pnpm dev` 每次热重载都会触发 onInstalled,必须压住 */ + isDev: boolean; + isMainFrame: boolean; + /** 站点在名单中被禁用翻译(黑名单命中 / 白名单未命中) */ + siteBlocked: boolean; + entries?: readonly ChangelogEntry[]; +} + +export type ShowDecision = + | { show: true; entry: ChangelogEntry } + | { + show: false; + reason: 'dev' | 'sub-frame' | 'site-blocked' | 'no-entry'; + }; + +/** + * 只做「不需要读存储」的本地预筛:开发模式、子框架、拉黑站点、内部版本。 + * + * 已读判定**不在这里** —— 它必须与「标记已读」成对原子执行,否则并发的 + * 多个标签页会同时判定为未读而一起弹窗。那一步在 background 的 + * claim.ts 里串行完成。本函数放行只意味着「值得去申请显示权」。 + */ +export function decideShow(input: ShowInput): ShowDecision { + if (input.isDev) return { show: false, reason: 'dev' }; + if (!input.isMainFrame) return { show: false, reason: 'sub-frame' }; + if (input.siteBlocked) return { show: false, reason: 'site-blocked' }; + + // ADR-0002: 数据里没有条目的版本就是内部版本,静默升级 + const entry = findEntry(input.version, input.entries); + if (!entry) return { show: false, reason: 'no-entry' }; + + return { show: true, entry }; +} diff --git a/src/changelog/locale.ts b/src/changelog/locale.ts new file mode 100644 index 0000000..c8aa830 --- /dev/null +++ b/src/changelog/locale.ts @@ -0,0 +1,25 @@ +// 变更条目的语言选择。 +// +// 骨架文案(「更新内容」标题、分组名、按钮)走 chrome.i18n,由浏览器按 +// manifest 的 default_locale 回退;条目文案是结构化数据(见 data.ts), +// 语言得自己挑。两条路径的回退目标必须一致 —— default_locale 是 zh_CN, +// 所以未支持的语言在这里同样落到 zh_CN,否则日语用户会看到中文标题配 +// 英文条目的混搭。 + +import type { LocaleId } from './data'; + +/** 繁体地区。zh-SG(新加坡)用简体,不在此列。 */ +const TRADITIONAL_REGIONS = new Set(['tw', 'hk', 'mo']); + +/** + * 把 BCP-47 的界面语言码映射到我们有译文的三种语言之一。 + * 大小写不敏感 —— getUILanguage() 各平台的大小写并不统一。 + */ +export function pickLocale(uiLang: string): LocaleId { + const [lang = '', region = ''] = uiLang.toLowerCase().split('-'); + if (lang === 'zh') { + return TRADITIONAL_REGIONS.has(region) ? 'zh_TW' : 'zh_CN'; + } + if (lang === 'en') return 'en'; + return 'zh_CN'; +} diff --git a/src/changelog/modal.css b/src/changelog/modal.css new file mode 100644 index 0000000..d9abb8e --- /dev/null +++ b/src/changelog/modal.css @@ -0,0 +1,185 @@ +/* 更新提示弹窗 —— 经 shadow DOM 注入宿主页面,样式自包含。 + 色值一律取 tokens.css 的设计令牌(README 全局要求:不得硬编码色值)。 + 本文件由 modal.ts 以 ?inline 引入,随动态 import 一同分包, + 不进 injected.css —— 否则每个页面的每个 shadow root 都要背上它。 */ + +.pt-changelog-overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: var(--pt-forest-40); + font-family: var(--pt-font-ui); + font-size: 14px; + line-height: 1.6; + color: var(--pt-forest); +} + +.pt-changelog-card { + display: flex; + flex-direction: column; + width: 600px; + max-width: 100%; + max-height: 85vh; + overflow: hidden; + background: var(--pt-surface); + border-radius: var(--pt-r-lg); + box-shadow: 0 12px 40px var(--pt-forest-22); + animation: pt-changelog-in .18s ease-out; +} + +@keyframes pt-changelog-in { + from { transform: scale(.96); opacity: 0 } + to { transform: scale(1); opacity: 1 } +} + +/* ===== 头部 ===== */ + +.pt-changelog-head { + display: flex; + align-items: center; + gap: 10px; + padding: 20px 24px 16px; + border-bottom: 1px solid var(--pt-forest-08); +} + +.pt-changelog-title { + margin: 0; + font-family: var(--pt-font-mono); + font-size: 17px; + font-weight: 700; + letter-spacing: -.02em; +} + +.pt-changelog-version { + padding: 1px 7px; + border: 1px solid var(--pt-brass); + border-radius: var(--pt-r-sm); + color: var(--pt-brass); + font-family: var(--pt-font-mono); + font-size: 12px; + font-weight: 700; +} + +.pt-changelog-close { + margin-left: auto; + padding: 2px 6px; + border: 0; + background: none; + color: var(--pt-forest-40); + font-size: 20px; + line-height: 1; + cursor: pointer; +} + +.pt-changelog-close:hover { color: var(--pt-forest); } + +/* ===== 正文 ===== */ + +.pt-changelog-body { + padding: 18px 24px; + overflow-y: auto; +} + +.pt-changelog-group + .pt-changelog-group { margin-top: 20px; } + +.pt-changelog-group-title { + margin: 0 0 8px; + font-family: var(--pt-font-mono); + font-size: 13px; + font-weight: 700; +} + +.pt-changelog-items { + margin: 0; + padding-left: 18px; +} + +.pt-changelog-items li + li { margin-top: 9px; } + +.pt-changelog-item-title { font-weight: 600; } + +.pt-changelog-item-desc { color: var(--pt-forest-55); } + +/* ===== 社交模块(SOCIAL_LINKS 为空时整块不渲染)===== */ + +.pt-changelog-social { + margin: 18px 24px 0; + padding: 14px 16px; + border: 1px solid var(--pt-forest-09); + border-radius: var(--pt-r-md); + background: var(--pt-paper); +} + +.pt-changelog-social-desc { + margin: 0 0 10px; + font-size: 13px; + color: var(--pt-forest-55); +} + +.pt-changelog-social-links { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.pt-changelog-social-link { + padding: 6px 12px; + border: 1px solid var(--pt-forest-09); + border-radius: var(--pt-r-sm); + background: var(--pt-surface); + color: var(--pt-forest); + font-size: 13px; + text-decoration: none; +} + +/* ===== 底部 ===== */ + +.pt-changelog-foot { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 24px 18px; + border-top: 1px solid var(--pt-forest-08); +} + +.pt-changelog-link { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 8px 14px; + border: 1px solid var(--pt-forest-15); + border-radius: var(--pt-r-sm); + color: var(--pt-forest); + font-size: 13px; + font-weight: 600; + text-decoration: none; +} + +.pt-changelog-link:hover { + border-color: var(--pt-forest-22); + background: var(--pt-paper); +} + +.pt-changelog-link svg { + width: 16px; + height: 16px; + fill: currentColor; +} + +.pt-changelog-ok { + margin-left: auto; + padding: 9px 22px; + border: 0; + border-radius: var(--pt-r-sm); + background: var(--pt-forest); + color: var(--pt-paper); + font-family: var(--pt-font-ui); + font-size: 14px; + font-weight: 600; + cursor: pointer; +} + +.pt-changelog-ok:hover { opacity: .88; } diff --git a/src/changelog/modal.ts b/src/changelog/modal.ts new file mode 100644 index 0000000..cdc0095 --- /dev/null +++ b/src/changelog/modal.ts @@ -0,0 +1,224 @@ +// 更新提示弹窗的渲染。 +// +// 由 content.ts 经动态 import() 引入 —— 该形式在 MV3 下并不会真的分包, +// 代价与理由见 ADR-0001,别误以为这段代码不占 content.js 的体积。 + +import { mountIsolated, unmountIsolated } from '~/src/ui/mount'; +import { tf } from '~/src/i18n'; +import modalCss from './modal.css?inline'; +import { pickLocale } from './locale'; +import { + GROUP_ORDER, + type ChangeGroup, + type ChangeType, + type ChangelogEntry, + type LocaleId, +} from './data'; + +const MOUNT_ID = 'changelog'; + +/** 分组标题的 i18n key 与兜底文案(漏配 key 时不至于显示空白)。 */ +const GROUP_LABEL: Record = { + feature: { key: 'changelogGroupFeature', fallback: '新功能' }, + improve: { key: 'changelogGroupImprove', fallback: '改进' }, + fix: { key: 'changelogGroupFix', fallback: '修复' }, +}; + +/** + * 标题与描述之间的分隔符。中文用全角冒号,英文用破折号 —— + * 「Side-by-side reading:The original…」这种中文标点配英文的混搭很刺眼。 + */ +const TITLE_SEP: Record = { + zh_CN: ':', + zh_TW: ':', + en: ' — ', +}; + +/** GitHub mark,16×16,fill 取 currentColor。 */ +const GITHUB_ICON = + ''; + +interface FooterLink { + url: string; + /** 内联 SVG(模块内常量,非外部输入) */ + icon: string; + i18nKey: string; + fallback: string; +} + +/** + * 底部链接。本版只放大 GitHub 求 star,赞赏与文档按钮暂不启用 —— + * 注释掉的是数据而不是渲染逻辑:渲染照常遍历此数组、照常过类型检查, + * 日后取消注释一行即可生效,仓库里不留一行会腐烂的死代码。 + */ +const FOOTER_LINKS: FooterLink[] = [ + { + url: 'https://github.com/Teeeeeeeerry/Parallel-Translation', + icon: GITHUB_ICON, + i18nKey: 'changelogGithub', + fallback: '在 GitHub 上 Star', + }, + // 日后启用(需先补 _locales 文案与图标常量): + // { url: '<赞赏页>', icon: HEART_ICON, i18nKey: 'changelogSponsor', fallback: '赞赏' }, + // { url: '<文档站>', icon: BOOK_ICON, i18nKey: 'changelogDocs', fallback: '使用文档' }, +]; + +interface SocialLink { + label: string; + url: string; +} + +/** 社交账号。留空则整块不渲染;账号建好后往这里填即可生效。 */ +const SOCIAL_LINKS: SocialLink[] = []; + +function el( + tag: K, + className?: string, + text?: string, +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (className) node.className = className; + if (text) node.textContent = text; + return node; +} + +/** 按 GROUP_ORDER 排序,并丢掉空分组。 */ +function orderedGroups(entry: ChangelogEntry): ChangeGroup[] { + return GROUP_ORDER.map((type) => + entry.groups.find((g) => g.type === type), + ).filter((g): g is ChangeGroup => !!g && g.items.length > 0); +} + +function renderGroup(group: ChangeGroup, locale: LocaleId): HTMLElement { + const section = el('section', 'pt-changelog-group'); + const label = GROUP_LABEL[group.type]; + section.appendChild( + el('h3', 'pt-changelog-group-title', tf(label.key, label.fallback)), + ); + + const list = el('ul', 'pt-changelog-items'); + for (const item of group.items) { + const li = document.createElement('li'); + li.appendChild(el('span', 'pt-changelog-item-title', item.title[locale])); + // 文案是我们自己的数据,但仍走 textContent —— content script 注入的是 + // 宿主页面,不给 innerHTML 留任何口子 + li.appendChild( + el( + 'span', + 'pt-changelog-item-desc', + `${TITLE_SEP[locale]}${item.desc[locale]}`, + ), + ); + list.appendChild(li); + } + section.appendChild(list); + return section; +} + +function renderSocial(): HTMLElement | null { + if (SOCIAL_LINKS.length === 0) return null; + const box = el('div', 'pt-changelog-social'); + box.appendChild( + el( + 'p', + 'pt-changelog-social-desc', + tf('changelogSocialDesc', '获取最新更新,欢迎关注:'), + ), + ); + const links = el('div', 'pt-changelog-social-links'); + for (const s of SOCIAL_LINKS) { + const a = el('a', 'pt-changelog-social-link', s.label); + a.href = s.url; + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + links.appendChild(a); + } + box.appendChild(links); + return box; +} + +/** + * 显示更新提示。重复调用幂等(已挂载则直接返回)—— + * content script 每帧只该有一个弹窗。 + */ +export function showChangelog(entry: ChangelogEntry): void { + if (document.getElementById(`pt-host-${MOUNT_ID}`)) return; + + const locale = pickLocale(chrome.i18n.getUILanguage()); + // 全屏遮罩,覆盖 mountIsolated 默认的右下角定位 + const shadow = mountIsolated(MOUNT_ID, { positionCss: 'inset: 0;' }); + + const style = document.createElement('style'); + style.textContent = modalCss; + shadow.appendChild(style); + + const overlay = el('div', 'pt-changelog-overlay'); + const card = el('div', 'pt-changelog-card'); + card.setAttribute('role', 'dialog'); + card.setAttribute('aria-modal', 'true'); + + // ── 头部 ── + const head = el('div', 'pt-changelog-head'); + const title = el('h2', 'pt-changelog-title', tf('changelogTitle', '更新内容')); + head.appendChild(title); + head.appendChild(el('span', 'pt-changelog-version', `v${entry.version}`)); + + const closeBtn = el('button', 'pt-changelog-close', '×'); + closeBtn.setAttribute('aria-label', tf('changelogClose', '关闭')); + head.appendChild(closeBtn); + card.appendChild(head); + + // ── 正文 ── + const body = el('div', 'pt-changelog-body'); + for (const group of orderedGroups(entry)) { + body.appendChild(renderGroup(group, locale)); + } + card.appendChild(body); + + const social = renderSocial(); + if (social) card.appendChild(social); + + // ── 底部 ── + const foot = el('div', 'pt-changelog-foot'); + for (const link of FOOTER_LINKS) { + const a = el('a', 'pt-changelog-link'); + a.href = link.url; + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + const icon = document.createElement('span'); + // 模块内的常量 SVG,不含任何外部数据 + icon.innerHTML = link.icon; + a.appendChild(icon); + a.appendChild(document.createTextNode(tf(link.i18nKey, link.fallback))); + foot.appendChild(a); + } + const okBtn = el('button', 'pt-changelog-ok', tf('changelogOk', '知道了')); + foot.appendChild(okBtn); + card.appendChild(foot); + + overlay.appendChild(card); + shadow.appendChild(overlay); + + // ── 关闭:X / 知道了 / 点遮罩 / Esc ── + function close(): void { + document.removeEventListener('keydown', onKeydown, true); + unmountIsolated(MOUNT_ID); + } + + function onKeydown(e: KeyboardEvent): void { + if (e.key === 'Escape') { + e.stopPropagation(); + close(); + } + } + + closeBtn.addEventListener('click', close); + okBtn.addEventListener('click', close); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) close(); + }); + // capture 阶段监听 —— 宿主页面可能在冒泡阶段吞掉 Esc + document.addEventListener('keydown', onKeydown, true); + + okBtn.focus(); +} diff --git a/src/changelog/state.ts b/src/changelog/state.ts new file mode 100644 index 0000000..01a1630 --- /dev/null +++ b/src/changelog/state.ts @@ -0,0 +1,60 @@ +// 更新提示的已读状态。 +// +// 独立 key `pt-changelog`,不并入 `pt-settings`:它不是设置(用户不会 +// 在 options 页里改它),而 settings-import 的配置导出会把 pt-settings +// 整个带走 —— 混进去会让别人导入你的配置时连「看过哪些更新」一起继承。 +// +// 存 sync 而非 local:跨设备重复弹同一条更新纯属骚扰。写入极少, +// 不会触碰 sync 的写入频率限制。 + +const KEY = 'pt-changelog'; + +interface ChangelogState { + /** 最后一次显示过更新提示的版本号 */ + lastSeenVersion?: string; +} + +/** 读状态。任何异常(脏数据、存储不可用)一律当未读处理。 */ +async function read(): Promise { + try { + const result = await chrome.storage.sync.get(KEY); + const raw: unknown = result[KEY]; + // 存储内容不受我们控制(旧版本残留、手工改写、同步冲突), + // 判类型而不是直接断言 —— 脏数据只该让提示不弹,不该抛错 + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + return raw as ChangelogState; + } + } catch { + // 存储不可用(配额、隐私模式)→ 当未读 + } + return {}; +} + +/** 该版本的更新提示是否已经显示过。 */ +export async function hasSeen(version: string): Promise { + const state = await read(); + return state.lastSeenVersion === version; +} + +/** + * 标记该版本已显示。按共识「显示出来即算已读」——点 X 关掉也算, + * 不需要用户点「知道了」。 + * + * 实际调用点在 claim.ts 发放显示权时(早于渲染),这样并发的多个标签页 + * 才只有一个能拿到。代价是渲染若失败就得由调用方 clearSeen 回滚。 + */ +export async function markSeen(version: string): Promise { + const next: ChangelogState = { lastSeenVersion: version }; + await chrome.storage.sync.set({ [KEY]: next }); +} + +/** + * 撤销已读标记 —— 仅用于「拿到了显示权但弹窗没能显示出来」的回滚。 + * 不撤销别的版本:期间若已被更新的版本覆盖,说明有更晚的一次显示成功了, + * 此时清掉反而会让那个版本重弹。 + */ +export async function clearSeen(version: string): Promise { + const state = await read(); + if (state.lastSeenVersion !== version) return; + await chrome.storage.sync.remove(KEY); +} diff --git a/src/storage/schema.ts b/src/storage/schema.ts index 23aab99..41b2c3f 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -17,7 +17,30 @@ export type StyleId = | 'underline' | 'bold' | 'italic' - | 'fade'; + | 'border'; + +/** 全部有效样式 id —— migrateStyle 据此判断存储里的值还认不认得。 */ +const STYLE_IDS = new Set([ + 'default', + 'dim', + 'underline', + 'bold', + 'italic', + 'border', +]); + +/** + * 把存储里的样式 id 迁到当前有效值。 + * + * 'fade'(纯半透明)的效果已成为新的 default,选过它的老用户迁过去 + * 观感不变;认不出的值一律回落 default —— 否则 applyStyle 会挂上一个 + * 没有任何 CSS 规则的类名,译文变成完全无样式,用户只会觉得设置失灵。 + */ +export function migrateStyle(style: unknown): StyleId { + if (typeof style !== 'string') return 'default'; + if (style === 'fade') return 'default'; + return STYLE_IDS.has(style) ? (style as StyleId) : 'default'; +} export type EngineId = | 'google-web' diff --git a/src/storage/settings.ts b/src/storage/settings.ts index 7bcfbf1..49f58b2 100644 --- a/src/storage/settings.ts +++ b/src/storage/settings.ts @@ -1,5 +1,5 @@ import type { DeepPartial, Settings } from './schema'; -import { DEFAULT_SETTINGS, clampConcurrency } from './schema'; +import { DEFAULT_SETTINGS, clampConcurrency, migrateStyle } from './schema'; const KEY = 'pt-settings'; @@ -88,7 +88,10 @@ function clampWriteEntry(s: Settings): Settings { * 合并存储值与默认值 —— mergeInto 的特化,base 固定为 DEFAULT_SETTINGS。 */ function merge(stored: Partial | undefined): Settings { - return mergeInto(DEFAULT_SETTINGS, stored ?? {}); + const merged = mergeInto(DEFAULT_SETTINGS, stored ?? {}); + // 样式 id 迁移放在读取单点:存储可能留着已删除的 'fade' 或任何脏值, + // 放行会让 applyStyle 挂上没有 CSS 规则的类名(译文完全无样式) + return { ...merged, style: migrateStyle(merged.style) }; } /** diff --git a/src/styles/custom.ts b/src/styles/custom.ts index ee4675b..e17e1e0 100644 --- a/src/styles/custom.ts +++ b/src/styles/custom.ts @@ -34,7 +34,7 @@ export function validateCustomCss( * 用户写 `color: #555`,实际注入 `.pt-trans { color: #555 }`。 * * #168: 选择器用 `.pt-trans.pt-trans`(特异性 0,2,0,与预设 - * `.pt-style-fade .pt-trans` 同级)—— 单类选择器(0,1,0)会被预设 + * `.pt-style-border .pt-trans` 同级)—— 单类选择器(0,1,0)会被预设 * 压住,opacity 等预设属性永远无法被自定义覆盖。同级特异性下 * 后注入者胜:扩展的预设样式随文档注入,本 style 在设置加载/变更时 * 追加到 head 末尾,时序上晚于预设,自定义总能覆盖预设。 diff --git a/src/styles/popup.css b/src/styles/popup.css index 153c216..c658dac 100644 --- a/src/styles/popup.css +++ b/src/styles/popup.css @@ -177,6 +177,11 @@ body { color: var(--pt-forest-40); } +.pt-footer-actions { + display: flex; + gap: 6px; +} + .pt-settings-btn { font-family: var(--pt-font-mono); font-size: 8px; diff --git a/src/styles/presets.css b/src/styles/presets.css index 9932acf..05bf39b 100644 --- a/src/styles/presets.css +++ b/src/styles/presets.css @@ -17,10 +17,8 @@ /* ── 6 种样式预设 ───────────────────────────────────── */ -/* 默认:黄铜金左边框做弱标识 + 半透明压低存在感(#143) */ +/* 默认:半透明压低存在感(#143),不加任何边框装饰 */ .pt-style-default .pt-trans { - border-left: 2px solid var(--pt-brass); - padding-left: 0.5em; opacity: 0.6; } @@ -49,8 +47,11 @@ font-style: italic; } -/* 半透明:始终可见,只压低存在感 */ -.pt-style-fade .pt-trans { +/* 左边线:黄铜金左边框做弱标识 + 半透明。原为默认样式, + 默认改为纯半透明后独立成一项,排在预设列表末尾 */ +.pt-style-border .pt-trans { + border-left: 2px solid var(--pt-brass); + padding-left: 0.5em; opacity: 0.6; } @@ -61,6 +62,7 @@ 折叠成一行长文本 */ white-space: pre-line; font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; + /* 覆盖「左边线」预设的边框 —— pre 内译文不带装饰 */ border-left: none; padding-left: 0; /* inline:译文紧贴原文块尾换行,不产生块级换行 + 空行框。 diff --git a/src/styles/shadow.ts b/src/styles/shadow.ts index f49b8e5..7fcc3f2 100644 --- a/src/styles/shadow.ts +++ b/src/styles/shadow.ts @@ -22,8 +22,6 @@ const HOST_CONTEXT_CSS = ` /* 6 种样式预设 */ :host-context(.pt-style-default) .pt-trans { - border-left: 2px solid var(--pt-brass); - padding-left: 0.5em; opacity: 0.6; } :host-context(.pt-style-dim) .pt-trans { @@ -43,7 +41,9 @@ const HOST_CONTEXT_CSS = ` :host-context(.pt-style-italic) .pt-trans { font-style: italic; } -:host-context(.pt-style-fade) .pt-trans { +:host-context(.pt-style-border) .pt-trans { + border-left: 2px solid var(--pt-brass); + padding-left: 0.5em; opacity: 0.6; } `; diff --git a/src/styles/tokens.css b/src/styles/tokens.css index d32f42e..7c46313 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -11,7 +11,7 @@ --pt-forest-09: rgba(31, 58, 46, .09); /* 卡片描边 */ --pt-forest-15: rgba(31, 58, 46, .15); /* 次级按钮描边 */ --pt-forest-22: rgba(31, 58, 46, .22); /* 输入框描边 */ - --pt-forest-40: rgba(31, 58, 46, .40); /* 页脚文字 */ + --pt-forest-40: rgba(31, 58, 46, .40); /* 页脚文字、模态遮罩 */ --pt-forest-55: rgba(31, 58, 46, .55); /* 次级文字 */ /* 字体 */ diff --git a/src/ui/mount.ts b/src/ui/mount.ts index ffe8216..234b9ec 100644 --- a/src/ui/mount.ts +++ b/src/ui/mount.ts @@ -16,7 +16,16 @@ const guards = new Map(); * 从 DOM 中移除,MutationObserver 会检测到并自动重新挂载。 * 这对于 Reddit 新版等会在 document_end 之后替换 body 子节点的站点至关重要。 */ -export function mountIsolated(id: string): ShadowRoot { +export interface MountOptions { + /** + * 覆盖挂载点的定位样式。收的是一段 CSS 声明串(须自带结尾分号), + * 不是单个属性值。默认右下角(悬浮球 / toast / 段落按钮), + * 更新提示的全屏遮罩传 `'inset: 0;'`。 + */ + positionCss?: string; +} + +export function mountIsolated(id: string, opts: MountOptions = {}): ShadowRoot { const host = document.createElement('div'); host.id = `pt-host-${id}`; @@ -26,7 +35,8 @@ export function mountIsolated(id: string): ShadowRoot { // 宿主页面可能有 div { position: static !important } 之类的规则, // 用 all: initial 兜底 host.style.cssText = - 'all: initial; position: fixed; z-index: 2147483647; right: 24px; bottom: 24px;'; + 'all: initial; position: fixed; z-index: 2147483647; ' + + (opts.positionCss ?? 'right: 24px; bottom: 24px;'); const shadow = host.attachShadow({ mode: 'open' }); diff --git a/store/description-en.md b/store/description-en.md index 1cddc93..84b813c 100644 --- a/store/description-en.md +++ b/store/description-en.md @@ -1,38 +1,65 @@ -# Parallel-Translation — Bilingual Web Translation Extension +Store listing copy for the Chrome Web Store (English). +The store's description box is plain text and does not render Markdown — +copy the two sections below as-is; adding ** or ## will show up literally. -**See original and translation side by side. No more toggle, no more context switching.** +================================================================ +[SUMMARY] Paste into the "Summary" field. Limit: 132 characters +================================================================ -## Why Parallel-Translation +Read the original and its translation side by side. Multiple engines, adjustable styling, no data collected. -Traditional translation extensions replace the original text with translations. To compare with the source, you have to toggle back and forth constantly. Parallel-Translation displays **both versions together** — original above, translation below — so you can read in two languages simultaneously. Ideal for language learners, researchers, and anyone who reads international content. +================================================================ +[DESCRIPTION] Paste into the "Description" field. Limit: 16000 characters +================================================================ -## Core Features +The original and its translation, side by side. No more jumping between two windows. -- **Bilingual Mode**: Original text above, translation below, perfectly aligned -- **Translation-Only Mode**: Traditional replacement view, one-click toggle -- **6 Translation Styles**: Dimmed, faded, underlined, bold, italic — instantly distinguish translation from original -- **Multi-Engine Support**: Google Translate & Bing Translate work out of the box; bring your own OpenAI, DeepL, or Gemini key for higher quality -- **Smart Failover**: Primary engine fails? Automatically switches to the next in priority order -- **Floating Ball & Paragraph Buttons**: Toggle translation, switch modes, translate current paragraph without leaving the page -- **Global Hotkeys**: Translate page, toggle mode, translate paragraph, toggle extension — all customizable -- **Selection Translation**: Right-click or drag-to-translate any selected text -- **Site Allowlist/Blocklist**: Control exactly where the extension runs -- **i18n**: UI available in Simplified Chinese, Traditional Chinese, and English -- **Zero Data Collection**: No analytics, no logging, no tracking. API keys stored locally only, never synced +Most translation extensions replace the original text, so checking it against the translation means toggling back and forth. This one keeps both languages on screen at once. Whether you are learning a language, reading a paper, or following the news, you can always see what a sentence actually said. -## Technical Highlights +How to use it -- Built on WXT framework, Manifest V3, fully written in TypeScript -- Shadow DOM isolated UI injection — zero style conflicts with host pages -- TreeWalker + shadowRoot recursive traversal for Web Components & iframe support -- Cross-site translation cache — same text translated once, reused everywhere +Six ways to start, whichever suits you: +· The floating button at the edge of the page, which translates everything in one click +· The panel behind the toolbar icon +· Keyboard shortcuts for full-page translation, display mode, single paragraph, and the master switch, all remappable +· Hover over a paragraph and a button appears, translating just that one +· Select text and right-click +· Drag the cursor while holding a modifier key -## Browser Support +How it looks -Chrome · Edge · Firefox (Manifest V3) +Side by side: the original on top, the translation below, paragraph for paragraph. +Translation only: the conventional replace-the-text approach, one click away. +Single paragraphs can use a different display mode from the full page. -## Open Source +Six styles for the translation: translucent (the default, keeping it unobtrusive), dimmed (appears on hover), underlined, bold, italic, and left-bordered. You can also write your own CSS, which applies only to the translation and cannot touch the page itself. -https://github.com/Teeeeeeeerry/Parallel-Translation +Engines + +Google and Bing work out of the box. No sign-up, no API key. +For higher quality, bring your own key for OpenAI, DeepL or Gemini. +Engines fail over in the order you set: if one fails the next takes over, and any that lack your target language are skipped rather than tried and wasted. + +Page coverage + +Reaches into Shadow DOM and same-origin iframes, so sites built on Web Components are covered. +Content arriving from infinite scroll or SPA navigation is translated as it appears. +Numbers, navigation bars and other non-article areas are filtered out before any request, so they never cost you quota. +The injected interface is isolated behind Shadow DOM in both directions, so it holds its shape even on sites with aggressive CSS resets. + +Privacy -(License pending) +No personal data is collected. No analytics, no tracking, no remote logging. +Only two permissions are requested: local storage and context menus. Network requests go solely to the translation service you picked, with nothing in between. +API keys are stored locally, are never synced to your browser account, and are never included when you export your settings. +The content script is present on every page, but reads and sends nothing until you actively ask for a translation. + +Also + +A site allowlist and blocklist, so you decide where the extension runs. +The interface speaks Simplified Chinese, Traditional Chinese and English, following your browser's language. +After an update, the next page you open tells you what changed. It shows once and never again. +Found a problem? There is a link at the bottom of the toolbar panel. + +Open source. Code and issues: +https://github.com/Teeeeeeeerry/Parallel-Translation diff --git a/store/description-zh.md b/store/description-zh.md index 7008f73..831ff31 100644 --- a/store/description-zh.md +++ b/store/description-zh.md @@ -1,38 +1,65 @@ -# Parallel-Translation — 对照式网页翻译扩展 +上架材料:Chrome Web Store 商品详情页文案(简体中文)。 +商店的说明框是纯文本框,不渲染 Markdown —— 下面两段请原样复制, +不要加 ** 或 ## 之类的标记,否则会原样显示成星号和井号。 -**一行胜千言:原文译文并排显示,再也不用来回切换。** +================================================================ +【简短说明】贴到「简介」栏,上限 132 字符 +================================================================ -## 为什么选它 +原文与译文并排显示,读外文不必在两个界面之间来回切换。多引擎可选,译文样式可调,不收集任何数据。 -传统翻译扩展把原文"替换"成译文,想对照原文只能反复开关翻译。Parallel-Translation 把原文和译文**并排展示**,让你同时看到两种语言 —— 学外语、读论文、看新闻的效率翻倍。 +================================================================ +【详细说明】贴到「详细说明」栏,上限 16000 字符 +================================================================ -## 核心功能 +原文与译文并排呈现,读外文不必在两个界面之间来回切换。 -- **对照模式**:原文在上、译文在下,一一对应 -- **仅译文模式**:传统替换式翻译,需要时一键切换 -- **6 种译文样式**:弱化、半透明、下划线、加粗、斜体……一眼区分原文与译文 -- **多引擎支持**:Google 翻译、Bing 翻译免配置即用;OpenAI、DeepL、Gemini 填入自有 key 解锁更高质量 -- **智能引擎路由**:首选引擎失败时自动切换备用引擎,翻译不中断 -- **悬浮球与段落按钮**:随时开关翻译、切换模式、翻译当前段落 -- **全局快捷键**:全页翻译、模式切换、段落翻译、扩展开关——全可自定义 -- **划词翻译**:选中文本 → 右键翻译,或拖光标直达 -- **站点黑白名单**:控制扩展在哪些网站生效 -- **i18n**:支持简体中文、繁体中文、English 三种界面语言 -- **零隐私顾虑**:不收集任何数据,不上报任何日志。API 密钥仅存本地,不参与浏览器同步 +传统翻译扩展把原文替换成译文,想核对原文只能反复开关翻译。这个扩展让两种语言同时在场 —— 学外语、读论文、看新闻的时候,你随时能对照着确认某一句到底在说什么。 -## 技术亮点 +怎么用 -- 基于 WXT 框架,Manifest V3,全 TypeScript -- Shadow DOM 隔离注入式 UI,与宿主页面零冲突 -- TreeWalker + shadowRoot 递归穿透,覆盖 Web Components 与 iframe -- 翻译缓存跨站点共享,同一段文字只翻一次 +六个入口,挑顺手的: +· 页面右侧的悬浮球,点一下翻译整页 +· 工具栏图标里的面板 +· 快捷键:全页翻译、切换显示模式、翻译当前段落、扩展总开关,全部可以改 +· 鼠标停在某一段上,出现按钮,只翻这一段 +· 选中文字后点右键 +· 按住修饰键拖动光标 -## 浏览器支持 +显示方式 -Chrome · Edge · Firefox(Manifest V3) +对照模式:原文在上,译文在下,一段一段对应。 +仅译文模式:传统的替换式翻译,一键切换回来。 +单段翻译的显示方式可以和整页分开设置,互不影响。 -## 开源 +译文样式六种:半透明(默认,压低存在感)、弱化显示(鼠标移上去才出现)、下划线、加粗、斜体、左边线。也可以自己写 CSS,只作用于译文,动不了原网页。 -https://github.com/Teeeeeeeerry/Parallel-Translation +翻译引擎 + +Google 与 Bing 免配置开箱即用,不需要注册也不需要 API key。 +想要更高的译文质量,可以填入自己的密钥接入 OpenAI、DeepL、Gemini。 +引擎按你排的优先顺序自动容错切换:某个引擎失败会换下一个,不支持目标语言的会自动跳过,而不是把所有引擎都试一遍。 + +网页兼容 + +穿透 Shadow DOM 与同源 iframe,覆盖用 Web Components 构建的现代站点。 +无限滚动、单页应用切换路由之后出现的新内容会自动补翻。 +数字、导航栏等非正文区域在采集阶段就被过滤掉,不消耗翻译额度。 +注入的界面经 Shadow DOM 双向隔离,在 CSS 写得很激进的站点上外观也不会变形。 + +隐私 -(许可证待定) +不收集任何个人信息,没有分析、没有埋点、没有远程日志。 +只申请两项权限:本地存储与右键菜单。网络请求仅发往你自己选定的翻译服务,不经过任何中转。 +API 密钥存在本地,不参与浏览器账号同步,导出配置时也不会带上。 +内容脚本虽然在所有页面注入,但在你主动触发翻译之前,不读取也不发送任何页面内容。 + +其他 + +站点黑白名单,控制扩展在哪些网站生效。 +界面支持简体中文、繁体中文、English,随浏览器语言自动切换。 +扩展更新后,下次打开网页时会告诉你这一版改了什么,看过一次就不再出现。 +遇到问题可以从工具栏面板底部直接提交反馈。 + +开源,源码与问题反馈: +https://github.com/Teeeeeeeerry/Parallel-Translation diff --git a/store/description-zh_TW.md b/store/description-zh_TW.md new file mode 100644 index 0000000..2f843e6 --- /dev/null +++ b/store/description-zh_TW.md @@ -0,0 +1,65 @@ +上架材料:Chrome 線上應用程式商店商品詳情頁文案(繁體中文)。 +商店的說明欄是純文字欄位,不會轉譯 Markdown —— 下面兩段請原樣複製, +不要加 ** 或 ## 之類的標記,否則會原樣顯示成星號和井號。 + +================================================================ +【簡短說明】貼到「摘要」欄,上限 132 字元 +================================================================ + +原文與譯文並排顯示,讀外文不必在兩個介面之間來回切換。多引擎可選,譯文樣式可調,不蒐集任何資料。 + +================================================================ +【詳細說明】貼到「詳細說明」欄,上限 16000 字元 +================================================================ + +原文與譯文並排呈現,讀外文不必在兩個介面之間來回切換。 + +傳統翻譯擴充功能把原文取代成譯文,想核對原文只能反覆開關翻譯。這個擴充功能讓兩種語言同時在場 —— 學外語、讀論文、看新聞的時候,你隨時能對照著確認某一句到底在說什麼。 + +怎麼用 + +六個入口,挑順手的: +· 頁面右側的懸浮球,點一下翻譯整頁 +· 工具列圖示裡的面板 +· 快速鍵:整頁翻譯、切換顯示模式、翻譯目前段落、擴充功能總開關,全部可以改 +· 滑鼠停在某一段上,出現按鈕,只翻這一段 +· 選取文字後按右鍵 +· 按住修飾鍵拖曳游標 + +顯示方式 + +對照模式:原文在上,譯文在下,一段一段對應。 +僅譯文模式:傳統的取代式翻譯,一鍵切換回來。 +單段翻譯的顯示方式可以和整頁分開設定,互不影響。 + +譯文樣式六種:半透明(預設,壓低存在感)、淡化顯示(滑鼠移上去才出現)、底線、粗體、斜體、左邊線。也可以自己寫 CSS,只作用於譯文,動不了原網頁。 + +翻譯引擎 + +Google 與 Bing 免設定開箱即用,不需要註冊也不需要 API 金鑰。 +想要更高的譯文品質,可以填入自己的金鑰接上 OpenAI、DeepL、Gemini。 +引擎會依你排的優先順序自動容錯切換:某個引擎失敗會換下一個,不支援目標語言的會自動略過,而不是把所有引擎都試一遍。 + +網頁相容性 + +可穿透 Shadow DOM 與同源 iframe,涵蓋以 Web Components 建構的現代網站。 +無限捲動、單頁應用程式切換路由之後出現的新內容會自動補翻。 +數字、導覽列等非內文區域在擷取階段就被濾掉,不消耗翻譯額度。 +注入的介面經 Shadow DOM 雙向隔離,在 CSS 寫得很激進的網站上外觀也不會變形。 + +隱私 + +不蒐集任何個人資訊,沒有分析、沒有追蹤、沒有遠端記錄。 +只申請兩項權限:本機儲存與右鍵選單。網路請求僅發往你自己選定的翻譯服務,不經過任何中繼。 +API 金鑰存在本機,不參與瀏覽器帳號同步,匯出設定時也不會帶上。 +內容腳本雖然在所有頁面注入,但在你主動觸發翻譯之前,不讀取也不傳送任何頁面內容。 + +其他 + +網站封鎖與允許清單,控制擴充功能在哪些網站生效。 +介面支援簡體中文、繁體中文、English,隨瀏覽器語言自動切換。 +擴充功能更新後,下次開啟網頁時會告訴你這一版改了什麼,看過一次就不再出現。 +遇到問題可以從工具列面板底部直接提交回報。 + +開源,原始碼與問題回報: +https://github.com/Teeeeeeeerry/Parallel-Translation diff --git a/store/permissions-justification.md b/store/permissions-justification.md new file mode 100644 index 0000000..d3a6945 --- /dev/null +++ b/store/permissions-justification.md @@ -0,0 +1,61 @@ +上架材料:Chrome Web Store「Privacy practices」分页的权限理由。 +审核员会拿这些文字对照 manifest 与实际行为,改动权限后必须同步更新此文件。 +全部用英文填写(审核界面与审核员语言)。 + +================================================================ +[storage] Justification +================================================================ + +All data is stored locally in the browser and none of it is transmitted anywhere. + +storage is used for four things: + +1. User settings (chrome.storage.sync) — target language, display mode (side-by-side or translation-only), translation styling, engine priority order, keyboard shortcuts, and the site allowlist/blocklist. These must survive browser restarts and follow the user across their signed-in browsers. + +2. Optional API keys (chrome.storage.local) — if a user chooses to bring their own key for OpenAI, DeepL or Gemini, it is kept here. The local area is used deliberately so that keys never leave the device through browser account sync, and they are excluded when the user exports their settings. + +3. Translation cache (chrome.storage.local) — translated text is cached for 30 days, so revisiting a page does not re-request the same sentences. This saves the user's API quota and reduces load on the translation service. + +4. One flag recording which version's release notes have already been shown, so that notice appears once and is not repeated. + +No browsing history, page content, or personal information is stored. Without this permission the extension could not remember a single setting and would reset to defaults on every page load. + +================================================================ +[contextMenus] Justification +================================================================ + +The extension adds exactly one context menu item, "Translate selected text", registered with contexts: ["selection"] so that it appears only when the user has selected text on a page. + +Choosing it sends the selected text to the translation engine the user has configured and displays the result in place. This is one of the extension's documented ways to start a translation, alongside the toolbar panel, the floating button, keyboard shortcuts, and hovering a paragraph. + +The extension creates no other menu items and neither reads nor modifies any other part of the context menu. + +================================================================ +[Host permissions] Justification —— 后续大概率也会被要求填写 +================================================================ + +Every host permission is the API endpoint of one translation service the user can select: + +- translate.googleapis.com — Google translation, the default engine, no API key required +- api-edge.cognitive.microsofttranslator.com and edge.microsoft.com — Bing/Edge translation, no API key required +- api.openai.com — OpenAI, used only when the user supplies their own API key +- generativelanguage.googleapis.com — Google Gemini, used only with the user's own key +- api.deepl.com and api-free.deepl.com — DeepL, used only with the user's own key + +The extension sends only the text the user asked to have translated, and only to the engine currently selected. These endpoints are declared explicitly rather than requesting broad host access, and no other network destination is ever contacted. + +================================================================ +[Single purpose] 描述 —— 后续大概率也会被要求填写 +================================================================ + +This extension translates web page text and shows the translation alongside the original, so that a foreign-language page can be read without switching between two windows. Every feature — the toolbar panel, the floating button, keyboard shortcuts, paragraph hover, and right-click translation — exists to trigger or configure that single task. + +================================================================ +其余分页的填法(依据代码事实,非文案) +================================================================ + +- Remote code: 选「No, I am not using remote code」。扩展不加载任何远程脚本, + 全部代码打包在扩展内。 +- Data usage: 所有类别都不勾选。扩展不收集、不传输、不出售任何用户数据; + 待翻译文本直接发往用户自选的翻译服务,不经过任何自有服务器。 + 三项声明(不出售、不用于无关用途、不用于信用评估)均可勾选确认。