Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 75 additions & 3 deletions .wxt/types/i18n.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@ declare module "wxt/browser" {
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
/**
* "汇报问题"
*/
getMessage(
messageName: "popupReportIssue",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
/**
* "通用"
*/
Expand Down Expand Up @@ -601,10 +609,10 @@ declare module "wxt/browser" {
options?: GetMessageOptions,
): string;
/**
* "半透明"
* "左边线"
*/
getMessage(
messageName: "styleFade",
messageName: "styleBorder",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 最末位版本号
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/) | 上架材料 |

## 约定
Expand Down
15 changes: 15 additions & 0 deletions docs/adr/0001-changelog-in-page-modal.md
Original file line number Diff line number Diff line change
@@ -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` 注入独立脚本。
- 站点名单中被禁用翻译的站点不弹:用户拉黑一个站点的预期是「这个扩展在这里别出现」,而不只是别翻译。
14 changes: 14 additions & 0 deletions docs/adr/0002-changelog-data-as-release-truth.md
Original file line number Diff line number Diff line change
@@ -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` 每次热重载都会弹。
- 历史条目会长期累积在数据文件中,需要定期归档。
68 changes: 68 additions & 0 deletions docs/testing/unit/changelog/claim.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
46 changes: 46 additions & 0 deletions docs/testing/unit/changelog/data.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
56 changes: 56 additions & 0 deletions docs/testing/unit/changelog/decide.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
Loading
Loading