├── .gitignore ├── LICENSE ├── README.md ├── _locales ├── bg │ └── messages.json └── en │ └── messages.json ├── amo-description ├── bg.md └── en.md ├── background.js ├── i18n.js ├── icon.svg ├── manifest.json ├── options.html └── options.js /.gitignore: -------------------------------------------------------------------------------- 1 | *.xpi 2 | *.zip -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 nosoop 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tab-deduper 2 | WebExtension to clean up duplicate tabs. Tested in Firefox. 3 | 4 | # Availability 5 | Available on AMO here: https://addons.mozilla.org/firefox/addon/tab-deduper/ 6 | 7 | ## Operation 8 | When a tab's location changes, it checks against currently open tabs in the 9 | same window. On a match, one is made active and the other is removed. 10 | 11 | Great for tab hoarders that revisit pages that are already open in a different 12 | tab. 13 | 14 | ## Permissions 15 | Requires the following permissions: 16 | 17 | * `tabs`: To query tabs and check for matching URLs. 18 | * `cookies`: To check that two matching tabs are in the same cookie store 19 | (i.e., in the same contextual identity). Prevents closing tabs being used in 20 | other contexts, such as in different containers. 21 | * `notifications`: To notify a user that their current tab was replaced with 22 | an existing one. 23 | * `storage`: To store extension settings. 24 | -------------------------------------------------------------------------------- /_locales/bg/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensionName": { 3 | "message": "Дедупликатор на раздели (Tab Deduper)", 4 | "description": "Наименование на добавка." 5 | }, 6 | "extensionDescription": { 7 | "message": "Затваря дублиращите се раздели като ги замества с вече отворени.", 8 | "description": "Описание на добавка." 9 | }, 10 | "autosaveNotice": { 11 | "message": "Промените в настройките се запазват автоматично.", 12 | "description": "Въвеждащ текст в екрана с настройки." 13 | }, 14 | "protectDuplicatesLabel": { 15 | "message": "Запазване на изрично дублирани раздели:", 16 | "description": "Етикет на настройка в екрана с настройки." 17 | }, 18 | "protectDuplicatesHint": { 19 | "message": "Разделите отворени чрез „$DUPLICATETABACTION$“ няма да бъдат затваряни от добавката.", 20 | "description": "Помощен текст под настройка в екрана с настройки. Параметърът трябва да отговаря на превода на действието в интерфейса на четеца.", 21 | "placeholders": { 22 | "duplicateTabAction": { 23 | "content": "Дублиране на раздела" 24 | } 25 | } 26 | }, 27 | "moveOrderLabel": { 28 | "message": "Преместване на по-стари раздели:", 29 | "description": "Етикет на настройка в екрана с настройки." 30 | }, 31 | "moveOrderHint": { 32 | "message": "По-старите раздели ще бъдат премествани на мястото на текущия.", 33 | "description": "Помощен текст под настройка в екрана с настройки." 34 | }, 35 | "checkMultiwindowLabel": { 36 | "message": "Проверяване на всички прозорци:", 37 | "description": "Етикет на настройка в екрана с настройки." 38 | }, 39 | "checkMultiwindowHint": { 40 | "message": "Всички неповерителни прозорци ще бъдат проверявани за дублирани раздели.", 41 | "description": "Помощен текст под настройка в екрана с настройки." 42 | }, 43 | "notificationTitle": { 44 | "message": "Затворен е дублиран раздел:", 45 | "description": "Заглавие на известието при подменяне на дублиран раздел." 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensionName": { 3 | "message": "Tab Deduper", 4 | "description": "Extension name." 5 | }, 6 | "extensionDescription": { 7 | "message": "Removes / closes duplicate tabs, replacing it with a matching existing tab.", 8 | "description": "Extension description." 9 | }, 10 | "autosaveNotice": { 11 | "message": "Changes to settings are automatically saved.", 12 | "description": "Permanent notice in the options screen." 13 | }, 14 | "protectDuplicatesLabel": { 15 | "message": "Keep explicitly duplicated tabs:", 16 | "description": "Option label in the options screen." 17 | }, 18 | "protectDuplicatesHint": { 19 | "message": "Tabs created by the \"$DUPLICATETABACTION$\" option will not be automatically removed.", 20 | "description": "Help under option in the options screen.", 21 | "placeholders": { 22 | "duplicateTabAction": { 23 | "content": "Duplicate Tab" 24 | } 25 | } 26 | }, 27 | "moveOrderLabel": { 28 | "message": "Change the old tab position:", 29 | "description": "Option label in the options screen." 30 | }, 31 | "moveOrderHint": { 32 | "message": "Old tabs will be moved to the current tab position.", 33 | "description": "Help under option in the options screen." 34 | }, 35 | "checkMultiwindowLabel": { 36 | "message": "Check all windows:", 37 | "description": "Option label in the options screen." 38 | }, 39 | "checkMultiwindowHint": { 40 | "message": "Duplicates are checked across all (non-private) windows.", 41 | "description": "Help under option in the options screen." 42 | }, 43 | "notificationTitle": { 44 | "message": "Duplicate tab replaced:", 45 | "description": "Notification title shown when closing a duplicated tab." 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /amo-description/bg.md: -------------------------------------------------------------------------------- 1 | ## NameAMO Description translated in Bulgarian 2 | Дедупликатор на раздели (Tab Deduper) 3 | 4 | ## Summary 5 | Автоматично разчиства дублиращите се раздели. 6 | 7 | ## Description 8 | Бивша минималистична алтернатива на Duplicate Tabs Closer, преди авторът ѝ да я пренапише като WebExtension. 9 | 10 | Идеална за хора с много отворени раздели, които често отварят в нов раздел страница, която вече имат отворена. 11 | 12 | При промяна на адреса на раздела, се извършва проверка дали такъв вече няма в отворените раздели на текущия прозорец. Ако има раздел с такъв адрес, то той се прави активен, а другия се затваря. 13 | 14 | Има няколко настройки, които управляват поведението на добавката. 15 | -------------------------------------------------------------------------------- /amo-description/en.md: -------------------------------------------------------------------------------- 1 | ## Name 2 | Tab Deduper 3 | 4 | ## Summary 5 | Cleans up duplicate tabs automatically. 6 | 7 | ## Description 8 | A minimal alternative for Duplicate Tabs Closer. 9 | It used to be a replacement, but the author of that plugin has also migrated it to WebExtensions. 10 | 11 | Good for tab hoarders that open new tabs to revisit sites they might already have tabs open for. 12 | 13 | Whenever a tab's URL changes, it is checked against the current window's existing tabs. On a match, one is made active and the other is removed. 14 | 15 | There are a few options to control the behavior. 16 | -------------------------------------------------------------------------------- /background.js: -------------------------------------------------------------------------------- 1 | /* stick all the options here so we can keep track of them */ 2 | let settings = { 3 | 'protectDuplicates': true, 4 | 'moveOlder': true, 5 | 'checkMultiWindow': false 6 | }; 7 | 8 | /* list of tab IDs that should be protected from removal */ 9 | let blessedTabs = new Set(); 10 | 11 | const TAB_QUERY_OPTIONS = { 12 | windowType: "normal" 13 | }; 14 | 15 | let replaceTab = (replacedTab, replacementTab, discardedTabs) => { 16 | if (settings.moveOlder) { 17 | browser.tabs.move(replacementTab.id, { index: replacedTab.index, windowId: replacedTab.windowId }); 18 | } 19 | 20 | /* don't focus backgrounded tabs */ 21 | if (replacedTab.active) { 22 | browser.tabs.update(replacementTab.id, { active: replacedTab.active }); 23 | } 24 | 25 | browser.notifications.create({ 26 | "type": "basic", 27 | "title": browser.i18n.getMessage('notificationTitle'), 28 | "message": (replacedTab.url).toString() 29 | }).then(currentNotification => { 30 | setTimeout((notification) => { 31 | browser.notifications.clear(notification); 32 | }, 5000, currentNotification); 33 | }); 34 | 35 | browser.tabs.remove(discardedTabs.map(tab => tab.id)); 36 | }; 37 | 38 | let getTabQuery = (url) => { 39 | const newURL = new URL(url); 40 | let filter; 41 | 42 | switch (newURL.protocol) { 43 | case 'about:': 44 | filter = { 45 | 'url': `${newURL.protocol}*` 46 | }; 47 | break; 48 | 49 | default: 50 | filter = { 51 | 'url': `*://${newURL.hostname}/*` 52 | }; 53 | break; 54 | } 55 | filter.currentWindow = settings.checkMultiWindow? null : true; 56 | return Object.assign({}, TAB_QUERY_OPTIONS, filter); 57 | } 58 | 59 | let checkDuplicateTabs = async (newTab) => { 60 | if (newTab.id === browser.tabs.TAB_ID_NONE || blessedTabs.has(newTab.id)) { 61 | return; 62 | } 63 | 64 | /* query to prefilter tabs */ 65 | const tabQuery = getTabQuery(newTab.url); 66 | 67 | await browser.tabs.query(tabQuery).then(tabs => { 68 | /* return tabs with in the same session and the same URL (including current) */ 69 | let copies = tabs.filter(tab => { 70 | return newTab.cookieStoreId === tab.cookieStoreId 71 | && newTab.url === tab.url && !blessedTabs.has(tab.id); 72 | }); 73 | 74 | if (copies.length > 1) { 75 | /* TODO handle priorities -- right now it keeps the older tab */ 76 | copies.sort((a, b) => a.id - b.id); 77 | 78 | /* keep first tab, discard the rest */ 79 | [ keptTab, ...discarded ] = copies; 80 | replaceTab(newTab, keptTab, discarded); 81 | } 82 | }); 83 | }; 84 | 85 | /* main routine */ 86 | browser.tabs.onUpdated.addListener((id, change, newTab) => { 87 | if (change.url && change.url !== 'about:blank') { 88 | checkDuplicateTabs(newTab); 89 | } 90 | }); 91 | 92 | /* add protections to newly created tabs */ 93 | browser.tabs.onCreated.addListener(tab => { 94 | if ('openerTabId' in tab && tab.status === 'loading' && settings.protectDuplicates) { 95 | blessedTabs.add(tab.id); 96 | } 97 | }); 98 | 99 | browser.tabs.onRemoved.addListener((id, info) => { 100 | blessedTabs.delete(id); 101 | }); 102 | 103 | browser.storage.onChanged.addListener((changes, areaName) => { 104 | /* extract just the new values so we can apply them to our options */ 105 | let additions = {}; 106 | Object.keys(changes).forEach((key) => additions[key] = changes[key].newValue); 107 | Object.assign(settings, additions); 108 | 109 | /** 110 | * wipe any 'protected' tabs if the option is disabled so they don't linger if another 111 | * duplicate shows up 112 | */ 113 | if (!settings.protectDuplicates) { 114 | blessedTabs.clear(); 115 | } 116 | }); 117 | 118 | browser.storage.sync.get(null, (data) => { 119 | Object.assign(settings, data); 120 | }); 121 | -------------------------------------------------------------------------------- /i18n.js: -------------------------------------------------------------------------------- 1 | document.querySelectorAll('[data-i18n]').forEach((node) => { 2 | let text = browser.i18n.getMessage(node.dataset.i18n) 3 | node.appendChild(document.createTextNode(text)) 4 | }) 5 | -------------------------------------------------------------------------------- /icon.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "__MSG_extensionName__", 4 | "version": "1.5.0", 5 | "description": "__MSG_extensionDescription__", 6 | "developer": { 7 | "name": "nosoop", 8 | "url": "https://github.com/nosoop/tab-deduper" 9 | }, 10 | "icons": { 11 | "48": "icon.svg", 12 | "96": "icon.svg", 13 | "128": "icon.svg", 14 | "256": "icon.svg" 15 | }, 16 | "background": { 17 | "scripts": [ "background.js" ] 18 | }, 19 | "options_ui": { 20 | "page": "options.html" 21 | }, 22 | "permissions": [ "tabs", "cookies", "notifications", "storage" ], 23 | "browser_specific_settings": { 24 | "gecko": { 25 | "id": "{28d03e32-9c43-4a90-b89d-b42abf9e3c8f}" 26 | } 27 | }, 28 | "default_locale": "en" 29 | } 30 | -------------------------------------------------------------------------------- /options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 51 | 52 |
53 |
54 | 55 |
56 |
57 |
58 | 62 | 63 |
64 |
65 | 69 | 70 |
71 |
72 | 76 | 77 |
78 |
79 | 80 | 81 |
82 | 83 | -------------------------------------------------------------------------------- /options.js: -------------------------------------------------------------------------------- 1 | document.querySelector("form").addEventListener("change", (e) => { 2 | browser.storage.sync.set({ 3 | 'protectDuplicates': document.querySelector("#protect_duplicates").checked, 4 | 'moveOlder': document.querySelector("#move_older").checked, 5 | 'checkMultiWindow': document.querySelector("#check_multiwindow").checked 6 | }); 7 | }); 8 | 9 | document.addEventListener("DOMContentLoaded", () => { 10 | browser.storage.sync.get(null).then(data => { 11 | document.querySelector("#protect_duplicates").checked = 'protectDuplicates' in data? 12 | data.protectDuplicates : true; 13 | 14 | document.querySelector("#move_older").checked = 'moveOlder' in data? 15 | data.moveOlder : true; 16 | 17 | document.querySelector("#check_multiwindow").checked = 'checkMultiWindow' in data? 18 | data.checkMultiWindow : false; 19 | }); 20 | }); 21 | --------------------------------------------------------------------------------