├── src ├── stores │ ├── index.ts │ ├── dialog.ts │ └── view.ts ├── static │ ├── icons │ │ ├── icon16.png │ │ ├── icon19.png │ │ ├── icon48.png │ │ ├── icon128.png │ │ └── icon256.png │ ├── fonts │ │ ├── material-icons.woff2 │ │ ├── roboto-v20-latin-300.woff2 │ │ ├── roboto-v20-latin-500.woff2 │ │ └── roboto-v20-latin-regular.woff2 │ ├── manifest.json │ ├── arrow.svg │ └── _locales │ │ ├── en │ │ └── messages.json │ │ └── de │ │ └── messages.json ├── composables │ ├── index.ts │ ├── chrome.ts │ ├── chrome │ │ ├── util.ts │ │ ├── state.ts │ │ ├── windows.ts │ │ ├── storage.ts │ │ ├── tab-groups.ts │ │ └── tabs.ts │ ├── meta.ts │ ├── use-synced-copy.ts │ └── use-group-configurations.ts ├── util │ ├── resources.ts │ ├── types.ts │ ├── when.ts │ ├── conflict-manager.ts │ ├── schemas.ts │ ├── lzma.d.ts │ ├── queue-until-resolve.ts │ ├── group-configurations.ts │ ├── helpers.ts │ ├── matcher-regex.ts │ ├── group-creation-tracker.ts │ ├── base64.ts │ ├── storage.ts │ └── derive-matcher-options.ts ├── components │ ├── ArrowRightIcon.vue │ ├── Text.vue │ ├── LabelText.vue │ ├── Form │ │ ├── ToggleLabel.vue │ │ ├── ColorMenu.vue │ │ ├── Textarea.vue │ │ ├── Textfield.vue │ │ └── Select.vue │ ├── ColorIndicator.vue │ ├── Card │ │ ├── NavigationCardSection.vue │ │ ├── SwitchCardSection.vue │ │ ├── Card.vue │ │ └── CardSection.vue │ ├── AppBar.vue │ ├── Dialog │ │ ├── SettingsDialog.vue │ │ ├── OverlayDialog.vue │ │ ├── TransferDialog.vue │ │ └── EditDialog.vue │ ├── Popup │ │ ├── AdditionalActions.vue │ │ ├── Suggestion.vue │ │ └── GroupSelection.vue │ ├── GroupTag.vue │ ├── Group.vue │ ├── Util │ │ └── SlideVertical.vue │ ├── TabBar.vue │ ├── GroupHeader.vue │ └── PatternList.vue ├── setup │ ├── i18n.ts │ └── color-names.ts ├── env.d.ts ├── test │ ├── browser │ │ ├── util │ │ │ └── evaluations.ts │ │ └── options │ │ │ ├── sort.test.ts │ │ │ ├── create.test.ts │ │ │ ├── edit.test.ts │ │ │ └── url-patterns.test.ts │ └── unit │ │ ├── validate-match-pattern.test.ts │ │ └── generate-matcher.test.ts ├── index.html ├── main.ts ├── Popup.vue ├── Layout.vue └── Options.vue ├── .gitignore ├── tsconfig.node.json ├── .prettierrc ├── rollup.config.js ├── tsconfig.json ├── pack.js ├── .vscode └── settings.json ├── .eslintrc.yml ├── LICENSE ├── vite.config.ts ├── README.md ├── playwright.config.ts └── package.json /src/stores/index.ts: -------------------------------------------------------------------------------- 1 | export * from './view' 2 | export * from './dialog' 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | *.local 4 | auto-group-tabs.zip 5 | /extension 6 | playwright-report -------------------------------------------------------------------------------- /src/static/icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idealclover/auto-group-tabs/main/src/static/icons/icon16.png -------------------------------------------------------------------------------- /src/static/icons/icon19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idealclover/auto-group-tabs/main/src/static/icons/icon19.png -------------------------------------------------------------------------------- /src/static/icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idealclover/auto-group-tabs/main/src/static/icons/icon48.png -------------------------------------------------------------------------------- /src/static/icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idealclover/auto-group-tabs/main/src/static/icons/icon128.png -------------------------------------------------------------------------------- /src/static/icons/icon256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idealclover/auto-group-tabs/main/src/static/icons/icon256.png -------------------------------------------------------------------------------- /src/static/fonts/material-icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idealclover/auto-group-tabs/main/src/static/fonts/material-icons.woff2 -------------------------------------------------------------------------------- /src/static/fonts/roboto-v20-latin-300.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idealclover/auto-group-tabs/main/src/static/fonts/roboto-v20-latin-300.woff2 -------------------------------------------------------------------------------- /src/static/fonts/roboto-v20-latin-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idealclover/auto-group-tabs/main/src/static/fonts/roboto-v20-latin-500.woff2 -------------------------------------------------------------------------------- /src/static/fonts/roboto-v20-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idealclover/auto-group-tabs/main/src/static/fonts/roboto-v20-latin-regular.woff2 -------------------------------------------------------------------------------- /src/composables/index.ts: -------------------------------------------------------------------------------- 1 | export * from './chrome' 2 | export * from './meta' 3 | export * from './use-group-configurations' 4 | export * from './use-synced-copy' 5 | -------------------------------------------------------------------------------- /src/stores/dialog.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue' 2 | import { defineStore } from 'pinia' 3 | 4 | export const useDialog = defineStore('dialog', () => { 5 | const counter = ref(0) 6 | 7 | return { counter } 8 | }) 9 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "skipLibCheck": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /src/composables/chrome.ts: -------------------------------------------------------------------------------- 1 | export * from './chrome/windows' 2 | export * from './chrome/tabs' 3 | export * from './chrome/tab-groups' 4 | export * from './chrome/storage' 5 | export * from './chrome/state' 6 | export * from './chrome/util' 7 | -------------------------------------------------------------------------------- /src/util/resources.ts: -------------------------------------------------------------------------------- 1 | // The available group colors 2 | export const colors = [ 3 | 'grey', 4 | 'blue', 5 | 'red', 6 | 'yellow', 7 | 'green', 8 | 'pink', 9 | 'purple', 10 | 'cyan', 11 | 'orange' 12 | ] as const 13 | -------------------------------------------------------------------------------- /src/components/ArrowRightIcon.vue: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | arrowParens: avoid 2 | bracketSpacing: true 3 | htmlWhitespaceSensitivity: ignore 4 | insertPragma: false 5 | jsxBracketSameLine: false 6 | jsxSingleQuote: false 7 | printWidth: 80 8 | proseWrap: preserve 9 | requirePragma: false 10 | semi: false 11 | singleQuote: true 12 | tabWidth: 2 13 | trailingComma: none 14 | useTabs: false 15 | endOfLine: auto 16 | -------------------------------------------------------------------------------- /src/components/Text.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 18 | -------------------------------------------------------------------------------- /src/components/LabelText.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 18 | -------------------------------------------------------------------------------- /src/components/Form/ToggleLabel.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 17 | -------------------------------------------------------------------------------- /src/composables/chrome/util.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue' 2 | 3 | // A control switch to ignore Chrome runtime events during a certain period 4 | // This is needed mostly during browser startup and initial assignment of tab groups 5 | // because for some reason, Chrome fires strange tab group change events in that period, 6 | // leading to inappropriate re-labeling/re-coloring of a tab group 7 | export const ignoreChromeRuntimeEvents = ref(false) 8 | -------------------------------------------------------------------------------- /src/setup/i18n.ts: -------------------------------------------------------------------------------- 1 | // Provide a 'msg' containing all localization 2 | 3 | import { App } from 'vue' 4 | import { Translation } from '@/util/types' 5 | 6 | export default { 7 | install(app: App, messages: Translation) { 8 | // For usage in 18 | -------------------------------------------------------------------------------- /src/components/Card/NavigationCardSection.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 14 | 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "useDefineForClassFields": true, 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "strict": true, 8 | "jsx": "preserve", 9 | "sourceMap": true, 10 | "resolveJsonModule": true, 11 | "esModuleInterop": true, 12 | "skipLibCheck": true, 13 | "lib": ["esnext", "dom"], 14 | "baseUrl": ".", 15 | "paths": { 16 | "@/*": ["src/*"] 17 | } 18 | }, 19 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /src/util/types.ts: -------------------------------------------------------------------------------- 1 | import type { z } from 'zod' 2 | import * as schemas from './schemas' 3 | 4 | export type SaveOptions = z.infer 5 | export type GroupConfiguration = z.infer< 6 | typeof schemas.GroupConfigurationSchema 7 | > 8 | 9 | // Use JSON file as typings for messages 10 | import type Messages from '@/static/_locales/en/messages.json' 11 | type MessageKey = keyof typeof Messages 12 | 13 | export type RawTranslation = { 14 | [P in MessageKey]: { 15 | message: string 16 | description: string 17 | } 18 | } 19 | 20 | export type Translation = { 21 | [P in MessageKey]: string 22 | } 23 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import type { DefineComponent } from 'vue' 4 | import { Translation } from './util/types' 5 | 6 | declare module 'vue' { 7 | interface ComponentCustomProperties { 8 | msg: Translation 9 | colorNames: Record 10 | } 11 | } 12 | 13 | declare module '*.vue' { 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types 15 | const component: DefineComponent<{}, {}, any> 16 | 17 | export default component 18 | } 19 | 20 | declare global { 21 | interface Crypto { 22 | randomUUID(): string 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pack.js: -------------------------------------------------------------------------------- 1 | const { createWriteStream } = require('fs') 2 | const { resolve } = require('path') 3 | const archiver = require('archiver') 4 | 5 | const output = createWriteStream(resolve(__dirname, 'auto-group-tabs.zip')) 6 | const archive = archiver('zip') 7 | 8 | output.on('close', () => { 9 | console.log('Extension has been packed') 10 | }) 11 | 12 | archive.on('error', error => { 13 | throw error 14 | }) 15 | 16 | archive.pipe(output) 17 | 18 | archive.directory(resolve(__dirname, 'extension'), false, data => { 19 | if (['.DS_Store', 'thumbs.db', 'desktop.ini'].includes(data.name)) { 20 | return false 21 | } 22 | return data 23 | }) 24 | 25 | archive.finalize() 26 | -------------------------------------------------------------------------------- /src/setup/color-names.ts: -------------------------------------------------------------------------------- 1 | import { colors } from '@/util/resources' 2 | import { capitalize } from '@/util/helpers' 3 | import { App } from 'vue' 4 | 5 | export default { 6 | install(app: App, msg: { [key: string]: string }) { 7 | const colorNames: { [P in typeof colors[number]]: string } = 8 | Object.fromEntries( 9 | colors.map(name => [name, capitalize(msg[`color${capitalize(name)}`])]) 10 | ) as any 11 | 12 | // For usage in 20 | 21 | 30 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorCustomizations": { 3 | "titleBar.activeBackground": "#93d978", 4 | "titleBar.activeForeground": "#15202b", 5 | "titleBar.inactiveBackground": "#93d97899", 6 | "titleBar.inactiveForeground": "#15202b99", 7 | "sash.hoverBorder": "#b3e4a0", 8 | "commandCenter.border": "#15202b99" 9 | }, 10 | "peacock.color": "#93d978", 11 | "editor.formatOnSave": true, 12 | "search.exclude": { 13 | "**/node_modules": true, 14 | "**/bower_components": true, 15 | "**/*.code-search": true, 16 | "**/extension": true 17 | }, 18 | "path-intellisense.extensionOnImport": false, 19 | "files.watcherExclude": { 20 | "**/extension/**": true 21 | }, 22 | "typescript.tsdk": "node_modules/typescript/lib" 23 | } 24 | -------------------------------------------------------------------------------- /src/util/when.ts: -------------------------------------------------------------------------------- 1 | import { nextTick, Ref, watch } from 'vue' 2 | 3 | // By default, check if the value is truthy 4 | const defaultPredicate = (value: T) => Boolean(value) 5 | 6 | /** 7 | * Create a promise that resolves when a ref fullfills a predicate. 8 | * With no predicate provided, the ref will be checked for truthiness. 9 | */ 10 | export function when( 11 | container: Ref, 12 | predicate: (value: T) => boolean = defaultPredicate 13 | ) { 14 | return new Promise(resolve => { 15 | const stop = watch( 16 | container, 17 | value => { 18 | if (predicate(value)) { 19 | nextTick(() => { 20 | stop() 21 | }) 22 | 23 | resolve() 24 | } 25 | }, 26 | { deep: true, immediate: true } 27 | ) 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /src/components/Card/Card.vue: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 17 | 18 | 38 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | es2021: true 4 | globals: 5 | chrome: true 6 | extends: 7 | - eslint:recommended 8 | - plugin:vue/vue3-essential 9 | - plugin:@typescript-eslint/recommended 10 | - prettier 11 | overrides: [] 12 | parser: 'vue-eslint-parser' 13 | parserOptions: 14 | parser: '@typescript-eslint/parser' 15 | ecmaVersion: latest 16 | sourceType: module 17 | extraFileExtensions: 18 | - '*.d.ts' 19 | plugins: 20 | - vue 21 | - '@typescript-eslint' 22 | rules: 23 | indent: off 24 | linebreak-style: 25 | - error 26 | - unix 27 | quotes: 28 | - error 29 | - single 30 | semi: 31 | - error 32 | - never 33 | '@typescript-eslint/no-explicit-any': off 34 | '@typescript-eslint/no-non-null-assertion': off 35 | 'vue/multi-word-component-names': off 36 | 'vue/no-v-text-v-html-on-component': off 37 | 'vue/no-deprecated-slot-attribute': off 38 | -------------------------------------------------------------------------------- /src/composables/chrome/state.ts: -------------------------------------------------------------------------------- 1 | import { computed } from 'vue' 2 | import { 3 | useChromeTabGroupsByWindowId, 4 | useReadonlyChromeTabGroups 5 | } from './tab-groups' 6 | import { 7 | useChromeTabsById, 8 | useChromeTabsByWindowId, 9 | useReadonlyChromeTabs 10 | } from './tabs' 11 | import { useReadonlyChromeWindows } from './windows' 12 | 13 | export function useChromeState() { 14 | const windows = useReadonlyChromeWindows() 15 | const tabs = useReadonlyChromeTabs() 16 | const tabGroups = useReadonlyChromeTabGroups() 17 | const loaded = computed( 18 | () => windows.loaded.value && tabs.loaded.value && tabGroups.loaded.value 19 | ) 20 | 21 | return { 22 | loaded, 23 | windows, 24 | tabs, 25 | tabGroups, 26 | tabsById: useChromeTabsById(), 27 | tabsByWindowId: useChromeTabsByWindowId(), 28 | tabGroupsByWindowId: useChromeTabGroupsByWindowId() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/composables/meta.ts: -------------------------------------------------------------------------------- 1 | import { JsonValue } from 'type-fest' 2 | import { computed, nextTick, ref, toRaw, UnwrapRef } from 'vue' 3 | 4 | /** 5 | * A ref that automatically resets to its initial value after one tick 6 | */ 7 | export function tickResetRef(initialValue: T) { 8 | const value = ref(initialValue) 9 | 10 | return computed({ 11 | get: () => value.value, 12 | set: newValue => { 13 | value.value = newValue 14 | 15 | nextTick(() => { 16 | value.value = initialValue as UnwrapRef 17 | }) 18 | } 19 | }) 20 | } 21 | 22 | /** 23 | * Like toRaw, but recursive 24 | */ 25 | export function toRawDeep(value: T): T { 26 | const rawValue = toRaw(value) 27 | if (typeof rawValue !== 'object' || rawValue === null) return rawValue 28 | 29 | if (Array.isArray(rawValue)) { 30 | return rawValue.map(toRawDeep) as T 31 | } else { 32 | return Object.fromEntries( 33 | Object.entries(rawValue).map(([key, value]) => [ 34 | key, 35 | toRawDeep(value as JsonValue) 36 | ]) 37 | ) as T 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Florian Reuschel 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | // Configuration for bundling the options page 2 | 3 | import { resolve } from 'path' 4 | import { defineConfig } from 'vitest/config' 5 | import vue from '@vitejs/plugin-vue' 6 | 7 | // See https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | resolve: { 10 | alias: { 11 | // Ensure that all parties import the exact same Vue build 12 | vue: require.resolve('vue/dist/vue.runtime.esm-bundler.js'), 13 | '@': resolve(__dirname, 'src') 14 | } 15 | }, 16 | plugins: [ 17 | vue({ 18 | template: { 19 | compilerOptions: { 20 | isCustomElement: tag => tag === 'focus-trap' || tag.startsWith('mwc-') 21 | } 22 | } 23 | }) 24 | ], 25 | 26 | // Code that is copied directly to the extension directory 27 | publicDir: 'static', 28 | root: resolve('src'), 29 | server: { port: 6655 }, 30 | build: { 31 | assetsDir: '.', 32 | outDir: '../extension', 33 | emptyOutDir: true, 34 | target: 'chrome89', 35 | sourcemap: true, 36 | minify: false 37 | }, 38 | test: { 39 | include: ['./test/unit/**/*.test.ts'] 40 | } 41 | }) 42 | -------------------------------------------------------------------------------- /src/util/conflict-manager.ts: -------------------------------------------------------------------------------- 1 | // The conflict manager is a collection of utilities that help with 2 | // marking a string as conflicting by attaching a unique conflict marker to it 3 | 4 | import { sanitizeRegex } from './helpers' 5 | 6 | const conflictMarker = ' ' 7 | const conflictPattern = conflictMarker 8 | .split('%s') 9 | .map(sanitizeRegex) 10 | .join('[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}') 11 | const conflictRegex = new RegExp(conflictPattern + '$') 12 | 13 | function generateMarker() { 14 | return conflictMarker.split('%s').join(crypto.randomUUID()) 15 | } 16 | 17 | export function hasMarker(title: string) { 18 | return conflictRegex.test(title) 19 | } 20 | 21 | export function withoutMarker(title: string) { 22 | return title.replace(conflictRegex, '') 23 | } 24 | 25 | export function withMarker( 26 | title: string, 27 | { recreate = false }: { recreate?: boolean } = {} 28 | ): string { 29 | if (hasMarker(title)) { 30 | if (recreate) { 31 | return withMarker(withoutMarker(title)) 32 | } else { 33 | return title 34 | } 35 | } else { 36 | return `${title}${generateMarker()}` 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/stores/view.ts: -------------------------------------------------------------------------------- 1 | import { ComponentInternalInstance, computed, ref } from 'vue' 2 | import { defineStore } from 'pinia' 3 | import { useEventBus } from '@vueuse/core' 4 | 5 | function getEditStore() { 6 | const editViews = ref(new Set()) 7 | 8 | function register(component: ComponentInternalInstance) { 9 | editViews.value.add(component) 10 | } 11 | 12 | function deregister(component: ComponentInternalInstance) { 13 | editViews.value.delete(component) 14 | } 15 | 16 | function isRegistered(component: ComponentInternalInstance) { 17 | return editViews.value.has(component) 18 | } 19 | 20 | const hasAny = computed(() => editViews.value.size > 0) 21 | 22 | const closeBus = useEventBus('close') 23 | function sendCloseSignal() { 24 | closeBus.emit() 25 | } 26 | function onCloseSignal(callback: () => void) { 27 | closeBus.on(callback) 28 | } 29 | 30 | return { 31 | register, 32 | deregister, 33 | isRegistered, 34 | hasAny, 35 | sendCloseSignal, 36 | onCloseSignal 37 | } 38 | } 39 | 40 | export const useViewStore = defineStore('view', () => { 41 | return { edit: getEditStore() } 42 | }) 43 | -------------------------------------------------------------------------------- /src/test/browser/util/evaluations.ts: -------------------------------------------------------------------------------- 1 | import { GroupConfiguration } from '@/util/types' 2 | 3 | export function isFocused(element: HTMLElement | SVGElement) { 4 | return document.activeElement === element 5 | } 6 | 7 | export function isValid(input: HTMLElement | SVGElement) { 8 | return (input as HTMLInputElement)?.checkValidity?.() ?? false 9 | } 10 | 11 | export function getValue(input: HTMLElement | SVGElement) { 12 | return (input as HTMLInputElement)?.value 13 | } 14 | 15 | export function getGroups() { 16 | return JSON.parse(localStorage.groups || '[]') as GroupConfiguration[] 17 | } 18 | 19 | export type GroupConfigurationWithoutId = Omit 20 | 21 | export function setGroups(groups: GroupConfigurationWithoutId[]) { 22 | const supplementedGroups = groups.map(group => ({ 23 | ...group, 24 | id: crypto.randomUUID() 25 | })) 26 | localStorage.groups = JSON.stringify(supplementedGroups) 27 | } 28 | 29 | export function isMacOS() { 30 | return navigator.platform.includes('Mac') 31 | } 32 | 33 | export function waitAnimationsFinished(element: HTMLElement | SVGElement) { 34 | return Promise.all( 35 | element 36 | .getAnimations({ subtree: true }) 37 | .map(animation => animation.finished) 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /src/util/schemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { matcherPattern } from './helpers' 3 | import { colors } from './resources' 4 | 5 | export const SaveOptionsSchema = z.object({ 6 | strict: z.boolean().default(false), 7 | merge: z.boolean().default(false) 8 | }) 9 | 10 | const matcherPatternRegex = new RegExp(matcherPattern) 11 | 12 | export const GroupConfigurationSchema = z.object({ 13 | id: z.string().uuid(), 14 | title: z.string(), 15 | color: z.enum(colors), 16 | options: SaveOptionsSchema, 17 | matchers: z 18 | .array( 19 | z 20 | .string() 21 | .refine( 22 | pattern => matcherPatternRegex.test(pattern), 23 | 'Invalid URL Pattern' 24 | ) 25 | ) 26 | .refine( 27 | matchers => new Set(matchers).size === matchers.length, 28 | 'Duplicate URL Patterns are not allowed' 29 | ) 30 | }) 31 | 32 | export const GroupConfigurationSchemas = z 33 | .array(GroupConfigurationSchema) 34 | .refine(groupConfigurations => { 35 | const groupConfigurationIds = new Set( 36 | groupConfigurations.map(groupConfiguration => groupConfiguration.id) 37 | ) 38 | 39 | return groupConfigurationIds.size === groupConfigurations.length 40 | }, 'Duplicate group configuration IDs are not allowed') 41 | -------------------------------------------------------------------------------- /src/components/AppBar.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | {{ props.label }} 11 | 12 | 13 | 14 | 36 | 37 | 55 | -------------------------------------------------------------------------------- /src/util/lzma.d.ts: -------------------------------------------------------------------------------- 1 | export type CompressOptions = Partial<{ 2 | /** 3 | * Which mode to use (1 through 9, defaults to 7) 4 | */ 5 | mode: 1 | 2 | 3 | 4 | 5 | 6 | 7 6 | 7 | /** 8 | * Whether to write an end mark 9 | */ 10 | enableEndMark: boolean 11 | }> 12 | 13 | /** 14 | * Compress a string with the LZMA algorithm 15 | * 16 | * @param value The string to compress 17 | * @param options Compression options 18 | * @returns The compressed string (may contain binary data) 19 | */ 20 | export function compress(value: string, options?: CompressOptions): number[] 21 | 22 | /** 23 | * Compress a string with the LZMA algorithm 24 | * 25 | * @param value The string to compress 26 | * @param options Compression options 27 | * @returns The compressed string, Base64-encoded 28 | */ 29 | export function compressBase64(value: string, options?: CompressOptions): string 30 | 31 | /** 32 | * Decompress a string compressed with the LZMA algorithm 33 | * 34 | * @param bytes The array created by the compress() function 35 | */ 36 | export function decompress(bytes: number[] | Int8Array): string 37 | 38 | /** 39 | * Decompress a string compressed with the LZMA algorithm 40 | * 41 | * @param value The string created by the compressBase64() function 42 | */ 43 | export function decompressBase64(value: string): string 44 | -------------------------------------------------------------------------------- /src/composables/use-synced-copy.ts: -------------------------------------------------------------------------------- 1 | import { nextTick, ref, Ref, UnwrapRef, watch } from 'vue' 2 | 3 | /** 4 | * Create a copy of a ref/computed callback that is updated whenever the ref changes but does not populate 5 | * changes back to the original ref. Instead, a callback will be called if the copy is 6 | * changed locally (i.e. not through a sync of the original ref), potentially to sync changes back 7 | * 8 | * This is essentially a computed ref with getter/setter but with an optimistic getter value. 9 | */ 10 | export function useSyncedCopy( 11 | original: Ref | (() => T), 12 | localChangeCallback?: (value: T) => void 13 | ) { 14 | const get = (): T => 15 | JSON.parse( 16 | JSON.stringify( 17 | typeof original === 'function' ? original() : original.value 18 | ) 19 | ) 20 | const syncedCopy = ref(get()) 21 | const externalChange = ref(false) 22 | 23 | watch( 24 | original, 25 | () => { 26 | externalChange.value = true 27 | syncedCopy.value = get() as UnwrapRef 28 | 29 | nextTick(() => { 30 | externalChange.value = false 31 | }) 32 | }, 33 | { deep: true } 34 | ) 35 | 36 | watch( 37 | syncedCopy, 38 | copyValue => { 39 | if (!externalChange.value) { 40 | localChangeCallback?.(copyValue as T) 41 | } 42 | }, 43 | { deep: true } 44 | ) 45 | 46 | return syncedCopy 47 | } 48 | -------------------------------------------------------------------------------- /src/composables/chrome/windows.ts: -------------------------------------------------------------------------------- 1 | import { readonly, ref } from 'vue' 2 | 3 | function _useReadonlyChromeWindows() { 4 | const windows = ref([]) 5 | const loaded = ref(false) 6 | const lastCreated = ref(undefined) 7 | const lastRemoved = ref(undefined) 8 | 9 | if (typeof chrome.windows !== 'undefined') { 10 | chrome.windows.getAll().then(queriedWindows => { 11 | windows.value = queriedWindows.filter(window => window.type === 'normal') 12 | loaded.value = true 13 | 14 | chrome.windows.onCreated.addListener(createdWindow => { 15 | if (createdWindow.type !== 'normal') return 16 | 17 | lastCreated.value = createdWindow 18 | windows.value = [...windows.value, createdWindow] 19 | }) 20 | 21 | chrome.windows.onRemoved.addListener(removedWindowId => { 22 | lastRemoved.value = removedWindowId 23 | windows.value = windows.value.filter( 24 | window => window.id !== removedWindowId 25 | ) 26 | }) 27 | }) 28 | } else { 29 | loaded.value = true 30 | } 31 | 32 | return { 33 | items: readonly(windows), 34 | loaded: readonly(loaded), 35 | lastCreated: readonly(lastCreated), 36 | lastRemoved: readonly(lastRemoved) 37 | } 38 | } 39 | 40 | let readonlyChromeWindows: ReturnType 41 | export function useReadonlyChromeWindows() { 42 | if (!readonlyChromeWindows) { 43 | readonlyChromeWindows = _useReadonlyChromeWindows() 44 | } 45 | return readonlyChromeWindows 46 | } 47 | -------------------------------------------------------------------------------- /src/test/unit/validate-match-pattern.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from 'vitest' 2 | 3 | import { matcherPattern } from '@/util/helpers' 4 | 5 | it('validates match patterns', () => { 6 | const regex = new RegExp(matcherPattern) 7 | 8 | expect(regex.test('*')).toBe(true) 9 | expect(regex.test('*.example.com')).toBe(true) 10 | expect(regex.test('*.example.com:8080')).toBe(true) 11 | expect(regex.test('*.example.com:8080/')).toBe(true) 12 | expect(regex.test('example.com')).toBe(true) 13 | expect(regex.test('example.com:8080')).toBe(true) 14 | expect(regex.test('example.com:8080/')).toBe(true) 15 | expect(regex.test('example.com/path')).toBe(true) 16 | expect(regex.test('example.com/path/to/file')).toBe(true) 17 | expect(regex.test('example.com/path/to/file?query=value')).toBe(true) 18 | expect(regex.test('example.com/*')).toBe(true) 19 | expect(regex.test('http://example.com')).toBe(true) 20 | expect(regex.test('https://example.com')).toBe(true) 21 | expect(regex.test('*://example.com')).toBe(true) 22 | expect(regex.test('ftp://example.com')).toBe(true) 23 | expect(regex.test('file://*')).toBe(true) 24 | expect(regex.test('[::1]')).toBe(true) 25 | expect(regex.test('chrome-extension://*')).toBe(true) 26 | expect(regex.test('user:password@host')).toBe(true) 27 | 28 | expect(regex.test('*://')).toBe(false) 29 | expect(regex.test(':')).toBe(false) 30 | expect(regex.test('/')).toBe(false) 31 | expect(regex.test('example.com:*')).toBe(false) 32 | expect(regex.test('http://example.com:invalid-port')).toBe(false) 33 | expect(regex.test('example.com:invalid-port')).toBe(false) 34 | }) 35 | -------------------------------------------------------------------------------- /src/util/queue-until-resolve.ts: -------------------------------------------------------------------------------- 1 | import monomitter from 'monomitter' 2 | 3 | export type Queue = { 4 | promise: Promise 5 | push: (newPromise: Promise) => void 6 | } 7 | 8 | /** 9 | * A queue that sequentially checks on a list of Promises sequentially, 10 | * resolving when the first Promise of the list resolves, and with the ability 11 | * to asynchronously add new Promises to the list. 12 | * 13 | * Note that the queue *never* rejects. If no resolved Promise is in the list, 14 | * the queue will wait for a new Promise to be added and continue evaluating. 15 | */ 16 | export function queueUntilResolve( 17 | initialPromise: Promise = Promise.reject() 18 | ) { 19 | const list = [initialPromise] 20 | const [publishNewPromise, subscribeNewPromise] = monomitter() 21 | 22 | function push(newPromise: Promise) { 23 | list.push(newPromise) 24 | publishNewPromise() 25 | } 26 | 27 | function resolveList(): Promise { 28 | const firstItem = list.shift()! 29 | 30 | return firstItem 31 | .catch(() => { 32 | if (list.length === 0) { 33 | // If no list items remain, wait for a new Promise to be attached 34 | return new Promise(resolve => { 35 | const unsubscribe = subscribeNewPromise(() => { 36 | unsubscribe() 37 | resolve(resolveList()) 38 | }) 39 | }) 40 | } else { 41 | return resolveList() 42 | } 43 | }) 44 | .then(result => { 45 | if (list.length === 0) return result 46 | return resolveList() 47 | }) 48 | } 49 | return { push, promise: resolveList() } 50 | } 51 | -------------------------------------------------------------------------------- /src/components/Form/ColorMenu.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 17 | 18 | 19 | 20 | 21 | 47 | 48 | 66 | -------------------------------------------------------------------------------- /src/components/Card/CardSection.vue: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | 15 | 16 | 30 | 31 | 76 | -------------------------------------------------------------------------------- /src/components/Dialog/SettingsDialog.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{ msg.settingsTransferConfigurationTitle }} 9 | 10 | {{ msg.settingsTransferConfigurationSubtitle }} 11 | 12 | 13 | 14 | 15 | 16 | {{ msg.settingsForceReloadTitle }} 17 | 18 | {{ msg.settingsForceReloadSubtitle }} 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 55 | 56 | 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Auto-Group Tabs 2 | 3 | This is a Google Chrome extension which enables the configuration of tab groups for certain URLs. Navigating to such a URL will automatically add the tab to its configured group (creating the group if it does not exist). 4 | 5 | ## Development 6 | 7 | This project is built with [Vue](https://v3.vuejs.org/) and [Vite](https://vitejs.dev/). 8 | 9 | ### Setup 10 | 11 | Clone this project: 12 | 13 | ```bash 14 | git clone https://github.com/loilo/auto-group-tabs.git 15 | ``` 16 | 17 | Step into the cloned folder and install [npm](https://www.npmjs.com/) dependencies: 18 | 19 | ```bash 20 | npm ci 21 | ``` 22 | 23 | ### Development of the Options UI 24 | 25 | The fastest way to tinker with the heart of this extension — its options page — is to run the `dev` script: 26 | 27 | ```bash 28 | npm run dev 29 | ``` 30 | 31 | This will start up the Vite dev server and serve the options page on [localhost:6655](http://localhost:6655/). Having the options page directly in the browser allows for comfort features like hot module reloading to be usable during development. 32 | 33 | In this mode, Chrome extension APIs accessed during production (e.g. `chrome.i18n` and `chrome.storage`) use browser-based fallbacks. 34 | 35 | > **Note:** You probably want to use the [device toolbar](https://developers.google.com/web/tools/chrome-devtools/device-mode) of Chrome's devtools to give the options page a proper viewport. Chrome's options overlays are (at the time of writing) 400px wide, and I used a height of 600px during development. 36 | 37 | ### Testing in Chrome 38 | 39 | To test the extension in Chrome, you'll have to do a production build of it: 40 | 41 | ```bash 42 | npm run build 43 | ``` 44 | 45 | This will create a subfolder with the name `extension` inside the project, which can be installed in your Chrome browser. 46 | -------------------------------------------------------------------------------- /src/composables/use-group-configurations.ts: -------------------------------------------------------------------------------- 1 | import { useStorage } from './chrome' 2 | import { GroupConfiguration } from '@/util/types' 3 | import * as lzma from '@/util/lzma' 4 | 5 | /** 6 | * Get configured groups from storage 7 | */ 8 | export function useGroupConfigurations() { 9 | return useStorage('groups', [], { 10 | saveMapper(groups) { 11 | // In browser environment (dev, testing), don't use compression 12 | if (typeof chrome.storage === 'undefined') return groups 13 | 14 | // Since 0.0.19, groups are serialized and compressed to avoid storage size limits 15 | return lzma.compressBase64(JSON.stringify(groups)) 16 | }, 17 | loadMapper(storedGroups) { 18 | // In very early extension versions, groups were serialized as JSON before storing 19 | if (typeof storedGroups === 'string' && /^[{[]/.test(storedGroups)) { 20 | storedGroups = JSON.parse(storedGroups) 21 | } 22 | 23 | // Since 0.0.19, groups are serialized and compressed to avoid storage size limits 24 | if (typeof storedGroups === 'string') { 25 | storedGroups = JSON.parse(lzma.decompressBase64(storedGroups)) 26 | } 27 | 28 | // Before 0.0.19, groups were stored as an array of objects, 29 | // serialization/deserialization was done by Chrome 30 | 31 | // Skip if value is not an array 32 | if (!Array.isArray(storedGroups)) return [] 33 | 34 | // Group options have been added in v0.0.12, add them if missing 35 | for (const group of storedGroups) { 36 | if (!('options' in group)) { 37 | group.options = { strict: false, merge: false } 38 | } 39 | 40 | if (!('merge' in group.options)) { 41 | group.options.merge = false 42 | } 43 | } 44 | 45 | return storedGroups 46 | } 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 54 | 55 | Options 56 | 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import '@a11y/focus-trap/index' 2 | import '@material/mwc-button' 3 | import '@material/mwc-icon-button' 4 | import '@material/mwc-fab' 5 | import '@material/mwc-formfield' 6 | import '@material/mwc-list' 7 | import '@material/mwc-menu' 8 | import '@material/mwc-radio' 9 | import '@material/mwc-select' 10 | import '@material/mwc-snackbar' 11 | import '@material/mwc-switch' 12 | import '@material/mwc-checkbox' 13 | import '@material/mwc-textfield' 14 | import '@material/mwc-textarea' 15 | import '@material/mwc-top-app-bar' 16 | import '@material/mwc-top-app-bar-fixed' 17 | 18 | import { createApp } from 'vue' 19 | import { createPinia } from 'pinia' 20 | import i18n from './setup/i18n' 21 | import colorNames from './setup/color-names' 22 | 23 | import Options from './Options.vue' 24 | import Popup from './Popup.vue' 25 | 26 | import { RawTranslation, Translation } from './util/types' 27 | import { isExtensionWorker } from './util/helpers' 28 | 29 | const language = /^de-?/.test( 30 | isExtensionWorker ? chrome.i18n.getUILanguage() : navigator.language 31 | ) 32 | ? 'de' 33 | : 'en' 34 | window.document.documentElement.setAttribute('lang', language) 35 | 36 | async function main() { 37 | const rawMessages: RawTranslation = await import( 38 | `./static/_locales/${language}/messages.json` 39 | ) 40 | const messages = Object.fromEntries( 41 | Object.entries(rawMessages).map(([key, { message }]) => [key, message]) 42 | ) as Translation 43 | 44 | const context = new URL(location.href).searchParams.get('context') 45 | let appComponent: any 46 | switch (context) { 47 | case 'popup': 48 | appComponent = Popup 49 | break 50 | 51 | default: 52 | appComponent = Options 53 | break 54 | } 55 | 56 | const pinia = createPinia() 57 | const app = createApp(appComponent) 58 | app.use(pinia) 59 | app.use(i18n, messages) 60 | app.use(colorNames, messages) 61 | 62 | app.mount('#app') 63 | } 64 | 65 | main() 66 | -------------------------------------------------------------------------------- /src/util/group-configurations.ts: -------------------------------------------------------------------------------- 1 | import { toRawDeep } from '@/composables' 2 | import * as lzma from '@/util/lzma' 3 | import * as conflictManager from './conflict-manager' 4 | import { writeStorage } from './storage' 5 | import { GroupConfiguration } from './types' 6 | 7 | /** 8 | * Save configured groups to storage 9 | */ 10 | export async function saveGroupConfigurations(groups: GroupConfiguration[]) { 11 | const groupsCopy: GroupConfiguration[] = JSON.parse( 12 | JSON.stringify(toRawDeep(groups)) 13 | ) 14 | 15 | for (const group of groupsCopy) { 16 | // If there are no more conflicts, get rid of the conflict marker 17 | 18 | if (!conflictManager.hasMarker(group.title)) continue 19 | 20 | const titleWithoutConflictMarker = conflictManager.withoutMarker( 21 | group.title 22 | ) 23 | 24 | if (groupsCopy.some(group => group.title === titleWithoutConflictMarker)) 25 | continue 26 | 27 | group.title = titleWithoutConflictMarker 28 | } 29 | 30 | // In browser environment (dev, testing), don't use compression 31 | const shouldCompress = typeof chrome.storage !== 'undefined' 32 | 33 | // Since 0.0.19, groups are serialized and compressed to avoid storage size limits 34 | // There has been an oversight that led to this function being uncompressed, 35 | // this has been fixed in 0.0.20. 36 | const saveableGroupsCopy = shouldCompress 37 | ? lzma.compressBase64(JSON.stringify(groupsCopy)) 38 | : groupsCopy 39 | 40 | return await writeStorage('groups', saveableGroupsCopy, 'sync') 41 | } 42 | 43 | /** 44 | * Create a predicate function for a given group configuration which matches 45 | * another group configuration if it has the same title and color 46 | */ 47 | export function createGroupConfigurationMatcher( 48 | group: Partial> 49 | ) { 50 | return (tabGroup: Partial>) => 51 | tabGroup.title === group.title && tabGroup.color === group.color 52 | } 53 | -------------------------------------------------------------------------------- /src/test/browser/options/sort.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | import { 3 | getGroups, 4 | GroupConfigurationWithoutId, 5 | setGroups, 6 | waitAnimationsFinished 7 | } from '../util/evaluations' 8 | 9 | test.use({ 10 | viewport: { 11 | width: 400, 12 | height: 600 13 | }, 14 | headless: false 15 | }) 16 | 17 | test('Drag and drop groups', async ({ page }) => { 18 | await page.goto('http://localhost:6655/?context=options') 19 | 20 | const group1 = { 21 | title: 'Test Group 1', 22 | color: 'blue', 23 | matchers: [], 24 | options: { strict: true, merge: false } 25 | } 26 | const group2 = { 27 | title: 'Test Group 2', 28 | color: 'red', 29 | matchers: ['example.com', 'example.org'], 30 | options: { strict: true, merge: false } 31 | } 32 | const group3 = { 33 | title: 'Test Group 3', 34 | color: 'green', 35 | matchers: ['github.com'], 36 | options: { strict: true, merge: false } 37 | } 38 | 39 | // Define groups programmatically 40 | await page.evaluate(setGroups, [ 41 | group1, 42 | group2, 43 | group3 44 | ] as GroupConfigurationWithoutId[]) 45 | 46 | // Reload the page to apply localStorage changes 47 | await page.reload() 48 | 49 | // Find sort mode button and click it 50 | await page.locator('.sort-button').click() 51 | 52 | // There should be three drag handles 53 | const dragHandles = page.locator('.drag-handle') 54 | await expect(dragHandles).toHaveCount(3) 55 | 56 | // Wait for group transition to finish 57 | await page.locator('body').evaluate(waitAnimationsFinished) 58 | 59 | await page.dragAndDrop( 60 | '.group:not(.group + .group) .drag-handle', 61 | '.group:last-child .drag-handle' 62 | ) 63 | 64 | // Validate groups structure in storage 65 | expect(await page.evaluate(getGroups)).toMatchObject([ 66 | { id: expect.any(String), ...group2 }, 67 | { id: expect.any(String), ...group3 }, 68 | { id: expect.any(String), ...group1 } 69 | ]) 70 | }) 71 | -------------------------------------------------------------------------------- /src/components/Popup/AdditionalActions.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 50 | 51 | {{ msg.popupEditCurrentGroup }} 52 | 53 | 54 | 55 | 56 | 70 | -------------------------------------------------------------------------------- /src/util/helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The raw pattern to match URL matchers 3 | * 4 | * @see https://developer.chrome.com/docs/extensions/mv3/match_patterns/ 5 | */ 6 | // prettier-ignore 7 | export const matcherPattern = 8 | '^(?:' + 9 | // First option: simple cases where just a valid scheme is enforced 10 | // URI Scheme 11 | '(?[a-z][a-z0-9+.\\-]*)' + 12 | 13 | // Exclude schemes that are reserved for complex cases 14 | '(?.*)' + 24 | '|' + 25 | // Second option: complex validation of asterisks in scheme, hostname, ... 26 | // Possible schemes are http, https, ftp, and a literal asterisk * 27 | '(?:(?https?|ftp|\\*)://)?' + 28 | 29 | // Credentials & Origin 30 | '(?' + 31 | // Literal asterisk 32 | '\\*' + 33 | 34 | '|(?:' + 35 | // Username 36 | '(?:[^@:\\/]+(?::[^@:\\/]+)?@)?' + 37 | 38 | // Origin 39 | '(?:' + 40 | // Asterisk as subdomain, followed by hostname 41 | '\\*\\.[^*:\\/]+' + 42 | '|' + 43 | 44 | // Just a hostname 45 | '[^:\\/]+' + 46 | '|' + 47 | 48 | // IPv6 address 49 | '\\[[0-9a-f:]+\\]'+ 50 | ')' + 51 | ')' + 52 | 53 | // Optional port 54 | '(?::[0-9]+)?' + 55 | ')' + 56 | 57 | // Path 58 | '(?:/(?.*))?' + 59 | ')$' 60 | 61 | /** 62 | * Capitalize a string 63 | */ 64 | export const capitalize = (string: string) => 65 | string.slice(0, 1).toUpperCase() + string.slice(1) 66 | 67 | /** 68 | * Escape special characters in a regex string 69 | */ 70 | export function sanitizeRegex(string: string) { 71 | return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 72 | } 73 | 74 | export const isExtensionWorker = 75 | typeof globalThis?.chrome?.runtime !== 'undefined' 76 | -------------------------------------------------------------------------------- /src/util/matcher-regex.ts: -------------------------------------------------------------------------------- 1 | import { matcherPattern, sanitizeRegex } from '@/util/helpers' 2 | 3 | /** 4 | * Generate a regular expression from a matcher string 5 | */ 6 | export function generateMatcherRegex(matcher: string) { 7 | /** 8 | * Replace asterisks in a regex string 9 | */ 10 | function generatePatternString(string: string, asteriskReplacement: string) { 11 | return string.split('*').map(sanitizeRegex).join(asteriskReplacement) 12 | } 13 | 14 | const matcherPatternRegex = new RegExp(matcherPattern) 15 | 16 | const result = matcher.match(matcherPatternRegex) 17 | 18 | if (!result) { 19 | throw new Error('Invalid matcher: ' + matcher) 20 | } 21 | 22 | // Shortcut for catch-all matchers 23 | if (matcher === '*') return /^.*$/ 24 | 25 | const { scheme, host, path, simpleScheme, simplePath } = result.groups ?? {} 26 | 27 | const schemePattern = simpleScheme 28 | ? simpleScheme 29 | : typeof scheme === 'string' 30 | ? generatePatternString(scheme, 'https?') 31 | : 'https?' 32 | 33 | let hostPattern: string 34 | if (simpleScheme) { 35 | hostPattern = '' 36 | } else if (typeof host === 'string') { 37 | // Three possible formats: *, *.host, host 38 | if (host === '*') { 39 | hostPattern = '[^/]+' 40 | } else if (host.startsWith('*.')) { 41 | // Format: *.host 42 | hostPattern = generatePatternString(`*${host.slice(2)}`, '(?:[^/]+\\.)?') 43 | } else { 44 | // Format: host 45 | hostPattern = sanitizeRegex(host) 46 | } 47 | 48 | // Add port wildcard 49 | if (!/:[0-9]+$/.test(host)) { 50 | hostPattern += '(?::[0-9]+)?' 51 | } 52 | } else { 53 | // A theoretically impossible case, but used as a fallback 54 | // if for some reason input validation did not catch it 55 | hostPattern = '[^/]+' 56 | } 57 | 58 | let pathPattern: string 59 | if (simplePath) { 60 | pathPattern = generatePatternString(simplePath, '.*') 61 | } else if (typeof path === 'string') { 62 | pathPattern = `(?:/${generatePatternString(path, '.*')})?` 63 | } else { 64 | pathPattern = '(?:/.*)?' 65 | } 66 | 67 | return new RegExp(`^${schemePattern}://${hostPattern}${pathPattern}$`) 68 | } 69 | -------------------------------------------------------------------------------- /src/components/GroupTag.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 33 | 45 | {{ displayTitle }} 46 | 47 | 48 | 49 | 85 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from '@playwright/test' 2 | import { devices } from '@playwright/test' 3 | 4 | /** 5 | * Read environment variables from file. 6 | * https://github.com/motdotla/dotenv 7 | */ 8 | // require('dotenv').config(); 9 | 10 | /** 11 | * See https://playwright.dev/docs/test-configuration. 12 | */ 13 | const config: PlaywrightTestConfig = { 14 | testDir: './src/test/browser', 15 | /* Maximum time one test can run for. */ 16 | timeout: 30 * 1000, 17 | expect: { 18 | /** 19 | * Maximum time expect() should wait for the condition to be met. 20 | * For example in `await expect(locator).toHaveText();` 21 | */ 22 | timeout: 5000 23 | }, 24 | /* Run tests in files in parallel */ 25 | fullyParallel: true, 26 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 27 | forbidOnly: !!process.env.CI, 28 | /* Retry on CI only */ 29 | retries: process.env.CI ? 2 : 0, 30 | /* Opt out of parallel tests on CI. */ 31 | workers: process.env.CI ? 1 : undefined, 32 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 33 | reporter: 'html', 34 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 35 | use: { 36 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ 37 | actionTimeout: 0, 38 | /* Base URL to use in actions like `await page.goto('/')`. */ 39 | // baseURL: 'http://localhost:3000', 40 | 41 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 42 | trace: 'on-first-retry' 43 | }, 44 | 45 | /* Configure projects for major browsers */ 46 | projects: [ 47 | { 48 | name: 'chromium', 49 | use: { 50 | ...devices['Desktop Chrome'] 51 | } 52 | } 53 | ], 54 | 55 | /* Folder for test artifacts such as screenshots, videos, traces, etc. */ 56 | // outputDir: 'test-results/', 57 | 58 | /* Run your local dev server before starting the tests */ 59 | webServer: { 60 | command: 'npm run dev:options', 61 | port: 6655, 62 | reuseExistingServer: true 63 | } 64 | } 65 | 66 | export default config 67 | -------------------------------------------------------------------------------- /src/util/group-creation-tracker.ts: -------------------------------------------------------------------------------- 1 | import { useReadonlyChromeWindows } from '@/composables' 2 | import { watch } from 'vue' 3 | import { Queue, queueUntilResolve } from './queue-until-resolve' 4 | import { GroupConfiguration } from './types' 5 | 6 | /** 7 | * The group creation tracker keeps track of groups in the (asynchronous) making 8 | * This is needed when assigning multiple tabs to the same group in quick succession 9 | * because otherwise multiple groups of the same name/color would be created 10 | */ 11 | export class GroupCreationTracker { 12 | #groupsInTheMaking = new Map>>() 13 | 14 | constructor() { 15 | const chromeWindows = useReadonlyChromeWindows() 16 | 17 | // Clean up maps with destroyed windows 18 | watch(chromeWindows.lastRemoved, removedWindowId => { 19 | this.#groupsInTheMaking.delete(removedWindowId!) 20 | }) 21 | } 22 | 23 | #getOrCreateWindowMap(windowId: number) { 24 | if (!this.#groupsInTheMaking.has(windowId)) { 25 | this.#groupsInTheMaking.set(windowId, new Map()) 26 | } 27 | 28 | return this.#groupsInTheMaking.get(windowId) 29 | } 30 | 31 | isCreating(windowId: number, group: GroupConfiguration) { 32 | return this.#groupsInTheMaking.get(windowId)?.has(group.id) ?? false 33 | } 34 | 35 | getCreationPromise(windowId: number, group: GroupConfiguration) { 36 | if (this.isCreating(windowId, group)) { 37 | return this.#groupsInTheMaking.get(windowId)!.get(group.id)!.promise 38 | } 39 | 40 | throw new Error( 41 | 'Cannot get group creation Promise for a group that is not currently being created.' 42 | ) 43 | } 44 | 45 | queueGroupCreation( 46 | windowId: number, 47 | group: GroupConfiguration, 48 | creationPromise: Promise 49 | ) { 50 | if (this.isCreating(windowId, group)) { 51 | this.#groupsInTheMaking 52 | .get(windowId)! 53 | .get(group.id)! 54 | .push(creationPromise) 55 | } else { 56 | const map = this.#getOrCreateWindowMap(windowId)! 57 | const queue = queueUntilResolve(creationPromise) 58 | map.set(group.id, queue) 59 | 60 | queue.promise.then(() => { 61 | map.delete(group.id) 62 | }) 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/components/Popup/Suggestion.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 58 | 59 | {{ msg.suggestionTitle }}: 60 | 61 | 62 | 67 | {{ option.description }} 68 | 69 | 70 | 71 | 72 | 73 | 74 | 88 | -------------------------------------------------------------------------------- /src/components/Dialog/OverlayDialog.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 112 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "dev-legacy:background": "rollup -c rollup.config.js --watch", 4 | "build-legacy:background": "rollup -c rollup.config.js", 5 | "dev:options": "vite --host", 6 | "dev:background": "esbuild src/background.ts --bundle --outfile=extension/background.js --watch", 7 | "dev:test": "vitest", 8 | "build:options": "vite build", 9 | "build:check": "vue-tsc --noEmit", 10 | "build:background": "esbuild src/background.ts --bundle --outfile=extension/background.js", 11 | "test:ts": "vue-tsc --noEmit", 12 | "test:eslint": "eslint \"src/**/*.{vue,ts}\"", 13 | "test:unit": "vitest run", 14 | "test:browser": "playwright test", 15 | "test": "run-s test:ts test:eslint test:unit test:browser", 16 | "dev": "run-p dev:options dev:background", 17 | "build": "run-s build:options build:background", 18 | "pack": "node pack", 19 | "deploy": "run-s test build:options build:background pack" 20 | }, 21 | "dependencies": { 22 | "@a11y/focus-trap": "^1.0.5", 23 | "@babel/types": "^7.23.0", 24 | "@material/mwc-button": "^0.27.0", 25 | "@material/mwc-checkbox": "^0.27.0", 26 | "@material/mwc-fab": "^0.27.0", 27 | "@material/mwc-formfield": "^0.27.0", 28 | "@material/mwc-icon-button": "^0.27.0", 29 | "@material/mwc-list": "^0.27.0", 30 | "@material/mwc-radio": "^0.27.0", 31 | "@material/mwc-select": "^0.27.0", 32 | "@material/mwc-snackbar": "^0.27.0", 33 | "@material/mwc-switch": "^0.27.0", 34 | "@material/mwc-textarea": "^0.27.0", 35 | "@material/mwc-textfield": "^0.27.0", 36 | "@material/mwc-top-app-bar": "^0.27.0", 37 | "@material/mwc-top-app-bar-fixed": "^0.27.0", 38 | "@vueuse/core": "^10.5.0", 39 | "@vueuse/shared": "^10.5.0", 40 | "destr": "^2.0.1", 41 | "gsap": "^3.12.2", 42 | "monomitter": "^2.0.0", 43 | "php-date": "^4.0.1", 44 | "pinia": "^2.1.7", 45 | "vue": "^3.3.4", 46 | "vuedraggable": "^4.1.0", 47 | "zod": "^3.22.4" 48 | }, 49 | "devDependencies": { 50 | "@playwright/test": "^1.39.0", 51 | "@types/chrome": "^0.0.246", 52 | "@typescript-eslint/eslint-plugin": "^6.7.5", 53 | "@typescript-eslint/parser": "^6.7.5", 54 | "@vitejs/plugin-vue": "^4.4.0", 55 | "@vue/compiler-sfc": "^3.3.4", 56 | "archiver": "^6.0.1", 57 | "esbuild": "^0.19.4", 58 | "eslint": "^8.51.0", 59 | "eslint-config-prettier": "^9.0.0", 60 | "eslint-plugin-vue": "^9.17.0", 61 | "npm-run-all": "^4.1.5", 62 | "sass": "^1.69.3", 63 | "type-fest": "^4.4.0", 64 | "typescript": "^5.2.2", 65 | "vite": "^4.4.11", 66 | "vitest": "^0.34.6", 67 | "vue-tsc": "^1.8.19" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/components/Form/Textarea.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | 19 | 20 | 21 | 26 | 27 | 101 | 102 | 111 | -------------------------------------------------------------------------------- /src/util/base64.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | /** 4 | * Convert between Uint8Array and Base64 strings 5 | * Allows for any encoded JS string to be converted (as opposed to atob()/btoa() which only supports latin1) 6 | * 7 | * Original implementation by madmurphy on MDN 8 | * @see https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding#Solution_1_–_JavaScript%27s_UTF-16_%3E_base64 9 | */ 10 | 11 | function b64ToUint6(nChr: number) { 12 | return nChr > 64 && nChr < 91 13 | ? nChr - 65 14 | : nChr > 96 && nChr < 123 15 | ? nChr - 71 16 | : nChr > 47 && nChr < 58 17 | ? nChr + 4 18 | : nChr === 43 19 | ? 62 20 | : nChr === 47 21 | ? 63 22 | : 0 23 | } 24 | 25 | export function decodeToArray(base64string: string, blockSize: number) { 26 | var sB64Enc = base64string.replace(/[^A-Za-z0-9\+\/]/g, ''), 27 | nInLen = sB64Enc.length, 28 | nOutLen = blockSize 29 | ? Math.ceil(((nInLen * 3 + 1) >>> 2) / blockSize) * blockSize 30 | : (nInLen * 3 + 1) >>> 2, 31 | aBytes = new Uint8Array(nOutLen) 32 | 33 | for ( 34 | var nMod3, nMod4, nUint24 = 0, nOutIdx = 0, nInIdx = 0; 35 | nInIdx < nInLen; 36 | nInIdx++ 37 | ) { 38 | nMod4 = nInIdx & 3 39 | nUint24 |= b64ToUint6(sB64Enc.charCodeAt(nInIdx)) << (18 - 6 * nMod4) 40 | if (nMod4 === 3 || nInLen - nInIdx === 1) { 41 | for (nMod3 = 0; nMod3 < 3 && nOutIdx < nOutLen; nMod3++, nOutIdx++) { 42 | aBytes[nOutIdx] = (nUint24 >>> ((16 >>> nMod3) & 24)) & 255 43 | } 44 | nUint24 = 0 45 | } 46 | } 47 | 48 | return aBytes 49 | } 50 | 51 | function uint6ToB64(nUint6: number) { 52 | return nUint6 < 26 53 | ? nUint6 + 65 54 | : nUint6 < 52 55 | ? nUint6 + 71 56 | : nUint6 < 62 57 | ? nUint6 - 4 58 | : nUint6 === 62 59 | ? 43 60 | : nUint6 === 63 61 | ? 47 62 | : 65 63 | } 64 | 65 | export function encodeFromArray(bytes: Uint8Array) { 66 | var eqLen = (3 - (bytes.length % 3)) % 3, 67 | sB64Enc = '' 68 | 69 | for ( 70 | var nMod3, nLen = bytes.length, nUint24 = 0, nIdx = 0; 71 | nIdx < nLen; 72 | nIdx++ 73 | ) { 74 | nMod3 = nIdx % 3 75 | /* Uncomment the following line in order to split the output in lines 76-character long: */ 76 | /* 77 | if (nIdx > 0 && (nIdx * 4 / 3) % 76 === 0) { sB64Enc += "\r\n"; } 78 | */ 79 | nUint24 |= bytes[nIdx] << ((16 >>> nMod3) & 24) 80 | if (nMod3 === 2 || bytes.length - nIdx === 1) { 81 | sB64Enc += String.fromCharCode( 82 | uint6ToB64((nUint24 >>> 18) & 63), 83 | uint6ToB64((nUint24 >>> 12) & 63), 84 | uint6ToB64((nUint24 >>> 6) & 63), 85 | uint6ToB64(nUint24 & 63) 86 | ) 87 | nUint24 = 0 88 | } 89 | } 90 | 91 | return eqLen === 0 92 | ? sB64Enc 93 | : sB64Enc.substring(0, sB64Enc.length - eqLen) + (eqLen === 1 ? '=' : '==') 94 | } 95 | -------------------------------------------------------------------------------- /src/composables/chrome/storage.ts: -------------------------------------------------------------------------------- 1 | import { tickResetRef, toRawDeep } from '@/composables' 2 | import { readStorage, watchStorage, writeStorage } from '@/util/storage' 3 | import { watchThrottled } from '@vueuse/core' 4 | import { JsonValue } from 'type-fest' 5 | import { ref, Ref, watch, WatchSource } from 'vue' 6 | import { ignoreChromeRuntimeEvents } from './util' 7 | 8 | const storageHandles = { 9 | sync: new Map(), 10 | local: new Map(), 11 | managed: new Map(), 12 | session: new Map() 13 | } 14 | 15 | export type UseStorageOptions = Partial<{ 16 | storage: chrome.storage.AreaName 17 | throttle: number 18 | loadMapper?: (value: any) => T 19 | saveMapper?: (value: T) => any 20 | }> 21 | 22 | /** 23 | * Reactive storage handling 24 | * 25 | * @param {string} key 26 | * @param {object} options 27 | * @returns {object} 28 | */ 29 | export function useStorage( 30 | key: string, 31 | fallback: T, 32 | { 33 | storage = 'sync', 34 | throttle = 0, 35 | loadMapper, 36 | saveMapper 37 | }: UseStorageOptions = {} 38 | ): { 39 | loaded: Ref 40 | data: Ref 41 | } { 42 | const storageHandle = storageHandles[storage] 43 | 44 | if (!storageHandle.has(key)) { 45 | const loaded = ref(false) 46 | const data = ref(fallback) 47 | const changedFromStorage = tickResetRef(false) 48 | 49 | readStorage(key, storage).then(value => { 50 | loaded.value = true 51 | 52 | if (typeof loadMapper === 'function') { 53 | value = loadMapper(value) 54 | } 55 | 56 | if (typeof value !== 'undefined') { 57 | changedFromStorage.value = true 58 | data.value = value 59 | } 60 | 61 | watchStorage( 62 | key, 63 | newValue => { 64 | if (ignoreChromeRuntimeEvents.value) { 65 | return 66 | } 67 | 68 | changedFromStorage.value = true 69 | 70 | if (typeof loadMapper === 'function') { 71 | newValue = loadMapper(newValue) 72 | } 73 | 74 | data.value = newValue 75 | }, 76 | storage 77 | ) 78 | }) 79 | 80 | const watcher = (newValue: U) => { 81 | if (changedFromStorage.value) return 82 | newValue = toRawDeep(newValue) 83 | 84 | if (typeof saveMapper === 'function') { 85 | newValue = saveMapper(newValue as unknown as T) 86 | } 87 | 88 | writeStorage(key, newValue, storage) 89 | } 90 | 91 | if (throttle > 0) { 92 | watchThrottled(data, watcher as any, { deep: true, throttle }) 93 | } else { 94 | watch(data as unknown as WatchSource, watcher, { deep: true }) 95 | } 96 | 97 | storageHandle.set(key, { loaded, data }) 98 | } 99 | 100 | return storageHandle.get(key) 101 | } 102 | -------------------------------------------------------------------------------- /src/components/Group.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | 17 | 18 | 19 | { 23 | localMatchers.splice(data.length) 24 | 25 | data.forEach((value, index) => { 26 | localMatchers[index] = value 27 | }) 28 | 29 | emit('update:matchers', localMatchers) 30 | } 31 | " 32 | :model-value="localMatchers" 33 | /> 34 | 35 | 36 | 37 | 38 | 39 | 80 | 81 | 106 | -------------------------------------------------------------------------------- /src/util/storage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Promisified storage reading. 3 | * Uses Chrome storage API if available, falls back to localStorage. 4 | */ 5 | export async function readStorage( 6 | key: string, 7 | storage: chrome.storage.AreaName 8 | ) { 9 | if (typeof chrome.storage === 'undefined') { 10 | const result = window.localStorage.getItem(key) 11 | return result ? JSON.parse(result) : undefined 12 | } else { 13 | return (await chrome.storage[storage].get([key]))?.[key] 14 | } 15 | } 16 | 17 | export type WatchStorageCallback = ( 18 | newValue: T, 19 | oldValue: T | undefined 20 | ) => void 21 | 22 | /** 23 | * Watch a storage key for changes. 24 | * Uses Chrome storage API if available, falls back to localStorage. 25 | */ 26 | export function watchStorage( 27 | key: string, 28 | callback: WatchStorageCallback, 29 | storage: chrome.storage.AreaName = 'sync' 30 | ) { 31 | if (typeof chrome.storage === 'undefined') { 32 | const handler = (event: StorageEvent) => { 33 | if (event.storageArea !== window.localStorage) return 34 | if (event.key !== key) return 35 | 36 | if (event.oldValue === event.newValue) return 37 | 38 | const oldValue = event.oldValue ? JSON.parse(event.oldValue) : undefined 39 | const newValue = event.newValue ? JSON.parse(event.newValue) : undefined 40 | 41 | callback(newValue, oldValue) 42 | } 43 | 44 | window.addEventListener('storage', handler) 45 | 46 | return () => window.removeEventListener('storage', handler) 47 | } else { 48 | const handler = ( 49 | changes: { [key: string]: any }, 50 | area: chrome.storage.AreaName 51 | ) => { 52 | if (area !== storage || !(key in changes)) return 53 | const { newValue, oldValue } = changes[key] 54 | if (JSON.stringify(newValue) === JSON.stringify(oldValue)) return 55 | callback(newValue, oldValue) 56 | } 57 | 58 | chrome.storage.onChanged.addListener(handler) 59 | 60 | return () => chrome.storage.onChanged.removeListener(handler) 61 | } 62 | } 63 | 64 | /** 65 | * Promisified storage writing. 66 | * Uses Chrome storage API if available, falls back to localStorage. 67 | */ 68 | export async function writeStorage( 69 | key: string, 70 | value: T, 71 | storage: chrome.storage.AreaName = 'sync' 72 | ) { 73 | return await new Promise(resolve => { 74 | if (typeof chrome.storage === 'undefined') { 75 | const oldValue = window.localStorage.getItem(key) 76 | const newValue = JSON.stringify(value) 77 | 78 | // localStorage fallback for testing outside an extension 79 | window.localStorage.setItem(key, newValue) 80 | 81 | // Create artifical 'storage' events to get parity with chrome storage events 82 | window.dispatchEvent( 83 | new StorageEvent('storage', { 84 | key, 85 | newValue, 86 | oldValue, 87 | storageArea: window.localStorage, 88 | url: window.location.href 89 | }) 90 | ) 91 | resolve() 92 | } else { 93 | chrome.storage[storage].set({ [key]: value }, () => resolve()) 94 | } 95 | }) 96 | } 97 | -------------------------------------------------------------------------------- /src/static/arrow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/test/browser/options/create.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test' 2 | import msg from '../../../static/_locales/en/messages.json' 3 | import { getGroups, getValue } from '../util/evaluations' 4 | 5 | test.use({ 6 | viewport: { 7 | width: 400, 8 | height: 600 9 | }, 10 | headless: false 11 | }) 12 | 13 | test('Test group creation flow', async ({ page }) => { 14 | await page.goto('http://localhost:6655/?context=options') 15 | 16 | // Find "+" button and click it 17 | await page.locator('text=add').click() 18 | 19 | // Await the dialog container to appear 20 | const dialogContainer = page.locator('.dialog-container') 21 | await expect(dialogContainer).toHaveCount(1) 22 | 23 | // Expect initial group preview to be empty and grey 24 | const previewLabel = dialogContainer.locator('.preview .group-label') 25 | expect(await previewLabel.textContent()).toBe('') 26 | 27 | const getPreviewColor = () => 28 | previewLabel.evaluate( 29 | previewLabel => getComputedStyle(previewLabel).backgroundColor 30 | ) 31 | expect(await getPreviewColor()).toBe('rgb(95, 99, 104)') 32 | 33 | // Expect initial group name input to be empty 34 | const groupNameInput = dialogContainer.locator('mwc-textfield.group-title') 35 | expect(await groupNameInput.evaluate(getValue)).toBe('') 36 | 37 | // Fill in a name and pick a color 38 | await groupNameInput.pressSequentially('Test Group') 39 | 40 | // Click the blue radio label to select the color 41 | await dialogContainer.locator(`text=${msg.colorBlue.message}`).click() 42 | 43 | // Show advanced options 44 | await dialogContainer.locator('.toggle-advanced-button').click() 45 | 46 | // Enable strict mode 47 | await dialogContainer.locator('#edit-dialog-strict').click() 48 | 49 | // Enable merge mode 50 | await dialogContainer.locator('#edit-dialog-merge').click() 51 | 52 | // Expect the preview to have updated 53 | expect(await previewLabel.textContent()).toBe('Test Group') 54 | expect(await getPreviewColor()).toBe('rgb(62, 115, 232)') 55 | 56 | // Save the new group 57 | await dialogContainer.locator(`text=${msg.buttonSave.message}`).click() 58 | 59 | // Wait for the dialog container to be gone 60 | await expect(page.locator('.dialog-container')).toHaveCount(0) 61 | 62 | // Ensure we got the groups list with one group 63 | const groupsList = page.locator('.groups') 64 | await expect(groupsList).toHaveCount(1) 65 | await expect(groupsList.locator('.group')).toHaveCount(1) 66 | 67 | // Input field for first URL pattern should appear 68 | await expect(page.locator('.pattern-list-new mwc-textfield')).toHaveCount(1) 69 | 70 | // Ensure that groups have been persisted through refresh 71 | await page.reload() 72 | 73 | const groupsListAfterRefresh = page.locator('.groups') 74 | await expect(groupsListAfterRefresh).toHaveCount(1) 75 | await expect(groupsListAfterRefresh.locator('.group')).toHaveCount(1) 76 | 77 | // Validate groups structure in storage 78 | expect(await page.evaluate(getGroups)).toMatchObject([ 79 | { 80 | id: expect.any(String), 81 | title: 'Test Group', 82 | color: 'blue', 83 | matchers: [], 84 | options: { strict: true, merge: true } 85 | } 86 | ]) 87 | }) 88 | -------------------------------------------------------------------------------- /src/components/Form/Textfield.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | 19 | 20 | 21 | 26 | 27 | 120 | 121 | 130 | -------------------------------------------------------------------------------- /src/composables/chrome/tab-groups.ts: -------------------------------------------------------------------------------- 1 | import { whenever } from '@vueuse/core' 2 | import { computed, readonly, ref } from 'vue' 3 | import { ignoreChromeRuntimeEvents } from './util' 4 | import { useReadonlyChromeWindows } from './windows' 5 | 6 | function _useReadonlyChromeTabGroups() { 7 | const tabGroups = ref([]) 8 | const loaded = ref(false) 9 | const lastCreated = ref(undefined) 10 | const lastUpdated = ref(undefined) 11 | const lastRemoved = ref(undefined) 12 | 13 | if (typeof chrome.tabGroups !== 'undefined') { 14 | const chromeWindows = useReadonlyChromeWindows() 15 | 16 | whenever(chromeWindows.loaded, async () => { 17 | const queriedTabGroups = await Promise.all( 18 | chromeWindows.items.value.map(window => 19 | chrome.tabGroups.query({ windowId: window.id }) 20 | ) 21 | ) 22 | 23 | tabGroups.value = queriedTabGroups.flat() 24 | loaded.value = true 25 | 26 | chrome.tabGroups.onCreated.addListener(createdTabGroup => { 27 | if (ignoreChromeRuntimeEvents.value) { 28 | return 29 | } 30 | 31 | lastCreated.value = createdTabGroup 32 | tabGroups.value = [...tabGroups.value, createdTabGroup] 33 | }) 34 | 35 | chrome.tabGroups.onRemoved.addListener(removedTabGroup => { 36 | if (ignoreChromeRuntimeEvents.value) { 37 | return 38 | } 39 | 40 | lastRemoved.value = removedTabGroup 41 | tabGroups.value = tabGroups.value.filter( 42 | Tab => Tab.id !== removedTabGroup.id 43 | ) 44 | }) 45 | 46 | chrome.tabGroups.onUpdated.addListener(updatedTabGroup => { 47 | console.log('UPDATE', updatedTabGroup) 48 | if (ignoreChromeRuntimeEvents.value) { 49 | return 50 | } 51 | 52 | lastUpdated.value = updatedTabGroup 53 | const index = tabGroups.value.findIndex( 54 | tabGroup => tabGroup.id === updatedTabGroup.id 55 | ) 56 | tabGroups.value = [ 57 | ...tabGroups.value.slice(0, index), 58 | updatedTabGroup, 59 | ...tabGroups.value.slice(index + 1) 60 | ] 61 | }) 62 | }) 63 | } else { 64 | loaded.value = true 65 | } 66 | 67 | return { 68 | items: readonly(tabGroups), 69 | loaded: readonly(loaded), 70 | lastCreated: readonly(lastCreated), 71 | lastUpdated: readonly(lastUpdated), 72 | lastRemoved: readonly(lastRemoved) 73 | } 74 | } 75 | 76 | let readonlyChromeTabGroups: ReturnType 77 | export function useReadonlyChromeTabGroups() { 78 | if (!readonlyChromeTabGroups) { 79 | readonlyChromeTabGroups = _useReadonlyChromeTabGroups() 80 | } 81 | return readonlyChromeTabGroups 82 | } 83 | 84 | export function useChromeTabGroupsByWindowId() { 85 | const chromeWindows = useReadonlyChromeWindows() 86 | const chromeTabGroups = useReadonlyChromeTabGroups() 87 | 88 | return computed<{ 89 | [windowId: string]: chrome.tabGroups.TabGroup[] 90 | }>(() => 91 | Object.fromEntries( 92 | chromeWindows.items.value.map(window => [ 93 | window.id, 94 | chromeTabGroups.items.value.filter( 95 | tabGroup => tabGroup.windowId === window.id 96 | ) 97 | ]) 98 | ) 99 | ) 100 | } 101 | -------------------------------------------------------------------------------- /src/components/Form/Select.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 15 | 16 | 21 | 28 | {{ option.label }} 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 43 | 44 | 130 | 131 | 140 | -------------------------------------------------------------------------------- /src/composables/chrome/tabs.ts: -------------------------------------------------------------------------------- 1 | import { whenever } from '@vueuse/core' 2 | import { computed, readonly, ref } from 'vue' 3 | import { ignoreChromeRuntimeEvents } from './util' 4 | import { useReadonlyChromeWindows } from './windows' 5 | 6 | export type TabUpdate = { 7 | changes: chrome.tabs.TabChangeInfo 8 | tab: chrome.tabs.Tab 9 | oldTab: chrome.tabs.Tab | undefined 10 | } 11 | 12 | function _useReadonlyChromeTabs() { 13 | const tabs = ref([]) 14 | const detachedTabs = ref([]) 15 | const loaded = ref(false) 16 | const lastCreated = ref(undefined) 17 | const lastUpdated = ref(undefined) 18 | const lastRemoved = ref(undefined) 19 | 20 | if (typeof chrome.tabs !== 'undefined') { 21 | const chromeWindows = useReadonlyChromeWindows() 22 | 23 | whenever(chromeWindows.loaded, async () => { 24 | const queriedTabs = await Promise.all( 25 | chromeWindows.items.value.map(window => 26 | chrome.tabs.query({ windowId: window.id }) 27 | ) 28 | ) 29 | 30 | tabs.value = queriedTabs.flat() 31 | loaded.value = true 32 | 33 | chrome.tabs.onCreated.addListener(createdTab => { 34 | if (ignoreChromeRuntimeEvents.value) { 35 | return 36 | } 37 | 38 | lastCreated.value = createdTab 39 | tabs.value = [...tabs.value, createdTab] 40 | }) 41 | 42 | chrome.tabs.onRemoved.addListener(removedTabId => { 43 | if (ignoreChromeRuntimeEvents.value) { 44 | return 45 | } 46 | 47 | lastRemoved.value = removedTabId 48 | tabs.value = tabs.value.filter(tab => tab.id !== removedTabId) 49 | }) 50 | 51 | chrome.tabs.onDetached.addListener(removedTabId => { 52 | if (ignoreChromeRuntimeEvents.value) { 53 | return 54 | } 55 | 56 | detachedTabs.value = [...detachedTabs.value, removedTabId] 57 | }) 58 | 59 | chrome.tabs.onAttached.addListener(async attachedTabId => { 60 | if (ignoreChromeRuntimeEvents.value) { 61 | return 62 | } 63 | 64 | detachedTabs.value = detachedTabs.value.filter( 65 | tabId => tabId !== attachedTabId 66 | ) 67 | }) 68 | 69 | chrome.tabs.onUpdated.addListener((tabId, changeInfo, updatedTab) => { 70 | if (ignoreChromeRuntimeEvents.value) { 71 | return 72 | } 73 | 74 | lastUpdated.value = { 75 | changes: changeInfo, 76 | tab: updatedTab, 77 | oldTab: tabs.value.find(tab => tab.id === tabId) 78 | } 79 | 80 | const index = tabs.value.findIndex(tab => tab.id === updatedTab.id) 81 | tabs.value = [ 82 | ...tabs.value.slice(0, index), 83 | updatedTab, 84 | ...tabs.value.slice(index + 1) 85 | ] 86 | }) 87 | }) 88 | } else { 89 | loaded.value = true 90 | } 91 | 92 | return { 93 | items: readonly(tabs), 94 | detachedTabs: readonly(detachedTabs), 95 | loaded: readonly(loaded), 96 | lastCreated: readonly(lastCreated), 97 | lastUpdated: readonly(lastUpdated), 98 | lastRemoved: readonly(lastRemoved) 99 | } 100 | } 101 | 102 | let readonlyChromeTabs: ReturnType 103 | export function useReadonlyChromeTabs() { 104 | if (!readonlyChromeTabs) { 105 | readonlyChromeTabs = _useReadonlyChromeTabs() 106 | } 107 | return readonlyChromeTabs 108 | } 109 | 110 | export function useChromeTabsById() { 111 | const chromeTabs = useReadonlyChromeTabs() 112 | 113 | return computed<{ 114 | [tabId: string]: chrome.tabs.Tab 115 | }>(() => Object.fromEntries(chromeTabs.items.value.map(tab => [tab.id, tab]))) 116 | } 117 | 118 | export function useChromeTabsByWindowId() { 119 | const chromeTabs = useReadonlyChromeTabs() 120 | const chromeWindows = useReadonlyChromeWindows() 121 | 122 | return computed<{ 123 | [windowId: string]: chrome.tabs.Tab[] 124 | }>(() => 125 | Object.fromEntries( 126 | chromeWindows.items.value.map(window => [ 127 | window.id, 128 | chromeTabs.items.value.filter(tab => tab.windowId === window.id) 129 | ]) 130 | ) 131 | ) 132 | } 133 | -------------------------------------------------------------------------------- /src/components/Util/SlideVertical.vue: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | 15 | 16 | 184 | -------------------------------------------------------------------------------- /src/test/browser/options/edit.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | import msg from '../../../static/_locales/en/messages.json' 3 | import { 4 | getGroups, 5 | getValue, 6 | GroupConfigurationWithoutId, 7 | isMacOS, 8 | setGroups 9 | } from '../util/evaluations' 10 | 11 | test.use({ 12 | viewport: { 13 | width: 400, 14 | height: 600 15 | }, 16 | headless: false 17 | }) 18 | 19 | test.beforeEach(async ({ page }) => { 20 | await page.goto('http://localhost:6655/?context=options') 21 | 22 | // Define groups programmatically 23 | await page.evaluate(setGroups, [ 24 | { 25 | title: 'Test Group', 26 | color: 'blue', 27 | matchers: [], 28 | options: { strict: true, merge: true } 29 | } as GroupConfigurationWithoutId 30 | ]) 31 | 32 | // Reload the page to apply localStorage changes 33 | await page.reload() 34 | }) 35 | 36 | test('Edit and save groups', async ({ page }) => { 37 | // Find "edit" button and click it 38 | await page.locator('text=edit').click() 39 | 40 | // Await the dialog container to appear 41 | const dialogContainer = page.locator('.dialog-container') 42 | await expect(dialogContainer).toHaveCount(1) 43 | 44 | // Expect initial group preview to be empty and grey 45 | const previewLabel = dialogContainer.locator('.preview .group-label') 46 | expect(await previewLabel.textContent()).toBe('Test Group') 47 | 48 | const getBackgroundColor = (element: HTMLElement | SVGElement) => 49 | getComputedStyle(element).backgroundColor 50 | expect(await previewLabel.evaluate(getBackgroundColor)).toBe( 51 | 'rgb(62, 115, 232)' 52 | ) 53 | 54 | // Expect initial group name input to be empty 55 | const groupNameInput = dialogContainer.locator('mwc-textfield.group-title') 56 | expect(await groupNameInput.evaluate(getValue)).toBe('Test Group') 57 | 58 | // Focus and override name field 59 | // await groupNameInput.click() 60 | const primaryKey = (await page.evaluate(isMacOS)) ? 'Meta' : 'Control' 61 | await groupNameInput.click() 62 | await page.keyboard.down(primaryKey) 63 | page.keyboard.press('A') 64 | await page.keyboard.up(primaryKey) 65 | 66 | await groupNameInput.press('Backspace') 67 | await groupNameInput.pressSequentially('Edited Group') 68 | 69 | // Click the red radio label to select the color 70 | await dialogContainer.locator(`text=${msg.colorRed.message}`).click() 71 | 72 | // Show advanced options 73 | await dialogContainer.locator('.toggle-advanced-button').click() 74 | 75 | // Toggle strict mode off 76 | await dialogContainer.locator('#edit-dialog-strict').click() 77 | 78 | // Toggle merge mode off 79 | await dialogContainer.locator('#edit-dialog-merge').click() 80 | 81 | // Expect the preview to have updated 82 | expect(await previewLabel.textContent()).toBe('Edited Group') 83 | expect(await previewLabel.evaluate(getBackgroundColor)).toBe( 84 | 'rgb(217, 48, 36)' 85 | ) 86 | 87 | // Save the group 88 | await dialogContainer.locator(`text=${msg.buttonSave.message}`).click() 89 | 90 | // Wait for the dialog container to be gone 91 | await expect(page.locator('.dialog-container')).toHaveCount(0) 92 | 93 | // Check tag in groups list 94 | const groupsListTag = page.locator('.groups .group .tag') 95 | await expect(groupsListTag).toHaveCount(1) 96 | expect(await groupsListTag.textContent()).toBe('Edited Group') 97 | expect(await groupsListTag.evaluate(getBackgroundColor)).toBe( 98 | 'rgb(217, 48, 36)' 99 | ) 100 | 101 | // Validate groups structure in storage 102 | expect(await page.evaluate(getGroups)).toMatchObject([ 103 | { 104 | id: expect.any(String), 105 | title: 'Edited Group', 106 | color: 'red', 107 | matchers: [], 108 | options: { strict: false, merge: false } 109 | } 110 | ]) 111 | }) 112 | 113 | test('Delete Groups and undo deletion', async ({ page }) => { 114 | // Find "edit" button and click it 115 | await page.locator('text=edit').click() 116 | 117 | // Await the dialog container to appear 118 | const dialogContainer = page.locator('.dialog-container') 119 | await expect(dialogContainer).toHaveCount(1) 120 | 121 | await dialogContainer.locator(`text=${msg.buttonDeleteGroup.message}`).click() 122 | 123 | // Expect the dialog to disappear 124 | await expect(page.locator('.dialog-container')).toHaveCount(0) 125 | 126 | // Validate that the group is also gone from storage 127 | expect(await page.evaluate(getGroups)).toEqual([]) 128 | 129 | // Press the "undo" button 130 | await page.locator(`text=${msg.undo.message}`).click() 131 | 132 | // Validate groups structure in storage 133 | expect(await page.evaluate(getGroups)).toMatchObject([ 134 | { 135 | id: expect.any(String), 136 | title: 'Test Group', 137 | color: 'blue', 138 | matchers: [], 139 | options: { strict: true, merge: true } 140 | } 141 | ]) 142 | }) 143 | -------------------------------------------------------------------------------- /src/components/Popup/GroupSelection.vue: -------------------------------------------------------------------------------- 1 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | {{ msg.popupAddCurrentGroup }} 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | {{ msg.popupCreateGroup }} 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 171 | -------------------------------------------------------------------------------- /src/test/unit/generate-matcher.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from 'vitest' 2 | import { generateMatcherRegex } from '@/util/matcher-regex' 3 | 4 | it('handles no-wildcard domain pattern', () => { 5 | const regex = generateMatcherRegex('example.com') 6 | 7 | expect(regex.test('http://example.com/')).toBe(true) 8 | expect(regex.test('https://example.com/')).toBe(true) 9 | 10 | expect(regex.test('ftp://example.com/')).toBe(false) 11 | expect(regex.test('file:///example.com/')).toBe(false) 12 | expect(regex.test('https://www.example.com/')).toBe(false) 13 | expect(regex.test('http://invalid.com/')).toBe(false) 14 | }) 15 | 16 | it('handles no-wildcard scheme + host', () => { 17 | const regex = generateMatcherRegex('https://example.com') 18 | 19 | expect(regex.test('https://example.com/')).toBe(true) 20 | 21 | expect(regex.test('http://example.com/')).toBe(false) 22 | expect(regex.test('ftp://example.com/')).toBe(false) 23 | expect(regex.test('ws://example.com/')).toBe(false) 24 | expect(regex.test('https://www.example.com/')).toBe(false) 25 | expect(regex.test('http://invalid.com/')).toBe(false) 26 | }) 27 | 28 | it('handles no-wildcard scheme + host + root path', () => { 29 | const regex = generateMatcherRegex('https://example.com/') 30 | 31 | expect(regex.test('https://example.com/')).toBe(true) 32 | 33 | expect(regex.test('http://example.com/')).toBe(false) 34 | expect(regex.test('ftp://example.com/')).toBe(false) 35 | expect(regex.test('ws://example.com/')).toBe(false) 36 | expect(regex.test('https://www.example.com/')).toBe(false) 37 | expect(regex.test('http://invalid.com/')).toBe(false) 38 | }) 39 | 40 | it('handles match-all pattern', () => { 41 | const regex = generateMatcherRegex('*') 42 | 43 | expect(regex.test('')).toBe(true) 44 | expect(regex.test('chrome://settings')).toBe(true) 45 | expect(regex.test('file:///')).toBe(true) 46 | expect(regex.test('data:text/plain;charset=utf8,')).toBe(true) 47 | 48 | expect(regex.test('http://example.com/')).toBe(true) 49 | expect(regex.test('https://example.com/')).toBe(true) 50 | expect(regex.test('https://example.com:8080/')).toBe(true) 51 | expect(regex.test('https://subdomain.example.com/')).toBe(true) 52 | expect(regex.test('https://example.com:8080/')).toBe(true) 53 | expect(regex.test('https://example.com/path')).toBe(true) 54 | expect(regex.test('ftp://example.com/')).toBe(true) 55 | }) 56 | 57 | it('handles scheme wildcard', () => { 58 | const regex = generateMatcherRegex('*://example.com') 59 | 60 | expect(regex.test('http://example.com/')).toBe(true) 61 | expect(regex.test('https://example.com/')).toBe(true) 62 | expect(regex.test('https://example.com/with/path/')).toBe(true) 63 | 64 | expect(regex.test('ftp://example.com/')).toBe(false) 65 | expect(regex.test('http://invalid.com/')).toBe(false) 66 | expect(regex.test('https://invalid.com/')).toBe(false) 67 | }) 68 | 69 | it('handles host wildcards', () => { 70 | it('subdomain wildcard', () => { 71 | const regex = generateMatcherRegex('*.example.com') 72 | 73 | expect(regex.test('http://example.com/')).toBe(true) 74 | expect(regex.test('https://example.com/')).toBe(true) 75 | expect(regex.test('https://example.com/with/path/')).toBe(true) 76 | 77 | expect(regex.test('ftp://example.com/')).toBe(false) 78 | expect(regex.test('http://invalid.com/')).toBe(false) 79 | expect(regex.test('https://invalid.com/')).toBe(false) 80 | }) 81 | 82 | it('arbitrary host wildcard', () => { 83 | const regex = generateMatcherRegex('ex*.com') 84 | 85 | expect(regex.test('http://example.com/')).toBe(true) 86 | expect(regex.test('https://example.com/')).toBe(true) 87 | expect(regex.test('https://exodus.com/')).toBe(true) 88 | expect(regex.test('https://example.com/with/path/')).toBe(true) 89 | 90 | expect(regex.test('ftp://example.com/')).toBe(false) 91 | expect(regex.test('http://invalid.com/')).toBe(false) 92 | expect(regex.test('https://invalid.com/')).toBe(false) 93 | }) 94 | }) 95 | 96 | it('handles path wildcard', () => { 97 | const regex = generateMatcherRegex('example.com/foo/*') 98 | 99 | expect(regex.test('https://example.com/foo/')).toBe(true) 100 | expect(regex.test('https://example.com/foo/bar/baz')).toBe(true) 101 | 102 | expect(regex.test('http://example.com/bar/')).toBe(false) 103 | expect(regex.test('ftp://example.com/bar/')).toBe(false) 104 | expect(regex.test('http://invalid.com/foo/')).toBe(false) 105 | expect(regex.test('https://invalid.com/foo/')).toBe(false) 106 | }) 107 | 108 | it('handles simple scheme', () => { 109 | const regex = generateMatcherRegex('file:///foo/*') 110 | 111 | expect(regex.test('file:///foo/')).toBe(true) 112 | expect(regex.test('file:///foo/bar.txt')).toBe(true) 113 | 114 | expect(regex.test('file:///bar/baz')).toBe(false) 115 | expect(regex.test('https://example.com/foo/')).toBe(false) 116 | expect(regex.test('https://example.com/foo/bar/baz')).toBe(false) 117 | expect(regex.test('http://example.com/bar/')).toBe(false) 118 | expect(regex.test('ftp://example.com/bar/')).toBe(false) 119 | expect(regex.test('http://invalid.com/foo/')).toBe(false) 120 | expect(regex.test('https://invalid.com/foo/')).toBe(false) 121 | }) 122 | -------------------------------------------------------------------------------- /src/test/browser/options/url-patterns.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | import { 3 | getGroups, 4 | getValue, 5 | GroupConfigurationWithoutId, 6 | isFocused, 7 | isValid, 8 | setGroups 9 | } from '../util/evaluations' 10 | 11 | test.use({ 12 | viewport: { 13 | width: 400, 14 | height: 600 15 | }, 16 | headless: false 17 | }) 18 | 19 | test.beforeEach(async ({ page }) => { 20 | await page.goto('http://localhost:6655/?context=options') 21 | 22 | // Define groups programmatically 23 | await page.evaluate(setGroups, [ 24 | { 25 | title: 'Test Group', 26 | color: 'blue', 27 | matchers: [], 28 | options: { strict: true, merge: false } 29 | } as GroupConfigurationWithoutId 30 | ]) 31 | 32 | // Reload the page to apply localStorage changes 33 | await page.reload() 34 | }) 35 | 36 | test('Add URL patterns via "add link" button and Enter key', async ({ 37 | page 38 | }) => { 39 | // No inputs should be there initially 40 | const urlPatternInputs = page.locator('mwc-textfield.textfield') 41 | await expect(urlPatternInputs).toHaveCount(0) 42 | 43 | // Find "add link" button and click it 44 | const addLinkButton = page.locator('text=add_link') 45 | await addLinkButton.click() 46 | 47 | // After clicking the button, there should be one input 48 | const urlPatternInput = page.locator('mwc-textfield.textfield') 49 | await expect(urlPatternInput).toHaveCount(1) 50 | 51 | await urlPatternInput.pressSequentially('example.com') 52 | 53 | // Expect a new empty, focused input to appear after pressing enter 54 | await urlPatternInput.press('Enter') 55 | const urlPatternInputsAfterEnter = page.locator('mwc-textfield.textfield') 56 | await expect(urlPatternInputsAfterEnter).toHaveCount(2) 57 | 58 | const lasturlPatternInputAfterEnter = urlPatternInputsAfterEnter.last() 59 | await expect(await lasturlPatternInputAfterEnter.evaluate(isFocused)).toBe( 60 | true 61 | ) 62 | 63 | // Expect the new input to disappear again ater pressing Backspace 64 | await lasturlPatternInputAfterEnter.press('Backspace') 65 | 66 | const urlPatternInputsAfterBackspace = page.locator('mwc-textfield.textfield') 67 | await expect(urlPatternInputsAfterBackspace).toHaveCount(1) 68 | await expect(await urlPatternInputsAfterBackspace.evaluate(getValue)).toBe( 69 | 'example.com' 70 | ) 71 | 72 | // Expect a new empty, focused input to appear after clicking the "add link" button 73 | await addLinkButton.click() 74 | 75 | const urlPatternInputsAfterAddLink = page.locator('mwc-textfield.textfield') 76 | await expect(urlPatternInputsAfterAddLink).toHaveCount(2) 77 | 78 | const lasturlPatternInputAfterAddLink = urlPatternInputsAfterAddLink.last() 79 | await expect(await lasturlPatternInputAfterAddLink.evaluate(isFocused)).toBe( 80 | true 81 | ) 82 | 83 | // Expect the new empty input to disappear again when focusing anything else 84 | await page.click('body') 85 | 86 | await expect(page.locator('mwc-textfield.textfield')).toHaveCount(1) 87 | 88 | // Validate groups structure in storage 89 | expect(await page.evaluate(getGroups)).toMatchObject([ 90 | { 91 | id: expect.any(String), 92 | title: 'Test Group', 93 | color: 'blue', 94 | matchers: ['example.com'], 95 | options: { strict: true } 96 | } 97 | ]) 98 | }) 99 | 100 | test('Report error on invalid pattern and prevent from adding new ones', async ({ 101 | page 102 | }) => { 103 | // No inputs should be there initially 104 | const urlPatternInputs = page.locator('mwc-textfield.textfield') 105 | await expect(urlPatternInputs).toHaveCount(0) 106 | 107 | // Find "add link" button and click it 108 | const addLinkButton = page.locator('text=add_link') 109 | await addLinkButton.click() 110 | 111 | // After clicking the button, there should be one input 112 | const urlPatternInput = page.locator('mwc-textfield.textfield') 113 | await expect(urlPatternInput).toHaveCount(1) 114 | 115 | // Insert an invalid pattern and press enter 116 | await urlPatternInput.pressSequentially(':') 117 | await urlPatternInput.press('Enter') 118 | 119 | // Expect the input to be invalid and no other inputs to appear 120 | await urlPatternInput.press('Enter') 121 | expect(await urlPatternInput.evaluate(isFocused)).toBe(true) 122 | expect(await urlPatternInput.evaluate(getValue)).toBe(':') 123 | expect(await urlPatternInput.evaluate(isValid)).toBe(false) 124 | 125 | const urlPatternInputsAfterEnter = page.locator('mwc-textfield.textfield') 126 | await expect(urlPatternInputsAfterEnter).toHaveCount(1) 127 | 128 | // Even pressing the "add link" button should not add new textfields 129 | await addLinkButton.click() 130 | const urlPatternInputsAfterAddLink = page.locator('mwc-textfield.textfield') 131 | await expect(urlPatternInputsAfterAddLink).toHaveCount(1) 132 | 133 | // URL patterns should not have been saved 134 | await page.click('body') 135 | 136 | // Validate groups structure in storage 137 | expect(await page.evaluate(getGroups)).toMatchObject([ 138 | { 139 | id: expect.any(String), 140 | title: 'Test Group', 141 | color: 'blue', 142 | matchers: [], 143 | options: { strict: true } 144 | } 145 | ]) 146 | }) 147 | -------------------------------------------------------------------------------- /src/components/TabBar.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 13 | 20 | 27 | 28 | {{ tabTitle }} 29 | 36 | 43 | 44 | 45 | 46 | 47 | 48 | 75 | 76 | 189 | -------------------------------------------------------------------------------- /src/util/derive-matcher-options.ts: -------------------------------------------------------------------------------- 1 | import { Translation } from './types' 2 | 3 | export type MatcherOptions = { 4 | patterns: string[] 5 | description: string 6 | } 7 | 8 | function deriveChromeMatcherOptions( 9 | rawUrl: string, 10 | url: URL, 11 | msg: Translation 12 | ) { 13 | const options: MatcherOptions[] = [] 14 | 15 | const [initialGroup] = url.host.split('/') 16 | const [, secondaryGroup] = url.pathname.split('/') 17 | 18 | const initialGroupPatterns = [ 19 | `chrome://${initialGroup}`, 20 | `chrome://${initialGroup}/*` 21 | ] 22 | 23 | const wellKnownInitialGroupNames = { 24 | extensions: msg.derivedNameChromeExtensions, 25 | downloads: msg.derivedNameChromeDownloads, 26 | history: msg.derivedNameChromeHistory, 27 | bookmarks: msg.derivedNameChromeBookmarks, 28 | apps: msg.derivedNameChromeApps, 29 | flags: msg.derivedNameChromeFlags 30 | } 31 | 32 | if (initialGroup in wellKnownInitialGroupNames) { 33 | options.push({ 34 | patterns: initialGroupPatterns, 35 | description: 36 | wellKnownInitialGroupNames[ 37 | initialGroup as keyof typeof wellKnownInitialGroupNames 38 | ] 39 | }) 40 | } else if (initialGroup === 'settings') { 41 | if (secondaryGroup) { 42 | options.push({ 43 | patterns: [ 44 | `chrome://settings/${secondaryGroup}`, 45 | `chrome://settings/${secondaryGroup}/*` 46 | ], 47 | description: msg.derivedNameChromeSettingsSection 48 | }) 49 | } 50 | 51 | options.push({ 52 | patterns: initialGroupPatterns, 53 | description: msg.derivedNameChromeSettings 54 | }) 55 | } else if (initialGroup === 'newtab') { 56 | options.push({ 57 | patterns: [rawUrl], 58 | description: msg.derivedNameChromeNewtab 59 | }) 60 | } else { 61 | options.push({ 62 | patterns: initialGroupPatterns, 63 | description: `${msg.derivedNameChromePage} "${initialGroup}"` 64 | }) 65 | } 66 | 67 | options.push({ 68 | patterns: ['chrome://*'], 69 | description: msg.derivedNameChromeAll 70 | }) 71 | 72 | if (!options.some(({ patterns }) => patterns.includes(rawUrl))) { 73 | options.push({ 74 | patterns: [rawUrl], 75 | description: msg.derivedNameChromeExactUrl 76 | }) 77 | } 78 | 79 | return options 80 | } 81 | 82 | function deriveExtensionMatcherOptions( 83 | rawUrl: string, 84 | url: URL, 85 | msg: Translation 86 | ) { 87 | return [ 88 | { 89 | patterns: [ 90 | `${url.protocol}//${url.host || url.pathname.slice(2)}`, 91 | `${url.protocol}//${url.host || url.pathname.slice(2)}/*` 92 | ], 93 | description: msg.derivedNameExtensionHost 94 | }, 95 | { 96 | patterns: [`${url.protocol}//*`], 97 | description: msg.derivedNameExtensionAll 98 | }, 99 | { 100 | patterns: [rawUrl], 101 | description: msg.derivedNameExtensionExactUrl 102 | } 103 | ] 104 | } 105 | 106 | function deriveFileMatcherOptions(rawUrl: string, url: URL, msg: Translation) { 107 | const options: MatcherOptions[] = [] 108 | 109 | const pathParts = url.pathname.split('/') 110 | const basename = pathParts.at(-1) 111 | const dirnamePath = pathParts.slice(0, -1).join('/') 112 | const dirname = pathParts.at(-2) 113 | 114 | options.push({ 115 | patterns: ['file://*'], 116 | description: msg.derivedNameFileAll 117 | }) 118 | 119 | if (dirnamePath) { 120 | options.push({ 121 | patterns: [`file://${dirnamePath}/*`], 122 | description: basename 123 | ? msg.derivedNameFileDirname.replace('%s', dirname!) 124 | : msg.derivedNameFileThisFolder 125 | }) 126 | } 127 | 128 | options.push({ 129 | patterns: [rawUrl], 130 | description: basename 131 | ? msg.derivedNameFileExactFileUrl 132 | : msg.derivedNameFileExactFolderUrl 133 | }) 134 | 135 | return options 136 | } 137 | 138 | function deriveHttpMatcherOptions(rawUrl: string, url: URL, msg: Translation) { 139 | return [ 140 | { 141 | patterns: [`${url.host}`], 142 | description: msg.derivedNameHttpDomain 143 | }, 144 | { 145 | patterns: [`*.${url.host}`], 146 | description: msg.derivedNameHttpSubdomain 147 | }, 148 | { 149 | patterns: [rawUrl], 150 | description: msg.derivedNameGenericExactUrl 151 | } 152 | ] 153 | } 154 | 155 | export function deriveMatcherOptions( 156 | rawUrl: string, 157 | msg: Translation 158 | ): MatcherOptions[] { 159 | let url: URL 160 | try { 161 | url = new URL(rawUrl) 162 | } catch { 163 | return [] 164 | } 165 | 166 | // chrome:// URLs 167 | if (url.protocol === 'chrome:') { 168 | return deriveChromeMatcherOptions(rawUrl, url, msg) 169 | } 170 | 171 | // extension:// and chrome-extension:// URLs 172 | if (/^(chrome-)?extension:$/.test(url.protocol)) { 173 | return deriveExtensionMatcherOptions(rawUrl, url, msg) 174 | } 175 | 176 | // file:// URLs 177 | if (url.protocol === 'file:') { 178 | return deriveFileMatcherOptions(rawUrl, url, msg) 179 | } 180 | 181 | // http(s):// URLs 182 | if (/^https?:$/.test(url.protocol)) { 183 | return deriveHttpMatcherOptions(rawUrl, url, msg) 184 | } 185 | 186 | // Generic fallback for all other URLs 187 | return [ 188 | { 189 | patterns: [rawUrl], 190 | description: msg.derivedNameGenericExactUrl 191 | } 192 | ] 193 | } 194 | -------------------------------------------------------------------------------- /src/components/GroupHeader.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | warning 10 | 11 | 12 | 13 | 19 | 25 | 26 | drag_indicator 27 | 28 | 29 | 30 | 41 | 42 | 43 | 44 | 97 | 98 | 207 | -------------------------------------------------------------------------------- /src/components/Dialog/TransferDialog.vue: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | Drop file to import 14 | 15 | 16 | 17 | {{ msg.headlineExport }} 18 | 19 | 20 | 21 | {{ msg.buttonExport }} 22 | 23 | 24 | 25 | 26 | 27 | {{ msg.headlineImport }} 28 | 29 | 30 | 31 | 37 | 38 | {{ msg.buttonImport }} 39 | 40 | 41 | warning 42 | {{ msg.importDiscardWarning }} 43 | 44 | 45 | 46 | {{ errorMessage }} 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 179 | 180 | 219 | -------------------------------------------------------------------------------- /src/Popup.vue: -------------------------------------------------------------------------------- 1 | 160 | 161 | 162 | 163 | 164 | 165 | {{ msg.popupHeadline }} 166 | 167 | 168 | 176 | 177 | 178 | 184 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | {{ msg.popupMoreOptions }} 200 | 201 | 202 | 207 | 208 | 209 | 210 | 211 | 212 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 252 | -------------------------------------------------------------------------------- /src/Layout.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 255 | -------------------------------------------------------------------------------- /src/components/PatternList.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 28 | 32 | 43 | 44 | 45 | 51 | 65 | 66 | 70 | 81 | 82 | 83 | 84 | 85 | 86 | 237 | 238 | 264 | -------------------------------------------------------------------------------- /src/components/Dialog/EditDialog.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | 13 | 21 | 22 | 27 | 28 | 34 | {{ msg.headlineAdvanced }} 35 | 36 | 37 | 38 | 39 | 40 | 45 | 46 | 47 | {{ msg.checkboxStrict }} 48 | 49 | {{ msg.checkboxStrictDescription }} 50 | 51 | 52 | 53 | 54 | 55 | 56 | 61 | 62 | 63 | {{ msg.checkboxMerge }} 64 | 65 | {{ msg.checkboxMergeDescription }} 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 81 | 87 | 94 | 95 | 96 | 97 | 98 | 222 | 223 | 343 | -------------------------------------------------------------------------------- /src/Options.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 | 13 | 21 | 22 | 23 | 24 | {{ msg.sortHint }} 25 | 26 | 27 | 28 | 29 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 62 | 63 | 71 | 72 | 79 | 80 | 81 | 82 | 88 | 89 | 90 | 91 | 96 | 97 | 98 | 99 | {{ msg.undo }} 100 | 101 | 102 | 103 | 104 | 105 | 106 | 287 | 288 | 349 | -------------------------------------------------------------------------------- /src/static/_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "appName": { 3 | "message": "Auto-Group Tabs", 4 | "description": "The title of the application, displayed in the web store." 5 | }, 6 | "appDesc": { 7 | "message": "Automatically add tabs to your configured groups, based on their URL.", 8 | "description": "The description of the application, displayed in the web store." 9 | }, 10 | "previewTitle": { 11 | "message": "Preview", 12 | "description": "The title of the preview tab in the edit dialog." 13 | }, 14 | "headlineAdvanced": { 15 | "message": "Advanced Settings", 16 | "description": "The headline of the advanced settings section in the edit dialog." 17 | }, 18 | "checkboxStrict": { 19 | "message": "Strict", 20 | "description": "The label of the strict checkbox in the edit dialog." 21 | }, 22 | "checkboxStrictDescription": { 23 | "message": "Remove tabs from this group if their URL no longer matches.", 24 | "description": "The description of the strict checkbox in the edit dialog." 25 | }, 26 | "checkboxMerge": { 27 | "message": "Merge", 28 | "description": "The label of the single window checkbox in the edit dialog." 29 | }, 30 | "checkboxMergeDescription": { 31 | "message": "Prevent creating instances of this group in different windows, move tabs into existing groups in other windows instead.", 32 | "description": "The description of the single window checkbox in the edit dialog." 33 | }, 34 | "noGroupTitle": { 35 | "message": "no title", 36 | "description": "A group with an empty title" 37 | }, 38 | "editGroupTooltip": { 39 | "message": "Edit group", 40 | "description": "The tooltip for the edit group button" 41 | }, 42 | "newMatcherTooltip": { 43 | "message": "Add URL pattern", 44 | "description": "The tooltip for the new matcher button" 45 | }, 46 | "invalidUrlPattern": { 47 | "message": "Invalid URL pattern", 48 | "description": "Error message for invalid URL pattern" 49 | }, 50 | "matchPatternInfo": { 51 | "message": "Learn more about URL patterns", 52 | "description": "Match pattern info link" 53 | }, 54 | "buttonDeleteGroup": { 55 | "message": "Delete group", 56 | "description": "Delete group button label" 57 | }, 58 | "groupDeletedNotice": { 59 | "message": "Group was deleted", 60 | "description": "Snackbar message when group was deleted" 61 | }, 62 | "undo": { 63 | "message": "Undo", 64 | "description": "Undo button label" 65 | }, 66 | "buttonSave": { 67 | "message": "Save", 68 | "description": "Save button label" 69 | }, 70 | "buttonCancel": { 71 | "message": "Cancel", 72 | "description": "Cancel button label" 73 | }, 74 | "headlineImport": { 75 | "message": "Import", 76 | "description": "The import headline of the import/export settings dialog." 77 | }, 78 | "headlineExport": { 79 | "message": "Export", 80 | "description": "The import headline of the import/export settings dialog." 81 | }, 82 | "buttonExport": { 83 | "message": "Save grouping rules to file", 84 | "description": "Export button label" 85 | }, 86 | "buttonImport": { 87 | "message": "Load grouping rules from file...", 88 | "description": "Import button label" 89 | }, 90 | "importFormatError": { 91 | "message": "The file to be imported contains invalid grouping rules.", 92 | "description": "Error message when imported file is not valid JSON or does not match the schema" 93 | }, 94 | "importDiscardWarning": { 95 | "message": "Preexisting grouping rules will be discarded.", 96 | "description": "Warning message when importing settings" 97 | }, 98 | "importSuccess": { 99 | "message": "Import finished successfully", 100 | "description": "Snackbar message when importing succeeded" 101 | }, 102 | "buttonSortMode": { 103 | "message": "Enable sort mode", 104 | "description": "Sort mode" 105 | }, 106 | "buttonSettings": { 107 | "message": "Settings", 108 | "description": "General settings" 109 | }, 110 | "buttonAddGroup": { 111 | "message": "Add group", 112 | "description": "Add group button label" 113 | }, 114 | "groupTitlePlaceholder": { 115 | "message": "Group title", 116 | "description": "Placeholder of a group title input" 117 | }, 118 | "urlInputPlaceholder": { 119 | "message": "URL pattern", 120 | "description": "Placeholder of a URL input" 121 | }, 122 | "furtherUrlInputPlaceholder": { 123 | "message": "Another URL pattern", 124 | "description": "Placeholder of URL input beyond the first one" 125 | }, 126 | "newGroupTitle": { 127 | "message": "New Group", 128 | "description": "Title of a newly created group" 129 | }, 130 | "duplicateGroupError": { 131 | "message": "This combination of group name and color already exists.", 132 | "description": "Error message when a group with the same name and color already exists" 133 | }, 134 | "color": { 135 | "message": "Color", 136 | "description": "The word 'color'" 137 | }, 138 | "colorGrey": { 139 | "message": "grey", 140 | "description": "The color grey" 141 | }, 142 | "colorBlue": { 143 | "message": "blue", 144 | "description": "The color blue" 145 | }, 146 | "colorRed": { 147 | "message": "red", 148 | "description": "The color red" 149 | }, 150 | "colorYellow": { 151 | "message": "yellow", 152 | "description": "The color yellow" 153 | }, 154 | "colorGreen": { 155 | "message": "green", 156 | "description": "The color green" 157 | }, 158 | "colorPink": { 159 | "message": "pink", 160 | "description": "The color pink" 161 | }, 162 | "colorPurple": { 163 | "message": "purple", 164 | "description": "The color purple" 165 | }, 166 | "colorCyan": { 167 | "message": "cyan", 168 | "description": "The color cyan" 169 | }, 170 | "colorOrange": { 171 | "message": "orange", 172 | "description": "The color orange" 173 | }, 174 | "sortHint": { 175 | "message": "The order of groups determines the order of pattern evaluation. Put groups with more specific patterns to the top to allow groups with more permissive patterns to serve as a fallback.", 176 | "description": "Hint to show when sort mode is enabled" 177 | }, 178 | "settingsTitle": { 179 | "message": "Settings", 180 | "description": "Name of the settings area" 181 | }, 182 | "settingsTransferConfigurationTitle": { 183 | "message": "Transfer Configuration", 184 | "description": "Title of the transfer configuration section" 185 | }, 186 | "settingsTransferConfigurationSubtitle": { 187 | "message": "Import and export grouping rules", 188 | "description": "Subtitle of the transfer configuration section" 189 | }, 190 | "settingsForceReloadTitle": { 191 | "message": "Force-Reload Extension", 192 | "description": "Title of the force reload section" 193 | }, 194 | "settingsForceReloadSubtitle": { 195 | "message": "Feel like the extension is stuck or rule changes are not applied properly? Try this.", 196 | "description": "Subtitle of the force reload section" 197 | }, 198 | "settingsReassignTitle": { 199 | "message": "Neuzuweisung", 200 | "description": "Name of the reassign section" 201 | }, 202 | "suggestionTitle": { 203 | "message": "Suggestions", 204 | "description": "Suggestions title for matcher patterns" 205 | }, 206 | "derivedNameChromePage": { 207 | "message": "Chrome Page", 208 | "description": "Description of a generic Chrome-specific page" 209 | }, 210 | "derivedNameChromeSettings": { 211 | "message": "Chrome Settings", 212 | "description": "Description of the Chrome Settings page" 213 | }, 214 | "derivedNameChromeSettingsSection": { 215 | "message": "This Chrome Settings section", 216 | "description": "Description of a Chrome Settings section page" 217 | }, 218 | "derivedNameChromeExtensions": { 219 | "message": "Chrome Extensions", 220 | "description": "Description of the Chrome Extensions page" 221 | }, 222 | "derivedNameChromeDownloads": { 223 | "message": "Chrome Downloads", 224 | "description": "Description of the Chrome Downloads page" 225 | }, 226 | "derivedNameChromeHistory": { 227 | "message": "Chrome History", 228 | "description": "Description of the Chrome History page" 229 | }, 230 | "derivedNameChromeBookmarks": { 231 | "message": "Chrome Bookmarks", 232 | "description": "Description of the Chrome Bookmarks page" 233 | }, 234 | "derivedNameChromeApps": { 235 | "message": "Chrome Apps", 236 | "description": "Description of the Chrome Apps page" 237 | }, 238 | "derivedNameChromeFlags": { 239 | "message": "Chrome Flags", 240 | "description": "Description of the Chrome Flags page" 241 | }, 242 | "derivedNameChromeNewtab": { 243 | "message": "Chrome New Tab Page", 244 | "description": "Description of the Chrome New Tab page" 245 | }, 246 | "derivedNameChromeExactUrl": { 247 | "message": "This exact URL", 248 | "description": "Description of an exactly matching Chrome-specific page" 249 | }, 250 | "derivedNameChromeAll": { 251 | "message": "All Chrome-specific pages", 252 | "description": "Description of all Chrome-specific pages" 253 | }, 254 | "derivedNameExtensionHost": { 255 | "message": "This extension", 256 | "description": "Description of a specific Chrome extension" 257 | }, 258 | "derivedNameExtensionAll": { 259 | "message": "All extensions", 260 | "description": "Description of all Chrome-Extension-specific pages" 261 | }, 262 | "derivedNameExtensionExactUrl": { 263 | "message": "This exact extension URL", 264 | "description": "Description of an exact Chrome extension URL" 265 | }, 266 | "derivedNameFileAll": { 267 | "message": "All file URLs", 268 | "description": "Description of all file:// URLs" 269 | }, 270 | "derivedNameFileDirname": { 271 | "message": "All files in the \"%s\" folder", 272 | "description": "Description of a file:// folder" 273 | }, 274 | "derivedNameFileThisFolder": { 275 | "message": "All files in this folder", 276 | "description": "Description of the current file:// folder" 277 | }, 278 | "derivedNameFileExactFileUrl": { 279 | "message": "This exact file", 280 | "description": "Description of an exact file:// file URL" 281 | }, 282 | "derivedNameFileExactFolderUrl": { 283 | "message": "This exact folder", 284 | "description": "Description of an exact file:// folder URL" 285 | }, 286 | "derivedNameHttpDomain": { 287 | "message": "This domain", 288 | "description": "Description of an HTTP domain pattern" 289 | }, 290 | "derivedNameHttpSubdomain": { 291 | "message": "This domain and its subdomains", 292 | "description": "Description of an HTTP domain and subdomain pattern" 293 | }, 294 | "derivedNameGenericExactUrl": { 295 | "message": "This exact URL", 296 | "description": "Description of an exact URL" 297 | }, 298 | "popupHeadline": { 299 | "message": "Add Grouping Patterns", 300 | "description": "Main headline in a popup" 301 | }, 302 | "popupSelectLabel": { 303 | "message": "Select group configuration", 304 | "description": "Main headline in a popup" 305 | }, 306 | "popupAddLink": { 307 | "message": "Add another pattern", 308 | "description": "Add pattern button in a popup" 309 | }, 310 | "popupCreateGroup": { 311 | "message": "Create group", 312 | "description": "Create Group item in the popup menu select" 313 | }, 314 | "popupSaveButton": { 315 | "message": "Save new patterns", 316 | "description": "Save button for new URL patterns item in a popup" 317 | }, 318 | "popupSavedMessage": { 319 | "message": "Patterns have been saved", 320 | "description": "Message when new patterns have been saved in a popup" 321 | }, 322 | "popupMoreOptions": { 323 | "message": "More options", 324 | "description": "Button to go to options page from a popup" 325 | }, 326 | "popupEditCurrentGroup": { 327 | "message": "Show all rules for", 328 | "description": "Edit existing tab group configuration button from a popup" 329 | }, 330 | "popupAddCurrentGroup": { 331 | "message": "Create configuration for", 332 | "description": "Add tab group configuration button from a popup" 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /src/static/_locales/de/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "appName": { 3 | "message": "Automatische Tab-Gruppierung", 4 | "description": "The title of the application, displayed in the web store." 5 | }, 6 | "appDesc": { 7 | "message": "Weise Tabs automatisiert bestimmten Gruppen zu, basierend auf ihrer Adresse.", 8 | "description": "The description of the application, displayed in the web store." 9 | }, 10 | "previewTitle": { 11 | "message": "Vorschau", 12 | "description": "The title of the preview tab in the edit dialog." 13 | }, 14 | "headlineAdvanced": { 15 | "message": "Erweiterte Einstellungen", 16 | "description": "The headline of the advanced settings section in the edit dialog." 17 | }, 18 | "checkboxStrict": { 19 | "message": "Exakt", 20 | "description": "The label of the strict checkbox in the edit dialog." 21 | }, 22 | "checkboxStrictDescription": { 23 | "message": "Entferne Tabs aus dieser Gruppe, wenn ihre URL nicht mehr übereinstimmt.", 24 | "description": "The description of the strict checkbox in the edit dialog." 25 | }, 26 | "checkboxMerge": { 27 | "message": "Zusammenführen", 28 | "description": "The label of the merge checkbox in the edit dialog." 29 | }, 30 | "checkboxMergeDescription": { 31 | "message": "Vermeide das Erstellen dieser Gruppe in mehreren Fenstern; verschiebe stattdessen Tabs zur bestehenden Gruppe in einem anderen Browserfenster.", 32 | "description": "The description of the merge checkbox in the edit dialog." 33 | }, 34 | "noGroupTitle": { 35 | "message": "kein Titel", 36 | "description": "A group with an empty title" 37 | }, 38 | "editGroupTooltip": { 39 | "message": "Gruppe bearbeiten", 40 | "description": "The tooltip for the edit group button" 41 | }, 42 | "newMatcherTooltip": { 43 | "message": "Weiteres URL-Muster hinzufügen", 44 | "description": "The tooltip for the new matcher button" 45 | }, 46 | "invalidUrlPattern": { 47 | "message": "Ungültiges URL-Muster", 48 | "description": "Error message for invalid URL pattern" 49 | }, 50 | "matchPatternInfo": { 51 | "message": "Mehr über URL-Muster erfahren (englisch)", 52 | "description": "Match pattern info link" 53 | }, 54 | "buttonDeleteGroup": { 55 | "message": "Gruppe löschen", 56 | "description": "Delete group button label" 57 | }, 58 | "groupDeletedNotice": { 59 | "message": "Gruppe wurde gelöscht", 60 | "description": "Snackbar message when group was deleted" 61 | }, 62 | "undo": { 63 | "message": "Rückgängig", 64 | "description": "Undo button label" 65 | }, 66 | "buttonSave": { 67 | "message": "Speichern", 68 | "description": "Save button label" 69 | }, 70 | "buttonCancel": { 71 | "message": "Abbrechen", 72 | "description": "Cancel button label" 73 | }, 74 | "headlineImport": { 75 | "message": "Importieren", 76 | "description": "The import headline of the import/export settings dialog." 77 | }, 78 | "headlineExport": { 79 | "message": "Exportieren", 80 | "description": "The import headline of the import/export settings dialog." 81 | }, 82 | "buttonExport": { 83 | "message": "Gruppierungsregeln in Datei speichern", 84 | "description": "Export button label" 85 | }, 86 | "buttonImport": { 87 | "message": "Gruppierungsregeln aus Datei laden...", 88 | "description": "Import button label" 89 | }, 90 | "importFormatError": { 91 | "message": "Die zu importierende Datei enthält ungültige Einstellungen.", 92 | "description": "Error message when imported file is not valid JSON or does not match the schema" 93 | }, 94 | "importDiscardWarning": { 95 | "message": "Existierende Regeln werden verworfen.", 96 | "description": "Warning message when importing settings" 97 | }, 98 | "importSuccess": { 99 | "message": "Import erfolgreich abgeschlossen", 100 | "description": "Snackbar message when importing succeeded" 101 | }, 102 | "buttonSortMode": { 103 | "message": "Sortiermodus aktivieren", 104 | "description": "Sort mode" 105 | }, 106 | "buttonSettings": { 107 | "message": "Einstellungen", 108 | "description": "General settings" 109 | }, 110 | "buttonAddGroup": { 111 | "message": "Neue Gruppe", 112 | "description": "Add group button label" 113 | }, 114 | "groupTitlePlaceholder": { 115 | "message": "Gruppe benennen", 116 | "description": "Placeholder of a group title input" 117 | }, 118 | "urlInputPlaceholder": { 119 | "message": "URL-Muster", 120 | "description": "Placeholder of a URL input" 121 | }, 122 | "furtherUrlInputPlaceholder": { 123 | "message": "Weiteres URL-Muster", 124 | "description": "Placeholder of URL input beyond the first one" 125 | }, 126 | "newGroupTitle": { 127 | "message": "Neue Gruppe", 128 | "description": "Title of a newly created group" 129 | }, 130 | "duplicateGroupError": { 131 | "message": "Diese Kombination aus Gruppenname und Farbe existiert bereits.", 132 | "description": "Error message when a group with the same name and color already exists" 133 | }, 134 | "color": { 135 | "message": "Farbe", 136 | "description": "The word 'color'" 137 | }, 138 | "colorGrey": { 139 | "message": "grau", 140 | "description": "The color grey" 141 | }, 142 | "colorBlue": { 143 | "message": "blau", 144 | "description": "The color blue" 145 | }, 146 | "colorRed": { 147 | "message": "rot", 148 | "description": "The color red" 149 | }, 150 | "colorYellow": { 151 | "message": "gelb", 152 | "description": "The color yellow" 153 | }, 154 | "colorGreen": { 155 | "message": "grün", 156 | "description": "The color green" 157 | }, 158 | "colorPink": { 159 | "message": "rosa", 160 | "description": "The color pink" 161 | }, 162 | "colorPurple": { 163 | "message": "violett", 164 | "description": "The color purple" 165 | }, 166 | "colorCyan": { 167 | "message": "cyan", 168 | "description": "The color cyan" 169 | }, 170 | "colorOrange": { 171 | "message": "orange", 172 | "description": "The color orange" 173 | }, 174 | "sortHint": { 175 | "message": "Die Sortierung der Gruppen bestimmt die Auswertungsreihenfolge der URL-Muster. Positioniere Gruppen mit spezifischeren URL-Mustern am Anfang, um auf Gruppen mit allgemeineren Mustern zurückzufallen.", 176 | "description": "Hint to show when sort mode is enabled" 177 | }, 178 | "settingsTitle": { 179 | "message": "Einstellungen", 180 | "description": "Name of the settings area" 181 | }, 182 | "settingsTransferConfigurationTitle": { 183 | "message": "Konfiguration übertragen", 184 | "description": "Title of the transfer configuration section" 185 | }, 186 | "settingsTransferConfigurationSubtitle": { 187 | "message": "Gruppierungsregeln importieren und exportieren", 188 | "description": "Subtitle of the transfer configuration section" 189 | }, 190 | "settingsForceReloadTitle": { 191 | "message": "Erweiterung neu starten", 192 | "description": "Title of the force reload section" 193 | }, 194 | "settingsForceReloadSubtitle": { 195 | "message": "Die Erweiterung scheint nicht zu reagieren oder neue Regeln nicht anzuwenden? Klicke hier.", 196 | "description": "Subtitle of the force reload section" 197 | }, 198 | "suggestionTitle": { 199 | "message": "Vorschläge", 200 | "description": "Suggestions title for matcher patterns" 201 | }, 202 | "derivedNameChromePage": { 203 | "message": "Chrome-Seite", 204 | "description": "Description of a generic Chrome-specific page" 205 | }, 206 | "derivedNameChromeSettings": { 207 | "message": "Chrome-Einstellungen", 208 | "description": "Description of the Chrome Settings page" 209 | }, 210 | "derivedNameChromeSettingsSection": { 211 | "message": "Dieser Bereich der Chrome-Einstellungen", 212 | "description": "Description of a Chrome Settings section page" 213 | }, 214 | "derivedNameChromeExtensions": { 215 | "message": "Chrome Erweiterungen", 216 | "description": "Description of the Chrome Extensions page" 217 | }, 218 | "derivedNameChromeDownloads": { 219 | "message": "Chrome Downloads", 220 | "description": "Description of the Chrome Downloads page" 221 | }, 222 | "derivedNameChromeHistory": { 223 | "message": "Chrome Verlauf", 224 | "description": "Description of the Chrome History page" 225 | }, 226 | "derivedNameChromeBookmarks": { 227 | "message": "Chrome Lesezeichen", 228 | "description": "Description of the Chrome Bookmarks page" 229 | }, 230 | "derivedNameChromeApps": { 231 | "message": "Chrome Apps", 232 | "description": "Description of the Chrome Apps page" 233 | }, 234 | "derivedNameChromeFlags": { 235 | "message": "Chrome Flags", 236 | "description": "Description of the Chrome Flags page" 237 | }, 238 | "derivedNameChromeNewtab": { 239 | "message": "Chrome \"Neuer Tab\" Seite", 240 | "description": "Description of the Chrome Newtab page" 241 | }, 242 | "derivedNameChromeExactUrl": { 243 | "message": "Nur diese URL", 244 | "description": "Description of an exactly matching Chrome-specific page" 245 | }, 246 | "derivedNameChromeAll": { 247 | "message": "Alle Chrome-spezifischen Seiten", 248 | "description": "Description of all Chrome-specific pages" 249 | }, 250 | "derivedNameExtensionHost": { 251 | "message": "Diese Erweiterung", 252 | "description": "Description of a specific Chrome extension" 253 | }, 254 | "derivedNameExtensionAll": { 255 | "message": "Alle Erweiterungen", 256 | "description": "Description of all Chrome-Extension-specific pages" 257 | }, 258 | "derivedNameExtensionExactUrl": { 259 | "message": "Nur diese Erweiterungs-URL", 260 | "description": "Description of an exact Chrome extension URL" 261 | }, 262 | "derivedNameFileAll": { 263 | "message": "Alle Datei-URLs", 264 | "description": "Description of all file:// URLs" 265 | }, 266 | "derivedNameFileDirname": { 267 | "message": "Alle Dateien im \"%s\" Ordner", 268 | "description": "Description of a file:// folder" 269 | }, 270 | "derivedNameFileThisFolder": { 271 | "message": "Alle Dateien in diesem Ordner", 272 | "description": "Description of the current file:// folder" 273 | }, 274 | "derivedNameFileExactUrl": { 275 | "message": "Nur diese Datei", 276 | "description": "Description of an exact file:// file URL" 277 | }, 278 | "derivedNameFileExactFolderUrl": { 279 | "message": "Nur dieser Ordner", 280 | "description": "Description of an exact file:// folder URL" 281 | }, 282 | "derivedNameHttpDomain": { 283 | "message": "Diese Domain", 284 | "description": "Description of an HTTP domain pattern" 285 | }, 286 | "derivedNameHttpSubdomain": { 287 | "message": "Diese Domain und ihre Subdomains", 288 | "description": "Description of an HTTP domain and subdomain pattern" 289 | }, 290 | "derivedNameGenericExactUrl": { 291 | "message": "Genau diese URL", 292 | "description": "Description of an exact URL" 293 | }, 294 | "popupHeadline": { 295 | "message": "URL-Muster anlegen", 296 | "description": "Main headline in a popup" 297 | }, 298 | "popupSelectLabel": { 299 | "message": "Konfigurierte Gruppe auswählen", 300 | "description": "Main headline in a popup" 301 | }, 302 | "popupAddLink": { 303 | "message": "Weiteres URL-Muster hinzufügen", 304 | "description": "Add pattern button in a popup" 305 | }, 306 | "popupCreateGroup": { 307 | "message": "Neue Gruppe", 308 | "description": "Create Group item in the popup menu select" 309 | }, 310 | "popupSaveButton": { 311 | "message": "Neue Muster speichern", 312 | "description": "Save button for new URL patterns item in a popup" 313 | }, 314 | "popupSavedMessage": { 315 | "message": "URL-Muster wurden gespeichert", 316 | "description": "Message when new patterns have been saved in a popup" 317 | }, 318 | "popupMoreOptions": { 319 | "message": "Weitere Optionen", 320 | "description": "Button to go to options page from a popup" 321 | }, 322 | "popupEditCurrentGroup": { 323 | "message": "Alle Regeln für", 324 | "description": "Edit existing tab group configuration button from a popup" 325 | }, 326 | "popupAddCurrentGroup": { 327 | "message": "Konfiguration erstellen für", 328 | "description": "Add tab group configuration button from a popup" 329 | } 330 | } 331 | --------------------------------------------------------------------------------
41 | warning 42 | {{ msg.importDiscardWarning }} 43 |
{{ errorMessage }}