├── .husky └── pre-commit ├── .prettierignore ├── src ├── preference │ ├── schema │ │ ├── v2 │ │ │ ├── index.ts │ │ │ ├── meta.ts │ │ │ ├── menu.ts │ │ │ ├── word.ts │ │ │ ├── schema.ts │ │ │ ├── general.ts │ │ │ └── filter.ts │ │ ├── v1 │ │ │ ├── index.ts │ │ │ ├── chrome.ts │ │ │ └── firefox.ts │ │ ├── validator.ts │ │ └── controllers.ts │ ├── types │ │ ├── v2 │ │ │ ├── meta.ts │ │ │ ├── index.ts │ │ │ ├── pref.ts │ │ │ ├── menu.ts │ │ │ ├── filter.ts │ │ │ ├── word.ts │ │ │ └── general.ts │ │ ├── v1 │ │ │ ├── index.ts │ │ │ ├── chrome.ts │ │ │ └── firefox.ts │ │ ├── all.ts │ │ ├── lastest.ts │ │ └── types.ts │ ├── upgrade │ │ ├── index.ts │ │ ├── validate.ts │ │ ├── upgrade-pref.ts │ │ ├── pref-gc-v1-to-v2.ts │ │ └── pref-fx-v1-to-v2.ts │ ├── default.ts │ └── filter-rule │ │ └── index.ts ├── content │ ├── convert │ │ ├── index.ts │ │ ├── update-nodes.ts │ │ ├── update-lang-attr.ts │ │ └── convert-nodes.ts │ ├── mutation-observer │ │ ├── index.ts │ │ ├── parse-mutation.ts │ │ ├── exhaust-mutations.ts │ │ └── mount-mutation-observer.ts │ ├── services │ │ ├── index.ts │ │ ├── get-target.ts │ │ └── get-detect-lang.ts │ ├── runtime │ │ ├── handle-textarea.ts │ │ └── mount-runtime-listener.ts │ ├── main.ts │ └── state.ts ├── background │ ├── runtime │ │ ├── index.ts │ │ ├── handle-get-filter-target.ts │ │ ├── handle-get-target.ts │ │ ├── handle-get-auto-convert.ts │ │ └── mount-runtime-listener.ts │ ├── session │ │ ├── type.ts │ │ └── index.ts │ ├── logger │ │ └── index.ts │ ├── state │ │ ├── mount-pref-listener.ts │ │ └── storage.ts │ ├── main.ts │ ├── clipboard │ │ └── index.ts │ ├── converter │ │ └── index.ts │ ├── browser-action.ts │ ├── commands │ │ └── index.ts │ └── menu │ │ ├── listen.ts │ │ └── browser-action.ts ├── utilities │ ├── index.ts │ ├── url.ts │ └── get-random-id.ts ├── service │ ├── browser │ │ └── index.ts │ ├── commands │ │ └── type.ts │ ├── types │ │ └── index.ts │ ├── i18n │ │ └── i18n.ts │ ├── tabs │ │ ├── tabs.constant.ts │ │ └── detect-language.ts │ ├── menu │ │ ├── menus.ts │ │ ├── determine-context.ts │ │ └── create-menu.ts │ ├── notification │ │ └── create-noti.ts │ ├── browser-action │ │ └── set-badge.ts │ ├── runtime │ │ ├── content.ts │ │ ├── action.ts │ │ ├── background.ts │ │ └── interface.ts │ └── storage │ │ ├── export-pref.ts │ │ ├── import-pref.ts │ │ ├── reset-pref.ts │ │ ├── local.ts │ │ └── storage.ts ├── options │ ├── components │ │ ├── forms │ │ │ ├── index.ts │ │ │ ├── Checkbox.tsx │ │ │ └── Select.tsx │ │ ├── layout │ │ │ ├── index.ts │ │ │ ├── Divider.tsx │ │ │ ├── Page.tsx │ │ │ ├── Header.tsx │ │ │ └── Navbar.tsx │ │ ├── index.ts │ │ ├── card │ │ │ └── index.tsx │ │ ├── button │ │ │ └── index.tsx │ │ └── modal │ │ │ └── index.tsx │ ├── pages │ │ ├── types │ │ │ └── index.ts │ │ ├── menu │ │ │ ├── MenuPage.tsx │ │ │ └── MenuSettings.tsx │ │ ├── word │ │ │ ├── WordPage.tsx │ │ │ ├── WordEntryEditor.tsx │ │ │ ├── WordEntryList.tsx │ │ │ ├── WordDefaultSettings.tsx │ │ │ └── WordSettings.tsx │ │ ├── filter │ │ │ ├── FilterPage.tsx │ │ │ ├── FilterRules.tsx │ │ │ ├── FilterRuleRow.tsx │ │ │ ├── FilterRuleEditor.tsx │ │ │ └── FilterSettings.tsx │ │ ├── general │ │ │ ├── GeneralPage.tsx │ │ │ ├── GeneralSettings.tsx │ │ │ └── Preferences.tsx │ │ └── about │ │ │ └── AboutPage.tsx │ ├── shared │ │ ├── css.ts │ │ └── entries-to-option.tsx │ ├── hooks │ │ ├── state │ │ │ └── use-toggle.ts │ │ ├── filter │ │ │ ├── options.tsx │ │ │ └── index.ts │ │ ├── word │ │ │ └── use-word.ts │ │ ├── general │ │ │ ├── options.tsx │ │ │ └── use-general-opt.ts │ │ ├── page │ │ │ └── index.tsx │ │ └── menu │ │ │ └── index.ts │ ├── index.tsx │ └── index.html ├── icons │ ├── tongwen-icon-16.png │ ├── tongwen-icon-32.png │ ├── tongwen-icon-48.png │ └── tongwen-icon-128.png ├── webextension-polyfill.d.ts └── _locales │ ├── zh_TW │ └── messages.json │ └── en │ └── messages.json ├── .vscode └── settings.json ├── .prettierrc ├── web-ext-config.mjs ├── web-ext-config-chrome.mjs ├── .env.example ├── web-ext-config-firefox.mjs ├── docs ├── build │ └── readme.md ├── permission │ ├── permission_zh-tw.md │ └── permission.md └── preferences │ ├── preferences_zh-tw.md │ └── preferences.md ├── .github └── workflows │ ├── stale.yml │ └── release.yml ├── tsconfig.json ├── LICENSE ├── eslint.config.mjs ├── README.md ├── package.json ├── manifest.js ├── rspack.config.js ├── CHANGELOG.md └── .gitignore /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run test:tsc 2 | npx nano-staged 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .github 2 | .husky 3 | dist 4 | node_modules -------------------------------------------------------------------------------- /src/preference/schema/v2/index.ts: -------------------------------------------------------------------------------- 1 | export * from './schema'; 2 | -------------------------------------------------------------------------------- /src/content/convert/index.ts: -------------------------------------------------------------------------------- 1 | export * from './convert-nodes'; 2 | -------------------------------------------------------------------------------- /src/background/runtime/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mount-runtime-listener'; 2 | -------------------------------------------------------------------------------- /src/content/mutation-observer/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mount-mutation-observer'; 2 | -------------------------------------------------------------------------------- /src/utilities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-random-id'; 2 | export * from './url'; 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules\\typescript\\lib" 3 | } 4 | -------------------------------------------------------------------------------- /src/preference/types/v2/meta.ts: -------------------------------------------------------------------------------- 1 | export interface PrefMeta { 2 | update: number; 3 | } 4 | -------------------------------------------------------------------------------- /src/service/browser/index.ts: -------------------------------------------------------------------------------- 1 | export { default as browser } from 'webextension-polyfill'; 2 | -------------------------------------------------------------------------------- /src/utilities/url.ts: -------------------------------------------------------------------------------- 1 | export const getHostName = (url: string) => new URL(url).hostname; 2 | -------------------------------------------------------------------------------- /src/preference/schema/v1/index.ts: -------------------------------------------------------------------------------- 1 | export * from './chrome'; 2 | export * from './firefox'; 3 | -------------------------------------------------------------------------------- /src/preference/types/v1/index.ts: -------------------------------------------------------------------------------- 1 | export type * from './chrome'; 2 | export * from './firefox'; 3 | -------------------------------------------------------------------------------- /src/content/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-detect-lang'; 2 | export * from './get-target'; 3 | -------------------------------------------------------------------------------- /src/options/components/forms/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Checkbox'; 2 | export * from './Select'; 3 | -------------------------------------------------------------------------------- /src/preference/upgrade/index.ts: -------------------------------------------------------------------------------- 1 | export * from './upgrade-pref'; 2 | export * from './validate'; 3 | -------------------------------------------------------------------------------- /src/options/pages/types/index.ts: -------------------------------------------------------------------------------- 1 | export type EventCallback = (...params: T) => void; 2 | -------------------------------------------------------------------------------- /src/utilities/get-random-id.ts: -------------------------------------------------------------------------------- 1 | export const getRandomId = (): string => Math.random().toString(16).slice(2); 2 | -------------------------------------------------------------------------------- /src/icons/tongwen-icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tongwentang/tongwentang-extension/HEAD/src/icons/tongwen-icon-16.png -------------------------------------------------------------------------------- /src/icons/tongwen-icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tongwentang/tongwentang-extension/HEAD/src/icons/tongwen-icon-32.png -------------------------------------------------------------------------------- /src/icons/tongwen-icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tongwentang/tongwentang-extension/HEAD/src/icons/tongwen-icon-48.png -------------------------------------------------------------------------------- /src/icons/tongwen-icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tongwentang/tongwentang-extension/HEAD/src/icons/tongwen-icon-128.png -------------------------------------------------------------------------------- /src/service/commands/type.ts: -------------------------------------------------------------------------------- 1 | export enum CommandType { 2 | wS2t = 'w_s2t', 3 | wT2s = 'w_t2s', 4 | cS2t = 'c_s2t', 5 | cT2s = 'c_t2s', 6 | } 7 | -------------------------------------------------------------------------------- /src/options/components/layout/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Divider'; 2 | export * from './Header'; 3 | export * from './Navbar'; 4 | export * from './Page'; 5 | -------------------------------------------------------------------------------- /src/options/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './button'; 2 | export * from './card'; 3 | export * from './forms'; 4 | export * from './layout'; 5 | export * from './modal'; 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "tabWidth": 2, 6 | "endOfLine": "lf", 7 | "arrowParens": "avoid" 8 | } 9 | -------------------------------------------------------------------------------- /src/preference/types/all.ts: -------------------------------------------------------------------------------- 1 | import type { PrefFxV1, PrefGcV1 } from './v1'; 2 | import type { PrefV2 } from './v2'; 3 | 4 | export type AllPref = PrefFxV1 | PrefGcV1 | PrefV2; 5 | -------------------------------------------------------------------------------- /src/preference/types/lastest.ts: -------------------------------------------------------------------------------- 1 | import type { PrefV2 } from './v2'; 2 | 3 | export type Pref = PrefV2; 4 | export type PrefKeys = keyof Pref; 5 | export type PrefPick = Pick; 6 | -------------------------------------------------------------------------------- /src/service/types/index.ts: -------------------------------------------------------------------------------- 1 | export enum BrowserType { 2 | FX = 'FX', 3 | GC = 'GC', 4 | } 5 | 6 | export const BROWSER_TYPE = navigator.userAgent.includes('Firefox') ? BrowserType.FX : BrowserType.GC; 7 | -------------------------------------------------------------------------------- /src/background/session/type.ts: -------------------------------------------------------------------------------- 1 | import type { MenuId } from '../../service/menu/create-menu'; 2 | 3 | export interface SessionState { 4 | menuId?: MenuId; 5 | hasBrowserActionMenu?: Promise; 6 | } 7 | -------------------------------------------------------------------------------- /src/options/shared/css.ts: -------------------------------------------------------------------------------- 1 | export const classEntries = (cn: Record): string => 2 | Object.entries(cn) 3 | .filter(([_, value]) => value) 4 | .map(([key]) => key) 5 | .join(' '); 6 | -------------------------------------------------------------------------------- /src/preference/schema/validator.ts: -------------------------------------------------------------------------------- 1 | import type { ZodFirstPartySchemaTypes } from 'zod'; 2 | 3 | export const vldFn = (schema: ZodFirstPartySchemaTypes) => (data: unknown) => schema.safeParse(data).success; 4 | -------------------------------------------------------------------------------- /src/preference/default.ts: -------------------------------------------------------------------------------- 1 | import { v2Schema } from './schema/v2'; 2 | import type { Pref } from './types/lastest'; 3 | 4 | // default TongWen preferences 5 | export const getDefaultPref = (): Pref => v2Schema({}).value(); 6 | -------------------------------------------------------------------------------- /src/service/i18n/i18n.ts: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-namespace 4 | export namespace i18n { 5 | export const { getMessage } = browser.i18n; 6 | } 7 | -------------------------------------------------------------------------------- /src/options/shared/entries-to-option.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const entriesToOption = ([value, label]: [string, string]) => ( 4 | 7 | ); 8 | -------------------------------------------------------------------------------- /src/preference/types/v2/index.ts: -------------------------------------------------------------------------------- 1 | export type * from './filter'; 2 | export type * from './general'; 3 | export type * from './menu'; 4 | export type * from './meta'; 5 | export type * from './pref'; 6 | export type * from './word'; 7 | -------------------------------------------------------------------------------- /src/background/logger/index.ts: -------------------------------------------------------------------------------- 1 | import { bgGetPref } from '../state/storage'; 2 | 3 | export const bgLog = async (...args: unknown[]) => { 4 | return bgGetPref().then(pref => void (pref.general.debugMode && console.debug(...args))); 5 | }; 6 | -------------------------------------------------------------------------------- /src/service/tabs/tabs.constant.ts: -------------------------------------------------------------------------------- 1 | export enum ZhType { 2 | hans = 'zh-hans', 3 | hant = 'zh-hant', 4 | und = 'und', 5 | } 6 | 7 | export const chtTypes = ['zh-hant', 'zh-tw', 'zh-hk'] as const; 8 | export const chsTypes = ['zh', 'zh-cn', 'zh-hans', 'zh-sg'] as const; 9 | -------------------------------------------------------------------------------- /web-ext-config.mjs: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | const { error, parsed: env } = dotenv.config(); 3 | 4 | if (error) { 5 | throw error; 6 | } 7 | 8 | export const config = { 9 | verbose: env.WEBEXT_VERBOSE === 'true', 10 | }; 11 | 12 | export { env }; 13 | -------------------------------------------------------------------------------- /src/preference/types/v2/pref.ts: -------------------------------------------------------------------------------- 1 | import type { PrefFilter, PrefGeneral, PrefMenu, PrefMeta, PrefWord } from '.'; 2 | 3 | export interface PrefV2 { 4 | version: 2; 5 | meta: PrefMeta; 6 | general: PrefGeneral; 7 | menu: PrefMenu; 8 | filter: PrefFilter; 9 | word: PrefWord; 10 | } 11 | -------------------------------------------------------------------------------- /src/options/components/layout/Divider.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | 3 | export const Divider: FC<{ content?: string }> = ({ content }) => { 4 | return ( 5 |
6 | ); 7 | }; 8 | -------------------------------------------------------------------------------- /web-ext-config-chrome.mjs: -------------------------------------------------------------------------------- 1 | import { config, env } from './web-ext-config.mjs'; 2 | 3 | export default { 4 | ...config, 5 | sourceDir: './dist/chromium', 6 | run: { 7 | target: ['chromium'], 8 | chromiumBinary: env.CHROMIUM_BINARY || undefined, 9 | startUrl: ['chrome://extensions'], 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /src/options/components/layout/Page.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, ReactNode } from 'react'; 2 | 3 | export const Page: FC<{ title: string; children: ReactNode }> = ({ title, children }) => { 4 | return ( 5 |
6 |

{title}

7 | {children} 8 |
9 | ); 10 | }; 11 | -------------------------------------------------------------------------------- /src/preference/types/types.ts: -------------------------------------------------------------------------------- 1 | import type { LangType } from 'tongwen-core/dictionaries'; 2 | 3 | export type Disabled = 'disabled'; 4 | 5 | export type Auto = 'auto'; 6 | 7 | export type TransTarget = LangType; 8 | 9 | export type MaybeTransTarget = TransTarget | undefined; 10 | 11 | export type DetTransTarget = 'ds2t' | 'dt2s'; 12 | -------------------------------------------------------------------------------- /src/service/menu/menus.ts: -------------------------------------------------------------------------------- 1 | import type browser from 'webextension-polyfill'; 2 | 3 | export const ContextOnAll: browser.Menus.ContextType[] = [ 4 | 'page', 5 | 'frame', 6 | 'selection', 7 | 'link', 8 | 'image', 9 | 'video', 10 | 'audio', 11 | ]; 12 | export const ContextOnEditable: browser.Menus.ContextType[] = ['editable']; 13 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # firefoxVersion: firefox | beta | nightly | firefoxdeveloperedition | path 2 | FIREFOX= 3 | 4 | # chromePath: path 5 | CHROMIUM_BINARY= 6 | 7 | # webExtVerbose?: true | any 8 | WEBEXT_VERBOSE=false 9 | 10 | # firefoxApiKey?: string (web-ext sign only) 11 | API_KEY= 12 | 13 | # firefoxApiSecret?: string (web-ext sign only) 14 | API_SECRET= 15 | -------------------------------------------------------------------------------- /src/content/services/get-target.ts: -------------------------------------------------------------------------------- 1 | import type { MaybeTransTarget } from '../../preference/types/types'; 2 | import { dispatchBgAction } from '../../service/runtime/background'; 3 | 4 | type GetTarget = () => Promise; 5 | export const getTarget: GetTarget = async () => { 6 | return dispatchBgAction({ type: 'GetTarget', payload: undefined }); 7 | }; 8 | -------------------------------------------------------------------------------- /src/preference/schema/v2/meta.ts: -------------------------------------------------------------------------------- 1 | import type { Control} from 'data-fixer'; 2 | import { dctrl, vctrl } from 'data-fixer'; 3 | import { z } from 'zod'; 4 | import type { PrefMeta } from '../../types/v2'; 5 | import { vldFn } from '../validator'; 6 | 7 | export const metaSchema: Control = dctrl({ 8 | update: vctrl(vldFn(z.number().int()), Date.now()), 9 | }); 10 | -------------------------------------------------------------------------------- /src/content/services/get-detect-lang.ts: -------------------------------------------------------------------------------- 1 | import { dispatchBgAction } from '../../service/runtime/background'; 2 | import type { ZhType } from '../../service/tabs/tabs.constant'; 3 | 4 | type GetDetectLanguage = () => Promise; 5 | export const getDetectLanguage: GetDetectLanguage = async () => { 6 | return dispatchBgAction({ type: 'DetectLang', payload: undefined }); 7 | }; 8 | -------------------------------------------------------------------------------- /src/preference/types/v2/menu.ts: -------------------------------------------------------------------------------- 1 | import type { TransTarget } from '../types'; 2 | 3 | export type PrefMenuOptions = Record; 4 | 5 | export type PrefMenuGroupKeys = 'textarea' | 'webpage'; 6 | 7 | export type PrefMenuGroup = Record; 8 | 9 | export interface PrefMenu { 10 | enabled: boolean; 11 | group: PrefMenuGroup; 12 | } 13 | -------------------------------------------------------------------------------- /src/options/pages/menu/MenuPage.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | import { i18n } from '../../../service/i18n/i18n'; 3 | import { Page } from '../../components'; 4 | import { MenuSettings } from './MenuSettings'; 5 | 6 | export const MenuPage: FC = () => { 7 | return ( 8 | 9 | 10 | 11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /src/options/pages/word/WordPage.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | import { i18n } from '../../../service/i18n/i18n'; 3 | import { Page } from '../../components'; 4 | import { WordSettings } from './WordSettings'; 5 | 6 | export const WordPage: FC = () => { 7 | return ( 8 | 9 | 10 | 11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /src/background/session/index.ts: -------------------------------------------------------------------------------- 1 | import { browser } from '../../service/browser'; 2 | import type { SessionState } from './type'; 3 | 4 | export const getSessionState = async (): Promise => { 5 | return browser.storage.session.get(); 6 | }; 7 | 8 | export const setSessionState = async (state: Partial) => { 9 | return browser.storage.session.set(state); 10 | }; 11 | -------------------------------------------------------------------------------- /web-ext-config-firefox.mjs: -------------------------------------------------------------------------------- 1 | import { config, env } from './web-ext-config.mjs'; 2 | 3 | export default { 4 | ...config, 5 | sourceDir: './dist/firefox', 6 | run: { 7 | firefox: env.FIREFOX || 'firefox', 8 | target: ['firefox-desktop'], 9 | startUrl: ['about:debugging'], 10 | }, 11 | sign: { 12 | apiKey: env.API_KEY, 13 | apiSecret: env.API_SECRET, 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /src/options/pages/filter/FilterPage.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | import { i18n } from '../../../service/i18n/i18n'; 3 | import { Page } from '../../components'; 4 | import { FilterSettings } from './FilterSettings'; 5 | 6 | export const FilterPage: FC = () => { 7 | return ( 8 | 9 | 10 | 11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /src/background/state/mount-pref-listener.ts: -------------------------------------------------------------------------------- 1 | import { listenStorage } from '../../service/storage/storage'; 2 | import { bgLog } from '../logger'; 3 | import { bgHandlePrefUpdate } from './storage'; 4 | 5 | export function mountPrefListener() { 6 | listenStorage( 7 | changes => { 8 | bgLog('[BG_RECEIVE_SYNC_PREF_CHANGE]: ', changes); 9 | bgHandlePrefUpdate(changes); 10 | }, 11 | { areaName: ['local'] }, 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/preference/types/v2/filter.ts: -------------------------------------------------------------------------------- 1 | import type { Disabled, TransTarget } from '../types'; 2 | 3 | export type FilterTarget = Disabled | TransTarget; 4 | 5 | export type RegExpMaybe = RegExp | null; 6 | 7 | export interface PrefFilterRule { 8 | id: string; 9 | pattern: string; 10 | target: FilterTarget; 11 | regexp: RegExpMaybe; 12 | } 13 | 14 | export interface PrefFilter { 15 | enabled: boolean; 16 | rules: PrefFilterRule[]; 17 | } 18 | -------------------------------------------------------------------------------- /docs/build/readme.md: -------------------------------------------------------------------------------- 1 | # Build Instruction 2 | 3 | ## Repository 4 | 5 | [tongwentang-extension](https://github.com/tongwentang/tongwentang-extension) 6 | 7 | ## Environment 8 | 9 | - OS: [name] [build/version] 10 | - NodeJS: [version] 11 | - NPM: [name] [version] 12 | 13 | ## Build Step 14 | 15 | - Run `yarn install` to install packages. 16 | - Run `yarn build:firefox` to build project. 17 | - If no error, the build result is locate in `/dist` under project root. 18 | -------------------------------------------------------------------------------- /src/content/mutation-observer/parse-mutation.ts: -------------------------------------------------------------------------------- 1 | type ParseMutation = (m: MutationRecord) => Node[]; 2 | export const parseMutation: ParseMutation = m => { 3 | switch (true) { 4 | case m.type === 'characterData': 5 | return [m.target]; 6 | case m.type === 'childList' && m.addedNodes.length > 0: 7 | return Array.from(m.addedNodes); 8 | case m.type === 'attributes': 9 | return [m.target]; 10 | default: 11 | return []; 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /docs/permission/permission_zh-tw.md: -------------------------------------------------------------------------------- 1 | # 新同文堂套件需要的授權項目 2 | 3 | 套件需要授權以便更好的運作。 4 | 5 | ### 必要的授權 6 | 7 | - `contextMenus` 8 | - 套件圖示右鍵選單。 9 | - 網頁右鍵選單。 10 | - `downloads` 11 | - 透過下載匯出偏好設定。 12 | - `notifications` 13 | - 發送錯誤通知。 14 | - 發送其他通知諸如完成轉換。 15 | - `storage` 16 | - 用於儲存包含自訂的網址規則及轉換詞彙的偏好設定。 17 | - `unlimitedStorage` 18 | - 自訂的網址規則及轉換詞彙可能有多個。 19 | 20 | ### 選擇性的授權 21 | 22 | - `clipboardWrite` 23 | - 將轉換內容寫回剪貼簿。 24 | - `clipboardRead` 25 | - 從剪貼簿讀取欲轉換內容。 26 | -------------------------------------------------------------------------------- /src/options/components/layout/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { i18n } from '../../../service/i18n/i18n'; 3 | 4 | export const Header: React.FC = () => { 5 | return ( 6 |
7 |
8 |
9 |

{i18n.getMessage('MSG_EXT_NAME')}

10 |
11 |
12 |
13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /src/preference/schema/controllers.ts: -------------------------------------------------------------------------------- 1 | import { rctrl, vctrl } from 'data-fixer'; 2 | import { z } from 'zod'; 3 | import { vldFn } from './validator'; 4 | 5 | export const isTrue = vctrl(vldFn(z.literal(true)), true); 6 | 7 | export const isFalse = vctrl(vldFn(z.literal(false)), false); 8 | 9 | export const isBoolean = (alt: boolean) => vctrl(vldFn(z.boolean()), alt); 10 | 11 | export const isString = vctrl(vldFn(z.string()), ''); 12 | 13 | export const isDic = rctrl(isString); 14 | -------------------------------------------------------------------------------- /src/options/hooks/state/use-toggle.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | 3 | export const useToggle = (b: boolean) => { 4 | const [state, setState] = useState(b); 5 | const on = useCallback(() => { setState(true); }, []); 6 | const off = useCallback(() => { setState(false); }, []); 7 | const toggle = useCallback(() => { setState(s => !s); }, []); 8 | const set = useCallback((b: boolean) => { setState(b); }, []); 9 | return [state, { on, off, toggle, set }] as const; 10 | }; 11 | -------------------------------------------------------------------------------- /src/options/hooks/filter/options.tsx: -------------------------------------------------------------------------------- 1 | import { LangType } from 'tongwen-core/dictionaries'; 2 | import { i18n } from '../../../service/i18n/i18n'; 3 | import { entriesToOption } from '../../shared/entries-to-option'; 4 | 5 | export const FilterRuleTargetOptions = () => 6 | ( 7 | [ 8 | ['disabled', i18n.getMessage('MSG_DISABLED')], 9 | [LangType.s2t, i18n.getMessage('MSG_S2T')], 10 | [LangType.t2s, i18n.getMessage('MSG_T2S')], 11 | ] as [string, string][] 12 | ).map(entriesToOption); 13 | -------------------------------------------------------------------------------- /src/content/mutation-observer/exhaust-mutations.ts: -------------------------------------------------------------------------------- 1 | import { convertNode } from '../convert'; 2 | import { getTarget } from '../services'; 3 | import type { CtState } from '../state'; 4 | import { parseMutation } from './parse-mutation'; 5 | 6 | export type ExhaustMutations = (s: CtState, m: MutationRecord[]) => void; 7 | export const exhaustMutations: ExhaustMutations = (state, mutations) => { 8 | getTarget().then(async target => (target != null) && convertNode(state, target, mutations.flatMap(parseMutation))); 9 | }; 10 | -------------------------------------------------------------------------------- /src/preference/schema/v2/menu.ts: -------------------------------------------------------------------------------- 1 | import type { Control} from 'data-fixer'; 2 | import { dctrl } from 'data-fixer'; 3 | import type { PrefMenu } from '../../types/v2'; 4 | import { isBoolean } from '../controllers'; 5 | 6 | const menuGroupSchema = dctrl({ 7 | s2t: isBoolean(true), 8 | t2s: isBoolean(true), 9 | }); 10 | 11 | export const menuSchema: Control = dctrl({ 12 | enabled: isBoolean(true), 13 | group: dctrl({ 14 | textarea: menuGroupSchema, 15 | webpage: menuGroupSchema, 16 | }), 17 | }); 18 | -------------------------------------------------------------------------------- /src/preference/types/v2/word.ts: -------------------------------------------------------------------------------- 1 | import type { DicObj, LangType } from 'tongwen-core/dictionaries'; 2 | 3 | export interface PrefWordItem { 4 | name: string; 5 | url: string; 6 | enabled: boolean; 7 | type: LangType; 8 | map: DicObj; 9 | } 10 | 11 | export type PrefWordDefault = Record>; 12 | 13 | export type PrefWordCustom = Record>; 14 | 15 | export interface PrefWord { 16 | default: PrefWordDefault; 17 | custom: PrefWordCustom; 18 | } 19 | -------------------------------------------------------------------------------- /src/preference/types/v2/general.ts: -------------------------------------------------------------------------------- 1 | import type { LangType } from 'tongwen-core/dictionaries'; 2 | import type { Auto, DetTransTarget, Disabled, TransTarget } from '../types'; 3 | 4 | export type AutoConvertOpt = Disabled | DetTransTarget | TransTarget; 5 | 6 | export type BrowserActionOpt = Auto | TransTarget; 7 | 8 | export interface PrefGeneral { 9 | autoConvert: AutoConvertOpt; 10 | browserAction: BrowserActionOpt; 11 | defaultTarget: LangType; 12 | spaMode: boolean; 13 | updateLangAttr: boolean; 14 | debugMode: boolean; 15 | } 16 | -------------------------------------------------------------------------------- /src/content/runtime/handle-textarea.ts: -------------------------------------------------------------------------------- 1 | import type { LangType } from 'tongwen-core/dictionaries'; 2 | import { dispatchBgAction } from '../../service/runtime/background'; 3 | 4 | export const handleTextarea = async (target: LangType) => { 5 | const elm = document.activeElement; 6 | 7 | if (elm && (elm instanceof HTMLInputElement || elm instanceof HTMLTextAreaElement)) { 8 | return dispatchBgAction({ type: 'Convert', payload: { target, text: elm.value } }).then( 9 | converted => void (elm.value = converted), 10 | ); 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/options/pages/general/GeneralPage.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | import { i18n } from '../../../service/i18n/i18n'; 3 | import { Divider, Page } from '../../components'; 4 | import { GeneralSettings } from './GeneralSettings'; 5 | import { Preferences } from './Preferences'; 6 | 7 | export const GeneralPage: FC = () => { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/options/index.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import { Header, Navbar } from './components'; 4 | import { usePage } from './hooks/page'; 5 | 6 | function App() { 7 | const [page, setPage] = usePage(); 8 | const Page = useMemo(() => page.node, [page]); 9 | 10 | return ( 11 |
12 |
13 | 14 | 15 |
16 | ); 17 | } 18 | 19 | createRoot(document.querySelector('#app')!).render(); 20 | -------------------------------------------------------------------------------- /src/options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | TongWenTang Options 12 | 13 | 14 |
Loading...
15 | 16 | 17 | -------------------------------------------------------------------------------- /src/options/components/forms/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import type { ChangeEvent, FC } from 'react'; 2 | 3 | export const Checkbox: FC<{ 4 | isSwitch: boolean; 5 | label: string; 6 | checked: boolean; 7 | onChange: (evt: ChangeEvent) => void; 8 | }> = ({ isSwitch, label, checked: value, onChange }) => { 9 | return ( 10 |
11 | 15 |
16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/content/convert/update-nodes.ts: -------------------------------------------------------------------------------- 1 | import type { ParsedResult } from 'tongwen-core/walker'; 2 | 3 | const updateNode = (parsed: ParsedResult, text: string) => { 4 | switch (parsed.type) { 5 | case 'TEXT': 6 | parsed.node.nodeValue !== text && (parsed.node.nodeValue = text); 7 | break; 8 | case 'ELEMENT': 9 | parsed.node.getAttribute(parsed.attr) !== text && parsed.node.setAttribute(parsed.attr, text); 10 | } 11 | }; 12 | 13 | export const updateNodes = (parseds: ParsedResult[], texts: string[]): void => 14 | { parseds.forEach((parsed, index) => { updateNode(parsed, texts[index]); }); }; 15 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Close Stale issues and PRs 2 | 3 | on: 4 | schedule: 5 | - cron: 0 0 * * * 6 | 7 | jobs: 8 | stale: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/stale@v9 12 | with: 13 | days-before-stale: 30 14 | days-before-close: 3 15 | stale-issue-message: 'Inactive in 30 days. 30 天沒有動靜。' 16 | stale-pr-message: 'Inactive in 30 days. 30 天沒有動靜。' 17 | close-issue-message: 'Close inactive issue. 關閉不活躍 issue 。' 18 | close-pr-message: 'Close inactive PR. 關閉不活躍 PR 。' 19 | only-labels: question,invalid 20 | -------------------------------------------------------------------------------- /src/preference/schema/v2/word.ts: -------------------------------------------------------------------------------- 1 | import type { Control} from 'data-fixer'; 2 | import { dctrl, rctrl } from 'data-fixer'; 3 | import type { PrefWord } from '../../types/v2'; 4 | import { isBoolean, isString } from '../controllers'; 5 | 6 | export const wordSchema: Control = dctrl({ 7 | default: dctrl({ 8 | s2t: dctrl({ 9 | char: isBoolean(true), 10 | phrase: isBoolean(true), 11 | }), 12 | t2s: dctrl({ 13 | char: isBoolean(true), 14 | phrase: isBoolean(true), 15 | }), 16 | }), 17 | custom: dctrl({ 18 | s2t: rctrl(isString), 19 | t2s: rctrl(isString), 20 | }), 21 | }); 22 | -------------------------------------------------------------------------------- /src/preference/upgrade/validate.ts: -------------------------------------------------------------------------------- 1 | import { BrowserType } from '../../service/types'; 2 | import { v1SchemaFx, v1SchemaGc } from '../schema/v1'; 3 | import { v2Schema } from '../schema/v2'; 4 | 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 6 | export const validatePref = (code: BrowserType) => (pref: Record) => { 7 | switch (true) { 8 | case code === BrowserType.FX && pref.version === 1: 9 | return v1SchemaFx(pref); 10 | case code === BrowserType.GC && Number.parseInt(pref.version) === 1: 11 | return v1SchemaGc(pref); 12 | default: 13 | return v2Schema(pref); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/service/tabs/detect-language.ts: -------------------------------------------------------------------------------- 1 | import { browser } from '../browser'; 2 | import { chsTypes, chtTypes, ZhType } from './tabs.constant'; 3 | 4 | const langToZhtype = (lang: string): ZhType => 5 | chsTypes.find(tag => tag === lang) ? ZhType.hans : chtTypes.find(tag => tag === lang) ? ZhType.hant : ZhType.und; 6 | 7 | export const detectLanguage = async (tabId?: number): Promise => 8 | browser.tabs 9 | .detectLanguage?.(tabId) 10 | .then(lang => lang.toLowerCase()) 11 | .then(langToZhtype) 12 | // INFO: some browsers may fail with detect language api 13 | .catch(() => ZhType.und) || Promise.resolve(ZhType.und); 14 | -------------------------------------------------------------------------------- /src/options/components/card/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, ReactNode } from 'react'; 2 | 3 | export const Card: FC<{ children: ReactNode }> = props =>
{props.children}
; 4 | 5 | export const CardHeader: FC<{ title: string; children?: ReactNode }> = ({ title }) => ( 6 |
7 |
{title}
8 |
9 | ); 10 | 11 | export const CardBody: FC<{ children: ReactNode }> = props =>
{props.children}
; 12 | 13 | export const CardFooter: FC<{ children?: ReactNode }> = props =>
{props.children}
; 14 | -------------------------------------------------------------------------------- /src/service/notification/create-noti.ts: -------------------------------------------------------------------------------- 1 | import { getRandomId } from '../../utilities'; 2 | import { browser } from '../browser'; 3 | import { i18n } from '../i18n/i18n'; 4 | 5 | const autoDeleteNoti = (id: string, closeIn: number) => setTimeout(async () => browser.notifications.clear(id), closeIn); 6 | 7 | export const createNoti = async (message: string, closeIn = 5000, id = getRandomId()) => { 8 | autoDeleteNoti(id, closeIn); 9 | 10 | // TODO: need i18n 11 | return browser.notifications.create(id, { 12 | type: 'basic', 13 | title: i18n.getMessage('NT_TITLE'), 14 | message, 15 | iconUrl: 'icons/tongwen-icon-48.png', 16 | }); 17 | }; 18 | -------------------------------------------------------------------------------- /src/content/main.ts: -------------------------------------------------------------------------------- 1 | import { convertNode } from './convert'; 2 | import { mountMutationObserver } from './mutation-observer'; 3 | import { mountRuntimeListener } from './runtime/mount-runtime-listener'; 4 | import { getTarget } from './services'; 5 | import type { CtState } from './state'; 6 | import { createCtState } from './state'; 7 | 8 | (async function main() { 9 | const state: CtState = await createCtState(); 10 | 11 | mountRuntimeListener(state); 12 | await mountMutationObserver(state); 13 | 14 | const target = await getTarget().catch(console.error); 15 | (target != null) && convertNode(state, target, [document]).catch(console.error); 16 | })(); 17 | -------------------------------------------------------------------------------- /src/webextension-polyfill.d.ts: -------------------------------------------------------------------------------- 1 | import type { CommandType } from './service/commands/type'; 2 | 3 | declare module 'webextension-polyfill' { 4 | namespace Browser { 5 | // NOTE: mix with `undefined` is because these API may not exist on the mobile runtime 6 | const commands: 7 | | (Omit & { 8 | onCommand: Events.Event<(command: CommandType, tab: Tabs.Tab | undefined) => void>; 9 | }) 10 | | undefined; 11 | const downloads: Downloads.Static | undefined; 12 | const tabs: Omit & { detectLanguage?: Tabs.Static['detectLanguage'] }; 13 | } 14 | 15 | export = Browser; 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es2019", 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "outDir": "dist", 8 | "declaration": false, 9 | "sourceMap": true, 10 | "jsx": "react-jsx", 11 | "resolveJsonModule": true, 12 | "skipLibCheck": true, 13 | /* Strict Type-Checking Options */ 14 | "strict": true, 15 | /* Module Resolution Options */ 16 | "moduleResolution": "Bundler", 17 | "esModuleInterop": true, 18 | "forceConsistentCasingInFileNames": true 19 | }, 20 | "include": ["src"], 21 | "exclude": ["**/node_modules", "**/.*/", "src/**/*.test.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /src/options/components/forms/Select.tsx: -------------------------------------------------------------------------------- 1 | import type { ChangeEvent, FC, ReactNode } from 'react'; 2 | 3 | export const Select: FC<{ 4 | id: string; 5 | label: string; 6 | value?: string | number | string[]; 7 | children: ReactNode; 8 | onChange?: (evt: ChangeEvent) => void; 9 | }> = ({ id, label, value, onChange, children, ...props }) => { 10 | return ( 11 |
12 | 15 | 18 |
19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/preference/schema/v2/schema.ts: -------------------------------------------------------------------------------- 1 | import type { Control} from 'data-fixer'; 2 | import { dctrl, vctrl } from 'data-fixer'; 3 | import { z } from 'zod'; 4 | import type { Pref } from '../../types/lastest'; 5 | import { vldFn } from '../validator'; 6 | import { filterSchema } from './filter'; 7 | import { generalSchema } from './general'; 8 | import { menuSchema } from './menu'; 9 | import { metaSchema } from './meta'; 10 | import { wordSchema } from './word'; 11 | 12 | export const v2Schema: Control = dctrl({ 13 | version: vctrl<2>(vldFn(z.literal(2)), 2), 14 | meta: metaSchema, 15 | general: generalSchema, 16 | menu: menuSchema, 17 | filter: filterSchema, 18 | word: wordSchema, 19 | }); 20 | -------------------------------------------------------------------------------- /src/preference/types/v1/chrome.ts: -------------------------------------------------------------------------------- 1 | import type { DicObj } from 'tongwen-core/dictionaries'; 2 | 3 | export interface PrefGcV1FilterRule { 4 | url: string; 5 | zhflag: 'none' | 'trad' | 'simp'; 6 | } 7 | 8 | export interface PrefGcV1 { 9 | /** 1.x float number as string */ 10 | version: string; 11 | autoConvert: 'none' | 'trad' | 'simp'; 12 | iconAction: 'auto' | 'trad' | 'simp'; 13 | symConvert: boolean; 14 | inputConvert: 'none' | 'auto' | 'trad' | 'simp'; 15 | fontCustom: { enable: boolean; trad: string; simp: string }; 16 | urlFilter: { enable: boolean; list: PrefGcV1FilterRule[] }; 17 | userPhrase: { enable: boolean; trad: DicObj; simp: DicObj }; 18 | contextMenu: { enable: boolean }; 19 | } 20 | -------------------------------------------------------------------------------- /src/background/runtime/handle-get-filter-target.ts: -------------------------------------------------------------------------------- 1 | import { isRegExpLike } from '../../preference/filter-rule'; 2 | import type { Pref } from '../../preference/types/lastest'; 3 | import type { FilterTarget, PrefFilterRule } from '../../preference/types/v2'; 4 | 5 | const findRule = (rules: PrefFilterRule[], url: URL) => 6 | rules.find(rule => 7 | !rule.regexp ? false : isRegExpLike(rule.pattern) ? rule.regexp.test(url.href) : rule.regexp.test(url.host), 8 | ); 9 | 10 | export const getTargetByFilter = (pref: Pref, url: string): FilterTarget | undefined => { 11 | const rule = pref.filter.enabled ? findRule(pref.filter.rules, new URL(url)) : undefined; 12 | 13 | return rule ? rule.target : undefined; 14 | }; 15 | -------------------------------------------------------------------------------- /src/background/main.ts: -------------------------------------------------------------------------------- 1 | import { i18n } from '../service/i18n/i18n'; 2 | import { mountBrowserActionListener } from './browser-action'; 3 | import { mountCommandListener } from './commands'; 4 | import { createBrowserActionMenus } from './menu/browser-action'; 5 | import { listenMenusEvent } from './menu/listen'; 6 | import { mountRuntimeListener } from './runtime'; 7 | import { mountPrefListener } from './state/mount-pref-listener'; 8 | import { bgInitialPref } from './state/storage'; 9 | 10 | mountPrefListener(); 11 | mountRuntimeListener(); 12 | mountBrowserActionListener(); 13 | mountCommandListener(); 14 | listenMenusEvent(); 15 | createBrowserActionMenus(); 16 | bgInitialPref(); 17 | 18 | console.info(`${i18n.getMessage('MSG_EXT_NAME')} 👌`); 19 | -------------------------------------------------------------------------------- /src/service/browser-action/set-badge.ts: -------------------------------------------------------------------------------- 1 | import { LangType } from 'tongwen-core/dictionaries'; 2 | import type { Pref } from '../../preference/types/lastest'; 3 | import type { BrowserActionOpt } from '../../preference/types/v2'; 4 | import { browser } from '../browser'; 5 | 6 | const browserActionToBadge = (ba: BrowserActionOpt) => (ba === 'auto' ? 'A' : ba === LangType.s2t ? 'T' : 'S'); 7 | 8 | const color = '#C0C0C0'; 9 | 10 | export const setBadge = async (general: Pref['general']) => { 11 | const text = browserActionToBadge(general.browserAction); 12 | const setText = browser.action.setBadgeText({ text }); 13 | const setBg = browser.action.setBadgeBackgroundColor({ color }); 14 | 15 | return Promise.all([setText, setBg]); 16 | }; 17 | -------------------------------------------------------------------------------- /src/content/convert/update-lang-attr.ts: -------------------------------------------------------------------------------- 1 | import { LangType } from 'tongwen-core/dictionaries'; 2 | 3 | /** 4 | * Should match all possible valid Chinese language tags: 5 | * - `zh` 6 | * - `zh-TW` 7 | * - `zh-Hant` 8 | * - `zh-Hant-SG` 9 | * - etc. 10 | */ 11 | const ZH_LANG_REGEXP = /^zh(-[a-z]+)*$/i; 12 | 13 | /** 14 | * Updates the element's `lang` attribute according to the target `LangType` 15 | * only if `lang` is already a valid Chinese language tag: 16 | * - `LangType.s2t` -> `zh-Hant` 17 | * - `LangType.s2t` -> `zh-Hans` 18 | */ 19 | export function updateLangAttr(element: HTMLElement, target: LangType) { 20 | if (ZH_LANG_REGEXP.test(element.lang)) { 21 | element.lang = target === LangType.s2t ? 'zh-Hant' : 'zh-Hans'; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/options/hooks/word/use-word.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { getDefaultPref } from '../../../preference/default'; 3 | import type { PrefWord } from '../../../preference/types/v2'; 4 | import { getStorage, listenStorage } from '../../../service/storage/storage'; 5 | 6 | export const useWord = () => { 7 | const [word, setWord] = useState(getDefaultPref().word); 8 | 9 | listenStorage( 10 | ({ word }) => { 11 | setWord(word?.newValue as PrefWord); 12 | }, 13 | { keys: ['word'], areaName: ['local'] }, 14 | ); 15 | 16 | useEffect( 17 | () => 18 | void getStorage('word').then(({ word }) => { 19 | setWord(word); 20 | }), 21 | [], 22 | ); 23 | 24 | return { word, setWord }; 25 | }; 26 | -------------------------------------------------------------------------------- /docs/permission/permission.md: -------------------------------------------------------------------------------- 1 | # Permessions Required for New TongWenTang Extension 2 | 3 | The Extension require several permissions inorder to work well. 4 | 5 | ### Required Permissions 6 | 7 | - `contextMenus` 8 | - Browser action context menu. 9 | - Web page context menu. 10 | - `downloads` 11 | - Export preferences by download. 12 | - `notifications` 13 | - Notify for error. 14 | - Notify for information like convert done. 15 | - `storage` 16 | - For saving preferences including custom domain rules and mapping words. 17 | - `unlimitedStorage` 18 | - Custom domain rules and mapping words could be many. 19 | 20 | ### Optional Permissions 21 | 22 | - `clipboardWrite` 23 | - Write converted content back to clipboard. 24 | - `clipboardRead` 25 | - Read to convert content from clipboard. 26 | -------------------------------------------------------------------------------- /src/options/components/button/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, MouseEventHandler, ReactNode } from 'react'; 2 | import { classEntries } from '../../shared/css'; 3 | 4 | export const Button: FC<{ 5 | type?: 'primary' | 'link' | 'success' | 'error'; 6 | tooltip?: string; 7 | fullWidth?: boolean; 8 | disabled?: boolean; 9 | children: ReactNode; 10 | onClick?: MouseEventHandler; 11 | }> = ({ type, tooltip, fullWidth, children, onClick: handleClick, disabled }) => { 12 | const className = classEntries({ 13 | btn: true, 14 | [`btn-${type}`]: !!type, 15 | tooltip: !!tooltip, 16 | disabled: !!disabled, 17 | }); 18 | const style = fullWidth ? { width: '100%' } : {}; 19 | 20 | return ( 21 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/preference/schema/v2/general.ts: -------------------------------------------------------------------------------- 1 | import type { Control} from 'data-fixer'; 2 | import { dctrl, vctrl } from 'data-fixer'; 3 | import { LangType } from 'tongwen-core/dictionaries'; 4 | import { z } from 'zod'; 5 | import type { AutoConvertOpt, BrowserActionOpt, PrefGeneral } from '../../types/v2'; 6 | import { isBoolean } from '../controllers'; 7 | import { vldFn } from '../validator'; 8 | 9 | export const generalSchema: Control = dctrl({ 10 | autoConvert: vctrl( 11 | vldFn(z.enum(['disabled', LangType.s2t, LangType.t2s, 'ds2t', 'dt2s'])), 12 | 'disabled', 13 | ), 14 | browserAction: vctrl(vldFn(z.enum(['auto', LangType.s2t, LangType.t2s])), 'auto'), 15 | defaultTarget: vctrl(vldFn(z.enum([LangType.s2t, LangType.t2s])), LangType.s2t), 16 | spaMode: isBoolean(true), 17 | updateLangAttr: isBoolean(false), 18 | debugMode: isBoolean(false), 19 | }); 20 | -------------------------------------------------------------------------------- /src/service/runtime/content.ts: -------------------------------------------------------------------------------- 1 | import type { LangType } from 'tongwen-core/dictionaries'; 2 | import type { Tabs } from 'webextension-polyfill'; 3 | import { browser } from '../browser'; 4 | import type { ZhType } from '../tabs/tabs.constant'; 5 | import type { ReqAction, ReqActionDispatcher, ReqActionHandler, TActionMap, TActionPayload } from './action'; 6 | 7 | export type CtActionMap = TActionMap<{ 8 | Textarea: TActionPayload; 9 | Webpage: TActionPayload; 10 | ZhType: TActionPayload; 11 | }>; 12 | 13 | export type CtReqAction = ReqAction; 14 | 15 | export const handleCtReqAction: ReqActionHandler = async (_, repP) => repP; 16 | 17 | export const dispatchCtAction: ReqActionDispatcher]> = async ( 18 | action, 19 | tabId, 20 | ) => { 21 | return browser.tabs.sendMessage(tabId, action); 22 | }; 23 | -------------------------------------------------------------------------------- /src/background/runtime/handle-get-target.ts: -------------------------------------------------------------------------------- 1 | import type { Pref } from '../../preference/types/lastest'; 2 | import type { MaybeTransTarget } from '../../preference/types/types'; 3 | import type { FilterTarget } from '../../preference/types/v2'; 4 | import type { browser } from '../../service/browser'; 5 | import { getTargetByAutoConvert } from './handle-get-auto-convert'; 6 | import { getTargetByFilter } from './handle-get-filter-target'; 7 | 8 | // TODO: remove type assertion after Promise.then type infer bug fixed 9 | export const getTarget = async (pref: Pref, sender: browser.Runtime.MessageSender): Promise => 10 | Promise.resolve(getTargetByFilter(pref, sender.url!)) 11 | .then(async ft => 12 | ft != null 13 | ? ft 14 | : (getTargetByAutoConvert(sender.tab!.id!) as Promise), 15 | ) 16 | .then(ft => (ft === 'disabled' ? undefined : ft)); 17 | -------------------------------------------------------------------------------- /src/service/menu/determine-context.ts: -------------------------------------------------------------------------------- 1 | import type { PrefMenuGroup, PrefMenuGroupKeys, PrefMenuOptions } from '../../preference/types/v2'; 2 | import type { browser } from '../browser'; 3 | import { ContextOnAll, ContextOnEditable } from './menus'; 4 | 5 | const hasEnabled = (options: PrefMenuOptions) => options.s2t || options.t2s; 6 | 7 | export function getSubMenuContexts(prefKey: PrefMenuGroupKeys): browser.Menus.ContextType[] { 8 | return prefKey === 'textarea' ? ContextOnEditable : ContextOnAll; 9 | } 10 | 11 | export function getTopMenuContexts({ textarea, webpage }: PrefMenuGroup): browser.Menus.ContextType[] { 12 | const hasEditable = hasEnabled(textarea); 13 | const hasOthers = hasEnabled(webpage); 14 | 15 | return hasEditable && hasOthers 16 | ? [...ContextOnAll, ...ContextOnEditable] 17 | : hasEditable 18 | ? ContextOnEditable 19 | : hasOthers 20 | ? ContextOnAll 21 | : []; 22 | } 23 | -------------------------------------------------------------------------------- /src/options/components/modal/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, ReactNode } from 'react'; 2 | 3 | export const Modal: FC<{ 4 | isActive: boolean; 5 | head?: string; 6 | footer?: JSX.Element | JSX.Element[]; 7 | children: ReactNode; 8 | onOk?: () => void; 9 | onCancel: () => void; 10 | }> = ({ head, footer, isActive, onCancel, children }) => { 11 | const active = isActive ? 'active' : ''; 12 | return ( 13 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/content/mutation-observer/mount-mutation-observer.ts: -------------------------------------------------------------------------------- 1 | import { dispatchBgAction } from '../../service/runtime/background'; 2 | import type { CtState } from '../state'; 3 | import { exhaustMutations } from './exhaust-mutations'; 4 | 5 | type ObserverFn = (s: CtState) => (m: MutationRecord[]) => void; 6 | const observerFn: ObserverFn = state => mutations => { 7 | clearTimeout(state.timeoutId); 8 | state.mutations.push(...mutations); 9 | state.timeoutId = window.setTimeout(() => { 10 | const mutations = [...state.mutations]; 11 | state.mutations = []; 12 | exhaustMutations(state, mutations); 13 | }, 1000); 14 | }; 15 | 16 | export const mountMutationObserver = async (state: CtState): Promise => { 17 | dispatchBgAction({ type: 'SpaMode', payload: undefined }).then(isSpa => { 18 | if (isSpa) { 19 | state.mutationObserver = new MutationObserver(observerFn(state)); 20 | state.mutationObserver.observe(document, state.mutationOpt); 21 | } 22 | }); 23 | }; 24 | -------------------------------------------------------------------------------- /src/preference/upgrade/upgrade-pref.ts: -------------------------------------------------------------------------------- 1 | import { BrowserType } from '../../service/types'; 2 | import { getDefaultPref } from '../default'; 3 | import { v1SchemaFx, v1SchemaGc } from '../schema/v1'; 4 | import { v2Schema } from '../schema/v2'; 5 | import type { Pref } from '../types/lastest'; 6 | import { prefFxV1ToV2 } from './pref-fx-v1-to-v2'; 7 | import { prefGcV1ToV2 } from './pref-gc-v1-to-v2'; 8 | 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 | export const safeUpgradePref = (type: BrowserType, pref: any = {}): Pref => { 11 | switch (true) { 12 | case pref?.version === 2: 13 | return v2Schema(pref).value(); 14 | case pref?.version === 1 && type === BrowserType.FX: 15 | return safeUpgradePref(type, prefFxV1ToV2(v1SchemaFx(pref).value())); 16 | case Number.parseInt(pref?.version) === 1 && type === BrowserType.GC: 17 | return safeUpgradePref(type, prefGcV1ToV2(v1SchemaGc(pref).value())); 18 | default: 19 | return getDefaultPref(); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /src/preference/schema/v2/filter.ts: -------------------------------------------------------------------------------- 1 | import type { Control} from 'data-fixer'; 2 | import { actrl, dctrl, vctrl } from 'data-fixer'; 3 | import { z } from 'zod'; 4 | import { getRandomId } from '../../../utilities'; 5 | import { DOMAIN_PATTERN, REGEXP_PATTERN } from '../../filter-rule'; 6 | import type { FilterTarget, PrefFilter, PrefFilterRule, RegExpMaybe } from '../../types/v2'; 7 | import { isBoolean } from '../controllers'; 8 | import { vldFn } from '../validator'; 9 | 10 | const filterRuleSchema: Control = dctrl({ 11 | id: vctrl(vldFn(z.string().min(10)), getRandomId), 12 | pattern: vctrl(vldFn(z.union([z.string().regex(DOMAIN_PATTERN), z.string().regex(REGEXP_PATTERN)])), ''), 13 | target: vctrl(vldFn(z.enum(['disabled', 's2t', 't2s'])), 'disabled'), 14 | regexp: vctrl(vldFn(z.literal(null).optional()), null), 15 | }); 16 | 17 | export const filterSchema: Control = dctrl({ 18 | enabled: isBoolean(true), 19 | rules: actrl(filterRuleSchema), 20 | }); 21 | -------------------------------------------------------------------------------- /src/options/hooks/general/options.tsx: -------------------------------------------------------------------------------- 1 | import { i18n } from '../../../service/i18n/i18n'; 2 | import { entriesToOption } from '../../shared/entries-to-option'; 3 | 4 | export const autoConvertOptions = () => 5 | ( 6 | [ 7 | ['disabled', i18n.getMessage('MSG_DISABLED')], 8 | ['s2t', i18n.getMessage('MSG_S2T')], 9 | ['t2s', i18n.getMessage('MSG_T2S')], 10 | ['ds2t', i18n.getMessage('MSG_DETECTIVE_S2T')], 11 | ['dt2s', i18n.getMessage('MSG_DETECTIVE_T2S')], 12 | ] as [string, string][] 13 | ).map(entriesToOption); 14 | 15 | export const browserActionOptions = () => 16 | ( 17 | [ 18 | ['auto', i18n.getMessage('MSG_AUTO_CONVERT')], 19 | ['s2t', i18n.getMessage('MSG_S2T')], 20 | ['t2s', i18n.getMessage('MSG_T2S')], 21 | ] as [string, string][] 22 | ).map(entriesToOption); 23 | 24 | export const defaultTargetOptions = () => 25 | ( 26 | [ 27 | ['s2t', i18n.getMessage('MSG_S2T')], 28 | ['t2s', i18n.getMessage('MSG_T2S')], 29 | ] as [string, string][] 30 | ).map(entriesToOption); 31 | -------------------------------------------------------------------------------- /src/content/runtime/mount-runtime-listener.ts: -------------------------------------------------------------------------------- 1 | import { browser } from '../../service/browser'; 2 | import { dispatchBgAction } from '../../service/runtime/background'; 3 | import type { CtReqAction } from '../../service/runtime/content'; 4 | import { handleCtReqAction } from '../../service/runtime/content'; 5 | import { convertNode } from '../convert'; 6 | import type { CtState } from '../state'; 7 | import { handleTextarea } from './handle-textarea'; 8 | 9 | export const mountRuntimeListener = (state: CtState) => { 10 | browser.runtime.onMessage.addListener(async message => { 11 | const action = message as CtReqAction; 12 | dispatchBgAction({ type: 'Log', payload: ['[CT_RECEIVE_REQ]', action] }); 13 | 14 | switch (action.type) { 15 | case 'Webpage': 16 | return handleCtReqAction(action, convertNode(state, action.payload, [document])); 17 | case 'Textarea': 18 | return handleCtReqAction(action, handleTextarea(action.payload)); 19 | case 'ZhType': 20 | return handleCtReqAction(action, state.zhType); 21 | } 22 | }); 23 | }; 24 | -------------------------------------------------------------------------------- /src/service/storage/export-pref.ts: -------------------------------------------------------------------------------- 1 | import { safeUpgradePref } from '../../preference/upgrade'; 2 | import { browser } from '../browser'; 3 | import { i18n } from '../i18n/i18n'; 4 | import { createNoti } from '../notification/create-noti'; 5 | import { BROWSER_TYPE } from '../types'; 6 | import { getStorage } from './storage'; 7 | 8 | const delayRevoke = (url: string) => setTimeout(() => { URL.revokeObjectURL(url); }, 60000); 9 | 10 | export const exportPref = async () => { 11 | // TODO: maybe we can optionally get the download permission here 12 | return browser.downloads 13 | ? getStorage() 14 | .then(pref => safeUpgradePref(BROWSER_TYPE, pref)) 15 | .then(pref => new Blob([JSON.stringify(pref, null, 2)], { type: 'application/json;charset=utf-8' })) 16 | .then(blob => URL.createObjectURL(blob)) 17 | .then(url => (delayRevoke(url), url)) 18 | .then(url => ({ url, filename: 'tongwentang-pref.json', saveAs: true })) 19 | .then(browser.downloads.download) 20 | .catch(async () => createNoti(i18n.getMessage('MSG_EXPORT_FAILED'))) 21 | : Promise.resolve(); 22 | }; 23 | -------------------------------------------------------------------------------- /src/background/clipboard/index.ts: -------------------------------------------------------------------------------- 1 | import { LangType } from 'tongwen-core/dictionaries'; 2 | import { browser } from '../../service/browser'; 3 | import { i18n } from '../../service/i18n/i18n'; 4 | import { createNoti } from '../../service/notification/create-noti'; 5 | import { getConverter } from '../converter'; 6 | 7 | const convertClipboardContent = async (target: LangType): Promise => 8 | Promise.all([navigator.clipboard.readText(), getConverter()]) 9 | .then(([text, converter]) => converter.phrase(target, text)) 10 | .then(async text => navigator.clipboard.writeText(text)); 11 | 12 | export const convertClipboard = async (target: LangType): Promise => 13 | browser.permissions 14 | .request({ permissions: ['clipboardRead', 'clipboardWrite'] }) 15 | .then(async isGet => (isGet && (await convertClipboardContent(target)), isGet)) 16 | .then(isGet => { 17 | createNoti( 18 | i18n.getMessage(!isGet ? 'NT_GRT_PRM_DENIED' : target === LangType.s2t ? 'NT_CLB_TO_S2T' : 'NT_CLB_TO_T2S'), 19 | ); 20 | }) 21 | .catch(() => void createNoti(i18n.getMessage('NT_GRT_PRM_ONLY_USR_INTER'))); 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Tan Xiang Yang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/service/runtime/action.ts: -------------------------------------------------------------------------------- 1 | export interface TActionPayload { 2 | request: Request; 3 | response: Response; 4 | } 5 | 6 | export interface TAction { 7 | type: Type; 8 | payload: Payload; 9 | } 10 | 11 | export type ActionMap = Record>; 12 | export type TActionMap = Map; 13 | 14 | export type ReqActionOf = TAction; 15 | export type RepPayloadOf = Map[Type]['response']; 16 | 17 | export type ReqAction = { 18 | [Type in keyof All]: TAction; 19 | }[keyof All]; 20 | 21 | export type ReqActionHandler = < 22 | const ReqA extends ReqAction, 23 | Rep extends Map[ReqA['type']]['response'], 24 | >( 25 | reqA: ReqA, 26 | rep: Rep | Promise, 27 | ) => typeof rep; 28 | 29 | export type ReqActionDispatcher = >( 30 | action: A, 31 | ...args: Args 32 | ) => Promise>; 33 | -------------------------------------------------------------------------------- /src/service/storage/import-pref.ts: -------------------------------------------------------------------------------- 1 | import type { Holder } from 'data-fixer'; 2 | import { safeUpgradePref, validatePref } from '../../preference/upgrade'; 3 | import { i18n } from '../i18n/i18n'; 4 | import { createNoti } from '../notification/create-noti'; 5 | import type { BrowserType } from '../types'; 6 | import { setStorage } from './storage'; 7 | 8 | const parseJson = async (raw: string) => 9 | Promise.resolve(JSON.parse(raw)).catch(async () => Promise.reject(i18n.getMessage('MSG_JSON_ERROR'))); 10 | 11 | const confirmFix = () => confirm(i18n.getMessage('MSG_CONFIRM_FIX_IMPORT')); 12 | 13 | const getValidPref = (type: BrowserType) => (holder: Holder) => { 14 | if (holder.invalid && !confirmFix()) { 15 | throw i18n.getMessage('MSG_IMPORT_CANCELED'); 16 | } 17 | return safeUpgradePref(type, holder.value()); 18 | }; 19 | 20 | export const importPref = async (type: BrowserType, raw: string): Promise => 21 | parseJson(raw) 22 | .then(validatePref(type)) 23 | .then(getValidPref(type)) 24 | .then(setStorage) 25 | .then(async () => createNoti(i18n.getMessage('MSG_IMPORT_COMPLETED'))) 26 | .catch(async () => createNoti(i18n.getMessage('MSG_IMPORT_FAILED'))); 27 | -------------------------------------------------------------------------------- /src/service/storage/reset-pref.ts: -------------------------------------------------------------------------------- 1 | import { getDefaultPref } from '../../preference/default'; 2 | import { i18n } from '../i18n/i18n'; 3 | import { createNoti } from '../notification/create-noti'; 4 | import { getStorage, resetStorage, setStorage } from './storage'; 5 | 6 | const confirmReset = (msg: string) => confirm(msg); 7 | 8 | export const confirmResetPref = async (): Promise => 9 | confirmReset(i18n.getMessage('MSG_CONFIRM_RESET_ALL')) 10 | ? resetStorage() 11 | .then(() => void createNoti(i18n.getMessage('MSG_PREF_RESET_COMPLETED'))) 12 | .catch(() => void createNoti(i18n.getMessage('MSG_PREF_RESET_FAILED'))) 13 | : undefined; 14 | 15 | const extractCustom = async () => getStorage().then(({ word: { custom } }) => custom); 16 | 17 | export const confirmResetPrefKeep = async (): Promise => 18 | confirmReset(i18n.getMessage('MSG_CONFIRM_RESET')) 19 | ? extractCustom() 20 | .then(custom => (pref => ((pref.word.custom = custom), pref))(getDefaultPref())) 21 | .then(setStorage) 22 | .then(() => void createNoti(i18n.getMessage('MSG_PREF_RESET_COMPLETED'))) 23 | .catch(() => void createNoti(i18n.getMessage('MSG_PREF_RESET_FAILED'))) 24 | : undefined; 25 | -------------------------------------------------------------------------------- /src/preference/schema/v1/chrome.ts: -------------------------------------------------------------------------------- 1 | import type { Control} from 'data-fixer'; 2 | import { actrl, dctrl, vctrl } from 'data-fixer'; 3 | import { z } from 'zod'; 4 | import type { PrefGcV1 } from '../../types/v1'; 5 | import { isBoolean, isDic, isString } from '../controllers'; 6 | import { vldFn } from '../validator'; 7 | 8 | const isFilterRule = dctrl({ 9 | url: isString, 10 | zhflag: vctrl<'none' | 'trad' | 'simp'>(vldFn(z.enum(['none', 'trad', 'simp'])), 'none'), 11 | }); 12 | 13 | export const v1SchemaGc: Control = dctrl({ 14 | version: vctrl(vldFn(z.string().regex(/^1..+/)), '1.0.0.0'), 15 | autoConvert: vctrl<'none' | 'trad' | 'simp'>(vldFn(z.enum(['none', 'trad', 'simp'])), 'none'), 16 | iconAction: vctrl<'auto' | 'trad' | 'simp'>(vldFn(z.enum(['auto', 'trad', 'simp'])), 'auto'), 17 | symConvert: isBoolean(false), 18 | inputConvert: vctrl<'none' | 'auto' | 'trad' | 'simp'>(vldFn(z.enum(['none', 'auto', 'trad', 'simp'])), 'none'), 19 | fontCustom: dctrl({ enable: isBoolean(false), trad: isString, simp: isString }), 20 | urlFilter: dctrl({ enable: isBoolean(false), list: actrl(isFilterRule) }), 21 | userPhrase: dctrl({ enable: isBoolean(true), trad: isDic, simp: isDic }), 22 | contextMenu: dctrl({ enable: isBoolean(true) }), 23 | }); 24 | -------------------------------------------------------------------------------- /src/options/hooks/page/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, Reducer} from 'react'; 2 | import { useReducer } from 'react'; 3 | import { AboutPage } from '../../pages/about/AboutPage'; 4 | import { FilterPage } from '../../pages/filter/FilterPage'; 5 | import { GeneralPage } from '../../pages/general/GeneralPage'; 6 | import { MenuPage } from '../../pages/menu/MenuPage'; 7 | import { WordPage } from '../../pages/word/WordPage'; 8 | 9 | export enum PageType { 10 | general = 'GENERAL', 11 | menu = 'MENU', 12 | filter = 'FILTER', 13 | word = 'WORD', 14 | about = 'ABOUT', 15 | } 16 | 17 | export interface PageAction { type: PageType } 18 | 19 | export interface PageState { type: PageType; node: FC } 20 | 21 | const pageReducer: Reducer = (s, { type }) => { 22 | switch (type) { 23 | case PageType.general: 24 | return { type, node: GeneralPage }; 25 | case PageType.menu: 26 | return { type, node: MenuPage }; 27 | case PageType.filter: 28 | return { type, node: FilterPage }; 29 | case PageType.word: 30 | return { type, node: WordPage }; 31 | case PageType.about: 32 | return { type, node: AboutPage }; 33 | } 34 | }; 35 | 36 | export const usePage = () => useReducer(pageReducer, { type: PageType.general, node: GeneralPage }); 37 | -------------------------------------------------------------------------------- /src/service/runtime/background.ts: -------------------------------------------------------------------------------- 1 | import type { LangType } from 'tongwen-core/dictionaries'; 2 | import type { MaybeTransTarget } from '../../preference/types/types'; 3 | import type { FilterTarget } from '../../preference/types/v2'; 4 | import { browser } from '../browser'; 5 | import type { ZhType } from '../tabs/tabs.constant'; 6 | import type { ReqAction, ReqActionDispatcher, ReqActionHandler, TActionMap, TActionPayload } from './action'; 7 | 8 | export type BgActionMap = TActionMap<{ 9 | Convert: TActionPayload<{ target: LangType; text: string }, string>; 10 | NodesText: TActionPayload<{ target: LangType; texts: string[] }, string[]>; 11 | DetectLang: TActionPayload; 12 | FilterTarget: TActionPayload; 13 | AutoConvert: TActionPayload; 14 | ConvertClipboard: TActionPayload; 15 | GetTarget: TActionPayload; 16 | SpaMode: TActionPayload; 17 | Log: TActionPayload; 18 | }>; 19 | 20 | export type BgReqAction = ReqAction; 21 | 22 | export const handleBgReqAction: ReqActionHandler = async (_, repP) => repP; 23 | 24 | export const dispatchBgAction: ReqActionDispatcher = async action => { 25 | return browser.runtime.sendMessage(action); 26 | }; 27 | -------------------------------------------------------------------------------- /src/background/converter/index.ts: -------------------------------------------------------------------------------- 1 | import { createConverterMap, type Converter } from 'tongwen-core'; 2 | import { LangType, type DicObj, type SrcPack } from 'tongwen-core/dictionaries'; 3 | import type { PrefWord } from '../../preference/types/v2'; 4 | import { bgGetPref } from '../state/storage'; 5 | 6 | const getDict = async (dir: LangType, type: 'char' | 'phrase') => { 7 | return fetch(`dictionaries/${dir}-${type}.min.json`).then(async r => r.json() as Promise); 8 | }; 9 | 10 | const createSrcPack = async ({ default: def, custom }: PrefWord): Promise => { 11 | return Promise.all([ 12 | def.s2t.char ? getDict(LangType.s2t, 'char') : {}, 13 | def.s2t.phrase ? getDict(LangType.s2t, 'phrase') : {}, 14 | def.t2s.char ? getDict(LangType.t2s, 'char') : {}, 15 | def.t2s.phrase ? getDict(LangType.t2s, 'phrase') : {}, 16 | ]).then(([ss, sp, ts, tp]) => ({ s2t: [ss, sp, custom.s2t], t2s: [ts, tp, custom.t2s] })); 17 | }; 18 | 19 | let converter: Converter | undefined = undefined; 20 | let queue: Promise | undefined = undefined; 21 | 22 | export const getConverter = async (): Promise => { 23 | return converter 24 | ? Promise.resolve(converter) 25 | : (queue ?? 26 | (queue = bgGetPref() 27 | .then(async pref => createSrcPack(pref.word)) 28 | .then(src => (converter = createConverterMap(src))))); 29 | }; 30 | -------------------------------------------------------------------------------- /src/service/storage/local.ts: -------------------------------------------------------------------------------- 1 | import type { Pref } from '../../preference/types/lastest'; 2 | import type { PrefFilterRule } from '../../preference/types/v2'; 3 | import { getStorage, setStorage } from './storage'; 4 | 5 | export type StoreReducer = (store: Pref) => Partial; 6 | export const patchLocalStorage = async (reducer: StoreReducer): Promise => getStorage().then(reducer).then(setStorage); 7 | 8 | export const addFilterRule = async (rule: PrefFilterRule): Promise => { 9 | return patchLocalStorage(({ filter: { rules, ...rest } }) => ({ 10 | filter: { 11 | ...rest, 12 | rules: (index => (index === -1 ? [rule, ...rules] : Object.assign([...rules], { [index]: rule })))( 13 | rules.findIndex(r => r.pattern === rule.pattern), 14 | ), 15 | }, 16 | })); 17 | }; 18 | 19 | export const updateFilterRule = async (rule: PrefFilterRule, index: number): Promise => 20 | patchLocalStorage(({ filter: { enabled, rules } }) => ({ 21 | filter: { 22 | enabled, 23 | rules: Object.assign([...rules], { [index]: rule }).filter(r => r.pattern !== rule.pattern), 24 | }, 25 | })); 26 | 27 | export const deleteFilterRule = async (index: number): Promise => 28 | patchLocalStorage(({ filter: { enabled, rules } }) => ({ 29 | filter: { 30 | enabled, 31 | rules: rules.filter((_, i) => i !== index), 32 | }, 33 | })); 34 | -------------------------------------------------------------------------------- /docs/preferences/preferences_zh-tw.md: -------------------------------------------------------------------------------- 1 | # 新同文堂偏好設定 2 | 3 | 在偏好設定頁面中有不少可供自訂的選項。 4 | 5 | ## General 6 | 7 | 自動轉換 8 | 9 | - 停用 10 | - 頁面載入後不做任何動作。 11 | - 簡轉正: 12 | - 頁面載入後轉換成正體。 13 | - 正轉簡: 14 | - 頁面載入後轉換成簡體。 15 | - 偵測式簡轉正: 16 | - 頁面載入後若網頁內容被識別為簡體則轉換成正體。 17 | - 偵測式正轉簡: 18 | - 頁面載入後若網頁內容被識別為正體則轉換成簡體。 19 | 20 | 圖示動作 21 | 22 | - 自動: 23 | - 把網頁內容在正體與簡體間來回切換。 24 | - 若網頁內容無法在首次點選時被瀏覽器  API 識別則預設轉換目標。 25 | - 正體 26 | - 每次點選時都轉換成正體。 27 | - 簡體 28 | - 每次點選時都轉換成簡體。 29 | 30 | 預設轉換 31 | 32 | - 若圖示動作設定為「自動」且網頁內容無法識別時則採用此設定值。 33 | 34 | 動態轉換 35 | 36 | - 根據網頁內容動態轉換,多數的網站都會以不重新整理頁面的方式局部更新內容。 37 | 38 | 偵錯模式 39 | 40 | - 若啟用,一些關鍵資訊將會被記錄在套件的主控台。 41 | - 如何開啟套件主控台 42 | - Firefox: 43 | - 開啟 `about:debugging` ,切換到 `This Firefox` 分頁,找到 `新同文堂` 並點選 `Inspect`,切換到 `主控台` 分頁。 44 | - Chrome: 45 | - 開啟 `chrome://extensions`,找到 `新同文堂` 並點選 `Detail`,點選 `Inspect Views` 下的 `background page` 連結。 46 | 47 | ## 右鍵選單 48 | 49 | 啟用右鍵選單 50 | 51 | - 完全啟用或停用在網頁右鍵選單中顯示的指令。 52 | 53 | 其他 54 | 55 | - 針對每一個指令啟用或停用在網頁右鍵選單中顯示。 56 | 57 | ## 網域規則 58 | 59 | 啟用網域規則 60 | 61 | - 完全啟用或停用這個功能。 62 | 63 | 新增 64 | 65 | - 觸發網域規則編輯器。 66 | 67 | 儲存 68 | 69 | - 要儲存所有的變更需要手動點選「儲存」按鈕。 70 | 71 | 網域規則 72 | 73 | - 網域規則接受純文字或正規表示法。 74 | - 純文字表示任何連結只要包含這串文字既可以觸發轉換。 75 | - 正規表示法表示唯有在 `regex.test` 以連結進行呼叫並回傳 `true` 的情況下才會觸發轉換。 76 | 77 | ## 詞彙 78 | 79 | 預設 80 | 81 | - 內建四個可以被開關的對應列表。 82 | 83 | 自訂簡轉正 / 自訂正轉簡 84 | 85 | - 新增、編輯、刪除使用者自訂的對應列表。 86 | -------------------------------------------------------------------------------- /src/preference/schema/v1/firefox.ts: -------------------------------------------------------------------------------- 1 | import type { Control} from 'data-fixer'; 2 | import { actrl, dctrl, vctrl } from 'data-fixer'; 3 | import { z } from 'zod'; 4 | import type { PrefFxV1 } from '../../types/v1'; 5 | import { isDic, isFalse, isString, isTrue } from '../controllers'; 6 | import { vldFn } from '../validator'; 7 | 8 | const isFilterItem = dctrl({ 9 | action: vctrl(vldFn(z.union([z.literal(0), z.literal(1), z.literal(2)])), 0), 10 | url: isString, 11 | }); 12 | 13 | export const v1SchemaFx: Control = dctrl({ 14 | version: vctrl<1>(vldFn(z.literal(1)), 1), 15 | autoConvert: vctrl(vldFn(z.union([z.literal(0), z.literal(1), z.literal(2)])), 0), 16 | iconAction: vctrl(vldFn(z.union([z.literal(1), z.literal(2), z.literal(3)])), 1), 17 | inputConvert: vctrl(vldFn(z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)])), 0), 18 | symConvert: isTrue, 19 | fontCustomEnabled: isFalse, 20 | fontCustomTrad: isString, 21 | fontCustomSimp: isString, 22 | contextMenuEnabled: isTrue, 23 | contextMenuInput2Trad: isTrue, 24 | contextMenuInput2Simp: isTrue, 25 | contextMenuPage2Trad: isTrue, 26 | contextMenuPage2Simp: isTrue, 27 | contextMenuClip2Trad: isTrue, 28 | contextMenuClip2Simp: isTrue, 29 | urlFilterEnabled: isTrue, 30 | urlFilterList: actrl(isFilterItem), 31 | userPhraseEnable: isTrue, 32 | userPhraseTradList: isDic, 33 | userPhraseSimpList: isDic, 34 | }); 35 | -------------------------------------------------------------------------------- /src/preference/types/v1/firefox.ts: -------------------------------------------------------------------------------- 1 | import type { DicObj } from 'tongwen-core/dictionaries'; 2 | 3 | export const V1PrefFxAutoConverterEnum = ['disabled', 's2t', 't2s']; 4 | 5 | export const V1PrefFxActionEnum = ['disabled', 'auto', 's2t', 't2s']; 6 | 7 | enum AutoConvert { 8 | disabled = 0, 9 | s2t = 1, 10 | t2s = 2, 11 | } 12 | 13 | enum IconAction { 14 | auto = 1, 15 | s2t = 2, 16 | t2s = 3, 17 | } 18 | 19 | enum InputConvert { 20 | disabled = 0, 21 | auto = 1, 22 | s2t = 2, 23 | t2s = 3, 24 | } 25 | 26 | enum FilterAction { 27 | disabled = 0, 28 | s2t = 2, 29 | t2s = 3, 30 | } 31 | 32 | export interface PrefFxV1Filter { 33 | action: FilterAction; 34 | url: string; 35 | } 36 | 37 | export interface PrefFxV1 { 38 | version: 1; 39 | autoConvert: AutoConvert; 40 | iconAction: IconAction; 41 | inputConvert: InputConvert; 42 | symConvert: boolean; 43 | fontCustomEnabled: boolean; 44 | fontCustomTrad: string; 45 | fontCustomSimp: string; 46 | contextMenuEnabled: boolean; 47 | contextMenuInput2Trad: boolean; 48 | contextMenuInput2Simp: boolean; 49 | contextMenuPage2Trad: boolean; 50 | contextMenuPage2Simp: boolean; 51 | contextMenuClip2Trad: boolean; 52 | contextMenuClip2Simp: boolean; 53 | urlFilterEnabled: boolean; 54 | urlFilterList: PrefFxV1Filter[]; 55 | userPhraseEnable: boolean; 56 | userPhraseTradList: DicObj; 57 | userPhraseSimpList: DicObj; 58 | } 59 | -------------------------------------------------------------------------------- /src/background/browser-action.ts: -------------------------------------------------------------------------------- 1 | import { LangType } from 'tongwen-core/dictionaries'; 2 | import { browser } from '../service/browser'; 3 | import { dispatchCtAction } from '../service/runtime/content'; 4 | import { ZhType } from '../service/tabs/tabs.constant'; 5 | import { bgLog } from './logger'; 6 | import { bgGetPref } from './state/storage'; 7 | 8 | type GetTargetByDetection = (id: number, f: LangType) => Promise; 9 | const getTargetByDetection: GetTargetByDetection = async (id, fallback) => { 10 | return dispatchCtAction({ type: 'ZhType', payload: undefined }, id).then(zh => { 11 | switch (zh) { 12 | case ZhType.hans: 13 | return LangType.s2t; 14 | case ZhType.hant: 15 | return LangType.t2s; 16 | case ZhType.und: 17 | return fallback; 18 | } 19 | }); 20 | }; 21 | 22 | export function mountBrowserActionListener(): void { 23 | browser.action.onClicked.addListener(async tab => { 24 | bgLog('[ACTION_RECEIVE_REQ] req:', { tab }); 25 | const tabId = tab.id; 26 | if (typeof tabId !== 'number') return; 27 | 28 | return bgGetPref() 29 | .then(async pref => 30 | pref.general.browserAction === 'auto' 31 | ? getTargetByDetection(tabId, pref.general.defaultTarget) 32 | : pref.general.browserAction, 33 | ) 34 | .then(async target => dispatchCtAction({ type: 'Webpage', payload: target }, tabId)); 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /src/content/state.ts: -------------------------------------------------------------------------------- 1 | import { TARGET_NODE_ATTRIBUTES } from 'tongwen-core/walker'; 2 | import { getStorage } from '../service/storage/storage'; 3 | import type { ZhType } from '../service/tabs/tabs.constant'; 4 | import { getDetectLanguage } from './services'; 5 | 6 | const mutationOpt: MutationObserverInit = { 7 | childList: true, 8 | attributes: true, 9 | characterData: true, 10 | subtree: true, 11 | attributeFilter: TARGET_NODE_ATTRIBUTES as string[], 12 | }; 13 | 14 | export interface CtState { 15 | zhType: ZhType; 16 | updateLangAttr: boolean; 17 | debugMode: boolean; 18 | timeoutId: number | undefined; 19 | mutationOpt: MutationObserverInit; 20 | mutationObserver?: MutationObserver; 21 | mutations: MutationRecord[]; 22 | converting: Promise; 23 | } 24 | 25 | const getUpdateLangAttr = async () => getStorage('general').then(({ general }) => general.updateLangAttr); 26 | const getDebugMode = async () => getStorage('general').then(({ general }) => general.debugMode); 27 | 28 | export async function createCtState(): Promise { 29 | return Promise.all([getDetectLanguage(), getUpdateLangAttr(), getDebugMode()]).then( 30 | ([zhType, updateLangAttr, debugMode]) => ({ 31 | zhType, 32 | updateLangAttr, 33 | debugMode, 34 | timeoutId: undefined, 35 | mutationOpt, 36 | mutations: [], 37 | converting: Promise.resolve(undefined), 38 | }), 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js'; 2 | import react from 'eslint-plugin-react'; 3 | import globals from 'globals'; 4 | import tseslint from 'typescript-eslint'; 5 | 6 | const scripts = ['**/rspack.config.js', '**/manifest.js', '**/web-ext-config*.js']; 7 | 8 | /** 9 | * @type {import('typescript-eslint').Config} 10 | */ 11 | export default [ 12 | { ignores: ['**/dist/', '**/web-ext-artifacts/'] }, 13 | eslint.configs.recommended, 14 | ...tseslint.configs.recommended, 15 | { 16 | rules: { 17 | '@typescript-eslint/no-unused-expressions': 'off', 18 | '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 19 | }, 20 | }, 21 | { 22 | name: 'scripts', 23 | files: scripts, 24 | languageOptions: { globals: globals.node, sourceType: 'commonjs' }, 25 | rules: { 26 | '@typescript-eslint/no-require-imports': 'off', 27 | }, 28 | }, 29 | react.configs.flat.recommended, 30 | { 31 | settings: { react: { version: 'detect' } }, 32 | rules: { 33 | 'react/prop-types': 'off', 34 | 'react/react-in-jsx-scope': 'off', 35 | }, 36 | }, 37 | { 38 | name: 'extension', 39 | files: ['**/src/**/*.{js,mjs,cjs,ts,jsx,tsx}'], 40 | languageOptions: { 41 | parser: tseslint.parser, 42 | parserOptions: { project: true, tsconfigRootDir: import.meta.dirname }, 43 | globals: globals.browser, 44 | }, 45 | }, 46 | ]; 47 | -------------------------------------------------------------------------------- /src/background/commands/index.ts: -------------------------------------------------------------------------------- 1 | import { LangType } from 'tongwen-core/dictionaries'; 2 | import { browser } from '../../service/browser'; 3 | import { CommandType } from '../../service/commands/type'; 4 | import { dispatchCtAction } from '../../service/runtime/content'; 5 | import { convertClipboard } from '../clipboard'; 6 | import { bgLog } from '../logger'; 7 | 8 | export const mountCommandListener = () => { 9 | browser.commands?.onCommand.addListener(async cmd => { 10 | bgLog('[BG_RECEIVE_COMMAND]:', cmd); 11 | 12 | switch (cmd) { 13 | case CommandType.wS2t: 14 | return browser.tabs 15 | .query({ active: true, currentWindow: true }) 16 | .then(async ([tab]) => 17 | typeof tab.id === 'number' 18 | ? dispatchCtAction({ type: 'Webpage', payload: LangType.s2t }, tab.id) 19 | : Promise.resolve(undefined), 20 | ); 21 | case CommandType.wT2s: 22 | return browser.tabs 23 | .query({ active: true, currentWindow: true }) 24 | .then(async ([tab]) => 25 | typeof tab.id === 'number' 26 | ? dispatchCtAction({ type: 'Webpage', payload: LangType.t2s }, tab.id) 27 | : Promise.resolve(undefined), 28 | ); 29 | case CommandType.cS2t: 30 | return convertClipboard(LangType.s2t); 31 | case CommandType.cT2s: 32 | return convertClipboard(LangType.t2s); 33 | } 34 | }); 35 | }; 36 | -------------------------------------------------------------------------------- /src/options/pages/menu/MenuSettings.tsx: -------------------------------------------------------------------------------- 1 | import type { FC} from 'react'; 2 | import { Fragment } from 'react'; 3 | import { i18n } from '../../../service/i18n/i18n'; 4 | import { Checkbox } from '../../components/forms'; 5 | import { useMenu } from '../../hooks/menu'; 6 | 7 | export const MenuSettings: FC = () => { 8 | const { menu, setMenuEnable, setWebS2t, setWebT2s, setTextS2t, setTextT2s } = useMenu(); 9 | 10 | return ( 11 | 12 | 18 | 24 | 30 | 36 | 42 | 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release build 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*.*.*' 7 | 8 | jobs: 9 | build_and_upload: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | - uses: actions/setup-node@v2 15 | with: 16 | node-version: '14.x' 17 | 18 | - name: Get yarn cache directory path 19 | id: yarn-cache-dir-path 20 | run: echo "::set-output name=dir::$(yarn cache dir)" 21 | 22 | - uses: actions/cache@v2 23 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 24 | with: 25 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 26 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 27 | restore-keys: | 28 | ${{ runner.os }}-yarn- 29 | 30 | - name: Install dependencies 31 | if: ${{ steps.yarn-cache.outputs.cache-hit != 'true' }} 32 | run: yarn install 33 | 34 | - name: Build project 35 | run: yarn build:all 36 | 37 | - name: Compress dist 38 | run: | 39 | tar -cf firefox.tar dist/firefox/* 40 | tar -cf chromium.tar dist/chromium/* 41 | 42 | - name: Publish release 43 | uses: softprops/action-gh-release@v1 44 | with: 45 | files: | 46 | firefox.tar 47 | chromium.tar 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | -------------------------------------------------------------------------------- /src/background/runtime/handle-get-auto-convert.ts: -------------------------------------------------------------------------------- 1 | import { LangType } from 'tongwen-core/dictionaries'; 2 | import type { MaybeTransTarget } from '../../preference/types/types'; 3 | import { detectLanguage } from '../../service/tabs/detect-language'; 4 | import { ZhType } from '../../service/tabs/tabs.constant'; 5 | import { bgGetPref } from '../state/storage'; 6 | 7 | type GetTargetByDetectLanguage = (tabId: number, t: LangType) => Promise; 8 | export const getTargetByDetectLanguage: GetTargetByDetectLanguage = async (tabId, target) => 9 | detectLanguage(tabId).then(zh => { 10 | switch (zh) { 11 | case ZhType.hans: 12 | return target === LangType.t2s ? undefined : LangType.s2t; 13 | case ZhType.hant: 14 | return target === LangType.s2t ? undefined : LangType.t2s; 15 | case ZhType.und: 16 | return target; 17 | } 18 | }); 19 | 20 | type GetTargetByAutoConvert = (tabId: number) => Promise; 21 | export const getTargetByAutoConvert: GetTargetByAutoConvert = async tabId => { 22 | return bgGetPref().then(async pref => { 23 | switch (pref.general.autoConvert) { 24 | case LangType.s2t: 25 | case LangType.t2s: 26 | return Promise.resolve(pref.general.autoConvert); 27 | case 'ds2t': 28 | return getTargetByDetectLanguage(tabId, LangType.s2t); 29 | case 'dt2s': 30 | return getTargetByDetectLanguage(tabId, LangType.t2s); 31 | case 'disabled': 32 | return Promise.resolve(undefined); 33 | } 34 | }); 35 | }; 36 | -------------------------------------------------------------------------------- /src/content/convert/convert-nodes.ts: -------------------------------------------------------------------------------- 1 | import { LangType } from 'tongwen-core/dictionaries'; 2 | import { walkNode } from 'tongwen-core/walker'; 3 | import { dispatchBgAction } from '../../service/runtime/background'; 4 | import { ZhType } from '../../service/tabs/tabs.constant'; 5 | import type { CtState } from '../state'; 6 | import { updateLangAttr } from './update-lang-attr'; 7 | import { updateNodes } from './update-nodes'; 8 | 9 | type SConvertNode = (state: CtState, target: LangType, nodes: Node[]) => Promise; 10 | export const convertNode: SConvertNode = async (state, target, nodes) => { 11 | const parsedNodes = nodes.flatMap(node => walkNode(node)); 12 | return parsedNodes.length === 0 13 | ? void 0 14 | : (state.converting = state.converting 15 | .catch(() => undefined) 16 | .then(async () => dispatchBgAction({ type: 'NodesText', payload: { target, texts: parsedNodes.map(n => n.text) } })) 17 | .then(texts => { 18 | state.mutationObserver?.disconnect(); 19 | updateNodes(parsedNodes, texts); 20 | state.mutationObserver?.observe(document, state.mutationOpt); 21 | state.updateLangAttr && 22 | document.querySelectorAll('[lang|="zh"]').forEach(el => { updateLangAttr(el, target); }); 23 | 24 | switch (target) { 25 | case LangType.s2t: 26 | state.zhType = ZhType.hant; 27 | break; 28 | case LangType.t2s: 29 | state.zhType = ZhType.hans; 30 | break; 31 | default: 32 | state.zhType = ZhType.und; 33 | } 34 | })); 35 | }; 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # New Tong Wen Tang 2 | 3 | New Tong Wen Tang is a Browser Extension that provide functions for convert between Simplicity Chinese and Traditional Chinese. 4 | 5 | Main Features: 6 | 7 | - Convert 8 | - Automatic convert on webpage loaded. 9 | - Responsively convert when content change (for Single Page Application). 10 | - Manually convert via Browser Action icon and browser context menu. 11 | - Convert content in clipboard. 12 | - Import and export preferences 13 | - Support import and export config (including v1 config). 14 | - URL Rule 15 | - Set the convert rule by url or regular expression. 16 | - Mapping Words 17 | - Built-in and custom mapping words. 18 | 19 | ### Download 20 | 21 | - [Firefox](https://addons.mozilla.org/firefox/addon/new_tongwentang/) 22 | - [Chrome](https://chrome.google.com/webstore/detail/new-tongwentang/ldmgbgaoglmaiblpnphffibpbfchjaeg) 23 | - [Microsoft Edge](https://microsoftedge.microsoft.com/addons/detail/%E6%96%B0%E5%90%8C%E6%96%87%E5%A0%82/ijddgmclgedepadbikmfekambhhfjfnl) 24 | 25 | ### Todo Features 26 | 27 | - Settings sync. 28 | 29 | ### Development 30 | 31 | To start developing, first git clone then install dependencies: 32 | 33 | ``` 34 | $ yarn install 35 | ``` 36 | 37 | #### Developing with Firefox 38 | 39 | Run command: 40 | 41 | ``` 42 | $ yarn dev:firefox 43 | ``` 44 | 45 | #### Developing with chromium-based browser 46 | 47 | To start a Chromium-based developing environment you need to create a `.env` file from `env.sample` then paste your chromium binary path to `CHROMIUM_BINARY`. ([issue](https://github.com/mozilla/web-ext/issues/1862)) 48 | 49 | Run command: 50 | 51 | ``` 52 | $ yarn dev:chromium 53 | ``` 54 | -------------------------------------------------------------------------------- /src/options/pages/filter/FilterRules.tsx: -------------------------------------------------------------------------------- 1 | import type { Dispatch, FC} from 'react'; 2 | import { useCallback } from 'react'; 3 | import type { PrefFilterRule } from '../../../preference/types/v2'; 4 | import { i18n } from '../../../service/i18n/i18n'; 5 | import type { UseFilterRuleAction } from '../../hooks/filter'; 6 | import { FilterRuleRow } from './FilterRuleRow'; 7 | 8 | export const FilterRules: FC<{ 9 | rules: PrefFilterRule[]; 10 | setRules: Dispatch; 11 | onUpdate: (rule: PrefFilterRule) => void; 12 | }> = ({ rules, setRules, onUpdate: handleUpdate }) => { 13 | const handleUp = useCallback((index: number) => { setRules({ type: 'UP', payload: index }); }, [setRules]); 14 | 15 | const handleDown = useCallback((index: number) => { setRules({ type: 'DOWN', payload: index }); }, [setRules]); 16 | 17 | const handleRemove = useCallback((rule: PrefFilterRule) => { setRules({ type: 'DELETE', payload: rule }); }, [setRules]); 18 | 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | 26 | 31 | 32 | 33 | {rules.map((rule, index) => ( 34 | 44 | ))} 45 | 46 |
#{i18n.getMessage('MSG_URL_REGEX')}{i18n.getMessage('MSG_TARGET')} 27 | 28 | 29 | 30 |
47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /src/options/pages/filter/FilterRuleRow.tsx: -------------------------------------------------------------------------------- 1 | import type { FC} from 'react'; 2 | import { useCallback } from 'react'; 3 | import type { PrefFilterRule } from '../../../preference/types/v2'; 4 | import { i18n } from '../../../service/i18n/i18n'; 5 | import { Button } from '../../components'; 6 | 7 | export const FilterRuleRow: FC<{ 8 | index: number; 9 | rule: PrefFilterRule; 10 | length: number; 11 | onChange: (rule: PrefFilterRule) => void; 12 | onRemove: (rule: PrefFilterRule) => void; 13 | onUp: (index: number) => void; 14 | onDown: (index: number) => void; 15 | }> = ({ index, rule, length, onChange: handleChange, onRemove: handleRemove, onUp: handleUp, onDown: handleDown }) => { 16 | const change = useCallback(() => { handleChange(rule); }, [rule]); 17 | const remove = useCallback(() => { handleRemove(rule); }, [rule]); 18 | const up = useCallback(() => { handleUp(index); }, [handleUp]); 19 | const down = useCallback(() => { handleDown(index); }, [handleDown]); 20 | 21 | return ( 22 | 23 | {index + 1} 24 | {rule.pattern} 25 | {i18n.getMessage(`MSG_${rule.target}`)} 26 | 27 | 30 | 31 | 32 | 35 | 36 | 37 | 40 | 41 | 42 | 45 | 46 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /src/options/pages/word/WordEntryEditor.tsx: -------------------------------------------------------------------------------- 1 | import type { FC} from 'react'; 2 | import { useCallback, useEffect, useState } from 'react'; 3 | import { i18n } from '../../../service/i18n/i18n'; 4 | import { Button } from '../../components'; 5 | import type { EventCallback } from '../types'; 6 | 7 | export const WordEntryEditor: FC<{ 8 | entry: [string, string]; 9 | onSubmit: EventCallback<[[string, string]]>; 10 | }> = ({ entry, onSubmit }) => { 11 | const [key, setKey] = useState(''); 12 | const [value, setValue] = useState(''); 13 | 14 | useEffect(() => (setKey(entry[0]), setValue(entry[1])), [entry]); 15 | 16 | const submit = useCallback(() => (onSubmit([key, value]), setKey(''), setValue('')), [onSubmit, key, value]); 17 | 18 | return ( 19 |
20 |
21 |
22 | 23 | { setKey(evt.currentTarget.value); }} /> 24 |
25 |
26 |
27 |
28 | 29 | { setValue(evt.currentTarget.value); }} /> 30 |
31 |
32 |
33 |
34 | 37 | 38 |
39 |
40 |
41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /src/background/menu/listen.ts: -------------------------------------------------------------------------------- 1 | import { LangType } from 'tongwen-core/dictionaries'; 2 | import { browser } from '../../service/browser'; 3 | import type { ContextMenuChildrenId } from '../../service/menu/create-menu'; 4 | import { dispatchCtAction } from '../../service/runtime/content'; 5 | import { convertClipboard } from '../clipboard'; 6 | import { bgLog } from '../logger'; 7 | import { addDomainToRules, type ActionMenuId } from './browser-action'; 8 | 9 | export const listenMenusEvent = () => { 10 | browser.contextMenus.onClicked.addListener(async (info, tab) => { 11 | bgLog('[BG_RECEIVED_MENU_EVENT]: ', { info, tab }); 12 | 13 | switch (info.menuItemId as ActionMenuId | ContextMenuChildrenId) { 14 | case 'domain_disabled': 15 | return tab && addDomainToRules('disabled', tab); 16 | case 'domain_s2t': 17 | return tab && addDomainToRules(LangType.s2t, tab); 18 | case 'domain_t2s': 19 | return tab && addDomainToRules(LangType.t2s, tab); 20 | case 'options': 21 | return browser.runtime.openOptionsPage(); 22 | case 'clipboard_s2t': 23 | return convertClipboard(LangType.s2t); 24 | case 'clipboard_t2s': 25 | return convertClipboard(LangType.t2s); 26 | case 'textarea_s2t': 27 | return typeof tab?.id === 'number' && dispatchCtAction({ type: 'Textarea', payload: LangType.s2t }, tab.id); 28 | case 'textarea_t2s': 29 | return typeof tab?.id === 'number' && dispatchCtAction({ type: 'Textarea', payload: LangType.t2s }, tab.id); 30 | case 'webpage_s2t': 31 | return typeof tab?.id === 'number' && dispatchCtAction({ type: 'Webpage', payload: LangType.s2t }, tab.id); 32 | case 'webpage_t2s': 33 | return typeof tab?.id === 'number' && dispatchCtAction({ type: 'Webpage', payload: LangType.t2s }, tab.id); 34 | } 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /src/options/pages/word/WordEntryList.tsx: -------------------------------------------------------------------------------- 1 | import type { FC} from 'react'; 2 | import { useCallback } from 'react'; 3 | import type { DicObj } from 'tongwen-core/dictionaries'; 4 | import { i18n } from '../../../service/i18n/i18n'; 5 | import { Button } from '../../components'; 6 | 7 | const WordEntryRow: FC<{ 8 | index: number; 9 | entry: [string, string]; 10 | onEdit: (entry: [string, string]) => void; 11 | onRemove: (key: string) => void; 12 | }> = ({ index, entry, onEdit: handleEdit, onRemove: handleRemove }) => { 13 | const edit = useCallback(() => { handleEdit(entry); }, [entry]); 14 | const remove = useCallback(() => { handleRemove(entry[0]); }, [entry]); 15 | 16 | return ( 17 | 18 | {index + 1} 19 | {`${entry[0]} => ${entry[1]}`} 20 | 21 | 24 | 25 | 26 | 29 | 30 | 31 | ); 32 | }; 33 | 34 | export const WordEntryList: FC<{ 35 | words: DicObj; 36 | onEdit: (entry: [string, string]) => void; 37 | onRemove: (key: string) => void; 38 | }> = ({ words, onRemove: handleRemove, onEdit: handleEdit }) => { 39 | return ( 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | {Object.entries(words).map((entry, index) => ( 51 | 52 | ))} 53 | 54 |
#{i18n.getMessage('MSG_WORD')}
55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /src/preference/upgrade/pref-gc-v1-to-v2.ts: -------------------------------------------------------------------------------- 1 | import { LangType } from 'tongwen-core/dictionaries'; 2 | import { getRandomId } from '../../utilities'; 3 | import { getDefaultPref } from '../default'; 4 | import { patternRegExpify, regularOldPattern } from '../filter-rule'; 5 | import type { PrefGcV1, PrefGcV1FilterRule } from '../types/v1'; 6 | import type { FilterTarget, PrefFilterRule, PrefV2 } from '../types/v2'; 7 | 8 | const TargetConverter = { 9 | none: 'disabled' as const, 10 | trad: LangType.s2t, 11 | simp: LangType.t2s, 12 | auto: 'auto' as const, 13 | }; 14 | 15 | const filterList2Rules = (rules: PrefGcV1FilterRule[]): PrefFilterRule[] => 16 | rules.map(rule => { 17 | const pattern = regularOldPattern(rule.url); 18 | return { 19 | id: getRandomId(), 20 | pattern, 21 | target: TargetConverter[rule.zhflag] as FilterTarget, 22 | regexp: patternRegExpify(pattern), 23 | }; 24 | }); 25 | 26 | export function prefGcV1ToV2(v1Pref: PrefGcV1): PrefV2 { 27 | const pref = getDefaultPref(); 28 | return { 29 | version: 2, 30 | meta: { update: Date.now() }, 31 | general: { 32 | autoConvert: TargetConverter[v1Pref.autoConvert], 33 | browserAction: TargetConverter[v1Pref.iconAction], 34 | defaultTarget: LangType.s2t, 35 | spaMode: true, 36 | updateLangAttr: false, 37 | debugMode: false, 38 | }, 39 | menu: { 40 | enabled: v1Pref.contextMenu.enable, 41 | group: { 42 | textarea: { s2t: true, t2s: true }, 43 | webpage: { s2t: true, t2s: true }, 44 | }, 45 | }, 46 | filter: { 47 | enabled: v1Pref.urlFilter.enable, 48 | rules: filterList2Rules(v1Pref.urlFilter.list), 49 | }, 50 | word: { 51 | default: pref.word.default, 52 | custom: { 53 | s2t: v1Pref.userPhrase.trad, 54 | t2s: v1Pref.userPhrase.simp, 55 | }, 56 | }, 57 | }; 58 | } 59 | -------------------------------------------------------------------------------- /src/preference/filter-rule/index.ts: -------------------------------------------------------------------------------- 1 | import { getRandomId } from '../../utilities'; 2 | import type { Pref } from '../types/lastest'; 3 | import type { PrefFilterRule, RegExpMaybe } from '../types/v2'; 4 | 5 | export const REGEXP_PATTERN = /^\/(.+)\/([gimuy]{0,5})$/; 6 | export const DOMAIN_PATTERN = /^[\w-]+\.([\w-]+\.)*[\w-]+$/; 7 | 8 | export const isUrlLike = (pattern: string) => /https?:(\/?\/?)[^\s]+/.test(pattern); 9 | export const isDomainLike = (pattern: string) => DOMAIN_PATTERN.test(pattern); 10 | export const isRegExpLike = (pattern: string) => REGEXP_PATTERN.test(pattern); 11 | export const isFilterPatternValid = (pattern: string) => isDomainLike(pattern) || isRegExpLike(pattern); 12 | 13 | const escapeRegex = (str: string): string => str.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); 14 | 15 | const createRegExpWithRegexLike = (pattern: string): RegExpMaybe => { 16 | try { 17 | const [, body = '', options = ''] = REGEXP_PATTERN.exec(pattern.trim()) || []; 18 | return new RegExp(body, [...new Set(options)].join('')); 19 | } catch { 20 | return null; 21 | } 22 | }; 23 | 24 | const createRegExpWithDomainLike = (pattern: string): RegExpMaybe => { 25 | try { 26 | return new RegExp(escapeRegex(pattern)); 27 | } catch { 28 | return null; 29 | } 30 | }; 31 | 32 | export const patternRegExpify = (pattern: string): RegExpMaybe => 33 | isRegExpLike(pattern) ? createRegExpWithRegexLike(pattern) : createRegExpWithDomainLike(pattern); 34 | 35 | export const regularOldPattern = (pattern: string) => `/${pattern.replace(/(\W)/g, '\\$1').replace(/\\\*/g, '.*')}/`; 36 | 37 | export const patchFilterRulesRegExp = (filter: Pref['filter']): Pref['filter'] => { 38 | return { ...filter, rules: filter.rules.map(r => ({ ...r, regexp: patternRegExpify(r.pattern) })) }; 39 | }; 40 | 41 | export const createFilterRule = (): PrefFilterRule => ({ 42 | id: getRandomId(), 43 | pattern: '', 44 | target: 'disabled', 45 | regexp: null, 46 | }); 47 | -------------------------------------------------------------------------------- /src/options/pages/general/GeneralSettings.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | import { i18n } from '../../../service/i18n/i18n'; 3 | import { Checkbox, Select } from '../../components'; 4 | import { autoConvertOptions, browserActionOptions, defaultTargetOptions } from '../../hooks/general/options'; 5 | import { useGeneralOpt } from '../../hooks/general/use-general-opt'; 6 | 7 | export const GeneralSettings: FC = () => { 8 | const { general, setAutoConvert, setBrowserAction, setDefaultTarget, setSpaMode, setUpdateLangAttr, setDebugMode } = 9 | useGeneralOpt(); 10 | 11 | return ( 12 |
13 | 21 | 29 | 37 | 43 | 49 | 55 | 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /src/preference/upgrade/pref-fx-v1-to-v2.ts: -------------------------------------------------------------------------------- 1 | import { LangType } from 'tongwen-core/dictionaries'; 2 | import { getRandomId } from '../../utilities'; 3 | import { getDefaultPref } from '../default'; 4 | import { patternRegExpify, regularOldPattern } from '../filter-rule'; 5 | import type { PrefFxV1, PrefFxV1Filter} from '../types/v1'; 6 | import { V1PrefFxActionEnum, V1PrefFxAutoConverterEnum } from '../types/v1'; 7 | import type { AutoConvertOpt, BrowserActionOpt, FilterTarget, PrefFilterRule, PrefV2 } from '../types/v2'; 8 | 9 | const filters2Rules = (rules: PrefFxV1Filter[]): PrefFilterRule[] => 10 | rules.map(rule => { 11 | const pattern = regularOldPattern(rule.url); 12 | return { 13 | id: getRandomId(), 14 | pattern, 15 | target: V1PrefFxActionEnum[rule.action] as FilterTarget, 16 | regexp: patternRegExpify(pattern), 17 | }; 18 | }); 19 | 20 | // TODO: fallback handler 21 | export function prefFxV1ToV2(v1Pref: PrefFxV1): PrefV2 { 22 | const pref = getDefaultPref(); 23 | return { 24 | version: 2, 25 | meta: { update: Date.now() }, 26 | general: { 27 | autoConvert: V1PrefFxAutoConverterEnum[v1Pref.autoConvert] as AutoConvertOpt, 28 | browserAction: V1PrefFxActionEnum[v1Pref.iconAction] as BrowserActionOpt, 29 | defaultTarget: LangType.s2t, 30 | spaMode: true, 31 | updateLangAttr: false, 32 | debugMode: false, 33 | }, 34 | menu: { 35 | enabled: v1Pref.contextMenuEnabled, 36 | group: { 37 | textarea: { 38 | s2t: v1Pref.contextMenuInput2Trad, 39 | t2s: v1Pref.contextMenuInput2Simp, 40 | }, 41 | webpage: { 42 | s2t: v1Pref.contextMenuPage2Trad, 43 | t2s: v1Pref.contextMenuPage2Simp, 44 | }, 45 | }, 46 | }, 47 | filter: { 48 | enabled: v1Pref.urlFilterEnabled, 49 | rules: filters2Rules(v1Pref.urlFilterList), 50 | }, 51 | word: { 52 | default: pref.word.default, 53 | custom: { 54 | s2t: v1Pref.userPhraseTradList, 55 | t2s: v1Pref.userPhraseSimpList, 56 | }, 57 | }, 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /src/options/hooks/filter/index.ts: -------------------------------------------------------------------------------- 1 | import type { Reducer} from 'react'; 2 | import { useEffect, useReducer, useState } from 'react'; 3 | import { getDefaultPref } from '../../../preference/default'; 4 | import type { PrefFilterRule } from '../../../preference/types/v2'; 5 | import { getStorage } from '../../../service/storage/storage'; 6 | 7 | export type UseFilterRuleAction = 8 | | { type: 'DELETE'; payload: PrefFilterRule } 9 | | { type: 'ADD' | 'UPDATE'; payload: PrefFilterRule } 10 | | { type: 'UP' | 'DOWN'; payload: number } 11 | | { type: 'RESET'; payload: PrefFilterRule[] }; 12 | 13 | const reducer: Reducer = (rules, action) => { 14 | switch (action.type) { 15 | case 'ADD': 16 | return [action.payload, ...rules]; 17 | case 'UPDATE': 18 | return Object.assign([...rules], { [rules.findIndex(r => r.id === action.payload.id)]: action.payload }); 19 | case 'DELETE': 20 | return rules.filter(r => r.id !== action.payload.id); 21 | case 'UP': 22 | return Object.assign([...rules], { 23 | [action.payload - 1]: rules[action.payload], 24 | [action.payload]: rules[action.payload - 1], 25 | }); 26 | case 'DOWN': 27 | return Object.assign([...rules], { 28 | [action.payload]: rules[action.payload + 1], 29 | [action.payload + 1]: rules[action.payload], 30 | }); 31 | case 'RESET': 32 | return action.payload; 33 | } 34 | }; 35 | 36 | const useFilterRules = (org: PrefFilterRule[]) => { 37 | const [rules, setRules] = useReducer>(reducer, org); 38 | 39 | return { rules, setRules }; 40 | }; 41 | 42 | export const useFilter = () => { 43 | const [enabled, setEnable] = useState(getDefaultPref().filter.enabled); 44 | const { rules, setRules } = useFilterRules(getDefaultPref().filter.rules); 45 | 46 | useEffect(() => { 47 | getStorage('filter').then( 48 | ({ filter: { enabled, rules } }) => (setEnable(enabled), setRules({ type: 'RESET', payload: rules })), 49 | ); 50 | }, []); 51 | 52 | return { enabled, setEnable, rules, setRules }; 53 | }; 54 | -------------------------------------------------------------------------------- /src/options/components/layout/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import type { Dispatch, FC} from 'react'; 2 | import { useCallback } from 'react'; 3 | import { i18n } from '../../../service/i18n/i18n'; 4 | import type { PageAction, PageState} from '../../hooks/page'; 5 | import { PageType } from '../../hooks/page'; 6 | 7 | export const Navbar: FC<{ page: PageState; setPage: Dispatch }> = ({ page, setPage }) => { 8 | const setPageWith = useCallback((type: PageType) => () => { setPage({ type }); }, [setPage]); 9 | 10 | const toGeneral = useCallback(setPageWith(PageType.general), [setPageWith]); 11 | const toMenu = useCallback(setPageWith(PageType.menu), [setPageWith]); 12 | const toFilter = useCallback(setPageWith(PageType.filter), [setPageWith]); 13 | const toWord = useCallback(setPageWith(PageType.word), [setPageWith]); 14 | const toAbout = useCallback(setPageWith(PageType.about), [setPageWith]); 15 | 16 | const isActive = useCallback((type: PageType) => (type === page.type ? 'active' : ''), [page]); 17 | 18 | return ( 19 |
48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /src/options/pages/about/AboutPage.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | import { i18n } from '../../../service/i18n/i18n'; 3 | import { Card, CardBody, CardFooter, CardHeader, Page } from '../../components'; 4 | 5 | export const AboutPage: FC = () => { 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 |

13 | {i18n.getMessage('MSG_REPOSITORY')} 14 | 15 | 16 | 17 |

18 |

19 | {i18n.getMessage('MSG_ISSUE_REPORT')} 20 | 21 | 22 | 23 |

24 |

25 | {i18n.getMessage('MSG_CHANGELOG')} 26 | 31 | 32 | 33 |

34 |

35 | {i18n.getMessage('MSG_CONTRIBUTORS')} 36 | 41 | 42 | 43 |

44 |

45 | {i18n.getMessage('MSG_LICENSE')} 46 | 51 | 52 | 53 |

54 |
55 | 56 |
57 |
58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /src/background/state/storage.ts: -------------------------------------------------------------------------------- 1 | import { patchFilterRulesRegExp } from '../../preference/filter-rule'; 2 | import type { Pref } from '../../preference/types/lastest'; 3 | import { setBadge } from '../../service/browser-action/set-badge'; 4 | import { createMenu } from '../../service/menu/create-menu'; 5 | import { getStorage, initialStorage, setStorage, type StorageChanges } from '../../service/storage/storage'; 6 | 7 | let state: Pref | undefined; 8 | let queue: Promise | null = null; 9 | 10 | export const bgInitialPref = async () => { 11 | return (queue = initialStorage().then(pref => (state = { ...pref, filter: patchFilterRulesRegExp(pref.filter) }))); 12 | }; 13 | 14 | export const bgGetPref = async () => { 15 | return state 16 | ? Promise.resolve(state) 17 | : queue 18 | ? queue 19 | : (queue = getStorage().then(p => (state = { ...p, filter: patchFilterRulesRegExp(p.filter) }))); 20 | }; 21 | 22 | export const bgSetPref = async (...args: Parameters) => { 23 | return setStorage(...args).then(async () => (queue = getStorage().then(p => (state = p)))); 24 | }; 25 | 26 | export const bgHandlePrefUpdate = (changes: StorageChanges): void => { 27 | Object.entries(changes).forEach(async ([key, change]) => { 28 | switch (key as keyof Pref) { 29 | case 'general': 30 | if (change.newValue) { 31 | const general = change.newValue as Pref['general']; 32 | state && (state.general = general); 33 | setBadge(general); 34 | } 35 | break; 36 | case 'menu': 37 | if (change.newValue) { 38 | const menu = change.newValue as Pref['menu']; 39 | state && (state.menu = menu); 40 | await createMenu(menu); 41 | } 42 | break; 43 | case 'filter': 44 | if (change.newValue) { 45 | const filter = change.newValue as Pref['filter']; 46 | state && (state.filter = patchFilterRulesRegExp(filter)); 47 | } 48 | break; 49 | case 'word': 50 | if (change.newValue) { 51 | const word = change.newValue as Pref['word']; 52 | state && (state.word = word); 53 | } 54 | break; 55 | } 56 | }); 57 | }; 58 | -------------------------------------------------------------------------------- /src/options/hooks/menu/index.ts: -------------------------------------------------------------------------------- 1 | import type { ChangeEventHandler } from 'react'; 2 | import { useEffect, useState } from 'react'; 3 | import { LangType } from 'tongwen-core/dictionaries'; 4 | import { getDefaultPref } from '../../../preference/default'; 5 | import type { Pref } from '../../../preference/types/lastest'; 6 | import { getStorage, listenStorage, setStorage } from '../../../service/storage/storage'; 7 | 8 | export const useMenu = () => { 9 | const [menu, set] = useState(getDefaultPref().menu); 10 | 11 | const setMenuEnable: ChangeEventHandler = async e => 12 | setStorage({ menu: { ...menu, enabled: e.currentTarget.checked } }); 13 | const setWebS2t: ChangeEventHandler = async e => 14 | setStorage({ 15 | menu: { 16 | ...menu, 17 | group: { ...menu.group, webpage: { ...menu.group.webpage, [LangType.s2t]: e.currentTarget.checked } }, 18 | }, 19 | }); 20 | const setWebT2s: ChangeEventHandler = async e => 21 | setStorage({ 22 | menu: { 23 | ...menu, 24 | group: { ...menu.group, webpage: { ...menu.group.webpage, [LangType.t2s]: e.currentTarget.checked } }, 25 | }, 26 | }); 27 | const setTextS2t: ChangeEventHandler = async e => 28 | setStorage({ 29 | menu: { 30 | ...menu, 31 | group: { ...menu.group, textarea: { ...menu.group.textarea, [LangType.s2t]: e.currentTarget.checked } }, 32 | }, 33 | }); 34 | const setTextT2s: ChangeEventHandler = async e => 35 | setStorage({ 36 | menu: { 37 | ...menu, 38 | group: { ...menu.group, textarea: { ...menu.group.textarea, [LangType.t2s]: e.currentTarget.checked } }, 39 | }, 40 | }); 41 | 42 | useEffect( 43 | () => 44 | listenStorage( 45 | changes => { 46 | set(changes.menu?.newValue as Pref['menu']); 47 | }, 48 | { keys: ['menu'], areaName: ['local'] }, 49 | ), 50 | [], 51 | ); 52 | 53 | useEffect(() => { 54 | getStorage('menu').then(({ menu }) => { 55 | set(menu); 56 | }); 57 | }, []); 58 | 59 | return { menu, setMenuEnable, setWebS2t, setWebT2s, setTextS2t, setTextT2s }; 60 | }; 61 | -------------------------------------------------------------------------------- /src/service/menu/create-menu.ts: -------------------------------------------------------------------------------- 1 | import type { LangType } from 'tongwen-core/dictionaries'; 2 | import type { Menus } from 'webextension-polyfill'; 3 | import { getSessionState, setSessionState } from '../../background/session'; 4 | import type { Pref } from '../../preference/types/lastest'; 5 | import type { PrefMenuGroupKeys, PrefMenuOptions } from '../../preference/types/v2'; 6 | import { browser } from '../browser'; 7 | import { i18n } from '../i18n/i18n'; 8 | import { getSubMenuContexts, getTopMenuContexts } from './determine-context'; 9 | 10 | export type MenuId = string | number; 11 | export type ContextMenuChildrenId = `${PrefMenuGroupKeys}_${LangType}`; 12 | 13 | export const TOP_CONTEXT_MENU_ID = 'top_context_menu_id'; 14 | 15 | function createSubMenu(parentId: MenuId, funcKey: PrefMenuGroupKeys, settings: PrefMenuOptions) { 16 | Object.entries(settings) 17 | .filter(([, enabled]) => enabled) 18 | .forEach(([target]) => { 19 | const menuProps: Menus.CreateCreatePropertiesType = { 20 | parentId, 21 | id: `${funcKey}_${target}`, 22 | type: 'normal', 23 | title: i18n.getMessage(`MSG_${funcKey}_${target}`), 24 | contexts: getSubMenuContexts(funcKey), 25 | }; 26 | 27 | browser.contextMenus.create(menuProps); 28 | }); 29 | } 30 | 31 | export async function createMenu(menu: Pref['menu']): Promise { 32 | return getSessionState() 33 | .then(async state => (state.menuId != null ? browser.contextMenus.remove(state.menuId) : Promise.resolve(null))) 34 | .then(() => menu.enabled && getTopMenuContexts(menu.group)) 35 | .then( 36 | contexts => 37 | contexts && 38 | browser.contextMenus.create({ 39 | id: TOP_CONTEXT_MENU_ID, 40 | type: 'normal', 41 | title: i18n.getMessage('MSG_EXT_NAME'), 42 | contexts, 43 | }), 44 | ) 45 | .then(async menuId => 46 | menuId === false 47 | ? Promise.resolve(null) 48 | : Promise.all([ 49 | setSessionState({ menuId }), 50 | Object.entries(menu.group).forEach(([funcKey, settings]) => 51 | { createSubMenu(menuId, funcKey as PrefMenuGroupKeys, settings); }, 52 | ), 53 | ]), 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/options/hooks/general/use-general-opt.ts: -------------------------------------------------------------------------------- 1 | import type { ChangeEventHandler } from 'react'; 2 | import { useEffect, useState } from 'react'; 3 | import type { LangType } from 'tongwen-core/dictionaries'; 4 | import { getDefaultPref } from '../../../preference/default'; 5 | import type { Pref } from '../../../preference/types/lastest'; 6 | import type { AutoConvertOpt, BrowserActionOpt, PrefGeneral } from '../../../preference/types/v2'; 7 | import { getStorage, listenStorage, setStorage } from '../../../service/storage/storage'; 8 | 9 | export const useGeneralOpt = () => { 10 | const [general, set] = useState(getDefaultPref().general); 11 | 12 | const setGeneral = async (key: T, value: PrefGeneral[T]) => 13 | setStorage({ general: { ...general, [key]: value } }); 14 | const setAutoConvert: ChangeEventHandler = e => 15 | void setGeneral('autoConvert', e.currentTarget.value as AutoConvertOpt); 16 | const setBrowserAction: ChangeEventHandler = e => 17 | void setGeneral('browserAction', e.currentTarget.value as BrowserActionOpt); 18 | const setDefaultTarget: ChangeEventHandler = e => 19 | void setGeneral('defaultTarget', e.currentTarget.value as LangType); 20 | const setSpaMode: ChangeEventHandler = e => void setGeneral('spaMode', e.currentTarget.checked); 21 | const setUpdateLangAttr: ChangeEventHandler = e => 22 | void setGeneral('updateLangAttr', e.currentTarget.checked); 23 | const setDebugMode: ChangeEventHandler = e => void setGeneral('debugMode', e.currentTarget.checked); 24 | 25 | useEffect( 26 | () => 27 | listenStorage(changes => changes.general?.newValue && set(changes.general.newValue as Pref['general']), { 28 | keys: ['general'], 29 | areaName: ['local'], 30 | }), 31 | [], 32 | ); 33 | 34 | useEffect(() => { 35 | getStorage('general').then(({ general }) => { 36 | set(general); 37 | }); 38 | }, []); 39 | 40 | return { 41 | general, 42 | setGeneral, 43 | setAutoConvert, 44 | setBrowserAction, 45 | setDefaultTarget, 46 | setSpaMode, 47 | setUpdateLangAttr, 48 | setDebugMode, 49 | }; 50 | }; 51 | -------------------------------------------------------------------------------- /src/service/runtime/interface.ts: -------------------------------------------------------------------------------- 1 | import type { LangType } from 'tongwen-core/dictionaries'; 2 | import type { MaybeTransTarget } from '../../preference/types/types'; 3 | import type { FilterTarget } from '../../preference/types/v2'; 4 | import type { ZhType } from '../tabs/tabs.constant'; 5 | 6 | interface WeReqAction { 7 | type: T; 8 | payload: P; 9 | } 10 | 11 | /* req and res for background */ 12 | export enum BgActType { 13 | Convert = 'CONVERT', 14 | NodesText = 'NODES_TEXT', 15 | ConvertClipboard = 'CONVERT_CLIPBOARD', 16 | DetectLang = 'DETECT_LANG', 17 | FilterTarget = 'FILTER_TARGET', 18 | GetTarget = 'GET_TARGET', 19 | AutoConvertOpt = 'AUTO_CONVERT_OPT', 20 | SpaMode = 'SPA_MODE', 21 | Log = 'Log', 22 | } 23 | 24 | export type BgAct = 25 | | BgActConvert 26 | | BgActNodeText 27 | | BgActDetectLang 28 | | BgActFilterTarget 29 | | BgActAutoConvert 30 | | BgActConvertClipboard 31 | | BgActGetTarget 32 | | BgActSpaMode 33 | | BgActLog; 34 | 35 | export type BgActConvert = WeReqAction; 36 | 37 | export type BgActNodeText = WeReqAction; 38 | 39 | export type BgActDetectLang = WeReqAction; 40 | 41 | export type BgActFilterTarget = WeReqAction; 42 | 43 | export type BgActGetTarget = WeReqAction; 44 | 45 | export type BgActAutoConvert = WeReqAction; 46 | 47 | export type BgActConvertClipboard = WeReqAction; 48 | 49 | export type BgActSpaMode = WeReqAction; 50 | 51 | export type BgActLog = WeReqAction; 52 | 53 | /* req and res for content */ 54 | export enum CtActType { 55 | Textarea = 'TEXTAREA', 56 | Webpage = 'WEBPAGE', 57 | ZhType = 'ZHTYPE', 58 | } 59 | 60 | export type CtAct = CtActTextArea | CtActWebPage | CtActZhType; 61 | 62 | export type CtActTextArea = WeReqAction; 63 | 64 | export type CtActWebPage = WeReqAction; 65 | 66 | export type CtActZhType = WeReqAction; 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tongwentang-extension", 3 | "version": "2.4.0", 4 | "description": "", 5 | "keywords": [], 6 | "author": "t7yang", 7 | "license": "MIT", 8 | "scripts": { 9 | "dev:firefox": "rspack build --mode=development --env vendor=firefox", 10 | "dev:chromium": "rspack build --mode=development --env vendor=chromium", 11 | "we:firefox": "web-ext run --config=web-ext-config-firefox.mjs --no-config-discovery", 12 | "we:chromium": "web-ext run --config=web-ext-config-chrome.mjs --no-config-discovery --target=chromium", 13 | "test:tsc": "tsc --noEmit", 14 | "build:firefox": "rspack build --mode=production --env vendor=firefox", 15 | "build:chromium": "rspack build --mode=production --env vendor=chromium", 16 | "build:all": "npm run build:firefox && npm run build:chromium", 17 | "format": "prettier . --write --ignore-unknown", 18 | "lint": "eslint --fix", 19 | "release": "standard-version -t '' --packageFiles package.json", 20 | "update": "ncu -i --format group", 21 | "we:build": "web-ext build -s dist/firefox", 22 | "we:sign": "web-ext sign --config=web-ext-config-firefox.mjs --no-config-discovery", 23 | "prepare": "husky" 24 | }, 25 | "nano-staged": { 26 | "*.{js,jsx,ts,tsx,json,md}": [ 27 | "prettier --write --ignore-unknown" 28 | ], 29 | "*.{ts,tsx}": [ 30 | "eslint --fix" 31 | ] 32 | }, 33 | "dependencies": { 34 | "data-fixer": "^5.0.1", 35 | "react": "^18.3.1", 36 | "react-dom": "^18.3.1", 37 | "spectre.css": "^0.5.9", 38 | "tongwen-core": "^5.0.0-beta-1", 39 | "webextension-polyfill": "^0.12.0", 40 | "zod": "^3.24.1" 41 | }, 42 | "devDependencies": { 43 | "@eslint/js": "^9.17.0", 44 | "@rspack/cli": "^1.1.8", 45 | "@rspack/core": "^1.1.8", 46 | "@types/node": "^22.10.2", 47 | "@types/react": "^18.3.12", 48 | "@types/react-dom": "^18.3.1", 49 | "@types/webextension-polyfill": "^0.12.1", 50 | "dotenv": "^16.4.7", 51 | "eslint": "^9.17.0", 52 | "eslint-plugin-react": "^7.37.3", 53 | "globals": "^15.14.0", 54 | "husky": "^9.1.7", 55 | "nano-staged": "^0.8.0", 56 | "prettier": "^3.4.2", 57 | "standard-version": "^9.5.0", 58 | "tongwen-dict": "^1.0.2", 59 | "typescript": "~5.7.2", 60 | "typescript-eslint": "^8.18.2", 61 | "web-ext": "^8.3.0" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/background/runtime/mount-runtime-listener.ts: -------------------------------------------------------------------------------- 1 | import { browser } from '../../service/browser'; 2 | import type { BgReqAction } from '../../service/runtime/background'; 3 | import { handleBgReqAction } from '../../service/runtime/background'; 4 | import { detectLanguage } from '../../service/tabs/detect-language'; 5 | import { convertClipboard } from '../clipboard'; 6 | import { getConverter } from '../converter'; 7 | import { bgLog } from '../logger'; 8 | import { bgGetPref } from '../state/storage'; 9 | import { getTargetByAutoConvert } from './handle-get-auto-convert'; 10 | import { getTargetByFilter } from './handle-get-filter-target'; 11 | import { getTarget } from './handle-get-target'; 12 | 13 | /** 14 | * background message handler 15 | */ 16 | export function mountRuntimeListener() { 17 | browser.runtime.onMessage.addListener(async (message, sender) => { 18 | const action = message as BgReqAction; 19 | bgLog('[BG_RECEIVE_REQ] req:', action, 'sender:', sender); 20 | 21 | return bgGetPref().then(async pref => { 22 | switch (action.type) { 23 | case 'AutoConvert': 24 | return handleBgReqAction(action, getTargetByAutoConvert(sender.tab!.id!)); 25 | case 'FilterTarget': 26 | return handleBgReqAction(action, getTargetByFilter(pref, sender.url!)); 27 | case 'GetTarget': 28 | return handleBgReqAction(action, getTarget(pref, sender)); 29 | case 'DetectLang': 30 | return handleBgReqAction(action, detectLanguage(sender.tab!.id)); 31 | case 'NodesText': 32 | return getConverter().then(async converter => 33 | handleBgReqAction( 34 | action, 35 | action.payload.texts.map(text => converter.phrase(action.payload.target, text)), 36 | ), 37 | ); 38 | case 'Convert': 39 | return getConverter().then(async converter => 40 | handleBgReqAction(action, converter.phrase(action.payload.target, action.payload.text)), 41 | ); 42 | case 'ConvertClipboard': 43 | return handleBgReqAction(action, convertClipboard(action.payload)); 44 | case 'SpaMode': 45 | return handleBgReqAction(action, pref.general.spaMode); 46 | case 'Log': 47 | bgLog(...action.payload); 48 | return handleBgReqAction(action, undefined); 49 | } 50 | }); 51 | }); 52 | } 53 | -------------------------------------------------------------------------------- /src/options/pages/general/Preferences.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, RefObject, SyntheticEvent} from 'react'; 2 | import { useCallback, useRef } from 'react'; 3 | import { i18n } from '../../../service/i18n/i18n'; 4 | import { exportPref } from '../../../service/storage/export-pref'; 5 | import { importPref } from '../../../service/storage/import-pref'; 6 | import { confirmResetPref, confirmResetPrefKeep } from '../../../service/storage/reset-pref'; 7 | import { BROWSER_TYPE } from '../../../service/types'; 8 | import { Button } from '../../components'; 9 | 10 | const onload = (event: ProgressEvent) => { 11 | importPref(BROWSER_TYPE, event.target!.result! as string); 12 | }; 13 | 14 | export const Preferences: FC = () => { 15 | const ref = useRef(); 16 | 17 | const showDialog = useCallback(() => ref.current?.click(), [ref.current]); 18 | 19 | const handleLoad = useCallback((_: SyntheticEvent) => { 20 | const reader = new FileReader(); 21 | reader.onload = onload; 22 | reader.readAsText(ref.current!.files![0]); 23 | }, []); 24 | 25 | return ( 26 |
27 |
28 |
29 | } 35 | onChange={handleLoad} 36 | /> 37 | 40 |
41 |
42 | 45 |
46 |
47 | 50 |
51 |
52 | 55 |
56 |
57 |
58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /src/options/pages/word/WordDefaultSettings.tsx: -------------------------------------------------------------------------------- 1 | import type { ChangeEventHandler, FC } from 'react'; 2 | import { Fragment, useCallback } from 'react'; 3 | import type { PrefWordDefault } from '../../../preference/types/v2'; 4 | import { i18n } from '../../../service/i18n/i18n'; 5 | import { Button } from '../../components'; 6 | import { Checkbox } from '../../components/forms'; 7 | 8 | export const WordDefaultSettings: FC<{ 9 | value: PrefWordDefault; 10 | onChange: (d: PrefWordDefault) => void; 11 | onSave: () => Promise; 12 | }> = ({ value: defWord, onChange: handleChange, onSave: handleSave }) => { 13 | const upSc: ChangeEventHandler = useCallback( 14 | e => { 15 | ((d, char) => { 16 | handleChange(((d.s2t = { ...d.s2t, char }), d)); 17 | })({ ...defWord }, e.currentTarget.checked); 18 | }, 19 | [handleChange, defWord], 20 | ); 21 | const upSp: ChangeEventHandler = useCallback( 22 | e => { 23 | ((d, phrase) => { 24 | handleChange(((d.s2t = { ...d.s2t, phrase }), d)); 25 | })({ ...defWord }, e.currentTarget.checked); 26 | }, 27 | [handleChange, defWord], 28 | ); 29 | const upTc: ChangeEventHandler = useCallback( 30 | e => { 31 | ((d, char) => { 32 | handleChange(((d.t2s = { ...d.t2s, char }), d)); 33 | })({ ...defWord }, e.currentTarget.checked); 34 | }, 35 | [handleChange, defWord], 36 | ); 37 | const upTp: ChangeEventHandler = useCallback( 38 | e => { 39 | ((d, phrase) => { 40 | handleChange(((d.t2s = { ...d.t2s, phrase }), d)); 41 | })({ ...defWord }, e.currentTarget.checked); 42 | }, 43 | [handleChange, defWord], 44 | ); 45 | 46 | return ( 47 | 48 | 54 | 60 | 66 | 72 | 75 | 76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /src/service/storage/storage.ts: -------------------------------------------------------------------------------- 1 | import type { Storage } from 'webextension-polyfill'; 2 | import browser from 'webextension-polyfill'; 3 | import { getDefaultPref } from '../../preference/default'; 4 | import type { Pref, PrefKeys, PrefPick } from '../../preference/types/lastest'; 5 | import { safeUpgradePref, validatePref } from '../../preference/upgrade'; 6 | import { BROWSER_TYPE } from '../types'; 7 | 8 | type StorageAreaName = Exclude; 9 | export type StorageChanges = Partial<{ [P in PrefKeys]: Storage.StorageChange }>; 10 | type StorageListener = (store: StorageChanges, areaName: A) => void; 11 | 12 | const updatePrefTime = (pref: Partial): Partial => ({ ...pref, meta: { update: Date.now() } }); 13 | 14 | export const getStorage = async ( 15 | keys?: T, 16 | ): Promise> => { 17 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 18 | return browser.storage.local.get(keys) as any; 19 | }; 20 | 21 | export const setStorage = async (data: Partial): Promise => browser.storage.local.set(updatePrefTime(data)); 22 | 23 | /** 24 | * reset pref, if undefined or null given, reset to default pref 25 | */ 26 | export const resetStorage = async (pref?: Pref) => { 27 | return browser.storage.local 28 | .clear() 29 | .then(async () => browser.storage.local.set((pref as unknown as Record) || getDefaultPref())); 30 | }; 31 | 32 | export const listenStorage = ( 33 | listener: StorageListener, 34 | opt: Partial<{ keys: PKey[]; areaName: AreaName[] }> = {}, 35 | ): (() => void) => { 36 | const wrapper = (changes: StorageChanges, areaName: StorageAreaName) => { 37 | opt.areaName && !opt.areaName.includes(areaName as AreaName) 38 | ? null 39 | : !Array.isArray(opt.keys) 40 | ? listener(changes, areaName as AreaName) 41 | : Object.keys(changes).some(key => opt.keys?.includes(key as PKey)) 42 | ? listener(changes, areaName as AreaName) 43 | : null; 44 | }; 45 | 46 | browser.storage.onChanged.addListener(wrapper as never); 47 | return () => { 48 | browser.storage.onChanged.removeListener(wrapper as never); 49 | }; 50 | }; 51 | 52 | export const initialStorage = async (): Promise => { 53 | return browser.storage.local 54 | .get() 55 | .then(validatePref(BROWSER_TYPE)) 56 | .then(async holder => (holder.invalid && (await browser.storage.local.clear()), holder.value())) 57 | .then(pref => safeUpgradePref(BROWSER_TYPE, pref)) 58 | .then(async pref => (await browser.storage.local.set(pref as unknown as Record), pref)); 59 | }; 60 | -------------------------------------------------------------------------------- /src/background/menu/browser-action.ts: -------------------------------------------------------------------------------- 1 | import { LangType } from 'tongwen-core/dictionaries'; 2 | import { isUrlLike } from '../../preference/filter-rule'; 3 | import type { FilterTarget } from '../../preference/types/v2'; 4 | import { browser } from '../../service/browser'; 5 | import { i18n } from '../../service/i18n/i18n'; 6 | import { addFilterRule } from '../../service/storage/local'; 7 | import { getHostName, getRandomId } from '../../utilities'; 8 | import { getSessionState, setSessionState } from '../session'; 9 | 10 | // TODO: handle for none http protocol url 11 | export const addDomainToRules = (target: FilterTarget, tab: browser.Tabs.Tab) => { 12 | isUrlLike(tab.url!) && 13 | addFilterRule({ 14 | target, 15 | regexp: null, 16 | id: getRandomId(), 17 | pattern: getHostName(tab.url!), 18 | }); 19 | }; 20 | 21 | export type ActionMenuId = ( 22 | | ReturnType 23 | | ReturnType 24 | )[number]['id']; 25 | 26 | const createBrowserActionProperties = () => 27 | [ 28 | { 29 | id: 'domain_disabled', 30 | title: i18n.getMessage('MSG_ADD_DOMAIN_TO_DISABLED'), 31 | }, 32 | { 33 | id: `domain_${LangType.s2t}`, 34 | title: i18n.getMessage('MSG_ADD_DOMAIN_TO_S2T'), 35 | }, 36 | { 37 | id: `domain_${LangType.t2s}`, 38 | title: i18n.getMessage('MSG_ADD_DOMAIN_TO_T2S'), 39 | }, 40 | { 41 | id: 'options', 42 | title: i18n.getMessage('MSG_OPTION'), 43 | }, 44 | ] as const satisfies browser.Menus.CreateCreatePropertiesType[]; 45 | 46 | const createClipboardProperties = () => 47 | [ 48 | { 49 | id: `clipboard_${LangType.s2t}`, 50 | title: i18n.getMessage('MSG_CONVERT_CLIPBOARD_S2T'), 51 | }, 52 | { 53 | id: `clipboard_${LangType.t2s}`, 54 | title: i18n.getMessage('MSG_CONVERT_CLIPBOARD_T2S'), 55 | }, 56 | ] as const satisfies browser.Menus.CreateCreatePropertiesType[]; 57 | 58 | // TODO: need icon 59 | export async function createBrowserActionMenus(): Promise { 60 | return getSessionState().then(async ({ hasBrowserActionMenu }) => { 61 | if (hasBrowserActionMenu) return; 62 | 63 | const browserActionMenuItems: browser.Menus.CreateCreatePropertiesType[] = [ 64 | ...createBrowserActionProperties(), 65 | ...createClipboardProperties(), 66 | ].map(item => 67 | Object.assign(item, { 68 | type: 'normal', 69 | contexts: ['action'], 70 | } satisfies browser.Menus.CreateCreatePropertiesType), 71 | ); 72 | 73 | const task = browserActionMenuItems.map(item => browser.contextMenus.create(item)); 74 | return Promise.resolve([task, setSessionState({ hasBrowserActionMenu: Promise.resolve(task) })]); 75 | }); 76 | } 77 | -------------------------------------------------------------------------------- /src/options/pages/filter/FilterRuleEditor.tsx: -------------------------------------------------------------------------------- 1 | import type { ChangeEventHandler, FC} from 'react'; 2 | import { useCallback, useEffect, useState } from 'react'; 3 | import { isFilterPatternValid } from '../../../preference/filter-rule'; 4 | import type { FilterTarget, PrefFilterRule } from '../../../preference/types/v2'; 5 | import { i18n } from '../../../service/i18n/i18n'; 6 | import { Button, Select } from '../../components'; 7 | import { FilterRuleTargetOptions } from '../../hooks/filter/options'; 8 | 9 | export const FilterRuleEditor: FC<{ 10 | value: PrefFilterRule; 11 | onSubmit: (rule: PrefFilterRule) => void; 12 | onCancel: () => void; 13 | }> = ({ value: org, onSubmit: handleSubmit, onCancel: handleCancel }) => { 14 | const [rule, setRule] = useState({ ...org }); 15 | 16 | useEffect(() => { setRule({ ...org }); }, [org]); 17 | 18 | const [isError, setError] = useState(false); 19 | 20 | const updatePattern: ChangeEventHandler = useCallback( 21 | e => { (pattern => { setRule(rule => ({ ...rule, pattern })); })(e.currentTarget.value); }, 22 | [], 23 | ); 24 | 25 | const updateTarget: ChangeEventHandler = useCallback( 26 | e => { (target => { setRule(rule => ({ ...rule, target })); })(e.currentTarget.value as FilterTarget); }, 27 | [], 28 | ); 29 | 30 | const submit = useCallback(() => !isError && handleSubmit(rule), [rule, isError]); 31 | 32 | useEffect(() => { setError(!isFilterPatternValid(rule.pattern)); }, [rule.pattern]); 33 | 34 | return ( 35 |
36 |
37 |
38 | 39 | 45 |
46 |
47 |
48 | 51 |
52 |
53 |
54 | 55 | 58 |
59 |
60 |
61 |
62 | 63 | 66 |
67 |
68 |
69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /docs/preferences/preferences.md: -------------------------------------------------------------------------------- 1 | # New TongWenTang Preferences 2 | 3 | There are many settings for customization in preferences page. 4 | 5 | ## General 6 | 7 | Auto Convert 8 | 9 | - Disabled 10 | - Do nothing on page loaded. 11 | - Simplified to Traditional: 12 | - Convert to Traditional Chinese on page loaded. 13 | - Traditional to Simplified: 14 | - Convert to Simplified Chinese on page loaded. 15 | - Detective Simplified to Traditional: 16 | - Convert to Traditional Chinese on page loaded if the web page content regconize as Simplified Chinese. 17 | - Detective Traditional to Simplified: 18 | - Convert to Simplified Chinese on page loaded if the web page content regconize as Traditional Chinese. 19 | 20 | Icon Action 21 | 22 | - Auto: 23 | - Switch page content between Traditional Chinese and Simplified Chinese. 24 | - If the web page content can not regconize by browser API, then extension will convert to default on first time click. 25 | - Traditional Chinese 26 | - Convert to Traditional Chinese each time icon clicked. 27 | - Simplified Chinese 28 | - Convert to Simplified Chinese each time icon clicked. 29 | 30 | Default Convert 31 | 32 | - If icon action set to "Auto" and web page content can not regconize then fallback to this value. 33 | 34 | Dynamic Convert 35 | 36 | - Responsively convert web page content on changed, many website partially update content without refresh whole web page. 37 | 38 | Debug Mode 39 | 40 | - If enabled, some critical information will log into extension console. 41 | - How to open extension console 42 | - Firefox: 43 | - Goto `about:debugging`, switch to `This Firefox` tab, find `New TongWenTang` and click `Inspect`, switch to `console` tab. 44 | - Chrome: 45 | - Goto `chrome://extensions`, find `New TongWenTang` and click `Detail`, click `background page` link under `Inspect Views`. 46 | 47 | ## Context Menu 48 | 49 | Enabled Context Menu 50 | 51 | - Completely disabled or enabled showing commands on web page context menu. 52 | 53 | Others 54 | 55 | - Disabled or enabled for each commands showing on web page context menu. 56 | 57 | ## Domain Rule 58 | 59 | Enabled Domain Rule 60 | 61 | - Completely enabled or disabled this feature. 62 | 63 | Add 64 | 65 | - Trigger Domain Rule editor. 66 | 67 | Save 68 | 69 | - In order to persist all changes made, manually click "Save" button is required. 70 | 71 | Domain Rule 72 | 73 | - Domain Rule can be plain text or regular expression. 74 | - Plain text mean any url that contain this text will trigger convert action. 75 | - Regular expression mean `regexp.test` function call with URL must return `true` in order to trigger convert action. 76 | 77 | ## Word 78 | 79 | Default 80 | 81 | - There are four built-in mapping list which can turn on and off. 82 | 83 | Custom Simplified to Traditional / Custom Traditional to Simplified 84 | 85 | - Add, edit, or remove user custom mapping list. 86 | -------------------------------------------------------------------------------- /manifest.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const { writeFileSync } = require('fs'); 3 | const { resolve } = require('path'); 4 | const pkg = require('./package.json'); 5 | 6 | /** @import { Manifest } from 'webextension-polyfill' */ 7 | 8 | /** 9 | * @param {string | undefined} vendor 10 | * @returns {Manifest.WebExtensionManifest} 11 | */ 12 | const createManifest = vendor => { 13 | const isFirefox = vendor === 'firefox'; 14 | /** @type {Manifest.WebExtensionManifest['browser_specific_settings']} */ 15 | const browser_specific_settings = isFirefox 16 | ? { gecko: { id: 'tongwen@softcup', strict_min_version: '63.0' } } 17 | : undefined; 18 | /** @type {Manifest.WebExtensionManifest['background']} */ 19 | const background = isFirefox ? { scripts: ['background.js'] } : { service_worker: 'background.js' }; 20 | 21 | return { 22 | manifest_version: 3, 23 | name: '__MSG_MSG_EXT_NAME__', 24 | version: pkg.version, 25 | description: '__MSG_MSG_EXT_DESC__', 26 | author: 't7yang', 27 | homepage_url: 'https://github.com/tongwentang/tongwentang-extension', 28 | default_locale: 'en', 29 | browser_specific_settings, 30 | icons: { 31 | 16: 'icons/tongwen-icon-16.png', 32 | 32: 'icons/tongwen-icon-32.png', 33 | 48: 'icons/tongwen-icon-48.png', 34 | 128: 'icons/tongwen-icon-128.png', 35 | }, 36 | permissions: ['contextMenus', 'downloads', 'notifications', 'storage', 'tabs', 'unlimitedStorage'], 37 | optional_permissions: ['clipboardWrite', 'clipboardRead'], 38 | background, 39 | content_scripts: [ 40 | { 41 | matches: [''], 42 | js: ['content.js'], 43 | all_frames: true, 44 | run_at: 'document_idle', 45 | }, 46 | ], 47 | action: { 48 | default_icon: { 49 | 16: 'icons/tongwen-icon-16.png', 50 | 32: 'icons/tongwen-icon-32.png', 51 | 48: 'icons/tongwen-icon-48.png', 52 | 128: 'icons/tongwen-icon-128.png', 53 | }, 54 | }, 55 | options_ui: { 56 | browser_style: true, 57 | open_in_tab: true, 58 | page: 'options.html', 59 | }, 60 | commands: { 61 | w_s2t: { 62 | description: '__MSG_MSG_WEBPAGE_S2T__', 63 | suggested_key: { 64 | default: 'Shift+Alt+C', 65 | }, 66 | }, 67 | w_t2s: { 68 | description: '__MSG_MSG_WEBPAGE_T2S__', 69 | suggested_key: { 70 | default: 'Shift+Alt+V', 71 | }, 72 | }, 73 | c_s2t: { 74 | description: '__MSG_MSG_CONVERT_CLIPBOARD_S2T__', 75 | suggested_key: { 76 | default: 'Shift+Alt+Z', 77 | }, 78 | }, 79 | c_t2s: { 80 | description: '__MSG_MSG_CONVERT_CLIPBOARD_T2S__', 81 | suggested_key: { 82 | default: 'Shift+Alt+X', 83 | }, 84 | }, 85 | }, 86 | }; 87 | }; 88 | 89 | /** 90 | * @param {string} vendor 91 | * @param {string} path 92 | * @returns {void} 93 | */ 94 | const writeManifest = (vendor, path) => { 95 | const manifest = createManifest(vendor); 96 | writeFileSync(resolve(path, 'manifest.json'), JSON.stringify(manifest, null, 2)); 97 | }; 98 | 99 | writeManifest(process.env.vendor || '', process.env.distPath || ''); 100 | -------------------------------------------------------------------------------- /src/options/pages/filter/FilterSettings.tsx: -------------------------------------------------------------------------------- 1 | import type { ChangeEventHandler, FC} from 'react'; 2 | import { Fragment, useCallback, useState } from 'react'; 3 | import { createFilterRule } from '../../../preference/filter-rule'; 4 | import type { PrefFilterRule } from '../../../preference/types/v2'; 5 | import { i18n } from '../../../service/i18n/i18n'; 6 | import { createNoti } from '../../../service/notification/create-noti'; 7 | import { setStorage } from '../../../service/storage/storage'; 8 | import { Button, Checkbox, Modal } from '../../components'; 9 | import { useFilter } from '../../hooks/filter'; 10 | import { useToggle } from '../../hooks/state/use-toggle'; 11 | import { FilterRuleEditor } from './FilterRuleEditor'; 12 | import { FilterRules } from './FilterRules'; 13 | 14 | export const FilterSettings: FC = () => { 15 | const { enabled, setEnable, rules, setRules } = useFilter(); 16 | 17 | const handleEnabledChange: ChangeEventHandler = useCallback( 18 | e => { setEnable(e.currentTarget.checked); }, 19 | [setEnable], 20 | ); 21 | 22 | const [isModal, { on, off }] = useToggle(false); 23 | 24 | const [toEdit, setToEdit] = useState<{ isAdd: boolean; rule: PrefFilterRule }>({ 25 | isAdd: true, 26 | rule: createFilterRule(), 27 | }); 28 | 29 | const handleAdd = useCallback(() => { 30 | setToEdit({ isAdd: true, rule: createFilterRule() }); 31 | on(); 32 | }, [setToEdit, on]); 33 | 34 | const handleUpdate = useCallback( 35 | (rule: PrefFilterRule) => { 36 | setToEdit({ isAdd: false, rule }); 37 | on(); 38 | }, 39 | [setToEdit, on], 40 | ); 41 | 42 | const handleSubmit = useCallback( 43 | (rule: PrefFilterRule) => { 44 | !toEdit.isAdd ? setRules({ type: 'UPDATE', payload: rule }) : setRules({ type: 'ADD', payload: rule }); 45 | off(); 46 | }, 47 | [toEdit, setRules], 48 | ); 49 | 50 | const save = useCallback( 51 | async () => setStorage({ filter: { enabled, rules } }).then(async () => createNoti(i18n.getMessage('MSG_UPDATE_COMPLETED'))), 52 | [enabled, rules], 53 | ); 54 | 55 | return ( 56 | 57 |
58 |
59 |
60 |
61 | 67 |
68 |
69 | 72 |
73 |
74 | 77 |
78 |
79 |
80 | 81 |
82 | 83 |
84 |
85 | 86 | 87 | 88 | 89 |
90 | ); 91 | }; 92 | -------------------------------------------------------------------------------- /rspack.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const path = require('path'); 3 | const { spawn, exec } = require('child_process'); 4 | const { watch } = require('fs'); 5 | const rspack = require('@rspack/core'); 6 | 7 | /** 8 | * @type {(env: Record, argv: Record) => import('@rspack/cli').Configuration} 9 | */ 10 | module.exports = (env, argv) => { 11 | const isProd = argv.mode === 'production'; 12 | const vendor = (process.env.vendor = env.vendor || 'firefox'); 13 | const distPath = (process.env.distPath = path.resolve(__dirname, 'dist', vendor)); 14 | 15 | return { 16 | context: __dirname, 17 | devtool: isProd ? false : 'source-map', 18 | watch: !isProd, 19 | entry: { 20 | background: './src/background/main.ts', 21 | content: './src/content/main.ts', 22 | options: './src/options/index.tsx', 23 | }, 24 | output: { 25 | path: distPath, 26 | filename: '[name].js', 27 | }, 28 | resolve: { extensions: ['.ts', '.tsx', '.js', 'jsx'] }, 29 | module: { 30 | rules: [ 31 | { test: /\.svg$/, type: 'asset' }, 32 | { 33 | test: /\.ts$/, 34 | exclude: [/node_modules/], 35 | loader: 'builtin:swc-loader', 36 | options: { 37 | jsc: { 38 | parser: { syntax: 'typescript' }, 39 | }, 40 | }, 41 | type: 'javascript/auto', 42 | }, 43 | { 44 | test: /\.[jt]sx$/, 45 | use: { 46 | loader: 'builtin:swc-loader', 47 | options: { 48 | jsc: { 49 | parser: { syntax: 'typescript', jsx: true }, 50 | transform: { 51 | react: { 52 | runtime: 'automatic', 53 | throwIfNamespace: true, 54 | development: false, 55 | useBuiltins: false, 56 | }, 57 | }, 58 | }, 59 | }, 60 | }, 61 | type: 'javascript/auto', 62 | }, 63 | ], 64 | }, 65 | plugins: [ 66 | new rspack.HtmlRspackPlugin({ 67 | filename: 'options.html', 68 | template: './src/options/index.html', 69 | chunks: ['options'], 70 | }), 71 | new rspack.CopyRspackPlugin({ 72 | patterns: [ 73 | { from: './src/_locales/', to: '_locales/', toType: 'dir' }, 74 | { from: './node_modules/spectre.css/dist/spectre.min.css' }, 75 | { from: './node_modules/spectre.css/dist/spectre-icons.min.css' }, 76 | { from: './node_modules/spectre.css/dist/spectre-exp.min.css' }, 77 | { from: './src/icons', to: 'icons' }, 78 | { from: './node_modules/tongwen-dict/dist/*.min.json', to: 'dictionaries/[name][ext]' }, 79 | ], 80 | }), 81 | compiler => { 82 | const state = { manifest: false, webExt: false }; 83 | 84 | function writeManifest() { 85 | exec('node ./manifest.js', (error, stdout, stderr) => { 86 | console.log('write manifest:'); 87 | error || stderr ? console.log(`error - ${error || stderr}`) : console.log(stdout || 'done'); 88 | }); 89 | } 90 | 91 | compiler.hooks.done.tap('generate manifest.json', () => { 92 | if (state.manifest) return; 93 | state.manifest = true; 94 | 95 | writeManifest(); 96 | 97 | if (isProd) return; 98 | 99 | watch(path.resolve(__dirname, 'manifest.js'), event => { 100 | if (event !== 'change') return; 101 | writeManifest(); 102 | }); 103 | }); 104 | 105 | if (isProd) return; 106 | 107 | compiler.hooks.afterDone.tap('start web-ext', () => { 108 | if (state.webExt) return; 109 | state.webExt = true; 110 | 111 | const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; 112 | const webext = spawn(npm, ['run', `we:${vendor}`], { cwd: __dirname, env: process.env, shell: true }); 113 | 114 | webext.stdout.on('data', data => { 115 | console.log(`WebExt: ${data}`); 116 | }); 117 | 118 | webext.stderr.on('data', data => { 119 | console.error(`WebExt Error: ${data}`); 120 | }); 121 | 122 | webext.on('close', code => { 123 | console.log(`WebExt process exited with code ${code}`); 124 | }); 125 | }); 126 | }, 127 | ], 128 | }; 129 | }; 130 | -------------------------------------------------------------------------------- /src/options/pages/word/WordSettings.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | import { Fragment, useCallback, useState } from 'react'; 3 | import { LangType } from 'tongwen-core/dictionaries'; 4 | import type { PrefWordDefault } from '../../../preference/types/v2'; 5 | import { i18n } from '../../../service/i18n/i18n'; 6 | import { createNoti } from '../../../service/notification/create-noti'; 7 | import { setStorage } from '../../../service/storage/storage'; 8 | import { Button, Divider, Modal } from '../../components'; 9 | import { useToggle } from '../../hooks/state/use-toggle'; 10 | import { useWord } from '../../hooks/word/use-word'; 11 | import { WordDefaultSettings } from './WordDefaultSettings'; 12 | import { WordEntryEditor } from './WordEntryEditor'; 13 | import { WordEntryList } from './WordEntryList'; 14 | 15 | export const WordSettings: FC = () => { 16 | const { word, setWord } = useWord(); 17 | const setDefault = useCallback((def: PrefWordDefault) => { setWord(word => ({ ...word, default: def })); }, [setWord]); 18 | const [tab, setTab] = useState(null); 19 | const [toEdit, setToEdit] = useState<[string, string]>(['', '']); 20 | const [isModal, { on, off }] = useToggle(false); 21 | 22 | const edit = useCallback( 23 | (entry?: [string, string]) => { 24 | setToEdit(entry || ['', '']); 25 | on(); 26 | }, 27 | [setToEdit, on], 28 | ); 29 | 30 | const update = useCallback( 31 | ([key, value]: [string, string]) => { 32 | if (tab == null) return; 33 | 34 | setWord(pw => { 35 | const newPw = { ...pw }; 36 | const map = new Map(Object.entries(pw.custom[tab])); 37 | 38 | toEdit[0] !== '' && map.delete(toEdit[0]); 39 | map.set(key, value); 40 | newPw.custom[tab] = Object.fromEntries(map); 41 | return newPw; 42 | }); 43 | 44 | off(); 45 | }, 46 | [tab, toEdit], 47 | ); 48 | 49 | const remove = useCallback( 50 | (key: string) => { 51 | if (tab == null) return; 52 | 53 | setWord(pw => { 54 | const newPw = { ...pw }; 55 | const map = new Map(Object.entries(pw.custom[tab])); 56 | 57 | map.delete(key); 58 | newPw.custom[tab] = Object.fromEntries(map); 59 | return newPw; 60 | }); 61 | }, 62 | [tab], 63 | ); 64 | 65 | const save = useCallback( 66 | async () => setStorage({ word }).then(async () => createNoti(i18n.getMessage('MSG_UPDATE_COMPLETED'))), 67 | [word], 68 | ); 69 | 70 | return ( 71 | 72 |
73 | 92 | 93 |
94 | {tab === null ? ( 95 | 96 | ) : ( 97 | 98 |
99 |
100 | 103 |
104 |
105 | 108 |
109 |
110 | 111 | 112 |
113 | )} 114 |
115 |
116 | 117 | 118 | 119 | 120 | ); 121 | }; 122 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [2.4.0](https://github.com/tongwentang/tongwentang-extension/compare/2.2.0...2.4.0) (2024-12-29) 6 | 7 | Manifest MV3 supported 8 | 9 | ### Bug Fixes 10 | 11 | - should escape regex keyword before convert ([898c30e](https://github.com/tongwentang/tongwentang-extension/commit/898c30e78ab4d72aa8f532037c469a27c0e95cb0)) 12 | - try to fix the blank page on mobile [#48](https://github.com/tongwentang/tongwentang-extension/issues/48) ([712ca2d](https://github.com/tongwentang/tongwentang-extension/commit/712ca2d56507c664461c09f58c5fc301155d916f)) 13 | - unable to add filter rule from browser action menu ([349e897](https://github.com/tongwentang/tongwentang-extension/commit/349e897c253713a5aaf1db6569de7d00bb2c94b5)), closes [#41](https://github.com/tongwentang/tongwentang-extension/issues/41) [#65](https://github.com/tongwentang/tongwentang-extension/issues/65) 14 | - Update `lang` attribute of every element ([#64](https://github.com/tongwentang/tongwentang-extension/issues/64)) ([196606d](https://github.com/tongwentang/tongwentang-extension/commit/196606db2912d122a1b06b78f44142839e737bb3)) 15 | 16 | ## [2.2.0](https://github.com/tongwentang/tongwentang-extension/compare/2.1.6...2.2.0) (2022-11-12) 17 | 18 | ### Features 19 | 20 | - update HTML lang attr on convert ([#60](https://github.com/tongwentang/tongwentang-extension/issues/60)) ([907dd94](https://github.com/tongwentang/tongwentang-extension/commit/907dd9462585c9f0c39d6e451b660ae7ad0bae0a)) 21 | 22 | ### [2.1.6](https://github.com/tongwentang/tongwentang-extension/compare/2.1.5...2.1.6) (2022-08-27) 23 | 24 | ### Bug Fixes 25 | 26 | - avoid to update node content if the converted content is same as original ([36270a0](https://github.com/tongwentang/tongwentang-extension/commit/36270a00a4d6f5992b555a1353ac3827667c550d)), closes [#39](https://github.com/tongwentang/tongwentang-extension/issues/39) 27 | 28 | ### [2.1.5](https://github.com/tongwentang/tongwentang-extension/compare/2.1.4...2.1.5) (2022-08-21) 29 | 30 | ### Bug Fixes 31 | 32 | - fix types errors ([c9ff4f4](https://github.com/tongwentang/tongwentang-extension/commit/c9ff4f4e89557cc0cd83decb90e71702495dda0a)) 33 | 34 | ## [2.1.4](https://github.com/tongwentang/tongwentang-extension/compare/2.1.3...2.1.4) (2021-05-29) 35 | 36 | ### Bug Fixes 37 | 38 | - custom t2s mapping words do not merge into converter. ([96bff08](https://github.com/tongwentang/tongwentang-extension/commit/96bff081e95d05098cc39e506118b795e929fb1c)), closes [#33](https://github.com/tongwentang/tongwentang-extension/issues/33) 39 | 40 | ## [2.1.3] - 2021-04-28 41 | 42 | ### Fixed 43 | 44 | - Cursor jump to line start in input area. (#31) 45 | - Can not save with "detective simplified to Traditional" as auto convert option. (#22 #30) 46 | 47 | ## [2.1.2] - 2021-04-24 48 | 49 | ### Fixed 50 | 51 | - Grant permission for clipboard failed message now show properly. 52 | - Switching default word now work properly (#23). 53 | - Cursor now do not jump to line start when typing on input area (#10). 54 | - All relating punctuations now collect and send to converter (#11). 55 | - Prevent detect language API failed (#12). 56 | - v1 preference for Google Chromium is now handle properly on importing (#15). 57 | 58 | ## [2.1.1] - 2021-04-11 59 | 60 | ### Changed 61 | 62 | - Remove `activeTab` permission due no using. 63 | 64 | ### Fixed 65 | 66 | - Remove trailing bracket in context menu. 67 | 68 | ## [2.1.0] - 2021-04-01 69 | 70 | ### Changed 71 | 72 | - Change default shortcut due to Chrome disallow combination of `ctrl` and `alt`. 73 | - Update i18n messages. 74 | 75 | ### Fixed 76 | 77 | - Replace plain text in UI by i18n messages. 78 | 79 | ## [2.0.0] - 2021-03-28 80 | 81 | Compare to v[1.5.1](https://github.com/tongwentang/New-Tongwentang-for-Firefox/releases/tag/1.5) 82 | 83 | ### Added 84 | 85 | - New [convert core](https://github.com/tongwentang/tongwen-core) with completely new algorithm, convert speed is significantly increase 🚀. 86 | - New settings page UI design. 87 | - Import preferences is now checking with schema, provide an option to fix imported preferences if broken. 88 | - Reset preference is now support `Reset` and `Reset All`, latter wipe out everthing including custom mapping words. 89 | - New Detective Auto Convert Mode which detect page content first, do nothing if page content not Chinese. 90 | - New Default Convert Target, if page content is unknown, then extension will convert to default target. 91 | - New Dynamic Mode which reponsively convert on page content changed (SPA web page is now supported). 92 | - New Debug Mode which logging infomation to debug console. 93 | - Domain rule is now support both plain text and regular expression. 94 | - New BrowserAction Context Menu, including direct set domain rule and emit clipboard convert. 95 | 96 | ### Changed 97 | 98 | - Clipboard convert menu is moved to browser action (icon) context menu. 99 | - Clipboard permissions is now optional. 100 | -------------------------------------------------------------------------------- /src/_locales/zh_TW/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "MSG_EXT_NAME": { 3 | "message": "新同文堂" 4 | }, 5 | "MSG_EXT_DESC": { 6 | "message": "切換簡體和正體中文" 7 | }, 8 | "MSG_GENERAL": { 9 | "message": "一般" 10 | }, 11 | "MSG_MAINTAIN_PREF": { 12 | "message": "維護偏好設定" 13 | }, 14 | "MSG_AUTO_CONVERT": { 15 | "message": "自動轉換" 16 | }, 17 | "MSG_BROWSER_ACTION": { 18 | "message": "圖示動作" 19 | }, 20 | "MSG_DEFAULT_CONVERT": { 21 | "message": "預設轉換" 22 | }, 23 | "MSG_DYNAMIC_CONVERT": { 24 | "message": "動態轉換" 25 | }, 26 | "MSG_UPDATE_LANG": { 27 | "message": "同時更新網頁的語言屬性(有助於瀏覽器使用適當的字型)" 28 | }, 29 | "MSG_DEBUG_MODE": { 30 | "message": "偵錯模式" 31 | }, 32 | "MSG_IMPORT": { 33 | "message": "匯入" 34 | }, 35 | "MSG_EXPORT": { 36 | "message": "匯出" 37 | }, 38 | "MSG_RESET": { 39 | "message": "重設" 40 | }, 41 | "MSG_RESET_ALL": { 42 | "message": "全部重設" 43 | }, 44 | "MSG_RESET_TIP": { 45 | "message": "保留自訂字典檔" 46 | }, 47 | "MSG_RESET_ALL_TIP": { 48 | "message": "同時刪除自訂字典檔" 49 | }, 50 | "MSG_EXPORT_FAILED": { 51 | "message": "匯出失敗" 52 | }, 53 | "MSG_IMPORT_CANCELED": { 54 | "message": "匯入取消" 55 | }, 56 | "MSG_IMPORT_COMPLETED": { 57 | "message": "匯入完成" 58 | }, 59 | "MSG_IMPORT_FAILED": { 60 | "message": "匯入失敗" 61 | }, 62 | "MSG_PREF_RESET_COMPLETED": { 63 | "message": "重設偏好完成" 64 | }, 65 | "MSG_PREF_RESET_FAILED": { 66 | "message": "重設偏好失敗" 67 | }, 68 | "MSG_OPTION": { 69 | "message": "選項" 70 | }, 71 | "MSG_DISABLED": { 72 | "message": "停用" 73 | }, 74 | "MSG_S2T": { 75 | "message": "簡轉正" 76 | }, 77 | "MSG_T2S": { 78 | "message": "正轉簡" 79 | }, 80 | "MSG_DETECTIVE_S2T": { 81 | "message": "偵測式簡轉正" 82 | }, 83 | "MSG_DETECTIVE_T2S": { 84 | "message": "偵測式正轉簡" 85 | }, 86 | "MSG_CONFIRM_FIX_IMPORT": { 87 | "message": "匯入的偏好設定格是不正確,是否讓程式自動修正後匯入?" 88 | }, 89 | "MSG_CONFIRM_RESET": { 90 | "message": "這個動作會重設所有的偏好設定除了自訂的字典檔,是否繼續?" 91 | }, 92 | "MSG_CONFIRM_RESET_ALL": { 93 | "message": "這個動作會重設所有的偏好設定包含自訂的字點檔,是否繼續?" 94 | }, 95 | "MSG_MENU": { 96 | "message": "右鍵選單" 97 | }, 98 | "MSG_ENABLE_MENU": { 99 | "message": "啟用右鍵選單" 100 | }, 101 | "MSG_WEBPAGE_S2T": { 102 | "message": "網頁簡轉正" 103 | }, 104 | "MSG_WEBPAGE_T2S": { 105 | "message": "網頁正轉簡" 106 | }, 107 | "MSG_TEXTAREA_S2T": { 108 | "message": "文字區簡轉正" 109 | }, 110 | "MSG_TEXTAREA_T2S": { 111 | "message": "文字區正轉簡" 112 | }, 113 | "MSG_DOMAIN_RULE": { 114 | "message": "網域規則" 115 | }, 116 | "MSG_ENABLE_DOMAIN_RULE": { 117 | "message": "啟用網域規則" 118 | }, 119 | "MSG_TARGET": { 120 | "message": "目標" 121 | }, 122 | "MSG_URL_REGEX": { 123 | "message": "網址或正規表示" 124 | }, 125 | "MSG_ADD": { 126 | "message": "加入" 127 | }, 128 | "MSG_EDIT": { 129 | "message": "編輯" 130 | }, 131 | "MSG_DELETE": { 132 | "message": "刪除" 133 | }, 134 | "MSG_SAVE": { 135 | "message": "儲存" 136 | }, 137 | "MSG_MOVE_UP": { 138 | "message": "上移" 139 | }, 140 | "MSG_MOVE_DOWN": { 141 | "message": "下移" 142 | }, 143 | "MSG_DEFAULT_S2T_CHAR": { 144 | "message": "預設簡轉正單字" 145 | }, 146 | "MSG_DEFAULT_S2T_WORD": { 147 | "message": "預設簡轉正詞彙" 148 | }, 149 | "MSG_DEFAULT_T2S_CHAR": { 150 | "message": "預設正轉簡單字" 151 | }, 152 | "MSG_DEFAULT_T2S_WORD": { 153 | "message": "預設正轉簡詞彙" 154 | }, 155 | "MSG_CUSTOM_S2T": { 156 | "message": "自訂簡轉正" 157 | }, 158 | "MSG_CUSTOM_T2S": { 159 | "message": "自訂正轉簡" 160 | }, 161 | "MSG_WORD": { 162 | "message": "詞彙" 163 | }, 164 | "MSG_DEFAULT": { 165 | "message": "預設" 166 | }, 167 | "MSG_UPDATE_COMPLETED": { 168 | "message": "完成更新" 169 | }, 170 | "MSG_CONVERT_TARGET": { 171 | "message": "轉換目標" 172 | }, 173 | "MSG_CONVERT_VALUE": { 174 | "message": "轉換值" 175 | }, 176 | "MSG_ABOUT": { 177 | "message": "關於" 178 | }, 179 | "MSG_REPOSITORY": { 180 | "message": "程式碼倉庫" 181 | }, 182 | "MSG_CHANGELOG": { 183 | "message": "變更紀錄" 184 | }, 185 | "MSG_CONTRIBUTORS": { 186 | "message": "貢獻者" 187 | }, 188 | "MSG_LICENSE": { 189 | "message": "授權方式" 190 | }, 191 | "MSG_HELP_SUPPORT": { 192 | "message": "說明與支援" 193 | }, 194 | "MSG_ISSUE_REPORT": { 195 | "message": "問題回報" 196 | }, 197 | "MSG_ADD_DOMAIN_TO_DISABLED": { 198 | "message": "加入停用網域" 199 | }, 200 | "MSG_ADD_DOMAIN_TO_S2T": { 201 | "message": "加入簡轉正網域" 202 | }, 203 | "MSG_ADD_DOMAIN_TO_T2S": { 204 | "message": "加入正轉簡網域" 205 | }, 206 | "MSG_CONVERT_CLIPBOARD_S2T": { 207 | "message": "轉換剪貼簿簡至正" 208 | }, 209 | "MSG_CONVERT_CLIPBOARD_T2S": { 210 | "message": "轉換剪貼簿正至簡" 211 | }, 212 | "MSG_OK": { 213 | "message": "確認" 214 | }, 215 | "MSG_CANCEL": { 216 | "message": "取消" 217 | }, 218 | "MSG_JSON_ERROR": { 219 | "message": "無法解析匯入的檔案,請確保檔案符合 JSON 格式" 220 | }, 221 | "NT_TITLE": { 222 | "message": "新同文堂 - 通知" 223 | }, 224 | "NT_CLB_TO_S2T": { 225 | "message": "剪貼簿已轉換成正體" 226 | }, 227 | "NT_CLB_TO_T2S": { 228 | "message": "剪貼簿已轉換成簡體" 229 | }, 230 | "NT_GRT_PRM_DENIED": { 231 | "message": "請求選擇性授權被拒絕" 232 | }, 233 | "NT_GRT_PRM_ONLY_USR_INTER": { 234 | "message": "請求選擇性授權只能在使用者互動下,請先到套件的授權分頁啟用" 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig 2 | # Created by https://www.toptal.com/developers/gitignore/api/windows,visualstudiocode,node,macos,linux 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=windows,visualstudiocode,node,macos,linux 4 | 5 | ### Linux ### 6 | *~ 7 | 8 | # temporary files which can be created if a process still has a handle open of a deleted file 9 | .fuse_hidden* 10 | 11 | # KDE directory preferences 12 | .directory 13 | 14 | # Linux trash folder which might appear on any partition or disk 15 | .Trash-* 16 | 17 | # .nfs files are created when an open file is removed but is still being accessed 18 | .nfs* 19 | 20 | ### macOS ### 21 | # General 22 | .DS_Store 23 | .AppleDouble 24 | .LSOverride 25 | 26 | # Icon must end with two \r 27 | Icon 28 | 29 | # Thumbnails 30 | ._* 31 | 32 | # Files that might appear in the root of a volume 33 | .DocumentRevisions-V100 34 | .fseventsd 35 | .Spotlight-V100 36 | .TemporaryItems 37 | .Trashes 38 | .VolumeIcon.icns 39 | .com.apple.timemachine.donotpresent 40 | 41 | # Directories potentially created on remote AFP share 42 | .AppleDB 43 | .AppleDesktop 44 | Network Trash Folder 45 | Temporary Items 46 | .apdisk 47 | 48 | ### macOS Patch ### 49 | # iCloud generated files 50 | *.icloud 51 | 52 | ### Node ### 53 | # Logs 54 | logs 55 | *.log 56 | npm-debug.log* 57 | yarn-debug.log* 58 | yarn-error.log* 59 | lerna-debug.log* 60 | .pnpm-debug.log* 61 | 62 | # Diagnostic reports (https://nodejs.org/api/report.html) 63 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 64 | 65 | # Runtime data 66 | pids 67 | *.pid 68 | *.seed 69 | *.pid.lock 70 | 71 | # Directory for instrumented libs generated by jscoverage/JSCover 72 | lib-cov 73 | 74 | # Coverage directory used by tools like istanbul 75 | coverage 76 | *.lcov 77 | 78 | # nyc test coverage 79 | .nyc_output 80 | 81 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 82 | .grunt 83 | 84 | # Bower dependency directory (https://bower.io/) 85 | bower_components 86 | 87 | # node-waf configuration 88 | .lock-wscript 89 | 90 | # Compiled binary addons (https://nodejs.org/api/addons.html) 91 | build/Release 92 | 93 | # Dependency directories 94 | node_modules/ 95 | jspm_packages/ 96 | 97 | # Snowpack dependency directory (https://snowpack.dev/) 98 | web_modules/ 99 | 100 | # TypeScript cache 101 | *.tsbuildinfo 102 | 103 | # Optional npm cache directory 104 | .npm 105 | 106 | # Optional eslint cache 107 | .eslintcache 108 | 109 | # Optional stylelint cache 110 | .stylelintcache 111 | 112 | # Microbundle cache 113 | .rpt2_cache/ 114 | .rts2_cache_cjs/ 115 | .rts2_cache_es/ 116 | .rts2_cache_umd/ 117 | 118 | # Optional REPL history 119 | .node_repl_history 120 | 121 | # Output of 'npm pack' 122 | *.tgz 123 | 124 | # Yarn Integrity file 125 | .yarn-integrity 126 | 127 | # dotenv environment variable files 128 | .env 129 | .env.development.local 130 | .env.test.local 131 | .env.production.local 132 | .env.local 133 | 134 | # parcel-bundler cache (https://parceljs.org/) 135 | .cache 136 | .parcel-cache 137 | 138 | # Next.js build output 139 | .next 140 | out 141 | 142 | # Nuxt.js build / generate output 143 | .nuxt 144 | dist 145 | 146 | # Gatsby files 147 | .cache/ 148 | # Comment in the public line in if your project uses Gatsby and not Next.js 149 | # https://nextjs.org/blog/next-9-1#public-directory-support 150 | # public 151 | 152 | # vuepress build output 153 | .vuepress/dist 154 | 155 | # vuepress v2.x temp and cache directory 156 | .temp 157 | 158 | # Docusaurus cache and generated files 159 | .docusaurus 160 | 161 | # Serverless directories 162 | .serverless/ 163 | 164 | # FuseBox cache 165 | .fusebox/ 166 | 167 | # DynamoDB Local files 168 | .dynamodb/ 169 | 170 | # TernJS port file 171 | .tern-port 172 | 173 | # Stores VSCode versions used for testing VSCode extensions 174 | .vscode-test 175 | 176 | # yarn v2 177 | .yarn/cache 178 | .yarn/unplugged 179 | .yarn/build-state.yml 180 | .yarn/install-state.gz 181 | .pnp.* 182 | 183 | ### Node Patch ### 184 | # Serverless Webpack directories 185 | .webpack/ 186 | 187 | # Optional stylelint cache 188 | 189 | # SvelteKit build / generate output 190 | .svelte-kit 191 | 192 | ### VisualStudioCode ### 193 | .vscode/* 194 | !.vscode/settings.json 195 | !.vscode/tasks.json 196 | !.vscode/launch.json 197 | !.vscode/extensions.json 198 | !.vscode/*.code-snippets 199 | 200 | # Local History for Visual Studio Code 201 | .history/ 202 | 203 | # Built Visual Studio Code Extensions 204 | *.vsix 205 | 206 | ### VisualStudioCode Patch ### 207 | # Ignore all local history of files 208 | .history 209 | .ionide 210 | 211 | # Support for Project snippet scope 212 | .vscode/*.code-snippets 213 | 214 | # Ignore code-workspaces 215 | *.code-workspace 216 | 217 | ### Windows ### 218 | # Windows thumbnail cache files 219 | Thumbs.db 220 | Thumbs.db:encryptable 221 | ehthumbs.db 222 | ehthumbs_vista.db 223 | 224 | # Dump file 225 | *.stackdump 226 | 227 | # Folder config file 228 | [Dd]esktop.ini 229 | 230 | # Recycle Bin used on file shares 231 | $RECYCLE.BIN/ 232 | 233 | # Windows Installer files 234 | *.cab 235 | *.msi 236 | *.msix 237 | *.msm 238 | *.msp 239 | 240 | # Windows shortcuts 241 | *.lnk 242 | 243 | # End of https://www.toptal.com/developers/gitignore/api/windows,visualstudiocode,node,macos,linux 244 | 245 | # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) 246 | 247 | web-ext-artifacts 248 | dict 249 | -------------------------------------------------------------------------------- /src/_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "MSG_EXT_NAME": { 3 | "message": "New TongWenTang" 4 | }, 5 | "MSG_EXT_DESC": { 6 | "message": "Convert characters between Chinese Simplified and Chinese Traditional" 7 | }, 8 | "MSG_GENERAL": { 9 | "message": "General" 10 | }, 11 | "MSG_MAINTAIN_PREF": { 12 | "message": "Maintain Preferences" 13 | }, 14 | "MSG_AUTO_CONVERT": { 15 | "message": "Auto Convert" 16 | }, 17 | "MSG_BROWSER_ACTION": { 18 | "message": "Icon Action" 19 | }, 20 | "MSG_DEFAULT_CONVERT": { 21 | "message": "Default Convert" 22 | }, 23 | "MSG_DYNAMIC_CONVERT": { 24 | "message": "Dynamic Convert" 25 | }, 26 | "MSG_UPDATE_LANG": { 27 | "message": "Update webpage language attribute (helps the browser display the appropriate font)" 28 | }, 29 | "MSG_DEBUG_MODE": { 30 | "message": "Debug Mode" 31 | }, 32 | "MSG_IMPORT": { 33 | "message": "Import" 34 | }, 35 | "MSG_EXPORT": { 36 | "message": "Export" 37 | }, 38 | "MSG_RESET": { 39 | "message": "Reset" 40 | }, 41 | "MSG_RESET_ALL": { 42 | "message": "Reset ALL" 43 | }, 44 | "MSG_RESET_TIP": { 45 | "message": "Keep Your Custom Words" 46 | }, 47 | "MSG_RESET_ALL_TIP": { 48 | "message": "Clean Including Your Custom Words" 49 | }, 50 | "MSG_EXPORT_FAILED": { 51 | "message": "Export Failed" 52 | }, 53 | "MSG_IMPORT_CANCELED": { 54 | "message": "Import Canceled" 55 | }, 56 | "MSG_IMPORT_COMPLETED": { 57 | "message": "Import Completed" 58 | }, 59 | "MSG_IMPORT_FAILED": { 60 | "message": "Import FAILED" 61 | }, 62 | "MSG_PREF_RESET_COMPLETED": { 63 | "message": "Reset Preference Completed" 64 | }, 65 | "MSG_PREF_RESET_FAILED": { 66 | "message": "Reset Preference Failed" 67 | }, 68 | "MSG_OPTION": { 69 | "message": "Option" 70 | }, 71 | "MSG_DISABLED": { 72 | "message": "Disabled" 73 | }, 74 | "MSG_S2T": { 75 | "message": "Simplified to Traditional" 76 | }, 77 | "MSG_T2S": { 78 | "message": "Traditional to Simplified" 79 | }, 80 | "MSG_DETECTIVE_S2T": { 81 | "message": "Detective Simplified to Traditional" 82 | }, 83 | "MSG_DETECTIVE_T2S": { 84 | "message": "Detective Traditional to Simplified" 85 | }, 86 | "MSG_CONFIRM_FIX_IMPORT": { 87 | "message": "Imported preferences are invalid, do you want to import a fixed preferences base on imported?" 88 | }, 89 | "MSG_CONFIRM_RESET": { 90 | "message": "This action will reset all your preferences while still keeping your custom dictionaries, continue?" 91 | }, 92 | "MSG_CONFIRM_RESET_ALL": { 93 | "message": "This action will reset all your preferences including your custom dictionaries, continue?" 94 | }, 95 | "MSG_MENU": { 96 | "message": "Context Menu" 97 | }, 98 | "MSG_ENABLE_MENU": { 99 | "message": "Enable Menu" 100 | }, 101 | "MSG_WEBPAGE_S2T": { 102 | "message": "Web Page Simplified to Traditional" 103 | }, 104 | "MSG_WEBPAGE_T2S": { 105 | "message": "Web Page Traditional to Simplified" 106 | }, 107 | "MSG_TEXTAREA_S2T": { 108 | "message": "Textarea Simplified to Traditional" 109 | }, 110 | "MSG_TEXTAREA_T2S": { 111 | "message": "Textarea Traditional to Simplified" 112 | }, 113 | "MSG_DOMAIN_RULE": { 114 | "message": "Domain Rule" 115 | }, 116 | "MSG_ENABLE_DOMAIN_RULE": { 117 | "message": "Enable Domain Rule" 118 | }, 119 | "MSG_TARGET": { 120 | "message": "Target" 121 | }, 122 | "MSG_URL_REGEX": { 123 | "message": "URL or RegExp" 124 | }, 125 | "MSG_ADD": { 126 | "message": "Add" 127 | }, 128 | "MSG_EDIT": { 129 | "message": "Edit" 130 | }, 131 | "MSG_DELETE": { 132 | "message": "Delete" 133 | }, 134 | "MSG_SAVE": { 135 | "message": "Save" 136 | }, 137 | "MSG_MOVE_UP": { 138 | "message": "Up" 139 | }, 140 | "MSG_MOVE_DOWN": { 141 | "message": "Down" 142 | }, 143 | "MSG_DEFAULT_S2T_CHAR": { 144 | "message": "Default Simplified to Traditional Character" 145 | }, 146 | "MSG_DEFAULT_S2T_WORD": { 147 | "message": "Default Simplified to Traditional Word" 148 | }, 149 | "MSG_DEFAULT_T2S_CHAR": { 150 | "message": "Default Traditional to Simplified Character" 151 | }, 152 | "MSG_DEFAULT_T2S_WORD": { 153 | "message": "Default Traditional to Simplified Word" 154 | }, 155 | "MSG_CUSTOM_S2T": { 156 | "message": "Custom Simplified to Traditional" 157 | }, 158 | "MSG_CUSTOM_T2S": { 159 | "message": "Custom Traditional to Simplified" 160 | }, 161 | "MSG_WORD": { 162 | "message": "Word" 163 | }, 164 | "MSG_DEFAULT": { 165 | "message": "Default" 166 | }, 167 | "MSG_UPDATE_COMPLETED": { 168 | "message": "Update Completed" 169 | }, 170 | "MSG_CONVERT_TARGET": { 171 | "message": "Convert Target" 172 | }, 173 | "MSG_CONVERT_VALUE": { 174 | "message": "Convert Value" 175 | }, 176 | "MSG_ABOUT": { 177 | "message": "About" 178 | }, 179 | "MSG_REPOSITORY": { 180 | "message": "Repository" 181 | }, 182 | "MSG_CHANGELOG": { 183 | "message": "Changelog" 184 | }, 185 | "MSG_CONTRIBUTORS": { 186 | "message": "Contributors" 187 | }, 188 | "MSG_LICENSE": { 189 | "message": "LICENSE" 190 | }, 191 | "MSG_HELP_SUPPORT": { 192 | "message": "Help and Support" 193 | }, 194 | "MSG_ISSUE_REPORT": { 195 | "message": "Issue Report" 196 | }, 197 | "MSG_ADD_DOMAIN_TO_DISABLED": { 198 | "message": "Add Domain to Disabled" 199 | }, 200 | "MSG_ADD_DOMAIN_TO_S2T": { 201 | "message": "Add Domain to Simplified to Traditional" 202 | }, 203 | "MSG_ADD_DOMAIN_TO_T2S": { 204 | "message": "Add Domain to Traditonal to Simplified" 205 | }, 206 | "MSG_CONVERT_CLIPBOARD_S2T": { 207 | "message": "Convert Clipboard Simplified to Traditional" 208 | }, 209 | "MSG_CONVERT_CLIPBOARD_T2S": { 210 | "message": "Convert Clipboard Traditional to Simplified" 211 | }, 212 | "MSG_OK": { 213 | "message": "OK" 214 | }, 215 | "MSG_CANCEL": { 216 | "message": "Cancel" 217 | }, 218 | "MSG_JSON_ERROR": { 219 | "message": "Can not parse file import, make sure the file is JSON form" 220 | }, 221 | "NT_TITLE": { 222 | "message": "New TongWenTang - Notify" 223 | }, 224 | "NT_CLB_TO_S2T": { 225 | "message": "Clipboard has been converted to Traditional Chinese" 226 | }, 227 | "NT_CLB_TO_T2S": { 228 | "message": "Clipboard has been converted to Simpified Chinese" 229 | }, 230 | "NT_GRT_PRM_DENIED": { 231 | "message": "Grant permission(s) denied" 232 | }, 233 | "NT_GRT_PRM_ONLY_USR_INTER": { 234 | "message": "Grant optional permission can only under user interaction, to give permission(s) please go extension permission tab" 235 | } 236 | } 237 | --------------------------------------------------------------------------------