├── .nvmrc ├── src ├── vite-env.d.ts ├── components │ ├── global │ │ ├── NewFeature.vue │ │ ├── RefreshButton.vue │ │ ├── donations │ │ │ ├── PayPalIcon.vue │ │ │ └── BuyMeACoffeeIcon.vue │ │ ├── Toaster.vue │ │ ├── HelpSwap.vue │ │ ├── Disclaimer.vue │ │ ├── CustomSelect.vue │ │ └── RegexVisualizer.vue │ ├── icons │ │ ├── ChevronDownIcon.vue │ │ ├── BurgerIcon.vue │ │ ├── PlusIcon.vue │ │ ├── CheckIcon.vue │ │ ├── ArrowUpIcon.vue │ │ ├── ArrowDownIcon.vue │ │ ├── CloseIcon.vue │ │ ├── GripIcon.vue │ │ ├── TabRulesIcon.vue │ │ ├── ExternalIcon.vue │ │ ├── EditIcon.vue │ │ ├── RefreshIcon.vue │ │ ├── DonationIcon.vue │ │ ├── TabGroupsIcon.vue │ │ ├── ClipboardIcon.vue │ │ ├── TabHiveIcon.vue │ │ ├── DeleteIcon.vue │ │ ├── DuplicateIcon.vue │ │ ├── GithubIcon.vue │ │ ├── HelpIcon.vue │ │ ├── SettingsIcon.vue │ │ └── ChromeIcon.vue │ └── options │ │ ├── center │ │ ├── sections │ │ │ ├── TabGroups │ │ │ │ ├── ColorVisualizer.vue │ │ │ │ ├── EmptyGroups.vue │ │ │ │ ├── TableGroups.vue │ │ │ │ └── GroupForm.vue │ │ │ ├── TabRules │ │ │ │ └── ShortGroupForm.vue │ │ │ ├── TabHivePane.vue │ │ │ ├── TabGroupsPane.vue │ │ │ └── TabRulesPane.vue │ │ └── resources │ │ │ ├── DonationCard.vue │ │ │ └── DonationPane.vue │ │ └── left │ │ └── Menu.vue ├── style.css ├── common │ ├── emoji-data │ │ ├── types.ts │ │ ├── index.ts │ │ └── categories │ │ │ ├── gestures.ts │ │ │ ├── buildings.ts │ │ │ ├── transports.ts │ │ │ ├── objects.ts │ │ │ ├── animals.ts │ │ │ └── sports.ts │ ├── feature-flags.ts │ ├── types.ts │ ├── regex-safety.ts │ ├── helpers.urlFragment.test.js │ └── regex-safety.test.js ├── popup.ts ├── options.ts ├── sidepanel.ts ├── index.html ├── sidepanel.html ├── stores │ ├── menu.store.ts │ └── rules.store.test.js ├── options.html ├── __mocks__ │ └── chrome.js ├── Popup.vue ├── background │ ├── ContextMenuService.ts │ ├── WindowService.ts │ ├── __tests__ │ │ ├── ContextMenuService.test.ts │ │ └── WindowService.test.ts │ ├── SpotSearchService.ts │ ├── TabGroupsService.ts │ └── TabRulesService.ts ├── content │ ├── RegexService.ts │ ├── IconService.ts │ ├── StorageService.ts │ ├── __tests__ │ │ └── RegexService.test.ts │ ├── UrlChangeDetector.ts │ ├── TitleService.ts │ └── RuleApplicationService.ts ├── content.ts └── SidePanel.vue ├── .vscode └── extensions.json ├── public ├── assets │ ├── icon_16.png │ ├── icon_32.png │ ├── icon_48.png │ ├── icon_64.png │ ├── icon_128.png │ ├── chrome │ │ ├── chrome.png │ │ ├── default.png │ │ ├── history.png │ │ ├── bookmarks.png │ │ ├── downloads.png │ │ ├── extensions.png │ │ ├── settings.png │ │ └── transparent.png │ └── bullets │ │ ├── bullet-blue.png │ │ ├── bullet-cyan.png │ │ ├── bullet-pink.png │ │ ├── bullet-red.png │ │ ├── bullet-teal.png │ │ ├── bullet-amber.png │ │ ├── bullet-green.png │ │ ├── bullet-indigo.png │ │ ├── bullet-purple.png │ │ ├── bullet-red-alt.png │ │ ├── bullet-amber-alt.png │ │ ├── bullet-blue-alt.png │ │ ├── bullet-blue-grey.png │ │ ├── bullet-cyan-alt.png │ │ ├── bullet-green-alt.png │ │ ├── bullet-pink-alt.png │ │ ├── bullet-star-blue.png │ │ ├── bullet-star-cyan.png │ │ ├── bullet-star-pink.png │ │ ├── bullet-star-red.png │ │ ├── bullet-star-teal.png │ │ ├── bullet-teal-alt.png │ │ ├── bullet-deep-orange.png │ │ ├── bullet-indigo-alt.png │ │ ├── bullet-purple-alt.png │ │ ├── bullet-star-amber.png │ │ ├── bullet-star-green.png │ │ ├── bullet-star-indigo.png │ │ ├── bullet-star-purple.png │ │ ├── bullet-blue-grey-alt.png │ │ ├── bullet-star-blue-grey.png │ │ ├── bullet-deep-orange-alt.png │ │ └── bullet-star-deep-orange.png └── fonts │ ├── quicksand-variable.ttf │ └── quicksand.css ├── postcss.config.js ├── .prettierrc ├── vitest.config.ts ├── vitest.setup.js ├── vite.config.ts ├── .github ├── dependabot.yml └── ISSUE_TEMPLATE │ └── bug_report.md ├── .gitignore ├── tsconfig.json ├── LICENSE.md ├── SEMANTIC_COMMIT_MESSAGES.md ├── .eslintrc.cjs ├── package.json ├── tailwind.config.js ├── manifest.json ├── docs ├── store_description.md └── IMPROVEMENTS.md └── CODE_OF_CONDUCT.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar"] 3 | } 4 | -------------------------------------------------------------------------------- /public/assets/icon_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/icon_16.png -------------------------------------------------------------------------------- /public/assets/icon_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/icon_32.png -------------------------------------------------------------------------------- /public/assets/icon_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/icon_48.png -------------------------------------------------------------------------------- /public/assets/icon_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/icon_64.png -------------------------------------------------------------------------------- /public/assets/icon_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/icon_128.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/components/global/NewFeature.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /public/assets/chrome/chrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/chrome/chrome.png -------------------------------------------------------------------------------- /public/assets/chrome/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/chrome/default.png -------------------------------------------------------------------------------- /public/assets/chrome/history.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/chrome/history.png -------------------------------------------------------------------------------- /public/assets/chrome/bookmarks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/chrome/bookmarks.png -------------------------------------------------------------------------------- /public/assets/chrome/downloads.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/chrome/downloads.png -------------------------------------------------------------------------------- /public/assets/chrome/extensions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/chrome/extensions.png -------------------------------------------------------------------------------- /public/assets/chrome/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/chrome/settings.png -------------------------------------------------------------------------------- /public/fonts/quicksand-variable.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/fonts/quicksand-variable.ttf -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "useTabs": true 6 | } 7 | -------------------------------------------------------------------------------- /public/assets/bullets/bullet-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-blue.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-cyan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-cyan.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-pink.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-pink.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-red.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-teal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-teal.png -------------------------------------------------------------------------------- /public/assets/chrome/transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/chrome/transparent.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-amber.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-amber.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-green.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-indigo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-indigo.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-purple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-purple.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-red-alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-red-alt.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-amber-alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-amber-alt.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-blue-alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-blue-alt.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-blue-grey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-blue-grey.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-cyan-alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-cyan-alt.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-green-alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-green-alt.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-pink-alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-pink-alt.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-star-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-star-blue.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-star-cyan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-star-cyan.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-star-pink.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-star-pink.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-star-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-star-red.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-star-teal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-star-teal.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-teal-alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-teal-alt.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-deep-orange.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-deep-orange.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-indigo-alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-indigo-alt.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-purple-alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-purple-alt.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-star-amber.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-star-amber.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-star-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-star-green.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-star-indigo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-star-indigo.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-star-purple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-star-purple.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-blue-grey-alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-blue-grey-alt.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-star-blue-grey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-star-blue-grey.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-deep-orange-alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-deep-orange-alt.png -------------------------------------------------------------------------------- /public/assets/bullets/bullet-star-deep-orange.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/furybee/chrome-tab-modifier/HEAD/public/assets/bullets/bullet-star-deep-orange.png -------------------------------------------------------------------------------- /public/fonts/quicksand.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Quicksand'; 3 | font-style: normal; 4 | font-weight: 300 700; 5 | font-display: swap; 6 | src: url('/fonts/quicksand-variable.ttf') format('truetype'); 7 | } 8 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | @import url('/fonts/quicksand.css'); 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | 6 | .tooltip:before { 7 | text-wrap: nowrap; 8 | max-width: none !important; 9 | } 10 | 11 | @tailwind utilities; 12 | -------------------------------------------------------------------------------- /src/components/icons/ChevronDownIcon.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/common/emoji-data/types.ts: -------------------------------------------------------------------------------- 1 | export interface EmojiItem { 2 | emoji: string; 3 | keywords: string[]; 4 | } 5 | 6 | export interface EmojiCategory { 7 | id: string; 8 | name: string; 9 | icon: string; 10 | emojis: EmojiItem[]; 11 | } 12 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | test: { 3 | environment: 'jsdom', 4 | setupFiles: './vitest.setup.js', 5 | 6 | coverage: { 7 | reporter: ['text', 'json-summary', 'json'], 8 | reportOnFailure: true, 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/components/icons/BurgerIcon.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /src/components/options/center/sections/TabGroups/ColorVisualizer.vue: -------------------------------------------------------------------------------- 1 | 4 | 9 | -------------------------------------------------------------------------------- /src/popup.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import './style.css'; 3 | import Options from './Popup.vue'; 4 | import { createPinia } from 'pinia'; 5 | import mitt from 'mitt'; 6 | 7 | const pinia = createPinia(); 8 | const emitter = mitt(); 9 | 10 | createApp(Options).use(pinia).provide('emitter', emitter).mount('#app'); 11 | -------------------------------------------------------------------------------- /src/options.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import './style.css'; 3 | import Options from './Options.vue'; 4 | import { createPinia } from 'pinia'; 5 | import mitt from 'mitt'; 6 | 7 | const pinia = createPinia(); 8 | const emitter = mitt(); 9 | 10 | createApp(Options).use(pinia).provide('emitter', emitter).mount('#app'); 11 | -------------------------------------------------------------------------------- /src/components/icons/PlusIcon.vue: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /src/common/feature-flags.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Feature flags for controlling experimental or in-development features 3 | */ 4 | export const FEATURE_FLAGS = { 5 | /** 6 | * Enable copy/paste rule functionality 7 | * This feature allows users to copy rules to clipboard and paste them 8 | */ 9 | ENABLE_RULE_COPY_PASTE: false, 10 | } as const; 11 | -------------------------------------------------------------------------------- /src/components/icons/CheckIcon.vue: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /src/sidepanel.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import SidePanel from './SidePanel.vue'; 3 | import './style.css'; 4 | import { createPinia } from 'pinia'; 5 | import mitt from 'mitt'; 6 | 7 | const pinia = createPinia(); 8 | const emitter = mitt(); 9 | 10 | createApp(SidePanel).use(pinia).provide('emitter', emitter).mount('#app'); 11 | -------------------------------------------------------------------------------- /src/components/icons/ArrowUpIcon.vue: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /src/components/icons/ArrowDownIcon.vue: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /src/components/icons/CloseIcon.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /src/components/icons/GripIcon.vue: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Tabee 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/sidepanel.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Tabee - Tab Hive 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/stores/menu.store.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { MenuItem } from '../common/types.ts'; 3 | 4 | export const useMenuStore = defineStore('menu', { 5 | state: () => { 6 | return { 7 | currentMenuItem: undefined as MenuItem | undefined, 8 | }; 9 | }, 10 | actions: { 11 | setCurrentMenuItem(menuItem: MenuItem) { 12 | this.currentMenuItem = menuItem; 13 | }, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /vitest.setup.js: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | global.chrome = { 4 | runtime: { 5 | sendMessage: vi.fn(), 6 | onMessage: { 7 | addListener: vi.fn(), 8 | }, 9 | }, 10 | storage: { 11 | local: { 12 | get: vi.fn((keys, callback) => callback({ tab_modifier: null })), 13 | }, 14 | sync: { 15 | get: vi.fn((keys, callback) => callback({ tab_modifier: null })), 16 | }, 17 | }, 18 | tabs: { 19 | ungroup: vi.fn(), 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /src/components/icons/TabRulesIcon.vue: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import vue from '@vitejs/plugin-vue'; 3 | import { crx, ManifestV3Export } from '@crxjs/vite-plugin'; 4 | import manifest from './manifest.json' assert { type: 'json' }; 5 | 6 | export default defineConfig({ 7 | server: { 8 | strictPort: true, 9 | port: 5173, 10 | hmr: { 11 | clientPort: 5173, 12 | }, 13 | }, 14 | plugins: [vue(), crx({ manifest: manifest as unknown as ManifestV3Export })], 15 | }); 16 | -------------------------------------------------------------------------------- /src/components/icons/ExternalIcon.vue: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /src/components/icons/EditIcon.vue: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: 'npm' 9 | directory: '/' 10 | schedule: 11 | interval: 'monthly' 12 | -------------------------------------------------------------------------------- /src/components/icons/RefreshIcon.vue: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /src/components/icons/DonationIcon.vue: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /src/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Tabee 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | .venv 11 | node_modules 12 | dist 13 | dist-ssr 14 | *.local 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | tab-modifier.zip 28 | tabee.zip 29 | /deploy/* 30 | 31 | coverage/ 32 | dummy-* 33 | html/ 34 | 35 | tsconfig.tsbuildinfo 36 | 37 | # Claude Code configuration 38 | .claude/ 39 | CLAUDE.md 40 | -------------------------------------------------------------------------------- /src/components/icons/TabGroupsIcon.vue: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /src/components/icons/ClipboardIcon.vue: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /src/components/icons/TabHiveIcon.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/components/icons/DeleteIcon.vue: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /src/components/options/center/resources/DonationCard.vue: -------------------------------------------------------------------------------- 1 | 16 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Extension version:** `X.Y.Z` 27 | 28 | _Can be found here: chrome://extensions/?id=penegkenfmliefdbmnfkidlgjfjcidia 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /src/components/icons/DuplicateIcon.vue: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /src/__mocks__/chrome.js: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | export const chrome = { 4 | runtime: { 5 | sendMessage: vi.fn(), 6 | onMessage: { 7 | addListener: vi.fn(), 8 | }, 9 | getURL: vi.fn((path) => `chrome-extension://mocked-id${path}`), 10 | lastError: null, 11 | }, 12 | storage: { 13 | local: { 14 | get: vi.fn((keys, callback) => callback({ tab_modifier: null })), 15 | set: vi.fn((items, callback) => callback && callback()), 16 | remove: vi.fn((key, callback) => callback && callback()), 17 | }, 18 | sync: { 19 | get: vi.fn((keys, callback) => callback({ tab_modifier: null })), 20 | set: vi.fn((items, callback) => callback && callback()), 21 | remove: vi.fn((key, callback) => callback && callback()), 22 | }, 23 | }, 24 | tabs: { 25 | ungroup: vi.fn(), 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/icons/GithubIcon.vue: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /src/components/global/RefreshButton.vue: -------------------------------------------------------------------------------- 1 | 6 | 24 | 38 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "preserve", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | 23 | "composite": true, 24 | "allowSyntheticDefaultImports": true, 25 | 26 | "types": ["chrome"] 27 | }, 28 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "*.html", "*.json", "vite.config.ts"], 29 | "exclude": [ 30 | "node_modules", 31 | "dist", 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /src/components/options/center/sections/TabRules/ShortGroupForm.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 FuryBee 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /SEMANTIC_COMMIT_MESSAGES.md: -------------------------------------------------------------------------------- 1 | # Semantic Commit Messages 2 | 3 | See how a minor change to your commit message style can make you a better programmer. 🤓 4 | 5 | Format: `(): ` 6 | 7 | `` is optional 8 | 9 | ## Example 10 | 11 | ``` 12 | feat: add hat wobble 13 | ^--^ ^------------^ 14 | | | 15 | | +-> Summary in present tense. 16 | | 17 | +-------> Type: chore, docs, feat, fix, refactor, style, or test. 18 | ``` 19 | 20 | More Examples: 21 | 22 | - `feat`: (new feature for the user, not a new feature for build script) 23 | - `fix`: (bug fix for the user, not a fix to a build script) 24 | - `docs`: (changes to the documentation) 25 | - `style`: (formatting, missing semi colons, etc; no production code change) 26 | - `refactor`: (refactoring production code, eg. renaming a variable) 27 | - `test`: (adding missing tests, refactoring tests; no production code change) 28 | - `chore`: (updating grunt tasks etc; no production code change) 29 | 30 | References: 31 | 32 | - https://www.conventionalcommits.org/ 33 | - https://seesparkbox.com/foundry/semantic_commit_messages 34 | - http://karma-runner.github.io/1.0/dev/git-commit-msg.html 35 | -------------------------------------------------------------------------------- /src/components/global/donations/PayPalIcon.vue: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /src/components/options/left/Menu.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/components/options/center/sections/TabHivePane.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/components/icons/HelpIcon.vue: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /src/components/global/Toaster.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /src/components/icons/SettingsIcon.vue: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | webextensions: true, 6 | }, 7 | extends: [ 8 | 'plugin:vue/vue3-recommended', 9 | 'eslint:recommended', 10 | '@vue/typescript/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | parserOptions: { 14 | ecmaVersion: 2022, 15 | sourceType: 'module', 16 | project: './tsconfig.json', 17 | extraFileExtensions: ['.vue'], 18 | }, 19 | plugins: [ 20 | 'vue', 21 | 'prettier', 22 | ], 23 | rules: { 24 | 'vue/no-unused-vars': 'error', 25 | '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 26 | '@typescript-eslint/explicit-module-boundary-types': 'off', 27 | '@typescript-eslint/interface-name-prefix': 'off', 28 | '@typescript-eslint/explicit-function-return-type': 'off', 29 | '@typescript-eslint/no-explicit-any': 'off', 30 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 31 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 32 | 'prettier/prettier': 'error', 33 | }, 34 | overrides: [ 35 | { 36 | files: ['*.vue'], 37 | rules: { 38 | 'vue/multi-word-component-names': 'off', 39 | }, 40 | }, 41 | ], 42 | }; 43 | -------------------------------------------------------------------------------- /src/components/global/HelpSwap.vue: -------------------------------------------------------------------------------- 1 | 35 | 38 | -------------------------------------------------------------------------------- /src/components/options/center/resources/DonationPane.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/components/icons/ChromeIcon.vue: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /src/common/emoji-data/index.ts: -------------------------------------------------------------------------------- 1 | import type { EmojiCategory } from './types'; 2 | import { data as smileysData } from './categories/smileys'; 3 | import { data as gesturesData } from './categories/gestures'; 4 | import { data as animalsData } from './categories/animals'; 5 | import { data as objectsData } from './categories/objects'; 6 | import { data as buildingsData } from './categories/buildings'; 7 | import { data as transportsData } from './categories/transports'; 8 | import { data as sportsData } from './categories/sports'; 9 | import { data as flagsData } from './categories/flags'; 10 | 11 | export const EMOJI_CATEGORIES: EmojiCategory[] = [ 12 | { 13 | id: 'smileys', 14 | name: smileysData.name, 15 | icon: '😀', 16 | emojis: smileysData.emojis, 17 | }, 18 | { 19 | id: 'gestures', 20 | name: gesturesData.name, 21 | icon: '👋', 22 | emojis: gesturesData.emojis, 23 | }, 24 | { 25 | id: 'animals', 26 | name: animalsData.name, 27 | icon: '🐶', 28 | emojis: animalsData.emojis, 29 | }, 30 | { 31 | id: 'buildings', 32 | name: buildingsData.name, 33 | icon: '🏠', 34 | emojis: buildingsData.emojis, 35 | }, 36 | { 37 | id: 'transports', 38 | name: transportsData.name, 39 | icon: '🚗', 40 | emojis: transportsData.emojis, 41 | }, 42 | { 43 | id: 'sports', 44 | name: sportsData.name, 45 | icon: '⚽', 46 | emojis: sportsData.emojis, 47 | }, 48 | { 49 | id: 'objects', 50 | name: objectsData.name, 51 | icon: '💡', 52 | emojis: objectsData.emojis, 53 | }, 54 | { 55 | id: 'flags', 56 | name: flagsData.name, 57 | icon: '🏳️', 58 | emojis: flagsData.emojis, 59 | }, 60 | ]; 61 | 62 | export * from './types'; 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tabee", 3 | "private": true, 4 | "version": "1.1.4", 5 | "license": "MIT", 6 | "type": "module", 7 | "scripts": { 8 | "test": "vitest --run", 9 | "watch:test": "vitest", 10 | "watch:coverage": "npx vitest --coverage.enabled true", 11 | "coverage": "npx vitest --run --coverage.enabled true", 12 | "dev": "rm -rf ./dist && vite", 13 | "tsc": "vue-tsc", 14 | "build": "rm -rf ./dist && vite build", 15 | "preview": "vite preview", 16 | "build-zip": "rm -rf ./dist && rm -f tabee.zip && yarn build && cd ./dist && zip -r ../tabee.zip *" 17 | }, 18 | "dependencies": { 19 | "lz-string": "^1.5.0", 20 | "mitt": "^3.0.1", 21 | "pinia": "^2.1.7", 22 | "vue": "^3.4.21", 23 | "vuedraggable": "^4.1.0" 24 | }, 25 | "devDependencies": { 26 | "@crxjs/vite-plugin": "^2.0.0-beta.26", 27 | "@types/chrome": "^0.0.268", 28 | "@typescript-eslint/eslint-plugin": "^7.18.0", 29 | "@typescript-eslint/parser": "^7.18.0", 30 | "@vitejs/plugin-vue": "^5.1.4", 31 | "@vitest/coverage-v8": "^2.1.9", 32 | "@vitest/ui": "^2.1.9", 33 | "@vue/eslint-config-typescript": "^13.0.0", 34 | "autoprefixer": "^10.4.19", 35 | "daisyui": "^4.12.2", 36 | "eslint": "^8.57.1", 37 | "eslint-config-prettier": "^9.1.0", 38 | "eslint-plugin-import": "^2.25.2", 39 | "eslint-plugin-prettier": "^5.2.1", 40 | "eslint-plugin-vue": "^9.26.0", 41 | "jsdom": "^27.0.0", 42 | "postcss": "^8.4.38", 43 | "prettier": "^3.3.3", 44 | "tailwindcss": "^3.4.13", 45 | "typescript": "*", 46 | "vite": "^5.4.6", 47 | "vitest": "^2.1.9", 48 | "vue-eslint-parser": "^9.4.2", 49 | "vue-tsc": "^2.0.24" 50 | }, 51 | "resolutions": { 52 | "cross-spawn": "^7.0.5" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Popup.vue: -------------------------------------------------------------------------------- 1 | 11 | 64 | -------------------------------------------------------------------------------- /src/background/ContextMenuService.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Service responsible for managing context menus 3 | * Single Responsibility: Handle all context menu operations 4 | */ 5 | export class ContextMenuService { 6 | /** 7 | * Initialize all context menus 8 | */ 9 | initialize(): void { 10 | this.createRenameTabMenu(); 11 | this.createMergeWindowsMenu(); 12 | this.createSendToHiveMenu(); 13 | this.createTabHiveRejectMenus(); 14 | } 15 | 16 | /** 17 | * Create the "Rename Tab" context menu 18 | */ 19 | private createRenameTabMenu(): void { 20 | chrome.contextMenus.create({ 21 | id: 'rename-tab', 22 | title: '✏️ Rename Tab', 23 | contexts: ['all'], 24 | }); 25 | } 26 | 27 | /** 28 | * Create the "Merge All Windows" context menu 29 | */ 30 | private createMergeWindowsMenu(): void { 31 | chrome.contextMenus.create({ 32 | id: 'merge-windows', 33 | title: '🪟 Merge All Windows', 34 | contexts: ['all'], 35 | }); 36 | } 37 | 38 | /** 39 | * Create the "Send to Tab Hive" context menu 40 | */ 41 | private createSendToHiveMenu(): void { 42 | chrome.contextMenus.create({ 43 | id: 'send-to-hive', 44 | title: '🍯 Send to Tab Hive', 45 | contexts: ['all'], 46 | }); 47 | } 48 | 49 | /** 50 | * Create Tab Hive reject list context menus 51 | */ 52 | private createTabHiveRejectMenus(): void { 53 | // Parent menu 54 | chrome.contextMenus.create({ 55 | id: 'tab-hive-reject-parent', 56 | title: '🚫 Exclude from Tab Hive', 57 | contexts: ['all'], 58 | }); 59 | 60 | // Child menu: Exclude domain 61 | chrome.contextMenus.create({ 62 | id: 'tab-hive-reject-domain', 63 | parentId: 'tab-hive-reject-parent', 64 | title: '🌐 Exclude this domain', 65 | contexts: ['all'], 66 | }); 67 | 68 | // Child menu: Exclude URL 69 | chrome.contextMenus.create({ 70 | id: 'tab-hive-reject-url', 71 | parentId: 'tab-hive-reject-parent', 72 | title: '🔗 Exclude this URL', 73 | contexts: ['all'], 74 | }); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/content/RegexService.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Service responsible for safe regex pattern validation and creation 3 | * Prevents ReDoS (Regular Expression Denial of Service) attacks 4 | */ 5 | export class RegexService { 6 | /** 7 | * Validates if a regex pattern is safe to use 8 | * @param pattern - The regex pattern to validate 9 | * @returns true if the pattern is safe, false otherwise 10 | */ 11 | isRegexSafe(pattern: string): boolean { 12 | // Basic validation to prevent ReDoS attacks 13 | if (typeof pattern !== 'string' || pattern.length > 200) { 14 | return false; 15 | } 16 | 17 | // Check for potentially dangerous patterns that can cause ReDoS 18 | const dangerousPatterns = [ 19 | /\(\?=.*\)\+/, // Positive lookahead with quantifiers 20 | /\(\?!.*\)\+/, // Negative lookahead with quantifiers 21 | /\(.+\)\+\$/, // Catastrophic backtracking patterns 22 | /\(.+\)\*\+/, // Conflicting quantifiers 23 | /\(\.\*\)\{2,\}/, // Multiple .* in groups 24 | /\(\.\+\)\{2,\}/, // Multiple .+ in groups 25 | ]; 26 | 27 | return !dangerousPatterns.some((dangerous) => dangerous.test(pattern)); 28 | } 29 | 30 | /** 31 | * Creates a safe RegExp object from a pattern string 32 | * @param pattern - The regex pattern string 33 | * @param flags - Optional regex flags (default: 'g') 34 | * @returns A safe RegExp object 35 | * @throws Error if the pattern is unsafe or invalid 36 | */ 37 | createSafeRegex(pattern: string, flags: string = 'g'): RegExp { 38 | if (!this.isRegexSafe(pattern)) { 39 | throw new Error('Potentially unsafe regex pattern detected'); 40 | } 41 | 42 | try { 43 | // semgrep: ignore - Safe regex creation with validation above 44 | // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp 45 | return new RegExp(pattern, flags); 46 | } catch (e) { 47 | const errorMessage = e instanceof Error ? e.message : 'Unknown error'; 48 | throw new Error(`Invalid regex pattern: ${errorMessage}`); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/components/options/center/sections/TabGroups/EmptyGroups.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 58 | 59 | 67 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx,html}'], 3 | theme: { 4 | extend: { 5 | fontFamily: { 6 | sans: ['Quicksand', 'ui-sans-serif', 'system-ui', 'sans-serif'], 7 | }, 8 | }, 9 | }, 10 | plugins: [require('daisyui')], 11 | daisyui: { 12 | themes: [ 13 | 'dim', 14 | 'light', 15 | 'dark', 16 | 'cupcake', 17 | 'valentine', 18 | 'halloween', 19 | { 20 | tabee: { 21 | primary: '#fbbf24', // Amber-400 - Jaune d'abeille 22 | 'primary-content': '#0a0a0a', // Texte foncé sur jaune 23 | secondary: '#f59e0b', // Amber-500 - Orange miel 24 | 'secondary-content': '#0a0a0a', // Texte foncé sur orange 25 | accent: '#fde047', // Yellow-300 - Jaune clair 26 | 'accent-content': '#0a0a0a', // Texte foncé 27 | neutral: '#1f1f1f', // Gris très foncé 28 | 'neutral-content': '#e5e7eb', // Texte clair 29 | 'base-100': '#161616', // Fond presque noir 30 | 'base-200': '#141414', // Fond très sombre 31 | 'base-300': '#121212', // Fond sombre 32 | 'base-content': '#e5e7eb', // Texte principal clair 33 | info: '#3b82f6', // Bleu info 34 | 'info-content': '#ffffff', // Texte blanc sur bleu 35 | success: '#10b981', // Vert succès 36 | 'success-content': '#ffffff', // Texte blanc sur vert 37 | warning: '#f59e0b', // Orange warning (miel) 38 | 'warning-content': '#0a0a0a', // Texte foncé sur orange 39 | error: '#ef4444', // Rouge erreur 40 | 'error-content': '#ffffff', // Texte blanc sur rouge 41 | }, 42 | }, 43 | ], 44 | base: true, // applies background color and foreground color for root element by default 45 | styled: true, // include daisyUI colors and design decisions for all components 46 | utils: true, // adds responsive and modifier utility classes 47 | prefix: '', // prefix for daisyUI classnames (components, modifiers and responsive class names. Not colors) 48 | logs: true, // Shows info about daisyUI version and used config in the console when building your CSS 49 | themeRoot: ':root', // The element that receives theme color CSS variables 50 | }, 51 | }; 52 | -------------------------------------------------------------------------------- /src/components/global/Disclaimer.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 74 | 75 | 86 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Tabee: Tab Modifier", 4 | "version": "1.1.4", 5 | "description": "The original Tab Modifier reborn — rename, organize, and control your browser tabs effortlessly.", 6 | "homepage_url": "https://github.com/furybee/chrome-tab-modifier", 7 | "action": { 8 | "default_title": "Tabee", 9 | "default_icon": { 10 | "16": "assets/icon_16.png", 11 | "32": "assets/icon_32.png", 12 | "48": "assets/icon_48.png", 13 | "128": "assets/icon_128.png" 14 | } 15 | }, 16 | "icons": { 17 | "16": "assets/icon_16.png", 18 | "32": "assets/icon_32.png", 19 | "48": "assets/icon_48.png", 20 | "128": "assets/icon_128.png" 21 | }, 22 | "background": { 23 | "service_worker": "src/background.ts", 24 | "type": "module" 25 | }, 26 | "options_page": "src/options.html", 27 | "side_panel": { 28 | "default_path": "src/sidepanel.html" 29 | }, 30 | "web_accessible_resources": [{ 31 | "resources": [ 32 | "assets/*", 33 | "assets/*/*", 34 | "vendor/*", 35 | "vendor/*/*" 36 | ], 37 | "matches": [ 38 | "" 39 | ] 40 | }], 41 | "content_scripts": [{ 42 | "matches": [""], 43 | "js": [ 44 | "src/content.ts" 45 | ] 46 | }], 47 | "permissions": [ 48 | "tabs", 49 | "tabGroups", 50 | "storage", 51 | "contextMenus", 52 | "scripting", 53 | "sidePanel", 54 | "bookmarks", 55 | "alarms" 56 | ], 57 | "host_permissions": [ 58 | "http://*/*", 59 | "https://*/*" 60 | ], 61 | "commands": { 62 | "merge-windows": { 63 | "suggested_key": { 64 | "default": "Alt+Shift+W", 65 | "linux": "Alt+Shift+W", 66 | "mac": "Command+Shift+W", 67 | "windows": "Alt+Shift+W" 68 | }, 69 | "description": "Merge windows" 70 | }, 71 | "spot-search": { 72 | "suggested_key": { 73 | "default": "Alt+Shift+E", 74 | "linux": "Alt+Shift+E", 75 | "mac": "Command+Shift+E", 76 | "windows": "Alt+Shift+E" 77 | }, 78 | "description": "Search tabs and bookmarks" 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/content/IconService.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Service responsible for managing page favicons 3 | * Handles icon replacement and updates 4 | */ 5 | export class IconService { 6 | /** 7 | * Processes and updates the page favicon 8 | * @param newIcon - The new icon URL or asset name 9 | * @returns true if the icon was successfully updated 10 | */ 11 | processIcon(newIcon: string): boolean { 12 | const icons = document.querySelectorAll('head link[rel*="icon"]'); 13 | 14 | icons.forEach((icon) => { 15 | // ⚠️ icon.remove() causes issues with some websites 16 | // https://github.com/furybee/chrome-tab-modifier/issues/354 17 | // icon.remove(); 18 | // Instead, we'll just change the rel attribute 19 | icon.setAttribute('rel', 'old-icon'); 20 | }); 21 | 22 | let iconUrl: string; 23 | 24 | // Check if it's an emoji (single character or emoji sequence) 25 | if (this.isEmoji(newIcon)) { 26 | iconUrl = this.emojiToDataUrl(newIcon); 27 | } else if (/^(https?|data):/.test(newIcon)) { 28 | iconUrl = newIcon; 29 | } else { 30 | iconUrl = chrome.runtime.getURL(`/assets/${newIcon}`); 31 | } 32 | 33 | const newIconLink = document.createElement('link'); 34 | newIconLink.type = 'image/x-icon'; 35 | newIconLink.rel = 'icon'; 36 | newIconLink.href = iconUrl; 37 | document.head.appendChild(newIconLink); 38 | 39 | return true; 40 | } 41 | 42 | /** 43 | * Check if a string is an emoji 44 | */ 45 | private isEmoji(str: string): boolean { 46 | // Check if it's a short string (emojis are typically 1-7 characters due to modifiers) 47 | if (str.length > 10) return false; 48 | 49 | // Regex to detect emoji characters 50 | const emojiRegex = /^[\p{Emoji}\p{Emoji_Component}\p{Emoji_Modifier}\p{Emoji_Presentation}]+$/u; 51 | return emojiRegex.test(str); 52 | } 53 | 54 | /** 55 | * Convert emoji to SVG data URL for use as favicon 56 | */ 57 | private emojiToDataUrl(emoji: string): string { 58 | const svg = ` 59 | ${emoji} 60 | `; 61 | 62 | const encoded = encodeURIComponent(svg); 63 | return `data:image/svg+xml,${encoded}`; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/components/options/center/sections/TabGroups/TableGroups.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /src/background/WindowService.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Service responsible for window management operations 3 | * Single Responsibility: Handle all window-related operations 4 | */ 5 | export class WindowService { 6 | /** 7 | * Merge all browser windows into the current window 8 | */ 9 | async mergeAllWindows(): Promise { 10 | try { 11 | // Get all windows 12 | const windows = await chrome.windows.getAll({ populate: true }); 13 | 14 | if (windows.length <= 1) { 15 | console.log('[Tabee] Only one window open, nothing to merge'); 16 | return; 17 | } 18 | 19 | // Find the currently focused window or use the first normal window 20 | let targetWindow = windows.find((w) => w.focused); 21 | if (!targetWindow) { 22 | targetWindow = windows.find((w) => w.type === 'normal'); 23 | } 24 | 25 | if (!targetWindow || !targetWindow.id) { 26 | console.error('[Tabee] Could not find target window for merging'); 27 | return; 28 | } 29 | 30 | console.log(`[Tabee] Merging ${windows.length - 1} windows into window ${targetWindow.id}`); 31 | 32 | // Move all tabs from other windows to the target window 33 | for (const window of windows) { 34 | // Skip the target window itself 35 | if (window.id === targetWindow.id) continue; 36 | 37 | // Skip non-normal windows (popup, devtools, etc.) 38 | if (window.type !== 'normal') continue; 39 | 40 | if (window.tabs && window.tabs.length > 0) { 41 | const tabIds = window.tabs 42 | .map((tab) => tab.id) 43 | .filter((id): id is number => id !== undefined); 44 | 45 | if (tabIds.length > 0) { 46 | try { 47 | // Move tabs to target window 48 | await chrome.tabs.move(tabIds, { 49 | windowId: targetWindow.id, 50 | index: -1, // Append at the end 51 | }); 52 | 53 | console.log(`[Tabee] Moved ${tabIds.length} tabs from window ${window.id}`); 54 | } catch (error) { 55 | console.error(`[Tabee] Error moving tabs from window ${window.id}:`, error); 56 | } 57 | } 58 | } 59 | } 60 | 61 | // Focus the target window 62 | await chrome.windows.update(targetWindow.id, { focused: true }); 63 | 64 | console.log('[Tabee] Windows merged successfully'); 65 | } catch (error) { 66 | console.error('[Tabee] Error merging windows:', error); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/common/types.ts: -------------------------------------------------------------------------------- 1 | import { DefineComponent } from 'vue'; 2 | 3 | export type MenuItem = { 4 | title: string; 5 | emoji: string; 6 | description?: string; 7 | component?: string; 8 | link?: string; 9 | }; 10 | 11 | export type Tab = { 12 | title: string; 13 | icon: string | null; 14 | muted: boolean; 15 | pinned: boolean; 16 | protected: boolean; 17 | unique: boolean; 18 | group_id?: string | null; 19 | title_matcher: string | null; 20 | url_matcher: string | null; 21 | }; 22 | 23 | export type Rule = { 24 | id: string; 25 | name: string; 26 | detection: string; 27 | url_fragment: string; 28 | tab: Tab; 29 | is_enabled: boolean; 30 | }; 31 | 32 | export type Group = { 33 | id: string; 34 | title: string; 35 | color: string; 36 | collapsed: boolean; 37 | }; 38 | 39 | export type LightweightModePattern = { 40 | id: string; 41 | pattern: string; 42 | type: 'domain' | 'regex'; 43 | enabled: boolean; 44 | }; 45 | 46 | export type ClosedTab = { 47 | id: string; 48 | title: string; 49 | url: string; 50 | urlHash: string; // SHA-256 hash of URL for duplicate detection 51 | favIconUrl?: string; 52 | closedAt: number; // timestamp 53 | }; 54 | 55 | export type Settings = { 56 | enable_new_version_notification: boolean; 57 | theme: string; 58 | lightweight_mode_enabled: boolean; 59 | lightweight_mode_patterns: LightweightModePattern[]; 60 | lightweight_mode_apply_to_rules: boolean; 61 | lightweight_mode_apply_to_tab_hive: boolean; 62 | auto_close_enabled: boolean; 63 | auto_close_timeout: number; // en minutes 64 | tab_hive_reject_list: string[]; // List of domains to exclude from auto-close 65 | }; 66 | 67 | export type TabModifierSettings = { 68 | rules: Rule[]; 69 | groups: Group[]; 70 | settings: Settings; 71 | }; 72 | 73 | export const GLOBAL_EVENTS = { 74 | OPEN_ADD_RULE_MODAL: 'OPEN_ADD_RULE_MODAL', 75 | OPEN_ADD_GROUP_MODAL: 'OPEN_ADD_GROUP_MODAL', 76 | CLOSE_ADD_RULE_MODAL: 'CLOSE_ADD_RULE_MODAL', 77 | CLOSE_ADD_GROUP_MODAL: 'CLOSE_ADD_GROUP_MODAL', 78 | GLOBAL_KEY_SAVE: 'GLOBAL_KEY_SAVE', 79 | SHOW_TOAST: 'SHOW_TOAST', 80 | NAVIGATE_TO_SETTINGS: 'NAVIGATE_TO_SETTINGS', 81 | }; 82 | 83 | export type RuleModalParams = { 84 | rule?: Rule; 85 | }; 86 | 87 | export type GroupModalParams = { 88 | group?: Group; 89 | }; 90 | 91 | export type ToastType = 'success' | 'error' | 'info' | 'warning' | 'none'; 92 | 93 | export type ToastParams = { 94 | type: ToastType; 95 | message: string; 96 | timeout?: number; 97 | }; 98 | 99 | export type Components = Record< 100 | string, 101 | DefineComponent, NonNullable, any> 102 | >; 103 | -------------------------------------------------------------------------------- /src/common/regex-safety.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Regex Safety Utilities 3 | * Protects against Regular Expression Denial-of-Service (ReDoS) attacks 4 | */ 5 | 6 | 7 | /** 8 | * Checks if a regex pattern contains potentially dangerous constructs 9 | * that could lead to catastrophic backtracking 10 | */ 11 | export function _isRegexPatternSafe(pattern: string): boolean { 12 | try { 13 | // Check for empty or invalid patterns 14 | if (!pattern || typeof pattern !== 'string') { 15 | return false; 16 | } 17 | 18 | // Check for excessively long patterns (could indicate malicious intent) 19 | if (pattern.length > 1000) { 20 | return false; 21 | } 22 | 23 | // Detect patterns with nested quantifiers that can cause catastrophic backtracking 24 | // Examples: (a+)+, (a*)*, (a+)*, (a{1,5})+, etc. 25 | const nestedQuantifiers = /(\(.*?[*+{][^)]*\))[*+{]/g; 26 | if (nestedQuantifiers.test(pattern)) { 27 | return false; 28 | } 29 | 30 | // Detect patterns with multiple consecutive quantifiers 31 | const consecutiveQuantifiers = /[*+?{][*+?{]/; 32 | if (consecutiveQuantifiers.test(pattern)) { 33 | return false; 34 | } 35 | 36 | // Detect overlapping alternatives with quantifiers 37 | // Example: (a|a)*, (x+|x+y+)* 38 | const overlappingAlternatives = /\([^)]*\|[^)]*\)[*+{]/; 39 | if (overlappingAlternatives.test(pattern)) { 40 | return false; 41 | } 42 | 43 | // Try to construct the regex to ensure it's valid 44 | // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp 45 | // Safe: This is part of the validation process to check if the pattern is syntactically valid 46 | new RegExp(pattern); 47 | 48 | return true; 49 | } catch (error) { 50 | // Invalid regex pattern 51 | return false; 52 | } 53 | } 54 | 55 | /** 56 | * Safely executes a regex test with pattern validation 57 | * Note: Pattern validation helps prevent ReDoS but cannot provide runtime timeout in sync context 58 | * @param pattern - The regex pattern to test 59 | * @param input - The input string to test against 60 | * @returns true if the pattern matches, false otherwise or on error 61 | */ 62 | export function _safeRegexTestSync(pattern: string, input: string): boolean { 63 | // First, validate the pattern for safety 64 | if (!_isRegexPatternSafe(pattern)) { 65 | console.warn(`[Tabee] Unsafe regex pattern detected and blocked: ${pattern}`); 66 | return false; 67 | } 68 | 69 | try { 70 | // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp 71 | // Safe: Pattern has been validated by _isRegexPatternSafe() above to prevent ReDoS attacks 72 | const regex = new RegExp(pattern); 73 | return regex.test(input); 74 | } catch (error) { 75 | console.warn(`[Tabee] Regex execution error: ${error}`); 76 | return false; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /docs/store_description.md: -------------------------------------------------------------------------------- 1 | Take full control of your browser tabs with Tabee! Rename, organize, and customize your tabs effortlessly with powerful automation rules. 2 | 3 | ✨ Key Features: 4 | 5 | 🏷️ Smart Tab Renaming 6 | - Rename tabs with custom rules using URL patterns 7 | - Extract text from page elements with CSS selectors 8 | - Use regex patterns for advanced title matching 9 | - Right-click context menu for quick renaming 10 | 11 | 🎨 Visual Customization 12 | - Change tab icons easily (paste images directly!) 13 | - Custom favicons for any website 14 | - Support for emoji, URLs, and base64 images 15 | 16 | 📁 Organization Tools 17 | - Group tabs by color and label 18 | - Pin important tabs automatically 19 | - Protect tabs from accidental closure 20 | - Enforce unique tabs (auto-close duplicates) 21 | - Mute noisy tabs 22 | 23 | 🍯 Tab Hive - Auto-Close Inactive Tabs 24 | - Automatically close tabs after period of inactivity 25 | - Save closed tabs for easy restoration 26 | - Search and restore from the side panel 27 | - Grouped by domain for better organization 28 | 29 | 🌐 Side Panel Integration 30 | - Quick access to add rules 31 | - Browse and restore closed tabs 32 | - Real-time tab management 33 | 34 | 🔍 Spot Search - Quick Tab & Bookmark Finder 35 | - Instant search across all open tabs and bookmarks with Alt+Shift+E (or Cmd+Shift+E on Mac) 36 | - Real-time filtering as you type - search by title, URL, or tab group name 37 | - Keyboard navigation - use arrow keys (↑↓) to navigate, Enter to select, Escape to close 38 | 39 | 🪟 Windows Merger 40 | - Merge all browser windows into one with Alt+Shift+W (or Cmd+Shift+W on Mac) 41 | - Also accessible via right-click context menu 42 | 43 | ⚡ Performance Mode 44 | - Lightweight mode for specific domains 45 | - Reduce memory usage on resource-heavy sites 46 | - Disable listeners selectively 47 | 48 | ♻️ Sync Across Devices 49 | - Automatic rule synchronization via Chrome Sync 50 | - Consistent experience on all your devices 51 | 52 | 🔐 Privacy First 53 | - Open-source and transparent 54 | - No data collection 55 | - All rules stored locally on your device 56 | - ReDoS attack protection 57 | 58 | 🔧 Advanced Features: 59 | - URL fragment matching (CONTAINS, STARTS, ENDS, REGEX, EXACT) 60 | - Title matcher with capture groups (@0, @1, @2...) 61 | - URL matcher with capture groups ($0, $1, $2...) 62 | - Dynamic title updates with page changes 63 | - Keyboard shortcuts for common actions 64 | 65 | 🌍 Open Source: 66 | Contribute or report issues: https://github.com/furybee/chrome-tab-modifier 67 | Latest releases: https://github.com/furybee/chrome-tab-modifier/releases 68 | 69 | 🔒 About Permissions: 70 | Access to all websites is required to update tabs as you browse. We respect your privacy - no data is collected or transmitted. All your rules and settings remain on your 71 | device. 72 | 73 | 💬 Support: 74 | Questions? Feedback? Contact us on the GitHub project or leave a review! 75 | -------------------------------------------------------------------------------- /src/components/options/center/sections/TabGroupsPane.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 100 | 101 | -------------------------------------------------------------------------------- /src/components/global/CustomSelect.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /src/common/emoji-data/categories/gestures.ts: -------------------------------------------------------------------------------- 1 | export const data = { 2 | name: 'Gestures', 3 | emojis: [ 4 | { emoji: '👍', keywords: ['thumbs', 'up', 'good', 'yes', 'ok', 'approve'] }, 5 | { emoji: '👎', keywords: ['thumbs', 'down', 'bad', 'no', 'disapprove', 'dislike'] }, 6 | { emoji: '👌', keywords: ['ok', 'perfect', 'good', 'fine', 'excellent'] }, 7 | { emoji: '🤌', keywords: ['pinched', 'fingers', 'italian', 'chef', 'kiss'] }, 8 | { emoji: '🤏', keywords: ['pinch', 'small', 'tiny', 'little', 'bit'] }, 9 | { emoji: '✌️', keywords: ['peace', 'victory', 'two', 'fingers', 'sign'] }, 10 | { emoji: '🤞', keywords: ['cross', 'fingers', 'luck', 'hope', 'wish'] }, 11 | { emoji: '🤟', keywords: ['love', 'you', 'sign', 'language', 'hand'] }, 12 | { emoji: '🤘', keywords: ['rock', 'on', 'metal', 'horns', 'music'] }, 13 | { emoji: '🤙', keywords: ['call', 'me', 'hang', 'loose', 'shaka'] }, 14 | { emoji: '👈', keywords: ['point', 'left', 'finger', 'direction', 'that'] }, 15 | { emoji: '👉', keywords: ['point', 'right', 'finger', 'direction', 'this'] }, 16 | { emoji: '👆', keywords: ['point', 'up', 'finger', 'direction', 'above'] }, 17 | { emoji: '👇', keywords: ['point', 'down', 'finger', 'direction', 'below'] }, 18 | { emoji: '☝️', keywords: ['index', 'point', 'up', 'finger', 'one'] }, 19 | { emoji: '👋', keywords: ['wave', 'hello', 'goodbye', 'hi', 'bye'] }, 20 | { emoji: '🤚', keywords: ['raised', 'back', 'hand', 'stop', 'high', 'five'] }, 21 | { emoji: '🖐', keywords: ['hand', 'five', 'fingers', 'palm', 'stop'] }, 22 | { emoji: '✋', keywords: ['raised', 'hand', 'stop', 'high', 'five'] }, 23 | { emoji: '🖖', keywords: ['vulcan', 'spock', 'star', 'trek', 'live', 'long'] }, 24 | { emoji: '👏', keywords: ['clap', 'applause', 'bravo', 'good', 'job'] }, 25 | { emoji: '🙌', keywords: ['praise', 'celebration', 'hands', 'hooray', 'yay'] }, 26 | { emoji: '🤝', keywords: ['handshake', 'deal', 'agreement', 'partnership', 'hello'] }, 27 | { emoji: '👐', keywords: ['open', 'hands', 'hug', 'embrace', 'jazz'] }, 28 | { emoji: '🤲', keywords: ['palms', 'up', 'together', 'pray', 'ask'] }, 29 | { emoji: '🤜', keywords: ['right', 'facing', 'fist', 'punch', 'bump'] }, 30 | { emoji: '🤛', keywords: ['left', 'facing', 'fist', 'punch', 'bump'] }, 31 | { emoji: '✊', keywords: ['raised', 'fist', 'power', 'strength', 'solidarity'] }, 32 | { emoji: '👊', keywords: ['fist', 'bump', 'punch', 'knuckles', 'greeting'] }, 33 | { emoji: '🙏', keywords: ['folded', 'hands', 'pray', 'please', 'thank', 'you'] }, 34 | { emoji: '💪', keywords: ['flexed', 'bicep', 'strong', 'muscle', 'power'] }, 35 | { emoji: '🦵', keywords: ['leg', 'kick', 'limb'] }, 36 | { emoji: '🦶', keywords: ['foot', 'kick', 'stomp'] }, 37 | { emoji: '👂', keywords: ['ear', 'hear', 'listen', 'sound'] }, 38 | { emoji: '👃', keywords: ['nose', 'smell', 'sniff'] }, 39 | { emoji: '👀', keywords: ['eyes', 'look', 'see', 'watch'] }, 40 | { emoji: '👁️', keywords: ['eye', 'see', 'look', 'watch'] }, 41 | { emoji: '👄', keywords: ['mouth', 'lips', 'kiss', 'speak'] }, 42 | { emoji: '👅', keywords: ['tongue', 'lick', 'taste'] }, 43 | ], 44 | }; 45 | -------------------------------------------------------------------------------- /src/components/options/center/sections/TabRulesPane.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 115 | 116 | -------------------------------------------------------------------------------- /src/components/global/RegexVisualizer.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 107 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /src/background/__tests__/ContextMenuService.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, vi } from 'vitest'; 2 | import { ContextMenuService } from '../ContextMenuService'; 3 | 4 | // Mock chrome APIs 5 | const mockChrome = { 6 | contextMenus: { 7 | create: vi.fn(), 8 | }, 9 | }; 10 | 11 | // @ts-ignore 12 | global.chrome = mockChrome; 13 | 14 | describe('ContextMenuService', () => { 15 | let service: ContextMenuService; 16 | 17 | beforeEach(() => { 18 | service = new ContextMenuService(); 19 | vi.clearAllMocks(); 20 | }); 21 | 22 | describe('initialize', () => { 23 | it('should create all context menus', () => { 24 | service.initialize(); 25 | 26 | // 3 main menus + 3 reject list menus (1 parent + 2 children) 27 | expect(mockChrome.contextMenus.create).toHaveBeenCalledTimes(6); 28 | 29 | // Check rename tab menu 30 | expect(mockChrome.contextMenus.create).toHaveBeenCalledWith({ 31 | id: 'rename-tab', 32 | title: '✏️ Rename Tab', 33 | contexts: ['all'], 34 | }); 35 | 36 | // Check merge windows menu 37 | expect(mockChrome.contextMenus.create).toHaveBeenCalledWith({ 38 | id: 'merge-windows', 39 | title: '🪟 Merge All Windows', 40 | contexts: ['all'], 41 | }); 42 | 43 | // Check send to hive menu 44 | expect(mockChrome.contextMenus.create).toHaveBeenCalledWith({ 45 | id: 'send-to-hive', 46 | title: '🍯 Send to Tab Hive', 47 | contexts: ['all'], 48 | }); 49 | 50 | // Check Tab Hive reject menus 51 | expect(mockChrome.contextMenus.create).toHaveBeenCalledWith({ 52 | id: 'tab-hive-reject-parent', 53 | title: '🚫 Exclude from Tab Hive', 54 | contexts: ['all'], 55 | }); 56 | 57 | expect(mockChrome.contextMenus.create).toHaveBeenCalledWith({ 58 | id: 'tab-hive-reject-domain', 59 | parentId: 'tab-hive-reject-parent', 60 | title: '🌐 Exclude this domain', 61 | contexts: ['all'], 62 | }); 63 | 64 | expect(mockChrome.contextMenus.create).toHaveBeenCalledWith({ 65 | id: 'tab-hive-reject-url', 66 | parentId: 'tab-hive-reject-parent', 67 | title: '🔗 Exclude this URL', 68 | contexts: ['all'], 69 | }); 70 | }); 71 | 72 | it('should create menus in correct order', () => { 73 | service.initialize(); 74 | 75 | const calls = mockChrome.contextMenus.create.mock.calls; 76 | 77 | expect(calls[0][0].id).toBe('rename-tab'); 78 | expect(calls[1][0].id).toBe('merge-windows'); 79 | expect(calls[2][0].id).toBe('send-to-hive'); 80 | expect(calls[3][0].id).toBe('tab-hive-reject-parent'); 81 | expect(calls[4][0].id).toBe('tab-hive-reject-domain'); 82 | expect(calls[5][0].id).toBe('tab-hive-reject-url'); 83 | }); 84 | 85 | it('should set all menus to "all" contexts', () => { 86 | service.initialize(); 87 | 88 | const calls = mockChrome.contextMenus.create.mock.calls; 89 | 90 | calls.forEach((call) => { 91 | expect(call[0].contexts).toEqual(['all']); 92 | }); 93 | }); 94 | 95 | it('should include emojis in menu titles', () => { 96 | service.initialize(); 97 | 98 | const calls = mockChrome.contextMenus.create.mock.calls; 99 | 100 | expect(calls[0][0].title).toContain('✏️'); 101 | expect(calls[1][0].title).toContain('🪟'); 102 | expect(calls[2][0].title).toContain('🍯'); 103 | }); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /src/background/SpotSearchService.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Service responsible for spot search functionality 3 | * Handles searching tabs and bookmarks 4 | */ 5 | 6 | export interface SpotSearchTab { 7 | id?: number; 8 | title?: string; 9 | url?: string; 10 | favIconUrl?: string; 11 | windowId?: number; 12 | groupId?: number; 13 | groupTitle?: string; 14 | groupColor?: string; 15 | } 16 | 17 | export interface SpotSearchBookmark { 18 | id: string; 19 | title: string; 20 | url?: string; 21 | } 22 | 23 | export interface SpotSearchResults { 24 | tabs: SpotSearchTab[]; 25 | bookmarks: SpotSearchBookmark[]; 26 | } 27 | 28 | export class SpotSearchService { 29 | /** 30 | * Search tabs and bookmarks based on query 31 | * @param query - Search query (optional) 32 | * @returns Search results containing tabs and bookmarks 33 | */ 34 | async search(query?: string): Promise { 35 | const lowerQuery = query?.toLowerCase().trim(); 36 | 37 | // Get all tabs 38 | const allTabs = await chrome.tabs.query({}); 39 | const groups = await chrome.tabGroups.query({}); 40 | 41 | // Filter and map tabs 42 | const tabs = allTabs 43 | .map((tab) => { 44 | let tabGroup = undefined; 45 | 46 | if (tab.groupId && tab.groupId !== chrome.tabGroups.TAB_GROUP_ID_NONE) { 47 | tabGroup = groups.find((group) => group.id === tab.groupId); 48 | } 49 | 50 | return { 51 | id: tab.id, 52 | title: tab.title, 53 | url: tab.url, 54 | favIconUrl: tab.favIconUrl, 55 | windowId: tab.windowId, 56 | groupId: tab.groupId, 57 | groupTitle: tabGroup?.title, 58 | groupColor: tabGroup?.color, 59 | }; 60 | }) 61 | .filter((tab) => { 62 | // Filter out chrome:// URLs 63 | if (tab.url?.startsWith('chrome://') || tab.url?.startsWith('about:')) { 64 | return false; 65 | } 66 | 67 | // If no query, return all tabs 68 | if (!lowerQuery) { 69 | return true; 70 | } 71 | 72 | // Search in title, URL, and group title 73 | return ( 74 | tab.title?.toLowerCase().includes(lowerQuery) || 75 | tab.url?.toLowerCase().includes(lowerQuery) || 76 | tab.groupTitle?.toLowerCase().includes(lowerQuery) 77 | ); 78 | }); 79 | 80 | // Search bookmarks 81 | let bookmarks: SpotSearchBookmark[] = []; 82 | if (lowerQuery) { 83 | const rawBookmarks = await chrome.bookmarks.search({ query: lowerQuery }); 84 | bookmarks = rawBookmarks 85 | .filter((bookmark) => bookmark.url) // Only bookmarks with URLs 86 | .map((bookmark) => ({ 87 | id: bookmark.id, 88 | title: bookmark.title, 89 | url: bookmark.url, 90 | })); 91 | } 92 | 93 | return { tabs, bookmarks }; 94 | } 95 | 96 | /** 97 | * Activate a specific tab 98 | * @param tabId - ID of the tab to activate 99 | * @param windowId - ID of the window containing the tab 100 | */ 101 | async activateTab(tabId: number, windowId: number): Promise { 102 | await chrome.tabs.update(tabId, { active: true }); 103 | await chrome.windows.update(windowId, { focused: true }); 104 | } 105 | 106 | /** 107 | * Open a bookmark in a new tab 108 | * @param url - URL of the bookmark to open 109 | */ 110 | async openBookmark(url: string): Promise { 111 | await chrome.tabs.create({ url }); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/content/StorageService.ts: -------------------------------------------------------------------------------- 1 | import type { Rule, TabModifierSettings } from '../common/types'; 2 | import { RegexService } from './RegexService'; 3 | import { decompressFromUTF16 } from 'lz-string'; 4 | 5 | const STORAGE_KEY = 'tab_modifier'; 6 | const STORAGE_KEY_COMPRESSED = 'tab_modifier_compressed'; 7 | 8 | /** 9 | * Service responsible for storage operations and rule matching 10 | * Handles Chrome storage API interactions and URL-based rule detection 11 | */ 12 | export class StorageService { 13 | private regexService: RegexService; 14 | 15 | constructor(regexService: RegexService) { 16 | this.regexService = regexService; 17 | } 18 | 19 | /** 20 | * Decompress data from storage 21 | */ 22 | private decompressData(compressed: string): TabModifierSettings | null { 23 | try { 24 | const decompressed = decompressFromUTF16(compressed); 25 | if (!decompressed) { 26 | return null; 27 | } 28 | return JSON.parse(decompressed); 29 | } catch (error) { 30 | console.error('[Tabee Content] Failed to decompress data:', error); 31 | return null; 32 | } 33 | } 34 | 35 | /** 36 | * Retrieves storage data asynchronously 37 | * @returns Promise containing the stored data 38 | */ 39 | async getStorageAsync(): Promise { 40 | return new Promise((resolve, reject) => { 41 | chrome.storage.local.get([STORAGE_KEY, STORAGE_KEY_COMPRESSED], (items) => { 42 | if (chrome.runtime.lastError) { 43 | reject(new Error(chrome.runtime.lastError.message)); 44 | } else { 45 | // Priority: compressed data first, then uncompressed 46 | if (items[STORAGE_KEY_COMPRESSED]) { 47 | const decompressed = this.decompressData(items[STORAGE_KEY_COMPRESSED]); 48 | if (decompressed) { 49 | resolve(decompressed); 50 | return; 51 | } 52 | console.warn('[Tabee Content] Failed to decompress data, falling back to uncompressed'); 53 | } 54 | 55 | // Fallback to uncompressed data (backward compatibility) 56 | resolve(items[STORAGE_KEY]); 57 | } 58 | }); 59 | }); 60 | } 61 | 62 | /** 63 | * Finds a rule matching the given URL 64 | * @param url - The URL to match against rules 65 | * @returns The matching rule or undefined 66 | */ 67 | async getRuleFromUrl(url: string): Promise { 68 | const tabModifier = await this.getStorageAsync(); 69 | if (!tabModifier) { 70 | return; 71 | } 72 | 73 | const foundRule = tabModifier.rules.find((r: Rule) => { 74 | // Skip disabled rules 75 | if (r.is_enabled === false) { 76 | return false; 77 | } 78 | 79 | const detectionType = r.detection ?? 'CONTAINS'; 80 | const urlFragment = r.url_fragment; 81 | 82 | switch (detectionType) { 83 | case 'CONTAINS': 84 | return url.includes(urlFragment); 85 | case 'STARTS': 86 | case 'STARTS_WITH': 87 | return url.startsWith(urlFragment); 88 | case 'ENDS': 89 | case 'ENDS_WITH': 90 | return url.endsWith(urlFragment); 91 | case 'REGEX': 92 | case 'REGEXP': 93 | try { 94 | const regex = this.regexService.createSafeRegex(urlFragment); 95 | return regex.test(url); 96 | } catch (e) { 97 | console.error('Error processing regex pattern for URL matching:', e); 98 | return false; 99 | } 100 | case 'EXACT': 101 | return url === urlFragment; 102 | default: 103 | return false; 104 | } 105 | }); 106 | 107 | if (!foundRule) { 108 | return; 109 | } 110 | 111 | return foundRule; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/components/options/center/sections/TabGroups/GroupForm.vue: -------------------------------------------------------------------------------- 1 | 65 | 119 | -------------------------------------------------------------------------------- /src/common/emoji-data/categories/buildings.ts: -------------------------------------------------------------------------------- 1 | export const data = { 2 | name: 'Buildings & Places', 3 | emojis: [ 4 | { emoji: '🏠', keywords: ['house', 'home', 'building', 'residential'] }, 5 | { emoji: '🏡', keywords: ['house', 'garden', 'home', 'suburban', 'residential'] }, 6 | { emoji: '🏘️', keywords: ['houses', 'neighborhood', 'residential', 'community'] }, 7 | { emoji: '🏚️', keywords: ['derelict', 'house', 'abandoned', 'old', 'broken'] }, 8 | { emoji: '🏗️', keywords: ['building', 'construction', 'crane', 'work', 'site'] }, 9 | { emoji: '🏭', keywords: ['factory', 'industrial', 'manufacturing', 'pollution'] }, 10 | { emoji: '🏢', keywords: ['office', 'building', 'business', 'corporate', 'work'] }, 11 | { emoji: '🏬', keywords: ['department', 'store', 'shopping', 'mall', 'retail'] }, 12 | { emoji: '🏣', keywords: ['japanese', 'post', 'office', 'building'] }, 13 | { emoji: '🏤', keywords: ['european', 'post', 'office', 'building'] }, 14 | { emoji: '🏥', keywords: ['hospital', 'medical', 'health', 'emergency', 'doctor'] }, 15 | { emoji: '🏦', keywords: ['bank', 'money', 'financial', 'institution'] }, 16 | { emoji: '🏨', keywords: ['hotel', 'accommodation', 'travel', 'vacation'] }, 17 | { emoji: '🏩', keywords: ['love', 'hotel', 'romantic', 'heart'] }, 18 | { emoji: '🏪', keywords: ['convenience', 'store', 'shop', 'retail'] }, 19 | { emoji: '🏫', keywords: ['school', 'education', 'learning', 'students'] }, 20 | { emoji: '🏰', keywords: ['castle', 'fortress', 'medieval', 'royal', 'palace'] }, 21 | { emoji: '🗼', keywords: ['tokyo', 'tower', 'landmark', 'tall', 'structure'] }, 22 | { emoji: '🗽', keywords: ['statue', 'liberty', 'new', 'york', 'freedom'] }, 23 | { emoji: '⛪', keywords: ['church', 'religion', 'christian', 'worship'] }, 24 | { emoji: '🕌', keywords: ['mosque', 'islam', 'muslim', 'worship', 'religion'] }, 25 | { emoji: '🛕', keywords: ['hindu', 'temple', 'religion', 'worship'] }, 26 | { emoji: '🕍', keywords: ['synagogue', 'jewish', 'religion', 'worship'] }, 27 | { emoji: '⛩️', keywords: ['shinto', 'shrine', 'japanese', 'religion'] }, 28 | { emoji: '🕋', keywords: ['kaaba', 'mecca', 'islam', 'pilgrimage'] }, 29 | { emoji: '⛲', keywords: ['fountain', 'water', 'park', 'decoration'] }, 30 | { emoji: '⛺', keywords: ['tent', 'camping', 'outdoor', 'adventure'] }, 31 | { emoji: '🌁', keywords: ['foggy', 'city', 'skyline', 'mist', 'weather'] }, 32 | { emoji: '🌃', keywords: ['night', 'city', 'lights', 'urban', 'skyline'] }, 33 | { emoji: '🏙️', keywords: ['cityscape', 'urban', 'buildings', 'skyline'] }, 34 | { emoji: '🌄', keywords: ['sunrise', 'mountains', 'morning', 'landscape'] }, 35 | { emoji: '🌅', keywords: ['sunrise', 'ocean', 'morning', 'beach'] }, 36 | { emoji: '🌆', keywords: ['cityscape', 'dusk', 'evening', 'sunset'] }, 37 | { emoji: '🌇', keywords: ['sunset', 'city', 'evening', 'buildings'] }, 38 | { emoji: '🌉', keywords: ['bridge', 'night', 'lights', 'architecture'] }, 39 | { emoji: '♨️', keywords: ['hot', 'springs', 'spa', 'relax', 'steam'] }, 40 | { emoji: '🎠', keywords: ['carousel', 'horse', 'amusement', 'park', 'fun'] }, 41 | { emoji: '🎡', keywords: ['ferris', 'wheel', 'amusement', 'park', 'ride'] }, 42 | { emoji: '🎢', keywords: ['roller', 'coaster', 'amusement', 'park', 'thrill'] }, 43 | { emoji: '💈', keywords: ['barber', 'pole', 'haircut', 'salon', 'shop'] }, 44 | { emoji: '🎪', keywords: ['circus', 'tent', 'entertainment', 'show', 'fun'] }, 45 | { emoji: '🛏️', keywords: ['bed', 'sleep', 'hotel', 'rest', 'bedroom'] }, 46 | { emoji: '🛋️', keywords: ['couch', 'lamp', 'furniture', 'living', 'room'] }, 47 | { emoji: '🪑', keywords: ['chair', 'sit', 'furniture', 'seat'] }, 48 | { emoji: '🚪', keywords: ['door', 'entrance', 'exit', 'opening'] }, 49 | { emoji: '🪟', keywords: ['window', 'view', 'glass', 'opening'] }, 50 | { emoji: '🪜', keywords: ['ladder', 'climb', 'steps', 'up'] }, 51 | ], 52 | }; 53 | -------------------------------------------------------------------------------- /src/content/__tests__/RegexService.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach } from 'vitest'; 2 | import { RegexService } from '../RegexService'; 3 | 4 | describe('RegexService', () => { 5 | let service: RegexService; 6 | 7 | beforeEach(() => { 8 | service = new RegexService(); 9 | }); 10 | 11 | describe('isRegexSafe', () => { 12 | it('should return false for non-string patterns', () => { 13 | const result = service.isRegexSafe(123 as any); 14 | expect(result).toBe(false); 15 | }); 16 | 17 | it('should return false for patterns longer than 200 characters', () => { 18 | const longPattern = 'a'.repeat(201); 19 | const result = service.isRegexSafe(longPattern); 20 | expect(result).toBe(false); 21 | }); 22 | 23 | it('should return false for positive lookahead with quantifiers', () => { 24 | const result = service.isRegexSafe('(?=.*)+'); 25 | expect(result).toBe(false); 26 | }); 27 | 28 | it('should return false for negative lookahead with quantifiers', () => { 29 | const result = service.isRegexSafe('(?!.*)+'); 30 | expect(result).toBe(false); 31 | }); 32 | 33 | it('should return false for catastrophic backtracking patterns', () => { 34 | const result = service.isRegexSafe('(.+)+$'); 35 | expect(result).toBe(false); 36 | }); 37 | 38 | it('should return false for conflicting quantifiers', () => { 39 | const result = service.isRegexSafe('(.+)*+'); 40 | expect(result).toBe(false); 41 | }); 42 | 43 | it('should return false for multiple .* in groups', () => { 44 | const result = service.isRegexSafe('(.*){2,}'); 45 | expect(result).toBe(false); 46 | }); 47 | 48 | it('should return false for multiple .+ in groups', () => { 49 | const result = service.isRegexSafe('(.+){2,}'); 50 | expect(result).toBe(false); 51 | }); 52 | 53 | it('should return true for safe patterns', () => { 54 | expect(service.isRegexSafe('hello.*world')).toBe(true); 55 | expect(service.isRegexSafe('^https?://')).toBe(true); 56 | expect(service.isRegexSafe('\\d{3}-\\d{4}')).toBe(true); 57 | expect(service.isRegexSafe('[a-zA-Z]+')).toBe(true); 58 | }); 59 | }); 60 | 61 | describe('createSafeRegex', () => { 62 | it('should throw error for unsafe patterns', () => { 63 | expect(() => service.createSafeRegex('(?=.*)+')).toThrow('Potentially unsafe regex pattern detected'); 64 | }); 65 | 66 | it('should throw error for patterns longer than 200 characters', () => { 67 | const longPattern = 'a'.repeat(201); 68 | expect(() => service.createSafeRegex(longPattern)).toThrow('Potentially unsafe regex pattern detected'); 69 | }); 70 | 71 | it('should create regex with default flags', () => { 72 | const regex = service.createSafeRegex('test'); 73 | expect(regex.flags).toBe('g'); 74 | }); 75 | 76 | it('should create regex with custom flags', () => { 77 | const regex = service.createSafeRegex('test', 'gi'); 78 | expect(regex.flags).toBe('gi'); 79 | }); 80 | 81 | it('should throw error for invalid regex syntax', () => { 82 | expect(() => service.createSafeRegex('[invalid')).toThrow('Invalid regex pattern'); 83 | }); 84 | 85 | it('should create valid regex for safe patterns', () => { 86 | // Using '' flags instead of 'g' for test() method to avoid stateful matching 87 | const regex = service.createSafeRegex('hello.*world', ''); 88 | expect(regex.test('hello beautiful world')).toBe(true); 89 | expect(regex.test('hello world')).toBe(true); 90 | expect(regex.test('goodbye world')).toBe(false); 91 | }); 92 | 93 | it('should handle escape sequences correctly', () => { 94 | const regex = service.createSafeRegex('\\d{3}-\\d{4}'); 95 | expect(regex.test('123-4567')).toBe(true); 96 | expect(regex.test('abc-defg')).toBe(false); 97 | }); 98 | 99 | it('should handle character classes', () => { 100 | const regex = service.createSafeRegex('[a-z]+'); 101 | expect(regex.test('hello')).toBe(true); 102 | expect(regex.test('123')).toBe(false); 103 | }); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /src/content.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Content Script - Tab Modifier 3 | * 4 | * This script runs in the context of web pages and applies tab modification rules. 5 | * It has been refactored following SOLID principles with service-based architecture. 6 | */ 7 | 8 | import { RegexService } from './content/RegexService'; 9 | import { TitleService } from './content/TitleService'; 10 | import { IconService } from './content/IconService'; 11 | import { StorageService } from './content/StorageService'; 12 | import { RuleApplicationService } from './content/RuleApplicationService'; 13 | import { SpotSearchUI } from './content/SpotSearchUI'; 14 | import { UrlChangeDetector } from './content/UrlChangeDetector'; 15 | 16 | // ============================================================ 17 | // Service Initialization 18 | // ============================================================ 19 | 20 | const regexService = new RegexService(); 21 | const titleService = new TitleService(regexService); 22 | const iconService = new IconService(); 23 | const storageService = new StorageService(regexService); 24 | const ruleApplicationService = new RuleApplicationService(titleService, iconService); 25 | 26 | // Spot Search UI 27 | console.log('[Tabee Content] 🔍 Initializing Spot Search UI...'); 28 | const spotSearchUI = new SpotSearchUI(); 29 | try { 30 | spotSearchUI.init(); 31 | console.log('[Tabee Content] ✅ Spot Search UI initialized'); 32 | } catch (error) { 33 | console.error('[Tabee Content] ❌ Failed to initialize Spot Search UI:', error); 34 | } 35 | 36 | // ============================================================ 37 | // Initial Rule Application 38 | // ============================================================ 39 | 40 | /** 41 | * Apply rules for a given URL 42 | * This function is called on initial load and when URL changes (SPA navigation) 43 | */ 44 | async function applyRulesForUrl(url: string): Promise { 45 | try { 46 | const rule = await storageService.getRuleFromUrl(url); 47 | if (rule) { 48 | console.log('[Tabee Content] 📋 Applying rule for URL:', url); 49 | await ruleApplicationService.applyRule(rule); 50 | } 51 | } catch (error) { 52 | console.error('[Tabee Content] Error applying rule:', error); 53 | } 54 | } 55 | 56 | // Apply rules on initial page load 57 | applyRulesForUrl(location.href); 58 | 59 | // ============================================================ 60 | // SPA URL Change Detection 61 | // ============================================================ 62 | 63 | // Setup URL change detector for Single Page Applications 64 | const urlChangeDetector = new UrlChangeDetector(); 65 | urlChangeDetector.onChange(async (newUrl, _oldUrl) => { 66 | console.log('[Tabee Content] 🔄 SPA navigation detected, re-applying rules'); 67 | await applyRulesForUrl(newUrl); 68 | }); 69 | urlChangeDetector.start(); 70 | 71 | // ============================================================ 72 | // Message Listeners 73 | // ============================================================ 74 | 75 | chrome.runtime.onMessage.addListener(async function (request) { 76 | 77 | if (request.action === 'openPrompt') { 78 | const title = prompt( 79 | 'Enter the new title, a Tab rule will be automatically created for you based on current URL' 80 | ); 81 | 82 | if (title) { 83 | await chrome.runtime.sendMessage({ 84 | action: 'renameTab', 85 | title: title, 86 | }); 87 | } 88 | } else if (request.action === 'applyRule') { 89 | // Don't update title because it will be updated by the MutationObserver 90 | await ruleApplicationService.applyRule(request.rule, false); 91 | } else if (request.action === 'ungroupTab') { 92 | await chrome.tabs.ungroup(request.tabId); 93 | } else if (request.action === 'toggleSpotSearch') { 94 | console.log('[Tabee Content] 🔍 Toggling spot search UI...'); 95 | // Toggle spot search UI 96 | spotSearchUI.toggle(); 97 | console.log('[Tabee Content] ✅ Spot search toggled'); 98 | } else if (request.action === 'spotSearchResults') { 99 | console.log('[Tabee Content] 🔍 Displaying search results:', request.tabs.length, 'tabs,', request.bookmarks.length, 'bookmarks'); 100 | // Display search results 101 | spotSearchUI.displayResults(request.tabs, request.bookmarks); 102 | } 103 | }); 104 | -------------------------------------------------------------------------------- /src/common/helpers.urlFragment.test.js: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, vi } from 'vitest'; 2 | import { _processUrlFragment } from './helpers.ts'; 3 | 4 | describe('_processUrlFragment', () => { 5 | beforeEach(() => { 6 | vi.clearAllMocks(); 7 | }); 8 | 9 | it('should return original fragment when no urlMatcher is provided', () => { 10 | const urlFragment = 'https://example.com/ticket/$1'; 11 | const currentUrl = 'https://example.com/ticket/ABC-123'; 12 | 13 | const result = _processUrlFragment(urlFragment, currentUrl); 14 | 15 | expect(result).toBe(urlFragment); 16 | }); 17 | 18 | it('should return original fragment when no $ placeholders are present', () => { 19 | const urlFragment = 'https://example.com/ticket/'; 20 | const currentUrl = 'https://example.com/ticket/ABC-123'; 21 | const urlMatcher = 'https://example.com/ticket/([A-Z]+-\\d+)'; 22 | 23 | const result = _processUrlFragment(urlFragment, currentUrl, urlMatcher); 24 | 25 | expect(result).toBe(urlFragment); 26 | }); 27 | 28 | it('should process single capture group correctly', () => { 29 | const urlFragment = 'https://mysite.atlassian.net/browse/$1'; 30 | const currentUrl = 'https://mysite.atlassian.net/browse/ABC-123'; 31 | const urlMatcher = 'https://mysite.atlassian.net/browse/([A-Z]+-\\d+)'; 32 | 33 | const result = _processUrlFragment(urlFragment, currentUrl, urlMatcher); 34 | 35 | expect(result).toBe('https://mysite.atlassian.net/browse/ABC-123'); 36 | }); 37 | 38 | it('should process multiple capture groups correctly', () => { 39 | const urlFragment = 'https://$1.atlassian.net/browse/$2'; 40 | const currentUrl = 'https://mysite.atlassian.net/browse/PROJ-456'; 41 | const urlMatcher = 'https://([^.]+)\\.atlassian\\.net/browse/([A-Z]+-\\d+)'; 42 | 43 | const result = _processUrlFragment(urlFragment, currentUrl, urlMatcher); 44 | 45 | expect(result).toBe('https://mysite.atlassian.net/browse/PROJ-456'); 46 | }); 47 | 48 | it('should handle non-matching URL gracefully', () => { 49 | const urlFragment = 'https://example.com/ticket/$1'; 50 | const currentUrl = 'https://different.com/page'; 51 | const urlMatcher = 'https://example.com/ticket/([A-Z]+-\\d+)'; 52 | 53 | const result = _processUrlFragment(urlFragment, currentUrl, urlMatcher); 54 | 55 | // Should return original fragment since no matches found 56 | expect(result).toBe(urlFragment); 57 | }); 58 | 59 | it('should handle invalid regex gracefully', () => { 60 | const urlFragment = 'https://example.com/ticket/$1'; 61 | const currentUrl = 'https://example.com/ticket/ABC-123'; 62 | const urlMatcher = '[invalid(regex'; 63 | 64 | // Mock console.error to verify it's called 65 | const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); 66 | 67 | const result = _processUrlFragment(urlFragment, currentUrl, urlMatcher); 68 | 69 | expect(result).toBe(urlFragment); 70 | expect(consoleErrorSpy).toHaveBeenCalledWith( 71 | 'Tabee: Error processing URL fragment:', 72 | expect.any(Error) 73 | ); 74 | 75 | consoleErrorSpy.mockRestore(); 76 | }); 77 | 78 | it('should handle complex JIRA URL pattern from the issue', () => { 79 | const urlFragment = 'https://mysite.atlassian.net/browse/$1'; 80 | const currentUrl = 'https://mysite.atlassian.net/browse/PROJ-123?someParam=value'; 81 | const urlMatcher = 'https://mysite.atlassian.net/browse/([A-Z]+-\\d+)'; 82 | 83 | const result = _processUrlFragment(urlFragment, currentUrl, urlMatcher); 84 | 85 | expect(result).toBe('https://mysite.atlassian.net/browse/PROJ-123'); 86 | }); 87 | 88 | it('should replace multiple occurrences of the same placeholder', () => { 89 | const urlFragment = 'ticket-$1-copy-$1'; 90 | const currentUrl = 'https://example.com/ticket/ABC-123'; 91 | const urlMatcher = 'https://example.com/ticket/([A-Z]+-\\d+)'; 92 | 93 | const result = _processUrlFragment(urlFragment, currentUrl, urlMatcher); 94 | 95 | expect(result).toBe('ticket-ABC-123-copy-ABC-123'); 96 | }); 97 | 98 | it('should handle empty capture groups', () => { 99 | const urlFragment = 'prefix-$1-suffix'; 100 | const currentUrl = 'https://example.com/page/'; 101 | const urlMatcher = 'https://example.com/page/(.*)'; 102 | 103 | const result = _processUrlFragment(urlFragment, currentUrl, urlMatcher); 104 | 105 | expect(result).toBe('prefix--suffix'); 106 | }); 107 | }); -------------------------------------------------------------------------------- /src/content/UrlChangeDetector.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Service responsible for detecting URL changes in Single Page Applications (SPAs) 3 | * SPAs use History API (pushState/replaceState) which doesn't trigger page reloads 4 | * or chrome.tabs.onUpdated events, so we need to detect these changes client-side 5 | */ 6 | export class UrlChangeDetector { 7 | private lastUrl: string; 8 | private observers: Array<(newUrl: string, oldUrl: string) => void> = []; 9 | private debounceTimeout: number | null = null; 10 | 11 | constructor() { 12 | this.lastUrl = location.href; 13 | } 14 | 15 | /** 16 | * Start monitoring URL changes 17 | */ 18 | start(): void { 19 | // Monitor DOM changes that might indicate URL changes (heavily throttled) 20 | this.setupMutationObserver(); 21 | 22 | // Intercept History API methods (primary detection for SPAs) 23 | this.interceptHistoryAPI(); 24 | 25 | // Listen to popstate events (back/forward navigation) 26 | window.addEventListener('popstate', () => { 27 | this.checkUrlChange(); 28 | }); 29 | 30 | // Periodic check as fallback (catches URL changes missed by other methods) 31 | // 1000ms provides good coverage without excessive overhead 32 | setInterval(() => { 33 | this.checkUrlChange(); 34 | }, 1000); 35 | } 36 | 37 | /** 38 | * Register a callback to be called when URL changes 39 | */ 40 | onChange(callback: (newUrl: string, oldUrl: string) => void): void { 41 | this.observers.push(callback); 42 | } 43 | 44 | /** 45 | * Check if URL has changed and notify observers 46 | */ 47 | private checkUrlChange(): void { 48 | const currentUrl = location.href; 49 | if (currentUrl !== this.lastUrl) { 50 | const oldUrl = this.lastUrl; 51 | this.lastUrl = currentUrl; 52 | 53 | console.log('[Tabee] 🔄 URL changed (SPA navigation):', { 54 | from: oldUrl, 55 | to: currentUrl, 56 | }); 57 | 58 | // Notify all observers 59 | this.observers.forEach((callback) => { 60 | try { 61 | callback(currentUrl, oldUrl); 62 | } catch (error) { 63 | console.error('[Tabee] Error in URL change callback:', error); 64 | } 65 | }); 66 | } 67 | } 68 | 69 | /** 70 | * Debounced version of checkUrlChange with rate limiting 71 | * Prevents excessive calls during heavy DOM manipulation 72 | */ 73 | private debouncedCheckUrlChange(): void { 74 | // Cancel any pending check 75 | if (this.debounceTimeout !== null) { 76 | clearTimeout(this.debounceTimeout); 77 | } 78 | 79 | // Schedule the URL check with debounce 80 | // This means: wait 500ms of "silence" before checking 81 | this.debounceTimeout = setTimeout(() => { 82 | this.debounceTimeout = null; 83 | this.checkUrlChange(); 84 | }, 500) as any; // 500ms debounce - waits for DOM changes to completely settle 85 | } 86 | 87 | /** 88 | * Setup MutationObserver to detect DOM changes that might indicate navigation 89 | * Uses aggressive debouncing to prevent performance issues during page loads 90 | */ 91 | private setupMutationObserver(): void { 92 | const observer = new MutationObserver(() => { 93 | // Use debouncing instead of throttling 94 | // This ensures we only check AFTER mutations have settled 95 | this.debouncedCheckUrlChange(); 96 | }); 97 | 98 | // Only observe the title element (most reliable SPA navigation indicator) 99 | // Observing less = fewer callbacks = better performance 100 | const titleElement = document.querySelector('title'); 101 | if (titleElement) { 102 | observer.observe(titleElement, { 103 | childList: true, 104 | characterData: true, 105 | subtree: true, 106 | }); 107 | } 108 | } 109 | 110 | /** 111 | * Intercept History API methods (pushState, replaceState) 112 | * to detect URL changes immediately 113 | */ 114 | private interceptHistoryAPI(): void { 115 | // Store original methods 116 | const originalPushState = history.pushState; 117 | const originalReplaceState = history.replaceState; 118 | 119 | // Override pushState 120 | history.pushState = (...args) => { 121 | // Call original method 122 | originalPushState.apply(history, args); 123 | // Check for URL change immediately (not throttled, as History API calls are infrequent) 124 | this.checkUrlChange(); 125 | }; 126 | 127 | // Override replaceState 128 | history.replaceState = (...args) => { 129 | // Call original method 130 | originalReplaceState.apply(history, args); 131 | // Check for URL change immediately (not throttled, as History API calls are infrequent) 132 | this.checkUrlChange(); 133 | }; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/common/emoji-data/categories/transports.ts: -------------------------------------------------------------------------------- 1 | export const data = { 2 | name: 'Transports', 3 | emojis: [ 4 | { emoji: '🚗', keywords: ['car', 'automobile', 'vehicle', 'drive', 'road'] }, 5 | { emoji: '🚕', keywords: ['taxi', 'cab', 'yellow', 'ride', 'transport'] }, 6 | { emoji: '🚙', keywords: ['sport', 'utility', 'vehicle', 'suv', 'car'] }, 7 | { emoji: '🚌', keywords: ['bus', 'public', 'transport', 'vehicle', 'travel'] }, 8 | { emoji: '🚎', keywords: ['trolleybus', 'bus', 'electric', 'public', 'transport'] }, 9 | { emoji: '🏎️', keywords: ['racing', 'car', 'fast', 'speed', 'formula'] }, 10 | { emoji: '🚓', keywords: ['police', 'car', 'cop', 'law', 'enforcement'] }, 11 | { emoji: '🚑', keywords: ['ambulance', 'emergency', 'medical', 'hospital'] }, 12 | { emoji: '🚒', keywords: ['fire', 'engine', 'truck', 'emergency', 'rescue'] }, 13 | { emoji: '🚐', keywords: ['minibus', 'van', 'transport', 'vehicle'] }, 14 | { emoji: '🛻', keywords: ['pickup', 'truck', 'vehicle', 'transport'] }, 15 | { emoji: '🚛', keywords: ['articulated', 'lorry', 'truck', 'cargo', 'freight'] }, 16 | { emoji: '🚜', keywords: ['tractor', 'farm', 'agriculture', 'vehicle'] }, 17 | { emoji: '🏍️', keywords: ['motorcycle', 'bike', 'motorbike', 'vehicle'] }, 18 | { emoji: '🛵', keywords: ['motor', 'scooter', 'vespa', 'bike', 'vehicle'] }, 19 | { emoji: '🚲', keywords: ['bicycle', 'bike', 'cycle', 'pedal', 'exercise'] }, 20 | { emoji: '🛴', keywords: ['kick', 'scooter', 'ride', 'fun', 'transport'] }, 21 | { emoji: '🛹', keywords: ['skateboard', 'skate', 'board', 'sport', 'ride'] }, 22 | 23 | { emoji: '🚁', keywords: ['helicopter', 'chopper', 'aircraft', 'fly', 'rotor'] }, 24 | { emoji: '🛸', keywords: ['flying', 'saucer', 'ufo', 'alien', 'space'] }, 25 | { emoji: '✈️', keywords: ['airplane', 'plane', 'aircraft', 'fly', 'travel'] }, 26 | { emoji: '🛩️', keywords: ['small', 'airplane', 'plane', 'aircraft', 'private'] }, 27 | { emoji: '🛫', keywords: ['airplane', 'departure', 'takeoff', 'travel', 'fly'] }, 28 | { emoji: '🛬', keywords: ['airplane', 'arrival', 'landing', 'travel', 'fly'] }, 29 | 30 | { emoji: '🚀', keywords: ['rocket', 'space', 'launch', 'fast', 'blast'] }, 31 | { emoji: '🛰️', keywords: ['satellite', 'space', 'orbit', 'communication'] }, 32 | { emoji: '🚂', keywords: ['locomotive', 'train', 'steam', 'railway'] }, 33 | { emoji: '🚆', keywords: ['train', 'railway', 'transportation', 'travel'] }, 34 | { emoji: '🚄', keywords: ['high', 'speed', 'train', 'bullet', 'fast'] }, 35 | { emoji: '🚅', keywords: ['bullet', 'train', 'fast', 'japan', 'speed'] }, 36 | { emoji: '🚈', keywords: ['light', 'rail', 'train', 'metro', 'urban'] }, 37 | { emoji: '🚝', keywords: ['monorail', 'train', 'transportation'] }, 38 | { emoji: '🚞', keywords: ['mountain', 'railway', 'train', 'scenic'] }, 39 | { emoji: '🚋', keywords: ['tram', 'car', 'streetcar', 'public', 'transport'] }, 40 | { emoji: '🚃', keywords: ['railway', 'car', 'train', 'carriage'] }, 41 | { emoji: '🚟', keywords: ['suspension', 'railway', 'train', 'monorail'] }, 42 | { emoji: '🚠', keywords: ['mountain', 'cableway', 'ski', 'lift'] }, 43 | { emoji: '🚡', keywords: ['aerial', 'tramway', 'cable', 'car', 'mountain'] }, 44 | { emoji: '🛤️', keywords: ['railway', 'track', 'train', 'rails'] }, 45 | { emoji: '🚇', keywords: ['metro', 'subway', 'underground', 'train', 'urban'] }, 46 | { emoji: '🛳️', keywords: ['passenger', 'ship', 'cruise', 'boat', 'ocean'] }, 47 | { emoji: '⛵', keywords: ['sailboat', 'boat', 'sailing', 'yacht', 'wind'] }, 48 | { emoji: '🚤', keywords: ['speedboat', 'boat', 'fast', 'water', 'motor'] }, 49 | { emoji: '🛥️', keywords: ['motor', 'boat', 'yacht', 'luxury', 'water'] }, 50 | { emoji: '🚢', keywords: ['ship', 'boat', 'cruise', 'ocean', 'travel'] }, 51 | { emoji: '⚓', keywords: ['anchor', 'ship', 'boat', 'sea', 'port'] }, 52 | { emoji: '🛟', keywords: ['ring', 'buoy', 'life', 'saver', 'rescue'] }, 53 | { emoji: '🚧', keywords: ['construction', 'sign', 'warning', 'work', 'road'] }, 54 | { emoji: '⛽', keywords: ['fuel', 'pump', 'gas', 'station', 'petrol'] }, 55 | { emoji: '🚨', keywords: ['police', 'car', 'light', 'siren', 'emergency'] }, 56 | { emoji: '🚥', keywords: ['horizontal', 'traffic', 'light', 'signal', 'stop'] }, 57 | { emoji: '🚦', keywords: ['vertical', 'traffic', 'light', 'signal', 'stop'] }, 58 | { emoji: '🛣️', keywords: ['motorway', 'highway', 'road', 'travel'] }, 59 | { emoji: '🗺️', keywords: ['world', 'map', 'geography', 'travel', 'navigation'] }, 60 | 61 | { emoji: '🚩', keywords: ['triangular', 'flag', 'post', 'warning', 'mark'] }, 62 | ], 63 | }; 64 | -------------------------------------------------------------------------------- /src/background/TabGroupsService.ts: -------------------------------------------------------------------------------- 1 | import { Group, Rule, TabModifierSettings } from '../common/types'; 2 | import { _getStorageAsync } from '../common/storage'; 3 | 4 | /** 5 | * Service responsible for managing tab groups 6 | * Single Responsibility: Handle all tab grouping operations 7 | */ 8 | export class TabGroupsService { 9 | private handleTabGroupsMaxRetries = 600; 10 | private createAndSetupGroupMaxRetries = 600; 11 | private updateTabGroupMaxRetries = 600; 12 | 13 | /** 14 | * Ungroup a tab if it doesn't have a group rule 15 | */ 16 | async ungroupTab(rule: Rule | undefined, tab: chrome.tabs.Tab): Promise { 17 | if (!tab.id) return; 18 | 19 | let isRuleHasGroup = false; 20 | 21 | if (rule && rule.tab.group_id && rule.tab.group_id !== '') { 22 | isRuleHasGroup = true; 23 | } 24 | 25 | if (!isRuleHasGroup && tab.groupId && tab.groupId !== -1) { 26 | // Check if the group is one of user's groups 27 | const group = await chrome.tabGroups.get(tab.groupId); 28 | 29 | const tabModifier = await _getStorageAsync(); 30 | if (!tabModifier) return; 31 | 32 | const tmGroup = tabModifier.groups.find((g) => g.title === group.title); 33 | if (tmGroup) await chrome.tabs.ungroup(tab.id); 34 | } 35 | } 36 | 37 | /** 38 | * Apply a group rule to a tab 39 | */ 40 | async applyGroupRuleToTab( 41 | rule: Rule, 42 | tab: chrome.tabs.Tab, 43 | tabModifier: TabModifierSettings 44 | ): Promise { 45 | if (!tab.id) return; 46 | 47 | // remove tab from group if it's already in one 48 | if (!rule || !rule.tab.group_id) { 49 | await this.ungroupTab(rule, tab); 50 | return; 51 | } 52 | 53 | const tmGroup = tabModifier.groups.find((g) => g.id === rule.tab.group_id); 54 | 55 | if (!tmGroup) return; 56 | 57 | const tabGroupsQueryInfo = { 58 | title: tmGroup.title, 59 | color: tmGroup.color as chrome.tabGroups.ColorEnum, 60 | windowId: tab.windowId, 61 | }; 62 | 63 | chrome.tabGroups.query(tabGroupsQueryInfo, (groups: chrome.tabGroups.TabGroup[]) => 64 | this.handleTabGroups(groups, tab, tmGroup) 65 | ); 66 | } 67 | 68 | /** 69 | * Handle tab groups - create or add to existing 70 | */ 71 | private async handleTabGroups( 72 | groups: chrome.tabGroups.TabGroup[], 73 | tab: chrome.tabs.Tab, 74 | tmGroup: Group 75 | ): Promise { 76 | if (!tab.id) return; 77 | 78 | if (groups.length === 0) { 79 | await this.createAndSetupGroup([tab.id], tmGroup); 80 | } else { 81 | // If one or more groups exist, use the first one 82 | // This prevents duplicate groups from accumulating 83 | const group = groups[0]; 84 | 85 | const execute = () => { 86 | if (!tab.id) return; 87 | 88 | chrome.tabs.group({ groupId: group.id, tabIds: [tab.id] }, (groupId: number) => { 89 | if (chrome.runtime.lastError && this.handleTabGroupsMaxRetries > 0) { 90 | setTimeout(() => execute(), 100); 91 | this.handleTabGroupsMaxRetries--; 92 | return; 93 | } else { 94 | this.handleTabGroupsMaxRetries = 600; 95 | this.updateTabGroup(groupId, tmGroup); 96 | } 97 | }); 98 | }; 99 | 100 | execute(); 101 | } 102 | } 103 | 104 | /** 105 | * Create and setup a new tab group 106 | */ 107 | private async createAndSetupGroup(tabIds: number[], tmGroup: Group): Promise { 108 | const execute = () => { 109 | chrome.tabs.group({ tabIds: tabIds }, (groupId: number) => { 110 | if (chrome.runtime.lastError && this.createAndSetupGroupMaxRetries > 0) { 111 | setTimeout(() => execute(), 100); 112 | this.createAndSetupGroupMaxRetries--; 113 | return; 114 | } else { 115 | this.createAndSetupGroupMaxRetries = 600; 116 | this.updateTabGroup(groupId, tmGroup); 117 | } 118 | }); 119 | }; 120 | 121 | execute(); 122 | } 123 | 124 | /** 125 | * Update tab group properties 126 | */ 127 | private updateTabGroup(groupId: number, tmGroup: Group): void { 128 | if (!groupId) return; 129 | 130 | const updateProperties = { 131 | title: tmGroup.title, 132 | color: tmGroup.color, 133 | collapsed: tmGroup.collapsed, 134 | } as chrome.tabGroups.UpdateProperties; 135 | 136 | const execute = () => { 137 | chrome.tabGroups.update(groupId, updateProperties, () => { 138 | if (chrome.runtime.lastError && this.updateTabGroupMaxRetries > 0) { 139 | setTimeout(() => execute(), 100); 140 | this.updateTabGroupMaxRetries--; 141 | return; 142 | } 143 | }); 144 | }; 145 | 146 | execute(); 147 | } 148 | 149 | /** 150 | * Handle setting a group from a message 151 | */ 152 | async handleSetGroup(rule: Rule, tab: chrome.tabs.Tab): Promise { 153 | if (tab.url?.startsWith('chrome')) return; 154 | 155 | const tabModifier = await _getStorageAsync(); 156 | if (tabModifier) await this.applyGroupRuleToTab(rule, tab, tabModifier); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/common/emoji-data/categories/objects.ts: -------------------------------------------------------------------------------- 1 | export const data = { 2 | name: 'Objects & Symbols', 3 | emojis: [ 4 | { emoji: '🎉', keywords: ['party', 'celebration', 'confetti', 'tada', 'congrats'] }, 5 | { emoji: '🎊', keywords: ['confetti', 'ball', 'party', 'celebration', 'festive'] }, 6 | { emoji: '✨', keywords: ['sparkles', 'stars', 'magic', 'shine', 'glitter'] }, 7 | { emoji: '🔥', keywords: ['fire', 'hot', 'burn', 'lit', 'flame'] }, 8 | { emoji: '💯', keywords: ['hundred', 'percent', 'perfect', 'score', 'complete'] }, 9 | { emoji: '⭐', keywords: ['star', 'favorite', 'rate', 'excellent', 'good'] }, 10 | { emoji: '🌟', keywords: ['glowing', 'star', 'shine', 'sparkle', 'bright'] }, 11 | { emoji: '⚡', keywords: ['lightning', 'bolt', 'thunder', 'electric', 'power'] }, 12 | { emoji: '💥', keywords: ['boom', 'explosion', 'burst', 'comic', 'crash'] }, 13 | { emoji: '💫', keywords: ['dizzy', 'star', 'sparkle', 'magic', 'cosmic'] }, 14 | { emoji: '☮️', keywords: ['peace', 'symbol', 'hippie', 'love', 'harmony'] }, 15 | { emoji: '✅', keywords: ['check', 'mark', 'done', 'complete', 'yes'] }, 16 | { emoji: '❌', keywords: ['cross', 'mark', 'wrong', 'no', 'error'] }, 17 | { emoji: '❓', keywords: ['question', 'mark', 'confused', 'help', 'what'] }, 18 | { emoji: '❗', keywords: ['exclamation', 'mark', 'important', 'alert', 'warning'] }, 19 | { emoji: '💡', keywords: ['bulb', 'idea', 'light', 'bright', 'innovation'] }, 20 | { emoji: '🔔', keywords: ['bell', 'notification', 'alert', 'ring'] }, 21 | { emoji: '🔕', keywords: ['bell', 'slash', 'silent', 'mute', 'quiet'] }, 22 | 23 | { emoji: '💎', keywords: ['diamond', 'gem', 'jewel', 'precious', 'sparkle'] }, 24 | { emoji: '🎁', keywords: ['gift', 'present', 'box', 'surprise', 'birthday'] }, 25 | 26 | { emoji: '🎭', keywords: ['performing', 'arts', 'theater', 'masks', 'drama'] }, 27 | { emoji: '🎨', keywords: ['artist', 'palette', 'paint', 'creative', 'art'] }, 28 | { emoji: '🎸', keywords: ['guitar', 'music', 'rock', 'instrument', 'song'] }, 29 | { emoji: '🎵', keywords: ['musical', 'note', 'music', 'song', 'melody'] }, 30 | { emoji: '🎶', keywords: ['musical', 'notes', 'music', 'song', 'melody'] }, 31 | { emoji: '🎤', keywords: ['microphone', 'sing', 'karaoke', 'music', 'voice'] }, 32 | { emoji: '🎧', keywords: ['headphones', 'music', 'listen', 'audio', 'sound'] }, 33 | { emoji: '📱', keywords: ['mobile', 'phone', 'cell', 'smartphone', 'device'] }, 34 | { emoji: '💻', keywords: ['laptop', 'computer', 'pc', 'work', 'tech'] }, 35 | { emoji: '🖥️', keywords: ['desktop', 'computer', 'monitor', 'pc', 'work'] }, 36 | { emoji: '⌨️', keywords: ['keyboard', 'type', 'computer', 'input'] }, 37 | { emoji: '🖱️', keywords: ['mouse', 'computer', 'click', 'pointer'] }, 38 | { emoji: '📺', keywords: ['television', 'tv', 'watch', 'screen', 'entertainment'] }, 39 | { emoji: '📷', keywords: ['camera', 'photo', 'picture', 'snapshot'] }, 40 | { emoji: '📹', keywords: ['video', 'camera', 'record', 'film'] }, 41 | { emoji: '🔍', keywords: ['magnifying', 'glass', 'search', 'find', 'look'] }, 42 | { emoji: '🔎', keywords: ['magnifying', 'glass', 'tilted', 'search', 'examine'] }, 43 | { emoji: '💰', keywords: ['money', 'bag', 'cash', 'rich', 'wealth'] }, 44 | { emoji: '💳', keywords: ['credit', 'card', 'payment', 'money', 'purchase'] }, 45 | { emoji: '🎲', keywords: ['dice', 'game', 'gambling', 'chance', 'random'] }, 46 | { emoji: '🧩', keywords: ['puzzle', 'piece', 'solve', 'problem', 'solution'] }, 47 | { emoji: '❤️', keywords: ['love', 'heart', 'red', 'romance', 'affection'] }, 48 | { emoji: '🧡', keywords: ['orange', 'heart', 'love', 'warm', 'friendship'] }, 49 | { emoji: '💛', keywords: ['yellow', 'heart', 'love', 'friendship', 'happy'] }, 50 | { emoji: '💚', keywords: ['green', 'heart', 'love', 'nature', 'jealous'] }, 51 | { emoji: '💙', keywords: ['blue', 'heart', 'love', 'trust', 'loyalty'] }, 52 | { emoji: '💜', keywords: ['purple', 'heart', 'love', 'compassion', 'understanding'] }, 53 | { emoji: '🖤', keywords: ['black', 'heart', 'love', 'dark', 'evil'] }, 54 | { emoji: '🤍', keywords: ['white', 'heart', 'love', 'pure', 'clean'] }, 55 | { emoji: '🤎', keywords: ['brown', 'heart', 'love', 'earth', 'stability'] }, 56 | { emoji: '💔', keywords: ['broken', 'heart', 'love', 'sad', 'heartbreak'] }, 57 | { emoji: '❣️', keywords: ['heart', 'exclamation', 'love', 'affection', 'decoration'] }, 58 | { emoji: '💕', keywords: ['two', 'hearts', 'love', 'affection', 'romance'] }, 59 | { emoji: '💞', keywords: ['revolving', 'hearts', 'love', 'affection', 'romance'] }, 60 | { emoji: '💓', keywords: ['beating', 'heart', 'love', 'affection', 'alive'] }, 61 | { emoji: '💗', keywords: ['growing', 'heart', 'love', 'affection', 'excited'] }, 62 | { emoji: '💖', keywords: ['sparkling', 'heart', 'love', 'affection', 'excited'] }, 63 | { emoji: '💘', keywords: ['heart', 'arrow', 'love', 'cupid', 'romance'] }, 64 | { emoji: '💝', keywords: ['heart', 'box', 'love', 'gift', 'present'] }, 65 | { emoji: '💟', keywords: ['heart', 'decoration', 'love', 'purple', 'white'] }, 66 | ], 67 | }; 68 | -------------------------------------------------------------------------------- /src/SidePanel.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 156 | 157 | 165 | -------------------------------------------------------------------------------- /docs/IMPROVEMENTS.md: -------------------------------------------------------------------------------- 1 | # Future Improvements & Ideas 2 | 3 | This document tracks potential improvements and feature ideas for Tabee. 4 | 5 | ## Storage Optimization 6 | 7 | ### ✅ Implemented: Data Compression (v1.0.1) 8 | - **Status**: Implemented in v1.0.1 9 | - **Technology**: LZ-String compression (UTF-16) 10 | - **Results**: 79-88% size reduction 11 | - **Impact**: Increased capacity from ~15-20 rules to ~250 rules (without icons) 12 | 13 | ### 💡 Future: Storage Chunking Strategy 14 | 15 | **Problem**: Even with compression, users with 250+ rules may hit the 8KB Chrome sync storage limit per item. 16 | 17 | **Solution**: Implement a chunking strategy to split data across multiple storage keys. 18 | 19 | #### Implementation Strategy 20 | 21 | Instead of storing all data in a single key: 22 | ```javascript 23 | // Current approach (compressed, single key) 24 | chrome.storage.sync.set({ 25 | tab_modifier_compressed: "compressed_data" // ❌ Limited to 8KB 26 | }) 27 | ``` 28 | 29 | Split data across multiple chunks: 30 | ```javascript 31 | // Proposed approach (compressed + chunked) 32 | chrome.storage.sync.set({ 33 | tab_modifier_meta: { 34 | version: "2.0", 35 | chunks: 3, // Number of chunks 36 | totalSize: 15000, // Total uncompressed size 37 | compressed: true // Whether data is compressed 38 | }, 39 | tab_modifier_chunk_0: "compressed_part_1", // Max 8KB 40 | tab_modifier_chunk_1: "compressed_part_2", // Max 8KB 41 | tab_modifier_chunk_2: "compressed_part_3" // Max 8KB 42 | }) 43 | ``` 44 | 45 | #### Benefits 46 | - **Theoretical limit**: 512 items × 8KB = **~4MB** (vs current 8KB) 47 | - **Practical capacity**: With compression + chunking: **500-1,000+ rules** 48 | - **Maintains sync**: Still uses chrome.storage.sync for cross-device sync 49 | - **Backward compatible**: Can detect and migrate from single-key format 50 | 51 | #### Challenges 52 | - More complex read/write logic 53 | - Uses more of the 512-item quota 54 | - Slightly slower (multiple key reads required) 55 | - Need to handle partial failures during write 56 | 57 | #### When to Implement 58 | - **Priority**: Low (only needed when users regularly hit 250 rule limit) 59 | - **Monitor**: Track user complaints about storage limits 60 | - **Trigger**: If 5+ users report hitting the limit with compression enabled 61 | 62 | #### Implementation Notes 63 | ```typescript 64 | // Pseudo-code for chunking logic 65 | const CHUNK_SIZE = 7000; // Leave some margin below 8KB limit 66 | 67 | async function saveWithChunking(data: TabModifierSettings) { 68 | const compressed = compressData(data); 69 | const chunks = splitIntoChunks(compressed, CHUNK_SIZE); 70 | 71 | const storageData = { 72 | tab_modifier_meta: { 73 | version: "2.0", 74 | chunks: chunks.length, 75 | totalSize: compressed.length, 76 | compressed: true, 77 | timestamp: Date.now() 78 | } 79 | }; 80 | 81 | chunks.forEach((chunk, index) => { 82 | storageData[`tab_modifier_chunk_${index}`] = chunk; 83 | }); 84 | 85 | await chrome.storage.sync.set(storageData); 86 | 87 | // Clean up old chunks if we used fewer this time 88 | await cleanupOldChunks(chunks.length); 89 | } 90 | 91 | async function loadWithChunking(): Promise { 92 | const meta = await chrome.storage.sync.get('tab_modifier_meta'); 93 | 94 | if (!meta.tab_modifier_meta) { 95 | // Fallback to old format 96 | return loadLegacyFormat(); 97 | } 98 | 99 | const chunkKeys = Array.from( 100 | { length: meta.tab_modifier_meta.chunks }, 101 | (_, i) => `tab_modifier_chunk_${i}` 102 | ); 103 | 104 | const chunks = await chrome.storage.sync.get(chunkKeys); 105 | const reassembled = chunkKeys.map(key => chunks[key]).join(''); 106 | 107 | return decompressData(reassembled); 108 | } 109 | ``` 110 | 111 | #### Related Issues 112 | - None yet (preemptive documentation) 113 | 114 | #### Migration Path 115 | 1. Detect old format (single compressed key) 116 | 2. Check if data size requires chunking (> 7KB compressed) 117 | 3. If yes, split and migrate to chunked format 118 | 4. If no, keep in single-key format for simplicity 119 | 5. Always support reading both formats 120 | 121 | --- 122 | 123 | ## Other Ideas 124 | 125 | ### User Interface Improvements 126 | - [ ] Visual indicator showing storage usage (e.g., "Using 2,450 / 8,192 bytes") 127 | - [ ] Warning when approaching storage limit (> 80% full) 128 | - [ ] Rule organization: folders/categories for better management 129 | - [ ] Bulk operations: enable/disable/delete multiple rules at once 130 | 131 | ### Storage Alternatives 132 | - [ ] Optional cloud sync via Google Drive API (for power users) 133 | - [ ] Optional cloud sync via GitHub Gist (free, unlimited) 134 | - [ ] Local-only mode with larger storage.local limits (10MB) 135 | - [ ] Export/import presets (pre-configured rule sets) 136 | 137 | ### Performance 138 | - [ ] Lazy loading of rules in UI (virtualized list) 139 | - [ ] Rule caching for faster tab matching 140 | - [ ] Debounced saves to reduce write operations 141 | 142 | ### Features 143 | - [ ] Rule templates/presets (common patterns) 144 | - [ ] Rule testing mode (preview before applying) 145 | - [ ] Statistics: most-used rules, match counts 146 | - [ ] Conditional rules (time-based, domain combinations) 147 | 148 | --- 149 | 150 | **Last Updated**: 2025-10-21 151 | -------------------------------------------------------------------------------- /src/common/emoji-data/categories/animals.ts: -------------------------------------------------------------------------------- 1 | export const data = { 2 | name: 'Animals & Nature', 3 | emojis: [ 4 | { emoji: '🐶', keywords: ['dog', 'puppy', 'pet', 'animal', 'cute'] }, 5 | { emoji: '🐱', keywords: ['cat', 'kitten', 'pet', 'animal', 'cute'] }, 6 | { emoji: '🐭', keywords: ['mouse', 'rodent', 'small', 'animal'] }, 7 | { emoji: '🐹', keywords: ['hamster', 'pet', 'rodent', 'cute', 'small'] }, 8 | { emoji: '🐰', keywords: ['rabbit', 'bunny', 'easter', 'cute', 'hop'] }, 9 | { emoji: '🦊', keywords: ['fox', 'cunning', 'orange', 'wild', 'clever'] }, 10 | { emoji: '🐻', keywords: ['bear', 'teddy', 'cute', 'strong', 'forest'] }, 11 | { emoji: '🐼', keywords: ['panda', 'bear', 'cute', 'china', 'bamboo'] }, 12 | { emoji: '🐨', keywords: ['koala', 'australia', 'cute', 'sleepy', 'eucalyptus'] }, 13 | { emoji: '🐯', keywords: ['tiger', 'wild', 'stripes', 'fierce', 'jungle'] }, 14 | { emoji: '🦁', keywords: ['lion', 'king', 'mane', 'wild', 'roar'] }, 15 | { emoji: '🐮', keywords: ['cow', 'milk', 'farm', 'moo', 'cattle'] }, 16 | { emoji: '🐷', keywords: ['pig', 'farm', 'oink', 'pink', 'cute'] }, 17 | { emoji: '🐸', keywords: ['frog', 'green', 'pond', 'ribbit', 'jump'] }, 18 | { emoji: '🐵', keywords: ['monkey', 'banana', 'jungle', 'playful', 'swing'] }, 19 | { emoji: '🙈', keywords: ['see', 'no', 'evil', 'monkey', 'hide', 'embarrassed'] }, 20 | { emoji: '🙉', keywords: ['hear', 'no', 'evil', 'monkey', 'deaf', 'ignore'] }, 21 | { emoji: '🙊', keywords: ['speak', 'no', 'evil', 'monkey', 'quiet', 'secret'] }, 22 | { emoji: '🐒', keywords: ['monkey', 'banana', 'jungle', 'climb', 'wild'] }, 23 | { emoji: '🦍', keywords: ['gorilla', 'strong', 'jungle', 'ape', 'powerful'] }, 24 | { emoji: '🐺', keywords: ['wolf', 'howl', 'wild', 'pack', 'moon'] }, 25 | { emoji: '🐴', keywords: ['horse', 'pony', 'gallop', 'farm', 'ride'] }, 26 | { emoji: '🦄', keywords: ['unicorn', 'magic', 'rainbow', 'fantasy', 'horn'] }, 27 | { emoji: '🐝', keywords: ['bee', 'honey', 'buzz', 'flower', 'work'] }, 28 | { emoji: '🐛', keywords: ['bug', 'insect', 'caterpillar', 'crawl', 'small'] }, 29 | { emoji: '🦋', keywords: ['butterfly', 'beautiful', 'colorful', 'transformation', 'fly'] }, 30 | { emoji: '🐌', keywords: ['snail', 'slow', 'shell', 'slimy', 'garden'] }, 31 | { emoji: '🐞', keywords: ['lady', 'bug', 'beetle', 'red', 'spots'] }, 32 | { emoji: '🐜', keywords: ['ant', 'work', 'colony', 'small', 'strong'] }, 33 | { emoji: '🦟', keywords: ['mosquito', 'bite', 'annoying', 'buzz', 'blood'] }, 34 | { emoji: '🕷️', keywords: ['spider', 'web', 'scary', 'eight', 'legs'] }, 35 | { emoji: '🦂', keywords: ['scorpion', 'sting', 'desert', 'dangerous', 'tail'] }, 36 | { emoji: '🐢', keywords: ['turtle', 'slow', 'shell', 'green', 'reptile'] }, 37 | { emoji: '🐍', keywords: ['snake', 'slither', 'reptile', 'dangerous', 'coil'] }, 38 | { emoji: '🦎', keywords: ['lizard', 'gecko', 'reptile', 'wall', 'green'] }, 39 | { emoji: '🐙', keywords: ['octopus', 'tentacles', 'ocean', 'smart', 'eight'] }, 40 | { emoji: '🦑', keywords: ['squid', 'ocean', 'tentacles', 'ink', 'sea'] }, 41 | { emoji: '🦐', keywords: ['shrimp', 'seafood', 'ocean', 'small', 'crustacean'] }, 42 | { emoji: '🦀', keywords: ['crab', 'ocean', 'claws', 'beach', 'sideways'] }, 43 | { emoji: '🐠', keywords: ['fish', 'tropical', 'colorful', 'ocean', 'swim'] }, 44 | { emoji: '🐟', keywords: ['fish', 'swim', 'water', 'ocean', 'seafood'] }, 45 | { emoji: '🦈', keywords: ['shark', 'dangerous', 'ocean', 'teeth', 'predator'] }, 46 | { emoji: '🐳', keywords: ['whale', 'ocean', 'large', 'mammal', 'spout'] }, 47 | { emoji: '🐋', keywords: ['whale', 'ocean', 'large', 'blue', 'song'] }, 48 | { emoji: '🐬', keywords: ['dolphin', 'smart', 'ocean', 'playful', 'jump'] }, 49 | { emoji: '🐘', keywords: ['elephant', 'large', 'trunk', 'memory', 'africa'] }, 50 | { emoji: '🦏', keywords: ['rhino', 'horn', 'africa', 'heavy', 'charge'] }, 51 | { emoji: '🦛', keywords: ['hippo', 'water', 'large', 'africa', 'mouth'] }, 52 | { emoji: '🐆', keywords: ['leopard', 'spots', 'wild', 'fast', 'hunter'] }, 53 | { emoji: '🦓', keywords: ['zebra', 'stripes', 'africa', 'black', 'white'] }, 54 | { emoji: '🦒', keywords: ['giraffe', 'tall', 'neck', 'africa', 'spots'] }, 55 | { emoji: '🐃', keywords: ['water', 'buffalo', 'horns', 'strong', 'farm'] }, 56 | { emoji: '🐂', keywords: ['ox', 'strong', 'farm', 'bull', 'horns'] }, 57 | { emoji: '🐄', keywords: ['cow', 'farm', 'milk', 'moo', 'spots'] }, 58 | { emoji: '🐎', keywords: ['horse', 'racing', 'fast', 'gallop', 'brown'] }, 59 | { emoji: '🐖', keywords: ['pig', 'farm', 'pink', 'mud', 'oink'] }, 60 | { emoji: '🐗', keywords: ['boar', 'wild', 'pig', 'tusks', 'forest'] }, 61 | { emoji: '🐏', keywords: ['ram', 'sheep', 'horns', 'wool', 'farm'] }, 62 | { emoji: '🐑', keywords: ['sheep', 'wool', 'farm', 'fluffy', 'baa'] }, 63 | { emoji: '🐐', keywords: ['goat', 'horns', 'farm', 'climb', 'beard'] }, 64 | { emoji: '🦌', keywords: ['deer', 'antlers', 'forest', 'graceful', 'bambi'] }, 65 | { emoji: '🦘', keywords: ['kangaroo', 'australia', 'hop', 'pouch', 'jump'] }, 66 | { emoji: '🦥', keywords: ['sloth', 'slow', 'lazy', 'tree', 'hang'] }, 67 | { emoji: '🦦', keywords: ['otter', 'water', 'playful', 'cute', 'swim'] }, 68 | { emoji: '🦨', keywords: ['skunk', 'smell', 'stripe', 'spray', 'black'] }, 69 | { emoji: '🦡', keywords: ['badger', 'burrow', 'stripe', 'dig', 'fierce'] }, 70 | ], 71 | }; 72 | -------------------------------------------------------------------------------- /src/content/TitleService.ts: -------------------------------------------------------------------------------- 1 | import type { Rule } from '../common/types'; 2 | import { RegexService } from './RegexService'; 3 | 4 | /** 5 | * Service responsible for processing and updating page titles 6 | * Handles selector extraction, regex matching, and title updates 7 | */ 8 | export class TitleService { 9 | private regexService: RegexService; 10 | 11 | constructor(regexService: RegexService) { 12 | this.regexService = regexService; 13 | } 14 | 15 | /** 16 | * Updates a title by replacing a tag with a value 17 | * @param title - The current title 18 | * @param tag - The tag to replace 19 | * @param value - The value to replace with 20 | * @returns The updated title 21 | */ 22 | updateTitle(title: string, tag: string, value: string): string { 23 | if (!value) return title; 24 | // edge cases for unmatched capture groups 25 | if (value.startsWith('$')) return title.replace(tag, ''); 26 | if (value.startsWith('@')) return title.replace(tag, ''); 27 | 28 | // Try to decode URI, but if it fails (e.g., contains unencoded %), use the value as-is 29 | try { 30 | return title.replace(tag, decodeURI(value)); 31 | } catch (e) { 32 | return title.replace(tag, value); 33 | } 34 | } 35 | 36 | /** 37 | * Extracts text content from DOM using a CSS selector 38 | * Supports wildcard selectors with * character 39 | * @param selector - CSS selector string 40 | * @returns Extracted text content or empty string 41 | */ 42 | getTextBySelector(selector: string): string { 43 | let el: Element | null = null; 44 | 45 | if (selector.includes('*')) { 46 | const parts = selector.split(' '); 47 | 48 | const toSafe = (s: string) => 49 | typeof CSS !== 'undefined' && CSS.escape 50 | ? CSS.escape(s) 51 | : s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/]/g, '\\]'); 52 | 53 | const modifiedParts = parts.map((part) => { 54 | if (!part.includes('*')) return part; 55 | if (part.startsWith('.')) { 56 | const raw = part.replace(/\./g, '').replace(/\*/g, ''); 57 | return `[class*="${toSafe(raw)}"]`; 58 | } 59 | const rawAttr = part.replace(/\*/g, ''); 60 | return `[${toSafe(rawAttr)}]`; 61 | }); 62 | 63 | const modifiedSelector = modifiedParts.join(' '); 64 | const elements = document.querySelectorAll(modifiedSelector); 65 | 66 | if (elements.length > 0) { 67 | el = elements[0]; 68 | } 69 | } else { 70 | el = document.querySelector(selector); 71 | } 72 | 73 | let value = ''; 74 | 75 | if (el) { 76 | let targetEl: any = el; 77 | if (el.childNodes.length > 0) { 78 | targetEl = el.childNodes[0]; 79 | } 80 | 81 | if (targetEl.tagName?.toLowerCase() === 'input') { 82 | value = (targetEl as HTMLInputElement).value; 83 | } else if (targetEl.tagName?.toLowerCase() === 'select') { 84 | const selectEl = targetEl as HTMLSelectElement; 85 | value = selectEl.options[selectEl.selectedIndex].text; 86 | } else { 87 | value = targetEl.innerText || targetEl.textContent; 88 | } 89 | } 90 | 91 | return value.trim(); 92 | } 93 | 94 | /** 95 | * Processes a title template with selector extraction and regex matching 96 | * @param currentUrl - The current page URL 97 | * @param currentTitle - The current page title 98 | * @param rule - The rule to apply 99 | * @returns The processed title 100 | */ 101 | processTitle(currentUrl: string, currentTitle: string, rule: Rule): string { 102 | let title = rule.tab.title; 103 | const matches = title.match(/\{([^}]+)}/g); 104 | 105 | if (matches) { 106 | let selector: string, text: string; 107 | 108 | matches.forEach((match) => { 109 | selector = match.substring(1, match.length - 1); 110 | 111 | if (selector === 'title') { 112 | text = currentTitle; 113 | } else { 114 | text = this.getTextBySelector(selector); 115 | } 116 | 117 | title = this.updateTitle(title, match, text); 118 | }); 119 | } 120 | 121 | if (rule.tab.title_matcher) { 122 | try { 123 | const regex = this.regexService.createSafeRegex(rule.tab.title_matcher, 'g'); 124 | let matches: RegExpExecArray | null; 125 | let i = 0; 126 | let iterationCount = 0; 127 | const maxIterations = 100; // Prevent infinite loops 128 | 129 | while ((matches = regex.exec(currentTitle)) !== null && iterationCount < maxIterations) { 130 | for (let j = 0; j < matches.length; j++) { 131 | let tag = '@' + i; 132 | title = this.updateTitle(title, tag, matches[j] ?? tag); 133 | i++; 134 | } 135 | iterationCount++; 136 | } 137 | } catch (e) { 138 | console.error('Error processing title_matcher regex:', e); 139 | } 140 | } 141 | 142 | if (rule.tab.url_matcher) { 143 | try { 144 | const regex = this.regexService.createSafeRegex(rule.tab.url_matcher, 'g'); 145 | let matches: RegExpExecArray | null; 146 | let i = 0; 147 | let iterationCount = 0; 148 | const maxIterations = 100; // Prevent infinite loops 149 | 150 | while ((matches = regex.exec(currentUrl)) !== null && iterationCount < maxIterations) { 151 | for (let j = 0; j < matches.length; j++) { 152 | let tag = '$' + i; 153 | title = this.updateTitle(title, tag, matches[j] ?? tag); 154 | i++; 155 | } 156 | iterationCount++; 157 | } 158 | } catch (e) { 159 | console.error('Error processing url_matcher regex:', e); 160 | } 161 | } 162 | 163 | // Remove unhandled capture groups 164 | title = title.replace(/\s*[$@]\d+\s*/g, ' ').trim(); 165 | 166 | return title; 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/common/regex-safety.test.js: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi, beforeEach } from 'vitest'; 2 | import { _isRegexPatternSafe, _safeRegexTestSync } from './regex-safety'; 3 | 4 | describe('Regex Safety', () => { 5 | beforeEach(() => { 6 | vi.clearAllMocks(); 7 | // Spy on console.warn to verify warnings are logged 8 | vi.spyOn(console, 'warn').mockImplementation(() => {}); 9 | }); 10 | 11 | describe('_isRegexPatternSafe', () => { 12 | it('should accept safe simple patterns', () => { 13 | expect(_isRegexPatternSafe('example\\.com')).toBe(true); 14 | expect(_isRegexPatternSafe('https://[a-z]+\\.example\\.com')).toBe(true); 15 | expect(_isRegexPatternSafe('^https://example\\.com/path$')).toBe(true); 16 | expect(_isRegexPatternSafe('[0-9]{1,4}')).toBe(true); 17 | }); 18 | 19 | it('should accept complex but safe patterns from existing tests', () => { 20 | // From biblegateway test 21 | expect( 22 | _isRegexPatternSafe( 23 | 'https:\\/\\/www\\.biblegateway\\.com\\/passage\\/\\?search=.*version=(?!MOUNCE)(?!.*;).*' 24 | ) 25 | ).toBe(true); 26 | 27 | // From jira test (without the catastrophic .*? at the start) 28 | expect( 29 | _isRegexPatternSafe( 30 | 'furybee.atlassian.net\\/jira\\/software\\/c\\/projects\\/([a-zA-Z]{1,5})\\/boards\\/([0-9]{1,4})(\\?.*)?$' 31 | ) 32 | ).toBe(true); 33 | }); 34 | 35 | it('should reject patterns with nested quantifiers', () => { 36 | expect(_isRegexPatternSafe('(a+)+')).toBe(false); 37 | expect(_isRegexPatternSafe('(a*)*')).toBe(false); 38 | expect(_isRegexPatternSafe('(a+)*')).toBe(false); 39 | expect(_isRegexPatternSafe('(a{1,5})+')).toBe(false); 40 | expect(_isRegexPatternSafe('(x+|y+)+')).toBe(false); 41 | }); 42 | 43 | it('should reject patterns with consecutive quantifiers', () => { 44 | expect(_isRegexPatternSafe('a+++')).toBe(false); 45 | expect(_isRegexPatternSafe('a**')).toBe(false); 46 | expect(_isRegexPatternSafe('a+*')).toBe(false); 47 | expect(_isRegexPatternSafe('a?+')).toBe(false); 48 | }); 49 | 50 | it('should reject patterns with overlapping alternatives', () => { 51 | expect(_isRegexPatternSafe('(a|a)*')).toBe(false); 52 | expect(_isRegexPatternSafe('(x+|x+y+)*')).toBe(false); 53 | }); 54 | 55 | it('should reject invalid regex patterns', () => { 56 | expect(_isRegexPatternSafe('[')).toBe(false); 57 | expect(_isRegexPatternSafe('((')).toBe(false); 58 | expect(_isRegexPatternSafe('*')).toBe(false); 59 | }); 60 | 61 | it('should reject empty or non-string patterns', () => { 62 | expect(_isRegexPatternSafe('')).toBe(false); 63 | expect(_isRegexPatternSafe(null)).toBe(false); 64 | expect(_isRegexPatternSafe(undefined)).toBe(false); 65 | }); 66 | 67 | it('should reject excessively long patterns', () => { 68 | const longPattern = 'a'.repeat(1001); 69 | expect(_isRegexPatternSafe(longPattern)).toBe(false); 70 | }); 71 | }); 72 | 73 | describe('_safeRegexTestSync', () => { 74 | it('should match safe patterns correctly', () => { 75 | expect(_safeRegexTestSync('example\\.com', 'https://example.com/path')).toBe(true); 76 | expect(_safeRegexTestSync('example\\.com', 'https://other.com/path')).toBe(false); 77 | expect(_safeRegexTestSync('^https://example\\.com', 'https://example.com/path')).toBe( 78 | true 79 | ); 80 | }); 81 | 82 | it('should handle complex safe patterns from existing tests', () => { 83 | // Jira pattern (safe version without .*? at the start) 84 | const jiraPattern = 85 | 'furybee.atlassian.net\\/jira\\/software\\/c\\/projects\\/([a-zA-Z]{1,5})\\/boards\\/([0-9]{1,4})(\\?.*)?$'; 86 | const jiraUrl = 87 | 'https://furybee.atlassian.net/jira/software/c/projects/FB/boards/74?quickFilter=313'; 88 | expect(_safeRegexTestSync(jiraPattern, jiraUrl)).toBe(true); 89 | 90 | // Bible Gateway pattern 91 | const biblePattern = 92 | 'https:\\/\\/www\\.biblegateway\\.com\\/passage\\/\\?search=.*version=(?!MOUNCE)(?!.*;).*'; 93 | const bibleUrl = 'https://www.biblegateway.com/passage/?search=John+3&version=NASB'; 94 | expect(_safeRegexTestSync(biblePattern, bibleUrl)).toBe(true); 95 | }); 96 | 97 | it('should block dangerous ReDoS patterns', () => { 98 | // These patterns are known to cause catastrophic backtracking 99 | const dangerousPatterns = [ 100 | '(a+)+', 101 | '(a*)*', 102 | '(a|a)*', 103 | '(x+|x+y+)*', 104 | '(a+)+b', 105 | ]; 106 | 107 | dangerousPatterns.forEach((pattern) => { 108 | const result = _safeRegexTestSync(pattern, 'aaaaaaaaaaaaaaaaaaaaaaaaa'); 109 | expect(result).toBe(false); 110 | expect(console.warn).toHaveBeenCalledWith( 111 | expect.stringContaining('Unsafe regex pattern detected') 112 | ); 113 | }); 114 | }); 115 | 116 | it('should handle invalid regex patterns gracefully', () => { 117 | expect(_safeRegexTestSync('[', 'test')).toBe(false); 118 | expect(_safeRegexTestSync('((', 'test')).toBe(false); 119 | expect(console.warn).toHaveBeenCalled(); 120 | }); 121 | 122 | it('should handle empty or invalid inputs', () => { 123 | expect(_safeRegexTestSync('', 'test')).toBe(false); 124 | expect(_safeRegexTestSync(null, 'test')).toBe(false); 125 | expect(_safeRegexTestSync(undefined, 'test')).toBe(false); 126 | }); 127 | 128 | it('should work with patterns from existing storage tests', () => { 129 | // These are the actual patterns used in storage.test.js 130 | expect(_safeRegexTestSync('example\\.com\\/path', 'https://example.com/path')).toBe( 131 | true 132 | ); 133 | }); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /src/stores/rules.store.test.js: -------------------------------------------------------------------------------- 1 | import { createPinia, setActivePinia } from 'pinia'; 2 | import { beforeEach, describe, expect, it, vi } from 'vitest'; 3 | import { 4 | _getDefaultTabModifierSettings, 5 | _getStorageAsync, 6 | _setStorage, 7 | } from '../common/storage.ts'; 8 | import { useRulesStore } from './rules.store.ts'; 9 | 10 | // Mock des fonctions asynchrones 11 | vi.mock('../common/storage.ts'); 12 | 13 | describe('Rules Store', () => { 14 | beforeEach(() => { 15 | setActivePinia(createPinia()); 16 | document.body.setAttribute = vi.fn(); 17 | }); 18 | 19 | it('should initialize the store', async () => { 20 | const store = useRulesStore(); 21 | _getStorageAsync.mockImplementation(() => Promise.resolve(null)); 22 | _getDefaultTabModifierSettings.mockImplementation(() => ({ 23 | groups: [], 24 | rules: [], 25 | settings: { theme: 'dim' }, 26 | })); 27 | 28 | await store.init(); 29 | 30 | expect(store.groups).toEqual([]); 31 | expect(store.rules).toEqual([]); 32 | expect(store.settings).toEqual({ theme: 'dim' }); 33 | expect(_setStorage).toHaveBeenCalled(); 34 | }); 35 | 36 | it('should add a new rule', async () => { 37 | const store = useRulesStore(); 38 | const newRule = { id: '1', detection: 'STARTS_WITH' }; 39 | _getStorageAsync.mockImplementation(() => 40 | Promise.resolve({ groups: [], rules: [], settings: { theme: 'dim' } }) 41 | ); 42 | 43 | await store.addRule(newRule); 44 | 45 | expect(store.rules).toContainEqual(newRule); 46 | expect(_setStorage).toHaveBeenCalled(); 47 | }); 48 | 49 | it('should add a new group', async () => { 50 | const store = useRulesStore(); 51 | const newGroup = { id: '1', title: 'Test Group' }; 52 | _getStorageAsync.mockImplementation(() => 53 | Promise.resolve({ groups: [], rules: [], settings: { theme: 'dim' } }) 54 | ); 55 | 56 | await store.addGroup(newGroup); 57 | 58 | expect(store.groups).toContainEqual({ ...newGroup, title: 'Test Group\u200B' }); 59 | expect(_setStorage).toHaveBeenCalled(); 60 | }); 61 | 62 | it('should update an existing rule', async () => { 63 | const store = useRulesStore(); 64 | const existingRule = { id: '1', detection: 'STARTS_WITH' }; 65 | store.rules = [existingRule]; 66 | const updatedRule = { id: '1', detection: 'ENDS_WITH' }; 67 | 68 | await store.updateRule(updatedRule); 69 | 70 | expect(store.rules).toContainEqual(updatedRule); 71 | expect(_setStorage).toHaveBeenCalled(); 72 | }); 73 | 74 | it('should delete a rule', async () => { 75 | const store = useRulesStore(); 76 | const ruleToDelete = { id: '1', detection: 'STARTS_WITH' }; 77 | store.rules = [ruleToDelete]; 78 | 79 | await store.deleteRule(0); 80 | 81 | expect(store.rules).not.toContain(ruleToDelete); 82 | expect(_setStorage).toHaveBeenCalled(); 83 | }); 84 | 85 | it('should maintain unique IDs and add name suffix when duplicating rules multiple times', async () => { 86 | const store = useRulesStore(); 87 | _getStorageAsync.mockImplementation(() => 88 | Promise.resolve({ groups: [], rules: [], settings: { theme: 'dim' } }) 89 | ); 90 | 91 | // Create initial rule 92 | const originalRule = { 93 | id: 'original-id', 94 | name: 'Original Rule', 95 | detection: 'CONTAINS', 96 | url_fragment: 'example.com', 97 | tab: { 98 | title: 'Original Title', 99 | icon: null, 100 | muted: false, 101 | pinned: false, 102 | protected: false, 103 | unique: false, 104 | group_id: null, 105 | title_matcher: null, 106 | url_matcher: null, 107 | }, 108 | is_enabled: true, 109 | }; 110 | store.rules = [originalRule]; 111 | 112 | // Duplicate the rule twice 113 | const duplicatedRule1 = await store.duplicateRule('original-id'); 114 | const duplicatedRule2 = await store.duplicateRule(duplicatedRule1.id); 115 | 116 | // Verify we have 3 rules with unique IDs 117 | expect(store.rules.length).toBe(3); 118 | expect(store.rules[0].id).toBe('original-id'); 119 | expect(store.rules[1].id).toBe(duplicatedRule1.id); 120 | expect(store.rules[2].id).toBe(duplicatedRule2.id); 121 | 122 | // Verify all IDs are unique 123 | const ids = store.rules.map((r) => r.id); 124 | const uniqueIds = new Set(ids); 125 | expect(uniqueIds.size).toBe(3); 126 | 127 | // Verify that names have copy suffixes to distinguish them 128 | expect(store.rules[0].name).toBe('Original Rule'); 129 | expect(store.rules[1].name).toBe('Original Rule (Copy)'); 130 | expect(store.rules[2].name).toBe('Original Rule (Copy 2)'); 131 | 132 | // Update the first duplicated rule's title (not name) 133 | const updatedDuplicate1 = { ...duplicatedRule1, tab: { ...duplicatedRule1.tab, title: 'Updated Title 1' } }; 134 | await store.updateRule(updatedDuplicate1); 135 | 136 | // Update the second duplicated rule's title (not name) 137 | const updatedDuplicate2 = { ...duplicatedRule2, tab: { ...duplicatedRule2.tab, title: 'Updated Title 2' } }; 138 | await store.updateRule(updatedDuplicate2); 139 | 140 | // Verify that each rule was updated correctly (checking tab.title) 141 | expect(store.rules[0].tab.title).toBe('Original Title'); 142 | expect(store.rules[1].tab.title).toBe('Updated Title 1'); 143 | expect(store.rules[2].tab.title).toBe('Updated Title 2'); 144 | 145 | // Verify the IDs haven't changed 146 | expect(store.rules[0].id).toBe('original-id'); 147 | expect(store.rules[1].id).toBe(duplicatedRule1.id); 148 | expect(store.rules[2].id).toBe(duplicatedRule2.id); 149 | 150 | // Verify the names haven't changed 151 | expect(store.rules[0].name).toBe('Original Rule'); 152 | expect(store.rules[1].name).toBe('Original Rule (Copy)'); 153 | expect(store.rules[2].name).toBe('Original Rule (Copy 2)'); 154 | }); 155 | }); 156 | -------------------------------------------------------------------------------- /src/background/__tests__/WindowService.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, vi } from 'vitest'; 2 | import { WindowService } from '../WindowService'; 3 | 4 | // Mock chrome APIs 5 | const mockChrome = { 6 | windows: { 7 | getAll: vi.fn(), 8 | update: vi.fn(), 9 | }, 10 | tabs: { 11 | move: vi.fn(), 12 | }, 13 | }; 14 | 15 | // @ts-ignore 16 | global.chrome = mockChrome; 17 | 18 | describe('WindowService', () => { 19 | let service: WindowService; 20 | 21 | beforeEach(() => { 22 | service = new WindowService(); 23 | vi.clearAllMocks(); 24 | }); 25 | 26 | describe('mergeAllWindows', () => { 27 | it('should do nothing if only one window exists', async () => { 28 | const singleWindow = { 29 | id: 1, 30 | focused: true, 31 | type: 'normal' as 'normal', 32 | tabs: [{ id: 1 }], 33 | }; 34 | 35 | mockChrome.windows.getAll.mockResolvedValue([singleWindow]); 36 | 37 | await service.mergeAllWindows(); 38 | 39 | expect(mockChrome.tabs.move).not.toHaveBeenCalled(); 40 | expect(mockChrome.windows.update).not.toHaveBeenCalled(); 41 | }); 42 | 43 | it('should merge multiple windows into focused window', async () => { 44 | const focusedWindow = { 45 | id: 1, 46 | focused: true, 47 | type: 'normal' as 'normal', 48 | tabs: [{ id: 1 }, { id: 2 }], 49 | }; 50 | 51 | const otherWindow = { 52 | id: 2, 53 | focused: false, 54 | type: 'normal' as 'normal', 55 | tabs: [{ id: 3 }, { id: 4 }], 56 | }; 57 | 58 | mockChrome.windows.getAll.mockResolvedValue([focusedWindow, otherWindow]); 59 | mockChrome.tabs.move.mockResolvedValue([]); 60 | mockChrome.windows.update.mockResolvedValue(focusedWindow); 61 | 62 | await service.mergeAllWindows(); 63 | 64 | expect(mockChrome.tabs.move).toHaveBeenCalledWith([3, 4], { 65 | windowId: 1, 66 | index: -1, 67 | }); 68 | expect(mockChrome.windows.update).toHaveBeenCalledWith(1, { focused: true }); 69 | }); 70 | 71 | it('should skip non-normal windows', async () => { 72 | const normalWindow = { 73 | id: 1, 74 | focused: true, 75 | type: 'normal' as 'normal', 76 | tabs: [{ id: 1 }], 77 | }; 78 | 79 | const popupWindow = { 80 | id: 2, 81 | focused: false, 82 | type: 'popup' as 'popup', 83 | tabs: [{ id: 2 }], 84 | }; 85 | 86 | mockChrome.windows.getAll.mockResolvedValue([normalWindow, popupWindow]); 87 | mockChrome.windows.update.mockResolvedValue(normalWindow); 88 | 89 | await service.mergeAllWindows(); 90 | 91 | expect(mockChrome.tabs.move).not.toHaveBeenCalled(); 92 | expect(mockChrome.windows.update).toHaveBeenCalledWith(1, { focused: true }); 93 | }); 94 | 95 | it('should use first normal window if none are focused', async () => { 96 | const window1 = { 97 | id: 1, 98 | focused: false, 99 | type: 'normal' as 'normal', 100 | tabs: [{ id: 1 }], 101 | }; 102 | 103 | const window2 = { 104 | id: 2, 105 | focused: false, 106 | type: 'normal' as 'normal', 107 | tabs: [{ id: 2 }], 108 | }; 109 | 110 | mockChrome.windows.getAll.mockResolvedValue([window1, window2]); 111 | mockChrome.tabs.move.mockResolvedValue([]); 112 | mockChrome.windows.update.mockResolvedValue(window1); 113 | 114 | await service.mergeAllWindows(); 115 | 116 | expect(mockChrome.tabs.move).toHaveBeenCalledWith([2], { 117 | windowId: 1, 118 | index: -1, 119 | }); 120 | expect(mockChrome.windows.update).toHaveBeenCalledWith(1, { focused: true }); 121 | }); 122 | 123 | it('should handle windows without tabs', async () => { 124 | const window1 = { 125 | id: 1, 126 | focused: true, 127 | type: 'normal' as 'normal', 128 | tabs: [{ id: 1 }], 129 | }; 130 | 131 | const window2 = { 132 | id: 2, 133 | focused: false, 134 | type: 'normal' as 'normal', 135 | tabs: [], 136 | }; 137 | 138 | mockChrome.windows.getAll.mockResolvedValue([window1, window2]); 139 | mockChrome.windows.update.mockResolvedValue(window1); 140 | 141 | await service.mergeAllWindows(); 142 | 143 | expect(mockChrome.tabs.move).not.toHaveBeenCalled(); 144 | expect(mockChrome.windows.update).toHaveBeenCalledWith(1, { focused: true }); 145 | }); 146 | 147 | it('should handle tabs without IDs', async () => { 148 | const window1 = { 149 | id: 1, 150 | focused: true, 151 | type: 'normal' as 'normal', 152 | tabs: [{ id: 1 }], 153 | }; 154 | 155 | const window2 = { 156 | id: 2, 157 | focused: false, 158 | type: 'normal' as 'normal', 159 | tabs: [{ id: undefined }, { id: 2 }], 160 | }; 161 | 162 | mockChrome.windows.getAll.mockResolvedValue([window1, window2]); 163 | mockChrome.tabs.move.mockResolvedValue([]); 164 | mockChrome.windows.update.mockResolvedValue(window1); 165 | 166 | await service.mergeAllWindows(); 167 | 168 | // Should only move tab with ID 169 | expect(mockChrome.tabs.move).toHaveBeenCalledWith([2], { 170 | windowId: 1, 171 | index: -1, 172 | }); 173 | }); 174 | 175 | it('should handle errors when moving tabs', async () => { 176 | const window1 = { 177 | id: 1, 178 | focused: true, 179 | type: 'normal' as 'normal', 180 | tabs: [{ id: 1 }], 181 | }; 182 | 183 | const window2 = { 184 | id: 2, 185 | focused: false, 186 | type: 'normal' as 'normal', 187 | tabs: [{ id: 2 }], 188 | }; 189 | 190 | mockChrome.windows.getAll.mockResolvedValue([window1, window2]); 191 | mockChrome.tabs.move.mockRejectedValue(new Error('Failed to move tabs')); 192 | mockChrome.windows.update.mockResolvedValue(window1); 193 | 194 | // Should not throw 195 | await service.mergeAllWindows(); 196 | 197 | expect(mockChrome.windows.update).toHaveBeenCalledWith(1, { focused: true }); 198 | }); 199 | }); 200 | }); 201 | -------------------------------------------------------------------------------- /src/content/RuleApplicationService.ts: -------------------------------------------------------------------------------- 1 | import type { Rule } from '../common/types'; 2 | import { TitleService } from './TitleService'; 3 | import { IconService } from './IconService'; 4 | 5 | /** 6 | * Service responsible for applying rules to the current tab 7 | * Orchestrates title, icon, and other rule modifications 8 | */ 9 | export class RuleApplicationService { 10 | private titleService: TitleService; 11 | private iconService: IconService; 12 | private titleObserver: MutationObserver | null = null; 13 | private iconObserver: MutationObserver | null = null; 14 | 15 | constructor(titleService: TitleService, iconService: IconService) { 16 | this.titleService = titleService; 17 | this.iconService = iconService; 18 | } 19 | 20 | /** 21 | * Applies a rule to the current page 22 | * @param rule - The rule to apply 23 | * @param updateTitle - Whether to update the title (default: true) 24 | */ 25 | async applyRule(rule: Rule, updateTitle: boolean = true): Promise { 26 | if (!rule) { 27 | return; 28 | } 29 | 30 | if (rule.is_enabled === undefined) { 31 | rule.is_enabled = true; 32 | } 33 | 34 | if (rule.is_enabled === false) { 35 | return; 36 | } 37 | 38 | // Handle title modification with MutationObserver 39 | if (rule.tab.title && updateTitle) { 40 | this.applyTitleRule(rule); 41 | } 42 | 43 | // Pinning, muting handled through Chrome Runtime messages 44 | if (rule.tab.pinned) { 45 | await chrome.runtime.sendMessage({ action: 'setPinned' }); 46 | } 47 | 48 | if (rule.tab.muted) { 49 | await chrome.runtime.sendMessage({ action: 'setMuted' }); 50 | } 51 | 52 | // Favicon handling with MutationObserver 53 | if (rule.tab.icon && updateTitle) { 54 | this.applyIconRule(rule); 55 | } 56 | 57 | // Protection and unique tab handling 58 | if (rule.tab.protected) { 59 | await chrome.runtime.sendMessage({ 60 | action: 'setProtected', 61 | }); 62 | } 63 | 64 | if (rule.tab.unique) { 65 | await chrome.runtime.sendMessage({ 66 | action: 'setUnique', 67 | url_fragment: rule.url_fragment, 68 | rule: rule, 69 | }); 70 | } 71 | 72 | // Tab grouping is now handled directly in background.ts to avoid race conditions 73 | // No need to send a message here anymore 74 | } 75 | 76 | /** 77 | * Applies title rule with MutationObserver for dynamic title changes 78 | * @param rule - The rule containing title configuration 79 | */ 80 | private applyTitleRule(rule: Rule): void { 81 | let originalTitleElement = document.querySelector('meta[name="original-tab-modifier-title"]'); 82 | 83 | if (!originalTitleElement) { 84 | originalTitleElement = document.createElement('meta'); 85 | originalTitleElement.setAttribute('name', 'original-tab-modifier-title'); 86 | originalTitleElement.setAttribute('content', document.title); 87 | document.head.appendChild(originalTitleElement); 88 | } 89 | 90 | let originalTitle = originalTitleElement.getAttribute('content') || document.title; 91 | document.title = this.titleService.processTitle(location.href, originalTitle, rule); 92 | 93 | // CRITICAL FIX: Disconnect old observer before creating a new one 94 | // This prevents memory leaks and infinite loops on SPAs 95 | if (this.titleObserver) { 96 | this.titleObserver.disconnect(); 97 | this.titleObserver = null; 98 | } 99 | 100 | // Only observe the title element, not the entire document 101 | // This drastically reduces the number of mutation callbacks 102 | const titleElement = document.querySelector('title'); 103 | if (!titleElement) return; 104 | 105 | const config = { childList: true, characterData: true, subtree: true }; 106 | let lastTitle = document.title; 107 | 108 | const callback = () => { 109 | if (document.title !== lastTitle) { 110 | originalTitleElement!.setAttribute('content', document.title); 111 | 112 | originalTitle = originalTitleElement!.getAttribute('content') || document.title; 113 | document.title = this.titleService.processTitle(location.href, originalTitle, rule); 114 | 115 | lastTitle = document.title; 116 | } 117 | }; 118 | 119 | // Store the observer as an instance variable to reuse/disconnect later 120 | this.titleObserver = new MutationObserver(callback); 121 | this.titleObserver.observe(titleElement, config); 122 | } 123 | 124 | /** 125 | * Applies icon rule with MutationObserver for dynamic icon changes 126 | * @param rule - The rule containing icon configuration 127 | */ 128 | private applyIconRule(rule: Rule): void { 129 | if (!rule.tab.icon) return; 130 | 131 | const iconUrl = rule.tab.icon; 132 | this.iconService.processIcon(iconUrl); 133 | 134 | // CRITICAL FIX: Disconnect old observer before creating a new one 135 | // This prevents memory leaks and infinite loops on SPAs 136 | if (this.iconObserver) { 137 | this.iconObserver.disconnect(); 138 | this.iconObserver = null; 139 | } 140 | 141 | let iconChangedByMe = false; 142 | 143 | // Store the observer as an instance variable to reuse/disconnect later 144 | this.iconObserver = new MutationObserver((mutations) => { 145 | if (!iconChangedByMe) { 146 | mutations.forEach((mutation) => { 147 | if ((mutation.target as any).type === 'image/x-icon') { 148 | this.iconService.processIcon(iconUrl); 149 | iconChangedByMe = true; 150 | } else if (mutation.addedNodes.length) { 151 | mutation.addedNodes.forEach((node) => { 152 | if ((node as any).type === 'image/x-icon') { 153 | this.iconService.processIcon(iconUrl); 154 | iconChangedByMe = true; 155 | } 156 | }); 157 | } else if (mutation.removedNodes.length) { 158 | mutation.removedNodes.forEach((node) => { 159 | if ((node as any).type === 'image/x-icon') { 160 | this.iconService.processIcon(iconUrl); 161 | iconChangedByMe = true; 162 | } 163 | }); 164 | } 165 | }); 166 | } else { 167 | iconChangedByMe = false; 168 | } 169 | }); 170 | 171 | this.iconObserver.observe(document.head, { 172 | attributes: true, 173 | childList: true, 174 | characterData: true, 175 | subtree: true, 176 | attributeOldValue: true, 177 | characterDataOldValue: true, 178 | }); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/components/global/donations/BuyMeACoffeeIcon.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | -------------------------------------------------------------------------------- /src/background/TabRulesService.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from '../common/types'; 2 | import { 3 | _getDefaultRule, 4 | _getDefaultTabModifierSettings, 5 | _getRuleFromUrl, 6 | _getStorageAsync, 7 | _setStorage, 8 | _shouldSkipUrl, 9 | } from '../common/storage'; 10 | import { _processUrlFragment } from '../common/helpers'; 11 | 12 | /** 13 | * Service responsible for applying tab modification rules 14 | * Single Responsibility: Handle all tab rule-related operations 15 | */ 16 | export class TabRulesService { 17 | /** 18 | * Apply a rule to a tab based on its URL 19 | */ 20 | async applyRuleToTab(tab: chrome.tabs.Tab): Promise { 21 | if (!tab.id || !tab.url) return false; 22 | 23 | const rule = await _getRuleFromUrl(tab.url); 24 | 25 | if (rule) { 26 | try { 27 | await chrome.tabs.sendMessage(tab.id, { action: 'applyRule', rule: rule }); 28 | } catch (error) { 29 | // Content script not loaded (likely a tab that was open before extension reload) 30 | // The content script will apply rules automatically when the tab loads 31 | console.log( 32 | `[TabRulesService] Content script not ready for tab ${tab.id}, rule will be applied on next load` 33 | ); 34 | } 35 | } 36 | 37 | return !!rule; 38 | } 39 | 40 | /** 41 | * Handle unique tab logic - close duplicates 42 | */ 43 | async handleSetUnique(message: any, currentTab: chrome.tabs.Tab): Promise { 44 | if (!currentTab.id || !currentTab.url) return; 45 | 46 | const rule = message.rule as Rule; 47 | const urlFragment = message.url_fragment; 48 | 49 | // Check if current tab URL matches the url_matcher pattern (if defined) 50 | // If not, skip unique tab logic (tab doesn't match the rule) 51 | if (rule?.tab?.url_matcher) { 52 | try { 53 | const regex = new RegExp(rule.tab.url_matcher); 54 | if (!regex.test(currentTab.url)) { 55 | console.log( 56 | '[TabRulesService] Current tab URL does not match url_matcher, skipping unique check' 57 | ); 58 | return; 59 | } 60 | } catch (error) { 61 | console.error('[TabRulesService] Invalid url_matcher regex:', error); 62 | return; 63 | } 64 | } 65 | 66 | const processedUrlFragment = _processUrlFragment( 67 | urlFragment, 68 | currentTab.url, 69 | rule?.tab?.url_matcher 70 | ); 71 | 72 | const tabs = await this.queryTabs({}); 73 | 74 | for (const tab of tabs) { 75 | if (!tab.url || !tab.id) continue; 76 | 77 | // CRITICAL FIX: When url_matcher is NOT defined, compare full URLs 78 | // This prevents closing unrelated tabs (e.g., Gmail when refreshing GitHub) 79 | if (!rule?.tab?.url_matcher) { 80 | // Without url_matcher, we use exact URL comparison for safety 81 | // This ensures only true duplicates (same exact URL) are closed 82 | if (tab.url === currentTab.url && tab.id !== currentTab.id) { 83 | // Remove beforeunload handler from the duplicate tab before closing it 84 | try { 85 | await chrome.scripting.executeScript({ 86 | target: { tabId: tab.id }, 87 | func: () => { 88 | window.onbeforeunload = null; 89 | }, 90 | }); 91 | } catch (error) { 92 | // Ignore errors if we can't execute script (e.g., chrome:// pages) 93 | console.log( 94 | `[TabRulesService] Could not remove beforeunload from tab ${tab.id}:`, 95 | error 96 | ); 97 | } 98 | 99 | // Close the duplicate tab (keep the current tab) 100 | await chrome.tabs.remove(tab.id); 101 | return; // Exit after closing the first duplicate 102 | } 103 | continue; 104 | } 105 | 106 | // Skip tabs that don't match the url_matcher pattern 107 | // This prevents closing unrelated tabs that happen to have the same processed fragment 108 | try { 109 | const regex = new RegExp(rule.tab.url_matcher); 110 | if (!regex.test(tab.url)) { 111 | // This tab doesn't match the rule, skip it 112 | continue; 113 | } 114 | } catch (error) { 115 | console.error('[TabRulesService] Invalid url_matcher regex:', error); 116 | continue; 117 | } 118 | 119 | // Process the fragment for each tab to compare 120 | const tabProcessedFragment = _processUrlFragment(urlFragment, tab.url, rule.tab.url_matcher); 121 | 122 | // Compare processed fragments instead of raw URL 123 | if (tabProcessedFragment === processedUrlFragment && tab.id !== currentTab.id) { 124 | // Remove beforeunload handler from the duplicate tab before closing it 125 | try { 126 | await chrome.scripting.executeScript({ 127 | target: { tabId: tab.id }, 128 | func: () => { 129 | window.onbeforeunload = null; 130 | }, 131 | }); 132 | } catch (error) { 133 | // Ignore errors if we can't execute script (e.g., chrome:// pages) 134 | console.log(`[TabRulesService] Could not remove beforeunload from tab ${tab.id}:`, error); 135 | } 136 | 137 | // Close the duplicate tab (keep the current tab) 138 | await chrome.tabs.remove(tab.id); 139 | return; // Exit after closing the first duplicate 140 | } 141 | } 142 | } 143 | 144 | /** 145 | * Handle renaming a tab - creates a new rule 146 | */ 147 | async handleRenameTab(tab: chrome.tabs.Tab, title: string): Promise { 148 | if (!tab?.id || !tab?.url || !URL.canParse(tab.url)) return; 149 | 150 | let tabModifier = await _getStorageAsync(); 151 | 152 | if (!tabModifier) { 153 | tabModifier = _getDefaultTabModifierSettings(); 154 | } 155 | 156 | const urlParams = new URL(tab.url); 157 | const ruleName = title + ' (' + urlParams.host.substring(0, 15) + ')'; 158 | const rule = _getDefaultRule(ruleName, title ?? '', urlParams.href); 159 | 160 | tabModifier.rules.unshift(rule); 161 | 162 | await _setStorage(tabModifier); 163 | await chrome.tabs.reload(tab.id); 164 | } 165 | 166 | /** 167 | * Check if a URL should be processed 168 | */ 169 | async shouldProcessUrl(url: string): Promise { 170 | return !(await _shouldSkipUrl(url)); 171 | } 172 | 173 | /** 174 | * Query tabs helper 175 | */ 176 | private queryTabs(queryInfo = {}): Promise { 177 | return new Promise((resolve, reject) => { 178 | chrome.tabs.query(queryInfo, (result) => { 179 | if (chrome.runtime.lastError) { 180 | reject(new Error(chrome.runtime.lastError.message)); 181 | } else { 182 | resolve(result); 183 | } 184 | }); 185 | }); 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/common/emoji-data/categories/sports.ts: -------------------------------------------------------------------------------- 1 | export const data = { 2 | name: 'Sports & Activities', 3 | emojis: [ 4 | { emoji: '⚽', keywords: ['soccer', 'ball', 'football', 'sport', 'game'] }, 5 | { emoji: '🏀', keywords: ['basketball', 'ball', 'sport', 'game', 'hoop'] }, 6 | { emoji: '🏈', keywords: ['american', 'football', 'ball', 'sport', 'game'] }, 7 | { emoji: '⚾', keywords: ['baseball', 'ball', 'sport', 'game', 'bat'] }, 8 | { emoji: '🥎', keywords: ['softball', 'ball', 'sport', 'game', 'bat'] }, 9 | { emoji: '🎾', keywords: ['tennis', 'ball', 'sport', 'game', 'racket'] }, 10 | { emoji: '🏐', keywords: ['volleyball', 'ball', 'sport', 'game', 'net'] }, 11 | { emoji: '🏉', keywords: ['rugby', 'football', 'ball', 'sport', 'game'] }, 12 | { emoji: '🥏', keywords: ['flying', 'disc', 'frisbee', 'sport', 'throw'] }, 13 | { emoji: '🎱', keywords: ['pool', '8', 'ball', 'billiards', 'game'] }, 14 | { emoji: '🪀', keywords: ['yo', 'yo', 'toy', 'string', 'play'] }, 15 | { emoji: '🏓', keywords: ['ping', 'pong', 'table', 'tennis', 'paddle'] }, 16 | { emoji: '🏸', keywords: ['badminton', 'racquet', 'sport', 'game', 'shuttlecock'] }, 17 | { emoji: '🏒', keywords: ['ice', 'hockey', 'stick', 'puck', 'sport'] }, 18 | { emoji: '🥍', keywords: ['lacrosse', 'stick', 'sport', 'game', 'net'] }, 19 | { emoji: '🏑', keywords: ['field', 'hockey', 'stick', 'ball', 'sport'] }, 20 | { emoji: '🏏', keywords: ['cricket', 'bat', 'ball', 'sport', 'game'] }, 21 | { emoji: '🥊', keywords: ['boxing', 'glove', 'fight', 'sport', 'punch'] }, 22 | { emoji: '🥋', keywords: ['martial', 'arts', 'uniform', 'karate', 'judo'] }, 23 | { emoji: '🎽', keywords: ['running', 'shirt', 'marathon', 'sport', 'athletics'] }, 24 | { emoji: '🛼', keywords: ['roller', 'skate', 'wheels', 'fun', 'sport'] }, 25 | { emoji: '🛷', keywords: ['sled', 'sledding', 'winter', 'snow', 'fun'] }, 26 | { emoji: '⛸️', keywords: ['ice', 'skate', 'skating', 'winter', 'sport'] }, 27 | { emoji: '🥌', keywords: ['curling', 'stone', 'winter', 'sport', 'ice'] }, 28 | { emoji: '🎿', keywords: ['ski', 'skis', 'snow', 'winter', 'sport'] }, 29 | { emoji: '⛷️', keywords: ['skier', 'skiing', 'snow', 'winter', 'sport'] }, 30 | { emoji: '🏂', keywords: ['snowboarder', 'snowboard', 'snow', 'winter', 'sport'] }, 31 | { emoji: '🪂', keywords: ['parachute', 'skydiving', 'fall', 'jump', 'air'] }, 32 | { emoji: '🏋️‍♀️', keywords: ['woman', 'weightlifting', 'weights', 'gym', 'strong'] }, 33 | { emoji: '🏋️‍♂️', keywords: ['man', 'weightlifting', 'weights', 'gym', 'strong'] }, 34 | { emoji: '🏋️', keywords: ['person', 'weightlifting', 'weights', 'gym', 'strong'] }, 35 | { emoji: '🤸‍♀️', keywords: ['woman', 'cartwheeling', 'gymnastics', 'acrobat', 'flip'] }, 36 | { emoji: '🤸‍♂️', keywords: ['man', 'cartwheeling', 'gymnastics', 'acrobat', 'flip'] }, 37 | { emoji: '🤸', keywords: ['person', 'cartwheeling', 'gymnastics', 'acrobat', 'flip'] }, 38 | { emoji: '⛹️‍♀️', keywords: ['woman', 'bouncing', 'ball', 'basketball', 'sport'] }, 39 | { emoji: '⛹️‍♂️', keywords: ['man', 'bouncing', 'ball', 'basketball', 'sport'] }, 40 | { emoji: '⛹️', keywords: ['person', 'bouncing', 'ball', 'basketball', 'sport'] }, 41 | { emoji: '🤺', keywords: ['person', 'fencing', 'sword', 'sport', 'duel'] }, 42 | { emoji: '🤾‍♀️', keywords: ['woman', 'playing', 'handball', 'sport', 'ball'] }, 43 | { emoji: '🤾‍♂️', keywords: ['man', 'playing', 'handball', 'sport', 'ball'] }, 44 | { emoji: '🤾', keywords: ['person', 'playing', 'handball', 'sport', 'ball'] }, 45 | { emoji: '🏌️‍♀️', keywords: ['woman', 'golfing', 'golf', 'club', 'sport'] }, 46 | { emoji: '🏌️‍♂️', keywords: ['man', 'golfing', 'golf', 'club', 'sport'] }, 47 | { emoji: '🏌️', keywords: ['person', 'golfing', 'golf', 'club', 'sport'] }, 48 | { emoji: '🏇', keywords: ['horse', 'racing', 'jockey', 'equestrian', 'sport'] }, 49 | { emoji: '🧘‍♀️', keywords: ['woman', 'lotus', 'position', 'yoga', 'meditation'] }, 50 | { emoji: '🧘‍♂️', keywords: ['man', 'lotus', 'position', 'yoga', 'meditation'] }, 51 | { emoji: '🧘', keywords: ['person', 'lotus', 'position', 'yoga', 'meditation'] }, 52 | { emoji: '🏄‍♀️', keywords: ['woman', 'surfing', 'surf', 'wave', 'ocean'] }, 53 | { emoji: '🏄‍♂️', keywords: ['man', 'surfing', 'surf', 'wave', 'ocean'] }, 54 | { emoji: '🏄', keywords: ['person', 'surfing', 'surf', 'wave', 'ocean'] }, 55 | { emoji: '🏊‍♀️', keywords: ['woman', 'swimming', 'pool', 'water', 'sport'] }, 56 | { emoji: '🏊‍♂️', keywords: ['man', 'swimming', 'pool', 'water', 'sport'] }, 57 | { emoji: '🏊', keywords: ['person', 'swimming', 'pool', 'water', 'sport'] }, 58 | { emoji: '🤽‍♀️', keywords: ['woman', 'playing', 'water', 'polo', 'ball'] }, 59 | { emoji: '🤽‍♂️', keywords: ['man', 'playing', 'water', 'polo', 'ball'] }, 60 | { emoji: '🤽', keywords: ['person', 'playing', 'water', 'polo', 'ball'] }, 61 | { emoji: '🚣‍♀️', keywords: ['woman', 'rowing', 'boat', 'row', 'water'] }, 62 | { emoji: '🚣‍♂️', keywords: ['man', 'rowing', 'boat', 'row', 'water'] }, 63 | { emoji: '🚣', keywords: ['person', 'rowing', 'boat', 'row', 'water'] }, 64 | { emoji: '🧗‍♀️', keywords: ['woman', 'climbing', 'rock', 'mountain', 'sport'] }, 65 | { emoji: '🧗‍♂️', keywords: ['man', 'climbing', 'rock', 'mountain', 'sport'] }, 66 | { emoji: '🧗', keywords: ['person', 'climbing', 'rock', 'mountain', 'sport'] }, 67 | { emoji: '🚴‍♀️', keywords: ['woman', 'biking', 'bicycle', 'bike', 'cycling'] }, 68 | { emoji: '🚴‍♂️', keywords: ['man', 'biking', 'bicycle', 'bike', 'cycling'] }, 69 | { emoji: '🚴', keywords: ['person', 'biking', 'bicycle', 'bike', 'cycling'] }, 70 | { emoji: '🚵‍♀️', keywords: ['woman', 'mountain', 'biking', 'bike', 'cycling'] }, 71 | { emoji: '🚵‍♂️', keywords: ['man', 'mountain', 'biking', 'bike', 'cycling'] }, 72 | { emoji: '🚵', keywords: ['person', 'mountain', 'biking', 'bike', 'cycling'] }, 73 | { emoji: '🤹‍♀️', keywords: ['woman', 'juggling', 'juggle', 'balls', 'skill'] }, 74 | { emoji: '🤹‍♂️', keywords: ['man', 'juggling', 'juggle', 'balls', 'skill'] }, 75 | { emoji: '🤹', keywords: ['person', 'juggling', 'juggle', 'balls', 'skill'] }, 76 | { emoji: '🏆', keywords: ['trophy', 'award', 'winner', 'champion', 'first'] }, 77 | { emoji: '🥇', keywords: ['gold', 'medal', 'first', 'place', 'winner'] }, 78 | { emoji: '🥈', keywords: ['silver', 'medal', 'second', 'place', 'runner'] }, 79 | { emoji: '🥉', keywords: ['bronze', 'medal', 'third', 'place'] }, 80 | { emoji: '🏅', keywords: ['medal', 'sports', 'achievement', 'award'] }, 81 | { emoji: '🎖️', keywords: ['medal', 'military', 'honor', 'award', 'decoration'] }, 82 | { emoji: '🏁', keywords: ['chequered', 'flag', 'racing', 'finish', 'start'] }, 83 | { emoji: '🎯', keywords: ['target', 'goal', 'aim', 'bullseye', 'focus'] }, 84 | ], 85 | }; 86 | --------------------------------------------------------------------------------