├── .github └── workflows │ └── main.yml ├── .gitignore ├── .gitmodules ├── CONTRIBUTING.md ├── COPYING.txt ├── ISSUE_TEMPLATE.md ├── Makefile ├── README.md ├── _locales ├── en │ └── messages.json ├── ja │ └── messages.json └── zh_CN │ └── messages.json ├── background ├── background.html ├── background.js └── context-menu.js ├── common ├── commands.js ├── common.js ├── constants.js └── permissions.js ├── eslint.config.mjs ├── extlib └── .gitkeep ├── history.en.md ├── history.ja.md ├── licenses └── MPL2.0.txt ├── manifest.json ├── options ├── init.js ├── options.css └── options.html ├── package-lock.json ├── package.json ├── resources ├── Save.svg ├── blank.html ├── downloads-option.png └── notify-features.html └── screenshots └── menu.png /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v1 12 | - name: prepare manifest.json with a revision number 13 | run: | 14 | cp manifest.json manifest.json.bak 15 | version=$(cat manifest.json.bak | jq -r ".version" | sed -r -e "s/$/.$(git log --oneline | wc -l)/") 16 | cat manifest.json.bak | jq ".version |= \"$version\"" > manifest.json 17 | - name: build xpi 18 | run: make 19 | - uses: actions/upload-artifact@v4 20 | with: 21 | name: save-selected-tabs-to-files.xpi 22 | path: save-selected-tabs-to-files.xpi 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.xpi 2 | extlib/*.js 3 | 4 | # node.js dependency 5 | node_modules/ 6 | 7 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "submodules/webextensions-lib-configs"] 2 | path = submodules/webextensions-lib-configs 3 | url = https://github.com/piroor/webextensions-lib-configs.git 4 | [submodule "submodules/webextensions-lib-l10n"] 5 | path = submodules/webextensions-lib-l10n 6 | url = https://github.com/piroor/webextensions-lib-l10n.git 7 | [submodule "submodules/webextensions-lib-options"] 8 | path = submodules/webextensions-lib-options 9 | url = https://github.com/piroor/webextensions-lib-options.git 10 | [submodule "submodules/webextensions-lib-rich-confirm"] 11 | path = submodules/webextensions-lib-rich-confirm 12 | url = https://github.com/piroor/webextensions-lib-rich-confirm.git 13 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guideline 2 | 3 | If you are planning to open a new issue for a bug report or a feature request, or having additional information for an existing issue, or hoping to translate the language resource for your language, then please see this document before posting. 4 | 5 | This is possibly a generic guideline for contributing any Firefox addon project or a public OSS/free software project. 6 | 7 | ## Good, helpful bug reports including feature requests 8 | 9 | A good report is the fastest way to solve a problem. 10 | Even if the problem is very clear for you, possibly unclear for me. 11 | Unclear report can be left unfixed for long time. 12 | You'll see an example of [good report](https://github.com/piroor/treestyletab/issues/1134) and [another report with too less information](https://github.com/piroor/treestyletab/issues/1135). 13 | 14 | Here is a list of typical questions I asked to existing reports: 15 | 16 | * **Does the problem appear with the [latest develpment build](http://piro.sakura.ne.jp/xul/xpi/nightly/)?** 17 | Possibly, problems you met has been resolved already. 18 | On Firefox 48 and later, you'll have to use an [unbranded Firefox](https://wiki.mozilla.org/Add-ons/Extension_Signing#Unbranded_Builds) (including Beta, Aurora, and Nightly) with a secret preference `xpinstall.signatures.required`=`false` (you can set it via `about:config`), to try unsigned development builds. 19 | * **Is the "problem" really introduced by this addon?** 20 | If you use this addon with others, please confirm which addon actually causes the problem you met. 21 | If the problem doesn't appear with no other addon, it can be introduced by others. 22 | See also the next. 23 | * **Does the problem appear with a clean profile?** 24 | You can start Firefox with temporary clean profile by a command line `-profile`, like: `"C:\Program Filex (x86)\Mozilla Firefox\firefox.exe" -no-remote -profile "%Temp%\FirefoxTemporaryProfile"` 25 | If the problem doesn't appear with a clean profile, please find complete reproduction steps out. 26 | * **Is the main topic single and clear?** 27 | Sometimes I got an issue including multiple topics, but such an issue is hard to be closed, then it often stays opened for long time and confuses me. 28 | If you have multiple topics, please report them as separate issues for each. 29 | 30 | For future requests, some more important information: 31 | 32 | * **Did you find other addon which provide the feature you are going to request?** 33 | If there is any other addon for the purpose, then this addon should become compatible to it instead of merging the feature into this addon itself. 34 | * **Please add `[feature request]` tag into the summary.** 35 | Sometimes a feature request can be misunderstood as a simple bug report. 36 | 37 | Then, please report the bug with these information: 38 | 39 | * **Detailed steps to reproduce the problem.** For example: 40 | 1. Prepare Firefox version XX with plain profile. 41 | 2. Install Tree Style Tab version XXXX. 42 | 3. Install another addon XXXX version XXXX from "http://....". 43 | 4. Click a button on the toolbar. 44 | 5. ... 45 | * **Expected result.** 46 | If you have any screenshot or screencast, it will help me more. 47 | * **Actual result.** 48 | If you have any screenshot or screencast, it will help me more. 49 | * **Platform information.** 50 | If the problem appear on your multiple platforms, please list them. 51 | 52 | If your issue is related to something complex conditions, figures or screenshots will help me a lot, instead of long descriptions. 53 | 54 | ## Please don't join to an existing discussion if your problem is different from the originally reported one 55 | 56 | Even if the result is quite similar, they may be different problem if the reproduction steps for yours are different from the one originally reported. 57 | Then, you should create a new issue for yours, instead of adding comment to the existing issue. 58 | Otherwise, I'll be confused if the original reporter said "the issue is a compatibility issue with another addon" but another reporter said "I saw this problem without any other addon". 59 | 60 | To avoid such a confusion, please post your report with detailed reproduction steps always. 61 | 62 | ## Feature requests can be tagged as "out of purpose" 63 | 64 | If there is any [readme page](./README.md), please see it before you post a new feature request. 65 | (Some my addon doesn't provide such an information, sorry.) 66 | Even if a requested feature is very useful, it is possibly rejected by the project policy. 67 | 68 | Instead, please tell me other addon which provide the feature and report a new issue as "compatibility issue with the addon, this addon should work together with it". 69 | I'm very positive to make my addons compatible to others. 70 | 71 | ## Translations, pull requests 72 | 73 | If you've fixed a problem you met by your hand, then please send a pull request to me. 74 | 75 | Translations also. 76 | Pull requests are easy to merge, than sending ZIP files. 77 | You'll do it without any local application - you can do it on the GitHub. 78 | For example, if you want to fix an existing typo in a locale, you just have to click the pencil button (with a tooltip "Edit this file") for a language resource file. 79 | -------------------------------------------------------------------------------- /COPYING.txt: -------------------------------------------------------------------------------- 1 | Each file under this directory is licensed under MPL 2.0 by default, if the file includes no license information. 2 | All rights reserved. 3 | 4 | ---- 5 | 6 | This project includes files licensed under different type licenses: 7 | 8 | * Mozilla Public License 2.0 (MPL 2.0) 9 | * MIT License 10 | 11 | Please note that these licenses are applied "per-file". In most cases each file includes a license header in itself. If a file has no license header due to some reasons (for example, image files), please see a file named "license.txt" in closest parent directory. Otherwise please treat the file is licensed under the default license declared at the top of this file. 12 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | ## Short description 7 | 8 | ## Steps to reproduce 9 | 10 | 1. Start Firefox with clean profile. 11 | 2. Install MTH. 12 | 3. 13 | 4. 14 | 15 | 25 | 26 | ## Expected result 27 | 28 | 29 | ## Actual result 30 | 31 | 32 | ## Environment 33 | 34 | * Platform (OS): 35 | * Version of Firefox: 36 | * Version (or revision) of Multiple Tab Handler: 37 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NPM_MOD_DIR := $(CURDIR)/node_modules 2 | NPM_BIN_DIR := $(NPM_MOD_DIR)/.bin 3 | 4 | .PHONY: xpi install_dependency install_hook lint format init_extlib update_extlib install_extlib 5 | 6 | all: xpi 7 | 8 | install_dependency: 9 | [ -e "$(NPM_BIN_DIR)/eslint" -a -e "$(NPM_BIN_DIR)/jsonlint-cli" ] || npm install 10 | 11 | install_hook: 12 | echo '#!/bin/sh\nmake lint' > "$(CURDIR)/.git/hooks/pre-commit" && chmod +x "$(CURDIR)/.git/hooks/pre-commit" 13 | 14 | lint: install_dependency 15 | "$(NPM_BIN_DIR)/eslint" . --report-unused-disable-directives 16 | find . -type d -name node_modules -prune -o -type f -name '*.json' -print | xargs "$(NPM_BIN_DIR)/jsonlint-cli" 17 | 18 | format: install_dependency 19 | "$(NPM_BIN_DIR)/eslint" . --ext=.js --report-unused-disable-directives --fix 20 | 21 | xpi: init_extlib install_extlib lint 22 | rm -f ./*.xpi 23 | zip -r -9 save-selected-tabs-to-files.xpi manifest.json common resources background panel options _locales extlib -x '*/.*' >/dev/null 2>/dev/null 24 | 25 | init_extlib: 26 | git submodule update --init 27 | 28 | update_extlib: 29 | git submodule foreach 'git checkout trunk || git checkout main || git checkout master && git pull' 30 | 31 | install_extlib: 32 | rm -f extlib/*.js 33 | cp submodules/webextensions-lib-configs/Configs.js extlib/; echo 'export default Configs;' >> extlib/Configs.js 34 | cp submodules/webextensions-lib-options/Options.js extlib/; echo 'export default Options;' >> extlib/Options.js 35 | cp submodules/webextensions-lib-l10n/l10n.js extlib/; echo 'export default l10n;' >> extlib/l10n.js 36 | cp submodules/webextensions-lib-rich-confirm/RichConfirm.js extlib/; echo 'export default RichConfirm;' >> extlib/RichConfirm.js 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Save Selected Tabs to Files 2 | 3 | ![Build Status](https://github.com/piroor/save-selected-tabs-to-files/actions/workflows/main.yml/badge.svg?branch=trunk) 4 | 5 | * [Signed package on AMO](https://addons.mozilla.org/firefox/addon/save-selected-tabs-to-files/) 6 | * [Development builds for each commit are available at "Artifacts" of the CI/CD action](https://github.com/piroor/save-selected-tabs-to-files/actions?query=workflow%3ACI%2FCD) 7 | 8 | ## Privacy Policy 9 | 10 | This software does not collect any privacy data automatically, but this includes ability to synchronize options across multiple devices automatically via Firefox Sync. 11 | Any data you input to options may be sent to Mozilla's Sync server, if you configure Firefox to activate Firefox Sync. 12 | 13 | このソフトウェアはいかなるプライバシー情報も自動的に収集しませんが、Firefox Syncを介して自動的に設定情報をデバイス間で同期する機能を含みます。 14 | Firefox Syncを有効化している場合、設定画面に入力されたデータは、Mozillaが運用するSyncサーバーに送信される場合があります。 15 | -------------------------------------------------------------------------------- /_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensionName": { "message": "Save Selected Tabs to Files" }, 3 | "extensionDescription": { "message": "Provides ability to save selected tabs to local files." }, 4 | 5 | "saveTabsPrefix_defaultValue": { "message": "Saved Tabs/" }, 6 | 7 | "context_saveTab_label": { "message": "Sa&ve Tab as File" }, 8 | "context_saveTabs_label": { "message": "Sa&ve Tabs as Files" }, 9 | "command_saveSelectedTabs": { "message": "Save Selected Tabs as Files" }, 10 | 11 | "dialog_title": { "message": "Where to save?" }, 12 | "dialog_inputDescription": { "message": "Input a folder name to save files within" }, 13 | "dialog_save": { "message": "Create Folder and Save" }, 14 | "dialog_cancel": { "message": "Cancel" }, 15 | 16 | 17 | "startup_useDownloadDirOptionNote_title": { "message": "Important note before start using Save Selected Tabs to Files" }, 18 | "startup_useDownloadDirOptionNote_message": { "message": "You should configure Firefox itself before using this addon. Click here to see more details." }, 19 | 20 | "useDownloadDirOption_caption": { "message": "Important note before start using" }, 21 | "useDownloadDirOption_note": { "message": "It is strongly recommended to configure Firefox itself to save downloaded files to a single folder: Firefox settings => \"General\" => \"Files and Applications\" => \"Downloads\" => choose \"Save files to\". If \"Always ask you where to save files\" is chosen, you'll see a \"save file as...\" common dialog for every files downloaded from tabs, and it will be too annoying." }, 22 | 23 | 24 | "config_saveTabs_caption": { "message": "Save Tabs as Files" }, 25 | "config_saveTabsPrefix_label_before": { "message": "Save tabs under " }, 26 | "config_saveTabsPrefix_label_after": { "message": "\u200b" }, 27 | "config_saveTabsPrefix_description": { "message": "*Path under the download folder. \"/\" will be recognized as a path delimiter. A `%input%` placeholder in the path produces a diaglog to input folder name when you try to save." }, 28 | "config_maxFileNameLength_label_before": { "message": "Shorten file name longer than " }, 29 | "config_maxFileNameLength_label_after": { "message": " characters" }, 30 | "config_maxDownloads_label_before": { "message": "Run " }, 31 | "config_maxDownloads_label_after": { "message": " downloads parallely at a time" }, 32 | 33 | "config_allUrlsPermissionGranted_label": { "message": "Guess filename extension based on Content-Type of tabs" }, 34 | 35 | "config_showContextCommandOnTab_label": { "message": "Show context menu item in the context menu on tabs" }, 36 | "config_showContextCommandOnPage_label": { "message": "Show context menu item in the context menu on the content area" }, 37 | "config_showContextCommandForSingleTab_label": { "message": "Show context menu item even if there is no multiselection" }, 38 | 39 | "config_clearSelectionAfterCommandInvoked_label": { "message": "Clear selection after a command is invoked" }, 40 | 41 | "config_shortcuts_caption": { "message": "Keyboard shortcuts" }, 42 | 43 | 44 | "config_debug_caption": { "message": "Development" }, 45 | "config_debug_label": { "message": "Debug mode" }, 46 | "config_all_caption": { "message": "All Configs" } 47 | } 48 | -------------------------------------------------------------------------------- /_locales/ja/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensionName": { "message": "選択したタブを保存" }, 3 | "extensionDescription": { "message": "選択されたタブをローカルファイルとして保存する機能を提供します。" }, 4 | 5 | "saveTabsPrefix_defaultValue": { "message": "保存されたタブ/" }, 6 | 7 | "context_saveTab_label": { "message": "ファイルに保存(&V)" }, 8 | "context_saveTabs_label": { "message": "ファイルに保存(&V)" }, 9 | "command_saveSelectedTabs": { "message": "選択中のタブをファイルに保存" }, 10 | 11 | "dialog_title": { "message": "どこに保存しますか?" }, 12 | "dialog_inputDescription": { "message": "ファイルの保存先フォルダ名を入力してください" }, 13 | "dialog_save": { "message": "フォルダを作成して保存" }, 14 | "dialog_cancel": { "message": "キャンセル" }, 15 | 16 | 17 | "startup_useDownloadDirOptionNote_title": { "message": "選択したタブを保存 の使用開始前の重要な注意事項" }, 18 | "startup_useDownloadDirOptionNote_message": { "message": "このアドオンを使い始める前に、Firefoxを自分で正しく設定してください。ここをクリックすると詳細を確認できます。" }, 19 | 20 | "useDownloadDirOption_caption": { "message": "使用開始前の重要な注意" }, 21 | "useDownloadDirOption_note": { "message": "このアドオンを使用するときは、Firefoxの設定の「一般」→「ファイルとプログラム」→「ダウンロード」で「次のフォルダーに保存する」を選択しておくことを強くお勧めします。「ファイルごとに保存先を指定する」を選択していると、保存しようとしているタブの数だけファイルの保存先を尋ねるダイアログが表示され、非常に煩わしい結果になるので、ご注意ください。" }, 22 | 23 | 24 | "config_saveTabs_caption": { "message": "タブのファイルへの保存" }, 25 | "config_saveTabsPrefix_label_before": { "message": "タブを" }, 26 | "config_saveTabsPrefix_label_after": { "message": "以下に保存する" }, 27 | "config_saveTabsPrefix_description": { "message": "※ダウンロードフォルダ配下の保存先パスを指定できます。「/」はパスの区切り文字として解釈されます。パスの中にプレースホルダ「%input%」を含めると、保存時にフォルダ名を入力するダイアログが表示されます。" }, 28 | "config_maxFileNameLength_label_before": { "message": "ファイル名の長さが " }, 29 | "config_maxFileNameLength_label_after": { "message": "文字より長い場合は省略する" }, 30 | "config_maxDownloads_label_before": { "message": "ダウンロードを" }, 31 | "config_maxDownloads_label_after": { "message": "件まで並行で行う" }, 32 | 33 | "config_allUrlsPermissionGranted_label": { "message": "タブの内容のContent-Typeに基づいてファイル名の拡張子を推定する" }, 34 | 35 | "config_showContextCommandOnTab_label": { "message": "タブのコンテキストメニューにメニュー項目を表示する" }, 36 | "config_showContextCommandOnPage_label": { "message": "コンテンツ領域のコンテキストメニューにメニュー項目を表示する" }, 37 | "config_showContextCommandForSingleTab_label": { "message": "タブが複数選択されていない場合でもコンテキストメニューの項目を有効化する" }, 38 | 39 | "config_clearSelectionAfterCommandInvoked_label": { "message": "コマンドの実行後にタブの選択を解除する" }, 40 | 41 | "config_shortcuts_caption": { "message": "キーボードショートカット" }, 42 | 43 | "config_debug_caption": { "message": "開発用" }, 44 | "config_debug_label": { "message": "デバッグモード" }, 45 | "config_all_caption": { "message": "すべての設定" } 46 | } 47 | -------------------------------------------------------------------------------- /_locales/zh_CN/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "saveTabsPrefix_defaultValue": { 3 | "message": "已保存标签页/", 4 | "hash": "c4de7a7d09f7247035c1686067b893b5" 5 | }, 6 | "context_saveTabs_label": { 7 | "message": "保存标签页到文件(&V)", 8 | "hash": "e4b61003932478b5800fe582aae631b3" 9 | }, 10 | "command_saveSelectedTabs": { 11 | "message": "保存选中标签页到文件", 12 | "hash": "66da9e69ca5c42f620d31d523aae8f1f" 13 | }, 14 | "config_saveTabsPrefix_label_before": { 15 | "message": "标签页保存到", 16 | "hash": "c5bfd74aed6217ca9d8a3c6b00e992f3" 17 | }, 18 | "config_saveTabsPrefix_label_after": { 19 | "message": "\u200b", 20 | "hash": "aa7906338dbdd2af4993295aa9112e97" 21 | }, 22 | "config_saveTabsPrefix_description": { 23 | "message": "*路径相对于“下载”文件夹。“/”将识别为路径分隔符。", 24 | "hash": "2ff802469cb2f1617681b50581ed2e94" 25 | }, 26 | "config_shortcuts_caption": { 27 | "message": "键盘快捷键", 28 | "hash": "3e4c94412820831fbea1494aa4d5ecf4" 29 | }, 30 | "config_debug_caption": { 31 | "message": "开发调试", 32 | "hash": "af84c3ce90181048c533886621f00ac2" 33 | }, 34 | "config_debug_label": { 35 | "message": "调试模式", 36 | "hash": "09b9b4dc690b71222315f1191f9a1d1b" 37 | }, 38 | "config_all_caption": { 39 | "message": "所有配置参数", 40 | "hash": "e0437bb7bd8d7ce28700078c6bb12418" 41 | }, 42 | 43 | "__WET_LOCALE__": { "message": "zh-CN" } 44 | } 45 | -------------------------------------------------------------------------------- /background/background.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /background/background.js: -------------------------------------------------------------------------------- 1 | /* 2 | # This Source Code Form is subject to the terms of the Mozilla Public 3 | # License, v. 2.0. If a copy of the MPL was not distributed with this 4 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 'use strict'; 7 | 8 | import { 9 | log, 10 | configs, 11 | handleMissingReceiverError, 12 | notify, 13 | } from '/common/common.js'; 14 | import * as Constants from '/common/constants.js'; 15 | import * as Commands from '/common/commands.js'; 16 | import * as ContextMenu from './context-menu.js'; 17 | 18 | log.context = 'BG'; 19 | 20 | configs.$loaded.then(async () => { 21 | browser.commands.onCommand.addListener(onShortcutCommand); 22 | browser.runtime.onMessageExternal.addListener(onMessageExternal); 23 | registerToTST(); 24 | notifyUseDownloadDirOptionNote(); 25 | window.addEventListener('pagehide', async () => { 26 | unregisterFromTST(); 27 | unregisterFromMTH(); 28 | }, { once: true }); 29 | }); 30 | 31 | 32 | /* listen events */ 33 | 34 | async function onShortcutCommand(command) { 35 | log('command: ', command); 36 | const activeTab = (await browser.tabs.query({ 37 | active: true, 38 | currentWindow: true 39 | }))[0]; 40 | const tabs = await Commands.getMultiselectedTabs(activeTab); 41 | log('tabs: ', { activeTab, tabs }); 42 | 43 | if (tabs.length <= 0) 44 | return; 45 | 46 | switch (command) { 47 | case 'saveSelectedTabs': 48 | await Commands.saveTabs(tabs); 49 | if (configs.clearSelectionAfterCommandInvoked) { 50 | browser.tabs.highlight({ 51 | windowId: activeTab.windowId, 52 | tabs: [activeTab.index] 53 | }); 54 | } 55 | break; 56 | } 57 | } 58 | 59 | function onMessageExternal(message, sender) { 60 | log('onMessageExternal: ', message, sender); 61 | 62 | switch (sender.id) { 63 | case Constants.kTST_ID: { // Tree Style Tab API 64 | let result; 65 | switch (message.type) { 66 | case Constants.kTSTAPI_NOTIFY_READY: 67 | registerToTST(); 68 | ContextMenu.init(); 69 | result = true; 70 | break; 71 | } 72 | if (result !== undefined) 73 | return Promise.resolve(result); 74 | }; break; 75 | 76 | case Constants.kMTH_ID: { // Multiple Tab Handler API 77 | let result; 78 | switch (message.type) { 79 | case Constants.kMTHAPI_READY: 80 | ContextMenu.init(); 81 | result = true; 82 | break; 83 | } 84 | if (result !== undefined) 85 | return Promise.resolve(result); 86 | }; break; 87 | 88 | default: 89 | break; 90 | } 91 | } 92 | 93 | async function registerToTST() { 94 | try { 95 | await browser.runtime.sendMessage(Constants.kTST_ID, { 96 | type: Constants.kTSTAPI_REGISTER_SELF, 97 | name: browser.i18n.getMessage('extensionName'), 98 | icons: browser.runtime.getManifest().icons, 99 | listeningTypes: [ 100 | Constants.kTSTAPI_NOTIFY_READY, 101 | Constants.kTSTAPI_CONTEXT_MENU_CLICK, 102 | Constants.kTSTAPI_CONTEXT_MENU_SHOWN 103 | ] 104 | }).catch(handleMissingReceiverError); 105 | } 106 | catch(_e) { 107 | return false; 108 | } 109 | } 110 | 111 | function unregisterFromTST() { 112 | try { 113 | browser.runtime.sendMessage(Constants.kTST_ID, { 114 | type: Constants.kTSTAPI_CONTEXT_MENU_REMOVE_ALL 115 | }).catch(handleMissingReceiverError); 116 | browser.runtime.sendMessage(Constants.kTST_ID, { 117 | type: Constants.kTSTAPI_UNREGISTER_SELF 118 | }).catch(handleMissingReceiverError); 119 | } 120 | catch(_e) { 121 | } 122 | } 123 | 124 | function unregisterFromMTH() { 125 | try { 126 | browser.runtime.sendMessage(Constants.kMTH_ID, { 127 | type: Constants.kMTHAPI_REMOVE_ALL_SELECTED_TAB_COMMANDS 128 | }).catch(handleMissingReceiverError); 129 | } 130 | catch(_e) { 131 | } 132 | } 133 | 134 | 135 | const USER_DOWNLOAD_DIR_OPTION_NOTE_URL = browser.extension.getURL(`resources/notify-features.html?useDownloadDirOptionNote`); 136 | 137 | async function notifyUseDownloadDirOptionNote() { 138 | if (configs.useDownloadDirOptionNoteShown) 139 | return false; 140 | 141 | configs.useDownloadDirOptionNoteShown = true; 142 | await notify({ 143 | url: USER_DOWNLOAD_DIR_OPTION_NOTE_URL, 144 | title: browser.i18n.getMessage(`startup_useDownloadDirOptionNote_title`), 145 | message: browser.i18n.getMessage(`startup_useDownloadDirOptionNote_message`), 146 | timeout: 90 * 1000 147 | }); 148 | configs.useDownloadDirOptionNoteShown = true; // failsafe: it can be overridden by the value loaded from sync storage 149 | 150 | return true; 151 | } 152 | 153 | function initUseDownloadDirOptionNoteTab(tab) { 154 | const title = `${browser.i18n.getMessage('extensionName')} ${browser.runtime.getManifest().version}`; 155 | const description = browser.i18n.getMessage('useDownloadDirOption_note'); 156 | 157 | browser.tabs.executeScript(tab.id, { 158 | code: `{ 159 | document.querySelector('#title').textContent = document.title = ${JSON.stringify(title)}; 160 | const descriptionContainer = document.querySelector('#description'); 161 | descriptionContainer.innerHTML = ''; 162 | descriptionContainer.appendChild(document.createTextNode(${JSON.stringify(description)})); 163 | descriptionContainer.appendChild(document.createElement('br')); 164 | const img = descriptionContainer.appendChild(document.createElement('img')); 165 | img.alt = ''; 166 | img.src = ${JSON.stringify(browser.runtime.getURL('/resources/downloads-option.png'))}; 167 | }` 168 | }); 169 | } 170 | 171 | browser.tabs.onUpdated.addListener( 172 | (_tabId, updateInfo, tab) => { 173 | if (updateInfo.status != 'complete') 174 | return; 175 | initUseDownloadDirOptionNoteTab(tab); 176 | }, 177 | { properties: ['status'], 178 | urls: [USER_DOWNLOAD_DIR_OPTION_NOTE_URL] } 179 | ); 180 | browser.tabs.query({ url: USER_DOWNLOAD_DIR_OPTION_NOTE_URL }) 181 | .then(tabs => tabs.forEach(initUseDownloadDirOptionNoteTab)); 182 | -------------------------------------------------------------------------------- /background/context-menu.js: -------------------------------------------------------------------------------- 1 | /* 2 | # This Source Code Form is subject to the terms of the Mozilla Public 3 | # License, v. 2.0. If a copy of the MPL was not distributed with this 4 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 'use strict'; 7 | 8 | import { 9 | log, 10 | configs, 11 | handleMissingReceiverError 12 | } from '/common/common.js'; 13 | import * as Constants from '/common/constants.js'; 14 | import * as Commands from '/common/commands.js'; 15 | 16 | const icons = { 17 | '16': '/resources/Save.svg#toolbar', 18 | }; 19 | 20 | const mMenuItems = [ 21 | { 22 | id: 'saveTabsOnTab', 23 | type: 'normal', 24 | visible: true, 25 | title: browser.i18n.getMessage('context_saveTabs_label'), 26 | icons, 27 | contexts: ['tab'], 28 | config: 'showContextCommandOnTab' 29 | }, 30 | { 31 | id: 'saveTabsOnPage', 32 | type: 'normal', 33 | visible: true, 34 | title: browser.i18n.getMessage('context_saveTabs_label'), 35 | icons, 36 | contexts: ['page'], 37 | config: 'showContextCommandOnPage' 38 | } 39 | ]; 40 | 41 | export function init() { 42 | for (const item of mMenuItems) { 43 | const params = { 44 | id: item.id, 45 | type: item.type || 'normal', 46 | visible: true, 47 | title: item.title, 48 | icons: item.icons, 49 | contexts: item.contexts 50 | }; 51 | browser.menus.create(params); 52 | if (!item.contexts.includes('tab')) 53 | continue; 54 | try { 55 | browser.runtime.sendMessage(Constants.kTST_ID, { 56 | type: Constants.kTSTAPI_CONTEXT_MENU_CREATE, 57 | params: params 58 | }).catch(handleMissingReceiverError); 59 | } 60 | catch(_e) { 61 | } 62 | try { 63 | browser.runtime.sendMessage(Constants.kMTH_ID, { 64 | ...params, 65 | type: Constants.kMTHAPI_ADD_SELECTED_TAB_COMMAND 66 | }).catch(handleMissingReceiverError); 67 | } 68 | catch(_e) { 69 | } 70 | } 71 | } 72 | 73 | async function onShown(info, tab) { 74 | const tabs = await Commands.getMultiselectedTabs(tab); 75 | let updated = false; 76 | for (const item of mMenuItems) { 77 | const lastVisible = item.visible; 78 | const lastTitle = item.title; 79 | item.visible = configs[item.config] && tabs.length > 1 || configs.showContextCommandForSingleTab; 80 | item.title = browser.i18n.getMessage(tabs.length > 1 ? 'context_saveTabs_label' : 'context_saveTab_label'); 81 | if (lastVisible == item.visible && 82 | lastTitle == item.title) 83 | continue; 84 | 85 | const params = { 86 | visible: item.visible, 87 | title: item.title 88 | }; 89 | browser.menus.update(item.id, params); 90 | updated = true; 91 | if (!item.contexts.includes('tab')) 92 | continue; 93 | try { 94 | browser.runtime.sendMessage(Constants.kTST_ID, { 95 | type: Constants.kTSTAPI_CONTEXT_MENU_UPDATE, 96 | params: [item.id, params] 97 | }).catch(handleMissingReceiverError); 98 | } 99 | catch(_e) { 100 | } 101 | } 102 | if (updated) 103 | browser.menus.refresh(); 104 | } 105 | browser.menus.onShown.addListener(onShown); 106 | 107 | async function onClick(info, tab, selectedTabs = null) { 108 | log('context menu item clicked: ', info, tab); 109 | const tabs = selectedTabs || await Commands.getMultiselectedTabs(tab); 110 | log('tabs: ', tabs); 111 | switch (info.menuItemId) { 112 | case 'saveTabsOnTab': 113 | case 'saveTabsOnPage': 114 | await Commands.saveTabs(tabs); 115 | if (configs.clearSelectionAfterCommandInvoked && 116 | tabs.length > 1) { 117 | const activeTab = tabs.filter(tab => tab.active)[0]; 118 | browser.tabs.highlight({ 119 | windowId: activeTab.windowId, 120 | tabs: [activeTab.index] 121 | }); 122 | } 123 | break; 124 | } 125 | }; 126 | browser.menus.onClicked.addListener(onClick); 127 | 128 | function onMessageExternal(message, sender) { 129 | log('onMessageExternal: ', message, sender); 130 | 131 | if (!message || 132 | typeof message.type != 'string') 133 | return; 134 | 135 | switch (sender.id) { 136 | case Constants.kTST_ID: { // Tree Style Tab API 137 | const result = onTSTAPIMessage(message); 138 | if (result !== undefined) 139 | return result; 140 | }; break; 141 | 142 | case Constants.kMTH_ID: { // Multiple Tab Handler API 143 | const result = onMTHAPIMessage(message); 144 | if (result !== undefined) 145 | return result; 146 | }; break; 147 | 148 | default: 149 | break; 150 | } 151 | } 152 | browser.runtime.onMessageExternal.addListener(onMessageExternal); 153 | 154 | function onTSTAPIMessage(message) { 155 | switch (message.type) { 156 | case Constants.kTSTAPI_CONTEXT_MENU_CLICK: 157 | if (!message.tab) 158 | return; 159 | return browser.tabs.get(message.tab.id).then(tab => onClick(message.info, tab)); 160 | 161 | case Constants.kTSTAPI_CONTEXT_MENU_SHOWN: 162 | if (!message.tab) 163 | return; 164 | return browser.tabs.get(message.tab.id).then(tab => onClick(message.info, tab)); 165 | } 166 | } 167 | 168 | function onMTHAPIMessage(message) { 169 | switch (message.type) { 170 | case Constants.kMTHAPI_INVOKE_SELECTED_TAB_COMMAND: 171 | return Commands.getMultiselectedTabs({ windowId: message.windowId, highlighted: true }).then(tabs => onClick({ menuItemId: message.id }, null, tabs)); 172 | } 173 | } 174 | 175 | init(); 176 | -------------------------------------------------------------------------------- /common/commands.js: -------------------------------------------------------------------------------- 1 | /* 2 | # This Source Code Form is subject to the terms of the Mozilla Public 3 | # License, v. 2.0. If a copy of the MPL was not distributed with this 4 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 'use strict'; 7 | 8 | import { 9 | log, 10 | configs, 11 | isRTL, 12 | } from './common.js'; 13 | import * as Permissions from './permissions.js'; 14 | 15 | import RichConfirm from '/extlib/RichConfirm.js'; 16 | 17 | export async function getMultiselectedTabs(tab) { 18 | if (!tab) 19 | return []; 20 | if (tab.highlighted) 21 | return browser.tabs.query({ 22 | windowId: tab.windowId, 23 | highlighted: true 24 | }); 25 | else 26 | return [tab]; 27 | } 28 | 29 | const PREFIX_INPUT_PLACEHOLDER_MATCHER = /(^.*)(%input%)(.*$)/i; 30 | 31 | export async function saveTabs(tabs) { 32 | if (!tabs.length) 33 | return; 34 | 35 | let prefix = configs.saveTabsPrefix; 36 | const matched = prefix.match(PREFIX_INPUT_PLACEHOLDER_MATCHER); 37 | if (matched) { 38 | log('matched for placeholder: ', matched); 39 | const prePart = matched[1] || ''; 40 | const postPart = matched[3] || ''; 41 | let input; 42 | try { 43 | const windowId = (tabs.find(tab => tab.active) || tabs[0]).windowId; 44 | log(' windowId: ', windowId); 45 | const result = await RichConfirm.showInPopup(windowId, { 46 | modal: true, 47 | type: 'common-dialog', 48 | url: '/resources/blank.html', // required on Firefox ESR68 49 | title: browser.i18n.getMessage('dialog_title'), 50 | content: ` 51 |
${browser.i18n.getMessage('dialog_inputDescription')}
68 | `.trim(), 69 | onShown(container) { 70 | container.querySelector('[name="input"]').select(); 71 | }, 72 | buttons: [ 73 | browser.i18n.getMessage('dialog_save'), 74 | browser.i18n.getMessage('dialog_cancel') 75 | ] 76 | }); 77 | log(' result: ', result); 78 | if (result.buttonIndex != 0) 79 | return; 80 | input = result.values.input || ''; 81 | prefix = `${prePart}${input}${postPart}`; 82 | } 83 | catch(error) { 84 | log('error: ', error); 85 | } 86 | log(' => ', { input, prefix }); 87 | } 88 | prefix = `${prefix.replace(/\/$/, '')}/`; 89 | const alreadyUsedNames = new Set(); 90 | await Promise.all(tabs.map(async tab => { 91 | tab.$fileName = await suggestUniqueFileNameForTab(tab, alreadyUsedNames) 92 | })); 93 | 94 | const downloadLoop = async () => { 95 | while (tabs.length > 0) { 96 | const tab = tabs.shift(); 97 | try { 98 | await browser.downloads.download({ 99 | url: tab.url, 100 | filename: `${prefix}${tab.$fileName}` 101 | }); 102 | } 103 | catch(_error) { 104 | } 105 | } 106 | }; 107 | const promisedTasks = []; 108 | for (let i = 0; i < configs.maxDownloads; i++) { 109 | promisedTasks.push(downloadLoop()); 110 | } 111 | await Promise.all(promisedTasks); 112 | } 113 | 114 | async function suggestUniqueFileNameForTab(tab, alreadyUsedNames) { 115 | let name = await suggestFileNameForTab(tab); 116 | if (!alreadyUsedNames.has(name)) { 117 | alreadyUsedNames.add(name); 118 | log(`filename for tab ${tab.id}: `, name); 119 | return name; 120 | } 121 | const WITH_SUFFIX_MATCHER = /(-(\d+)(\.?[^\.]+))$/; 122 | let matched = name.match(WITH_SUFFIX_MATCHER); 123 | if (!matched) { 124 | name = name.replace(/(\.?[^\.]+)$/, '-0$1'); 125 | matched = name.match(WITH_SUFFIX_MATCHER); 126 | } 127 | let count = parseInt(matched[2]); 128 | while (true) { 129 | count++; 130 | const newName = name.replace(matched[1], `-${count}${matched[3]}`); 131 | if (alreadyUsedNames.has(newName)) 132 | continue; 133 | alreadyUsedNames.add(newName); 134 | log(`filename for tab ${tab.id}: `, newName); 135 | return newName; 136 | } 137 | } 138 | 139 | const kMAYBE_IMAGE_PATTERN = /\.(jpe?g|png|gif|bmp|svg)/i; 140 | const kMAYBE_RAW_FILE_PATTERN = /\.(te?xt|md)/i; 141 | 142 | async function suggestFileNameForTab(tab) { 143 | const fileNameMatch = tab.url 144 | .replace(/^\w+:\/\/[^\/]+\//, '') // remove origin part 145 | .replace(/#.*$/, '') // remove fragment 146 | .replace(/\?.*$/, '') // remove query 147 | .match(/([^\/]+\.([^\.\/]+))$/); 148 | log('suggestFileNameForTab ', tab.id, fileNameMatch); 149 | if (fileNameMatch && 150 | (kMAYBE_IMAGE_PATTERN.test(fileNameMatch[1]) || 151 | kMAYBE_RAW_FILE_PATTERN.test(fileNameMatch[1]))) { 152 | const parts = fileNameMatch[1].split('.'); 153 | const baseName = parts.slice(0, parts.length - 1); 154 | return `${shorten(baseName)}.${parts[parts.length - 1]}`; 155 | } 156 | 157 | let suggestedExtension = ''; 158 | if (!tab.discarded && 159 | Permissions.isPermittedTab(tab) && 160 | await Permissions.isGranted(Permissions.ALL_URLS)) { 161 | log(`getting content type of ${tab.id}`); 162 | try { 163 | let contentType = await browser.tabs.executeScript(tab.id, { 164 | code: `document.contentType` 165 | }); 166 | if (Array.isArray(contentType)) 167 | contentType = contentType[0]; 168 | log(`contentType of ${tab.id}: `, contentType); 169 | if (/^(text\/html|application\/xhtml\+xml)/.test(contentType)) { 170 | suggestedExtension = '.html'; 171 | } 172 | else if (/^text\//.test(contentType)) { 173 | suggestedExtension = '.txt'; 174 | } 175 | else if (/^image\//.test(contentType)) { 176 | suggestedExtension = `.${contentType.replace(/^image\/|\+.+$/g, '')}`; 177 | } 178 | } 179 | catch(e) { 180 | log('Error! ', e); 181 | } 182 | } 183 | log('suggestedExtension: ', tab.id, suggestedExtension); 184 | const baseName = shorten(tab.title.replace(/[\/\\:*?"<>|]/g, '_')); 185 | const fileName = `${baseName}${suggestedExtension}`; 186 | log('finally suggested fileName: ', tab.id, fileName); 187 | return fileName; 188 | } 189 | 190 | function shorten(name) { 191 | if (configs.maxFileNameLength < 0 || 192 | name.length < configs.maxFileNameLength) 193 | return name; 194 | 195 | return `${name.substring(0, configs.maxFileNameLength - 1)}…`; 196 | } 197 | -------------------------------------------------------------------------------- /common/common.js: -------------------------------------------------------------------------------- 1 | /* 2 | # This Source Code Form is subject to the terms of the Mozilla Public 3 | # License, v. 2.0. If a copy of the MPL was not distributed with this 4 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 'use strict'; 7 | 8 | import Configs from '/extlib/Configs.js'; 9 | 10 | export const configs = new Configs({ 11 | showContextCommandOnTab: true, 12 | showContextCommandOnPage: false, 13 | showContextCommandForSingleTab: false, 14 | clearSelectionAfterCommandInvoked: false, 15 | saveTabsPrefix: browser.i18n.getMessage('saveTabsPrefix_defaultValue'), 16 | maxFileNameLength: 30, 17 | maxDownloads: 10, 18 | 19 | optionsExpandedGroups: [ 20 | 'useDownloadDirOptionNote', 21 | ], 22 | 23 | useDownloadDirOptionNoteShown: false, 24 | 25 | notificationTimeout: 10 * 1000, 26 | 27 | debug: false 28 | }, { 29 | localKeys: ` 30 | debug 31 | `.trim().split('\n').map(key => key.trim()).filter(key => key && key.indexOf('//') != 0) 32 | }); 33 | 34 | 35 | export function log(message, ...args) 36 | { 37 | if (!configs || !configs.debug) 38 | return; 39 | 40 | const nest = (new Error()).stack.split('\n').length; 41 | let indent = ''; 42 | for (let i = 0; i < nest; i++) { 43 | indent += ' '; 44 | } 45 | console.log(`savetab<${log.context}>: ${indent}${message}`, ...args); 46 | } 47 | log.context = '?'; 48 | 49 | export async function wait(task = 0, timeout = 0) { 50 | if (typeof task != 'function') { 51 | timeout = task; 52 | task = null; 53 | } 54 | return new Promise((resolve, _reject) => { 55 | setTimeout(async () => { 56 | if (task) 57 | await task(); 58 | resolve(); 59 | }, timeout); 60 | }); 61 | } 62 | 63 | export function handleMissingReceiverError(error) { 64 | if (!error || 65 | !error.message || 66 | error.message.indexOf('Could not establish connection. Receiving end does not exist.') == -1) 67 | throw error; 68 | // otherwise, this error is caused from missing receiver. 69 | // we just ignore it. 70 | } 71 | 72 | export async function notify({ icon, title, message, timeout, url } = {}) { 73 | const id = await browser.notifications.create({ 74 | type: 'basic', 75 | iconUrl: icon || browser.extension.getURL(`resources/Save.svg`), 76 | title, 77 | message 78 | }); 79 | 80 | let onClicked; 81 | let onClosed; 82 | return new Promise(async (resolve, _reject) => { 83 | let resolved = false; 84 | 85 | onClicked = notificationId => { 86 | if (notificationId != id) 87 | return; 88 | if (url) { 89 | browser.tabs.create({ 90 | url 91 | }); 92 | } 93 | resolved = true; 94 | resolve(true); 95 | }; 96 | browser.notifications.onClicked.addListener(onClicked); 97 | 98 | onClosed = notificationId => { 99 | if (notificationId != id) 100 | return; 101 | if (!resolved) { 102 | resolved = true; 103 | resolve(false); 104 | } 105 | }; 106 | browser.notifications.onClosed.addListener(onClosed); 107 | 108 | if (typeof timeout != 'number') 109 | timeout = configs.notificationTimeout; 110 | if (timeout >= 0) { 111 | await wait(timeout); 112 | } 113 | await browser.notifications.clear(id); 114 | if (!resolved) 115 | resolve(false); 116 | }).then(clicked => { 117 | browser.notifications.onClicked.removeListener(onClicked); 118 | onClicked = null; 119 | browser.notifications.onClosed.removeListener(onClosed); 120 | onClosed = null; 121 | return clicked; 122 | }); 123 | } 124 | 125 | 126 | const RTL_LANGUAGES = new Set([ 127 | 'ar', 128 | 'he', 129 | 'fa', 130 | 'ur', 131 | 'ps', 132 | 'sd', 133 | 'ckb', 134 | 'prs', 135 | 'rhg', 136 | ]); 137 | 138 | export function isRTL() { 139 | const lang = ( 140 | navigator.language || 141 | navigator.userLanguage || 142 | //(new Intl.DateTimeFormat()).resolvedOptions().locale || 143 | '' 144 | ).split('-')[0]; 145 | return RTL_LANGUAGES.has(lang); 146 | } 147 | -------------------------------------------------------------------------------- /common/constants.js: -------------------------------------------------------------------------------- 1 | /* 2 | # This Source Code Form is subject to the terms of the Mozilla Public 3 | # License, v. 2.0. If a copy of the MPL was not distributed with this 4 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 'use strict'; 7 | 8 | export const kTST_ID = 'treestyletab@piro.sakura.ne.jp'; 9 | 10 | export const kTSTAPI_REGISTER_SELF = 'register-self'; 11 | export const kTSTAPI_UNREGISTER_SELF = 'unregister-self'; 12 | export const kTSTAPI_PING = 'ping'; 13 | export const kTSTAPI_NOTIFY_READY = 'ready'; 14 | export const kTSTAPI_GET_TREE = 'get-tree'; 15 | export const kTSTAPI_GET_TREE_STRUCTURE = 'get-tree-structure'; 16 | export const kTSTAPI_CONTEXT_MENU_SHOWN = 'fake-contextMenu-shown'; 17 | export const kTSTAPI_CONTEXT_MENU_CREATE = 'fake-contextMenu-create'; 18 | export const kTSTAPI_CONTEXT_MENU_UPDATE = 'fake-contextMenu-update'; 19 | export const kTSTAPI_CONTEXT_MENU_REMOVE = 'fake-contextMenu-remove'; 20 | export const kTSTAPI_CONTEXT_MENU_REMOVE_ALL = 'fake-contextMenu-remove-all'; 21 | export const kTSTAPI_CONTEXT_MENU_CLICK = 'fake-contextMenu-click'; 22 | 23 | 24 | export const kMTH_ID = 'multipletab@piro.sakura.ne.jp'; 25 | 26 | export const kMTHAPI_READY = 'ready'; 27 | export const kMTHAPI_GET_TAB_SELECTION = 'get-tab-selection'; 28 | export const kMTHAPI_SET_TAB_SELECTION = 'set-tab-selection'; 29 | export const kMTHAPI_CLEAR_TAB_SELECTION = 'clear-tab-selection'; 30 | export const kMTHAPI_ADD_SELECTED_TAB_COMMAND = 'add-selected-tab-command'; 31 | export const kMTHAPI_REMOVE_SELECTED_TAB_COMMAND = 'remove-selected-tab-command'; 32 | export const kMTHAPI_REMOVE_ALL_SELECTED_TAB_COMMANDS = 'remove-all-selected-tab-commands'; 33 | export const kMTHAPI_INVOKE_SELECTED_TAB_COMMAND = 'selected-tab-command'; 34 | -------------------------------------------------------------------------------- /common/permissions.js: -------------------------------------------------------------------------------- 1 | /* 2 | # This Source Code Form is subject to the terms of the Mozilla Public 3 | # License, v. 2.0. If a copy of the MPL was not distributed with this 4 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 'use strict'; 7 | 8 | import { 9 | configs 10 | } from './common.js'; 11 | 12 | export const ALL_URLS = { origins: [''] }; 13 | 14 | export function clearRequest() { 15 | configs.requestingPermissions = null; 16 | } 17 | 18 | export function isGranted(permissions) { 19 | try { 20 | return browser.permissions.contains(permissions); 21 | } 22 | catch(_e) { 23 | return Promise.reject(new Error('unsupported permission')); 24 | } 25 | } 26 | 27 | export function bindToCheckbox(permissions, checkbox, options = {}) { 28 | isGranted(permissions) 29 | .then(granted => { 30 | checkbox.checked = granted; 31 | }) 32 | .catch(_error => { 33 | checkbox.setAttribute('readonly', true); 34 | checkbox.setAttribute('disabled', true); 35 | const label = checkbox.closest('label') || document.querySelector(`label[for=${checkbox.id}]`); 36 | if (label) 37 | label.setAttribute('disabled', true); 38 | }); 39 | 40 | checkbox.addEventListener('change', _event => { 41 | checkbox.requestPermissions() 42 | }); 43 | 44 | /* 45 | // These events are not available yet on Firefox... 46 | browser.permissions.onAdded.addListener(addedPermissions => { 47 | if (addedPermissions.permissions.indexOf('...') > -1) 48 | checkbox.checked = true; 49 | }); 50 | browser.permissions.onRemoved.addListener(removedPermissions => { 51 | if (removedPermissions.permissions.indexOf('...') > -1) 52 | checkbox.checked = false; 53 | }); 54 | */ 55 | 56 | checkbox.requestPermissions = async () => { 57 | try { 58 | if (!checkbox.checked) { 59 | await browser.permissions.remove(permissions); 60 | if (options.onChanged) 61 | options.onChanged(false); 62 | return; 63 | } 64 | 65 | checkbox.checked = false; 66 | if (configs.requestingPermissionsNatively) 67 | return; 68 | 69 | let granted = await browser.permissions.request(permissions); 70 | if (granted === undefined) 71 | granted = await isGranted(permissions); 72 | else if (!granted) 73 | return; 74 | 75 | if (granted) { 76 | checkbox.checked = true; 77 | if (options.onChanged) 78 | options.onChanged(true); 79 | return; 80 | } 81 | } 82 | catch(error) { 83 | console.log(error); 84 | } 85 | checkbox.checked = false; 86 | }; 87 | } 88 | 89 | export function isPermittedTab(tab) { 90 | if (tab.discarded) 91 | return false; 92 | return /^about:blank($|\?|#)/.test(tab.url) || 93 | !/^(about|resource|chrome|file|view-source):/.test(tab.url); 94 | } 95 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import _import from "eslint-plugin-import"; 3 | 4 | export default [{ 5 | ignores: ["eslint.config.mjs", "extlib/*", "submodules/*", "!**/.eslintrc.js"], 6 | }, { 7 | plugins: { 8 | import: _import, 9 | }, 10 | 11 | languageOptions: { 12 | globals: { 13 | ...globals.browser, 14 | ...globals.webextensions, 15 | }, 16 | 17 | ecmaVersion: 2022, 18 | sourceType: "module", 19 | }, 20 | 21 | settings: { 22 | "import/resolver": { 23 | "babel-module": { 24 | root: ["./"], 25 | }, 26 | }, 27 | }, 28 | 29 | rules: { 30 | indent: ["warn", 2, { 31 | SwitchCase: 1, 32 | MemberExpression: 1, 33 | 34 | CallExpression: { 35 | arguments: "first", 36 | }, 37 | 38 | VariableDeclarator: { 39 | var: 2, 40 | let: 2, 41 | const: 3, 42 | }, 43 | }], 44 | 45 | quotes: ["warn", "single", { 46 | avoidEscape: true, 47 | allowTemplateLiterals: true, 48 | }], 49 | 50 | 51 | "no-const-assign": "error", 52 | 53 | "prefer-const": ["warn", { 54 | destructuring: "any", 55 | ignoreReadBeforeAssign: false, 56 | }], 57 | 58 | "no-var": "error", 59 | 60 | "no-unused-vars": ["warn", { 61 | vars: "all", 62 | args: "after-used", 63 | argsIgnorePattern: "^_", 64 | caughtErrors: "all", 65 | caughtErrorsIgnorePattern: "^_", 66 | }], 67 | 68 | "no-use-before-define": ["error", { 69 | functions: false, 70 | classes: true, 71 | }], 72 | 73 | "no-unused-expressions": "error", 74 | "no-unused-labels": "error", 75 | 76 | "no-undef": ["error", { 77 | typeof: true, 78 | }], 79 | 80 | "import/default": "error", 81 | "import/namespace": "error", 82 | "import/no-duplicates": "error", 83 | "import/export": "error", 84 | "import/extensions": ["error", "always"], 85 | "import/first": "error", 86 | "import/named": "error", 87 | "import/no-named-as-default": "error", 88 | "import/no-named-as-default-member": "error", 89 | "import/no-cycle": ["warn", {}], 90 | "import/no-self-import": "error", 91 | 92 | "import/no-unresolved": ["error", { 93 | caseSensitive: true, 94 | }], 95 | 96 | "import/no-useless-path-segments": "error", 97 | }, 98 | }]; -------------------------------------------------------------------------------- /extlib/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piroor/save-selected-tabs-to-files/c65d2f18608c4d3262ea0765bc536f3eada9595e/extlib/.gitkeep -------------------------------------------------------------------------------- /history.en.md: -------------------------------------------------------------------------------- 1 | # History 2 | 3 | - master/HEAD 4 | - 1.2.0 (2022.9.12) 5 | * Restrict paralel downloads max 10 tabs by default. 6 | - 1.1.6 (2021.8.16) 7 | * Shorten too long file name by default. 8 | * Show message correctly about a download config of Firefox in the options page. 9 | - 1.1.5 (2021.5.5) 10 | * Show a notification about a download config of Firefox at the initial startup. 11 | * Fix wrong behaviors of "All Configs" UI: apply imported configs to options UI immediately and treat decimal values as valid for some numeric options. 12 | - 1.1.4 (2020.7.29) 13 | * Better support for the Managed Storage. 14 | * Flexible width input field for the dialog to input the folder name. 15 | - 1.1.3 (2020.4.28) 16 | * Handle dismissed semi-modal dialogs correctly. 17 | * Optimize semi-modal dialogs a little. 18 | - 1.1.2 (2020.4.25) 19 | * Improve implementation of semi-modal dialogs. Now it is more stable, more similar to native dialogs, more friendly for dark color scheme, and don't appear in the "Recently Closed Windows" list. 20 | - 1.1.1 (2020.4.24) 21 | * Show popup windows correctly on Firefox ESR68. (regression on 1.1.0) 22 | - 1.1.0 (2020.4.22) 23 | * Show folder name prompt as a semi-modal popup window. 24 | - 1.0.8 (2020.3.6) 25 | * Show in-content confirmation dialog correctly on lately versions of Firefox. 26 | * Remove keyboard shorctut customization UI, because Firefox ESR68 has it. 27 | * Uninitialized options page is now invisible. 28 | - 1.0.7 (2019.5.24) 29 | * Follow to changes on Tree Style Tab 3.0.12 and Multiple Tab Handler 3.0.7. 30 | * Add ability to export and import all configurations except keyboard shortcuts. (Options => "Development" => "Debug mode" => "All Configs" => "Import/Export") 31 | - 1.0.6 (2019.1.3) 32 | * Add ability co control visibility of context menu items for each: tab context menu and page context menu. 33 | - 1.0.5 (2018.12.15) 34 | * Invoke command from Multiple Tab Handler correctly. 35 | - 1.0.4 (2018.11.3) 36 | * Improve compatibility with Multiple Tab Handler. 37 | - 1.0.3 (2018.10.31) 38 | * Improve compatibility with Tree Style Tab. 39 | * Update menu label when there is no more multiselected tab. 40 | - 1.0.2 (2018.10.30) 41 | * Don't execute command for all unselected tabs. 42 | - 1.0.1 (2018.10.30) 43 | * Make `` permission optional. 44 | * Run command from keyboard shortcut correctly. 45 | - 1.0 (2018.10.30) 46 | * Separated from [Multiple Tab Handler](https://addons.mozilla.org/firefox/addon/multiple-tab-handler/). 47 | * The "zh-CN" locale is added by yfdyh000. Thanks! 48 | -------------------------------------------------------------------------------- /history.ja.md: -------------------------------------------------------------------------------- 1 | # 更新履歴 2 | 3 | - master/HEAD 4 | - 1.2.0 (2022.9.12) 5 | * 並列ダウンロード数を最大で10タブ分までに制限するようにした 6 | - 1.1.6 (2021.8.16) 7 | * 長すぎるファイル名を初期状態で短縮するように↓ 8 | * 設定ページでFirefoxのダウンロード設定に関するメッセージを正しく表示するように修正 9 | - 1.1.5 (2021.5.5) 10 | * 初回起動時にFirefoxのダウンロード設定に関する注意を表示するようにした 11 | * 「すべての設定」のUIの不備を改善:インポートした設定をUIに即座に反映し、また、数値型の一部の設定項目で小数が不正な値として警告されてしまわないようにした 12 | - 1.1.4 (2020.7.29) 13 | * Managed Storageへの対応を改善 14 | * フォルダ名入力用ダイアログの入力欄をウィンドウに合わせて拡大するようにした 15 | - 1.1.3 (2020.4.28) 16 | * モーダル風ダイアログがクローズボックスで閉じられた時の挙動を改善 17 | * モーダル風ダイアログを開くのに要する時間を若干短縮した 18 | - 1.1.2 (2020.4.25) 19 | * モーダル風ダイアログの実装を改善した(安定性の向上、ネイティブのダイアログを模したボタンの配置、Darkモードへの対応、閉じられたダイアログが「最近閉じたウィンドウ」の一覧になるべく現れないようにした) 20 | - 1.1.1 (2020.4.24) 21 | * Firefox ESR68でポップアップウィンドウが表示されない問題を修正(1.1.0での後退バグ) 22 | - 1.1.0 (2020.4.22) 23 | * 保存先フォルダ名の入力をモーダル風ポップアップウィンドウで表示するようにした 24 | - 1.0.8 (2020.3.6) 25 | * 最近のバージョンのFirefoxにおいて、コンテンツ領域内に確認ダイアログが表示されなくなっていたのを修正 26 | * Firefox ESR68時点ですでに本体にUIがあるため、キーボードショートカット設定用のUIを削除した 27 | * 初期化前の設定画面を隠すようにした 28 | - 1.0.7 (2019.5.24) 29 | * ツリー型タブ 3.0.12 および マルチプルタブハンドラ 3.0.7 の仕様変更に追従 30 | * キーボードショートカットを除く全設定のインポートとエクスポートに対応(設定→開発用→デバッグモード→すべての設定→Import/Export) 31 | - 1.0.6 (2019.1.3) 32 | * タブのコンテキストメニューとWebページ上のコンテキストメニューのそれぞれについてメニュー項目の表示・非表示を制御できるようにした 33 | - 1.0.5 (2018.12.15) 34 | * マルチプルタブハンドラのAPI経由でのコマンド実行に失敗していたのを修正 35 | - 1.0.4 (2018.11.3) 36 | * マルチプルタブハンドラと併用時の互換性を向上 37 | - 1.0.3 (2018.10.31) 38 | * ツリー型タブと併用時の互換性を向上 39 | - 1.0.2 (2018.10.30) 40 | * 選択されていないすべてに対してコマンドを実行してしまう不具合を修正 41 | - 1.0.1 (2018.10.30) 42 | * ``の権限を必須でなくした 43 | * キーボードショートカットからコマンドを実行できない問題を修正 44 | - 1.0 (2018.10.30) 45 | * [マルチプルタブハンドラ](https://addons.mozilla.org/firefox/addon/multiple-tab-handler/)から機能を分割した 46 | * zh-CNロケール追加(by yfdyh000, thanks!) 47 | -------------------------------------------------------------------------------- /licenses/MPL2.0.txt: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "__MSG_extensionName__", 4 | "version": "1.2.0", 5 | "author": "YUKI \"Piro\" Hiroshi", 6 | "description": "__MSG_extensionDescription__", 7 | "permissions": [ 8 | "activeTab", 9 | "downloads", 10 | "menus", 11 | "notifications", 12 | "storage", 13 | "tabs" 14 | ], 15 | "optional_permissions": [ 16 | "" 17 | ], 18 | "icons": { 19 | "16": "resources/Save.svg#default", 20 | "20": "resources/Save.svg#default", 21 | "24": "resources/Save.svg#default", 22 | "32": "resources/Save.svg#default" 23 | }, 24 | "background": { 25 | "page": "background/background.html" 26 | }, 27 | "commands": { 28 | "saveSelectedTabs": { 29 | "description": "__MSG_command_saveSelectedTabs__" 30 | } 31 | }, 32 | "options_ui": { 33 | "page": "options/options.html", 34 | "browser_style": true 35 | }, 36 | "default_locale": "en", 37 | "applications": { 38 | "gecko": { 39 | "id": "save-selected-tabs-to-files@piro.sakura.ne.jp", 40 | "strict_min_version": "67.0" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /options/init.js: -------------------------------------------------------------------------------- 1 | /* 2 | # This Source Code Form is subject to the terms of the Mozilla Public 3 | # License, v. 2.0. If a copy of the MPL was not distributed with this 4 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 'use strict'; 7 | 8 | import { 9 | log, 10 | configs, 11 | isRTL, 12 | } from '/common/common.js'; 13 | import * as Permissions from '/common/permissions.js'; 14 | import Options from '/extlib/Options.js'; 15 | import '../extlib/l10n.js'; 16 | 17 | log.context = 'Options'; 18 | const options = new Options(configs); 19 | 20 | function onConfigChanged(key) { 21 | switch (key) { 22 | case 'debug': 23 | if (configs.debug) 24 | document.documentElement.classList.add('debugging'); 25 | else 26 | document.documentElement.classList.remove('debugging'); 27 | break; 28 | } 29 | } 30 | 31 | configs.$addObserver(onConfigChanged); 32 | window.addEventListener('DOMContentLoaded', async () => { 33 | document.documentElement.classList.toggle('rtl', isRTL()); 34 | await configs.$loaded; 35 | 36 | const focusedItem = document.querySelector(':target'); 37 | for (const fieldset of document.querySelectorAll('fieldset.collapsible')) { 38 | if (configs.optionsExpandedGroups.includes(fieldset.id) || 39 | (focusedItem && fieldset.contains(focusedItem))) 40 | fieldset.classList.remove('collapsed'); 41 | else 42 | fieldset.classList.add('collapsed'); 43 | 44 | const onChangeCollapsed = () => { 45 | if (!fieldset.id) 46 | return; 47 | const otherExpandedSections = configs.optionsExpandedGroups.filter(id => id != fieldset.id); 48 | if (fieldset.classList.contains('collapsed')) 49 | configs.optionsExpandedGroups = otherExpandedSections; 50 | else 51 | configs.optionsExpandedGroups = otherExpandedSections.concat([fieldset.id]); 52 | }; 53 | 54 | const legend = fieldset.querySelector(':scope > legend'); 55 | legend.addEventListener('click', () => { 56 | fieldset.classList.toggle('collapsed'); 57 | onChangeCollapsed(); 58 | }); 59 | legend.addEventListener('keydown', event => { 60 | if (event.key != 'Enter') 61 | return; 62 | fieldset.classList.toggle('collapsed'); 63 | onChangeCollapsed(); 64 | }); 65 | } 66 | 67 | Permissions.bindToCheckbox( 68 | Permissions.ALL_URLS, 69 | document.querySelector('#allUrlsPermissionGranted') 70 | ); 71 | 72 | options.buildUIForAllConfigs(document.querySelector('#debug-configs')); 73 | onConfigChanged('debug'); 74 | 75 | document.documentElement.classList.add('initialized'); 76 | }, { once: true }); 77 | 78 | -------------------------------------------------------------------------------- /options/options.css: -------------------------------------------------------------------------------- 1 | /* 2 | # This Source Code Form is subject to the terms of the Mozilla Public 3 | # License, v. 2.0. If a copy of the MPL was not distributed with this 4 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 5 | */ 6 | 7 | :root > * { 8 | transition: opacity 0.25s ease-out; 9 | } 10 | :root:not(.initialized) > * { 11 | opacity: 0; 12 | } 13 | 14 | :root.rtl { 15 | direction: rtl; 16 | } 17 | 18 | hr { 19 | border: 0 none; 20 | border-top: 1px solid; 21 | margin: 2em 0; 22 | width: 100%; 23 | } 24 | 25 | p, ul { 26 | margin: 0 0 0.5em 0; 27 | padding: 0; 28 | } 29 | 30 | ul, 31 | ul li { 32 | list-style: none; 33 | } 34 | 35 | p.sub { 36 | margin-left: 2em; 37 | } 38 | 39 | ul p.sub { 40 | margin-top: 0; 41 | margin-bottom: 0; 42 | } 43 | 44 | :root:not(.debugging) #debug-configs { 45 | max-height: 0; 46 | overflow: hidden; 47 | } 48 | 49 | :root:not(.debugging) #debug-configs * { 50 | -moz-user-focus: ignore; 51 | -moz-user-input: disabled; 52 | } 53 | 54 | 55 | 56 | fieldset.collapsible.collapsed > *:not(legend):not(div) /* "div" is for the container of "import" and "export" buttons */ { 57 | display: none; 58 | } 59 | 60 | fieldset.collapsible > legend::before { 61 | content: "▼"; 62 | display: inline-block; 63 | font-size: 65%; 64 | margin-right: 0.5em; 65 | position: relative; 66 | transition: transform 0.2s ease; 67 | } 68 | 69 | fieldset.collapsible.collapsed > legend::before { 70 | transform: rotate(-90deg); 71 | } 72 | 73 | 74 | 75 | #useDownloadDirOptionNote { 76 | border: 0 none; 77 | margin: 0 0 2em; 78 | padding: 0; 79 | } 80 | 81 | #useDownloadDirOptionNote legend { 82 | font-size: large; 83 | font-weight: bold; 84 | } 85 | 86 | #useDownloadDirOptionNote img { 87 | display: block; 88 | margin: 1em auto 0; 89 | /* 90 | 664px is the value of `--section-width` defined in Firefox itself. 91 | https://searchfox.org/mozilla-central/rev/185ab5e4f4e01341e009cd4633d1275ffe4d4c8b/toolkit/mozapps/extensions/content/aboutaddons.css#8 92 | */ 93 | max-width: calc(664px * 0.9 - 2em); 94 | box-shadow: 0.2em 0.2em 1em rgba(0, 0, 0, 0.45); 95 | } 96 | -------------------------------------------------------------------------------- /options/options.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
15 | __MSG_useDownloadDirOption_caption__ 16 |

__MSG_useDownloadDirOption_note__ 17 |

18 |
19 | 20 |

25 |

__MSG_config_saveTabsPrefix_description__

26 |

31 |

36 | 37 |

40 | 41 |

44 |

47 |

50 |

53 | 54 |
55 | 56 |
57 |

__MSG_config_debug_caption__

58 |

61 |
62 |

__MSG_config_all_caption__

63 |
64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "save-selected-tabs-to-files", 3 | "version": "0.0.0", 4 | "engines": { 5 | "node": ">=8.6.0" 6 | }, 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "dependencies": { 11 | "babel-core": "^6.0.0", 12 | "babel-plugin-module-resolver": "^3.0.0", 13 | "eslint": "^9.15.0", 14 | "eslint-import-resolver-babel-module": "^4.0.0", 15 | "eslint-plugin-import": "^2.13.0", 16 | "jsonlint-cli": "*", 17 | "tunnel-agent": ">=0.6.0" 18 | }, 19 | "devDependencies": { 20 | "globals": "^15.12.0" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /resources/Save.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /resources/blank.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /resources/downloads-option.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piroor/save-selected-tabs-to-files/c65d2f18608c4d3262ea0765bc536f3eada9595e/resources/downloads-option.png -------------------------------------------------------------------------------- /resources/notify-features.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 34 | 35 |

36 |
37 |
38 | -------------------------------------------------------------------------------- /screenshots/menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piroor/save-selected-tabs-to-files/c65d2f18608c4d3262ea0765bc536f3eada9595e/screenshots/menu.png --------------------------------------------------------------------------------