├── .gitignore ├── static ├── icons │ ├── icon16.png │ ├── icon32.png │ ├── icon64.png │ ├── icon128.png │ ├── icon256.png │ └── icon512.png ├── popup.html ├── options.html └── svgs │ ├── chrome.svg │ ├── github.svg │ ├── pencil.svg │ ├── gear.svg │ └── firefox.svg ├── src ├── @types │ ├── svg.d.ts │ └── global.d.ts ├── popup │ ├── index.tsx │ ├── PopupApp.tsx │ ├── Gear.tsx │ ├── CurrentTitle.tsx │ ├── Revert.tsx │ ├── BookmarkTitle.tsx │ ├── ContentScriptChecker.tsx │ └── Form.tsx ├── options │ ├── index.tsx │ ├── OptionsApp.tsx │ ├── ContextMenuSwitch.tsx │ ├── Header.tsx │ ├── KeyboardShortcutSettings.tsx │ ├── Footer.tsx │ ├── Home.tsx │ ├── RegexPopup.tsx │ ├── AdvancedSettings.tsx │ ├── UserSettings.tsx │ └── SavedTitles.tsx ├── shared │ ├── AccessibleButton.tsx │ ├── utils.ts │ ├── RegexInputGroup.tsx │ ├── RegexInput.tsx │ ├── types.ts │ ├── injectedScripts.ts │ ├── ReTitleThemeWrapper.tsx │ ├── storageUtils.ts │ └── storageHandler.ts └── background │ ├── index.ts │ ├── onInstall.ts │ ├── manageTablock.ts │ └── retitle.ts ├── .vscode └── settings.json ├── tsconfig.json ├── babel.config.js ├── README.md ├── manifest.base.js ├── package.json └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist_chrome 4 | dist_firefox 5 | zip -------------------------------------------------------------------------------- /static/icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lazyuki/ReTitle/HEAD/static/icons/icon16.png -------------------------------------------------------------------------------- /static/icons/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lazyuki/ReTitle/HEAD/static/icons/icon32.png -------------------------------------------------------------------------------- /static/icons/icon64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lazyuki/ReTitle/HEAD/static/icons/icon64.png -------------------------------------------------------------------------------- /static/icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lazyuki/ReTitle/HEAD/static/icons/icon128.png -------------------------------------------------------------------------------- /static/icons/icon256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lazyuki/ReTitle/HEAD/static/icons/icon256.png -------------------------------------------------------------------------------- /static/icons/icon512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lazyuki/ReTitle/HEAD/static/icons/icon512.png -------------------------------------------------------------------------------- /src/@types/svg.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const value: any; 3 | export default value; 4 | } 5 | -------------------------------------------------------------------------------- /src/popup/index.tsx: -------------------------------------------------------------------------------- 1 | import { h, render } from 'preact'; 2 | import PopupApp from './PopupApp'; 3 | 4 | render(, document.body); 5 | -------------------------------------------------------------------------------- /src/options/index.tsx: -------------------------------------------------------------------------------- 1 | import { h, render } from 'preact'; 2 | import OptionsApp from './OptionsApp'; 3 | 4 | render(, document.body); 5 | -------------------------------------------------------------------------------- /src/@types/global.d.ts: -------------------------------------------------------------------------------- 1 | // Replaced during Webpack build process 2 | declare var BROWSER: 'chrome' | 'firefox'; 3 | declare var EXTENSION_VERSION: string; 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true, 4 | "editor.formatOnPaste": true, 5 | "typescript.tsdk": "node_modules/typescript/lib" 6 | } 7 | -------------------------------------------------------------------------------- /static/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ReTitle Popup 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /static/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ReTitle Settings 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/popup/PopupApp.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | import Form from './Form'; 4 | import ReTitleThemeWrapper from '../shared/ReTitleThemeWrapper'; 5 | 6 | const PopupApp = () => { 7 | return ( 8 | 9 |
10 | 11 | ); 12 | }; 13 | 14 | export default PopupApp; 15 | -------------------------------------------------------------------------------- /src/options/OptionsApp.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | import Home from './Home'; 4 | import ReTitleThemeWrapper from '../shared/ReTitleThemeWrapper'; 5 | 6 | const OptionsApp = () => { 7 | return ( 8 | 9 | 10 | 11 | ); 12 | }; 13 | 14 | export default OptionsApp; 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016", 4 | "lib": ["es7", "DOM", "DOM.Iterable"], 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "jsx": "react", 8 | "jsxFactory": "h", 9 | "moduleResolution": "node", 10 | "resolveJsonModule": true, 11 | "module": "commonjs", 12 | "baseUrl": ".", 13 | "paths": { 14 | "react": ["node_modules/preact/compat"], 15 | "react-dom": ["node_modules/preact/compat"] 16 | } 17 | }, 18 | "include": ["src/**/*"] 19 | } 20 | -------------------------------------------------------------------------------- /static/svgs/chrome.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | 4 | return { 5 | presets: [ 6 | [ 7 | '@babel/env', 8 | { 9 | targets: 'last 5 chrome version, last 5 firefox version', 10 | }, 11 | ], 12 | [ 13 | '@babel/preset-react', 14 | { 15 | pragma: 'h', 16 | pragmaFrag: 'Fragment', 17 | }, 18 | ], 19 | [ 20 | '@babel/preset-typescript', 21 | { 22 | allExtensions: true, 23 | isTSX: true, 24 | jsxPragma: 'h', 25 | }, 26 | ], 27 | ], 28 | plugins: ['@babel/plugin-proposal-class-properties'], 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /static/svgs/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/shared/AccessibleButton.tsx: -------------------------------------------------------------------------------- 1 | import { h, JSX, FunctionComponent as FC } from 'preact'; 2 | import clsx from 'clsx'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | 5 | const useStyles = makeStyles((theme) => ({ 6 | root: { 7 | appearance: 'none', 8 | background: 'none', 9 | border: 'none', 10 | 11 | '&:hover': { 12 | outline: 'none', 13 | }, 14 | color: theme.palette.text.primary, 15 | }, 16 | })); 17 | 18 | const AccessibleButton: FC> = ({ 19 | className, 20 | ...rest 21 | }) => { 22 | const styles = useStyles(); 23 | return 39 | 40 | ); 41 | }; 42 | 43 | export default KeyboardShortcutSettings; 44 | -------------------------------------------------------------------------------- /static/svgs/gear.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/popup/BookmarkTitle.tsx: -------------------------------------------------------------------------------- 1 | import { h, JSX } from 'preact'; 2 | import { useEffect, useState } from 'preact/compat'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import Tooltip from '@material-ui/core/Tooltip'; 5 | 6 | const useStyles = makeStyles({ 7 | root: { 8 | cursor: 'pointer', 9 | marginBottom: '10px', 10 | marginRight: '60px', 11 | textOverflow: 'ellipsis', 12 | overflow: 'hidden', 13 | whiteSpace: 'nowrap', 14 | }, 15 | label: { 16 | opacity: 0.7, 17 | fontSize: '12px', 18 | }, 19 | span: { 20 | fontSize: '14px', 21 | }, 22 | }); 23 | 24 | const BookmarkTitle = ({ 25 | url, 26 | setInputValue, 27 | ...rest 28 | }: { 29 | url?: string; 30 | setInputValue: (value: string) => void; 31 | } & JSX.HTMLAttributes) => { 32 | const [bookmarkedTitle, setBookmarkedTitle] = useState(null); 33 | const styles = useStyles(); 34 | 35 | useEffect(() => { 36 | if (url) { 37 | try { 38 | chrome.bookmarks.search({ url }, function (results) { 39 | if (results[0]) { 40 | setBookmarkedTitle(results[0].title); 41 | } 42 | }); 43 | } catch (e) { 44 | // URL not allowed; 45 | } 46 | } 47 | }, [url]); 48 | 49 | return bookmarkedTitle !== null ? ( 50 | 54 |
setInputValue(bookmarkedTitle)} 57 | > 58 | From Bookmark 59 | 60 | {bookmarkedTitle} 61 | 62 |
63 |
64 | ) : null; 65 | }; 66 | 67 | export default BookmarkTitle; 68 | -------------------------------------------------------------------------------- /src/shared/utils.ts: -------------------------------------------------------------------------------- 1 | // Unique storage keys for options 2 | export const KEY_DEFAULT_TAB_OPTION = 'OptDefault'; 3 | export const KEY_THEME = 'OptTheme'; 4 | export const KEY_CONTEXT_MENU = 'OptContextMenu'; 5 | export const KEY_BOOKMARKS = 'OptBookmarks'; 6 | 7 | // Title matcher prefixes 8 | export const PREFIX_CONTAINER = 'Gr:'; // Comes before other prefixes. Gr:google|Exact:http://example.com 9 | export const PREFIX_ONETIME = 'Temp#'; // Temp#5:http://example.com/page/1 10 | export const PREFIX_TABLOCK = 'Tab#'; // Tab#13:http://example.com/page/1 11 | export const PREFIX_EXACT = 'Exact:'; // Exact:http://example.com/page/1 12 | export const PREFIX_DOMAIN = 'Domain:'; // Domain:www.example.com 13 | export const PREFIX_REGEX = 'Regex:'; // Regex:^exampl.*\.com 14 | 15 | // Regex 16 | export const REGEX_FULL_DOMAIN = /https?:\/\/([^\s/]+)(?:$|\/)/; // http://some.example.com/ => some.example.com 17 | export const VALID_REGEX = /^\/((?:[^/]|\\\/)+)\/((?:[^/]|\\\/)+)\/(gi?|ig?)?$/; 18 | 19 | export function extractDomain(url?: string) { 20 | if (url) { 21 | const domainMatch = url.match(REGEX_FULL_DOMAIN); 22 | if (domainMatch) { 23 | const domain = domainMatch[1]; 24 | const subDomains = domain.split('.').reverse(); 25 | const depths = subDomains.reduce((prev: string[], curr) => { 26 | const last = prev[prev.length - 1]; 27 | if (last) { 28 | const partialDomain = `${curr}.${last}`; 29 | return [...prev, partialDomain]; 30 | } else { 31 | return [curr]; 32 | } 33 | }, []); // some.example.com => ['com', 'example.com', 'some.example.com'] 34 | console.log(depths); 35 | return domain; 36 | } 37 | } 38 | return ''; 39 | } 40 | 41 | export function createContextMenu() { 42 | chrome.contextMenus.create({ 43 | id: 'selection-ctxmnu', 44 | title: 'Set a temporary title', 45 | contexts: ['selection', 'page', 'tab'], 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /src/shared/RegexInputGroup.tsx: -------------------------------------------------------------------------------- 1 | import { h, JSX } from 'preact'; 2 | import { useState, useEffect } from 'preact/compat'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import TextField from '@material-ui/core/TextField'; 5 | import RegexInput from './RegexInput'; 6 | 7 | const useStyles = makeStyles({ 8 | root: { 9 | cursor: 'pointer', 10 | marginBottom: '10px', 11 | marginRight: '40px', 12 | wordBreak: 'break-all', 13 | }, 14 | input: { 15 | width: '100%', 16 | }, 17 | }); 18 | 19 | const RegexInputGroup = ({ 20 | onChange, 21 | initialValue = '', 22 | }: { 23 | onChange: (value: string) => void; 24 | initialValue?: string; 25 | }) => { 26 | const styles = useStyles(); 27 | const [flag, setFlag] = useState(initialValue); 28 | const [capRegex, setCapRegex] = useState(initialValue); 29 | const [newRegex, setNewRegex] = useState(initialValue); 30 | 31 | const valid = /^[gimsuy]*$/.test(flag); 32 | 33 | useEffect(() => { 34 | const constructed = `/${capRegex}/${newRegex}/${flag}`; 35 | onChange(constructed); 36 | }, [flag, capRegex, newRegex]); 37 | return ( 38 |
39 | 40 | 41 | { 47 | if (e.key === 'Enter' && !e.shiftKey) { 48 | e.preventDefault(); 49 | e.target.blur(); 50 | return false; 51 | } 52 | }} 53 | onChange={(e: any) => { 54 | setFlag(e.target.value); 55 | }} 56 | onFocus={(e: any) => e.target.select()} 57 | helperText={!valid && 'Acceptable flags: g and i'} 58 | /> 59 |
60 | ); 61 | }; 62 | 63 | export default RegexInputGroup; 64 | -------------------------------------------------------------------------------- /src/background/onInstall.ts: -------------------------------------------------------------------------------- 1 | import { TabOption } from '../shared/types'; 2 | import { KEY_DEFAULT_TAB_OPTION, PREFIX_REGEX } from '../shared/utils'; 3 | import { 4 | getAllSyncItems, 5 | removeSyncItems, 6 | setSyncItem, 7 | setLocalItem, 8 | } from '../shared/storageUtils'; 9 | import { setDefaultOption } from '../shared/storageHandler'; 10 | 11 | // On Extension Update 12 | interface LegacyUserOptionsSchema { 13 | options: { [key in TabOption]: boolean }; 14 | } 15 | type LegacyStorageSchema = { 16 | [key: string]: { title: string }; 17 | } & LegacyUserOptionsSchema; 18 | 19 | // UPDATE PREVIOUSLY STORED TITLES ON EXTENSION UPDATE 20 | chrome.runtime.onInstalled.addListener((details) => { 21 | const prev = details.previousVersion; 22 | // Upgrading from v0 or v1 23 | if (prev && (prev.startsWith('0.') || prev.startsWith('1.'))) { 24 | getAllSyncItems().then((items) => { 25 | const storage = items as LegacyStorageSchema; 26 | for (const key in storage) { 27 | // v0 tab lock mistake. 28 | if (key.startsWith('#')) { 29 | removeSyncItems(key); 30 | continue; 31 | } 32 | // v1 options key 33 | if (key === 'options') { 34 | const options = storage.options; 35 | let option: TabOption = 'onetime'; 36 | if (options.domain) option = 'domain'; 37 | if (options.tablock) option = 'tablock'; 38 | if (options.exact) option = 'exact'; 39 | setDefaultOption(option); 40 | removeSyncItems(key); 41 | continue; 42 | } 43 | const item = storage[key]; 44 | // v1 regex URL matcher 45 | if (key.startsWith('Tab#')) { 46 | removeSyncItems(key); // TabLocks shouldn't be stored in sync anymore 47 | setLocalItem(key, item); 48 | } 49 | if (key.startsWith('*') && key.endsWith('*')) { 50 | removeSyncItems(key); 51 | setSyncItem(PREFIX_REGEX, item); 52 | } 53 | } 54 | }); 55 | } 56 | }); 57 | -------------------------------------------------------------------------------- /src/shared/RegexInput.tsx: -------------------------------------------------------------------------------- 1 | import { h, JSX } from 'preact'; 2 | import { useState, useEffect } from 'preact/compat'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import TextField from '@material-ui/core/TextField'; 5 | 6 | const useStyles = makeStyles({ 7 | root: { 8 | cursor: 'pointer', 9 | marginBottom: '10px', 10 | marginRight: '40px', 11 | wordBreak: 'break-all', 12 | }, 13 | input: { 14 | width: '100%', 15 | }, 16 | }); 17 | 18 | function useDebounce(value: T, delayMillis: number) { 19 | const [debouncedValue, setDebouncedValue] = useState(value); 20 | useEffect(() => { 21 | const timeoutId = setTimeout(() => { 22 | setDebouncedValue(value); 23 | }, delayMillis); 24 | return () => { 25 | clearTimeout(timeoutId); 26 | }; 27 | }, [value, delayMillis]); 28 | return debouncedValue; 29 | } 30 | 31 | const RegexInput = ({ 32 | label, 33 | setValidInputValue, 34 | initialValue = '', 35 | }: { 36 | label: string; 37 | setValidInputValue: (value: string) => void; 38 | initialValue?: string; 39 | }) => { 40 | const styles = useStyles(); 41 | const [inputValue, setInputValue] = useState(initialValue); 42 | const [error, setError] = useState(''); 43 | const debouncedInput = useDebounce(inputValue, 250); 44 | 45 | useEffect(() => { 46 | try { 47 | new RegExp(String.raw`${debouncedInput}`); 48 | setError(''); 49 | setValidInputValue(debouncedInput); 50 | } catch (e) { 51 | setError(e.message); 52 | } 53 | }, [debouncedInput]); 54 | 55 | return ( 56 | { 62 | if (e.key === 'Enter' && !e.shiftKey) { 63 | e.preventDefault(); 64 | e.target.blur(); 65 | return false; 66 | } 67 | }} 68 | onChange={(e: any) => { 69 | setInputValue(e.target.value); 70 | setError(''); 71 | }} 72 | onFocus={(e: any) => e.target.select()} 73 | helperText={error} 74 | /> 75 | ); 76 | }; 77 | 78 | export default RegexInput; 79 | -------------------------------------------------------------------------------- /src/shared/types.ts: -------------------------------------------------------------------------------- 1 | export type TabOption = 'onetime' | 'tablock' | 'exact' | 'domain' | 'regex'; 2 | export type ThemeState = 'light' | 'dark'; 3 | export type ReplacerType = 'url' | 'title' | 'function'; 4 | 5 | export interface StorageChanges { 6 | [key: string]: chrome.storage.StorageChange; 7 | } 8 | 9 | export type NewTitleFunc = { 10 | replacerType: 'function'; 11 | func: string; 12 | }; 13 | 14 | export type NewTitleRegex = { 15 | replacerType: 'url' | 'title'; 16 | captureRegex: string; 17 | titleRegex: string; 18 | flags: string; 19 | }; 20 | 21 | export type NewTitle = string | NewTitleRegex | NewTitleFunc; 22 | 23 | // Optional settings for titles 24 | export interface TitleOptions { 25 | name?: string; // Optional human readable name. Default to the storage key 26 | disabled?: boolean; // Temporarily Disabled 27 | containerId?: string; // Container ID if used 28 | } 29 | 30 | export interface BaseTitle extends TitleOptions { 31 | option: TabOption; 32 | newTitle: NewTitle; // empty string is allowed. Use null to delete 33 | } 34 | 35 | export interface TabLockTitle extends BaseTitle { 36 | option: 'tablock'; 37 | tabId: number; 38 | } 39 | 40 | export interface ExactTitle extends BaseTitle { 41 | option: 'exact'; 42 | url: string; 43 | ignoreParams?: boolean; // Ignore URL params like # & ? 44 | } 45 | 46 | export interface DomainTitle extends BaseTitle { 47 | option: 'domain'; 48 | domain: string; 49 | allowSubdomains?: boolean; // Allow subdomains or not 50 | } 51 | 52 | export interface RegexTitle extends BaseTitle { 53 | option: 'regex'; 54 | urlPattern: string; // RegExp pattern to match URLs. Capturing doesn't matter here 55 | } 56 | 57 | export type CustomFunction = (originalTitle: string, url: string) => string; 58 | 59 | export type StoredTitle = TabLockTitle | ExactTitle | DomainTitle | RegexTitle; 60 | 61 | export type StorageAction = 'add' | 'remove' | 'edit'; 62 | 63 | export interface RenameRequest { 64 | type: 'rename'; 65 | tabId: number; 66 | oldTitle: string; 67 | newTitle: NewTitle; 68 | option: TabOption; 69 | } 70 | 71 | export interface RevertRequest { 72 | type: 'revert'; 73 | tabId: number; 74 | } 75 | 76 | export type RuntimeMessageRequest = RenameRequest | RevertRequest; 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "retitle", 3 | "version": "2.0.0", 4 | "description": "Change tab titles easily!", 5 | "author": "Lazyuki", 6 | "license": "MIT", 7 | "private": true, 8 | "bugs": { 9 | "url": "https://github.com/Lazyuki/ReTitle/issues" 10 | }, 11 | "homepage": "https://github.com/Lazyuki/ReTitle#readme", 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/Lazyuki/ReTitle.git" 15 | }, 16 | "scripts": { 17 | "lint": "eslint src", 18 | "preinstall": "npx npm-force-resolutions", 19 | "dev": "npm-run-all --parallel dev:chrome dev:firefox", 20 | "dev:chrome": "webpack --watch --env.BROWSER=chrome ", 21 | "dev:firefox": "webpack --watch --env.BROWSER=firefox ", 22 | "clean": "rimraf dist*", 23 | "prebuild": "npm run clean", 24 | "build": "npm-run-all --parallel build:chrome build:firefox", 25 | "build:chrome": "webpack --env.NODE_ENV=production --env.BROWSER=chrome", 26 | "build:firefox": "webpack --env.NODE_ENV=production --env.BROWSER=firefox" 27 | }, 28 | "dependencies": { 29 | "@material-ui/core": "^4.11.0", 30 | "@material-ui/icons": "^4.9.1", 31 | "clsx": "^1.1.1", 32 | "preact": "10.4.7", 33 | "react-redux": "^7.2.1", 34 | "redux": "^4.0.5", 35 | "redux-thunk": "^2.3.0" 36 | }, 37 | "devDependencies": { 38 | "@babel/core": "^7.11.6", 39 | "@babel/preset-env": "^7.11.5", 40 | "@babel/preset-react": "^7.10.4", 41 | "@babel/preset-typescript": "^7.10.4", 42 | "@types/chrome": "^0.0.133", 43 | "@types/firefox-webext-browser": "^82.0.0", 44 | "@types/react-redux": "^7.1.9", 45 | "babel-loader": "^8.1.0", 46 | "copy-webpack-plugin": "^5.1.2", 47 | "eslint": "^7.11.0", 48 | "husky": "^4.3.0", 49 | "npm-run-all": "^4.1.5", 50 | "preact-svg-loader": "^0.2.1", 51 | "prettier": "^2.1.2", 52 | "pretty-quick": "^2.0.2", 53 | "rimraf": "^3.0.2", 54 | "ts-loader": "^7.0.5", 55 | "typescript": "^3.9.7", 56 | "webpack": "^4.44.2", 57 | "webpack-cli": "^3.3.12", 58 | "webpack-extension-manifest-plugin": "^0.5.0", 59 | "zip-webpack-plugin": "^3.0.0" 60 | }, 61 | "husky": { 62 | "hooks": { 63 | "pre-commit": "pretty-quick --staged" 64 | } 65 | }, 66 | "prettier": { 67 | "singleQuote": true 68 | }, 69 | "resolutions": { 70 | "acorn": "8.0.1" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/options/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import Chip from '@material-ui/core/Chip'; 5 | import Container from '@material-ui/core/Container'; 6 | 7 | import ChromeIcon from '../../static/svgs/chrome.svg'; 8 | import FirefoxIcon from '../../static/svgs/firefox.svg'; 9 | import GitHubIcon from '../../static/svgs/github.svg'; 10 | 11 | const isChrome = BROWSER === 'chrome'; 12 | 13 | const chipProps = { 14 | size: 'small', 15 | clickable: true, 16 | color: 'primary', 17 | component: 'a', 18 | variant: 'outlined', 19 | target: '_blank', 20 | rel: 'noopener noreferrer', 21 | } as const; 22 | 23 | const useStyles = makeStyles((theme) => ({ 24 | root: { 25 | fontSize: '0.8em', 26 | background: 'black', 27 | }, 28 | container: { 29 | display: 'flex', 30 | justifyContent: 'space-between', 31 | alignItems: 'center', 32 | }, 33 | links: { 34 | margin: '10px 0', 35 | '& > *': { 36 | margin: '0 10px', 37 | }, 38 | '& svg': { 39 | height: '1.2em', 40 | }, 41 | }, 42 | copyRight: { 43 | flex: 1, 44 | textAlign: 'right', 45 | marginRight: '20px', 46 | }, 47 | })); 48 | 49 | const Chrome = ( 50 | } 52 | label={isChrome ? 'Rate Me!' : 'Chrome'} 53 | href="https://chrome.google.com/webstore/detail/tab-retitle/hilgambgdpjgljhjdaccadahckpdiapo" 54 | {...chipProps} 55 | /> 56 | ); 57 | const Firefox = ( 58 | } 60 | label={isChrome ? 'Firefox' : 'Rate Me!'} 61 | href="https://addons.mozilla.org/en-US/firefox/addon/tab-retitle/" 62 | {...chipProps} 63 | /> 64 | ); 65 | 66 | const Footer = () => { 67 | const styles = useStyles(); 68 | const links = isChrome ? [Chrome, Firefox] : [Firefox, Chrome]; 69 | 70 | return ( 71 |
72 | 73 |
74 | {links} 75 | } 77 | label="GitHub" 78 | href="https://github.com/Lazyuki/ReTitle" 79 | {...chipProps} 80 | /> 81 |
82 |
© 2021 Lazyuki
83 |
84 |
85 | ); 86 | }; 87 | 88 | export default Footer; 89 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const WebpackExtensionManifestPlugin = require('webpack-extension-manifest-plugin'); 3 | const CopyPlugin = require('copy-webpack-plugin'); 4 | const ZipPlugin = require('zip-webpack-plugin'); 5 | 6 | const baseManifest = require('./manifest.base'); 7 | const pkg = require('./package.json'); 8 | 9 | const firefoxManifestSettings = { 10 | browser_specific_settings: { 11 | gecko: { 12 | id: '{15fcc312-c0d6-4d8a-add7-edf49088fefd}', 13 | strict_min_version: '42.0', 14 | }, 15 | }, 16 | }; 17 | 18 | module.exports = (env) => { 19 | const dev = env.NODE_ENV !== 'production'; 20 | const browser = env.BROWSER || 'chrome'; 21 | 22 | return { 23 | mode: dev ? 'development' : 'production', 24 | devtool: dev ? 'cheap-source-map' : undefined, 25 | entry: { 26 | popup: './src/popup/index.tsx', 27 | options: './src/options/index.tsx', 28 | background: './src/background/index.ts', 29 | }, 30 | output: { 31 | filename: '[name].js', 32 | path: __dirname + `/dist_${browser}`, 33 | }, 34 | module: { 35 | rules: [ 36 | { 37 | test: /\.tsx?$/, 38 | exclude: /node_modules/, 39 | use: [{ loader: 'babel-loader' }], 40 | }, 41 | { 42 | test: /\.svg$/, 43 | exclude: /node_modules/, 44 | use: ['preact-svg-loader'], 45 | }, 46 | ], 47 | }, 48 | plugins: [ 49 | new webpack.DefinePlugin({ 50 | BROWSER: JSON.stringify(browser), 51 | EXTENSION_VERSION: JSON.stringify( 52 | dev ? `${pkg.version}-dev` : pkg.version 53 | ), 54 | }), 55 | new WebpackExtensionManifestPlugin({ 56 | config: { 57 | base: baseManifest, 58 | extend: { 59 | version: pkg.version, 60 | permissions: browser === 'firefox' ? ['contextualIdentities'] : [], 61 | ...(browser === 'firefox' ? firefoxManifestSettings : null), 62 | ...(browser === 'chrome' ? { minimum_chrome_version: '55' } : null), // async/await available from v55 63 | }, 64 | }, 65 | }), 66 | new CopyPlugin([ 67 | { 68 | from: 'static', 69 | ignore: ['svgs/*'], // SVGs get bundled in directly 70 | }, 71 | ]), 72 | !dev && 73 | new ZipPlugin({ 74 | path: '../zip', 75 | filename: `ReTitle-${pkg.version}.${browser}.zip`, 76 | }), 77 | ].filter(Boolean), 78 | resolve: { 79 | extensions: ['.js', 'jsx', '.ts', '.tsx'], 80 | alias: { 81 | react: 'preact/compat', 82 | 'react-dom/test-utils': 'preact/test-utils', 83 | 'react-dom': 'preact/compat', 84 | }, 85 | }, 86 | }; 87 | }; 88 | -------------------------------------------------------------------------------- /src/shared/injectedScripts.ts: -------------------------------------------------------------------------------- 1 | import { TabOption } from './types'; 2 | 3 | export function injectTitle(newTitle: string, option: TabOption) { 4 | const META_OPTION = 'retitle:option'; 5 | const META_ORIGINAL = 'retitle:original'; 6 | const META_MODIFIED = 'retitle:modified'; 7 | 8 | let headElement: HTMLHeadElement = document.head; 9 | if (!headElement) { 10 | headElement = document.createElement('head'); 11 | const docEl = document.documentElement; 12 | docEl.insertBefore(headElement, docEl.firstChild); 13 | } 14 | 15 | function getOrCreateMeta(name: string, content: string): HTMLMetaElement { 16 | let meta: HTMLMetaElement | null = document.querySelector( 17 | `meta[name='${name}']` 18 | ); 19 | if (!meta) { 20 | meta = document.createElement('meta'); 21 | meta.setAttribute('name', name); 22 | meta.setAttribute('content', content); 23 | headElement.appendChild(meta); 24 | } 25 | return meta; 26 | } 27 | 28 | const metaOption = getOrCreateMeta(META_OPTION, option); 29 | const metaContent = metaOption.getAttribute('content'); 30 | if (metaContent !== option) { 31 | metaOption.setAttribute('content', option); 32 | } 33 | 34 | if (document.querySelector('head > title')) { 35 | const metaOriginal = getOrCreateMeta(META_ORIGINAL, document.title); 36 | if (newTitle.includes('$0')) { 37 | // Make sure it doesn't use the cached old title and as an original title. 38 | const originalTitle = metaOriginal.getAttribute('content'); 39 | if (originalTitle) { 40 | newTitle = newTitle.replace('$0', originalTitle); // the first $0 turns into the previous title 41 | } 42 | } 43 | document.title = newTitle; 44 | } else { 45 | const titleElement = document.createElement('title'); 46 | titleElement.appendChild(document.createTextNode(newTitle)); 47 | headElement.appendChild(titleElement); 48 | } 49 | } 50 | 51 | export function getCurrentOption() { 52 | const META_OPTION = 'retitle:option'; 53 | const META_ORIGINAL = 'retitle:original'; 54 | const metaOption = document.querySelector(`meta[name='${META_OPTION}']`); 55 | const metaOriginal = document.querySelector(`meta[name='${META_ORIGINAL}']`); 56 | if (metaOption && metaOriginal) { 57 | return [ 58 | metaOption.getAttribute('content'), 59 | metaOriginal.getAttribute('content'), 60 | ] as const; 61 | } 62 | return null; 63 | } 64 | 65 | export function revertRetitle() { 66 | const META_ORIGINAL = 'retitle:original'; 67 | const metaOriginal = document.querySelector(`meta[name='${META_ORIGINAL}']`); 68 | if (metaOriginal) { 69 | document.title = metaOriginal.getAttribute('content') || ''; 70 | document 71 | .querySelectorAll('meta[name^="retitle"]') 72 | .forEach((n) => n.parentNode?.removeChild(n)); 73 | return true; 74 | } 75 | return false; 76 | } 77 | -------------------------------------------------------------------------------- /src/shared/ReTitleThemeWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { FC, useState, useMemo, useEffect, useCallback } from 'preact/compat'; 3 | import { 4 | createMuiTheme, 5 | MuiThemeProvider, 6 | makeStyles, 7 | } from '@material-ui/core/styles'; 8 | import CssBaseline from '@material-ui/core/CssBaseline'; 9 | import { StorageChanges, ThemeState } from './types'; 10 | import { KEY_THEME } from './utils'; 11 | import { getTheme } from './storageHandler'; 12 | 13 | const StyleFix = { 14 | overrides: { 15 | MuiTooltip: { 16 | tooltip: { 17 | fontSize: '14px', 18 | }, 19 | tooltipPlacementBottom: { 20 | margin: '10px', 21 | }, 22 | }, 23 | }, 24 | props: { 25 | MuiButtonBase: { 26 | disableRipple: true, 27 | }, 28 | }, 29 | }; 30 | 31 | const lightTheme = createMuiTheme({ 32 | palette: { 33 | type: 'light', 34 | primary: { 35 | main: '#688fe6', 36 | }, 37 | secondary: { 38 | main: '#ec4fc4', 39 | }, 40 | }, 41 | ...StyleFix, 42 | }); 43 | 44 | const darkTheme = createMuiTheme({ 45 | palette: { 46 | type: 'dark', 47 | primary: { 48 | main: '#47bfff', 49 | }, 50 | secondary: { 51 | main: '#ffaf47', 52 | }, 53 | }, 54 | ...StyleFix, 55 | }); 56 | 57 | const globalStyles = makeStyles({ 58 | '@global': { 59 | code: { 60 | display: 'inline-block', 61 | padding: '2px 5px', 62 | borderRadius: '5px', 63 | color: 'white', 64 | background: '#232323', 65 | margin: '0 3px', 66 | fontFamily: '"Menlo", "Lucida Console", monospace', 67 | fontSize: '0.8em', 68 | }, 69 | }, 70 | }); 71 | 72 | const ReTitleThemeWrapper: FC = ({ children }) => { 73 | const [theme, setTheme] = useState( 74 | // Using localStorage as cache since it is way faster than using the storage API 75 | (localStorage.getItem('theme') as ThemeState) || 'dark' 76 | ); 77 | globalStyles(); 78 | 79 | useEffect(() => { 80 | getTheme().then((storedTheme) => { 81 | if (storedTheme) { 82 | setTheme(storedTheme); 83 | localStorage.setItem('theme', storedTheme); 84 | } 85 | }); 86 | }, []); 87 | 88 | const storageChangeHandler = useCallback((changes: StorageChanges) => { 89 | if (changes[KEY_THEME]) { 90 | const newTheme = changes[KEY_THEME].newValue; 91 | if (newTheme) { 92 | setTheme(newTheme); 93 | localStorage.setItem('theme', newTheme); 94 | } 95 | } 96 | }, []); 97 | 98 | useEffect(() => { 99 | chrome.storage.onChanged.addListener(storageChangeHandler); 100 | return () => chrome.storage.onChanged.removeListener(storageChangeHandler); 101 | }, [storageChangeHandler]); 102 | return ( 103 | 104 | 105 | {children} 106 | 107 | ); 108 | }; 109 | 110 | export default ReTitleThemeWrapper; 111 | -------------------------------------------------------------------------------- /src/options/Home.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { useState } from 'preact/compat'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import Container from '@material-ui/core/Container'; 5 | import Tabs from '@material-ui/core/Tabs'; 6 | import Tab from '@material-ui/core/Tab'; 7 | import Box from '@material-ui/core/Box'; 8 | import Typography from '@material-ui/core/Typography'; 9 | 10 | import Header from './Header'; 11 | import SavedTitles from './SavedTitles'; 12 | import UserSettings from './UserSettings'; 13 | import Footer from './Footer'; 14 | import AdvancedSettings from './AdvancedSettings'; 15 | 16 | const useStyles = makeStyles((theme) => ({ 17 | root: { 18 | display: 'flex', 19 | flexDirection: 'column', 20 | height: '100vh', 21 | }, 22 | main: { 23 | flex: '1', 24 | overflow: 'auto', 25 | }, 26 | tabRoot: { 27 | flexGrow: 1, 28 | backgroundColor: theme.palette.background.paper, 29 | display: 'flex', 30 | height: 224, 31 | }, 32 | tabs: { 33 | borderRight: `1px solid ${theme.palette.divider}`, 34 | }, 35 | })); 36 | 37 | function a11yProps(index: number) { 38 | return { 39 | id: `vertical-tab-${index}`, 40 | 'aria-controls': `vertical-tabpanel-${index}`, 41 | }; 42 | } 43 | 44 | interface TabPanelProps { 45 | children?: any; 46 | index: number; 47 | value: any; 48 | } 49 | 50 | function TabPanel(props: TabPanelProps) { 51 | const { children, value, index, ...other } = props; 52 | 53 | return ( 54 | 67 | ); 68 | } 69 | 70 | const Home = () => { 71 | const styles = useStyles(); 72 | const [tab, setTab] = useState(0); 73 | 74 | const handleChange = (event: any, newValue: number) => { 75 | setTab(newValue); 76 | }; 77 | 78 | return ( 79 |
80 |
81 | 82 | 83 | 84 | 85 | 86 |
87 |
88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 |
100 |
101 |
102 | ); 103 | }; 104 | 105 | export default Home; 106 | -------------------------------------------------------------------------------- /src/popup/ContentScriptChecker.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { useState, useEffect, FC } from 'preact/compat'; 3 | const isChrome = BROWSER === 'chrome'; 4 | 5 | // Taken from https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Content_scripts 6 | const FF_BLOCKED_SITES = [ 7 | 'accounts-static.cdn.mozilla.net', 8 | 'accounts.firefox.com', 9 | 'addons.cdn.mozilla.net', 10 | 'addons.mozilla.org', 11 | 'api.accounts.firefox.com', 12 | 'content.cdn.mozilla.net', 13 | 'content.cdn.mozilla.net', 14 | 'discovery.addons.mozilla.org', 15 | 'input.mozilla.org', 16 | 'install.mozilla.org', 17 | 'oauth.accounts.firefox.com', 18 | 'profile.accounts.firefox.com', 19 | 'support.mozilla.org', 20 | 'sync.services.mozilla.com', 21 | 'testpilot.firefox.com', 22 | ]; 23 | 24 | const ContentScriptChecker: FC<{ 25 | domain: string; 26 | url?: string; 27 | className: string; 28 | }> = ({ domain, url, className, children }) => { 29 | const [isAllowed, setIsAllowed] = useState(true); 30 | useEffect(() => { 31 | browser.tabs.executeScript({ code: '' }).catch(() => { 32 | setIsAllowed(false); 33 | }); 34 | }, []); 35 | if (isChrome) { 36 | if (url && /^(chrome|chrome-extension):/.test(url)) { 37 | return ( 38 |
39 | Extensions are disabled on browser internal URLs 40 |
41 | ); 42 | } 43 | } else { 44 | if (url) { 45 | if (/^(about|moz-extension):/.test(url)) { 46 | return ( 47 |
48 | Add-ons are disabled on browser internal URLs 49 |
50 | ); 51 | } 52 | if (!isAllowed) { 53 | const sanitizedURL = url.split('?')[0].split('#')[0]; 54 | if (/(\.json|\.pdf)$/i.test(sanitizedURL)) { 55 | return ( 56 |
57 | PDFs and JSON files cannot be accessed by add-ons on Firefox for{' '} 58 | 62 | security reasons 63 | 64 | . You can still use this extension with those files on Google 65 | Chrome. 66 |
67 | ); 68 | } else if (FF_BLOCKED_SITES.includes(domain)) { 69 | return ( 70 |
71 | Firefox add-ons cannot access 72 |
73 | {domain} 74 |
75 | websites.{' '} 76 | 80 | Details 81 | 82 |
83 | ); 84 | } else { 85 | return ( 86 |
87 | This extension does not work on this page 88 |
89 | ); 90 | } 91 | } 92 | } 93 | } 94 | return
{children}
; 95 | }; 96 | 97 | export default ContentScriptChecker; 98 | -------------------------------------------------------------------------------- /src/shared/storageUtils.ts: -------------------------------------------------------------------------------- 1 | export type GeneralStorageType = { [key: string]: unknown }; 2 | 3 | type StorageArea = 'sync' | 'local'; 4 | 5 | async function getStorage( 6 | area: StorageArea, 7 | transformer: (items: GeneralStorageType) => T, 8 | keys?: string | string[] 9 | ): Promise { 10 | if (BROWSER === 'firefox') { 11 | return transformer(await browser.storage[area].get(keys)); 12 | } 13 | return new Promise((resolve) => { 14 | chrome.storage[area].get(keys || null, (items?: GeneralStorageType) => { 15 | resolve(transformer?.(items || {})); 16 | }); 17 | }); 18 | } 19 | 20 | async function modifyStorage( 21 | area: StorageArea, 22 | method: 'set', 23 | keys: GeneralStorageType 24 | ): Promise; 25 | async function modifyStorage( 26 | area: StorageArea, 27 | method: 'remove', 28 | keys: string | string[] 29 | ): Promise; 30 | 31 | async function modifyStorage( 32 | area: StorageArea, 33 | method: 'set' | 'remove', 34 | keys: string | string[] | GeneralStorageType 35 | ) { 36 | if (BROWSER === 'firefox') { 37 | return browser.storage[area][method](keys as any); 38 | } 39 | return new Promise((resolve) => { 40 | chrome.storage[area][method](keys as any, resolve); 41 | }); 42 | } 43 | 44 | /** 45 | * Get all items 46 | */ 47 | export function getAllSyncItems(): Promise { 48 | return getStorage('sync', (i) => i); 49 | } 50 | 51 | export function getAllLocalItems(): Promise { 52 | return getStorage('local', (i) => i); 53 | } 54 | 55 | /** 56 | * Get a single item with the key null otherwise 57 | */ 58 | export function getSyncItem(key: string): Promise { 59 | return getStorage('sync', (items) => (items[key] as T) || null, key); 60 | } 61 | 62 | export function getLocalItem(key: string): Promise { 63 | return getStorage('local', (items) => (items[key] as T) || null, key); 64 | } 65 | 66 | /** 67 | * Get items with multiple keys 68 | */ 69 | export function getSyncItems( 70 | keys: string[] 71 | ): Promise { 72 | return getStorage('sync', (i) => i as T, keys); 73 | } 74 | export function getLocalItems( 75 | keys: string[] 76 | ): Promise { 77 | return getStorage('local', (i) => i as T, keys); 78 | } 79 | 80 | /** 81 | * Set a key value pair 82 | */ 83 | export function setSyncItem(key: string, value: any): Promise { 84 | return modifyStorage('sync', 'set', { [key]: value }); 85 | } 86 | 87 | export function setLocalItem(key: string, value: any): Promise { 88 | return modifyStorage('local', 'set', { [key]: value }); 89 | } 90 | 91 | /** 92 | * Set key value pairs 93 | */ 94 | export function setSyncItems(items: Record): Promise { 95 | return modifyStorage('sync', 'set', items); 96 | } 97 | export function setLocalItems(items: Record): Promise { 98 | return modifyStorage('local', 'set', items); 99 | } 100 | 101 | /** 102 | * Remove items with the specified keys 103 | */ 104 | export function removeSyncItems(keys: string | string[]): Promise { 105 | return modifyStorage('sync', 'remove', keys); 106 | } 107 | 108 | export function removeLocalItems(keys: string | string[]): Promise { 109 | return modifyStorage('local', 'remove', keys); 110 | } 111 | -------------------------------------------------------------------------------- /src/options/RegexPopup.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | const AdvancedSettings = () => { 4 | const regex101 = 5 | 'https://regex101.com/?flavor=javascript®ex=reddit%5C.com%5C%2Fr%5C%2F(%5B%5E%2F%5D%2B)'; 6 | return ( 7 |
8 |
    9 |
  • 10 |

    11 | Regeular Expression (regex) Replacement 12 |

    13 |
    Regex is a powerful tool.
    14 |
    15 | 16 |
    17 | Regex 101 18 |
    19 | The syntax is
    20 | /regex/replacement/flags 21 |
    22 | Notice that there are three forward slashes, so if you want to use 23 | a slash in regex or replacement, you need to escape it with a 24 | backward slash \.
    25 | In replacement, you can use regex's captured groups with 26 | $1, $2 and so on. 27 |
    28 | Possible flags are "g" for global, and "i" for ignore case. Do not 29 | forget the last slash if not using any flags. 30 |
    31 | Examples: 32 |
    33 | /.*/Lazy/ is the same as just setting the title to 34 | "Lazy". 35 |
    36 | /(.*)/LAZ $1/ will replace "old title" to "LAZ old 37 | title". 38 |
    39 | /(.*)/r\/$1/ will replace "Lazy" to "r/Lazy". 40 |
    41 | /([a-z])/0$1/gi will replace "sPonGe" to 42 | "0s0P0o0n0G0e" 43 |
    44 | /f([^o]+)(.*)/FB $2$1/i will replace "Facebook" to 45 | "FB ookaceb" (but why) 46 |
    47 |
    48 |
  • 49 |
  • 50 |
    51 | Beta Feature: Add Your Own URL Pattern 52 |
    53 |
    54 | 55 |
    This feature may not work as expected!
    56 |
    57 | Another beta feature that lets you create your own URL pattern 58 | match. 59 |
    60 | Note that regex matching has the lowest priority when searching 61 | for a URL match. 62 |
    63 | The URL pattern must start and end with an asterisk 64 | * 65 |
    66 | Instead of using $1, $2 to use capture groups, use ${1}, ${2}{' '} 67 | instead for URLs. 68 |
    69 | Examples:
    70 | *reddit\.com/(r/[^/]+)* | Red ${1} will change 71 | https://www.reddit.com/r/funny to 72 | Red r/funny It can be combined with the title regex 73 | mentioned above too. 74 |
    75 | 76 | *\.([^.]+)\.com/(.*)* | /(.*)/${1} $1 ${2}/ 77 | 78 | will change https://www.reddit.com/r/funny to 79 | reddit funny r/funny
    80 |
    81 |
    82 |
    83 |
  • 84 |
85 |
86 | ); 87 | }; 88 | 89 | export default AdvancedSettings; 90 | -------------------------------------------------------------------------------- /src/options/AdvancedSettings.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | const AdvancedSettings = () => { 4 | return ( 5 |
6 |
    7 |
  • 8 |
    Beta Feature: Regex Replacement
    9 |
    10 | 11 |
    This feature may not work as expected!
    12 |
    13 | There's a beta feature that lets you replace titles using regex. 14 | If you do not know what regex is, I don't recommend using this 15 | feature. 16 |
    17 | The syntax is
    18 | /regex/replacement/flags 19 |
    20 | Notice that there are three forward slashes, so if you want to use 21 | a slash in regex or replacement, you need to escape it with a 22 | backward slash \.
    23 | In replacement, you can use regex's captured groups with 24 | $1, $2 and so on. 25 |
    26 | Possible flags are "g" for global, and "i" for ignore case. Do not 27 | forget the last slash if not using any flags. 28 |
    29 | Examples: 30 |
    31 | /.*/Lazy/ is the same as just setting the title to 32 | "Lazy". 33 |
    34 | /(.*)/LAZ $1/ will replace "old title" to "LAZ old 35 | title". 36 |
    37 | /(.*)/r\/$1/ will replace "Lazy" to "r/Lazy". 38 |
    39 | /([a-z])/0$1/gi will replace "sPonGe" to 40 | "0s0P0o0n0G0e" 41 |
    42 | /f([^o]+)(.*)/FB $2$1/i will replace "Facebook" to 43 | "FB ookaceb" (but why) 44 |
    45 |
    46 |
  • 47 |
  • 48 |
    49 | Beta Feature: Add Your Own URL Pattern 50 |
    51 |
    52 | 53 |
    This feature may not work as expected!
    54 |
    55 | Another beta feature that lets you create your own URL pattern 56 | match. 57 |
    58 | Note that regex matching has the lowest priority when searching 59 | for a URL match. 60 |
    61 | The URL pattern must start and end with an asterisk 62 | * 63 |
    64 | Instead of using $1, $2 to use capture groups, use ${1}, ${2}{' '} 65 | instead for URLs. 66 |
    67 | Examples:
    68 | *reddit\.com/(r/[^/]+)* | Red ${1} will change 69 | https://www.reddit.com/r/funny to 70 | Red r/funny It can be combined with the title regex 71 | mentioned above too. 72 |
    73 | 74 | *\.([^.]+)\.com/(.*)* | /(.*)/${1} $1 ${2}/ 75 | 76 | will change https://www.reddit.com/r/funny to 77 | reddit funny r/funny
    78 |
    79 |
    80 | 81 |
    82 |
    83 | 87 | 88 | 92 |
    93 |
    94 | 98 | 99 |
    100 |
    101 |
    102 | Add Patterns 103 | 104 | check 105 | 106 |
    107 | 108 |
    109 |
    110 |
    111 |
  • 112 |
113 |
114 | ); 115 | }; 116 | 117 | export default AdvancedSettings; 118 | -------------------------------------------------------------------------------- /src/background/manageTablock.ts: -------------------------------------------------------------------------------- 1 | import { PREFIX_TABLOCK } from '../shared/utils'; 2 | import { 3 | setLocalItems, 4 | getAllLocalItems, 5 | setLocalItem, 6 | removeLocalItems, 7 | } from '../shared/storageUtils'; 8 | 9 | const isChrome = BROWSER === 'chrome'; 10 | 11 | interface TablockCache { 12 | tabId: number; 13 | windowId: number; 14 | index: number; 15 | title: string; 16 | url?: string; 17 | } 18 | interface TablockCaches { 19 | [tabId: number]: TablockCache; 20 | } 21 | 22 | // Map tab position to IDs so that Tablock persists through sessions. 23 | const tablockCaches: TablockCaches = {}; 24 | const previousTablockCaches: TablockCache[] = []; 25 | let previousCrashed = false; 26 | 27 | const debounce = (func: (...args: T) => any, wait = 250) => { 28 | let timer: any; 29 | return (...args: T) => { 30 | clearTimeout(timer); 31 | timer = setTimeout(() => func(...args), wait); 32 | }; 33 | }; 34 | 35 | function _recalculateTabPositionToId(windowId: number) { 36 | const tabIds = Object.keys(tablockCaches).map(parseInt); 37 | chrome.tabs.query({ windowId }, function (tabs) { 38 | tabs.forEach((tab) => { 39 | const { index, id: tabId, url } = tab; 40 | if (!tabId) return; 41 | if (tabIds.includes(tabId)) { 42 | const prev = tablockCaches[tabId]; 43 | tablockCaches[tabId] = { 44 | tabId, 45 | windowId, 46 | index, 47 | url, 48 | title: prev.title, 49 | }; 50 | } 51 | }); 52 | // use local storage since it should not be synced 53 | setLocalItems({ 54 | tablockCaches, 55 | }); 56 | }); 57 | } 58 | 59 | const recalculateTabPositionToId = debounce( 60 | (windowId: number) => _recalculateTabPositionToId(windowId), 61 | 200 62 | ); 63 | 64 | // Restore stored tablocks if possible 65 | chrome.windows.onCreated.addListener(function (window) { 66 | if (window.type !== 'normal') return; 67 | window.tabs?.forEach((tab) => { 68 | if (tab.id === undefined || tab.id in tablockCaches) return; 69 | const matchedTab = previousTablockCaches.find( 70 | (t) => t.url === tab.url && t.index === tab.index 71 | ); 72 | if (!matchedTab) return; 73 | const obj = { 74 | ...matchedTab, 75 | windowId: window.id, 76 | }; 77 | setLocalItem(`${PREFIX_TABLOCK}${tab.id}`, obj); 78 | tablockCaches[tab.id] = obj; 79 | }); 80 | }); 81 | 82 | chrome.tabs.onCreated.addListener(function (tab) { 83 | recalculateTabPositionToId(tab.windowId); 84 | }); 85 | 86 | // When closing a tab, clean up tab lock titles 87 | chrome.tabs.onRemoved.addListener(function (tabId, info) { 88 | // Do not delete Tablock info when the window is closing. 89 | if (!info.isWindowClosing) { 90 | recalculateTabPositionToId(info.windowId); 91 | removeLocalItems(`${PREFIX_TABLOCK}${tabId}`); 92 | } 93 | }); 94 | 95 | // Keep track of tab position to tabID 96 | chrome.tabs.onMoved.addListener(function (tabId, info) { 97 | const windowId = info.windowId; 98 | // no need for debounce 99 | _recalculateTabPositionToId(windowId); 100 | }); 101 | 102 | chrome.tabs.onDetached.addListener(function (tabId, info) { 103 | const windowId = info.oldWindowId; 104 | // no need for debounce 105 | _recalculateTabPositionToId(windowId); 106 | }); 107 | chrome.tabs.onAttached.addListener(function (tabId, info) { 108 | const windowId = info.newWindowId; 109 | // no need for debounce 110 | _recalculateTabPositionToId(windowId); 111 | }); 112 | 113 | chrome.tabs.onUpdated.addListener(function (tabId, info, tab) { 114 | if (tabId in tablockCaches) { 115 | tablockCaches[tabId] = { 116 | tabId, 117 | windowId: tab.windowId, 118 | index: tab.index, 119 | title: tab.title || '', 120 | url: tab.url, 121 | }; 122 | } 123 | }); 124 | 125 | // Get tablock caches when extension startsup 126 | chrome.runtime.onStartup.addListener(function () { 127 | // tablock caches are stored locally 128 | getAllLocalItems().then(function (items) { 129 | const hasCrashed = items['crash'] as boolean | undefined; 130 | const tlc = items['tablockCaches'] as Record; 131 | Object.keys(tlc).forEach((tabId) => { 132 | previousTablockCaches.push({ 133 | ...tlc[tabId], 134 | }); 135 | }); 136 | previousCrashed = hasCrashed || false; 137 | setLocalItem('crash', true); 138 | }); 139 | getAllLocalItems().then(function (items) { 140 | // Clean up residual Tablock keys stored in storage, since we fill those up through cache 141 | Object.keys(items).forEach((itemKey) => { 142 | if (itemKey.startsWith(PREFIX_TABLOCK)) { 143 | removeLocalItems(itemKey); 144 | } 145 | }); 146 | }); 147 | }); 148 | 149 | if (isChrome) { 150 | // Indicate that all states are saved successfully 151 | chrome.runtime.onSuspend.addListener(function () { 152 | removeLocalItems('crash'); 153 | }); 154 | 155 | // // Flag that won't be cleaned if crashed 156 | chrome.runtime.onSuspendCanceled.addListener(function () { 157 | setLocalItem('crash', true); 158 | }); 159 | } 160 | -------------------------------------------------------------------------------- /src/options/UserSettings.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { useState, useEffect, useCallback } from 'preact/compat'; 3 | import Radio from '@material-ui/core/Radio'; 4 | import RadioGroup from '@material-ui/core/RadioGroup'; 5 | import FormControlLabel from '@material-ui/core/FormControlLabel'; 6 | import FormControl from '@material-ui/core/FormControl'; 7 | import Switch from '@material-ui/core/Switch'; 8 | import { makeStyles } from '@material-ui/core/styles'; 9 | 10 | import ContextMenuSwitch from './ContextMenuSwitch'; 11 | import KeyboardShortcutSettings from './KeyboardShortcutSettings'; 12 | import { TabOption } from '../shared/types'; 13 | import { 14 | getDefaultOption, 15 | setDefaultOption, 16 | setTheme, 17 | getTheme, 18 | } from '../shared/storageHandler'; 19 | 20 | const useStyles = makeStyles((theme) => ({ 21 | root: { 22 | fontSize: '0.8em', 23 | borderTop: `1px solid ${theme.palette.primary.main}`, 24 | }, 25 | label: { 26 | margin: '0 12px 0 16px', 27 | fontWeight: 600, 28 | }, 29 | radios: { 30 | margin: '10px 0', 31 | paddingLeft: '20px', 32 | }, 33 | button: { 34 | textAlign: 'center', 35 | }, 36 | saved: { 37 | opacity: 0, 38 | zIndex: -1, 39 | position: 'absolute', 40 | marginTop: '4px', 41 | marginLeft: '-20px', 42 | display: 'inline-block', 43 | transition: '0.2s', 44 | '&[data-shown="true"]': { 45 | opacity: 1, 46 | marginLeft: '10px', 47 | }, 48 | }, 49 | check: { 50 | verticalAlign: 'middle', 51 | color: theme.palette.success.main, 52 | }, 53 | })); 54 | 55 | const UserSettings = () => { 56 | const styles = useStyles(); 57 | const [option, setOption] = useState('onetime'); 58 | const [isDark, setIsDark] = useState( 59 | localStorage.getItem('theme') !== 'light' 60 | ); 61 | 62 | useEffect(() => { 63 | getDefaultOption().then(setDefaultOption); 64 | getTheme().then((theme) => setIsDark(theme === 'dark')); 65 | }, []); 66 | 67 | const handleOptionChange = useCallback((e: any) => { 68 | const newOption: TabOption = e.target.value; 69 | setOption(newOption); 70 | setDefaultOption(newOption); 71 | }, []); 72 | 73 | const toggleTheme = () => { 74 | setTheme(isDark ? 'light' : 'dark'); 75 | setIsDark(!isDark); 76 | }; 77 | 78 | return ( 79 |
80 |
81 | {/* Popup flickers when using light theme, so stick to dark theme until I figure out a workaround */} 82 | 89 | } 90 | labelPlacement="start" 91 | label={} 92 | /> 93 |
94 | 95 | 96 |
97 |
98 | 99 | 100 |
101 |
102 | 103 |

104 | This option will be used as the default value in the extension popup 105 | menu. These options are in the order of priority, so for example{' '} 106 | Set for this tab will be matched instead of 107 | Only exact match if the given tab matches both. 108 |

109 | 110 | 116 | } 119 | label={ 120 |
121 | Set it temporarily 122 | Temporarily sets the title just once which does not persist at 123 | all. Reloading or changing the URL loses the changed title. 124 |
125 | } 126 | /> 127 | } 130 | label={ 131 |
132 | Set for this tab 133 | This will match the current tab no matter the URL, but will be 134 | lost once the tab is closed. This will persist if you close the 135 | window and reopen it with previous tabs. However, if the browser 136 | crashes or the window didn't load the tabs on startup then this 137 | settings will be lost. 138 |
139 | } 140 | /> 141 | } 144 | label={ 145 |
146 | Set for this exact URL 147 | This will match the URL exactly and it will be persistent across 148 | sessions. You can set this to ignore URL parameters such as{' '} 149 | #, &, and ? to be 150 | ignored in the saved titles page. 151 |
152 | } 153 | /> 154 | } 157 | label={ 158 |
159 | Set for this domain 160 | This will match the domain part of the URL and it will be 161 | persistent across sessions. 162 |
163 | } 164 | /> 165 |
166 |
167 |
168 | ); 169 | }; 170 | 171 | export default UserSettings; 172 | -------------------------------------------------------------------------------- /src/options/SavedTitles.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { 3 | useState, 4 | useEffect, 5 | useCallback, 6 | useMemo, 7 | StateUpdater, 8 | } from 'preact/compat'; 9 | import { makeStyles } from '@material-ui/core/styles'; 10 | import List from '@material-ui/core/List'; 11 | import ListItem from '@material-ui/core/ListItem'; 12 | import ListItemIcon from '@material-ui/core/ListItemIcon'; 13 | import ListItemText from '@material-ui/core/ListItemText'; 14 | import DeleteIcon from '@material-ui/icons/Delete'; 15 | import EditIcon from '@material-ui/icons/Edit'; 16 | 17 | import { 18 | StorageChanges, 19 | TabLockTitle, 20 | ExactTitle, 21 | DomainTitle, 22 | RegexTitle, 23 | StorageAction, 24 | StoredTitle, 25 | } from '../shared/types'; 26 | import { storageChangeHandler, initTitles } from '../shared/storageHandler'; 27 | 28 | const noop = () => {}; 29 | 30 | const useStyles = makeStyles((theme) => ({ 31 | root: { 32 | position: 'relative', 33 | }, 34 | })); 35 | 36 | const editTitleState = ( 37 | predicate: (title: T) => boolean, 38 | newTitle: T 39 | ) => (previous: T[]) => { 40 | const index = previous.findIndex(predicate); 41 | if (index >= 0) { 42 | const copy = [...previous]; 43 | copy[index] = newTitle; 44 | return copy; 45 | } else { 46 | return previous; 47 | } 48 | }; 49 | const removeTitleState = ( 50 | predicate: (title: T) => boolean 51 | ) => (previous: T[]) => { 52 | const index = previous.findIndex(predicate); 53 | if (index >= 0) { 54 | const copy = [...previous].splice(index, 1); 55 | return copy; 56 | } else { 57 | return previous; 58 | } 59 | }; 60 | 61 | const generateCallback = ( 62 | stateUpdater: StateUpdater, 63 | predicate: (t1: T, t2: T) => boolean 64 | ) => (action: StorageAction, newTitle: T) => { 65 | switch (action) { 66 | case 'add': 67 | stateUpdater((p) => [...p, newTitle]); 68 | break; 69 | case 'remove': 70 | stateUpdater(removeTitleState((t) => predicate(t, newTitle))); 71 | case 'edit': 72 | stateUpdater(editTitleState((t) => predicate(t, newTitle), newTitle)); 73 | } 74 | }; 75 | 76 | const SavedTitles = () => { 77 | const styles = useStyles(); 78 | const [tabLocks, setTablocks] = useState([]); 79 | const [exacts, setExacts] = useState([]); 80 | const [domains, setDomains] = useState([]); 81 | const [regexes, setRegexes] = useState([]); 82 | 83 | const onTablockChange = useCallback( 84 | generateCallback(setTablocks, (t1, t2) => t1.tabId === t2.tabId), 85 | [] 86 | ); 87 | 88 | const onExactChange = useCallback( 89 | generateCallback(setExacts, (t1, t2) => t1.url === t2.url), 90 | [] 91 | ); 92 | 93 | const onDomainChange = useCallback( 94 | generateCallback(setDomains, (t1, t2) => t1.domain === t2.domain), 95 | [] 96 | ); 97 | 98 | const onRegexChange = useCallback( 99 | generateCallback(setRegexes, (t1, t2) => t1.urlPattern === t2.urlPattern), 100 | [] 101 | ); 102 | 103 | useEffect(() => { 104 | const handler = storageChangeHandler({ 105 | onTablockChange, 106 | onExactChange, 107 | onDomainChange, 108 | onRegexChange, 109 | }); 110 | initTitles({ 111 | onTablockChange, 112 | onExactChange, 113 | onDomainChange, 114 | onRegexChange, 115 | }); 116 | chrome.storage.onChanged.addListener(handler); 117 | return () => chrome.storage.onChanged.removeListener(handler); 118 | }, []); 119 | 120 | return ( 121 |
122 |
    123 |
  • Temporary titles are not shown here
  • 124 |
  • 125 | Use $0 to insert the original title. So if you want 126 | Title to say My Title, set the title name to{' '} 127 | My $0. 128 |
  • 129 |
130 |

Tabs

131 | 132 | {tabLocks.map((t) => { 133 | return ( 134 | 135 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | ); 146 | })} 147 | 148 |

Exact URLs

149 | 150 | {exacts.map((t) => { 151 | return ( 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | ); 162 | })} 163 | 164 |

Domains

165 | 166 | {domains.map((t) => { 167 | return ( 168 | 169 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | ); 180 | })} 181 | 182 |

Regexes

183 | 184 | {regexes.map((t) => { 185 | return ( 186 | 187 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | ); 198 | })} 199 | 200 |
201 | ); 202 | }; 203 | 204 | export default SavedTitles; 205 | -------------------------------------------------------------------------------- /src/popup/Form.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { useState, useEffect, useRef, useCallback } from 'preact/compat'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import TextField from '@material-ui/core/TextField'; 5 | import Radio from '@material-ui/core/Radio'; 6 | import RadioGroup from '@material-ui/core/RadioGroup'; 7 | import FormControlLabel from '@material-ui/core/FormControlLabel'; 8 | import FormControl from '@material-ui/core/FormControl'; 9 | import Button from '@material-ui/core/Button'; 10 | import Switch from '@material-ui/core/Switch'; 11 | 12 | import ContentScriptChecker from './ContentScriptChecker'; 13 | import Revert from './Revert'; 14 | import Gear from './Gear'; 15 | import CurrentTitle from './CurrentTitle'; 16 | import BookmarkTitle from './BookmarkTitle'; 17 | import { extractDomain } from '../shared/utils'; 18 | import { TabOption } from '../shared/types'; 19 | import { 20 | saveTitle, 21 | getDefaultOption, 22 | setDefaultOption, 23 | } from '../shared/storageHandler'; 24 | import RegexInputGroup from '../shared/RegexInputGroup'; 25 | import { getCurrentOption } from '../shared/injectedScripts'; 26 | 27 | const isChrome = BROWSER === 'chrome'; 28 | 29 | const useStyles = makeStyles({ 30 | root: { 31 | width: '450px', 32 | padding: '20px', 33 | overflow: 'hidden', 34 | }, 35 | warning: { 36 | padding: '40px 20px', 37 | fontSize: '18px', 38 | textAlign: 'center', 39 | '& a': { 40 | color: 'orange !important', 41 | }, 42 | }, 43 | input: { 44 | width: '100%', 45 | }, 46 | radios: { 47 | margin: '10px 0', 48 | paddingLeft: '20px', 49 | }, 50 | button: { 51 | display: 'block', 52 | margin: '0 auto', 53 | }, 54 | version: { 55 | position: 'absolute', 56 | bottom: '20px', 57 | right: '20px', 58 | opacity: 0.5, 59 | fontSize: '12px', 60 | }, 61 | }); 62 | 63 | const Form = () => { 64 | const [tab, setTab] = useState(null); 65 | const [option, setOption] = useState('onetime'); 66 | const [activeOption, setActiveOption] = useState(null); 67 | const [useRegex, setUseRegex] = useState(false); 68 | const [inputValue, setInputValue] = useState(''); 69 | const inputRef = useRef(null); 70 | const styles = useStyles(); 71 | 72 | useEffect(() => { 73 | if (tab && typeof tab.id === 'number') { 74 | chrome.tabs.executeScript( 75 | tab.id, 76 | { 77 | code: `${getCurrentOption.toString()}; getCurrentOption();`, 78 | runAt: 'document_end', 79 | }, 80 | (results) => { 81 | const result = results?.[0]; 82 | if (result) { 83 | setActiveOption(result); 84 | } 85 | } 86 | ); 87 | } 88 | }, [tab]); 89 | 90 | const setInputAndSelect = useCallback( 91 | (newInput?: string) => { 92 | setInputValue(newInput || ''); 93 | setTimeout(() => { 94 | inputRef?.current?.select(); 95 | }, 0); 96 | }, 97 | [inputRef] 98 | ); 99 | 100 | const initialize = useCallback( 101 | (tabs: chrome.tabs.Tab[]) => { 102 | const currentTab = tabs[0]; 103 | setTab(currentTab); 104 | setInputAndSelect(currentTab.title || ''); 105 | getDefaultOption().then(setDefaultOption); 106 | }, 107 | [setInputAndSelect] 108 | ); 109 | 110 | useEffect(() => { 111 | chrome.tabs.query( 112 | { 113 | active: true, 114 | currentWindow: true, 115 | }, 116 | initialize 117 | ); 118 | }, [initialize]); 119 | 120 | const handleOptionChange = useCallback((e: any) => { 121 | setOption(e.target.value); 122 | }, []); 123 | 124 | const setTitle = useCallback(() => { 125 | if (tab) { 126 | saveTitle(inputValue, option, tab); 127 | if (isChrome) { 128 | chrome.runtime.sendMessage({ 129 | type: 'rename', 130 | tabId: tab.id, 131 | oldTitle: tab.title, 132 | newTitle: inputValue, 133 | option, 134 | }); 135 | window.close(); 136 | } else { 137 | browser.runtime 138 | .sendMessage({ 139 | type: 'rename', 140 | tabId: tab.id, 141 | oldTitle: tab.title, 142 | newTitle: inputValue, 143 | option, 144 | }) 145 | .then(() => window.close()); 146 | } 147 | } 148 | }, [inputValue, option, tab]); 149 | 150 | const domain = extractDomain(tab?.url); 151 | 152 | return ( 153 |
154 | {tab && activeOption && } 155 | 156 | 161 | 165 | 166 | setUseRegex((p) => !p)} 171 | name="use-regex" 172 | color="primary" 173 | /> 174 | } 175 | label="Use Regex" 176 | /> 177 | {useRegex ? ( 178 | setInputValue(regexString)} 180 | /> 181 | ) : ( 182 | { 190 | if (e.which == 13 && !e.shiftKey) { 191 | e.preventDefault(); 192 | setTitle(); 193 | return false; 194 | } 195 | }} 196 | onChange={(e: any) => setInputValue(e.target.value)} 197 | onFocus={(e: any) => e.target.select()} 198 | /> 199 | )} 200 | {activeOption &&
Option: {activeOption} is active
} 201 | 202 | 208 | } 211 | label="Set it temporarily" 212 | /> 213 | } 216 | label="Set for this tab" 217 | /> 218 | } 221 | label="Set for this exact URL" 222 | /> 223 | } 226 | label={`Set for this domain: ${domain}`} 227 | disabled={!domain} 228 | /> 229 | 230 | 231 | 239 |
240 | 241 |
v{EXTENSION_VERSION}
242 |
243 | ); 244 | }; 245 | 246 | export default Form; 247 | -------------------------------------------------------------------------------- /src/background/retitle.ts: -------------------------------------------------------------------------------- 1 | import { injectTitle, revertRetitle } from '../shared/injectedScripts'; 2 | 3 | import { storageChangeHandler } from '../shared/storageHandler'; 4 | import { 5 | TabLockTitle, 6 | ExactTitle, 7 | DomainTitle, 8 | RegexTitle, 9 | TabOption, 10 | RuntimeMessageRequest, 11 | NewTitle, 12 | StorageAction, 13 | StoredTitle, 14 | } from '../shared/types'; 15 | 16 | function escapeRegExp(str: string) { 17 | return str.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string 18 | } 19 | 20 | let tablocks: TabLockTitle[] = []; 21 | let exacts: ExactTitle[] = []; 22 | let domains: DomainTitle[] = []; 23 | let regexes: RegexTitle[] = []; 24 | 25 | const cache: string[] = []; // To avoid eventListener reacting to its own change 26 | let wait = false; 27 | const waitList: (() => void)[] = []; // for each tab? 28 | 29 | function executeNext() { 30 | wait = false; 31 | if (waitList.length) { 32 | waitList.shift()?.(); 33 | } 34 | } 35 | 36 | // Update the tab title 37 | function insertTitle(tabId: number, newTitle: string, option: TabOption) { 38 | function execute() { 39 | wait = true; 40 | cache.push(newTitle); 41 | //if (recursionStopper.shouldStop(tabId)) return; 42 | console.log('Changing the title to ' + newTitle); 43 | const escapedTitle = newTitle.replace(/'/g, "\\'"); 44 | const code = `${injectTitle.toString()}; injectTitle('${escapedTitle}', '${option}');`; 45 | chrome.tabs.executeScript( 46 | tabId, 47 | { 48 | code, 49 | runAt: 'document_end', 50 | }, 51 | executeNext 52 | ); 53 | } 54 | 55 | if (wait) { 56 | waitList.push(execute); 57 | } else { 58 | execute(); 59 | } 60 | } 61 | 62 | // Returns a title specified by regex 63 | async function decodeTitle( 64 | oldTitle: string, 65 | newTitle: NewTitle, 66 | tabId: number, 67 | url: string | null = null, 68 | urlPattern: RegExp | null = null 69 | ): Promise { 70 | if (typeof newTitle === 'object') { 71 | if (newTitle.replacerType === 'function') { 72 | const code = newTitle.func; 73 | return new Promise((resolve, reject) => { 74 | chrome.tabs.executeScript( 75 | tabId, 76 | { 77 | code, 78 | runAt: 'document_end', 79 | }, 80 | (results: string[]) => { 81 | resolve(results[0]); 82 | } 83 | ); 84 | }); 85 | } else { 86 | const pattern = newTitle.captureRegex; 87 | const replacement = newTitle.titleRegex; 88 | const flags = newTitle.flags; 89 | newTitle = oldTitle.replace(new RegExp(pattern, flags), replacement); 90 | } 91 | return ''; 92 | } else if (newTitle.includes('$0')) { 93 | // // Make sure it doesn't use the cached old title and as an original title. 94 | // const newTitleRegex = new RegExp( 95 | // escapeRegExp(newTitle.replace('$0', 'RETITLE_ORIGINAL_TITLE')).replace( 96 | // 'RETITLE_ORIGINAL_TITLE', 97 | // '(.+)' 98 | // ) 99 | // ); 100 | // const match = newTitleRegex.exec(oldTitle); 101 | // if (match) { 102 | // oldTitle = match[1]; 103 | // } 104 | // newTitle = newTitle.replace('$0', oldTitle).replace(/\\/g, ''); // the first $0 turns into the previous title 105 | } 106 | if (url && urlPattern) { 107 | newTitle = newTitle.replace(/\$\{([0-9])\}/g, '$$$1'); 108 | newTitle = url.replace(urlPattern, newTitle); 109 | } 110 | console.log(`New title decoded for ${oldTitle} is: ${newTitle}`); 111 | return newTitle; 112 | } 113 | 114 | const URL_PARAMS_REGEX = /(\?.*$|#.*$)/g; 115 | 116 | function compareURLs(url1: string, url2: string, ignoreParams?: boolean) { 117 | if (ignoreParams) { 118 | url1 = url1.replace(URL_PARAMS_REGEX, ''); 119 | url2 = url2.replace(URL_PARAMS_REGEX, ''); 120 | } 121 | if (url1.endsWith('/')) url1 = url1.slice(0, url1.length - 2); 122 | if (url2.endsWith('/')) url2 = url2.slice(0, url2.length - 2); 123 | return url1 === url2; 124 | } 125 | function compareDomains(url: string, domain: string, useFull?: boolean) { 126 | if (useFull) { 127 | url = url.replace(URL_PARAMS_REGEX, ''); 128 | url = url.replace(URL_PARAMS_REGEX, ''); 129 | } 130 | return url === domain; 131 | } 132 | 133 | // Listens for tab title changes, and update them if necessary. 134 | chrome.tabs.onUpdated.addListener(function (tabId, info, tab) { 135 | if (info.title) { 136 | const infoTitle = info.title; 137 | let url = tab.url || ''; 138 | let index = cache.indexOf(infoTitle); 139 | if (index > -1) { 140 | // TODO: detect titles with $0 141 | cache.splice(index, 1); 142 | return; // I'm the one who changed the title to this 143 | } 144 | console.log(infoTitle); 145 | if (!infoTitle) return; 146 | (async () => { 147 | for (const tabTitle of tablocks) { 148 | if (tabTitle.tabId === tabId) { 149 | insertTitle( 150 | tabId, 151 | await decodeTitle(infoTitle, tabTitle.newTitle!, tabId), 152 | tabTitle.option 153 | ); 154 | return; 155 | } 156 | } 157 | for (const exactTitle of exacts) { 158 | if (compareURLs(url, exactTitle.url, exactTitle.ignoreParams)) { 159 | insertTitle( 160 | tabId, 161 | await decodeTitle(infoTitle, exactTitle.newTitle!, tabId), 162 | exactTitle.option 163 | ); 164 | return; 165 | } 166 | } 167 | for (const domainTitle of domains) { 168 | if (compareDomains(url, domainTitle.domain, true)) { 169 | insertTitle( 170 | tabId, 171 | await decodeTitle(infoTitle, domainTitle.newTitle!, tabId), 172 | domainTitle.option 173 | ); 174 | return; 175 | } 176 | } 177 | for (const regexTitle of regexes) { 178 | const pattern = regexTitle.urlPattern; 179 | try { 180 | const urlPattern = new RegExp(pattern); 181 | if (urlPattern.test(url)) { 182 | insertTitle( 183 | tabId, 184 | await decodeTitle( 185 | infoTitle, 186 | regexTitle.newTitle!, 187 | tabId, 188 | url, 189 | urlPattern 190 | ), 191 | regexTitle.option 192 | ); 193 | } 194 | } catch (e) { 195 | console.log(e); 196 | } 197 | } 198 | })(); 199 | } 200 | }); 201 | 202 | const generateActionHandler = ( 203 | state: T[], 204 | equals: (t1: T, t2: T) => boolean 205 | ) => (action: StorageAction, newTitle: T) => { 206 | switch (action) { 207 | case 'add': 208 | state.push(newTitle); 209 | break; 210 | case 'edit': { 211 | const index = state.findIndex((t) => equals(t, newTitle)); 212 | if (index >= 0) { 213 | state[index] = newTitle; 214 | } 215 | } 216 | case 'remove': { 217 | const index = state.findIndex((t) => equals(t, newTitle)); 218 | if (index >= 0) { 219 | state.splice(index, 1); 220 | } 221 | } 222 | } 223 | }; 224 | 225 | const onTablockChange = generateActionHandler( 226 | tablocks, 227 | (t1, t2) => t1.tabId === t2.tabId 228 | ); 229 | const onExactChange = generateActionHandler( 230 | exacts, 231 | (t1, t2) => t1.url === t2.url 232 | ); 233 | const onDomainChange = generateActionHandler( 234 | domains, 235 | (t1, t2) => t1.domain === t2.domain 236 | ); 237 | const onRegexChange = generateActionHandler( 238 | regexes, 239 | (t1, t2) => t1.urlPattern === t2.urlPattern 240 | ); 241 | 242 | chrome.storage.onChanged.addListener( 243 | storageChangeHandler({ 244 | onTablockChange, 245 | onExactChange, 246 | onDomainChange, 247 | onRegexChange, 248 | }) 249 | ); 250 | 251 | function revertTitle(tabId: number) { 252 | chrome.tabs.executeScript(tabId, { 253 | code: `${revertRetitle.toString()}; revertRetitle();`, 254 | runAt: 'document_end', 255 | }); 256 | } 257 | 258 | // Receives rename message from popup.js 259 | chrome.runtime.onMessage.addListener(function (request: RuntimeMessageRequest) { 260 | console.log(request); 261 | if (request.type === 'rename') { 262 | (async () => { 263 | insertTitle( 264 | request.tabId, 265 | await decodeTitle(request.oldTitle, request.newTitle, 3), 266 | request.option 267 | ); 268 | })(); 269 | } else if (request.type === 'revert') { 270 | revertTitle(request.tabId); 271 | } 272 | }); 273 | -------------------------------------------------------------------------------- /src/shared/storageHandler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | KEY_THEME, 3 | KEY_DEFAULT_TAB_OPTION, 4 | KEY_CONTEXT_MENU, 5 | KEY_BOOKMARKS, 6 | PREFIX_TABLOCK, 7 | PREFIX_EXACT, 8 | PREFIX_DOMAIN, 9 | PREFIX_REGEX, 10 | extractDomain, 11 | PREFIX_CONTAINER, 12 | } from './utils'; 13 | import type { 14 | ThemeState, 15 | TabOption, 16 | StorageChanges, 17 | TabLockTitle, 18 | DomainTitle, 19 | RegexTitle, 20 | ExactTitle, 21 | StoredTitle, 22 | NewTitle, 23 | TitleOptions, 24 | StorageAction, 25 | } from './types'; 26 | import { 27 | getAllSyncItems, 28 | getAllLocalItems, 29 | getLocalItems, 30 | getLocalItem, 31 | setLocalItem, 32 | setSyncItem, 33 | removeLocalItems, 34 | GeneralStorageType, 35 | } from './storageUtils'; 36 | 37 | /** 38 | * Get all titles and call title setters 39 | */ 40 | export async function initTitles({ 41 | onTablockChange, 42 | onExactChange, 43 | onDomainChange, 44 | onRegexChange, 45 | }: { 46 | onTablockChange?: (action: StorageAction, tabLock: TabLockTitle) => void; 47 | onExactChange?: (action: StorageAction, exact: ExactTitle) => void; 48 | onDomainChange?: (action: StorageAction, domain: DomainTitle) => void; 49 | onRegexChange?: (action: StorageAction, regex: RegexTitle) => void; 50 | }) { 51 | const callHandlers = (items: GeneralStorageType) => { 52 | for (const titleKey of Object.keys(items)) { 53 | const newTitle = items[titleKey] as StoredTitle; 54 | switch (newTitle?.option) { 55 | case 'tablock': 56 | onTablockChange?.('add', newTitle); 57 | break; 58 | case 'exact': 59 | onExactChange?.('add', newTitle); 60 | break; 61 | case 'domain': 62 | onDomainChange?.('add', newTitle); 63 | break; 64 | case 'regex': 65 | onRegexChange?.('add', newTitle); 66 | break; 67 | } 68 | } 69 | }; 70 | callHandlers(await getAllSyncItems()); 71 | const localItems = await getAllLocalItems(); 72 | delete localItems[KEY_DEFAULT_TAB_OPTION]; 73 | delete localItems[KEY_THEME]; 74 | callHandlers(localItems); 75 | } 76 | 77 | interface AllOptions extends GeneralStorageType { 78 | [KEY_THEME]: ThemeState; 79 | [KEY_DEFAULT_TAB_OPTION]: TabOption; 80 | [KEY_CONTEXT_MENU]: boolean; 81 | } 82 | 83 | // Get all user options 84 | export async function getOptions(): Promise { 85 | return getLocalItems([ 86 | KEY_THEME, 87 | KEY_DEFAULT_TAB_OPTION, 88 | KEY_CONTEXT_MENU, 89 | ]); 90 | } 91 | 92 | // Get theme option 93 | export async function getTheme(): Promise { 94 | return (await getLocalItem(KEY_THEME)) || 'dark'; 95 | } 96 | 97 | // Get default tab option 98 | export async function getDefaultOption(): Promise { 99 | return (await getLocalItem(KEY_DEFAULT_TAB_OPTION)) || 'onetime'; 100 | } 101 | 102 | // Get context menu option 103 | export async function getContextMenuOption(): Promise { 104 | return Boolean(await getLocalItem(KEY_CONTEXT_MENU)); 105 | } 106 | 107 | // Set theme option 108 | export function setTheme(theme: ThemeState) { 109 | return setLocalItem(KEY_THEME, theme); 110 | } 111 | 112 | // Set default tab option 113 | export function setDefaultOption(option: TabOption) { 114 | return setLocalItem(KEY_DEFAULT_TAB_OPTION, option); 115 | } 116 | 117 | // Set context menu option 118 | export function setContextMenuOption(option: boolean) { 119 | return setLocalItem(KEY_CONTEXT_MENU, option); 120 | } 121 | 122 | // Set title matcher 123 | export function setSyncTitle(key: string, value: StoredTitle) { 124 | return setSyncItem(key, value); 125 | } 126 | 127 | // Set title matcher in local storage 128 | export function setLocalTitle(key: string, value: StoredTitle) { 129 | return setLocalItem(key, value); 130 | } 131 | 132 | export const storageChangeHandler = ({ 133 | onThemeChange, 134 | onDefaultOptionChange, 135 | onTablockChange, 136 | onExactChange, 137 | onDomainChange, 138 | onRegexChange, 139 | }: { 140 | onThemeChange?: (theme: ThemeState) => void; 141 | onDefaultOptionChange?: (defaultOption: TabOption) => void; 142 | onTablockChange?: (action: StorageAction, tabLock: TabLockTitle) => void; 143 | onExactChange?: (action: StorageAction, exact: ExactTitle) => void; 144 | onDomainChange?: (action: StorageAction, domain: DomainTitle) => void; 145 | onRegexChange?: (action: StorageAction, regex: RegexTitle) => void; 146 | }) => (changes: StorageChanges) => { 147 | if (changes[KEY_THEME]) { 148 | onThemeChange?.(changes[KEY_THEME].newValue); 149 | } 150 | if (changes[KEY_DEFAULT_TAB_OPTION]) { 151 | onDefaultOptionChange?.(changes[KEY_DEFAULT_TAB_OPTION].newValue); 152 | } 153 | delete changes[KEY_THEME]; 154 | delete changes[KEY_DEFAULT_TAB_OPTION]; 155 | 156 | for (const changeKey of Object.keys(changes)) { 157 | let action: StorageAction = 'edit'; 158 | const oldValue: StoredTitle | undefined = changes[changeKey].oldValue; 159 | let newValue: StoredTitle | undefined = changes[changeKey].newValue; 160 | if (!oldValue) { 161 | action = 'add'; 162 | } 163 | if (!newValue) { 164 | action = 'remove'; 165 | newValue = oldValue!; 166 | } 167 | 168 | switch (newValue.option) { 169 | case 'tablock': 170 | onTablockChange?.(action, newValue); 171 | break; 172 | case 'exact': 173 | onExactChange?.(action, newValue); 174 | break; 175 | case 'domain': 176 | onDomainChange?.(action, newValue); 177 | break; 178 | case 'regex': 179 | onRegexChange?.(action, newValue); 180 | break; 181 | } 182 | } 183 | }; 184 | 185 | export function saveTitle( 186 | newTitle: NewTitle, 187 | tabOption: TabOption, 188 | currentTab: chrome.tabs.Tab, 189 | options?: TitleOptions & { [key: string]: any } 190 | ): Promise { 191 | const url = currentTab.url; 192 | switch (tabOption) { 193 | case 'tablock': { 194 | return TablockRule.save(currentTab.id!, newTitle, options); 195 | } 196 | case 'exact': { 197 | return ExactRule.save(url!, newTitle, options); 198 | } 199 | case 'domain': { 200 | const urlDomain = extractDomain(url); 201 | return DomainRule.save(urlDomain, newTitle, options); 202 | } 203 | case 'regex': { 204 | const { urlPattern, ...rest } = options as TitleOptions & { 205 | urlPattern: string; 206 | }; 207 | return RegexRule.save(urlPattern, newTitle, rest); 208 | } 209 | } 210 | return new Promise(() => {}); 211 | } 212 | 213 | export class TablockRule { 214 | static option = 'tablock' as TabOption; 215 | static generateKey(tabId: number) { 216 | return `${PREFIX_TABLOCK}${tabId}`; 217 | } 218 | static equals(t1: TabLockTitle, t2: TabLockTitle) { 219 | return t1.tabId === t2.tabId; 220 | } 221 | static save(tabId: number, newTitle: NewTitle, options?: TitleOptions) { 222 | return setLocalTitle(TablockRule.generateKey(tabId), { 223 | option: 'tablock', 224 | tabId, 225 | newTitle, 226 | ...options, 227 | }); 228 | } 229 | static remove(tabId: number) { 230 | return removeLocalItems(TablockRule.generateKey(tabId)); 231 | } 232 | } 233 | 234 | export class ExactRule { 235 | static option = 'exact' as TabOption; 236 | static generateKey(url: string, containerId?: string) { 237 | return `${ 238 | containerId ? `${PREFIX_CONTAINER}${containerId}|` : '' 239 | }${PREFIX_EXACT}${url}`; 240 | } 241 | static equals(t1: ExactTitle, t2: ExactTitle) { 242 | return t1.url === t2.url && t1.containerId === t2.containerId; 243 | } 244 | static save(url: string, newTitle: NewTitle, options?: TitleOptions) { 245 | return setSyncTitle(ExactRule.generateKey(url, options?.containerId), { 246 | option: 'exact', 247 | url, 248 | newTitle, 249 | ...options, 250 | }); 251 | } 252 | static remove(url: string, containerId?: string) { 253 | return removeLocalItems(ExactRule.generateKey(url, containerId)); 254 | } 255 | } 256 | 257 | export class DomainRule { 258 | static option = 'domain' as TabOption; 259 | static generateKey(domain: string, containerId?: string) { 260 | return `${ 261 | containerId ? `${PREFIX_CONTAINER}${containerId}|` : '' 262 | }${PREFIX_DOMAIN}${domain}`; 263 | } 264 | static save(domain: string, newTitle: NewTitle, options?: TitleOptions) { 265 | return setSyncTitle(DomainRule.generateKey(domain, options?.containerId), { 266 | option: 'domain', 267 | domain, 268 | newTitle, 269 | ...options, 270 | }); 271 | } 272 | static remove(domain: string, containerId?: string) { 273 | return removeLocalItems(DomainRule.generateKey(domain, containerId)); 274 | } 275 | } 276 | 277 | export class RegexRule { 278 | static option = 'regex' as TabOption; 279 | static generateKey(regex: string, containerId?: string) { 280 | return `${ 281 | containerId ? `${PREFIX_CONTAINER}${containerId}|` : '' 282 | }${PREFIX_REGEX}${regex}`; 283 | } 284 | static save(urlPattern: string, newTitle: NewTitle, options?: TitleOptions) { 285 | return setSyncTitle( 286 | RegexRule.generateKey(urlPattern, options?.containerId), 287 | { 288 | option: 'regex', 289 | urlPattern, 290 | newTitle, 291 | ...options, 292 | } 293 | ); 294 | } 295 | static remove(regex: string, containerId?: string) { 296 | return removeLocalItems(RegexRule.generateKey(regex, containerId)); 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /static/svgs/firefox.svg: -------------------------------------------------------------------------------- 1 | 5 | 7 | 15 | 19 | 23 | 27 | 31 | 35 | 39 | 43 | 47 | 48 | 55 | 59 | 63 | 67 | 71 | 75 | 79 | 83 | 87 | 91 | 92 | 99 | 103 | 108 | 113 | 118 | 123 | 124 | 131 | 135 | 139 | 143 | 147 | 148 | 155 | 159 | 163 | 167 | 171 | 172 | 179 | 184 | 189 | 194 | 199 | 200 | 207 | 211 | 215 | 219 | 223 | 227 | 231 | 232 | 239 | 243 | 247 | 251 | 255 | 259 | 260 | 267 | 271 | 275 | 279 | 283 | 287 | 291 | 295 | 299 | 300 | 307 | 311 | 315 | 319 | 323 | 327 | 328 | 335 | 339 | 343 | 347 | 351 | 352 | 360 | 365 | 370 | 375 | 380 | 381 | 382 | 386 | 390 | 394 | 398 | 402 | 406 | 410 | 414 | 418 | 422 | 426 | 430 | 431 | --------------------------------------------------------------------------------