├── 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 |
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 | -
19 | Right click on the
extension
20 | icon above and select 'Manage extension'
21 |
22 | -
23 | Scroll down the list of options and check the 'Allow access to file URLs'
24 | permission.
25 |
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 |
--------------------------------------------------------------------------------