├── .editorconfig ├── .gitignore ├── LICENSE ├── README.md ├── background.js ├── clipboard-helper.js ├── icons ├── error.svg └── icon.svg ├── manifest.json └── popup ├── urls-list.css ├── urls-list.html └── urls-list.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | web-ext-artifacts 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Minas Abrahamyan 4 | Copyright (c) 2017 Moritz Heinemann 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Save Tabs URLs with Titles, Restore Later 2 | 3 | Show list the URLs and Titles of all tabs of current browser window as copyable plaintext. 4 | Also can save this info as plaintext and restore it back later to individual tabs. 5 | -------------------------------------------------------------------------------- /background.js: -------------------------------------------------------------------------------- 1 | browser.contextMenus.create({ 2 | id: "save-tabs-copy-urls", 3 | title: "Copy URLs (all tabs)", 4 | contexts: ["tab"], 5 | }); 6 | browser.contextMenus.onClicked.addListener(function (info, tab) { 7 | if (info.menuItemId === "save-tabs-copy-urls") { 8 | browser.tabs.query({currentWindow: true}).then((tabs) => { 9 | // Use active tab for copy to clipboard, so it is possible to open 10 | // the context menu on inactive tabs. Save clicked tab as fallback. 11 | let activeTabId = tab.id; 12 | let urls = ''; 13 | for (let tab of tabs) { 14 | urls += tab.title + "\r\n" + tab.url + "\r\n\r\n"; 15 | if (tab.active) { 16 | activeTabId = tab.id; 17 | } 18 | } 19 | 20 | // example source: https://github.com/mdn/webextensions-examples/tree/master/context-menu-copy-link-with-types 21 | 22 | // The example will show how data can be copied, but since background 23 | // pages cannot directly write to the clipboard, we will run a content 24 | // script that copies the actual content. 25 | 26 | // clipboard-helper.js defines function copyToClipboard. 27 | const code = "copyToClipboard(" + JSON.stringify(urls) + ");"; 28 | 29 | browser.tabs.executeScript({ 30 | code: "typeof copyToClipboard === 'function';", 31 | }).then(function (results) { 32 | // The content script's last expression will be true if the function 33 | // has been defined. If this is not the case, then we need to run 34 | // clipboard-helper.js to define function copyToClipboard. 35 | if (!results || results[0] !== true) { 36 | return browser.tabs.executeScript(activeTabId, { 37 | file: "clipboard-helper.js", 38 | }); 39 | } 40 | }).then(function () { 41 | return browser.tabs.executeScript(activeTabId, { 42 | code, 43 | }); 44 | }).catch(function (error) { 45 | // This could happen if the extension is not allowed to run code in 46 | // the page, for example if the tab is a privileged page. 47 | console.error("urls-list: Failed to copy text: " + error); 48 | notifyError(); 49 | }); 50 | }); 51 | } 52 | }); 53 | 54 | function notifyError() { 55 | browser.notifications.create({ 56 | "type": "basic", 57 | "iconUrl": browser.extension.getURL("icons/error.svg"), 58 | "title": "Error!", 59 | "message": "Cannot write to clipboard on settings tab!" 60 | }); 61 | } 62 | -------------------------------------------------------------------------------- /clipboard-helper.js: -------------------------------------------------------------------------------- 1 | // example source: https://github.com/mdn/webextensions-examples/tree/master/context-menu-copy-link-with-types 2 | 3 | // This function must be called in a visible page, such as a browserAction popup 4 | // or a content script. Calling it in a background page has no effect! 5 | function copyToClipboard(text) { 6 | function oncopy(event) { 7 | document.removeEventListener("copy", oncopy, true); 8 | // Hide the event from the page to prevent tampering. 9 | event.stopImmediatePropagation(); 10 | 11 | // Overwrite the clipboard content. 12 | event.preventDefault(); 13 | event.clipboardData.setData("text/plain", text); 14 | } 15 | 16 | document.addEventListener("copy", oncopy, true); 17 | 18 | // Requires the clipboardWrite permission, or a user gesture: 19 | document.execCommand("copy"); 20 | } 21 | -------------------------------------------------------------------------------- /icons/error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ! 5 | 6 | -------------------------------------------------------------------------------- /icons/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ST 5 | 6 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Save Tab URLs with Titles, Restore Later", 4 | "version": "0.1.1", 5 | "description": "Show, save and restore the URLs with Titles of all tabs of current browser window. Show as copyable plaintext. Save and restore from text file.", 6 | "homepage_url": "https://github.com/mnba/save-tabs", 7 | "permissions": [ 8 | "activeTab", 9 | "clipboardWrite", 10 | "contextMenus", 11 | "notifications", 12 | "tabs" 13 | ], 14 | "background": { 15 | "scripts": [ 16 | "background.js" 17 | ] 18 | }, 19 | "browser_action": { 20 | "default_icon": "icons/icon.svg", 21 | "browser_style": true, 22 | "default_title": "URLs List", 23 | "default_popup": "popup/urls-list.html" 24 | }, 25 | "icons": { 26 | "48": "icons/icon.svg", 27 | "96": "icons/icon.svg" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /popup/urls-list.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | width: 350px; 3 | } 4 | 5 | .panel { 6 | width: 100%; 7 | } 8 | 9 | .filterBox { 10 | padding-left: 6px; 11 | padding-top: 10px; 12 | } 13 | 14 | .sortButtons { 15 | float:right; 16 | padding-right: 6px; 17 | } 18 | 19 | .filterInput { 20 | width: 150px; 21 | } 22 | 23 | .filterWarning { 24 | background-color: orange; 25 | text-align: center; 26 | padding-top: 3px; 27 | padding-bottom: 3px; 28 | display: none; 29 | } 30 | 31 | .urlText { 32 | height: 400px; 33 | margin-top: -1px; 34 | resize: none; 35 | white-space: pre; 36 | width: 100%; 37 | } 38 | 39 | .buttonRow { 40 | padding: 5px; 41 | text-align: center; 42 | width: 100%; 43 | } 44 | 45 | .buttonRow > button { 46 | margin: 5px; 47 | } 48 | 49 | .hide { 50 | display: none; 51 | } 52 | -------------------------------------------------------------------------------- /popup/urls-list.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 |
11 |
Save Tabs URLs
12 |
13 |
14 | 15 | 16 | 17 |
18 | 19 | 20 |
21 |
22 |
23 | Remove filter to edit these text lines. 24 |
25 | 26 |
27 | 28 | 29 | 30 | 31 |
32 |
33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /popup/urls-list.js: -------------------------------------------------------------------------------- 1 | let resetBtn = document.querySelector('.reset'); 2 | let openBtn = document.querySelector('.open'); 3 | let copyBtn = document.querySelector('.copy'); 4 | let saveBtn = document.querySelector('.save'); 5 | let sortAscBtn = document.querySelector('.sortAsc'); 6 | let sortDescBtn = document.querySelector('.sortDesc'); 7 | let resetFilterBtn = document.querySelector('.resetFilter'); 8 | let urlText = document.querySelector('.urlText'); 9 | let filterInput = document.querySelector('.filterInput'); 10 | let filterWarning = document.querySelector('.filterWarning'); 11 | 12 | function listTabs() { 13 | disableFilterMode(); 14 | 15 | browser.tabs.query({currentWindow: true}).then((tabs) => { 16 | let urls = ''; 17 | for (let tab of tabs) { 18 | urls += tab.title + "\n" + tab.url + '\n\n'; 19 | } 20 | urlText.value = urls; 21 | }); 22 | } 23 | 24 | //https://stackoverflow.com/questions/42274304/is-there-a-list-of-url-schemes 25 | scheme_list = ["https://", "http://", "file://", "ftps://", "ftp://", "mailto:", "news:", "telnet://"] 26 | 27 | function isURL(urlcand){ 28 | for (let scheme of scheme_list) 29 | if (urlcand.startsWith(scheme)) 30 | return true; 31 | return false; 32 | } 33 | 34 | function open() { 35 | browser.tabs.query({currentWindow: true}).then((tabs) => { 36 | // save list of current urls 37 | let currentUrls = []; 38 | for (let tab of tabs) { 39 | currentUrls.push(tab.url); 40 | } 41 | // take urls from text in extension window 42 | let linesList = urlText.value.split('\n'); 43 | 44 | /* // Currently this step is optional 45 | // filter out URL lines, ignoring other lines, like: title line and empty line delimiters 46 | newUrls = linesList.filter(function(line) { 47 | return isURL(line); 48 | }); 49 | */ 50 | newUrls = linesList; 51 | for (let newurl of newUrls) { 52 | // Open only if line is not empty string, is actually URL, and is not currently opened 53 | if ( newurl == "") 54 | continue; 55 | if (! isURL(newurl) ) 56 | continue; 57 | if ( currentUrls.indexOf(newurl) >= 0 ) 58 | continue; 59 | browser.tabs.create({url: newurl}); 60 | } 61 | }); 62 | } 63 | 64 | function copy() { 65 | let tmp = urlText.value; 66 | urlText.select(); 67 | document.execCommand('Copy'); 68 | 69 | // workaround to not have text selected after button click 70 | urlText.value = ''; 71 | urlText.value = tmp; 72 | } 73 | 74 | function save() { 75 | let dl = document.createElement('a'); 76 | 77 | dl.download = 'urls-list-' + Date.now() + '.txt'; // filename 78 | dl.href = window.URL.createObjectURL( 79 | new Blob([urlText.value], {type: 'text/plain'}) // file content 80 | ); 81 | dl.onclick = event => document.body.removeChild(event.target); 82 | dl.style.display = 'none'; 83 | document.body.appendChild(dl); 84 | dl.click(); 85 | } 86 | 87 | function sort(desc = false) { 88 | let urls = urlText.value.split('\n'); 89 | let cleanUrls = []; 90 | for (let i in urls) { 91 | let clean = urls[i].trim(); 92 | if (clean !== '') { 93 | cleanUrls.push(clean); 94 | } 95 | } 96 | cleanUrls.sort(); 97 | if (desc) { 98 | cleanUrls.reverse(); 99 | } 100 | urlText.value = cleanUrls.join('\n') + '\n'; 101 | } 102 | 103 | function sortAsc() { 104 | sort(false); 105 | } 106 | 107 | function sortDesc() { 108 | sort(true); 109 | } 110 | 111 | let filterBackup = ''; 112 | let filterMode = false; 113 | 114 | function enableFilterMode() { 115 | if (!filterMode) { 116 | filterBackup = urlText.value; 117 | urlText.readOnly = true; 118 | urlText.style.backgroundColor = '#ddd'; 119 | filterWarning.style.display = 'block'; 120 | filterMode = true; 121 | } 122 | } 123 | 124 | function disableFilterMode() { 125 | if (filterMode) { 126 | urlText.value = filterBackup; 127 | urlText.readOnly = false; 128 | urlText.style.backgroundColor = '#fff'; 129 | filterWarning.style.display = 'none'; 130 | filterInput.style.backgroundColor = '#fff'; 131 | filterInput.value = ''; 132 | filterMode = false; 133 | } 134 | } 135 | 136 | function filter(e) { 137 | let val = e.target.value; 138 | filterInput.style.backgroundColor = '#fff'; 139 | if (val !== '') { 140 | enableFilterMode(); 141 | try { 142 | let re = new RegExp(val, 'i'); 143 | let urls = filterBackup.split('\n'); 144 | let filteredUrls = []; 145 | for (let i in urls) { 146 | let clean = urls[i].trim(); 147 | if (clean !== '' && re.test(clean)) { 148 | filteredUrls.push(clean); 149 | } 150 | } 151 | urlText.value = filteredUrls.join('\n') + '\n'; 152 | } catch (ex) { 153 | filterInput.style.backgroundColor = '#fbb'; 154 | } 155 | } else { 156 | disableFilterMode(); 157 | } 158 | } 159 | 160 | function resetFilter() { 161 | disableFilterMode(); 162 | } 163 | 164 | document.addEventListener('DOMContentLoaded', listTabs); 165 | resetBtn.addEventListener('click', listTabs); 166 | openBtn.addEventListener('click', open); 167 | copyBtn.addEventListener('click', copy); 168 | saveBtn.addEventListener('click', save); 169 | sortAscBtn.addEventListener('click', sortAsc); 170 | sortDescBtn.addEventListener('click', sortDesc); 171 | resetFilterBtn.addEventListener('click', resetFilter); 172 | filterInput.addEventListener('input', filter); 173 | --------------------------------------------------------------------------------