├── .npmrc ├── .husky ├── setup.js ├── commit-msg └── pre-commit ├── src ├── icons │ ├── 128.png │ ├── 32.png │ └── 64.png ├── scripts │ ├── sharre │ │ ├── locale.ts │ │ ├── constant.ts │ │ └── chrome-storage.ts │ ├── background │ │ ├── context-menu.ts │ │ ├── omnibox.ts │ │ ├── start-changelog.ts │ │ ├── keyword-search.ts │ │ └── hanzi-to-pinyin.ts │ ├── background.ts │ ├── options.ts │ └── popup.ts ├── popup.html ├── sheets │ ├── scrollbar.css │ ├── options.css │ ├── base.css │ └── popup.css ├── _locales │ ├── zh_CN │ │ └── messages.json │ └── en │ │ └── messages.json ├── manifest.json └── options.html ├── commitlint.config.js ├── screenshot ├── options.png ├── management.jpg ├── keyword-search.gif └── omnibox-search.gif ├── .prettierignore ├── .gitignore ├── global.d.ts ├── tsconfig.json ├── docs └── privacy-policy.md ├── changelog.md ├── .github └── workflows │ ├── test.yml │ └── publish.yml ├── gulpfile.js ├── .editorconfig ├── LICENSE ├── README.md ├── package.json └── test └── keyword-search.html /.npmrc: -------------------------------------------------------------------------------- 1 | # git tag prefix 2 | tag-version-prefix="" 3 | -------------------------------------------------------------------------------- /.husky/setup.js: -------------------------------------------------------------------------------- 1 | !require("ci-info").isCI && require("husky").install(); 2 | -------------------------------------------------------------------------------- /src/icons/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Semibold/Extensions-Steward/HEAD/src/icons/128.png -------------------------------------------------------------------------------- /src/icons/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Semibold/Extensions-Steward/HEAD/src/icons/32.png -------------------------------------------------------------------------------- /src/icons/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Semibold/Extensions-Steward/HEAD/src/icons/64.png -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["@commitlint/config-conventional"], 3 | }; 4 | -------------------------------------------------------------------------------- /screenshot/options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Semibold/Extensions-Steward/HEAD/screenshot/options.png -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run husky:commit-msg 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run husky:pre-commit 5 | -------------------------------------------------------------------------------- /screenshot/management.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Semibold/Extensions-Steward/HEAD/screenshot/management.jpg -------------------------------------------------------------------------------- /screenshot/keyword-search.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Semibold/Extensions-Steward/HEAD/screenshot/keyword-search.gif -------------------------------------------------------------------------------- /screenshot/omnibox-search.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Semibold/Extensions-Steward/HEAD/screenshot/omnibox-search.gif -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | demo/**/* 2 | dist/**/* 3 | coverage/**/* 4 | node_modules/**/* 5 | 6 | # ignore root configuration files 7 | *.json 8 | *.yml 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | .idea 3 | .vscode 4 | 5 | node_modules 6 | dist 7 | 8 | npm-debug.log* 9 | 10 | src/**/*.js 11 | src/**/*.js.map 12 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | interface KeywordSearchSchema { 2 | type: "keywordSearch"; 3 | input: string; 4 | } 5 | 6 | /** 7 | * Union type of schema 8 | */ 9 | declare type ServiceWorkerMessage = KeywordSearchSchema; 10 | -------------------------------------------------------------------------------- /src/scripts/sharre/locale.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 自定义的 i18n 3 | * @desc selector = "[data-i18n]" 4 | */ 5 | export function locale() { 6 | const nodes = document.querySelectorAll("[data-i18n]"); 7 | for (const node of nodes) { 8 | node.textContent = chrome.i18n.getMessage(node.dataset.i18n || ""); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "strict": true, 5 | "strictNullChecks": false, 6 | "sourceMap": true, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "resolveJsonModule": true, 10 | "target": "ESNext", 11 | "module": "ESNext", 12 | "moduleResolution": "node" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /docs/privacy-policy.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy 2 | 3 | ## Personal or Sensitive User Data 4 | 5 | 目前,我们没有收集任何用户信息。 6 | 7 | :-) 8 | 9 | ## Use of Permissions 10 | 11 | | 权限 | 用途说明 | 12 | | ------------ | ------------------------ | 13 | | contextMenus | 扩展图标的右键菜单 | 14 | | management | 管理已安装的扩展 | 15 | | storage | 存储配置信息 | 16 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | > 更新日志 4 | 5 | ### 4.0.0 6 | 7 | - 更新:支持 Manifest V3 8 | - 更新:迁移到 TypeScript 9 | - 更新:支持 Dark Mode 10 | 11 | ### 3.3.0 12 | 13 | - 新增:问题反馈菜单(在扩展图标上右击) 14 | - 新增:自动显示更新日志(在选项中可以关闭) 15 | - 新增:关键字搜索(具体内容请查看 [README.md](https://github.com/Semibold/Extensions-Steward/blob/master/README.md)) 16 | 17 | ### 3.2.0 18 | 19 | - 启用自动部署功能 20 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: [push] 3 | jobs: 4 | Run-Test: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | node-version: [20.x] 9 | steps: 10 | - uses: actions/checkout@v3 11 | - name: Use Node.js ${{ matrix.node-version }} 12 | uses: actions/setup-node@v3 13 | with: 14 | node-version: ${{ matrix.node-version }} 15 | - run: corepack enable 16 | - run: pnpm i 17 | - run: pnpm run test 18 | -------------------------------------------------------------------------------- /src/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | __MSG_popup_title 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/scripts/background/context-menu.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Feedback context menu 3 | */ 4 | chrome.runtime.onInstalled.addListener(() => { 5 | chrome.contextMenus.create({ 6 | id: "menu_feedback", 7 | title: chrome.i18n.getMessage("menu_feedback"), 8 | contexts: ["action"], 9 | }); 10 | }); 11 | 12 | chrome.contextMenus.onClicked.addListener((info, tab) => { 13 | if (info.menuItemId === "menu_feedback") { 14 | chrome.tabs.create({ url: chrome.i18n.getMessage("project_issue") }); 15 | } 16 | }); 17 | -------------------------------------------------------------------------------- /src/sheets/scrollbar.css: -------------------------------------------------------------------------------- 1 | ::-webkit-scrollbar { 2 | width: 4px; 3 | height: 4px; 4 | } 5 | 6 | ::-webkit-scrollbar-track { 7 | background-color: #f1f1f1; 8 | border-radius: 2px; 9 | } 10 | 11 | ::-webkit-scrollbar-thumb { 12 | background-color: #bcbcbc; 13 | border-radius: 2px; 14 | } 15 | 16 | ::-webkit-scrollbar-thumb:hover { 17 | background-color: #888; 18 | } 19 | 20 | @media (prefers-color-scheme: dark) { 21 | ::-webkit-scrollbar-track { 22 | background-color: #222; 23 | } 24 | 25 | ::-webkit-scrollbar-thumb { 26 | background-color: #666; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/scripts/background.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc 仅需要引入让其执行 3 | */ 4 | import "./background/context-menu.js"; 5 | import "./background/start-changelog.js"; 6 | import "./background/omnibox.js"; 7 | 8 | import { KeywordSearch } from "./background/keyword-search.js"; 9 | 10 | const keywordSearch = new KeywordSearch(); 11 | 12 | chrome.runtime.onMessage.addListener((message: ServiceWorkerMessage, sender, sendResponse) => { 13 | if (!message) return; 14 | 15 | switch (message.type) { 16 | case "keywordSearch": 17 | sendResponse(keywordSearch.search(message.input)); 18 | return true; 19 | } 20 | }); 21 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require("gulp"); 2 | const gulp_zip = require("gulp-zip"); 3 | const gulp_json_modify = require("gulp-json-modify"); 4 | const merge_stream = require("merge-stream"); 5 | const pkg = require("./package.json"); 6 | 7 | gulp.task("bundle", function () { 8 | const others = gulp.src(["src/**", "!src/manifest.json", "!src/**/*.ts", "!src/**/*.js.map"]); 9 | const manifest = gulp.src("src/manifest.json").pipe( 10 | gulp_json_modify({ 11 | key: "version", 12 | value: pkg.version, 13 | }), 14 | ); 15 | 16 | return merge_stream(manifest, others).pipe(gulp_zip("bundle.zip")).pipe(gulp.dest("dist")); 17 | }); 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | # Option for setting a maximum length for each line 8 | # Supported By A Limited Number of Editors 9 | # https://github.com/editorconfig/editorconfig/wiki/EditorConfig-Properties#supported-by-a-limited-number-of-editors 10 | max_line_length = 120 11 | 12 | # Unix-style newlines with a newline ending every file 13 | end_of_line = lf 14 | 15 | # Matches multiple files with brace expansion notation 16 | # Set default charset 17 | charset = utf-8 18 | 19 | # 4 space indentation 20 | indent_style = space 21 | indent_size = 4 22 | 23 | 24 | # Matches the exact files 25 | [*.{json,yml}] 26 | indent_size = 2 27 | 28 | # Configration files 29 | [.*] 30 | indent_size = 2 31 | -------------------------------------------------------------------------------- /src/scripts/background/omnibox.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc Omnibox default suggestion 3 | */ 4 | chrome.omnibox.setDefaultSuggestion({ 5 | description: chrome.i18n.getMessage("omnibox_default_suggestion"), 6 | }); 7 | 8 | /** 9 | * @desc Chrome webstore search 10 | */ 11 | chrome.omnibox.onInputEntered.addListener((text, disposition) => { 12 | const url = `https://chrome.google.com/webstore/search/${encodeURIComponent(text)}`; 13 | switch (disposition) { 14 | case "currentTab": 15 | chrome.tabs.update({ url: url }); 16 | break; 17 | case "newForegroundTab": 18 | chrome.tabs.create({ url: url, active: true }); 19 | break; 20 | case "newBackgroundTab": 21 | chrome.tabs.create({ url: url, active: false }); 22 | break; 23 | } 24 | }); 25 | -------------------------------------------------------------------------------- /src/sheets/options.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --opt-bg-color: #fff; 3 | --opt-border-color: #dbdbdb; 4 | --opt-text-color: #000 5 | } 6 | 7 | @media (prefers-color-scheme: dark) { 8 | :root { 9 | --opt-bg-color: #353535; 10 | --opt-text-color: #fff 11 | } 12 | } 13 | 14 | body { 15 | margin-bottom: 2.4em; 16 | min-width: 400px; 17 | font-size: 81.25%; 18 | color: var(--opt-text-color); 19 | background-color: var(--opt-bg-color); 20 | } 21 | 22 | h2 { 23 | line-height: 1.8; 24 | border-bottom: 1px solid var(--opt-border-color); 25 | margin-bottom: 1rem; 26 | padding-bottom: 0.4rem; 27 | } 28 | 29 | label { 30 | display: inline-flex; 31 | align-items: center; 32 | margin-left: 2em; 33 | line-height: 1.8; 34 | } 35 | 36 | label > span { 37 | margin-left: 0.3em; 38 | user-select: none; 39 | } 40 | -------------------------------------------------------------------------------- /src/_locales/zh_CN/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "ext_name": { 3 | "message": "扩展管家" 4 | }, 5 | "ext_desc": { 6 | "message": "扩展管家,快速的启用、禁用扩展" 7 | }, 8 | "disable_extension": { 9 | "message": "禁用扩展" 10 | }, 11 | "enable_extension": { 12 | "message": "启用扩展" 13 | }, 14 | "one_key_disable": { 15 | "message": "禁用全部扩展" 16 | }, 17 | "one_key_restore": { 18 | "message": "还原已禁扩展" 19 | }, 20 | "omnibox_default_suggestion": { 21 | "message": "在 Chrome 网上商店中搜索应用" 22 | }, 23 | "options_general": { 24 | "message": "选项" 25 | }, 26 | "options_update_details": { 27 | "message": "通知我更新详情" 28 | }, 29 | "options_search_status": { 30 | "message": "保持上一次搜索的状态" 31 | }, 32 | "options_title": { 33 | "message": "扩展管家选项" 34 | }, 35 | "options_extension_type": { 36 | "message": "管理已勾选类型的扩展" 37 | }, 38 | "menu_feedback": { 39 | "message": "问题反馈" 40 | } 41 | } -------------------------------------------------------------------------------- /src/sheets/base.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-size: 1rem; 4 | font-family: "Helvetica Neue", "Segoe UI", Arial, sans-serif; 5 | } 6 | 7 | h1, 8 | h2, 9 | h3, 10 | h4, 11 | h5, 12 | h6 { 13 | margin-top: 0; 14 | margin-bottom: 0; 15 | } 16 | 17 | ol, 18 | ul { 19 | margin-top: 0; 20 | margin-bottom: 0; 21 | padding-left: 0; 22 | list-style-type: none; 23 | } 24 | 25 | a { 26 | outline-style: none; 27 | text-decoration: none; 28 | } 29 | 30 | img { 31 | vertical-align: middle; 32 | border-style: none; 33 | } 34 | 35 | button, 36 | input, 37 | select { 38 | font: inherit; 39 | margin: 0; 40 | outline-style: none; 41 | overflow: visible; 42 | } 43 | 44 | textarea { 45 | font: inherit; 46 | margin: 0; 47 | outline-style: none; 48 | overflow: auto; 49 | resize: none; 50 | } 51 | 52 | table { 53 | border-collapse: collapse; 54 | border-spacing: 0; 55 | } 56 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "__MSG_ext_name__", 4 | "version": "1.0.0", 5 | "default_locale": "en", 6 | "description": "__MSG_ext_desc__", 7 | "icons": { 8 | "32": "icons/32.png", 9 | "64": "icons/64.png", 10 | "128": "icons/128.png" 11 | }, 12 | "action": { 13 | "default_popup": "popup.html" 14 | }, 15 | "background": { 16 | "service_worker": "scripts/background.js", 17 | "type": "module" 18 | }, 19 | "commands": { 20 | "_execute_action": { 21 | "suggested_key": { 22 | "default": "Alt+Shift+Z" 23 | } 24 | } 25 | }, 26 | "homepage_url": "https://github.com/Semibold/Extensions-Steward", 27 | "omnibox": { 28 | "keyword": "ems" 29 | }, 30 | "minimum_chrome_version": "100", 31 | "options_ui": { 32 | "page": "options.html", 33 | "open_in_tab": true 34 | }, 35 | "permissions": [ 36 | "contextMenus", 37 | "management", 38 | "storage" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: CWS Publish 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | workflow_dispatch: 7 | jobs: 8 | Run-CWS-Publish: 9 | runs-on: ubuntu-latest 10 | env: 11 | SOURCE: dist/bundle.zip 12 | EXTENSION_ID: cjphebojlojkmfgclckgpnjdkgkfkmkf 13 | CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }} 14 | CLIENT_SECRET: ${{ secrets.CWS_CLIENT_SECRET }} 15 | REFRESH_TOKEN: ${{ secrets.CWS_REFRESH_TOKEN }} 16 | strategy: 17 | matrix: 18 | node-version: [20.x] 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | - run: corepack enable 26 | - run: pnpm i 27 | - run: pnpm run bundle 28 | - run: pnpm chrome-webstore-upload upload --source $SOURCE --extension-id $EXTENSION_ID --client-id $CLIENT_ID --client-secret $CLIENT_SECRET --refresh-token $REFRESH_TOKEN --auto-publish 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 L&H 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/scripts/background/start-changelog.ts: -------------------------------------------------------------------------------- 1 | import { K_AUTO_DISPLAY_CHANGELOG } from "../sharre/constant.js"; 2 | import { chromeStorageSync } from "../sharre/chrome-storage.js"; 3 | 4 | /** 5 | * Changelog 6 | */ 7 | chrome.runtime.onInstalled.addListener((details) => { 8 | if (details.reason === "install") { 9 | chrome.tabs.create({ 10 | url: chrome.i18n.getMessage("project_readme"), 11 | }); 12 | } 13 | if (details.reason === "update") { 14 | const [prevMajor, prevMinor] = details.previousVersion.split(".", 2); 15 | const [major, minor] = chrome.runtime.getManifest().version.split(".", 2); 16 | // ignore changelog if major and minor have been not changed 17 | if (prevMajor === major) { 18 | if (prevMinor || minor) { 19 | if (prevMinor === minor) return; 20 | } else { 21 | return; 22 | } 23 | } 24 | chromeStorageSync.promise.then((items) => { 25 | if (items[K_AUTO_DISPLAY_CHANGELOG]) { 26 | chrome.tabs.create({ 27 | url: chrome.i18n.getMessage("project_changelog"), 28 | }); 29 | } 30 | }); 31 | } 32 | }); 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Extensions-Steward 2 | 3 | [更新日志(CHANGELOG)](changelog.md) 4 | 5 | ## Introduction 6 | 7 | 扩展管家,快速启用、禁用扩展,禁用全部扩展、恢复被禁用的扩展以及搜索扩展 8 | 9 | ## Screenshots and Examples 10 | 11 | 12 | 13 | 上图是处于打开状态的扩展管家。按下 Shift+Alt+Z 可以快速打开扩展列表,如果此快捷键被其他程序占用,可以前往 `chrome://extensions/shortcuts` 自定义快捷键。 14 | 15 | 16 | 17 | 上图是选项栏。取消勾选通知我更新详情,可以关闭更新时自动打开更新日志的功能。勾选保持上一次搜索的状态,可以在打开扩展列表时保持上一次的搜索状态。 18 | 19 | 20 | 21 | 上图是使用 Omnibox(地址栏)方便的搜索 Chrome 商店中的扩展。在地址栏中输入 `ems`,按下 SpaceTab 键,输入你要搜索的扩展名称,按下 Enter 键即可快速到达 Chrome 商店的搜索列表中。 22 | 23 | 24 | 25 | 上图展示了快捷搜索扩展的功能。输入英文字母/汉字拼音可以快速查找相关的扩展,同时可以使用 `@` 限定符来查找启用/禁用的扩展。按下 TabShift+Tab 可以切换焦点,按下 Enter 键可以启用/禁用已获取焦点的扩展。 26 | 27 | ## FAQ 28 | 29 | - 为什么不提供移除其他扩展的功能? 30 | - 由于缺少设计方案,因此暂时不提供此功能 31 | - 触发 Omnibox(地址栏)事件的关键字是什么意思? 32 | - Extensions Management Searcher 首字母的缩写(ems) 33 | - 为什么快捷搜索时不能输入一些特殊字符? 34 | - 快捷搜索时只能设别指定字符,包括:英文字母(A-Z)、阿拉伯数字(0-9)、限定符(@)、EscBackspace 35 | - 限定符后面有效的关键字有哪些? 36 | - 以下是有效的关键字,选取自己熟悉一种的即可 37 | - 启用:`on`, `enable`, `enabled`, `dk`, `dakai`, `qy`, `qiyong` 38 | - 禁用:`off`, `disable`, `disabled`, `gb`, `guanbi`, `jy`, `jinyong` 39 | 40 | ## Privacy Policy 41 | 42 | [隐私权和条款](docs/privacy-policy.md) 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "extensions-steward", 3 | "version": "4.0.3", 4 | "private": true, 5 | "description": "Chrome Extension", 6 | "packageManager": "pnpm@8.10.5", 7 | "scripts": { 8 | "prepare": "node ./.husky/setup.js", 9 | "husky:pre-commit": "lint-staged", 10 | "husky:commit-msg": "commitlint --edit ${1}", 11 | "bundle": "rimraf dist && pnpm run build && gulp bundle", 12 | "test": "pnpm run tscheck", 13 | "tscheck": "tsc --noEmit", 14 | "watch": "tsc --watch", 15 | "build": "tsc --sourceMap false" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/Semibold/Extensions-Steward.git" 20 | }, 21 | "author": "Aqours", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/Semibold/Extensions-Steward/issues" 25 | }, 26 | "homepage": "https://github.com/Semibold/Extensions-Steward#readme", 27 | "prettier": { 28 | "singleQuote": false, 29 | "trailingComma": "all" 30 | }, 31 | "lint-staged": { 32 | "**/*": "prettier --write --ignore-unknown" 33 | }, 34 | "devDependencies": { 35 | "@commitlint/cli": "^18.4.3", 36 | "@commitlint/config-conventional": "^18.4.3", 37 | "@types/chrome": "latest", 38 | "@types/gulp": "^4.0.6", 39 | "@types/merge-stream": "^1.1.2", 40 | "@types/node": "^20.9.3", 41 | "chrome-webstore-upload-cli": "^2.2.2", 42 | "ci-info": "^4.0.0", 43 | "gulp": "^4.0.2", 44 | "gulp-json-modify": "^1.0.2", 45 | "gulp-zip": "^5.0.1", 46 | "husky": "^8.0.3", 47 | "lint-staged": "^15.1.0", 48 | "merge-stream": "^2.0.0", 49 | "npm-check-updates": "^16.14.11", 50 | "prettier": "^3.1.0", 51 | "rimraf": "^5.0.5", 52 | "typescript": "^5.3.2" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/scripts/sharre/constant.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @desc chrome.StorageArea.sync 3 | */ 4 | export const K_EXTENSION_TYPE_CHECKED = "extension_type_checked"; // 用于存储扩展类型的状态 5 | export const K_AUTO_DISPLAY_CHANGELOG = "auto_display_changelog"; 6 | export const K_KEEP_LAST_SEARCH_STATUS = "keep_last_search_status"; 7 | 8 | /** 9 | * @desc for `popup` page 10 | * @desc chrome.StorageArea.local 11 | */ 12 | export const K_DISABLED_EXTENSION_ID = "disabled_extension_id"; // 用于存储被批量方式禁用的扩展ID 13 | export const K_LAST_SEARCH_USER_INPUT = "last_search_user_input"; 14 | 15 | /** 16 | * @enum 17 | * @see https://developer.chrome.com/extensions/management#type-ExtensionType 18 | */ 19 | export type ChromeExtensionType = 20 | | "extension" 21 | | "hosted_app" 22 | | "packaged_app" 23 | | "legacy_packaged_app" 24 | | "theme" 25 | | "login_screen_extension"; 26 | 27 | /** 28 | * @static 29 | */ 30 | export class PConfig { 31 | /** 32 | * @enum 33 | * @see https://developer.chrome.com/extensions/management#type-ExtensionType 34 | */ 35 | static get eTypeChecked() { 36 | return { 37 | extension: true, 38 | hosted_app: false, 39 | packaged_app: false, 40 | legacy_packaged_app: false, 41 | theme: false, 42 | login_screen_extension: false, 43 | }; 44 | } 45 | 46 | /** 47 | * @enum 48 | * @see https://developer.chrome.com/extensions/management#type-ExtensionType 49 | */ 50 | static get eTypeDisabled() { 51 | return { 52 | extension: true, 53 | hosted_app: false, 54 | packaged_app: false, 55 | legacy_packaged_app: false, 56 | theme: false, 57 | login_screen_extension: false, 58 | }; 59 | } 60 | 61 | /** 62 | * @desc 默认的选项配置 63 | */ 64 | static get defaultOptions() { 65 | return { 66 | autoDisplayChangelog: true, 67 | keepLastSearchStatus: false, 68 | }; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/scripts/options.ts: -------------------------------------------------------------------------------- 1 | import { locale } from "./sharre/locale.js"; 2 | import { 3 | PConfig, 4 | K_EXTENSION_TYPE_CHECKED, 5 | K_AUTO_DISPLAY_CHANGELOG, 6 | K_KEEP_LAST_SEARCH_STATUS, 7 | ChromeExtensionType, 8 | } from "./sharre/constant.js"; 9 | import { chromeStorageSync } from "./sharre/chrome-storage.js"; 10 | 11 | /** 12 | * @desc i18n 13 | */ 14 | locale(); 15 | 16 | function setOptionsItem(node: HTMLInputElement, items: Record, key: string) { 17 | if (!node) return; 18 | node.checked = Boolean(items[key]); 19 | node.addEventListener("click", (e) => { 20 | const target = e.target as HTMLInputElement; 21 | const checked = target.checked; 22 | chrome.storage.sync.set({ [key]: checked }, () => { 23 | if (chrome.runtime.lastError) { 24 | node.checked = !checked; 25 | } 26 | }); 27 | }); 28 | } 29 | 30 | chromeStorageSync.promise.then((items) => { 31 | setOptionsItem(document.querySelector(`input[value="update_details"]`), items, K_AUTO_DISPLAY_CHANGELOG); 32 | setOptionsItem(document.querySelector(`input[value="search_status"]`), items, K_KEEP_LAST_SEARCH_STATUS); 33 | }); 34 | 35 | /** 36 | * @desc 配置数据同步 37 | */ 38 | chromeStorageSync.promise.then((items) => { 39 | const eTypeChecked = Object.assign(PConfig.eTypeChecked, items[K_EXTENSION_TYPE_CHECKED]); 40 | for (const [type, checked] of Object.entries(eTypeChecked)) { 41 | const node = document.querySelector(`input[value="${type}"]`); 42 | const disabled = PConfig.eTypeDisabled[type as ChromeExtensionType]; 43 | if (!node) continue; 44 | node.checked = checked; 45 | node.disabled = disabled; 46 | node.addEventListener("click", (e) => { 47 | if (!disabled) { 48 | eTypeChecked[type as ChromeExtensionType] = node.checked; 49 | chrome.storage.sync.set({ 50 | [K_EXTENSION_TYPE_CHECKED]: eTypeChecked, 51 | }); 52 | } 53 | }); 54 | } 55 | }); 56 | -------------------------------------------------------------------------------- /src/_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "ext_name": { 3 | "message": "Extensions Steward" 4 | }, 5 | "ext_desc": { 6 | "message": "Extensions Steward, Extensions Manager" 7 | }, 8 | "disable_extension": { 9 | "message": "Disable extension" 10 | }, 11 | "enable_extension": { 12 | "message": "Enable extension" 13 | }, 14 | "one_key_disable": { 15 | "message": "Disable all extensions" 16 | }, 17 | "one_key_restore": { 18 | "message": "Restore disabled extensions" 19 | }, 20 | "omnibox_default_suggestion": { 21 | "message": "Search input in Chrome webstore" 22 | }, 23 | "options_general": { 24 | "message": "Options" 25 | }, 26 | "options_update_details": { 27 | "message": "Notify me to update details" 28 | }, 29 | "options_search_status": { 30 | "message": "Keep the status of the last search" 31 | }, 32 | "options_title": { 33 | "message": "Extensions Steward Options" 34 | }, 35 | "options_extension_type": { 36 | "message": "Select the type(s) which you want to manage" 37 | }, 38 | "options_extension": { 39 | "message": "Extension", 40 | "description": "No translation required" 41 | }, 42 | "options_hosted_app": { 43 | "message": "Hosted app", 44 | "description": "No translation required" 45 | }, 46 | "options_packaged_app": { 47 | "message": "Packaged app", 48 | "description": "No translation required" 49 | }, 50 | "options_legacy_packaged_app": { 51 | "message": "Legacy packaged app", 52 | "description": "No translation required" 53 | }, 54 | "options_theme": { 55 | "message": "Theme", 56 | "description": "No translation required" 57 | }, 58 | "options_login_screen_extension": { 59 | "message": "Login screen extension", 60 | "description": "No translation required" 61 | }, 62 | "menu_feedback": { 63 | "message": "Feedback" 64 | }, 65 | "author_email": { 66 | "message": "abc@hub.moe" 67 | }, 68 | "project_issue": { 69 | "message": "https://github.com/Semibold/Extensions-Steward/issues" 70 | }, 71 | "project_readme": { 72 | "message": "https://github.com/Semibold/Extensions-Steward/blob/master/README.md" 73 | }, 74 | "project_changelog": { 75 | "message": "https://github.com/Semibold/Extensions-Steward/blob/master/changelog.md" 76 | } 77 | } -------------------------------------------------------------------------------- /src/sheets/popup.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --act-color: #333; 3 | --act-bg-color: #fff; 4 | --act-em-color: #000; 5 | --act-em-bg-color: yellow; 6 | --act-disabled-bg-color: rgb(229 233 239); 7 | --act-shadow-color-1: #e3e3e3; 8 | --act-shadow-color-2: rgb(229 233 239 / 0.3); 9 | --act-img-filter-contrast: 1; 10 | } 11 | 12 | @media (prefers-color-scheme: dark) { 13 | :root { 14 | --act-color: #fff; 15 | --act-bg-color: #353535; 16 | --act-disabled-bg-color: #222; 17 | --act-shadow-color-1: #333; 18 | --act-shadow-color-2: #fff; 19 | --act-img-filter-contrast: 0.6; 20 | } 21 | } 22 | 23 | body { 24 | color: var(--act-color); 25 | background-color: var(--act-bg-color); 26 | } 27 | 28 | #app { 29 | font-size: 14px; 30 | user-select: none; 31 | width: 280px; 32 | } 33 | 34 | h1 { 35 | margin-top: 0; 36 | margin-bottom: 0; 37 | font-size: 14px; 38 | font-weight: normal; 39 | text-align: center; 40 | } 41 | 42 | ul { 43 | margin-top: 0; 44 | margin-bottom: 0; 45 | padding-left: 0; 46 | list-style-type: none; 47 | } 48 | 49 | em, 50 | h1, 51 | ul li { 52 | margin: 6px 4px; 53 | height: 32px; 54 | line-height: 32px; 55 | border-radius: 2px; 56 | box-shadow: 0 0 0 1px var(--act-shadow-color-1), 0 0 2px 0 var(--act-shadow-color-2); 57 | transition: background-color 0.15s; 58 | overflow: hidden; 59 | cursor: pointer; 60 | } 61 | 62 | ul li { 63 | display: flex; 64 | align-items: center; 65 | } 66 | 67 | ul li[data-enabled="false"] { 68 | background-color: var(--act-disabled-bg-color); 69 | } 70 | 71 | img { 72 | flex: 0 0 auto; 73 | margin: auto 4px auto 8px; 74 | width: 16px; 75 | height: 16px; 76 | filter: contrast(var(--act-img-filter-contrast)); 77 | } 78 | 79 | span { 80 | flex: 1 1 100%; 81 | margin: auto 8px auto 4px; 82 | text-overflow: ellipsis; 83 | white-space: nowrap; 84 | overflow: hidden; 85 | } 86 | 87 | em { 88 | position: absolute; 89 | top: 0; 90 | left: 0; 91 | padding: 0 8px; 92 | width: 272px; 93 | font-style: normal; 94 | color: var(--act-em-color); 95 | background-color: var(--act-em-bg-color); 96 | box-sizing: border-box; 97 | z-index: 100; 98 | cursor: default; 99 | } 100 | 101 | em:empty { 102 | display: none; 103 | } 104 | -------------------------------------------------------------------------------- /src/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | __MSG_options_title__ 6 | 7 | 8 | 9 |
10 |

11 |
12 | 16 |
17 |
18 | 22 |
23 |

24 |
25 | 29 |
30 |
31 | 35 |
36 |
37 | 41 |
42 |
43 | 47 |
48 |
49 | 53 |
54 |
55 | 59 |
60 |
61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /test/keyword-search.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Keyword Search 6 | 7 | 8 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /src/scripts/sharre/chrome-storage.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChromeExtensionType, 3 | K_AUTO_DISPLAY_CHANGELOG, 4 | K_DISABLED_EXTENSION_ID, 5 | K_EXTENSION_TYPE_CHECKED, 6 | K_KEEP_LAST_SEARCH_STATUS, 7 | K_LAST_SEARCH_USER_INPUT, 8 | PConfig, 9 | } from "./constant.js"; 10 | 11 | interface IChromeStorageLocalInfo { 12 | [K_DISABLED_EXTENSION_ID]: string; 13 | [K_LAST_SEARCH_USER_INPUT]: string; 14 | } 15 | 16 | interface IChromeStorageSyncInfo { 17 | [K_EXTENSION_TYPE_CHECKED]: Record; 18 | [K_AUTO_DISPLAY_CHANGELOG]: boolean; 19 | [K_KEEP_LAST_SEARCH_STATUS]: boolean; 20 | } 21 | 22 | const SADCache = new Map(); 23 | 24 | async function initializeStorageArea( 25 | areaName: chrome.storage.AreaName, 26 | keys?: Required, 27 | ): Promise { 28 | const promise = chrome.storage[areaName].get(keys) as Promise; 29 | promise.then((data) => SADCache.set(areaName, data)); 30 | return promise; 31 | } 32 | 33 | chrome.storage.onChanged.addListener((changes, areaName) => { 34 | const data: Record = SADCache.get(areaName) || Object.create(null); 35 | 36 | for (let [key, { oldValue, newValue }] of Object.entries(changes)) { 37 | data[key] = newValue; 38 | } 39 | 40 | SADCache.set(areaName, data); 41 | }); 42 | 43 | class ChromeStorageArea { 44 | readonly __initPromise: Promise; 45 | 46 | get promise(): Promise { 47 | if (SADCache.has(this.areaName)) { 48 | return Promise.resolve(SADCache.get(this.areaName)) as Promise; 49 | } else { 50 | return this.__initPromise; 51 | } 52 | } 53 | 54 | /** 55 | * @desc 要么提供全部的 keys 参数, 要么不提供。 56 | */ 57 | constructor( 58 | readonly areaName: chrome.storage.AreaName, 59 | keys?: Required, 60 | ) { 61 | this.__initPromise = initializeStorageArea(this.areaName, keys); 62 | } 63 | 64 | get(): T { 65 | return (SADCache.get(this.areaName) || Object.create(null)) as T; 66 | } 67 | 68 | set(items: Partial): Promise { 69 | return chrome.storage[this.areaName].set(items); 70 | } 71 | } 72 | 73 | export const chromeStorageLocal = new ChromeStorageArea("local"); 74 | 75 | export const chromeStorageSync = new ChromeStorageArea("sync", { 76 | [K_EXTENSION_TYPE_CHECKED]: null, 77 | [K_AUTO_DISPLAY_CHANGELOG]: PConfig.defaultOptions.autoDisplayChangelog, 78 | [K_KEEP_LAST_SEARCH_STATUS]: PConfig.defaultOptions.keepLastSearchStatus, 79 | }); 80 | -------------------------------------------------------------------------------- /src/scripts/background/keyword-search.ts: -------------------------------------------------------------------------------- 1 | import { getPinyinFromHanzi, IToken } from "./hanzi-to-pinyin.js"; 2 | 3 | interface IKeywordCacheItem { 4 | item: chrome.management.ExtensionInfo; 5 | tokens: IToken[]; 6 | contents: { target: string; source: string }; 7 | } 8 | 9 | /** 10 | * Search chrome extensions in background page 11 | */ 12 | export class KeywordSearch { 13 | caches: Map; 14 | separatorSymbol: string; 15 | 16 | constructor() { 17 | this.caches = new Map(); 18 | this.separatorSymbol = "@"; 19 | this.generateAllCaches(); 20 | this.registerEvents(); 21 | } 22 | 23 | /** 24 | * @private 25 | */ 26 | generateAllCaches() { 27 | chrome.management.getAll((result) => { 28 | for (const item of result) { 29 | this.addItemCache(item); 30 | } 31 | }); 32 | } 33 | 34 | /** 35 | * @private 36 | * @param {chrome.management.ExtensionInfo} item 37 | */ 38 | addItemCache(item: chrome.management.ExtensionInfo) { 39 | const tokens = getPinyinFromHanzi(item.shortName || item.name); 40 | const contents = { target: "", source: "" }; 41 | for (const token of tokens) { 42 | contents.target += token.target.toLowerCase(); 43 | contents.source += token.source.toLowerCase(); 44 | } 45 | this.caches.set(item.id, { item, tokens, contents }); 46 | } 47 | 48 | /** 49 | * @private 50 | * @param {chrome.management.ExtensionInfo} item 51 | */ 52 | syncItemCache(item: chrome.management.ExtensionInfo) { 53 | if (this.caches.has(item.id)) { 54 | const cache = this.caches.get(item.id); 55 | this.caches.set(item.id, { item, tokens: cache.tokens, contents: cache.contents }); 56 | } else { 57 | this.addItemCache(item); 58 | } 59 | } 60 | 61 | /** 62 | * @private 63 | * @param {string} id 64 | */ 65 | removeItemCache(id: string) { 66 | this.caches.delete(id); 67 | } 68 | 69 | /** 70 | * @private 71 | */ 72 | registerEvents() { 73 | chrome.management.onEnabled.addListener((item) => this.syncItemCache(item)); 74 | chrome.management.onDisabled.addListener((item) => this.syncItemCache(item)); 75 | chrome.management.onInstalled.addListener((item) => this.addItemCache(item)); 76 | chrome.management.onUninstalled.addListener((id) => this.removeItemCache(id)); 77 | } 78 | 79 | /** 80 | * @public 81 | * @param {string} input - User input 82 | * @return {chrome.management.ExtensionInfo[]} 83 | */ 84 | search(input: string): chrome.management.ExtensionInfo[] { 85 | if (!input) { 86 | return this.filterByQualifier(); 87 | } 88 | const segments = input.trim().toLowerCase().split(this.separatorSymbol); 89 | const keyword = segments.shift(); 90 | const qualifier = segments.pop(); 91 | if (!keyword) { 92 | return this.filterByQualifier(qualifier); 93 | } 94 | const result: IKeywordCacheItem[] = []; 95 | const chars = Array.from(keyword); 96 | for (const cache of this.caches.values()) { 97 | if (this.isKeywordMatchName(cache.item.id, chars)) { 98 | result.push(cache); 99 | } 100 | } 101 | return this.filterByQualifier(qualifier, result); 102 | } 103 | 104 | /** 105 | * @private 106 | */ 107 | filterByQualifier(qualifier?: string, partial?: IKeywordCacheItem[]) { 108 | const result: chrome.management.ExtensionInfo[] = (partial || Array.from(this.caches.values())).map( 109 | (cache) => cache.item, 110 | ); 111 | switch (qualifier) { 112 | // 打开、启用 113 | case "dk": 114 | case "dakai": 115 | case "qy": 116 | case "qiyong": 117 | case "on": 118 | case "enable": 119 | case "enabled": 120 | return result.filter((item) => item.enabled); 121 | // 关闭、禁用 122 | case "gb": 123 | case "guanbi": 124 | case "jy": 125 | case "jinyong": 126 | case "off": 127 | case "disable": 128 | case "disabled": 129 | return result.filter((item) => !item.enabled); 130 | default: 131 | return result; 132 | } 133 | } 134 | 135 | /** 136 | * @private 137 | */ 138 | isKeywordMatchName(id: string, chars: string[]) { 139 | const cache = this.caches.get(id); 140 | const pointers = { target: 0, source: 0 }; 141 | for (const char of chars) { 142 | if (pointers.target !== -1) { 143 | pointers.target = cache.contents.target.indexOf(char, pointers.target); 144 | } 145 | if (pointers.source !== -1) { 146 | pointers.source = cache.contents.source.indexOf(char, pointers.source); 147 | } 148 | if (pointers.target === -1 && pointers.source === -1) { 149 | return false; 150 | } 151 | } 152 | return true; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/scripts/popup.ts: -------------------------------------------------------------------------------- 1 | import { 2 | K_DISABLED_EXTENSION_ID, 3 | K_EXTENSION_TYPE_CHECKED, 4 | K_KEEP_LAST_SEARCH_STATUS, 5 | K_LAST_SEARCH_USER_INPUT, 6 | PConfig, 7 | } from "./sharre/constant.js"; 8 | import { chromeStorageLocal, chromeStorageSync } from "./sharre/chrome-storage.js"; 9 | 10 | class ExtensionManager { 11 | excludeTypeSet: Set; 12 | enableLastSearchStatus: boolean; 13 | maxIconSize: number; 14 | renderTimer: number; 15 | maxInputLength: number; 16 | lastSearchUserInput: string; 17 | diagramWeakMap: WeakMap; 18 | disabledExtensionIdSet: Set; 19 | fragment: DocumentFragment; 20 | container: HTMLElement; 21 | 22 | /** 23 | * @param {Set} excludeTypeSet 24 | * @param {boolean} enableLastSearchStatus 25 | */ 26 | constructor(excludeTypeSet: Set, enableLastSearchStatus: boolean) { 27 | this.excludeTypeSet = excludeTypeSet; 28 | this.enableLastSearchStatus = enableLastSearchStatus; 29 | this.maxIconSize = 64; 30 | this.renderTimer = -1; 31 | this.maxInputLength = 64; 32 | this.lastSearchUserInput = ""; 33 | this.diagramWeakMap = new WeakMap(); 34 | this.disabledExtensionIdSet = new Set(); 35 | this.fragment = document.createDocumentFragment(); 36 | this.container = document.getElementById("app"); 37 | this.init(); 38 | } 39 | 40 | async init() { 41 | await this.getLastSearchUserInput(); 42 | await this.getDisabledExtensionIds(); 43 | await this.renderFrameContent(); 44 | this.registerAutoFocusEvent(); 45 | this.registerUserInputEvent(); 46 | this.registerOtherEvents(); 47 | } 48 | 49 | async getLastSearchUserInput() { 50 | if (this.enableLastSearchStatus) { 51 | chromeStorageLocal.promise.then((items) => { 52 | const data = items[K_LAST_SEARCH_USER_INPUT]; 53 | if (typeof data === "string") { 54 | this.lastSearchUserInput = data; 55 | } 56 | }); 57 | } 58 | } 59 | 60 | setLastSearchUserInput() { 61 | chromeStorageLocal.set({ 62 | [K_LAST_SEARCH_USER_INPUT]: this.lastSearchUserInput, 63 | }); 64 | } 65 | 66 | async getDisabledExtensionIds() { 67 | return chromeStorageLocal.promise.then((items) => { 68 | const data = items[K_DISABLED_EXTENSION_ID]; 69 | const list = data ? data.split(",") : []; 70 | list.forEach((id) => this.disabledExtensionIdSet.add(id)); 71 | }); 72 | } 73 | 74 | setDisabledExtensionIds() { 75 | chromeStorageLocal.set({ 76 | [K_DISABLED_EXTENSION_ID]: Array.from(this.disabledExtensionIdSet).join(","), 77 | }); 78 | } 79 | 80 | resetContainerContents() { 81 | this.container.textContent = ""; 82 | } 83 | 84 | async getTargetExtensionInfos(input = ""): Promise { 85 | return Array.from(await this.fetchKeywordSearch(input)).filter( 86 | (item) => !(item.id === chrome.runtime.id || this.excludeTypeSet.has(item.type)), 87 | ); 88 | } 89 | 90 | /** 91 | * Fetches keyword search results. 92 | * 93 | * @param {string} input - The keyword to search for. 94 | * @return {Promise} A promise that resolves to an array of ExtensionInfo objects. 95 | */ 96 | async fetchKeywordSearch(input: string): Promise { 97 | return new Promise((resolve, reject) => { 98 | chrome.runtime.sendMessage( 99 | { 100 | type: "keywordSearch", 101 | input: input, 102 | }, 103 | (result) => { 104 | if (chrome.runtime.lastError) { 105 | reject(chrome.runtime.lastError); 106 | } else { 107 | resolve(result); 108 | } 109 | }, 110 | ); 111 | }); 112 | } 113 | 114 | async renderFrameContent() { 115 | const em = document.createElement("em"); 116 | const h1 = document.createElement("h1"); 117 | const ul = document.createElement("ul"); 118 | const list = await this.getTargetExtensionInfos(this.lastSearchUserInput); 119 | list.sort((prev, next) => prev.name.localeCompare(next.name, "en-US")).forEach((item) => 120 | this.renderItemContent(ul, item), 121 | ); 122 | this.renderLastSearchUserInput(em); 123 | this.renderFrameState(h1); 124 | this.fragment.append(em, h1, ul); 125 | this.resetContainerContents(); 126 | this.container.append(this.fragment); 127 | } 128 | 129 | /** 130 | * @param {HTMLElement} h1 131 | */ 132 | renderFrameState(h1: HTMLElement) { 133 | h1.tabIndex = this.lastSearchUserInput.length ? -1 : 0; 134 | h1.textContent = chrome.i18n.getMessage( 135 | this.disabledExtensionIdSet.size ? "one_key_restore" : "one_key_disable", 136 | ); 137 | } 138 | 139 | /** 140 | * @param {HTMLElement} [em] 141 | */ 142 | renderLastSearchUserInput(em?: HTMLElement) { 143 | const node = em || this.container.querySelector("em"); 144 | if (em) node.textContent = this.lastSearchUserInput; 145 | } 146 | 147 | /** 148 | * @param {HTMLElement} ul 149 | * @param {chrome.management.ExtensionInfo} item 150 | */ 151 | renderItemContent(ul: HTMLUListElement, item: chrome.management.ExtensionInfo) { 152 | const li = document.createElement("li"); 153 | const img = document.createElement("img"); 154 | const span = document.createElement("span"); 155 | li.tabIndex = 0; 156 | li.append(img, span); 157 | ul.append(li); 158 | this.renderItemState(li, item); 159 | this.diagramWeakMap.set(li, item.id); 160 | } 161 | 162 | /** 163 | * @param {HTMLElement} li 164 | * @param {chrome.management.ExtensionInfo} item 165 | */ 166 | renderItemState(li: HTMLElement, item: chrome.management.ExtensionInfo) { 167 | const img = li.querySelector("img"); 168 | const span = li.querySelector("span"); 169 | const iconInfo = { size: 32, url: `chrome://extension-icon/${item.id}/32/0` }; 170 | if (item.icons && item.icons.length) { 171 | const firstIcon = item.icons[0]; 172 | const restIcons = item.icons.slice(1); 173 | iconInfo.size = firstIcon.size; 174 | iconInfo.url = firstIcon.url; 175 | for (const icon of restIcons) { 176 | if (icon.size > iconInfo.size && icon.size < this.maxIconSize) { 177 | iconInfo.size = icon.size; 178 | iconInfo.url = icon.url; 179 | } 180 | } 181 | } 182 | span.textContent = img.alt = item.shortName || item.name; 183 | img.src = `${iconInfo.url}${item.enabled ? "" : "?grayscale=true"}`; 184 | li.title = `${item.name}\n${chrome.i18n.getMessage(item.enabled ? "disable_extension" : "enable_extension")}`; 185 | li.dataset.enabled = item.enabled.toString(); 186 | } 187 | 188 | registerAutoFocusEvent() { 189 | this.container.addEventListener("mouseover", (e) => { 190 | const node = e.target as HTMLElement; 191 | if (!node) return; 192 | const s = node.closest("h1") || node.closest("li"); 193 | if (s && s !== document.activeElement) { 194 | s.focus(); 195 | } 196 | }); 197 | } 198 | 199 | registerUserInputEvent() { 200 | // prettier-ignore 201 | const validCharSet = new Set([ 202 | 'Escape', 'Backspace', '@', 203 | '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 204 | 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 205 | 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 206 | ]); 207 | document.addEventListener("keydown", (e) => { 208 | if (validCharSet.has(e.key)) { 209 | const startLength = this.lastSearchUserInput.length; 210 | switch (e.key) { 211 | case "Escape": 212 | if (this.lastSearchUserInput) { 213 | e.preventDefault(); 214 | this.lastSearchUserInput = ""; 215 | } 216 | break; 217 | case "Backspace": 218 | if (this.lastSearchUserInput) { 219 | e.preventDefault(); 220 | this.lastSearchUserInput = this.lastSearchUserInput.slice(0, -1); 221 | } 222 | break; 223 | default: 224 | if (this.lastSearchUserInput.length <= this.maxInputLength) { 225 | e.preventDefault(); 226 | this.lastSearchUserInput += e.key; 227 | } 228 | break; 229 | } 230 | if (startLength !== this.lastSearchUserInput.length) { 231 | this.renderLastSearchUserInput(); 232 | this.continuousFilterFrameContent(); 233 | } 234 | } 235 | }); 236 | } 237 | 238 | continuousFilterFrameContent() { 239 | clearTimeout(this.renderTimer); 240 | this.renderTimer = window.setTimeout(() => { 241 | this.renderFrameContent(); 242 | this.setLastSearchUserInput(); 243 | }, 100); 244 | } 245 | 246 | registerOtherEvents() { 247 | this.container.addEventListener("click", (e) => { 248 | const node = e.target as HTMLElement; 249 | if (!node) return; 250 | if (node.closest("h1")) { 251 | this.disabledExtensionIdSet.size ? this.oneKeyRestore() : this.oneKeyDisable(); 252 | } else { 253 | const li = node.closest("li"); 254 | if (this.diagramWeakMap.has(li)) { 255 | chrome.management.get(this.diagramWeakMap.get(li), (item) => 256 | chrome.management.setEnabled(item.id, !item.enabled), 257 | ); 258 | } 259 | } 260 | }); 261 | document.addEventListener("keyup", (e) => { 262 | const node = e.target as HTMLElement; 263 | if (!node) return; 264 | if (e.key === "Enter") { 265 | const s = node.closest("h1") || node.closest("li"); 266 | if (s) { 267 | e.preventDefault(); 268 | s.click(); 269 | } 270 | } 271 | }); 272 | chrome.management.onEnabled.addListener((item) => this.toggleItemState(item)); 273 | chrome.management.onDisabled.addListener((item) => this.toggleItemState(item)); 274 | chrome.management.onInstalled.addListener((item) => this.continuousFilterFrameContent()); 275 | chrome.management.onUninstalled.addListener((id) => this.continuousFilterFrameContent()); 276 | } 277 | 278 | /** 279 | * Toggles the state of an item. 280 | */ 281 | toggleItemState(item: chrome.management.ExtensionInfo) { 282 | for (const li of document.querySelectorAll("li")) { 283 | if (this.diagramWeakMap.has(li)) { 284 | if (this.diagramWeakMap.get(li) === item.id) { 285 | this.renderItemState(li, item); 286 | break; 287 | } 288 | } 289 | } 290 | } 291 | 292 | /** 293 | * Asynchronously disables all enabled extensions. 294 | */ 295 | async oneKeyDisable() { 296 | const list = await this.getTargetExtensionInfos(); 297 | const filtered = list.filter((item) => item.enabled); 298 | const tailId = Boolean(filtered.length) && filtered[filtered.length - 1].id; 299 | while (filtered.length) { 300 | const item = filtered.shift(); 301 | chrome.management.setEnabled(item.id, false, () => { 302 | this.disabledExtensionIdSet.add(item.id); 303 | if (item.id === tailId) { 304 | const h1 = this.container.querySelector("h1"); 305 | this.setDisabledExtensionIds(); 306 | if (h1) { 307 | h1.textContent = chrome.i18n.getMessage("one_key_restore"); 308 | } 309 | } 310 | }); 311 | } 312 | } 313 | 314 | /** 315 | * Asynchronously restores the target extension by enabling previously disabled extensions. 316 | */ 317 | async oneKeyRestore() { 318 | const list = await this.getTargetExtensionInfos(); 319 | const disabledRecently = Array.from(this.disabledExtensionIdSet); 320 | const disabledExisting = new Set( 321 | list.map((item) => { 322 | if (!item.enabled) { 323 | return item.id; 324 | } 325 | }), 326 | ); 327 | const h1 = this.container.querySelector("h1"); 328 | while (disabledRecently.length) { 329 | const id = disabledRecently.shift(); 330 | disabledExisting.has(id) && chrome.management.setEnabled(id, true); 331 | } 332 | this.disabledExtensionIdSet.clear(); 333 | this.setDisabledExtensionIds(); 334 | if (h1) { 335 | h1.textContent = chrome.i18n.getMessage("one_key_disable"); 336 | } 337 | } 338 | } 339 | 340 | chromeStorageSync.promise.then((items) => { 341 | const excludeTypeSet: Set = new Set(); 342 | const eTypeChecked = Object.assign(PConfig.eTypeChecked, items[K_EXTENSION_TYPE_CHECKED]); 343 | const enableLastSearchStatus = Boolean( 344 | items[K_KEEP_LAST_SEARCH_STATUS] == null 345 | ? PConfig.defaultOptions.keepLastSearchStatus 346 | : items[K_KEEP_LAST_SEARCH_STATUS], 347 | ); 348 | for (const [type, checked] of Object.entries(eTypeChecked)) { 349 | if (!checked) excludeTypeSet.add(type); 350 | } 351 | new ExtensionManager(excludeTypeSet, enableLastSearchStatus); 352 | }); 353 | -------------------------------------------------------------------------------- /src/scripts/background/hanzi-to-pinyin.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * An object to convert Chinese character to its corresponding pinyin string. For characters with 3 | * multiple possible pinyin string, only one is selected according to collator. Polyphone is not 4 | * supported in this implementation. This class is implemented to achieve the best runtime 5 | * performance and minimum runtime resources with tolerable sacrifice of accuracy. This 6 | * implementation highly depends on zh_CN ICU collation data and must be always synchronized with 7 | * ICU. 8 | * 9 | * Currently this file is aligned to zh.txt in ICU 4.6 10 | * 11 | * @see https://android.googlesource.com/platform/packages/providers/ContactsProvider/+/0c49720fb3d58e346739c2ccd56ed2b739249e07/src/com/android/providers/contacts/HanziToPinyin.java 12 | */ 13 | 14 | export interface IToken { 15 | type: number; 16 | target: string; 17 | source: string; 18 | } 19 | 20 | const TAG = "[HanziToPinyin]"; 21 | const LOCALE_CHINA = "zh-Hans-CN"; 22 | 23 | // Turn on this flag when we want to check internal data structure. 24 | const DEBUG = false; 25 | 26 | // prettier-ignore 27 | const UNIHANS = [ 28 | '\u963f', '\u54ce', '\u5b89', '\u80ae', '\u51f9', '\u516b', 29 | '\u6300', '\u6273', '\u90a6', '\u52f9', '\u9642', '\u5954', 30 | '\u4f3b', '\u5c44', '\u8fb9', '\u706c', '\u618b', '\u6c43', 31 | '\u51ab', '\u7676', '\u5cec', '\u5693', '\u5072', '\u53c2', 32 | '\u4ed3', '\u64a1', '\u518a', '\u5d7e', '\u66fd', '\u66fe', 33 | '\u5c64', '\u53c9', '\u8286', '\u8fbf', '\u4f25', '\u6284', 34 | '\u8f66', '\u62bb', '\u6c88', '\u6c89', '\u9637', '\u5403', 35 | '\u5145', '\u62bd', '\u51fa', '\u6b3b', '\u63e3', '\u5ddb', 36 | '\u5205', '\u5439', '\u65fe', '\u9034', '\u5472', '\u5306', 37 | '\u51d1', '\u7c97', '\u6c46', '\u5d14', '\u90a8', '\u6413', 38 | '\u5491', '\u5446', '\u4e39', '\u5f53', '\u5200', '\u561a', 39 | '\u6265', '\u706f', '\u6c10', '\u55f2', '\u7538', '\u5201', 40 | '\u7239', '\u4e01', '\u4e1f', '\u4e1c', '\u543a', '\u53be', 41 | '\u8011', '\u8968', '\u5428', '\u591a', '\u59b8', '\u8bf6', 42 | '\u5940', '\u97a5', '\u513f', '\u53d1', '\u5e06', '\u531a', 43 | '\u98de', '\u5206', '\u4e30', '\u8985', '\u4ecf', '\u7d11', 44 | '\u4f15', '\u65ee', '\u4f85', '\u7518', '\u5188', '\u768b', 45 | '\u6208', '\u7ed9', '\u6839', '\u522f', '\u5de5', '\u52fe', 46 | '\u4f30', '\u74dc', '\u4e56', '\u5173', '\u5149', '\u5f52', 47 | '\u4e28', '\u5459', '\u54c8', '\u548d', '\u4f44', '\u592f', 48 | '\u8320', '\u8bc3', '\u9ed2', '\u62eb', '\u4ea8', '\u5677', 49 | '\u53ff', '\u9f41', '\u4e6f', '\u82b1', '\u6000', '\u72bf', 50 | '\u5ddf', '\u7070', '\u660f', '\u5419', '\u4e0c', '\u52a0', 51 | '\u620b', '\u6c5f', '\u827d', '\u9636', '\u5dfe', '\u5755', 52 | '\u5182', '\u4e29', '\u51e5', '\u59e2', '\u5658', '\u519b', 53 | '\u5494', '\u5f00', '\u520a', '\u5ffc', '\u5c3b', '\u533c', 54 | '\u808e', '\u52a5', '\u7a7a', '\u62a0', '\u625d', '\u5938', 55 | '\u84af', '\u5bbd', '\u5321', '\u4e8f', '\u5764', '\u6269', 56 | '\u5783', '\u6765', '\u5170', '\u5577', '\u635e', '\u808b', 57 | '\u52d2', '\u5d1a', '\u5215', '\u4fe9', '\u5941', '\u826f', 58 | '\u64a9', '\u5217', '\u62ce', '\u5222', '\u6e9c', '\u56d6', 59 | '\u9f99', '\u779c', '\u565c', '\u5a08', '\u7567', '\u62a1', 60 | '\u7f57', '\u5463', '\u5988', '\u57cb', '\u5ada', '\u7264', 61 | '\u732b', '\u4e48', '\u5445', '\u95e8', '\u753f', '\u54aa', 62 | '\u5b80', '\u55b5', '\u4e5c', '\u6c11', '\u540d', '\u8c2c', 63 | '\u6478', '\u54de', '\u6bea', '\u55ef', '\u62cf', '\u8149', 64 | '\u56e1', '\u56d4', '\u5b6c', '\u7592', '\u5a1e', '\u6041', 65 | '\u80fd', '\u59ae', '\u62c8', '\u5b22', '\u9e1f', '\u634f', 66 | '\u56dc', '\u5b81', '\u599e', '\u519c', '\u7fba', '\u5974', 67 | '\u597b', '\u759f', '\u9ec1', '\u90cd', '\u5594', '\u8bb4', 68 | '\u5991', '\u62cd', '\u7705', '\u4e53', '\u629b', '\u5478', 69 | '\u55b7', '\u5309', '\u4e15', '\u56e8', '\u527d', '\u6c15', 70 | '\u59d8', '\u4e52', '\u948b', '\u5256', '\u4ec6', '\u4e03', 71 | '\u6390', '\u5343', '\u545b', '\u6084', '\u767f', '\u4eb2', 72 | '\u72c5', '\u828e', '\u4e18', '\u533a', '\u5cd1', '\u7f3a', 73 | '\u590b', '\u5465', '\u7a63', '\u5a06', '\u60f9', '\u4eba', 74 | '\u6254', '\u65e5', '\u8338', '\u53b9', '\u909a', '\u633c', 75 | '\u5827', '\u5a51', '\u77a4', '\u637c', '\u4ee8', '\u6be2', 76 | '\u4e09', '\u6852', '\u63bb', '\u95aa', '\u68ee', '\u50e7', 77 | '\u6740', '\u7b5b', '\u5c71', '\u4f24', '\u5f30', '\u5962', 78 | '\u7533', '\u8398', '\u6552', '\u5347', '\u5c38', '\u53ce', 79 | '\u4e66', '\u5237', '\u8870', '\u95e9', '\u53cc', '\u8c01', 80 | '\u542e', '\u8bf4', '\u53b6', '\u5fea', '\u635c', '\u82cf', 81 | '\u72fb', '\u590a', '\u5b59', '\u5506', '\u4ed6', '\u56fc', 82 | '\u574d', '\u6c64', '\u5932', '\u5fd1', '\u71a5', '\u5254', 83 | '\u5929', '\u65eb', '\u5e16', '\u5385', '\u56f2', '\u5077', 84 | '\u51f8', '\u6e4d', '\u63a8', '\u541e', '\u4e47', '\u7a75', 85 | '\u6b6a', '\u5f2f', '\u5c23', '\u5371', '\u6637', '\u7fc1', 86 | '\u631d', '\u4e4c', '\u5915', '\u8672', '\u4eda', '\u4e61', 87 | '\u7071', '\u4e9b', '\u5fc3', '\u661f', '\u51f6', '\u4f11', 88 | '\u5401', '\u5405', '\u524a', '\u5743', '\u4e2b', '\u6079', 89 | '\u592e', '\u5e7a', '\u503b', '\u4e00', '\u56d9', '\u5e94', 90 | '\u54df', '\u4f63', '\u4f18', '\u625c', '\u56e6', '\u66f0', 91 | '\u6655', '\u7b60', '\u7b7c', '\u5e00', '\u707d', '\u5142', 92 | '\u5328', '\u50ae', '\u5219', '\u8d3c', '\u600e', '\u5897', 93 | '\u624e', '\u635a', '\u6cbe', '\u5f20', '\u957f', '\u9577', 94 | '\u4f4b', '\u8707', '\u8d1e', '\u4e89', '\u4e4b', '\u5cd9', 95 | '\u5ea2', '\u4e2d', '\u5dde', '\u6731', '\u6293', '\u62fd', 96 | '\u4e13', '\u5986', '\u96b9', '\u5b92', '\u5353', '\u4e72', 97 | '\u5b97', '\u90b9', '\u79df', '\u94bb', '\u539c', '\u5c0a', 98 | '\u6628', '\u5159', '\u9fc3', '\u9fc4', 99 | ]; 100 | 101 | /** 102 | * Pinyin array. 103 | * 104 | * Each pinyin is corresponding to unihans of same 105 | * offset in the unihans array. 106 | */ 107 | // prettier-ignore 108 | const PINYINS = [ 109 | [ 65, 0, 0, 0, 0, 0], [ 65, 73, 0, 0, 0, 0], 110 | [ 65, 78, 0, 0, 0, 0], [ 65, 78, 71, 0, 0, 0], 111 | [ 65, 79, 0, 0, 0, 0], [ 66, 65, 0, 0, 0, 0], 112 | [ 66, 65, 73, 0, 0, 0], [ 66, 65, 78, 0, 0, 0], 113 | [ 66, 65, 78, 71, 0, 0], [ 66, 65, 79, 0, 0, 0], 114 | [ 66, 69, 73, 0, 0, 0], [ 66, 69, 78, 0, 0, 0], 115 | [ 66, 69, 78, 71, 0, 0], [ 66, 73, 0, 0, 0, 0], 116 | [ 66, 73, 65, 78, 0, 0], [ 66, 73, 65, 79, 0, 0], 117 | [ 66, 73, 69, 0, 0, 0], [ 66, 73, 78, 0, 0, 0], 118 | [ 66, 73, 78, 71, 0, 0], [ 66, 79, 0, 0, 0, 0], 119 | [ 66, 85, 0, 0, 0, 0], [ 67, 65, 0, 0, 0, 0], 120 | [ 67, 65, 73, 0, 0, 0], [ 67, 65, 78, 0, 0, 0], 121 | [ 67, 65, 78, 71, 0, 0], [ 67, 65, 79, 0, 0, 0], 122 | [ 67, 69, 0, 0, 0, 0], [ 67, 69, 78, 0, 0, 0], 123 | [ 67, 69, 78, 71, 0, 0], [ 90, 69, 78, 71, 0, 0], 124 | [ 67, 69, 78, 71, 0, 0], [ 67, 72, 65, 0, 0, 0], 125 | [ 67, 72, 65, 73, 0, 0], [ 67, 72, 65, 78, 0, 0], 126 | [ 67, 72, 65, 78, 71, 0], [ 67, 72, 65, 79, 0, 0], 127 | [ 67, 72, 69, 0, 0, 0], [ 67, 72, 69, 78, 0, 0], 128 | [ 83, 72, 69, 78, 0, 0], [ 67, 72, 69, 78, 0, 0], 129 | [ 67, 72, 69, 78, 71, 0], [ 67, 72, 73, 0, 0, 0], 130 | [ 67, 72, 79, 78, 71, 0], [ 67, 72, 79, 85, 0, 0], 131 | [ 67, 72, 85, 0, 0, 0], [ 67, 72, 85, 65, 0, 0], 132 | [ 67, 72, 85, 65, 73, 0], [ 67, 72, 85, 65, 78, 0], 133 | [ 67, 72, 85, 65, 78, 71], [ 67, 72, 85, 73, 0, 0], 134 | [ 67, 72, 85, 78, 0, 0], [ 67, 72, 85, 79, 0, 0], 135 | [ 67, 73, 0, 0, 0, 0], [ 67, 79, 78, 71, 0, 0], 136 | [ 67, 79, 85, 0, 0, 0], [ 67, 85, 0, 0, 0, 0], 137 | [ 67, 85, 65, 78, 0, 0], [ 67, 85, 73, 0, 0, 0], 138 | [ 67, 85, 78, 0, 0, 0], [ 67, 85, 79, 0, 0, 0], 139 | [ 68, 65, 0, 0, 0, 0], [ 68, 65, 73, 0, 0, 0], 140 | [ 68, 65, 78, 0, 0, 0], [ 68, 65, 78, 71, 0, 0], 141 | [ 68, 65, 79, 0, 0, 0], [ 68, 69, 0, 0, 0, 0], 142 | [ 68, 69, 78, 0, 0, 0], [ 68, 69, 78, 71, 0, 0], 143 | [ 68, 73, 0, 0, 0, 0], [ 68, 73, 65, 0, 0, 0], 144 | [ 68, 73, 65, 78, 0, 0], [ 68, 73, 65, 79, 0, 0], 145 | [ 68, 73, 69, 0, 0, 0], [ 68, 73, 78, 71, 0, 0], 146 | [ 68, 73, 85, 0, 0, 0], [ 68, 79, 78, 71, 0, 0], 147 | [ 68, 79, 85, 0, 0, 0], [ 68, 85, 0, 0, 0, 0], 148 | [ 68, 85, 65, 78, 0, 0], [ 68, 85, 73, 0, 0, 0], 149 | [ 68, 85, 78, 0, 0, 0], [ 68, 85, 79, 0, 0, 0], 150 | [ 69, 0, 0, 0, 0, 0], [ 69, 73, 0, 0, 0, 0], 151 | [ 69, 78, 0, 0, 0, 0], [ 69, 78, 71, 0, 0, 0], 152 | [ 69, 82, 0, 0, 0, 0], [ 70, 65, 0, 0, 0, 0], 153 | [ 70, 65, 78, 0, 0, 0], [ 70, 65, 78, 71, 0, 0], 154 | [ 70, 69, 73, 0, 0, 0], [ 70, 69, 78, 0, 0, 0], 155 | [ 70, 69, 78, 71, 0, 0], [ 70, 73, 65, 79, 0, 0], 156 | [ 70, 79, 0, 0, 0, 0], [ 70, 79, 85, 0, 0, 0], 157 | [ 70, 85, 0, 0, 0, 0], [ 71, 65, 0, 0, 0, 0], 158 | [ 71, 65, 73, 0, 0, 0], [ 71, 65, 78, 0, 0, 0], 159 | [ 71, 65, 78, 71, 0, 0], [ 71, 65, 79, 0, 0, 0], 160 | [ 71, 69, 0, 0, 0, 0], [ 71, 69, 73, 0, 0, 0], 161 | [ 71, 69, 78, 0, 0, 0], [ 71, 69, 78, 71, 0, 0], 162 | [ 71, 79, 78, 71, 0, 0], [ 71, 79, 85, 0, 0, 0], 163 | [ 71, 85, 0, 0, 0, 0], [ 71, 85, 65, 0, 0, 0], 164 | [ 71, 85, 65, 73, 0, 0], [ 71, 85, 65, 78, 0, 0], 165 | [ 71, 85, 65, 78, 71, 0], [ 71, 85, 73, 0, 0, 0], 166 | [ 71, 85, 78, 0, 0, 0], [ 71, 85, 79, 0, 0, 0], 167 | [ 72, 65, 0, 0, 0, 0], [ 72, 65, 73, 0, 0, 0], 168 | [ 72, 65, 78, 0, 0, 0], [ 72, 65, 78, 71, 0, 0], 169 | [ 72, 65, 79, 0, 0, 0], [ 72, 69, 0, 0, 0, 0], 170 | [ 72, 69, 73, 0, 0, 0], [ 72, 69, 78, 0, 0, 0], 171 | [ 72, 69, 78, 71, 0, 0], [ 72, 77, 0, 0, 0, 0], 172 | [ 72, 79, 78, 71, 0, 0], [ 72, 79, 85, 0, 0, 0], 173 | [ 72, 85, 0, 0, 0, 0], [ 72, 85, 65, 0, 0, 0], 174 | [ 72, 85, 65, 73, 0, 0], [ 72, 85, 65, 78, 0, 0], 175 | [ 72, 85, 65, 78, 71, 0], [ 72, 85, 73, 0, 0, 0], 176 | [ 72, 85, 78, 0, 0, 0], [ 72, 85, 79, 0, 0, 0], 177 | [ 74, 73, 0, 0, 0, 0], [ 74, 73, 65, 0, 0, 0], 178 | [ 74, 73, 65, 78, 0, 0], [ 74, 73, 65, 78, 71, 0], 179 | [ 74, 73, 65, 79, 0, 0], [ 74, 73, 69, 0, 0, 0], 180 | [ 74, 73, 78, 0, 0, 0], [ 74, 73, 78, 71, 0, 0], 181 | [ 74, 73, 79, 78, 71, 0], [ 74, 73, 85, 0, 0, 0], 182 | [ 74, 85, 0, 0, 0, 0], [ 74, 85, 65, 78, 0, 0], 183 | [ 74, 85, 69, 0, 0, 0], [ 74, 85, 78, 0, 0, 0], 184 | [ 75, 65, 0, 0, 0, 0], [ 75, 65, 73, 0, 0, 0], 185 | [ 75, 65, 78, 0, 0, 0], [ 75, 65, 78, 71, 0, 0], 186 | [ 75, 65, 79, 0, 0, 0], [ 75, 69, 0, 0, 0, 0], 187 | [ 75, 69, 78, 0, 0, 0], [ 75, 69, 78, 71, 0, 0], 188 | [ 75, 79, 78, 71, 0, 0], [ 75, 79, 85, 0, 0, 0], 189 | [ 75, 85, 0, 0, 0, 0], [ 75, 85, 65, 0, 0, 0], 190 | [ 75, 85, 65, 73, 0, 0], [ 75, 85, 65, 78, 0, 0], 191 | [ 75, 85, 65, 78, 71, 0], [ 75, 85, 73, 0, 0, 0], 192 | [ 75, 85, 78, 0, 0, 0], [ 75, 85, 79, 0, 0, 0], 193 | [ 76, 65, 0, 0, 0, 0], [ 76, 65, 73, 0, 0, 0], 194 | [ 76, 65, 78, 0, 0, 0], [ 76, 65, 78, 71, 0, 0], 195 | [ 76, 65, 79, 0, 0, 0], [ 76, 69, 0, 0, 0, 0], 196 | [ 76, 69, 73, 0, 0, 0], [ 76, 69, 78, 71, 0, 0], 197 | [ 76, 73, 0, 0, 0, 0], [ 76, 73, 65, 0, 0, 0], 198 | [ 76, 73, 65, 78, 0, 0], [ 76, 73, 65, 78, 71, 0], 199 | [ 76, 73, 65, 79, 0, 0], [ 76, 73, 69, 0, 0, 0], 200 | [ 76, 73, 78, 0, 0, 0], [ 76, 73, 78, 71, 0, 0], 201 | [ 76, 73, 85, 0, 0, 0], [ 76, 79, 0, 0, 0, 0], 202 | [ 76, 79, 78, 71, 0, 0], [ 76, 79, 85, 0, 0, 0], 203 | [ 76, 85, 0, 0, 0, 0], [ 76, 85, 65, 78, 0, 0], 204 | [ 76, 85, 69, 0, 0, 0], [ 76, 85, 78, 0, 0, 0], 205 | [ 76, 85, 79, 0, 0, 0], [ 77, 0, 0, 0, 0, 0], 206 | [ 77, 65, 0, 0, 0, 0], [ 77, 65, 73, 0, 0, 0], 207 | [ 77, 65, 78, 0, 0, 0], [ 77, 65, 78, 71, 0, 0], 208 | [ 77, 65, 79, 0, 0, 0], [ 77, 69, 0, 0, 0, 0], 209 | [ 77, 69, 73, 0, 0, 0], [ 77, 69, 78, 0, 0, 0], 210 | [ 77, 69, 78, 71, 0, 0], [ 77, 73, 0, 0, 0, 0], 211 | [ 77, 73, 65, 78, 0, 0], [ 77, 73, 65, 79, 0, 0], 212 | [ 77, 73, 69, 0, 0, 0], [ 77, 73, 78, 0, 0, 0], 213 | [ 77, 73, 78, 71, 0, 0], [ 77, 73, 85, 0, 0, 0], 214 | [ 77, 79, 0, 0, 0, 0], [ 77, 79, 85, 0, 0, 0], 215 | [ 77, 85, 0, 0, 0, 0], [ 78, 0, 0, 0, 0, 0], 216 | [ 78, 65, 0, 0, 0, 0], [ 78, 65, 73, 0, 0, 0], 217 | [ 78, 65, 78, 0, 0, 0], [ 78, 65, 78, 71, 0, 0], 218 | [ 78, 65, 79, 0, 0, 0], [ 78, 69, 0, 0, 0, 0], 219 | [ 78, 69, 73, 0, 0, 0], [ 78, 69, 78, 0, 0, 0], 220 | [ 78, 69, 78, 71, 0, 0], [ 78, 73, 0, 0, 0, 0], 221 | [ 78, 73, 65, 78, 0, 0], [ 78, 73, 65, 78, 71, 0], 222 | [ 78, 73, 65, 79, 0, 0], [ 78, 73, 69, 0, 0, 0], 223 | [ 78, 73, 78, 0, 0, 0], [ 78, 73, 78, 71, 0, 0], 224 | [ 78, 73, 85, 0, 0, 0], [ 78, 79, 78, 71, 0, 0], 225 | [ 78, 79, 85, 0, 0, 0], [ 78, 85, 0, 0, 0, 0], 226 | [ 78, 85, 65, 78, 0, 0], [ 78, 85, 69, 0, 0, 0], 227 | [ 78, 85, 78, 0, 0, 0], [ 78, 85, 79, 0, 0, 0], 228 | [ 79, 0, 0, 0, 0, 0], [ 79, 85, 0, 0, 0, 0], 229 | [ 80, 65, 0, 0, 0, 0], [ 80, 65, 73, 0, 0, 0], 230 | [ 80, 65, 78, 0, 0, 0], [ 80, 65, 78, 71, 0, 0], 231 | [ 80, 65, 79, 0, 0, 0], [ 80, 69, 73, 0, 0, 0], 232 | [ 80, 69, 78, 0, 0, 0], [ 80, 69, 78, 71, 0, 0], 233 | [ 80, 73, 0, 0, 0, 0], [ 80, 73, 65, 78, 0, 0], 234 | [ 80, 73, 65, 79, 0, 0], [ 80, 73, 69, 0, 0, 0], 235 | [ 80, 73, 78, 0, 0, 0], [ 80, 73, 78, 71, 0, 0], 236 | [ 80, 79, 0, 0, 0, 0], [ 80, 79, 85, 0, 0, 0], 237 | [ 80, 85, 0, 0, 0, 0], [ 81, 73, 0, 0, 0, 0], 238 | [ 81, 73, 65, 0, 0, 0], [ 81, 73, 65, 78, 0, 0], 239 | [ 81, 73, 65, 78, 71, 0], [ 81, 73, 65, 79, 0, 0], 240 | [ 81, 73, 69, 0, 0, 0], [ 81, 73, 78, 0, 0, 0], 241 | [ 81, 73, 78, 71, 0, 0], [ 81, 73, 79, 78, 71, 0], 242 | [ 81, 73, 85, 0, 0, 0], [ 81, 85, 0, 0, 0, 0], 243 | [ 81, 85, 65, 78, 0, 0], [ 81, 85, 69, 0, 0, 0], 244 | [ 81, 85, 78, 0, 0, 0], [ 82, 65, 78, 0, 0, 0], 245 | [ 82, 65, 78, 71, 0, 0], [ 82, 65, 79, 0, 0, 0], 246 | [ 82, 69, 0, 0, 0, 0], [ 82, 69, 78, 0, 0, 0], 247 | [ 82, 69, 78, 71, 0, 0], [ 82, 73, 0, 0, 0, 0], 248 | [ 82, 79, 78, 71, 0, 0], [ 82, 79, 85, 0, 0, 0], 249 | [ 82, 85, 0, 0, 0, 0], [ 82, 85, 65, 0, 0, 0], 250 | [ 82, 85, 65, 78, 0, 0], [ 82, 85, 73, 0, 0, 0], 251 | [ 82, 85, 78, 0, 0, 0], [ 82, 85, 79, 0, 0, 0], 252 | [ 83, 65, 0, 0, 0, 0], [ 83, 65, 73, 0, 0, 0], 253 | [ 83, 65, 78, 0, 0, 0], [ 83, 65, 78, 71, 0, 0], 254 | [ 83, 65, 79, 0, 0, 0], [ 83, 69, 0, 0, 0, 0], 255 | [ 83, 69, 78, 0, 0, 0], [ 83, 69, 78, 71, 0, 0], 256 | [ 83, 72, 65, 0, 0, 0], [ 83, 72, 65, 73, 0, 0], 257 | [ 83, 72, 65, 78, 0, 0], [ 83, 72, 65, 78, 71, 0], 258 | [ 83, 72, 65, 79, 0, 0], [ 83, 72, 69, 0, 0, 0], 259 | [ 83, 72, 69, 78, 0, 0], [ 88, 73, 78, 0, 0, 0], 260 | [ 83, 72, 69, 78, 0, 0], [ 83, 72, 69, 78, 71, 0], 261 | [ 83, 72, 73, 0, 0, 0], [ 83, 72, 79, 85, 0, 0], 262 | [ 83, 72, 85, 0, 0, 0], [ 83, 72, 85, 65, 0, 0], 263 | [ 83, 72, 85, 65, 73, 0], [ 83, 72, 85, 65, 78, 0], 264 | [ 83, 72, 85, 65, 78, 71], [ 83, 72, 85, 73, 0, 0], 265 | [ 83, 72, 85, 78, 0, 0], [ 83, 72, 85, 79, 0, 0], 266 | [ 83, 73, 0, 0, 0, 0], [ 83, 79, 78, 71, 0, 0], 267 | [ 83, 79, 85, 0, 0, 0], [ 83, 85, 0, 0, 0, 0], 268 | [ 83, 85, 65, 78, 0, 0], [ 83, 85, 73, 0, 0, 0], 269 | [ 83, 85, 78, 0, 0, 0], [ 83, 85, 79, 0, 0, 0], 270 | [ 84, 65, 0, 0, 0, 0], [ 84, 65, 73, 0, 0, 0], 271 | [ 84, 65, 78, 0, 0, 0], [ 84, 65, 78, 71, 0, 0], 272 | [ 84, 65, 79, 0, 0, 0], [ 84, 69, 0, 0, 0, 0], 273 | [ 84, 69, 78, 71, 0, 0], [ 84, 73, 0, 0, 0, 0], 274 | [ 84, 73, 65, 78, 0, 0], [ 84, 73, 65, 79, 0, 0], 275 | [ 84, 73, 69, 0, 0, 0], [ 84, 73, 78, 71, 0, 0], 276 | [ 84, 79, 78, 71, 0, 0], [ 84, 79, 85, 0, 0, 0], 277 | [ 84, 85, 0, 0, 0, 0], [ 84, 85, 65, 78, 0, 0], 278 | [ 84, 85, 73, 0, 0, 0], [ 84, 85, 78, 0, 0, 0], 279 | [ 84, 85, 79, 0, 0, 0], [ 87, 65, 0, 0, 0, 0], 280 | [ 87, 65, 73, 0, 0, 0], [ 87, 65, 78, 0, 0, 0], 281 | [ 87, 65, 78, 71, 0, 0], [ 87, 69, 73, 0, 0, 0], 282 | [ 87, 69, 78, 0, 0, 0], [ 87, 69, 78, 71, 0, 0], 283 | [ 87, 79, 0, 0, 0, 0], [ 87, 85, 0, 0, 0, 0], 284 | [ 88, 73, 0, 0, 0, 0], [ 88, 73, 65, 0, 0, 0], 285 | [ 88, 73, 65, 78, 0, 0], [ 88, 73, 65, 78, 71, 0], 286 | [ 88, 73, 65, 79, 0, 0], [ 88, 73, 69, 0, 0, 0], 287 | [ 88, 73, 78, 0, 0, 0], [ 88, 73, 78, 71, 0, 0], 288 | [ 88, 73, 79, 78, 71, 0], [ 88, 73, 85, 0, 0, 0], 289 | [ 88, 85, 0, 0, 0, 0], [ 88, 85, 65, 78, 0, 0], 290 | [ 88, 85, 69, 0, 0, 0], [ 88, 85, 78, 0, 0, 0], 291 | [ 89, 65, 0, 0, 0, 0], [ 89, 65, 78, 0, 0, 0], 292 | [ 89, 65, 78, 71, 0, 0], [ 89, 65, 79, 0, 0, 0], 293 | [ 89, 69, 0, 0, 0, 0], [ 89, 73, 0, 0, 0, 0], 294 | [ 89, 73, 78, 0, 0, 0], [ 89, 73, 78, 71, 0, 0], 295 | [ 89, 79, 0, 0, 0, 0], [ 89, 79, 78, 71, 0, 0], 296 | [ 89, 79, 85, 0, 0, 0], [ 89, 85, 0, 0, 0, 0], 297 | [ 89, 85, 65, 78, 0, 0], [ 89, 85, 69, 0, 0, 0], 298 | [ 89, 85, 78, 0, 0, 0], [ 74, 85, 78, 0, 0, 0], 299 | [ 89, 85, 78, 0, 0, 0], [ 90, 65, 0, 0, 0, 0], 300 | [ 90, 65, 73, 0, 0, 0], [ 90, 65, 78, 0, 0, 0], 301 | [ 90, 65, 78, 71, 0, 0], [ 90, 65, 79, 0, 0, 0], 302 | [ 90, 69, 0, 0, 0, 0], [ 90, 69, 73, 0, 0, 0], 303 | [ 90, 69, 78, 0, 0, 0], [ 90, 69, 78, 71, 0, 0], 304 | [ 90, 72, 65, 0, 0, 0], [ 90, 72, 65, 73, 0, 0], 305 | [ 90, 72, 65, 78, 0, 0], [ 90, 72, 65, 78, 71, 0], 306 | [ 67, 72, 65, 78, 71, 0], [ 90, 72, 65, 78, 71, 0], 307 | [ 90, 72, 65, 79, 0, 0], [ 90, 72, 69, 0, 0, 0], 308 | [ 90, 72, 69, 78, 0, 0], [ 90, 72, 69, 78, 71, 0], 309 | [ 90, 72, 73, 0, 0, 0], [ 83, 72, 73, 0, 0, 0], 310 | [ 90, 72, 73, 0, 0, 0], [ 90, 72, 79, 78, 71, 0], 311 | [ 90, 72, 79, 85, 0, 0], [ 90, 72, 85, 0, 0, 0], 312 | [ 90, 72, 85, 65, 0, 0], [ 90, 72, 85, 65, 73, 0], 313 | [ 90, 72, 85, 65, 78, 0], [ 90, 72, 85, 65, 78, 71], 314 | [ 90, 72, 85, 73, 0, 0], [ 90, 72, 85, 78, 0, 0], 315 | [ 90, 72, 85, 79, 0, 0], [ 90, 73, 0, 0, 0, 0], 316 | [ 90, 79, 78, 71, 0, 0], [ 90, 79, 85, 0, 0, 0], 317 | [ 90, 85, 0, 0, 0, 0], [ 90, 85, 65, 78, 0, 0], 318 | [ 90, 85, 73, 0, 0, 0], [ 90, 85, 78, 0, 0, 0], 319 | [ 90, 85, 79, 0, 0, 0], [ 0, 0, 0, 0, 0, 0], 320 | [ 83, 72, 65, 78, 0, 0], [ 0, 0, 0, 0, 0, 0], 321 | ]; 322 | 323 | /** First and last Chinese character with known Pinyin according to zh collation */ 324 | const FIRST_PINYIN_UNIHAN = "\u963F"; 325 | const LAST_PINYIN_UNIHAN = "\u9FFF"; 326 | 327 | const COLLATOR = new Intl.Collator(LOCALE_CHINA); 328 | 329 | class StringBuilder { 330 | input = ""; 331 | 332 | constructor(input = "") { 333 | this.input = input; 334 | } 335 | 336 | get length() { 337 | return this.input.length; 338 | } 339 | 340 | append(substr: string) { 341 | this.input += substr; 342 | } 343 | 344 | setLength(len: number) { 345 | this.input = this.input.slice(0, len); 346 | } 347 | 348 | toString() { 349 | return this.input; 350 | } 351 | } 352 | 353 | class Token implements IToken { 354 | /** 355 | * Separator between target string for each source char 356 | */ 357 | static get SEPARATOR() { 358 | return " "; 359 | } 360 | 361 | static get LATIN() { 362 | return 1; 363 | } 364 | static get PINYIN() { 365 | return 2; 366 | } 367 | static get UNKNOWN() { 368 | return 3; 369 | } 370 | 371 | type: number; 372 | source: string; 373 | target: string; 374 | 375 | /** 376 | * @param {int} [type] - Type of this token, ASCII, PINYIN or UNKNOWN. 377 | * @param {string} [source] - Original string before translation. 378 | * @param {string} [target] - Translated string of source. For Han, target is corresponding Pinyin. 379 | * Otherwise target is original string in source. 380 | */ 381 | constructor(type: number = 1, source: string = "", target: string = "") { 382 | this.type = type; 383 | this.source = source; 384 | this.target = target; 385 | } 386 | } 387 | 388 | /** 389 | * @return {boolean} 390 | */ 391 | function hasChinaCollator(): boolean { 392 | // Check if zh_CN collation data is available 393 | const locale = Intl.Collator.supportedLocalesOf([LOCALE_CHINA]); 394 | const hasChinaCollator = locale.some((lang) => lang === LOCALE_CHINA); 395 | if (hasChinaCollator) { 396 | // Do self validation just once. 397 | if (DEBUG) { 398 | console.log(TAG, `Self validation. Result: ${doSelfValidation()}`); 399 | } 400 | } else { 401 | console.warn(TAG, "There is no Chinese collator, HanziToPinyin is disabled"); 402 | } 403 | return hasChinaCollator; 404 | } 405 | 406 | /** 407 | * Validate if our internal table has some wrong value. 408 | * 409 | * @return {boolean} - true when the table looks correct. 410 | */ 411 | function doSelfValidation(): boolean { 412 | let lastChar = UNIHANS[0]; 413 | let lastString = lastChar.toString(); 414 | for (const c of UNIHANS) { 415 | if (lastChar === c) { 416 | continue; 417 | } 418 | const curString = c.toString(); 419 | const cmp = COLLATOR.compare(lastString, curString); 420 | if (cmp >= 0) { 421 | console.error( 422 | TAG, 423 | "Internal error in Unihan table. The last string", 424 | lastString, 425 | "is greater than current string", 426 | curString, 427 | ".", 428 | ); 429 | return false; 430 | } 431 | lastString = curString; 432 | } 433 | return true; 434 | } 435 | 436 | /** 437 | * Generates a token based on the given character. 438 | * 439 | * @param {string} character - The character to generate the token from. 440 | * @returns {Token} The generated token. 441 | */ 442 | function getToken(character: string): Token { 443 | const token = new Token(); 444 | const letter = character.toString(); 445 | 446 | token.source = letter; 447 | 448 | let offset = -1; 449 | let cmp; 450 | 451 | if (character.codePointAt(0) < 256) { 452 | token.type = Token.LATIN; 453 | token.target = letter; 454 | return token; 455 | } else { 456 | cmp = COLLATOR.compare(letter, FIRST_PINYIN_UNIHAN); 457 | if (cmp < 0) { 458 | token.type = Token.UNKNOWN; 459 | token.target = letter; 460 | return token; 461 | } else if (cmp === 0) { 462 | token.type = Token.PINYIN; 463 | offset = 0; 464 | } else { 465 | cmp = COLLATOR.compare(letter, LAST_PINYIN_UNIHAN); 466 | if (cmp > 0) { 467 | token.type = Token.UNKNOWN; 468 | token.target = letter; 469 | return token; 470 | } else if (cmp === 0) { 471 | token.type = Token.PINYIN; 472 | offset = UNIHANS.length - 1; 473 | } 474 | } 475 | } 476 | 477 | token.type = Token.PINYIN; 478 | if (offset < 0) { 479 | let begin = 0; 480 | let end = UNIHANS.length - 1; 481 | while (begin <= end) { 482 | offset = Math.floor((begin + end) / 2); 483 | const unihan = UNIHANS[offset].toString(); 484 | cmp = COLLATOR.compare(letter, unihan); 485 | if (cmp === 0) { 486 | break; 487 | } else if (cmp > 0) { 488 | begin = offset + 1; 489 | } else { 490 | end = offset - 1; 491 | } 492 | } 493 | } 494 | if (cmp < 0) { 495 | offset--; 496 | } 497 | const pinyin = new StringBuilder(); 498 | for (let j = 0; j < PINYINS[offset].length && PINYINS[offset][j] !== 0; j++) { 499 | pinyin.append(String.fromCodePoint(PINYINS[offset][j])); 500 | } 501 | token.target = pinyin.toString(); 502 | if (!token.target) { 503 | token.type = Token.UNKNOWN; 504 | token.target = token.source; 505 | } 506 | return token; 507 | } 508 | 509 | /** 510 | * @param {StringBuilder} sb 511 | * @param {Token[]} tokens 512 | * @param {int} tokenType 513 | */ 514 | function addToken(sb: StringBuilder, tokens: Token[], tokenType: number) { 515 | const str = sb.toString(); 516 | tokens.push(new Token(tokenType, str, str)); 517 | sb.setLength(0); 518 | } 519 | 520 | /** 521 | * Convert the input to a array of tokens. The sequence of ASCII or Unknown characters without 522 | * space will be put into a Token, One Hanzi character which has pinyin will be treated as a 523 | * Token. If these is no China collator, the empty token array is returned. 524 | * 525 | * @public 526 | * @export 527 | * @param {string} input 528 | * @return {Token[]} 529 | */ 530 | export function getPinyinFromHanzi(input: string): Token[] { 531 | if (typeof input !== "string") { 532 | throw new Error("`input` must be string."); 533 | } 534 | const tokens: Token[] = []; 535 | const inputLength = input.length; 536 | if (!hasChinaCollator() || !input) { 537 | // return empty tokens. 538 | return tokens; 539 | } 540 | let sb = new StringBuilder(); 541 | let tokenType = Token.LATIN; 542 | // Go through the input, create a new token when 543 | // a. Token type changed 544 | // b. Get the Pinyin of current character. 545 | // c. current character is space. 546 | for (let i = 0; i < inputLength; i++) { 547 | const character = input.charAt(i); 548 | if (character === " ") { 549 | if (sb.length > 0) { 550 | addToken(sb, tokens, tokenType); 551 | } 552 | } else if (character.codePointAt(0) < 256) { 553 | if (tokenType !== Token.LATIN && sb.length > 0) { 554 | addToken(sb, tokens, tokenType); 555 | } 556 | tokenType = Token.LATIN; 557 | sb.append(character); 558 | } else { 559 | const t = getToken(character); 560 | if (t.type === Token.PINYIN) { 561 | if (sb.length > 0) { 562 | addToken(sb, tokens, tokenType); 563 | } 564 | tokens.push(t); 565 | tokenType = Token.PINYIN; 566 | } else { 567 | if (tokenType !== t.type && sb.length > 0) { 568 | addToken(sb, tokens, tokenType); 569 | } 570 | tokenType = t.type; 571 | sb.append(character); 572 | } 573 | } 574 | } 575 | if (sb.length > 0) { 576 | addToken(sb, tokens, tokenType); 577 | } 578 | return tokens; 579 | } 580 | --------------------------------------------------------------------------------