├── start.bat ├── mht-icon.png ├── privacy-policy.md ├── zip.ps1 ├── zip.bat ├── .gitignore ├── .vscode └── settings.json ├── mht-info.html ├── tsconfig.json ├── README.md ├── package.json ├── file-permission.html ├── manifest.json └── src ├── mht-info.ts └── worker.ts /start.bat: -------------------------------------------------------------------------------- 1 | start chrome --load-extension="%~dp0." 2 | -------------------------------------------------------------------------------- /mht-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vsDizzy/SaveAsMHT/HEAD/mht-icon.png -------------------------------------------------------------------------------- /privacy-policy.md: -------------------------------------------------------------------------------- 1 | This extension is not handling sensitive or personal data. 2 | -------------------------------------------------------------------------------- /zip.ps1: -------------------------------------------------------------------------------- 1 | Compress-Archive dist,src,manifest.json,*.html,*.png SaveAsMHT.zip -Force 2 | -------------------------------------------------------------------------------- /zip.bat: -------------------------------------------------------------------------------- 1 | call npm run build 2 | start /wait powershell -ExecutionPolicy RemoteSigned -File zip.ps1 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /package-lock.json 3 | /tsconfig.tsbuildinfo 4 | /dist/ 5 | /*.zip 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "json.schemas": [ 3 | { 4 | "fileMatch": [ 5 | "/manifest.json" 6 | ], 7 | "url": "https://json.schemastore.org/chrome-manifest" 8 | } 9 | ] 10 | } -------------------------------------------------------------------------------- /mht-info.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 |
🔗 open
10 |
📅
11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "outDir": "dist", 5 | "incremental": true, 6 | "sourceMap": true, 7 | "target": "ESNext", 8 | "moduleResolution": "NodeNext", 9 | "module": "NodeNext" 10 | }, 11 | "include": [ 12 | "src" 13 | ] 14 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SaveAsMHT 2 | 3 | Chrome extension saving web page as single `.mht` file. 4 | 5 | Available to install at [Chrome Web Store](https://chrome.google.com/webstore/detail/save-as-mht/hfmodljjaibbdndlikgagimhhodmobkc) 6 | 7 | License: MIT 8 | 9 | NPM scripts: 10 | 11 | - `dev` - compile Typescript sources in watch mode 12 | - `start` - launch Chrome with unpacked extension 13 | - `zip` - build sources and zip extension package 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "save-as-mht", 3 | "version": "0.2.1", 4 | "license": "MIT", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "tsc --build --watch", 8 | "start": "cmd /c start", 9 | "build": "tsc --build", 10 | "clean": "tsc --build --clean", 11 | "zip": "cmd /c zip" 12 | }, 13 | "devDependencies": { 14 | "@types/chrome": "^0.0.239", 15 | "@types/node": "^20.4.0", 16 | "typescript": "^5.1.6" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /file-permission.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | ⚠ An additional 'Allow access to file URLs' permission is required to view saved 13 | .mht file info. 14 |
15 |
16 | Here are the steps to grant this permission: 17 |
    18 |
  1. 19 | Right click on the extension 20 | icon above and select 'Manage extension' 21 |
  2. 22 |
  3. 23 | Scroll down the list of options and check the 'Allow access to file URLs' 24 | permission. 25 |
  4. 26 |
27 | Reload saved .mht file. The ⚠ sign should be changed to 🛈. 28 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Save As MHT", 4 | "version": "0.2.1", 5 | "short_name": "SaveAsMHT", 6 | "description": "Save page as `.mht` files.", 7 | "homepage_url": "https://github.com/vsDizzy/SaveAsMHT", 8 | "icons": { 9 | "128": "mht-icon.png" 10 | }, 11 | "action": { 12 | "default_icon": { 13 | "128": "mht-icon.png" 14 | } 15 | }, 16 | "background": { 17 | "service_worker": "dist/worker.js", 18 | "type": "module" 19 | }, 20 | "permissions": ["tabs", "pageCapture", "downloads", "contextMenus"], 21 | "host_permissions": ["file:///*"], 22 | "commands": { 23 | "_execute_action": { 24 | "suggested_key": { 25 | "default": "Alt+Shift+S" 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/mht-info.ts: -------------------------------------------------------------------------------- 1 | const tabs = await chrome.tabs.query({ currentWindow: true, active: true }) 2 | const res = await fetch(tabs[0].url, { headers: { Range: 'bytes=0-16384' } }) 3 | const mhtText = await res.text() 4 | 5 | function getMimeHeader(headerName: string) { 6 | return mhtText.match(new RegExp(`^${headerName}: (.*?)\r?\n[^ ]`, 'ms'))?.[1] 7 | } 8 | 9 | const originalLink = document.getElementById( 10 | 'original-link' 11 | ) as HTMLAnchorElement 12 | originalLink.title = originalLink.href = getMimeHeader( 13 | 'Snapshot-Content-Location' 14 | ) 15 | originalLink.textContent = mimeDecode(getMimeHeader('Subject')) 16 | 17 | function mimeDecode(mimeText: string) { 18 | return decodeURIComponent( 19 | mimeText 20 | .replace(/\r?\n /g, '') 21 | .replace(/=\?utf-8\?Q\?(.*?)\?=/g, function (_, mimeWord) { 22 | return mimeWord.replace(/=/g, '%') 23 | }) 24 | ) 25 | } 26 | 27 | const originalDate = document.getElementById('original-date') as HTMLSpanElement 28 | originalDate.textContent = new Date(getMimeHeader('Date')).toLocaleString() 29 | -------------------------------------------------------------------------------- /src/worker.ts: -------------------------------------------------------------------------------- 1 | chrome.action.onClicked.addListener(function (tab) { 2 | saveTab(tab) 3 | }) 4 | 5 | chrome.runtime.onInstalled.addListener(function () { 6 | chrome.contextMenus.create({ 7 | id: 'save-mht', 8 | title: 'Save as .mht...', 9 | contexts: ['all'], 10 | }) 11 | }) 12 | 13 | chrome.contextMenus.onClicked.addListener(function (info, tab) { 14 | if (info.menuItemId == 'save-mht') { 15 | saveTab(tab) 16 | } 17 | }) 18 | 19 | async function saveTab(tab: chrome.tabs.Tab) { 20 | chrome.downloads.download({ 21 | conflictAction: 'prompt', 22 | filename: `${sanitizeFilename(tab.title)}.mht`, 23 | saveAs: true, 24 | url: await captureTabToDataUrl(tab.id), 25 | }) 26 | } 27 | 28 | function sanitizeFilename(filename: string) { 29 | return filename.replace(/[<>:"/\\|?*\x00-\x1F~]/g, '_') 30 | } 31 | 32 | async function captureTabToDataUrl(tabId: number) { 33 | const tabText = await new Promise(function (resolve, reject) { 34 | chrome.pageCapture.saveAsMHTML({ tabId }, function (mhtmlData) { 35 | return mhtmlData 36 | ? resolve(mhtmlData.text()) 37 | : reject(new Error(chrome.runtime.lastError.message)) 38 | }) 39 | }) 40 | 41 | return `data:multipart/related;base64,${btoa(tabText)}` 42 | } 43 | 44 | chrome.tabs.onUpdated.addListener(function (tabId, changeInfo, tab) { 45 | if (changeInfo.status != 'loading') { 46 | return 47 | } 48 | 49 | setPopup(tabId, tab.url) 50 | }) 51 | 52 | chrome.tabs.query({}).then(function (tabs) { 53 | tabs.forEach(function (tab) { 54 | setPopup(tab.id, tab.url) 55 | }) 56 | }) 57 | 58 | async function setPopup(tabId: number, url: string) { 59 | if (!/^file:\/\/\/.*\.mht(ml)?$/i.test(url)) { 60 | return 61 | } 62 | 63 | const { text, popup }: { text: string; popup: string } = 64 | (await chrome.permissions.contains({ origins: ['file:///*'] })) 65 | ? { text: '🛈', popup: 'mht-info.html' } 66 | : { text: '⚠', popup: 'file-permission.html' } 67 | chrome.action.setBadgeText({ tabId, text }) 68 | chrome.action.setPopup({ tabId, popup }) 69 | } 70 | --------------------------------------------------------------------------------