Explorar o código

merge SingleFileZ code

Gildas %!s(int64=2) %!d(string=hai) anos
pai
achega
6a927a3716

+ 1 - 1
.eslintrc.js

@@ -11,7 +11,7 @@ module.exports = {
 	},
 	"extends": "eslint:recommended",
 	"parserOptions": {
-		"ecmaVersion": 2017,
+		"ecmaVersion": 2018,
 		"sourceType": "module"
 	},
 	"ignorePatterns": [

+ 37 - 1
_locales/de/messages.json

@@ -255,6 +255,42 @@
 		"message": "Verborgene Elemente entfernen",
 		"description": "Options page label: 'remove hidden elements'"
 	},
+	"optionsFileFormatSubTitle": {
+		"message": "Dateiformat",
+		"description": "Options page label: 'File format'"
+	},
+	"optionFileFormat": {
+		"message": "format",
+		"description": "Options page label: 'format'"
+	},
+	"optionFileFormatSelectHTML": {
+		"message": "HTML",
+		"description": "Options page label: 'HTML'"
+	},
+	"optionFileFormatSelectSelfExtractingUniversal": {
+		"message": "selbstextrahierendes ZIP (universal)",
+		"description": "Options page label: 'self-extracting ZIP (universal)'"
+	},
+	"optionFileFormatSelectSelfExtracting": {
+		"message": "selbstextrahierendes ZIP",
+		"description": "Options page label: 'self-extracting ZIP'"
+	},
+	"optionFileFormatSelectZIP": {
+		"message": "ZIP",
+		"description": "Options page label: 'ZIP'"
+	},
+	"optionPassword": {
+		"message": "Passwort",
+		"description": "Options page label: 'password'"
+	},
+	"optionCreateRootDirectory": {
+		"message": "Stammverzeichnis erstellen",
+		"description": "Options page label: 'create a root directory'"
+	},
+	"optionInsertTextBody": {
+		"message": "Text durchsuchbar machen",
+		"description": "Options page label: 'make text searchable'"
+	},
 	"optionsInfobarSubTitle": {
 		"message": "Infoknopf",
 		"description": "Options sub-title: 'Infobar'"
@@ -891,4 +927,4 @@
 		"message": "URL",
 		"description": "Title of the column in the table of the URLs"
 	}
-}
+}

+ 38 - 2
_locales/en/messages.json

@@ -211,7 +211,7 @@
 		"message": "replacement character",
 		"description": "Options page label: 'replacement character'"
 	},
-	"optionReplaceEmojisInFilename" : {
+	"optionReplaceEmojisInFilename": {
 		"message": "replace emojis with text",
 		"description": "Options page label: 'replace emojis with text'"
 	},
@@ -255,6 +255,42 @@
 		"message": "remove hidden elements",
 		"description": "Options page label: 'remove hidden elements'"
 	},
+	"optionsFileFormatSubTitle": {
+		"message": "File format",
+		"description": "Options page label: 'File format'"
+	},
+	"optionFileFormat": {
+		"message": "format",
+		"description": "Options page label: 'format'"
+	},
+	"optionFileFormatSelectHTML": {
+		"message": "HTML",
+		"description": "Options page label: 'HTML'"
+	},
+	"optionFileFormatSelectSelfExtractingUniversal": {
+		"message": "self-extracting ZIP (universal)",
+		"description": "Options page label: 'self-extracting ZIP (universal)'"
+	},
+	"optionFileFormatSelectSelfExtracting": {
+		"message": "self-extracting ZIP",
+		"description": "Options page label: 'self-extracting ZIP'"
+	},
+	"optionFileFormatSelectZIP": {
+		"message": "ZIP",
+		"description": "Options page label: 'ZIP'"
+	},
+	"optionPassword": {
+		"message": "password",
+		"description": "Options page label: 'password'"
+	},
+	"optionCreateRootDirectory": {
+		"message": "create a root directory",
+		"description": "Options page label: 'create a root directory'"
+	},
+	"optionInsertTextBody": {
+		"message": "make text searchable",
+		"description": "Options page label: 'make text searchable'"
+	},
 	"optionsInfobarSubTitle": {
 		"message": "Infobar",
 		"description": "Options sub-title: 'Infobar'"
@@ -891,4 +927,4 @@
 		"message": "URL",
 		"description": "Title of the column in the table of the URLs"
 	}
-}
+}

+ 38 - 2
_locales/es/messages.json

@@ -211,7 +211,7 @@
 		"message": "carácter de reemplazo",
 		"description": "Options page label: 'replacement character'"
 	},
-	"optionReplaceEmojisInFilename" : {
+	"optionReplaceEmojisInFilename": {
 		"message": "reemplazar emojis con texto",
 		"description": "Options page label: 'replace emojis with text'"
 	},
@@ -255,6 +255,42 @@
 		"message": "eliminar elementos ocultos (hidden)",
 		"description": "Options page label: 'remove hidden elements'"
 	},
+	"optionsFileFormatSubTitle": {
+		"message": "Formato de archivo",
+		"description": "Options page label: 'File format'"
+	},
+	"optionFileFormat": {
+		"message": "formato",
+		"description": "Options page label: 'format'"
+	},
+	"optionFileFormatSelectHTML": {
+		"message": "HTML",
+		"description": "Options page label: 'HTML'"
+	},
+	"optionFileFormatSelectSelfExtractingUniversal": {
+		"message": "ZIP autoextraíble (universal)",
+		"description": "Options page label: 'self-extracting ZIP (universal)'"
+	},
+	"optionFileFormatSelectSelfExtracting": {
+		"message": "ZIP autoextraíble",
+		"description": "Options page label: 'self-extracting ZIP'"
+	},
+	"optionFileFormatSelectZIP": {
+		"message": "ZIP",
+		"description": "Options page label: 'ZIP'"
+	},
+	"optionPassword": {
+		"message": "contraseña",
+		"description": "Options page label: 'password'"
+	},
+	"optionCreateRootDirectory": {
+		"message": "crear un directorio raíz",
+		"description": "Options page label: 'create a root directory'"
+	},
+	"optionInsertTextBody": {
+		"message": "hacer buscable el texto",
+		"description": "Options page label: 'make text searchable'"
+	},
 	"optionsInfobarSubTitle": {
 		"message": "Barra informativa",
 		"description": "Options sub-title: 'Infobar'"
@@ -891,4 +927,4 @@
 		"message": "URL",
 		"description": "Title of the column in the table of the URLs"
 	}
-}
+}

+ 38 - 2
_locales/fr/messages.json

@@ -211,7 +211,7 @@
 		"message": "caractère de remplacement",
 		"description": "Options page label: 'replacement character'"
 	},
-	"optionReplaceEmojisInFilename" : {
+	"optionReplaceEmojisInFilename": {
 		"message": "remplacer les emojis par du texte",
 		"description": "Options page label: 'replace emojis with text'"
 	},
@@ -255,6 +255,42 @@
 		"message": "supprimer les élements cachés",
 		"description": "Options page label: 'remove hidden elements'"
 	},
+	"optionsFileFormatSubTitle": {
+		"message": "Format de fichier",
+		"description": "Options page label: 'File format'"
+	},
+	"optionFileFormat": {
+		"message": "format",
+		"description": "Options page label: 'format'"
+	},
+	"optionFileFormatSelectHTML": {
+		"message": "HTML",
+		"description": "Options page label: 'HTML'"
+	},
+	"optionFileFormatSelectSelfExtractingUniversal": {
+		"message": "auto-extractibles ZIP (universel)",
+		"description": "Options page label: 'self-extracting ZIP (universal)'"
+	},
+	"optionFileFormatSelectSelfExtracting": {
+		"message": "auto-extractibles ZIP",
+		"description": "Options page label: 'self-extracting ZIP'"
+	},
+	"optionFileFormatSelectZIP": {
+		"message": "ZIP",
+		"description": "Options page label: 'ZIP'"
+	},
+	"optionPassword": {
+		"message": "mot de passe",
+		"description": "Options page label: 'password'"
+	},
+	"optionCreateRootDirectory": {
+		"message": "créer un répertoire racine",
+		"description": "Options page label: 'create a root directory'"
+	},
+	"optionInsertTextBody": {
+		"message": "rendre le texte indexable",
+		"description": "Options page label: 'make text searchable'"
+	},
 	"optionsInfobarSubTitle": {
 		"message": "Barre d'information",
 		"description": "Options sub-title: 'Infobar'"
@@ -891,4 +927,4 @@
 		"message": "URL",
 		"description": "Title of the column in the table of the URLs"
 	}
-}
+}

+ 38 - 2
_locales/it/messages.json

@@ -211,7 +211,7 @@
 		"message": "carattere di sostituzione",
 		"description": "Options page label: 'replacement character'"
 	},
-	"optionReplaceEmojisInFilename" : {
+	"optionReplaceEmojisInFilename": {
 		"message": "sostituisci le emoji con il testo",
 		"description": "Options page label: 'replace emojis with text'"
 	},
@@ -255,6 +255,42 @@
 		"message": "rimuovi elementi nascosti",
 		"description": "Options page label: 'remove hidden elements'"
 	},
+	"optionsFileFormatSubTitle": {
+		"message": "Formato del file",
+		"description": "Options page label: 'File format'"
+	},
+	"optionFileFormat": {
+		"message": "formato",
+		"description": "Options page label: 'format'"
+	},
+	"optionFileFormatSelectHTML": {
+		"message": "HTML",
+		"description": "Options page label: 'HTML'"
+	},
+	"optionFileFormatSelectSelfExtractingUniversal": {
+		"message": "ZIP autoestraente (universale)",
+		"description": "Options page label: 'self-extracting ZIP (universal)'"
+	},
+	"optionFileFormatSelectSelfExtracting": {
+		"message": "ZIP autoestraente",
+		"description": "Options page label: 'self-extracting ZIP'"
+	},
+	"optionFileFormatSelectZIP": {
+		"message": "ZIP",
+		"description": "Options page label: 'ZIP'"
+	},
+	"optionPassword": {
+		"message": "password",
+		"description": "Options page label: 'password'"
+	},
+	"optionCreateRootDirectory": {
+		"message": "crea una cartella principale",
+		"description": "Options page label: 'create a root directory'"
+	},
+	"optionInsertTextBody": {
+		"message": "rendi il testo ricercabile",
+		"description": "Options page label: 'make text searchable'"
+	},
 	"optionsInfobarSubTitle": {
 		"message": "Barra informativa",
 		"description": "Options sub-title: 'Infobar'"
@@ -891,4 +927,4 @@
 		"message": "URL",
 		"description": "Title of the column in the table of the URLs"
 	}
-}
+}

+ 38 - 2
_locales/ja/messages.json

@@ -211,7 +211,7 @@
 		"message": "置換文字",
 		"description": "Options page label: 'replacement character'"
 	},
-	"optionReplaceEmojisInFilename" : {
+	"optionReplaceEmojisInFilename": {
 		"message": "絵文字をテキストに置き換える",
 		"description": "Options page label: 'replace emojis with text'"
 	},
@@ -255,6 +255,42 @@
 		"message": "隠された要素を削除する",
 		"description": "Options page label: 'remove hidden elements'"
 	},
+	"optionsFileFormatSubTitle": {
+		"message": "ファイル形式",
+		"description": "Options page label: 'File format'"
+	},
+	"optionFileFormat": {
+		"message": "形式",
+		"description": "Options page label: 'format'"
+	},
+	"optionFileFormatSelectHTML": {
+		"message": "HTML",
+		"description": "Options page label: 'HTML'"
+	},
+	"optionFileFormatSelectSelfExtractingUniversal": {
+		"message": "自己解凍型ZIP(ユニバーサル)",
+		"description": "Options page label: 'self-extracting ZIP (universal)'"
+	},
+	"optionFileFormatSelectSelfExtracting": {
+		"message": "自己解凍型ZIP",
+		"description": "Options page label: 'self-extracting ZIP'"
+	},
+	"optionFileFormatSelectZIP": {
+		"message": "ZIP",
+		"description": "Options page label: 'ZIP'"
+	},
+	"optionPassword": {
+		"message": "パスワード",
+		"description": "Options page label: 'password'"
+	},
+	"optionCreateRootDirectory": {
+		"message": "ルートディレクトリを作成する",
+		"description": "Options page label: 'create a root directory'"
+	},
+	"optionInsertTextBody": {
+		"message": "テキストを検索可能にする",
+		"description": "Options page label: 'make text searchable'"
+	},
 	"optionsInfobarSubTitle": {
 		"message": "インフォバー",
 		"description": "Options sub-title: 'Infobar'"
@@ -891,4 +927,4 @@
 		"message": "URL",
 		"description": "Title of the column in the table of the URLs"
 	}
-}
+}

+ 37 - 1
_locales/pl/messages.json

@@ -211,7 +211,7 @@
 		"message": "znak zastępczy",
 		"description": "Options page label: 'replacement character'"
 	},
-	"optionReplaceEmojisInFilename" : {
+	"optionReplaceEmojisInFilename": {
 		"message": "zamień emotikony na tekst",
 		"description": "Options page label: 'replace emojis with text'"
 	},
@@ -255,6 +255,42 @@
 		"message": "usuwaj ukryte elementy",
 		"description": "Options page label: 'remove hidden elements'"
 	},
+	"optionsFileFormatSubTitle": {
+		"message": "File format",
+		"description": "Options page label: 'File format'"
+	},
+	"optionFileFormat": {
+		"message": "format",
+		"description": "Options page label: 'format'"
+	},
+	"optionFileFormatSelectHTML": {
+		"message": "HTML",
+		"description": "Options page label: 'HTML'"
+	},
+	"optionFileFormatSelectSelfExtractingUniversal": {
+		"message": "self-extracting ZIP (universal)",
+		"description": "Options page label: 'self-extracting ZIP (universal)'"
+	},
+	"optionFileFormatSelectSelfExtracting": {
+		"message": "self-extracting ZIP",
+		"description": "Options page label: 'self-extracting ZIP'"
+	},
+	"optionFileFormatSelectZIP": {
+		"message": "ZIP",
+		"description": "Options page label: 'ZIP'"
+	},
+	"optionPassword": {
+		"message": "password",
+		"description": "Options page label: 'password'"
+	},
+	"optionCreateRootDirectory": {
+		"message": "create a root directory",
+		"description": "Options page label: 'create a root directory'"
+	},
+	"optionInsertTextBody": {
+		"message": "make text searchable",
+		"description": "Options page label: 'make text searchable'"
+	},
 	"optionsInfobarSubTitle": {
 		"message": "Pasek informacyjny",
 		"description": "Options sub-title: 'Infobar'"

+ 38 - 2
_locales/pt_br/messages.json

@@ -211,7 +211,7 @@
 		"message": "caractere de substituição",
 		"description": "Options page label: 'replacement character'"
 	},
-	"optionReplaceEmojisInFilename" : {
+	"optionReplaceEmojisInFilename": {
 		"message": "substituir emojis por texto",
 		"description": "Options page label: 'replace emojis with text'"
 	},
@@ -255,6 +255,42 @@
 		"message": "remover elementos escondidos",
 		"description": "Options page label: 'remove hidden elements'"
 	},
+	"optionsFileFormatSubTitle": {
+		"message": "Formato do arquivo",
+		"description": "Options page label: 'File format'"
+	},
+	"optionFileFormat": {
+		"message": "formato",
+		"description": "Options page label: 'format'"
+	},
+	"optionFileFormatSelectHTML": {
+		"message": "HTML",
+		"description": "Options page label: 'HTML'"
+	},
+	"optionFileFormatSelectSelfExtractingUniversal": {
+		"message": "ZIP autoextraível (universal)",
+		"description": "Options page label: 'self-extracting ZIP (universal)'"
+	},
+	"optionFileFormatSelectSelfExtracting": {
+		"message": "ZIP autoextraível",
+		"description": "Options page label: 'self-extracting ZIP'"
+	},
+	"optionFileFormatSelectZIP": {
+		"message": "ZIP",
+		"description": "Options page label: 'ZIP'"
+	},
+	"optionPassword": {
+		"message": "senha",
+		"description": "Options page label: 'password'"
+	},
+	"optionCreateRootDirectory": {
+		"message": "criar um diretório raiz",
+		"description": "Options page label: 'create a root directory'"
+	},
+	"optionInsertTextBody": {
+		"message": "tornar texto pesquisável",
+		"description": "Options page label: 'make text searchable'"
+	},
 	"optionsInfobarSubTitle": {
 		"message": "Infobar",
 		"description": "Options sub-title: 'Infobar'"
@@ -891,4 +927,4 @@
 		"message": "URL",
 		"description": "Title of the column in the table of the URLs"
 	}
-}
+}

+ 38 - 2
_locales/ru/messages.json

@@ -211,7 +211,7 @@
 		"message": "заменяющий символ",
 		"description": "Options page label: 'replacement character'"
 	},
-	"optionReplaceEmojisInFilename" : {
+	"optionReplaceEmojisInFilename": {
 		"message": "заменять эмодзи текстом",
 		"description": "Options page label: 'replace emojis with text'"
 	},
@@ -255,6 +255,42 @@
 		"message": "удалить скрытые элементы",
 		"description": "Options page label: 'remove hidden elements'"
 	},
+	"optionsFileFormatSubTitle": {
+		"message": "File format",
+		"description": "Options page label: 'File format'"
+	},
+	"optionFileFormat": {
+		"message": "format",
+		"description": "Options page label: 'format'"
+	},
+	"optionFileFormatSelectHTML": {
+		"message": "HTML",
+		"description": "Options page label: 'HTML'"
+	},
+	"optionFileFormatSelectSelfExtractingUniversal": {
+		"message": "self-extracting ZIP (universal)",
+		"description": "Options page label: 'self-extracting ZIP (universal)'"
+	},
+	"optionFileFormatSelectSelfExtracting": {
+		"message": "self-extracting ZIP",
+		"description": "Options page label: 'self-extracting ZIP'"
+	},
+	"optionFileFormatSelectZIP": {
+		"message": "ZIP",
+		"description": "Options page label: 'ZIP'"
+	},
+	"optionPassword": {
+		"message": "password",
+		"description": "Options page label: 'password'"
+	},
+	"optionCreateRootDirectory": {
+		"message": "create a root directory",
+		"description": "Options page label: 'create a root directory'"
+	},
+	"optionInsertTextBody": {
+		"message": "make text searchable",
+		"description": "Options page label: 'make text searchable'"
+	},
 	"optionsInfobarSubTitle": {
 		"message": "Информационная панель",
 		"description": "Options sub-title: 'Infobar'"
@@ -891,4 +927,4 @@
 		"message": "URL",
 		"description": "Title of the column in the table of the URLs"
 	}
-}
+}

+ 38 - 2
_locales/tr/messages.json

@@ -211,7 +211,7 @@
 		"message": "yerine geçen karakter",
 		"description": "Options page label: 'replacement character'"
 	},
-	"optionReplaceEmojisInFilename" : {
+	"optionReplaceEmojisInFilename": {
 		"message": "emoji'leri metinle değiştir",
 		"description": "Options page label: 'replace emojis with text'"
 	},
@@ -255,6 +255,42 @@
 		"message": "gizli öğeleri kaldır",
 		"description": "Options page label: 'remove hidden elements'"
 	},
+	"optionsFileFormatSubTitle": {
+		"message": "Dosya biçimi",
+		"description": "Options page label: 'File format'"
+	},
+	"optionFileFormat": {
+		"message": "biçim",
+		"description": "Options page label: 'format'"
+	},
+	"optionFileFormatSelectHTML": {
+		"message": "HTML",
+		"description": "Options page label: 'HTML'"
+	},
+	"optionFileFormatSelectSelfExtractingUniversal": {
+		"message": "kendinden çıkartılabilen ZIP (evrensel)",
+		"description": "Options page label: 'self-extracting ZIP (universal)'"
+	},
+	"optionFileFormatSelectSelfExtracting": {
+		"message": "kendinden çıkartılabilen ZIP",
+		"description": "Options page label: 'self-extracting ZIP'"
+	},
+	"optionFileFormatSelectZIP": {
+		"message": "ZIP",
+		"description": "Options page label: 'ZIP'"
+	},
+	"optionPassword": {
+		"message": "şifre",
+		"description": "Options page label: 'password'"
+	},
+	"optionCreateRootDirectory": {
+		"message": "kök dizin oluştur",
+		"description": "Options page label: 'create a root directory'"
+	},
+	"optionInsertTextBody": {
+		"message": "metni aranabilir yap",
+		"description": "Options page label: 'make text searchable'"
+	},
 	"optionsInfobarSubTitle": {
 		"message": "Bilgi çubuğu",
 		"description": "Options sub-title: 'Infobar'"
@@ -891,4 +927,4 @@
 		"message": "URL",
 		"description": "Title of the column in the table of the URLs"
 	}
-}
+}

+ 38 - 2
_locales/uk/messages.json

@@ -211,7 +211,7 @@
 		"message": "замінний символ",
 		"description": "Options page label: 'replacement character'"
 	},
-	"optionReplaceEmojisInFilename" : {
+	"optionReplaceEmojisInFilename": {
 		"message": "Замініть смайлики на текст",
 		"description": "Options page label: 'replace emojis with text'"
 	},
@@ -255,6 +255,42 @@
 		"message": "видалити приховані елементи",
 		"description": "Options page label: 'remove hidden elements'"
 	},
+	"optionsFileFormatSubTitle": {
+		"message": "Формат файлу",
+		"description": "Options page label: 'File format'"
+	},
+	"optionFileFormat": {
+		"message": "формату",
+		"description": "Options page label: 'format'"
+	},
+	"optionFileFormatSelectHTML": {
+		"message": "HTML",
+		"description": "Options page label: 'HTML'"
+	},
+	"optionFileFormatSelectSelfExtractingUniversal": {
+		"message": "саморозпаковується ZIP (універсальний)",
+		"description": "Options page label: 'self-extracting ZIP (universal)'"
+	},
+	"optionFileFormatSelectSelfExtracting": {
+		"message": "саморозпаковується ZIP",
+		"description": "Options page label: 'self-extracting ZIP'"
+	},
+	"optionFileFormatSelectZIP": {
+		"message": "ZIP",
+		"description": "Options page label: 'ZIP'"
+	},
+	"optionPassword": {
+		"message": "пароль",
+		"description": "Options page label: 'password'"
+	},
+	"optionCreateRootDirectory": {
+		"message": "створити кореневий каталог",
+		"description": "Options page label: 'create a root directory'"
+	},
+	"optionInsertTextBody": {
+		"message": "зробити текст доступним для пошуку",
+		"description": "Options page label: 'make text searchable'"
+	},
 	"optionsInfobarSubTitle": {
 		"message": "Інфобар",
 		"description": "Options sub-title: 'Infobar'"
@@ -891,4 +927,4 @@
 		"message": "URL",
 		"description": "Title of the column in the table of the URLs"
 	}
-}
+}

+ 38 - 2
_locales/zh_CN/messages.json

@@ -211,7 +211,7 @@
 		"message": "replacement character",
 		"description": "Options page label: 'replacement character'"
 	},
-	"optionReplaceEmojisInFilename" : {
+	"optionReplaceEmojisInFilename": {
 		"message": "用文字替换表情符号",
 		"description": "Options page label: 'replace emojis with text'"
 	},
@@ -255,6 +255,42 @@
 		"message": "移除隐藏元素",
 		"description": "Options page label: 'remove hidden elements'"
 	},
+	"optionsFileFormatSubTitle": {
+		"message": "文件格式",
+		"description": "Options page label: 'File format'"
+	},
+	"optionFileFormat": {
+		"message": "格式",
+		"description": "Options page label: 'format'"
+	},
+	"optionFileFormatSelectHTML": {
+		"message": "HTML",
+		"description": "Options page label: 'HTML'"
+	},
+	"optionFileFormatSelectSelfExtractingUniversal": {
+		"message": "自解压缩 ZIP(通用)",
+		"description": "Options page label: 'self-extracting ZIP (universal)'"
+	},
+	"optionFileFormatSelectSelfExtracting": {
+		"message": "自解压缩 ZIP",
+		"description": "Options page label: 'self-extracting ZIP'"
+	},
+	"optionFileFormatSelectZIP": {
+		"message": "ZIP",
+		"description": "Options page label: 'ZIP'"
+	},
+	"optionPassword": {
+		"message": "密码",
+		"description": "Options page label: 'password'"
+	},
+	"optionCreateRootDirectory": {
+		"message": "创建根目录",
+		"description": "Options page label: 'create a root directory'"
+	},
+	"optionInsertTextBody": {
+		"message": "使文本可被搜索",
+		"description": "Options page label: 'make text searchable'"
+	},
 	"optionsInfobarSubTitle": {
 		"message": "信息栏",
 		"description": "Options sub-title: 'Infobar'"
@@ -891,4 +927,4 @@
 		"message": "网址",
 		"description": "Title of the column in the table of the URLs"
 	}
-}
+}

+ 38 - 2
_locales/zh_TW/messages.json

@@ -211,7 +211,7 @@
 		"message": "replacement character",
 		"description": "Options page label: 'replacement character'"
 	},
-	"optionReplaceEmojisInFilename" : {
+	"optionReplaceEmojisInFilename": {
 		"message": "用文字替換表情符號",
 		"description": "Options page label: 'replace emojis with text'"
 	},
@@ -255,6 +255,42 @@
 		"message": "移除隱藏元素",
 		"description": "Options page label: 'remove hidden elements'"
 	},
+	"optionsFileFormatSubTitle": {
+		"message": "檔案格式",
+		"description": "Options page label: 'File format'"
+	},
+	"optionFileFormat": {
+		"message": "格式",
+		"description": "Options page label: 'format'"
+	},
+	"optionFileFormatSelectHTML": {
+		"message": "HTML",
+		"description": "Options page label: 'HTML'"
+	},
+	"optionFileFormatSelectSelfExtractingUniversal": {
+		"message": "自解壓縮 ZIP(通用)",
+		"description": "Options page label: 'self-extracting ZIP (universal)'"
+	},
+	"optionFileFormatSelectSelfExtracting": {
+		"message": "自解壓縮 ZIP",
+		"description": "Options page label: 'self-extracting ZIP'"
+	},
+	"optionFileFormatSelectZIP": {
+		"message": "ZIP",
+		"description": "Options page label: 'ZIP'"
+	},
+	"optionPassword": {
+		"message": "密碼",
+		"description": "Options page label: 'password'"
+	},
+	"optionCreateRootDirectory": {
+		"message": "創建根目錄",
+		"description": "Options page label: 'create a root directory'"
+	},
+	"optionInsertTextBody": {
+		"message": "使文本可被搜索",
+		"description": "Options page label: 'make text searchable'"
+	},
 	"optionsInfobarSubTitle": {
 		"message": "信息欄",
 		"description": "Options sub-title: 'Infobar'"
@@ -891,4 +927,4 @@
 		"message": "網址",
 		"description": "Title of the column in the table of the URLs"
 	}
-}
+}

+ 2 - 0
manifest.json

@@ -83,6 +83,8 @@
 		"lib/single-file-extension-editor-init.js",
 		"lib/single-file-extension-editor.js",
 		"lib/single-file-extension-editor-helper.js",
+		"lib/single-file-zip.min.js",
+		"lib/single-file-z-worker.js",
 		"src/lib/readability/Readability.js",
 		"src/lib/readability/Readability-readerable.js",
 		"src/ui/pages/editor-note-web.css",

+ 2 - 2
package.json

@@ -12,8 +12,8 @@
 		"single-file": "./cli/single-file"
 	},
 	"dependencies": {
-		"single-file-core": "1.1.79",
-		"single-file-cli": "1.0.69"
+		"single-file-core": "1.2.0",
+		"single-file-cli": "1.1.0"
 	},
 	"devDependencies": {
 		"@rollup/plugin-node-resolve": "15.0.1",

+ 35 - 2
rollup.config.dev.js

@@ -54,6 +54,35 @@ export default [{
 	}],
 	plugins: PLUGINS,
 	external: EXTERNAL
+}, {
+	input: ["single-file-core/vendor/zip/z-worker.js"],
+	output: [{
+		file: "lib/single-file-z-worker.js",
+		format: "es",
+		plugins: []
+	}],
+	plugins: PLUGINS,
+	external: EXTERNAL
+}, {
+	input: ["single-file-core/vendor/zip/zip.js"],
+	output: [{
+		file: "lib/single-file-zip.js",
+		format: "es",
+		plugins: []
+	}],
+	context: "this",
+	plugins: PLUGINS,
+	external: EXTERNAL
+}, {
+	input: ["single-file-core/vendor/zip/zip.min.js"],
+	output: [{
+		file: "lib/single-file-zip.min.js",
+		format: "es",
+		plugins: []
+	}],
+	context: "this",
+	plugins: PLUGINS,
+	external: EXTERNAL
 }, {
 	input: ["src/core/content/content-bootstrap.js"],
 	output: [{
@@ -89,14 +118,18 @@ export default [{
 		file: "lib/single-file-extension-editor-init.js",
 		format: "iife",
 		plugins: []
-	}]
+	}],
+	plugins: PLUGINS,
+	external: EXTERNAL
 }, {
 	input: ["src/ui/content/content-ui-editor-web.js"],
 	output: [{
 		file: "lib/single-file-extension-editor.js",
 		format: "iife",
 		plugins: []
-	}]
+	}],
+	plugins: PLUGINS,
+	external: EXTERNAL
 }, {
 	input: ["single-file-core/single-file-editor-helper.js"],
 	output: [{

+ 35 - 2
rollup.config.js

@@ -54,6 +54,35 @@ export default [{
 	}],
 	plugins: PLUGINS,
 	external: EXTERNAL
+}, {
+	input: ["single-file-core/vendor/zip/z-worker.js"],
+	output: [{
+		file: "lib/single-file-z-worker.js",
+		format: "es",
+		plugins: [terser()]
+	}],
+	plugins: PLUGINS,
+	external: EXTERNAL
+}, {
+	input: ["single-file-core/vendor/zip/zip.js"],
+	output: [{
+		file: "lib/single-file-zip.js",
+		format: "es",
+		plugins: [terser()]
+	}],
+	context: "this",
+	plugins: PLUGINS,
+	external: EXTERNAL
+}, {
+	input: ["single-file-core/vendor/zip/zip.min.js"],
+	output: [{
+		file: "lib/single-file-zip.min.js",
+		format: "es",
+		plugins: [terser()]
+	}],
+	context: "this",
+	plugins: PLUGINS,
+	external: EXTERNAL
 }, {
 	input: ["src/core/content/content-bootstrap.js"],
 	output: [{
@@ -89,14 +118,18 @@ export default [{
 		file: "lib/single-file-extension-editor-init.js",
 		format: "iife",
 		plugins: [terser()]
-	}]
+	}],
+	plugins: PLUGINS,
+	external: EXTERNAL
 }, {
 	input: ["src/ui/content/content-ui-editor-web.js"],
 	output: [{
 		file: "lib/single-file-extension-editor.js",
 		format: "iife",
 		plugins: []
-	}]
+	}],
+	plugins: PLUGINS,
+	external: EXTERNAL
 }, {
 	input: ["single-file-core/single-file-editor-helper.js"],
 	output: [{

+ 54 - 37
src/core/bg/autosave.js

@@ -160,46 +160,63 @@ async function saveContent(message, tab) {
 				if (options.passReferrerOnError) {
 					enableReferrerOnError();
 				}
+				options.tabId = tabId;
 				pageData = await getPageData(options, null, null, { fetch });
-				if (options.saveToGDrive) {
-					const blob = new Blob([pageData.content], { type: "text/html" });
-					await downloads.saveToGDrive(message.taskId, downloads.encodeSharpCharacter(pageData.filename), blob, options, {
-						forceWebAuthFlow: options.forceWebAuthFlow
-					}, {
-						filenameConflictAction: options.filenameConflictAction
-					});
-				} else if (options.saveWithWebDAV) {
-					await downloads.saveWithWebDAV(message.taskId, downloads.encodeSharpCharacter(pageData.filename), pageData.content, options.webDAVURL, options.webDAVUser, options.webDAVPassword, {
-						filenameConflictAction: options.filenameConflictAction
-					});
-				} else if (options.saveToGitHub) {
-					await (await downloads.saveToGitHub(message.taskId, downloads.encodeSharpCharacter(pageData.filename), pageData.content, options.githubToken, options.githubUser, options.githubRepository, options.githubBranch, {
-						filenameConflictAction: options.filenameConflictAction
-					})).pushPromise;
-				} else if (options.saveWithCompanion) {
-					await companion.save({
-						filename: pageData.filename,
-						content: pageData.content,
-						filenameConflictAction: options.filenameConflictAction
-					});
-				} else {
-					const blob = new Blob([pageData.content], { type: "text/html" });
-					pageData.url = URL.createObjectURL(blob);
-					await downloads.downloadPage(pageData, options);
-					if (options.openSavedPage) {
-						const createTabProperties = { active: true, url: URL.createObjectURL(blob), windowId: tab.windowId };
-						const index = tab.index;
-						try {
-							await browser.tabs.get(tabId);
-							createTabProperties.index = index + 1;
-						} catch (error) {
-							createTabProperties.index = index;
+				let skipped;
+				if (!options.saveToGDrive && !options.saveWithWebDAV && !options.saveToGitHub && !options.saveWithCompanion) {
+					const testSkip = await downloads.testSkipSave(pageData.filename, options);
+					skipped = testSkip.skipped;
+					options.filenameConflictAction = testSkip.filenameConflictAction;
+				}
+				if (!skipped) {
+					let { content } = pageData;
+					if (options.compressContent) {
+						content = new Blob([new Uint8Array(content)], { type: "text/html" });
+					}
+					if (options.saveToGDrive) {
+						if (!(content instanceof Blob)) {
+							content = new Blob([content], { type: "text/html" });
+						}
+						await downloads.saveToGDrive(message.taskId, downloads.encodeSharpCharacter(pageData.filename), content, options, {
+							forceWebAuthFlow: options.forceWebAuthFlow
+						}, {
+							filenameConflictAction: options.filenameConflictAction
+						});
+					} else if (options.saveWithWebDAV) {
+						await downloads.saveWithWebDAV(message.taskId, downloads.encodeSharpCharacter(pageData.filename), content, options.webDAVURL, options.webDAVUser, options.webDAVPassword, {
+							filenameConflictAction: options.filenameConflictAction
+						});
+					} else if (options.saveToGitHub) {
+						await (await downloads.saveToGitHub(message.taskId, downloads.encodeSharpCharacter(pageData.filename), content, options.githubToken, options.githubUser, options.githubRepository, options.githubBranch, {
+							filenameConflictAction: options.filenameConflictAction
+						})).pushPromise;
+					} else if (options.saveWithCompanion && !options.compressContent) {
+						await companion.save({
+							filename: pageData.filename,
+							content: pageData.content,
+							filenameConflictAction: options.filenameConflictAction
+						});
+					} else {
+						if (!(content instanceof Blob)) {
+							content = new Blob([content], { type: "text/html" });
+						}
+						pageData.url = URL.createObjectURL(content);
+						await downloads.downloadPage(pageData, options);
+						if (options.openSavedPage && !options.compressContent) {
+							const createTabProperties = { active: true, url: URL.createObjectURL(content), windowId: tab.windowId };
+							const index = tab.index;
+							try {
+								await browser.tabs.get(tabId);
+								createTabProperties.index = index + 1;
+							} catch (error) {
+								createTabProperties.index = index;
+							}
+							browser.tabs.create(createTabProperties);
 						}
-						browser.tabs.create(createTabProperties);
 					}
-				}
-				if (pageData.hash) {
-					await woleet.anchor(pageData.hash, options.woleetKey);
+					if (pageData.hash) {
+						await woleet.anchor(pageData.hash, options.woleetKey);
+					}
 				}
 			}
 		} finally {

+ 6 - 0
src/core/bg/config.js

@@ -126,10 +126,16 @@ const DEFAULT_CONFIG = {
 	includeBOM: false,
 	warnUnsavedPage: true,
 	displayInfobarInEditor: false,
+	compressContent: false,
+	createRootDirectory: false,
+	selfExtractingArchive: true,
+	extractDataFromPage: true,
+	insertTextBody: false,
 	autoSaveExternalSave: false,
 	insertMetaNoIndex: false,
 	insertMetaCSP: true,
 	passReferrerOnError: false,
+	password: "",
 	insertSingleFileComment: true,
 	removeSavedDate: false,
 	blockMixedContent: false,

+ 223 - 99
src/core/bg/downloads.js

@@ -21,7 +21,7 @@
  *   Source.
  */
 
-/* global browser, Blob, URL, document, fetch */
+/* global browser, singlefile, URL, fetch, document, Blob */
 
 import * as config from "./config.js";
 import * as bookmarks from "./bookmarks.js";
@@ -35,8 +35,10 @@ import { GDrive } from "./../../lib/gdrive/gdrive.js";
 import { WebDAV } from "./../../lib/webdav/webdav.js";
 import { GitHub } from "./../../lib/github/github.js";
 import { download } from "./download-util.js";
+import * as yabson from "./../../lib/yabson/yabson.js";
 
 const partialContents = new Map();
+const parsers = new Map();
 const MIMETYPE_HTML = "text/html";
 const GDRIVE_CLIENT_ID = "207618107333-7tjs1im1pighftpoepea2kvkubnfjj44.apps.googleusercontent.com";
 const GDRIVE_CLIENT_KEY = "VQJ8Gq8Vxx72QyxPyeLtWvUt";
@@ -49,6 +51,7 @@ const gDrive = new GDrive(GDRIVE_CLIENT_ID, GDRIVE_CLIENT_KEY, SCOPES);
 export {
 	onMessage,
 	downloadPage,
+	testSkipSave,
 	saveToGDrive,
 	saveToGitHub,
 	saveWithWebDAV,
@@ -94,100 +97,209 @@ async function onMessage(message, sender) {
 }
 
 async function downloadTabPage(message, tab) {
+	const tabId = tab.id;
 	let contents;
 	if (message.blobURL) {
 		try {
-			message.content = await (await fetch(message.blobURL)).text();
+			if (message.compressContent) {
+				message.pageData = await yabson.parse(new Uint8Array(await (await fetch(message.blobURL)).arrayBuffer()));
+				await downloadCompressedContent(message, tab);
+			} else {
+				message.content = await (await fetch(message.blobURL)).text();
+				await downloadContent([message.content], tab, tab.incognito, message);
+			}
 		} catch (error) {
 			return { error: true };
 		}
-	}
-	if (message.truncated) {
-		contents = partialContents.get(tab.id);
-		if (!contents) {
-			contents = [];
-			partialContents.set(tab.id, contents);
+	} else if (message.compressContent) {
+		let parser = parsers.get(tabId);
+		if (!parser) {
+			parser = yabson.getParser();
+			parsers.set(tabId, parser);
+		}
+		let result = await parser.next(message.data);
+		if (result.done) {
+			const message = result.value;
+			parsers.delete(tabId);
+			await downloadCompressedContent(message, tab);
+		}
+	} else {
+		if (message.truncated) {
+			contents = partialContents.get(tabId);
+			if (!contents) {
+				contents = [];
+				partialContents.set(tabId, contents);
+			}
+			contents.push(message.content);
+			if (message.finished) {
+				partialContents.delete(tabId);
+			}
+		} else if (message.content) {
+			contents = [message.content];
 		}
-		contents.push(message.content);
-		if (message.finished) {
-			partialContents.delete(tab.id);
+		if (!message.truncated || message.finished) {
+			await downloadContent(contents, tab, tab.incognito, message);
 		}
-	} else if (message.content) {
-		contents = [message.content];
 	}
-	if (!message.truncated || message.finished) {
+	return {};
+}
+
+async function downloadContent(contents, tab, incognito, message) {
+	const tabId = tab.id;
+	let skipped;
+	if (message.backgroundSave && !message.saveToGDrive && !message.saveWithWebDAV && !message.saveToGitHub) {
+		const testSkip = await testSkipSave(message.filename, message);
+		message.filenameConflictAction = testSkip.filenameConflictAction;
+		skipped = testSkip.skipped;
+	}
+	if (skipped) {
+		ui.onEnd(tabId);
+	} else {
 		if (message.openEditor) {
-			ui.onEdit(tab.id);
+			ui.onEdit(tabId);
 			await editor.open({ tabIndex: tab.index + 1, filename: message.filename, content: contents.join("") });
 		} else {
 			if (message.saveToClipboard) {
 				message.content = contents.join("");
 				saveToClipboard(message);
-				ui.onEnd(tab.id);
+				ui.onEnd(tabId);
 			} else {
-				await downloadContent(contents, tab, tab.incognito, message);
+				try {
+					const prompt = filename => promptFilename(tabId, filename);
+					let response;
+					if (message.saveWithWebDAV) {
+						response = await saveWithWebDAV(message.taskId, encodeSharpCharacter(message.filename), contents.join(""), message.webDAVURL, message.webDAVUser, message.webDAVPassword, { filenameConflictAction: message.filenameConflictAction, prompt });
+					} else if (message.saveToGDrive) {
+						await saveToGDrive(message.taskId, encodeSharpCharacter(message.filename), new Blob(contents, { type: MIMETYPE_HTML }), {
+							forceWebAuthFlow: message.forceWebAuthFlow
+						}, {
+							onProgress: (offset, size) => ui.onUploadProgress(tabId, offset, size),
+							filenameConflictAction: message.filenameConflictAction,
+							prompt
+						});
+					} else if (message.saveToGitHub) {
+						response = await saveToGitHub(message.taskId, encodeSharpCharacter(message.filename), contents.join(""), message.githubToken, message.githubUser, message.githubRepository, message.githubBranch, {
+							filenameConflictAction: message.filenameConflictAction,
+							prompt
+						});
+						await response.pushPromise;
+					} else if (message.saveWithCompanion) {
+						await companion.save({
+							filename: message.filename,
+							content: message.content,
+							filenameConflictAction: message.filenameConflictAction
+						});
+					} else {
+						message.url = URL.createObjectURL(new Blob(contents, { type: MIMETYPE_HTML }));
+						response = await downloadPage(message, {
+							confirmFilename: message.confirmFilename,
+							incognito,
+							filenameConflictAction: message.filenameConflictAction,
+							filenameReplacementCharacter: message.filenameReplacementCharacter,
+							includeInfobar: message.includeInfobar
+						});
+					}
+					if (message.replaceBookmarkURL && response && response.url) {
+						await bookmarks.update(message.bookmarkId, { url: response.url });
+					}
+					ui.onEnd(tabId);
+					if (message.openSavedPage) {
+						const createTabProperties = { active: true, url: URL.createObjectURL(new Blob(contents, { type: MIMETYPE_HTML })) };
+						if (tab.index != null) {
+							createTabProperties.index = tab.index + 1;
+						}
+						browser.tabs.create(createTabProperties);
+					}
+				} catch (error) {
+					if (!error.message || error.message != "upload_cancelled") {
+						console.error(error); // eslint-disable-line no-console
+						ui.onError(tabId, error.message, error.link);
+					}
+				} finally {
+					if (message.url) {
+						URL.revokeObjectURL(message.url);
+					}
+				}
 			}
 		}
 	}
-	return {};
 }
 
-async function downloadContent(contents, tab, incognito, message) {
+async function downloadCompressedContent(message, tab) {
 	const tabId = tab.id;
-	try {
-		const prompt = filename => promptFilename(tabId, filename);
-		let response;
-		if (message.saveWithWebDAV) {
-			response = await saveWithWebDAV(message.taskId, encodeSharpCharacter(message.filename), contents.join(""), message.webDAVURL, message.webDAVUser, message.webDAVPassword, { filenameConflictAction: message.filenameConflictAction, prompt });
-		} else if (message.saveToGDrive) {
-			await saveToGDrive(message.taskId, encodeSharpCharacter(message.filename), new Blob(contents, { type: MIMETYPE_HTML }), {
-				forceWebAuthFlow: message.forceWebAuthFlow
-			}, {
-				onProgress: (offset, size) => ui.onUploadProgress(tabId, offset, size),
-				filenameConflictAction: message.filenameConflictAction,
-				prompt
-			});
-		} else if (message.saveToGitHub) {
-			response = await saveToGitHub(message.taskId, encodeSharpCharacter(message.filename), contents.join(""), message.githubToken, message.githubUser, message.githubRepository, message.githubBranch, {
-				filenameConflictAction: message.filenameConflictAction,
-				prompt
-			});
-			await response.pushPromise;
-		} else if (message.saveWithCompanion) {
-			await companion.save({
-				filename: message.filename,
-				content: message.content,
-				filenameConflictAction: message.filenameConflictAction
-			});
-		} else {
-			message.url = URL.createObjectURL(new Blob(contents, { type: MIMETYPE_HTML }));
-			response = await downloadPage(message, {
-				confirmFilename: message.confirmFilename,
-				incognito,
-				filenameConflictAction: message.filenameConflictAction,
-				filenameReplacementCharacter: message.filenameReplacementCharacter,
-				includeInfobar: message.includeInfobar
-			});
-		}
-		if (message.replaceBookmarkURL && response && response.url) {
-			await bookmarks.update(message.bookmarkId, { url: response.url });
-		}
+	let skipped;
+	if (message.backgroundSave && !message.saveToGDrive && !message.saveWithWebDAV && !message.saveToGitHub) {
+		const testSkip = await testSkipSave(message.filename, message);
+		message.filenameConflictAction = testSkip.filenameConflictAction;
+		skipped = testSkip.skipped;
+	}
+	if (skipped) {
 		ui.onEnd(tabId);
-		if (message.openSavedPage) {
-			const createTabProperties = { active: true, url: URL.createObjectURL(new Blob(contents, { type: MIMETYPE_HTML })) };
-			if (tab.index != null) {
-				createTabProperties.index = tab.index + 1;
+	} else {
+		const pageData = message.pageData;
+		const blob = await singlefile.processors.compression.process(pageData, {
+			insertTextBody: message.insertTextBody,
+			url: pageData.url || tab.url,
+			createRootDirectory: message.createRootDirectory,
+			tabId,
+			selfExtractingArchive: message.selfExtractingArchive,
+			extractDataFromPage: message.extractDataFromPage,
+			insertCanonicalLink: message.insertCanonicalLink,
+			insertMetaNoIndex: message.insertMetaNoIndex,
+			password: message.password
+		});
+		if (message.openEditor) {
+			ui.onEdit(tabId);
+			await editor.open({ tabIndex: tab.index + 1, filename: message.filename, content: Array.from(new Uint8Array(await blob.arrayBuffer())), compressContent: true });
+		} else {
+			try {
+				const prompt = filename => promptFilename(tabId, filename);
+				let response;
+				if (message.saveWithWebDAV) {
+					response = await saveWithWebDAV(message.taskId, encodeSharpCharacter(message.filename), blob, message.webDAVURL, message.webDAVUser, message.webDAVPassword, { filenameConflictAction: message.filenameConflictAction, prompt });
+				} else if (message.saveToGDrive) {
+					await saveToGDrive(message.taskId, encodeSharpCharacter(message.filename), blob, {
+						forceWebAuthFlow: message.forceWebAuthFlow
+					}, {
+						onProgress: (offset, size) => ui.onUploadProgress(tabId, offset, size),
+						filenameConflictAction: message.filenameConflictAction,
+						prompt
+					});
+				} else if (message.saveToGitHub) {
+					response = await saveToGitHub(message.taskId, encodeSharpCharacter(message.filename), blob, message.githubToken, message.githubUser, message.githubRepository, message.githubBranch, {
+						filenameConflictAction: message.filenameConflictAction,
+						prompt
+					});
+					await response.pushPromise;
+				} else {
+					if (message.backgroundSave) {
+						message.url = URL.createObjectURL(blob);
+						response = await downloadPage(message, {
+							confirmFilename: message.confirmFilename,
+							incognito: tab.incognito,
+							filenameConflictAction: message.filenameConflictAction,
+							filenameReplacementCharacter: message.filenameReplacementCharacter,
+							bookmarkId: message.bookmarkId,
+							replaceBookmarkURL: message.replaceBookmarkURL
+						});
+					} else {
+						await downloadPageForeground(message.taskId, message.filename, blob, tabId);
+					}
+				}
+				if (message.bookmarkId && message.replaceBookmarkURL && response && response.url) {
+					await bookmarks.update(message.bookmarkId, { url: response.url });
+				}
+				ui.onEnd(tabId);
+			} catch (error) {
+				if (!error.message || error.message != "upload_cancelled") {
+					console.error(error); // eslint-disable-line no-console
+					ui.onError(tabId, error.message);
+				}
+			} finally {
+				if (message.url) {
+					URL.revokeObjectURL(message.url);
+				}
 			}
-			browser.tabs.create(createTabProperties);
-		}
-	} catch (error) {
-		if (!error.message || error.message != "upload_cancelled") {
-			console.error(error); // eslint-disable-line no-console
-			ui.onError(tabId, error.message, error.link);
-		}
-	} finally {
-		if (message.url) {
-			URL.revokeObjectURL(message.url);
 		}
 	}
 }
@@ -278,45 +390,46 @@ async function saveToGDrive(taskId, filename, blob, authOptions, uploadOptions)
 	}
 }
 
-function promptFilename(tabId, filename) {
-	return browser.tabs.sendMessage(tabId, { method: "content.prompt", message: "Filename conflict, please enter a new filename", value: filename });
-}
-
-async function downloadPage(pageData, options) {
-	const filenameConflictAction = options.filenameConflictAction;
-	let skipped;
+async function testSkipSave(filename, options) {
+	let skipped, filenameConflictAction = options.filenameConflictAction;
 	if (filenameConflictAction == CONFLICT_ACTION_SKIP) {
 		const downloadItems = await browser.downloads.search({
-			filenameRegex: "(\\\\|/)" + getRegExp(pageData.filename) + "$",
+			filenameRegex: "(\\\\|/)" + getRegExp(filename) + "$",
 			exists: true
 		});
 		if (downloadItems.length) {
 			skipped = true;
 		} else {
-			options.filenameConflictAction = CONFLICT_ACTION_UNIQUIFY;
+			filenameConflictAction = CONFLICT_ACTION_UNIQUIFY;
 		}
 	}
-	if (!skipped) {
-		const downloadInfo = {
-			url: pageData.url,
-			saveAs: options.confirmFilename,
-			filename: pageData.filename,
-			conflictAction: options.filenameConflictAction
-		};
-		if (options.incognito) {
-			downloadInfo.incognito = true;
-		}
-		const downloadData = await download(downloadInfo, options.filenameReplacementCharacter);
-		if (downloadData.filename) {
-			let url = downloadData.filename;
-			if (!url.startsWith("file:")) {
-				if (url.startsWith("/")) {
-					url = downloadData.filename.substring(1);
-				}
-				url = "file:///" + encodeSharpCharacter(url);
+	return { skipped, filenameConflictAction };
+}
+
+function promptFilename(tabId, filename) {
+	return browser.tabs.sendMessage(tabId, { method: "content.prompt", message: "Filename conflict, please enter a new filename", value: filename });
+}
+
+async function downloadPage(pageData, options) {
+	const downloadInfo = {
+		url: pageData.url,
+		saveAs: options.confirmFilename,
+		filename: pageData.filename,
+		conflictAction: options.filenameConflictAction
+	};
+	if (options.incognito) {
+		downloadInfo.incognito = true;
+	}
+	const downloadData = await download(downloadInfo, options.filenameReplacementCharacter);
+	if (downloadData.filename) {
+		let url = downloadData.filename;
+		if (!url.startsWith("file:")) {
+			if (url.startsWith("/")) {
+				url = url.substring(1);
 			}
-			return { url };
+			url = "file:///" + encodeSharpCharacter(url);
 		}
+		return { url };
 	}
 }
 
@@ -331,4 +444,15 @@ function saveToClipboard(pageData) {
 		event.clipboardData.setData("text/plain", pageData.content);
 		event.preventDefault();
 	}
+}
+
+async function downloadPageForeground(taskId, filename, content, tabId) {
+	const serializer = yabson.getSerializer({ filename, taskId, content: await content.arrayBuffer() });
+	for await (const data of serializer) {
+		await browser.tabs.sendMessage(tabId, {
+			method: "content.download",
+			data: Array.from(data)
+		});
+	}
+	await browser.tabs.sendMessage(tabId, { method: "content.download" });
 }

+ 14 - 4
src/core/bg/editor.js

@@ -39,13 +39,13 @@ export {
 	EDITOR_URL
 };
 
-async function open({ tabIndex, content, filename }) {
+async function open({ tabIndex, content, filename, compressContent }) {
 	const createTabProperties = { active: true, url: EDITOR_PAGE_URL };
 	if (tabIndex != null) {
 		createTabProperties.index = tabIndex;
 	}
 	const tab = await browser.tabs.create(createTabProperties);
-	tabsData.set(tab.id, { content, filename });
+	tabsData.set(tab.id, { content, filename, compressContent });
 }
 
 function onTabRemoved(tabId) {
@@ -65,7 +65,8 @@ async function onMessage(message, sender) {
 			const content = JSON.stringify(tabData);
 			for (let blockIndex = 0; blockIndex * MAX_CONTENT_SIZE < content.length; blockIndex++) {
 				const message = {
-					method: "editor.setTabData"
+					method: "editor.setTabData",
+					compressContent: tabData.compressContent
 				};
 				message.truncated = content.length > MAX_CONTENT_SIZE;
 				if (message.truncated) {
@@ -102,7 +103,16 @@ async function onMessage(message, sender) {
 		if (!message.truncated || message.finished) {
 			const updateTabProperties = { url: EDITOR_PAGE_URL };
 			await browser.tabs.update(tab.id, updateTabProperties);
-			tabsData.set(tab.id, { url: tab.url, content: contents.join(""), filename: message.filename });
+			const content = message.compressContent ? contents.flat() : contents.join("");
+			tabsData.set(tab.id, { 
+				url: tab.url, 
+				content, 
+				filename: message.filename, 
+				compressContent: message.compressContent,
+				selfExtractingArchive: message.selfExtractingArchive,
+				extractDataFromPageTags: message.extractDataFromPageTags,
+				insertTextBody: message.insertTextBody
+			});
 		}
 		return {};
 	}

+ 87 - 51
src/core/common/download.js

@@ -23,6 +23,8 @@
 
 /* global browser, document, URL, Blob, MouseEvent, setTimeout, open */
 
+import * as yabson from "./../../lib/yabson/yabson.js";
+
 const MAX_CONTENT_SIZE = 32 * (1024 * 1024);
 
 export {
@@ -33,67 +35,101 @@ async function downloadPage(pageData, options) {
 	if (options.includeBOM) {
 		pageData.content = "\ufeff" + pageData.content;
 	}
-	if (options.backgroundSave || options.openEditor || options.saveToGDrive || options.saveToGitHub || options.saveWithCompanion || options.saveWithWebDAV) {
-		const blobURL = URL.createObjectURL(new Blob([pageData.content], { type: "text/html" }));
-		const message = {
-			method: "downloads.download",
-			taskId: options.taskId,
-			confirmFilename: options.confirmFilename,
-			filenameConflictAction: options.filenameConflictAction,
-			filename: pageData.filename,
-			saveToClipboard: options.saveToClipboard,
-			saveToGDrive: options.saveToGDrive,
-			saveWithWebDAV: options.saveWithWebDAV,
-			webDAVURL: options.webDAVURL,
-			webDAVUser: options.webDAVUser,
-			webDAVPassword: options.webDAVPassword,
-			saveToGitHub: options.saveToGitHub,
-			githubToken: options.githubToken,
-			githubUser: options.githubUser,
-			githubRepository: options.githubRepository,
-			githubBranch: options.githubBranch,
-			saveWithCompanion: options.saveWithCompanion,
-			forceWebAuthFlow: options.forceWebAuthFlow,
-			filenameReplacementCharacter: options.filenameReplacementCharacter,
-			openEditor: options.openEditor,
-			openSavedPage: options.openSavedPage,
-			compressHTML: options.compressHTML,
-			backgroundSave: options.backgroundSave,
-			bookmarkId: options.bookmarkId,
-			replaceBookmarkURL: options.replaceBookmarkURL,
-			applySystemTheme: options.applySystemTheme,
-			defaultEditorMode: options.defaultEditorMode,
-			includeInfobar: options.includeInfobar,
-			warnUnsavedPage: options.warnUnsavedPage,
-			blobURL
-		};
+	const message = {
+		method: "downloads.download",
+		taskId: options.taskId,
+		insertTextBody: options.insertTextBody,
+		confirmFilename: options.confirmFilename,
+		filenameConflictAction: options.filenameConflictAction,
+		filename: pageData.filename,
+		saveToClipboard: options.saveToClipboard,
+		saveToGDrive: options.saveToGDrive,
+		saveWithWebDAV: options.saveWithWebDAV,
+		webDAVURL: options.webDAVURL,
+		webDAVUser: options.webDAVUser,
+		webDAVPassword: options.webDAVPassword,
+		saveToGitHub: options.saveToGitHub,
+		githubToken: options.githubToken,
+		githubUser: options.githubUser,
+		githubRepository: options.githubRepository,
+		githubBranch: options.githubBranch,
+		saveWithCompanion: options.saveWithCompanion,
+		forceWebAuthFlow: options.forceWebAuthFlow,
+		filenameReplacementCharacter: options.filenameReplacementCharacter,
+		openEditor: options.openEditor,
+		openSavedPage: options.openSavedPage,
+		compressHTML: options.compressHTML,
+		backgroundSave: options.backgroundSave,
+		bookmarkId: options.bookmarkId,
+		replaceBookmarkURL: options.replaceBookmarkURL,
+		applySystemTheme: options.applySystemTheme,
+		defaultEditorMode: options.defaultEditorMode,
+		includeInfobar: options.includeInfobar,
+		warnUnsavedPage: options.warnUnsavedPage,
+		createRootDirectory: options.createRootDirectory,
+		selfExtractingArchive: options.selfExtractingArchive,
+		extractDataFromPage: options.extractDataFromPage,
+		insertCanonicalLink: options.insertCanonicalLink,
+		insertMetaNoIndex: options.insertMetaNoIndex,
+		password: options.password,
+		compressContent: options.compressContent
+	};
+	if (options.compressContent) {
+		const blobURL = URL.createObjectURL(new Blob([await yabson.serialize(pageData)], { type: "application/octet-stream" }));
+		message.blobURL = blobURL;
 		const result = await browser.runtime.sendMessage(message);
 		URL.revokeObjectURL(blobURL);
 		if (result.error) {
 			message.blobURL = null;
-			for (let blockIndex = 0; blockIndex * MAX_CONTENT_SIZE < pageData.content.length; blockIndex++) {
-				message.truncated = pageData.content.length > MAX_CONTENT_SIZE;
-				if (message.truncated) {
-					message.finished = (blockIndex + 1) * MAX_CONTENT_SIZE > pageData.content.length;
-					message.content = pageData.content.substring(blockIndex * MAX_CONTENT_SIZE, (blockIndex + 1) * MAX_CONTENT_SIZE);
-				} else {
-					message.content = pageData.content;
-				}
-				await browser.runtime.sendMessage(message);
+			message.pageData = pageData;
+			const serializer = yabson.getSerializer(message);
+			for await (const data of serializer) {
+				await browser.runtime.sendMessage({
+					method: "downloads.download",
+					compressContent: true,
+					data: Array.from(data)
+				});
 			}
+			await browser.runtime.sendMessage({
+				method: "downloads.download",
+				compressContent: true
+			});
+		}
+		if (options.backgroundSave) {
+			await browser.runtime.sendMessage({ method: "downloads.end", taskId: options.taskId });
 		}
 	} else {
-		if (options.saveToClipboard) {
-			saveToClipboard(pageData);
+		if (options.backgroundSave || options.openEditor || options.saveToGDrive || options.saveToGitHub || options.saveWithCompanion || options.saveWithWebDAV) {
+			const blobURL = URL.createObjectURL(new Blob([pageData.content], { type: "text/html" }));
+			message.blobURL = blobURL;
+			const result = await browser.runtime.sendMessage(message);
+			URL.revokeObjectURL(blobURL);
+			if (result.error) {
+				message.blobURL = null;
+				for (let blockIndex = 0; blockIndex * MAX_CONTENT_SIZE < pageData.content.length; blockIndex++) {
+					message.truncated = pageData.content.length > MAX_CONTENT_SIZE;
+					if (message.truncated) {
+						message.finished = (blockIndex + 1) * MAX_CONTENT_SIZE > pageData.content.length;
+						message.content = pageData.content.substring(blockIndex * MAX_CONTENT_SIZE, (blockIndex + 1) * MAX_CONTENT_SIZE);
+					} else {
+						message.content = pageData.content;
+					}
+					await browser.runtime.sendMessage(message);
+				}
+			}
 		} else {
-			await downloadPageForeground(pageData);
-		}
-		if (options.openSavedPage) {
-			open(URL.createObjectURL(new Blob([pageData.content], { type: "text/html" })));
+			if (options.saveToClipboard) {
+				saveToClipboard(pageData);
+			} else {
+				await downloadPageForeground(pageData);
+			}
+			if (options.openSavedPage) {
+				open(URL.createObjectURL(new Blob([pageData.content], { type: "text/html" })));
+			}
+			browser.runtime.sendMessage({ method: "ui.processEnd" });
 		}
-		browser.runtime.sendMessage({ method: "ui.processEnd" });
+		await browser.runtime.sendMessage({ method: "downloads.end", taskId: options.taskId, hash: pageData.hash, woleetKey: options.woleetKey });
 	}
-	await browser.runtime.sendMessage({ method: "downloads.end", taskId: options.taskId, hash: pageData.hash, woleetKey: options.woleetKey });
 }
 
 async function downloadPageForeground(pageData) {

+ 116 - 14
src/core/content/content-bootstrap.js

@@ -21,13 +21,14 @@
  *   Source.
  */
 
-/* global browser, globalThis, window, document, location, setTimeout, Node */
-
-const singlefile = globalThis.singlefileBootstrap;
+/* global browser, globalThis, window, document, location, setTimeout, XMLHttpRequest, Node, DOMParser */
 
 const MAX_CONTENT_SIZE = 32 * (1024 * 1024);
 
-let unloadListenerAdded, optionsAutoSave, tabId, tabIndex, autoSaveEnabled, autoSaveTimeout, autoSavingPage, pageAutoSaved, previousLocationHref;
+const singlefile = globalThis.singlefileBootstrap;
+const pendingResponses = new Map();
+
+let unloadListenerAdded, optionsAutoSave, tabId, tabIndex, autoSaveEnabled, autoSaveTimeout, autoSavingPage, pageAutoSaved, previousLocationHref, savedPageDetected, compressContent, extractDataFromPageTags, insertTextBody;
 singlefile.pageInfo = {
 	updatedResources: {},
 	visitDate: new Date()
@@ -57,11 +58,62 @@ browser.runtime.onMessage.addListener(message => {
 		message.method == "content.maybeInit" ||
 		message.method == "content.init" ||
 		message.method == "content.openEditor" ||
-		message.method == "devtools.resourceCommitted") {
+		message.method == "devtools.resourceCommitted" ||
+		message.method == "singlefile.fetchResponse") {
 		return onMessage(message);
 	}
 });
 document.addEventListener("DOMContentLoaded", init, false);
+if (globalThis.window == globalThis.top && location && location.href && (location.href.startsWith("file://") || location.href.startsWith("content://"))) {
+	if (document.readyState == "loading") {
+		document.addEventListener("DOMContentLoaded", extractFile, false);
+	} else {
+		extractFile();
+	}
+}
+
+async function extractFile() {
+	if (document.documentElement.dataset.sfz !== undefined) {
+		const data = await getContent();
+		executeBootstrap(data);
+	} else {
+		if ((document.body && document.body.childNodes.length == 1 && document.body.childNodes[0].tagName == "PRE" && /<html[^>]* data-sfz[^>]*>/i.test(document.body.childNodes[0].textContent))) {
+			const doc = (new DOMParser()).parseFromString(document.body.childNodes[0].textContent, "text/html");
+			document.replaceChild(doc.documentElement, document.documentElement);
+			document.querySelectorAll("script").forEach(element => {
+				const scriptElement = document.createElement("script");
+				scriptElement.textContent = element.textContent;
+				element.parentElement.replaceChild(scriptElement, element);
+			});
+			await extractFile();
+		}
+	}
+}
+
+function getContent() {
+	return new Promise((resolve, reject) => {
+		const xhr = new XMLHttpRequest();
+		xhr.open("GET", location.href);
+		xhr.send();
+		xhr.responseType = "arraybuffer";
+		xhr.onload = () => resolve(new Uint8Array(xhr.response));
+		xhr.onerror = () => {
+			const errorMessageElement = document.getElementById("sfz-error-message");
+			if (errorMessageElement) {
+				errorMessageElement.remove();
+			}
+			const requestId = pendingResponses.size;
+			pendingResponses.set(requestId, { resolve, reject });
+			browser.runtime.sendMessage({ method: "singlefile.fetch", requestId, url: location.href });
+		};
+	});
+}
+
+function executeBootstrap(data) {
+	const scriptElement = document.createElement("script");
+	scriptElement.textContent = "(() => { document.currentScript.remove(); const bootstrapReady = this.bootstrap && this.bootstrap([" + (new Uint8Array(data)).toString() + "]); if (bootstrapReady) { bootstrapReady.then(() => document.dispatchEvent(new CustomEvent(\"single-file-display-infobar\"))); } })()";
+	document.body.appendChild(scriptElement);
+}
 
 async function onMessage(message) {
 	if (autoSaveEnabled && message.method == "content.autosave") {
@@ -90,6 +142,36 @@ async function onMessage(message) {
 		singlefile.pageInfo.updatedResources[message.url] = { content: message.content, type: message.type, encoding: message.encoding };
 		return {};
 	}
+	if (message.method == "singlefile.fetchResponse") {
+		return await onFetchResponse(message);
+	}
+}
+
+async function onFetchResponse(message) {
+	const pendingResponse = pendingResponses.get(message.requestId);
+	if (pendingResponse) {
+		if (message.error) {
+			pendingResponse.reject(new Error(message.error));
+			pendingResponses.delete(message.requestId);
+		} else {
+			if (message.truncated) {
+				if (pendingResponse.array) {
+					pendingResponse.array = pendingResponse.array.concat(message.array);
+				} else {
+					pendingResponse.array = message.array;
+					pendingResponses.set(message.requestId, pendingResponse);
+				}
+				if (message.finished) {
+					message.array = pendingResponse.array;
+				}
+			}
+			if (!message.truncated || message.finished) {
+				pendingResponse.resolve(message.array);
+				pendingResponses.delete(message.requestId);
+			}
+		}
+		return {};
+	}
 }
 
 function init() {
@@ -228,29 +310,49 @@ function savePage(docData, frames, { autoSaveUnload, autoSaveDiscard, autoSaveRe
 }
 
 async function openEditor(document) {
-	serializeShadowRoots(document);
-	const content = singlefile.helper.serialize(document);
+	let content;
+	if (compressContent) {
+		content = await getContent();
+	} else {
+		serializeShadowRoots(document);
+		content = singlefile.helper.serialize(document);
+	}
 	for (let blockIndex = 0; blockIndex * MAX_CONTENT_SIZE < content.length; blockIndex++) {
 		const message = {
 			method: "editor.open",
-			filename: decodeURIComponent(location.href.match(/^.*\/(.*)$/)[1])
+			filename: decodeURIComponent(location.href.match(/^.*\/(.*)$/)[1]),
+			compressContent,
+			extractDataFromPageTags,
+			insertTextBody,
+			selfExtractingArchive: compressContent
 		};
 		message.truncated = content.length > MAX_CONTENT_SIZE;
 		if (message.truncated) {
 			message.finished = (blockIndex + 1) * MAX_CONTENT_SIZE > content.length;
-			message.content = content.substring(blockIndex * MAX_CONTENT_SIZE, (blockIndex + 1) * MAX_CONTENT_SIZE);
+			if (content instanceof Uint8Array) {
+				message.content = Array.from(content.subarray(blockIndex * MAX_CONTENT_SIZE, (blockIndex + 1) * MAX_CONTENT_SIZE));
+			} else {
+				message.content = content.substring(blockIndex * MAX_CONTENT_SIZE, (blockIndex + 1) * MAX_CONTENT_SIZE);
+			}
 		} else {
-			message.content = content;
+			message.content = content instanceof Uint8Array ? Array.from(content) : content;
 		}
 		await browser.runtime.sendMessage(message);
 	}
 }
 
 function detectSavedPage(document) {
-	const helper = singlefile.helper;
-	const firstDocumentChild = document.documentElement.firstChild;
-	return firstDocumentChild.nodeType == Node.COMMENT_NODE &&
-		(firstDocumentChild.textContent.includes(helper.COMMENT_HEADER) || firstDocumentChild.textContent.includes(helper.COMMENT_HEADER_LEGACY));
+	if (savedPageDetected === undefined) {
+		const helper = singlefile.helper;
+		const firstDocumentChild = document.documentElement.firstChild;
+		compressContent = document.documentElement.dataset.sfz == "";
+		extractDataFromPageTags = Boolean(document.querySelector("sfz-extra-data"));
+		insertTextBody = Boolean(document.querySelector("body > main[hidden]"));
+		savedPageDetected = compressContent || (
+			firstDocumentChild.nodeType == Node.COMMENT_NODE &&
+			(firstDocumentChild.textContent.includes(helper.COMMENT_HEADER) || firstDocumentChild.textContent.includes(helper.COMMENT_HEADER_LEGACY)));
+	}
+	return savedPageDetected;
 }
 
 function serializeShadowRoots(node) {

+ 20 - 3
src/core/content/content.js

@@ -21,24 +21,25 @@
  *   Source.
  */
 
-/* global browser, document, globalThis, location */
+/* global browser, document, globalThis, location, URL, Blob, MouseEvent */
 
 import * as download from "./../common/download.js";
 import { fetch, frameFetch } from "./../../lib/single-file/fetch/content/content-fetch.js";
 import * as ui from "./../../ui/content/content-ui.js";
 import { onError } from "./../../ui/common/content-error.js";
+import * as yabson from "./../../lib/yabson/yabson.js";
 
 const singlefile = globalThis.singlefile;
 const bootstrap = globalThis.singlefileBootstrap;
 
 const MOZ_EXTENSION_PROTOCOL = "moz-extension:";
 
-let processor, processing;
+let processor, processing, downloadParser;
 
 if (!bootstrap || !bootstrap.initializedSingleFile) {
 	singlefile.init({ fetch, frameFetch });
 	browser.runtime.onMessage.addListener(message => {
-		if (message.method == "content.save" || message.method == "content.cancelSave" || message.method == "content.getSelectedLinks" || message.method == "content.error" || message.method == "content.prompt") {
+		if (message.method == "content.save" || message.method == "content.cancelSave" || message.method == "content.download" || message.method == "content.getSelectedLinks" || message.method == "content.error" || message.method == "content.prompt") {
 			return onMessage(message);
 		}
 	});
@@ -71,6 +72,22 @@ async function onMessage(message) {
 				urls: ui.getSelectedLinks()
 			};
 		}
+		if (message.method == "content.download") {
+			if (!downloadParser) {
+				downloadParser = yabson.getParser();
+			}
+			const result = await downloadParser.next(message.data);
+			if (result.done) {
+				downloadParser = null;
+				const link = document.createElement("a");
+				link.download = result.value.filename;
+				link.href = URL.createObjectURL(new Blob([result.value.content]), "text/html");
+				link.dispatchEvent(new MouseEvent("click"));
+				URL.revokeObjectURL(link.href);
+				await browser.runtime.sendMessage({ method: "downloads.end", taskId: result.value.taskId });
+			}
+			return {};
+		}
 		if (message.method == "content.error") {
 			onError(message.error, message.link);
 			return {};

+ 1 - 1
src/lib/woleet/woleet.js

@@ -22,7 +22,7 @@
  */
 /* global fetch */
 const urlService = "https://api.woleet.io/v1/anchor";
-const apiKey = "";
+const apiKey = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhYzZmZTMzMi0wODNjLTRjZmMtYmYxNC0xNWU5MTJmMWY4OWIiLCJpYXQiOjE1NzYxNzQzNDV9.n31j9ctJj7R1Vjwyc5yd1d6Cmg0NDnpwSaLWsqtZJQA";
 export {
 	anchor
 };

+ 980 - 0
src/lib/yabson/yabson.js

@@ -0,0 +1,980 @@
+/* global TextEncoder, TextDecoder */
+
+const DEFAULT_CHUNK_SIZE = 8 * 1024 * 1024;
+const TYPE_REFERENCE = 0;
+const SPECIAL_TYPES = [TYPE_REFERENCE];
+const EMPTY_SLOT_VALUE = Symbol();
+
+const textEncoder = new TextEncoder();
+const textDecoder = new TextDecoder();
+const types = new Array(256);
+let typeIndex = 0;
+
+registerType(serializeCircularReference, parseCircularReference, testCircularReference, TYPE_REFERENCE);
+registerType(null, parseObject, testObject);
+registerType(serializeArray, parseArray, testArray);
+registerType(serializeString, parseString, testString);
+registerType(serializeTypedArray, parseFloat64Array, testFloat64Array);
+registerType(serializeTypedArray, parseFloat32Array, testFloat32Array);
+registerType(serializeTypedArray, parseUint32Array, testUint32Array);
+registerType(serializeTypedArray, parseInt32Array, testInt32Array);
+registerType(serializeTypedArray, parseUint16Array, testUint16Array);
+registerType(serializeTypedArray, parseInt16Array, testInt16Array);
+registerType(serializeTypedArray, parseUint8ClampedArray, testUint8ClampedArray);
+registerType(serializeTypedArray, parseUint8Array, testUint8Array);
+registerType(serializeTypedArray, parseInt8Array, testInt8Array);
+registerType(serializeArrayBuffer, parseArrayBuffer, testArrayBuffer);
+registerType(serializeNumber, parseNumber, testNumber);
+registerType(serializeUint32, parseUint32, testUint32);
+registerType(serializeInt32, parseInt32, testInt32);
+registerType(serializeUint16, parseUint16, testUint16);
+registerType(serializeInt16, parseInt16, testInt16);
+registerType(serializeUint8, parseUint8, testUint8);
+registerType(serializeInt8, parseInt8, testInt8);
+registerType(null, parseUndefined, testUndefined);
+registerType(null, parseNull, testNull);
+registerType(null, parseNaN, testNaN);
+registerType(serializeBoolean, parseBoolean, testBoolean);
+registerType(serializeSymbol, parseSymbol, testSymbol);
+registerType(null, parseEmptySlot, testEmptySlot);
+registerType(serializeMap, parseMap, testMap);
+registerType(serializeSet, parseSet, testSet);
+registerType(serializeDate, parseDate, testDate);
+registerType(serializeError, parseError, testError);
+registerType(serializeRegExp, parseRegExp, testRegExp);
+registerType(serializeStringObject, parseStringObject, testStringObject);
+registerType(serializeNumberObject, parseNumberObject, testNumberObject);
+registerType(serializeBooleanObject, parseBooleanObject, testBooleanObject);
+
+export {
+	getSerializer,
+	getParser,
+	registerType,
+	clone,
+	serialize,
+	parse,
+	serializeValue,
+	serializeArray,
+	serializeString,
+	serializeTypedArray,
+	serializeArrayBuffer,
+	serializeNumber,
+	serializeUint32,
+	serializeInt32,
+	serializeUint16,
+	serializeInt16,
+	serializeUint8,
+	serializeInt8,
+	serializeBoolean,
+	serializeMap,
+	serializeSet,
+	serializeDate,
+	serializeError,
+	serializeRegExp,
+	serializeStringObject,
+	serializeNumberObject,
+	serializeBooleanObject,
+	serializeSymbol,
+	parseValue,
+	parseObject,
+	parseArray,
+	parseString,
+	parseFloat64Array,
+	parseFloat32Array,
+	parseUint32Array,
+	parseInt32Array,
+	parseUint16Array,
+	parseInt16Array,
+	parseUint8ClampedArray,
+	parseUint8Array,
+	parseInt8Array,
+	parseArrayBuffer,
+	parseNumber,
+	parseUint32,
+	parseInt32,
+	parseUint16,
+	parseInt16,
+	parseUint8,
+	parseInt8,
+	parseUndefined,
+	parseNull,
+	parseNaN,
+	parseBoolean,
+	parseMap,
+	parseSet,
+	parseDate,
+	parseError,
+	parseRegExp,
+	parseStringObject,
+	parseNumberObject,
+	parseBooleanObject,
+	parseSymbol,
+	testObject,
+	testArray,
+	testString,
+	testFloat64Array,
+	testFloat32Array,
+	testUint32Array,
+	testInt32Array,
+	testUint16Array,
+	testInt16Array,
+	testUint8ClampedArray,
+	testUint8Array,
+	testInt8Array,
+	testArrayBuffer,
+	testNumber,
+	testBigInt,
+	testUint32,
+	testInt32,
+	testUint16,
+	testInt16,
+	testUint8,
+	testInt8,
+	testInteger,
+	testUndefined,
+	testNull,
+	testNaN,
+	testBoolean,
+	testMap,
+	testSet,
+	testDate,
+	testError,
+	testRegExp,
+	testStringObject,
+	testNumberObject,
+	testBooleanObject,
+	testSymbol
+};
+
+function registerType(serialize, parse, test, type) {
+	if (type === undefined) {
+		typeIndex++;
+		if (types.length - typeIndex >= SPECIAL_TYPES.length) {
+			types[types.length - typeIndex] = { serialize, parse, test };
+		} else {
+			throw new Error("Reached maximum number of custom types");
+		}
+	} else {
+		types[type] = { serialize, parse, test };
+	}
+}
+
+async function clone(object, options) {
+	const serializer = getSerializer(object, options);
+	const parser = getParser();
+	let result;
+	for await (const chunk of serializer) {
+		result = await parser.next(chunk);
+	}
+	result = await parser.next();
+	return result.value;
+}
+
+async function serialize(object, options) {
+	const serializer = getSerializer(object, options);
+	let result = new Uint8Array([]);
+	for await (const chunk of serializer) {
+		const previousResult = result;
+		result = new Uint8Array(previousResult.length + chunk.length);
+		result.set(previousResult, 0);
+		result.set(chunk, previousResult.length);
+	}
+	return result;
+}
+
+async function parse(array) {
+	const parser = getParser();
+	await parser.next(array);
+	const result = await parser.next();
+	return result.value;
+}
+
+class SerializerData {
+	constructor(appendData, chunkSize) {
+		this.stream = new WriteStream(appendData, chunkSize);
+		this.objects = [];
+	}
+
+	append(array) {
+		return this.stream.append(array);
+	}
+
+	flush() {
+		return this.stream.flush();
+	}
+
+	addObject(value) {
+		this.objects.push(testReferenceable(value) && !testCircularReference(value, this) ? value : undefined);
+	}
+}
+
+class WriteStream {
+	constructor(appendData, chunkSize) {
+		this.offset = 0;
+		this.appendData = appendData;
+		this.value = new Uint8Array(chunkSize);
+	}
+
+	async append(array) {
+		if (this.offset + array.length > this.value.length) {
+			const offset = this.value.length - this.offset;
+			await this.append(array.subarray(0, offset));
+			await this.appendData({ value: this.value });
+			this.offset = 0;
+			await this.append(array.subarray(offset));
+		} else {
+			this.value.set(array, this.offset);
+			this.offset += array.length;
+		}
+	}
+
+	async flush() {
+		if (this.offset) {
+			await this.appendData({ value: this.value.subarray(0, this.offset), done: true });
+		}
+	}
+}
+
+function getSerializer(value, { chunkSize = DEFAULT_CHUNK_SIZE } = {}) {
+	let serializerData, result, setResult, iterationDone, previousResult, resolvePreviousResult;
+	return {
+		[Symbol.asyncIterator]() {
+			return {
+				next() {
+					return iterationDone ? { done: iterationDone } : getResult();
+				},
+				return() {
+					return { done: true };
+				}
+			};
+		}
+	};
+
+	async function getResult() {
+		if (resolvePreviousResult) {
+			resolvePreviousResult();
+		} else {
+			initSerializerData().catch(() => { /* ignored */ });
+		}
+		initPreviousData();
+		const value = await getValue();
+		return { value };
+	}
+
+	async function initSerializerData() {
+		initResult();
+		serializerData = new SerializerData(appendData, chunkSize);
+		await serializeValue(serializerData, value);
+		await serializerData.flush();
+	}
+
+	function initResult() {
+		result = new Promise(resolve => setResult = resolve);
+	}
+
+	function initPreviousData() {
+		previousResult = new Promise(resolve => resolvePreviousResult = resolve);
+	}
+
+	async function appendData(result) {
+		setResult(result);
+		await previousResult;
+	}
+
+	async function getValue() {
+		const { value, done } = await result;
+		iterationDone = done;
+		if (!done) {
+			initResult();
+		}
+		return value;
+	}
+}
+
+async function serializeValue(data, value) {
+	const type = types.findIndex(({ test } = {}) => test && test(value, data));
+	data.addObject(value);
+	await data.append(new Uint8Array([type]));
+	const serialize = types[type].serialize;
+	if (serialize) {
+		await serialize(data, value);
+	}
+	if (type != TYPE_REFERENCE && testObject(value)) {
+		await serializeSymbols(data, value);
+		await serializeOwnProperties(data, value);
+	}
+}
+
+async function serializeSymbols(data, value) {
+	const ownPropertySymbols = Object.getOwnPropertySymbols(value);
+	const symbols = ownPropertySymbols.map(propertySymbol => [propertySymbol, value[propertySymbol]]);
+	await serializeArray(data, symbols);
+}
+
+async function serializeOwnProperties(data, value) {
+	let entries = Object.entries(value);
+	if (testArray(value)) {
+		entries = entries.filter(([key]) => !testInteger(Number(key)));
+	}
+	await serializeValue(data, entries.length);
+	for (const [key, value] of entries) {
+		await serializeString(data, key);
+		await serializeValue(data, value);
+	}
+}
+
+async function serializeCircularReference(data, value) {
+	const index = data.objects.indexOf(value);
+	await serializeValue(data, index);
+}
+
+async function serializeArray(data, array) {
+	await serializeValue(data, array.length);
+	const notEmptyIndexes = Object.keys(array).filter(key => testInteger(Number(key))).map(key => Number(key));
+	let indexNotEmptyIndexes = 0, currentNotEmptyIndex = notEmptyIndexes[indexNotEmptyIndexes];
+	for (const [indexArray, value] of array.entries()) {
+		if (currentNotEmptyIndex == indexArray) {
+			currentNotEmptyIndex = notEmptyIndexes[++indexNotEmptyIndexes];
+			await serializeValue(data, value);
+		} else {
+			await serializeValue(data, EMPTY_SLOT_VALUE);
+		}
+	}
+}
+
+async function serializeString(data, string) {
+	const encodedString = textEncoder.encode(string);
+	await serializeValue(data, encodedString.length);
+	await data.append(encodedString);
+}
+
+async function serializeTypedArray(data, array) {
+	await serializeValue(data, array.length);
+	await data.append(new Uint8Array(array.buffer));
+}
+
+async function serializeArrayBuffer(data, arrayBuffer) {
+	await serializeValue(data, arrayBuffer.byteLength);
+	await data.append(new Uint8Array(arrayBuffer));
+}
+
+async function serializeNumber(data, number) {
+	const serializedNumber = new Uint8Array(new Float64Array([number]).buffer);
+	await data.append(serializedNumber);
+}
+
+async function serializeUint32(data, number) {
+	const serializedNumber = new Uint8Array(new Uint32Array([number]).buffer);
+	await data.append(serializedNumber);
+}
+
+async function serializeInt32(data, number) {
+	const serializedNumber = new Uint8Array(new Int32Array([number]).buffer);
+	await data.append(serializedNumber);
+}
+
+async function serializeUint16(data, number) {
+	const serializedNumber = new Uint8Array(new Uint16Array([number]).buffer);
+	await data.append(serializedNumber);
+}
+
+async function serializeInt16(data, number) {
+	const serializedNumber = new Uint8Array(new Int16Array([number]).buffer);
+	await data.append(serializedNumber);
+}
+
+async function serializeUint8(data, number) {
+	const serializedNumber = new Uint8Array([number]);
+	await data.append(serializedNumber);
+}
+
+async function serializeInt8(data, number) {
+	const serializedNumber = new Uint8Array(new Int8Array([number]).buffer);
+	await data.append(serializedNumber);
+}
+
+async function serializeBoolean(data, boolean) {
+	const serializedBoolean = new Uint8Array([Number(boolean)]);
+	await data.append(serializedBoolean);
+}
+
+async function serializeMap(data, map) {
+	const entries = map.entries();
+	await serializeValue(data, map.size);
+	for (const [key, value] of entries) {
+		await serializeValue(data, key);
+		await serializeValue(data, value);
+	}
+}
+
+async function serializeSet(data, set) {
+	await serializeValue(data, set.size);
+	for (const value of set) {
+		await serializeValue(data, value);
+	}
+}
+
+async function serializeDate(data, date) {
+	await serializeNumber(data, date.getTime());
+}
+
+async function serializeError(data, error) {
+	await serializeString(data, error.message);
+	await serializeString(data, error.stack);
+}
+
+async function serializeRegExp(data, regExp) {
+	await serializeString(data, regExp.source);
+	await serializeString(data, regExp.flags);
+}
+
+async function serializeStringObject(data, string) {
+	await serializeString(data, string.valueOf());
+}
+
+async function serializeNumberObject(data, number) {
+	await serializeNumber(data, number.valueOf());
+}
+
+async function serializeBooleanObject(data, boolean) {
+	await serializeBoolean(data, boolean.valueOf());
+}
+
+async function serializeSymbol(data, symbol) {
+	await serializeString(data, symbol.description);
+}
+
+class Reference {
+	constructor(index, data) {
+		this.index = index;
+		this.data = data;
+	}
+
+	getObject() {
+		return this.data.objects[this.index];
+	}
+}
+
+class ParserData {
+	constructor(consumeData) {
+		this.stream = new ReadStream(consumeData);
+		this.objects = [];
+		this.setters = [];
+	}
+
+	consume(size) {
+		return this.stream.consume(size);
+	}
+
+	getObjectId() {
+		const objectIndex = this.objects.length;
+		this.objects.push(undefined);
+		return objectIndex;
+	}
+
+	resolveObject(objectId, value) {
+		if (testReferenceable(value) && !testReference(value)) {
+			this.objects[objectId] = value;
+		}
+	}
+
+	setObject(functionArguments, setterFunction) {
+		this.setters.push({ functionArguments, setterFunction });
+	}
+
+	executeSetters() {
+		this.setters.forEach(({ functionArguments, setterFunction }) => {
+			const resolvedArguments = functionArguments.map(argument => testReference(argument) ? argument.getObject() : argument);
+			setterFunction(...resolvedArguments);
+		});
+	}
+}
+
+class ReadStream {
+	constructor(consumeData) {
+		this.offset = 0;
+		this.value = new Uint8Array(0);
+		this.consumeData = consumeData;
+	}
+
+	async consume(size) {
+		if (this.offset + size > this.value.length) {
+			const pending = this.value.subarray(this.offset, this.value.length);
+			const value = await this.consumeData();
+			if (pending.length + value.length != this.value.length) {
+				this.value = new Uint8Array(pending.length + value.length);
+			}
+			this.value.set(pending);
+			this.value.set(value, pending.length);
+			this.offset = 0;
+			return this.consume(size);
+		} else {
+			const result = this.value.slice(this.offset, this.offset + size);
+			this.offset += result.length;
+			return result;
+		}
+	}
+}
+
+function getParser() {
+	let parserData, input, setInput, value, previousData, resolvePreviousData;
+	return {
+		async next(input) {
+			return input ? getResult(input) : { value: await value, done: true };
+		},
+		return() {
+			return { done: true };
+		}
+	};
+
+	async function getResult(input) {
+		if (previousData) {
+			await previousData;
+		} else {
+			initParserData().catch(() => { /* ignored */ });
+		}
+		initPreviousData();
+		setInput(input);
+		return { done: false };
+	}
+
+	async function initParserData() {
+		let setValue;
+		value = new Promise(resolve => setValue = resolve);
+		parserData = new ParserData(consumeData);
+		initChunk();
+		const data = await parseValue(parserData);
+		parserData.executeSetters();
+		setValue(data);
+	}
+
+	function initChunk() {
+		input = new Promise(resolve => setInput = resolve);
+	}
+
+	function initPreviousData() {
+		previousData = new Promise(resolve => resolvePreviousData = resolve);
+	}
+
+	async function consumeData() {
+		const data = await input;
+		initChunk();
+		if (resolvePreviousData) {
+			resolvePreviousData();
+		}
+		return data;
+	}
+}
+
+async function parseValue(data) {
+	const array = await data.consume(1);
+	const parserType = array[0];
+	const parse = types[parserType].parse;
+	const valueId = data.getObjectId();
+	const result = await parse(data);
+	if (parserType != TYPE_REFERENCE && testObject(result)) {
+		await parseSymbols(data, result);
+		await parseOwnProperties(data, result);
+	}
+	data.resolveObject(valueId, result);
+	return result;
+}
+
+async function parseSymbols(data, value) {
+	const symbols = await parseArray(data);
+	data.setObject([symbols], symbols => symbols.forEach(([symbol, propertyValue]) => value[symbol] = propertyValue));
+}
+
+async function parseOwnProperties(data, object) {
+	const size = await parseValue(data);
+	if (size) {
+		await parseNextProperty();
+	}
+
+	async function parseNextProperty(indexKey = 0) {
+		const key = await parseString(data);
+		const value = await parseValue(data);
+		data.setObject([value], value => object[key] = value);
+		if (indexKey < size - 1) {
+			await parseNextProperty(indexKey + 1);
+		}
+	}
+}
+
+async function parseCircularReference(data) {
+	const index = await parseValue(data);
+	const result = new Reference(index, data);
+	return result;
+}
+
+function parseObject() {
+	return {};
+}
+
+async function parseArray(data) {
+	const length = await parseValue(data);
+	const array = new Array(length);
+	if (length) {
+		await parseNextSlot();
+	}
+	return array;
+
+	async function parseNextSlot(indexArray = 0) {
+		const value = await parseValue(data);
+		if (!testEmptySlot(value)) {
+			data.setObject([value], value => array[indexArray] = value);
+		}
+		if (indexArray < length - 1) {
+			await parseNextSlot(indexArray + 1);
+		}
+	}
+}
+
+function parseEmptySlot() {
+	return EMPTY_SLOT_VALUE;
+}
+
+async function parseString(data) {
+	const size = await parseValue(data);
+	const array = await data.consume(size);
+	return textDecoder.decode(array);
+}
+
+async function parseFloat64Array(data) {
+	const length = await parseValue(data);
+	const array = await data.consume(length * 8);
+	return new Float64Array(array.buffer);
+}
+
+async function parseFloat32Array(data) {
+	const length = await parseValue(data);
+	const array = await data.consume(length * 4);
+	return new Float32Array(array.buffer);
+}
+
+async function parseUint32Array(data) {
+	const length = await parseValue(data);
+	const array = await data.consume(length * 4);
+	return new Uint32Array(array.buffer);
+}
+
+async function parseInt32Array(data) {
+	const length = await parseValue(data);
+	const array = await data.consume(length * 4);
+	return new Int32Array(array.buffer);
+}
+
+async function parseUint16Array(data) {
+	const length = await parseValue(data);
+	const array = await data.consume(length * 2);
+	return new Uint16Array(array.buffer);
+}
+
+async function parseInt16Array(data) {
+	const length = await parseValue(data);
+	const array = await data.consume(length * 2);
+	return new Int16Array(array.buffer);
+}
+
+async function parseUint8ClampedArray(data) {
+	const length = await parseValue(data);
+	const array = await data.consume(length);
+	return new Uint8ClampedArray(array.buffer);
+}
+
+async function parseUint8Array(data) {
+	const length = await parseValue(data);
+	const array = await data.consume(length);
+	return array;
+}
+
+async function parseInt8Array(data) {
+	const length = await parseValue(data);
+	const array = await data.consume(length);
+	return new Int8Array(array.buffer);
+}
+
+async function parseArrayBuffer(data) {
+	const length = await parseValue(data);
+	const array = await data.consume(length);
+	return array.buffer;
+}
+
+async function parseNumber(data) {
+	const array = await data.consume(8);
+	return new Float64Array(array.buffer)[0];
+}
+
+async function parseUint32(data) {
+	const array = await data.consume(4);
+	return new Uint32Array(array.buffer)[0];
+}
+
+async function parseInt32(data) {
+	const array = await data.consume(4);
+	return new Int32Array(array.buffer)[0];
+}
+
+async function parseUint16(data) {
+	const array = await data.consume(2);
+	return new Uint16Array(array.buffer)[0];
+}
+
+async function parseInt16(data) {
+	const array = await data.consume(2);
+	return new Int16Array(array.buffer)[0];
+}
+
+async function parseUint8(data) {
+	const array = await data.consume(1);
+	return new Uint8Array(array.buffer)[0];
+}
+
+async function parseInt8(data) {
+	const array = await data.consume(1);
+	return new Int8Array(array.buffer)[0];
+}
+
+function parseUndefined() {
+	return undefined;
+}
+
+function parseNull() {
+	return null;
+}
+
+function parseNaN() {
+	return NaN;
+}
+
+async function parseBoolean(data) {
+	const array = await data.consume(1);
+	return Boolean(array[0]);
+}
+
+async function parseMap(data) {
+	const size = await parseValue(data);
+	const map = new Map();
+	if (size) {
+		await parseNextEntry();
+	}
+	return map;
+
+	async function parseNextEntry(indexKey = 0) {
+		const key = await parseValue(data);
+		const value = await parseValue(data);
+		data.setObject([key, value], (key, value) => map.set(key, value));
+		if (indexKey < size - 1) {
+			await parseNextEntry(indexKey + 1);
+		}
+	}
+}
+
+async function parseSet(data) {
+	const size = await parseValue(data);
+	const set = new Set();
+	if (size) {
+		await parseNextEntry();
+	}
+	return set;
+
+	async function parseNextEntry(indexKey = 0) {
+		const value = await parseValue(data);
+		data.setObject([value], value => set.add(value));
+		if (indexKey < size - 1) {
+			await parseNextEntry(indexKey + 1);
+		}
+	}
+}
+
+async function parseDate(data) {
+	const milliseconds = await parseNumber(data);
+	return new Date(milliseconds);
+}
+
+async function parseError(data) {
+	const message = await parseString(data);
+	const stack = await parseString(data);
+	const error = new Error(message);
+	error.stack = stack;
+	return error;
+}
+
+async function parseRegExp(data) {
+	const source = await parseString(data);
+	const flags = await parseString(data);
+	return new RegExp(source, flags);
+}
+
+async function parseStringObject(data) {
+	return new String(await parseString(data));
+}
+
+async function parseNumberObject(data) {
+	return new Number(await parseNumber(data));
+}
+
+async function parseBooleanObject(data) {
+	return new Boolean(await parseBoolean(data));
+}
+
+async function parseSymbol(data) {
+	const description = await parseString(data);
+	return Symbol(description);
+}
+
+function testCircularReference(value, data) {
+	return testObject(value) && data.objects.includes(value);
+}
+
+function testReference(value) {
+	return value instanceof Reference;
+}
+
+function testObject(value) {
+	return value === Object(value);
+}
+
+function testArray(value) {
+	return typeof value.length == "number";
+}
+
+function testEmptySlot(value) {
+	return value === EMPTY_SLOT_VALUE;
+}
+
+function testString(value) {
+	return typeof value == "string";
+}
+
+function testFloat64Array(value) {
+	return value instanceof Float64Array;
+}
+
+function testUint32Array(value) {
+	return value instanceof Uint32Array;
+}
+
+function testInt32Array(value) {
+	return value instanceof Int32Array;
+}
+
+function testUint16Array(value) {
+	return value instanceof Uint16Array;
+}
+
+function testFloat32Array(value) {
+	return value instanceof Float32Array;
+}
+
+function testInt16Array(value) {
+	return value instanceof Int16Array;
+}
+
+function testUint8ClampedArray(value) {
+	return value instanceof Uint8ClampedArray;
+}
+
+function testUint8Array(value) {
+	return value instanceof Uint8Array;
+}
+
+function testInt8Array(value) {
+	return value instanceof Int8Array;
+}
+
+function testArrayBuffer(value) {
+	return value instanceof ArrayBuffer;
+}
+
+function testNumber(value) {
+	return typeof value == "number";
+}
+
+function testBigInt(value) {
+	return typeof value == "bigint";
+}
+
+function testUint32(value) {
+	return testInteger(value) && value >= 0 && value <= 4294967295;
+}
+
+function testInt32(value) {
+	return testInteger(value) && value >= -2147483648 && value <= 2147483647;
+}
+
+function testUint16(value) {
+	return testInteger(value) && value >= 0 && value <= 65535;
+}
+
+function testInt16(value) {
+	return testInteger(value) && value >= -32768 && value <= 32767;
+}
+
+function testUint8(value) {
+	return testInteger(value) && value >= 0 && value <= 255;
+}
+
+function testInt8(value) {
+	return testInteger(value) && value >= -128 && value <= 127;
+}
+
+function testInteger(value) {
+	return testNumber(value) && Number.isInteger(value);
+}
+
+function testUndefined(value) {
+	return value === undefined;
+}
+
+function testNull(value) {
+	return value === null;
+}
+
+function testNaN(value) {
+	return Number.isNaN(value);
+}
+
+function testBoolean(value) {
+	return typeof value == "boolean";
+}
+
+function testMap(value) {
+	return value instanceof Map;
+}
+
+function testSet(value) {
+	return value instanceof Set;
+}
+
+function testDate(value) {
+	return value instanceof Date;
+}
+
+function testError(value) {
+	return value instanceof Error;
+}
+
+function testRegExp(value) {
+	return value instanceof RegExp;
+}
+
+function testStringObject(value) {
+	return value instanceof String;
+}
+
+function testNumberObject(value) {
+	return value instanceof Number;
+}
+
+function testBooleanObject(value) {
+	return value instanceof Boolean;
+}
+
+function testSymbol(value) {
+	return typeof value == "symbol";
+}
+
+function testReferenceable(value) {
+	return testObject(value) || testSymbol(value);
+}

+ 140 - 9
src/ui/bg/ui-editor.js

@@ -21,10 +21,12 @@
  *   Source.
  */
 
-/* global browser, document, matchMedia, addEventListener, navigator, setInterval */
+/* global browser, document, matchMedia, addEventListener, navigator, prompt, URL, MouseEvent, Blob, setInterval */
 
 import * as download from "../../core/common/download.js";
 import { onError } from "./../common/content-error.js";
+import * as zip from "./../../../lib/single-file-zip.js";
+import * as yabson from "./../../lib/yabson/yabson.js";
 
 const FOREGROUND_SAVE = /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent) && !/Vivaldi/.test(navigator.userAgent) && !/OPR/.test(navigator.userAgent);
 
@@ -53,7 +55,7 @@ const savePageButton = document.querySelector(".save-page-button");
 const printPageButton = document.querySelector(".print-page-button");
 const lastButton = toolbarElement.querySelector(".buttons:last-of-type [type=button]:last-of-type");
 
-let tabData, tabDataContents = [];
+let tabData, tabDataContents = [], downloadParser;
 
 addYellowNoteButton.title = browser.i18n.getMessage("editorAddYellowNote");
 addPinkNoteButton.title = browser.i18n.getMessage("editorAddPinkNote");
@@ -266,13 +268,37 @@ addEventListener("resize", viewportSizeChange);
 addEventListener("message", event => {
 	const message = JSON.parse(event.data);
 	if (message.method == "setContent") {
-		const pageData = {
-			content: message.content,
-			filename: tabData.filename
-		};
 		tabData.options.openEditor = false;
 		tabData.options.openSavedPage = false;
-		download.downloadPage(pageData, tabData.options);
+		if (message.compressContent) {	
+			tabData.options.compressContent = true;
+			if (tabData.selfExtractingArchive !== undefined) {
+				tabData.options.selfExtractingArchive = tabData.selfExtractingArchive;
+			}
+			if (tabData.extractDataFromPageTags !== undefined) {
+				tabData.options.extractDataFromPage = tabData.extractDataFromPageTags;
+			}
+			if (tabData.options.insertTextBody !== undefined) {
+				tabData.options.insertTextBody = tabData.insertTextBody;
+			}
+			getContentPageData(tabData.content, message.content, { password: tabData.options.password })
+				.then(pageData => {
+					pageData.content = message.content;
+					pageData.title = message.title;
+					pageData.doctype = message.doctype;
+					pageData.viewport = message.viewport;
+					pageData.url = message.url;
+					pageData.filename = tabData.filename;
+					download.downloadPage(pageData, tabData.options);
+				});
+		} else {
+			const pageData = {
+				content: message.content,
+				filename: tabData.filename
+			};
+			tabData.options.compressContent = false;
+			download.downloadPage(pageData, tabData.options);
+		}
 	}
 	if (message.method == "onUpdate") {
 		tabData.docSaved = message.saved;
@@ -334,10 +360,9 @@ browser.runtime.onMessage.addListener(message => {
 			tabData = JSON.parse(tabDataContents.join(""));
 			tabData.options = message.options;
 			tabDataContents = [];
-			editorElement.contentWindow.postMessage(JSON.stringify({ method: "init", content: tabData.content }), "*");
+			editorElement.contentWindow.postMessage(JSON.stringify({ method: "init", content: tabData.content, password: tabData.options.password, compressContent: message.compressContent }), "*");
 			editorElement.contentWindow.focus();
 			setInterval(() => browser.runtime.sendMessage({ method: "editor.ping" }), 15000);
-			delete tabData.content;
 		}
 		return Promise.resolve({});
 	}
@@ -347,6 +372,9 @@ browser.runtime.onMessage.addListener(message => {
 	if (message.method == "content.error") {
 		onError(message.error, message.link);
 	}
+	if (message.method == "content.download") {
+		return downloadContent(message);
+	}
 });
 
 addEventListener("load", () => {
@@ -360,6 +388,24 @@ addEventListener("beforeunload", event => {
 	}
 });
 
+async function downloadContent(message) {
+	if (!downloadParser) {
+		downloadParser = yabson.getParser();
+	}
+	const result = await downloadParser.next(message.data);
+	if (result.done) {
+		downloadParser = null;
+		const link = document.createElement("a");
+		link.download = result.value.filename;
+		link.href = URL.createObjectURL(new Blob([result.value.content]), "text/html");
+		link.dispatchEvent(new MouseEvent("click"));
+		URL.revokeObjectURL(link.href);
+		return browser.runtime.sendMessage({ method: "downloads.end", taskId: result.value.taskId }).then(() => ({}));
+	} else {
+		return Promise.resolve({});
+	}
+}
+
 async function refreshOptions(profileName) {
 	const profiles = await browser.runtime.sendMessage({ method: "config.getProfiles" });
 	tabData.options = profiles[profileName];
@@ -460,4 +506,89 @@ function getPosition(event) {
 	} else {
 		return event;
 	}
+}
+
+async function getContentPageData(zipContent, page, options) {
+	zip.configure({ workerScripts: { inflate: ["/lib/single-file-z-worker.js"] } });
+	const zipReader = new zip.ZipReader(new zip.Uint8ArrayReader(new Uint8Array(zipContent)));
+	const entries = await zipReader.getEntries();
+	const resources = [];
+	await Promise.all(entries.map(async entry => {
+		let data;
+		if (!options.password && entry.bitFlag.encrypted) {
+			options.password = prompt("Please enter the password to view the page");
+		}
+		if (entry.filename.match(/^([0-9_]+\/)?index.html$/)) {
+			data = page;
+		} else {
+			if (entry.filename.endsWith(".html")) {
+				data = await entry.getData(new zip.TextWriter(), options);
+			} else {
+				data = await entry.getData(new zip.Uint8ArrayWriter(), options);
+			}
+		}
+		const extensionMatch = entry.filename.match(/\.([^.]+)/);
+		resources.push({
+			filename: entry.filename.match(/^([0-9_]+\/)?(.*)$/)[2],
+			extension: extensionMatch && extensionMatch[1],
+			content: data,
+			url: entry.comment
+		});
+	}));
+	return getPageData(resources);
+}
+
+function getPageData(resources) {
+	const pageData = JSON.parse(JSON.stringify(EMPTY_PAGE_DATA));
+	for (const resource of resources) {
+		const resourcePageData = getPageDataResource(resource, "", pageData);
+		const filename = resource.filename.substring(resourcePageData.prefixPath.length);
+		resource.name = filename;
+		if (filename.startsWith("images/")) {
+			resourcePageData.resources.images.push(resource);
+		}
+		if (filename.startsWith("fonts/")) {
+			resourcePageData.resources.fonts.push(resource);
+		}
+		if (filename.startsWith("scripts/")) {
+			resourcePageData.resources.scripts.push(resource);
+		}
+		if (filename.endsWith(".css")) {
+			resourcePageData.resources.stylesheets.push(resource);
+		}
+		if (filename.endsWith(".html")) {
+			resourcePageData.content = resource.content;
+		}
+	}
+	return pageData;
+}
+
+const EMPTY_PAGE_DATA = {
+	name: "",
+	prefixPath: "",
+	resources: {
+		stylesheets: [],
+		images: [],
+		fonts: [],
+		scripts: [],
+		frames: []
+	}
+};
+
+function getPageDataResource(resource, prefixPath = "", pageData) {
+	const filename = resource.filename.substring(prefixPath.length);
+	resource.name = filename;
+	if (filename.startsWith("frames/")) {
+		const framesIndex = Number(filename.match(/^frames\/(\d+)\//)[1]);
+		const framePath = "frames/" + framesIndex + "/";
+		if (!pageData.resources.frames[framesIndex]) {
+			pageData.resources.frames[framesIndex] = Object.assign(JSON.parse(JSON.stringify(EMPTY_PAGE_DATA)), {
+				name: framePath,
+				prefixPath: prefixPath + framePath
+			});
+		}
+		return getPageDataResource(resource, prefixPath + framePath, pageData.resources.frames[framesIndex]);
+	} else {
+		return pageData;
+	}
 }

+ 40 - 4
src/ui/bg/ui-options.js

@@ -91,6 +91,7 @@ const githubRepositoryLabel = document.getElementById("githubRepositoryLabel");
 const githubBranchLabel = document.getElementById("githubBranchLabel");
 const saveWithCompanionLabel = document.getElementById("saveWithCompanionLabel");
 const compressHTMLLabel = document.getElementById("compressHTMLLabel");
+const insertTextBodyLabel = document.getElementById("insertTextBodyLabel");
 const compressCSSLabel = document.getElementById("compressCSSLabel");
 const moveStylesInHeadLabel = document.getElementById("moveStylesInHeadLabel");
 const loadDeferredImagesLabel = document.getElementById("loadDeferredImagesLabel");
@@ -136,12 +137,20 @@ const passReferrerOnErrorLabel = document.getElementById("passReferrerOnErrorLab
 const replaceBookmarkURLLabel = document.getElementById("replaceBookmarkURLLabel");
 const allowedBookmarkFoldersLabel = document.getElementById("allowedBookmarkFoldersLabel");
 const ignoredBookmarkFoldersLabel = document.getElementById("ignoredBookmarkFoldersLabel");
+const createRootDirectoryLabel = document.getElementById("createRootDirectoryLabel");
+const passwordLabel = document.getElementById("passwordLabel");
 const titleLabel = document.getElementById("titleLabel");
 const userInterfaceLabel = document.getElementById("userInterfaceLabel");
 const filenameLabel = document.getElementById("filenameLabel");
 const htmlContentLabel = document.getElementById("htmlContentLabel");
-const imagesLabel = document.getElementById("imagesLabel");
+const fileFormatLabel = document.getElementById("fileFormatLabel");
+const fileFormatSelectHTMLLabel = document.getElementById("fileFormatSelectHTMLLabel");
+const fileFormatSelectSelfExtractingUniversalLabel = document.getElementById("fileFormatSelectSelfExtractingUniversalLabel");
+const fileFormatSelectSelfExtractingLabel = document.getElementById("fileFormatSelectSelfExtractingLabel");
+const fileFormatSelectZIPLabel = document.getElementById("fileFormatSelectZIPLabel");
+const fileFormatSelectLabel = document.getElementById("fileFormatSelectLabel");
 const infobarLabel = document.getElementById("infobarLabel");
+const imagesLabel = document.getElementById("imagesLabel");
 const stylesheetsLabel = document.getElementById("stylesheetsLabel");
 const fontsLabel = document.getElementById("fontsLabel");
 const networkLabel = document.getElementById("networkLabel");
@@ -166,11 +175,11 @@ const autoOpenEditorLabel = document.getElementById("autoOpenEditorLabel");
 const defaultEditorModeLabel = document.getElementById("defaultEditorModeLabel");
 const applySystemThemeLabel = document.getElementById("applySystemThemeLabel");
 const warnUnsavedPageLabel = document.getElementById("warnUnsavedPageLabel");
+const displayInfobarInEditorLabel = document.getElementById("displayInfobarInEditorLabel");
 const infobarTemplateLabel = document.getElementById("infobarTemplateLabel");
 const blockMixedContentLabel = document.getElementById("blockMixedContentLabel");
 const saveOriginalURLsLabel = document.getElementById("saveOriginalURLsLabel");
 const includeInfobarLabel = document.getElementById("includeInfobarLabel");
-const displayInfobarInEditorLabel = document.getElementById("displayInfobarInEditorLabel");
 const removeInfobarSavedDateLabel = document.getElementById("removeInfobarSavedDateLabel");
 const miscLabel = document.getElementById("miscLabel");
 const helpLabel = document.getElementById("helpLabel");
@@ -218,6 +227,7 @@ const githubBranchInput = document.getElementById("githubBranchInput");
 const saveWithCompanionInput = document.getElementById("saveWithCompanionInput");
 const saveToFilesystemInput = document.getElementById("saveToFilesystemInput");
 const compressHTMLInput = document.getElementById("compressHTMLInput");
+const insertTextBodyInput = document.getElementById("insertTextBodyInput");
 const compressCSSInput = document.getElementById("compressCSSInput");
 const moveStylesInHeadInput = document.getElementById("moveStylesInHeadInput");
 const loadDeferredImagesInput = document.getElementById("loadDeferredImagesInput");
@@ -258,6 +268,9 @@ const passReferrerOnErrorInput = document.getElementById("passReferrerOnErrorInp
 const replaceBookmarkURLInput = document.getElementById("replaceBookmarkURLInput");
 const allowedBookmarkFoldersInput = document.getElementById("allowedBookmarkFoldersInput");
 const ignoredBookmarkFoldersInput = document.getElementById("ignoredBookmarkFoldersInput");
+const fileFormatSelectInput = document.getElementById("fileFormatSelectInput");
+const createRootDirectoryInput = document.getElementById("createRootDirectoryInput");
+const passwordInput = document.getElementById("passwordInput");
 const groupDuplicateImagesInput = document.getElementById("groupDuplicateImagesInput");
 const infobarTemplateInput = document.getElementById("infobarTemplateInput");
 const blockMixedContentInput = document.getElementById("blockMixedContentInput");
@@ -596,6 +609,7 @@ githubRepositoryLabel.textContent = browser.i18n.getMessage("optionGitHubReposit
 githubBranchLabel.textContent = browser.i18n.getMessage("optionGitHubBranch");
 saveWithCompanionLabel.textContent = browser.i18n.getMessage("optionSaveWithCompanion");
 compressHTMLLabel.textContent = browser.i18n.getMessage("optionCompressHTML");
+insertTextBodyLabel.textContent = browser.i18n.getMessage("optionInsertTextBody");
 compressCSSLabel.textContent = browser.i18n.getMessage("optionCompressCSS");
 moveStylesInHeadLabel.textContent = browser.i18n.getMessage("optionMoveStylesInHead");
 loadDeferredImagesLabel.textContent = browser.i18n.getMessage("optionLoadDeferredImages");
@@ -641,11 +655,19 @@ passReferrerOnErrorLabel.textContent = browser.i18n.getMessage("optionPassReferr
 replaceBookmarkURLLabel.textContent = browser.i18n.getMessage("optionReplaceBookmarkURL");
 allowedBookmarkFoldersLabel.textContent = browser.i18n.getMessage("optionAllowedBookmarkFolders");
 ignoredBookmarkFoldersLabel.textContent = browser.i18n.getMessage("optionIgnoredBookmarkFolders");
+createRootDirectoryLabel.textContent = browser.i18n.getMessage("optionCreateRootDirectory");
+passwordLabel.textContent = browser.i18n.getMessage("optionPassword");
 groupDuplicateImagesLabel.textContent = browser.i18n.getMessage("optionGroupDuplicateImages");
 titleLabel.textContent = browser.i18n.getMessage("optionsTitle");
 userInterfaceLabel.textContent = browser.i18n.getMessage("optionsUserInterfaceSubTitle");
 filenameLabel.textContent = browser.i18n.getMessage("optionsFileNameSubTitle");
 htmlContentLabel.textContent = browser.i18n.getMessage("optionsHTMLContentSubTitle");
+fileFormatLabel.textContent = browser.i18n.getMessage("optionsFileFormatSubTitle");
+fileFormatSelectHTMLLabel.textContent = browser.i18n.getMessage("optionFileFormatSelectHTML");
+fileFormatSelectSelfExtractingUniversalLabel.textContent = browser.i18n.getMessage("optionFileFormatSelectSelfExtractingUniversal");
+fileFormatSelectSelfExtractingLabel.textContent = browser.i18n.getMessage("optionFileFormatSelectSelfExtracting");
+fileFormatSelectZIPLabel.textContent = browser.i18n.getMessage("optionFileFormatSelectZIP");
+fileFormatSelectLabel.textContent = browser.i18n.getMessage("optionFileFormat");
 infobarLabel.textContent = browser.i18n.getMessage("optionsInfobarSubTitle");
 imagesLabel.textContent = browser.i18n.getMessage("optionsImagesSubTitle");
 stylesheetsLabel.textContent = browser.i18n.getMessage("optionsStylesheetsSubTitle");
@@ -931,10 +953,18 @@ async function refresh(profileName) {
 	passReferrerOnErrorInput.checked = profileOptions.passReferrerOnError;
 	replaceBookmarkURLInput.checked = profileOptions.replaceBookmarkURL;
 	replaceBookmarkURLInput.disabled = !profileOptions.saveCreatedBookmarks;
-	allowedBookmarkFoldersInput.value = profileOptions.allowedBookmarkFolders.map(folder => folder.replace(/,/g, "\\,")).join(","); // eslint-disable-line no-useless-escape
+	allowedBookmarkFoldersInput.value = profileOptions.allowedBookmarkFolders.map(folder => folder.replace(/,/g, "\\,")).join(",");
 	allowedBookmarkFoldersInput.disabled = !profileOptions.saveCreatedBookmarks;
-	ignoredBookmarkFoldersInput.value = profileOptions.ignoredBookmarkFolders.map(folder => folder.replace(/,/g, "\\,")).join(","); // eslint-disable-line no-useless-escape
+	ignoredBookmarkFoldersInput.value = profileOptions.ignoredBookmarkFolders.map(folder => folder.replace(/,/g, "\\,")).join(",");
 	ignoredBookmarkFoldersInput.disabled = !profileOptions.saveCreatedBookmarks;
+	fileFormatSelectInput.value = profileOptions.compressContent ? profileOptions.selfExtractingArchive ? profileOptions.extractDataFromPage ?
+		"self-extracting-zip-universal" : "self-extracting-zip" : "zip" : "html";
+	createRootDirectoryInput.checked = profileOptions.createRootDirectory;
+	createRootDirectoryInput.disabled = !profileOptions.compressContent;
+	passwordInput.value = profileOptions.password;
+	passwordInput.disabled = !profileOptions.compressContent;
+	insertTextBodyInput.checked = profileOptions.insertTextBody;
+	insertTextBodyInput.disabled = !profileOptions.compressContent || (!profileOptions.selfExtractingArchive && !profileOptions.extractDataFromPage);
 	infobarTemplateInput.value = profileOptions.infobarTemplate;
 	blockMixedContentInput.checked = profileOptions.blockMixedContent;
 	saveOriginalURLsInput.checked = profileOptions.saveOriginalURLs;
@@ -1001,6 +1031,7 @@ async function update() {
 			githubBranch: githubBranchInput.value,
 			saveWithCompanion: saveWithCompanionInput.checked,
 			compressHTML: compressHTMLInput.checked,
+			insertTextBody: insertTextBodyInput.checked,
 			compressCSS: compressCSSInput.checked,
 			moveStylesInHead: moveStylesInHeadInput.checked,
 			loadDeferredImages: loadDeferredImagesInput.checked,
@@ -1040,6 +1071,11 @@ async function update() {
 			replaceBookmarkURL: replaceBookmarkURLInput.checked,
 			allowedBookmarkFolders: allowedBookmarkFoldersInput.value.replace(/([^\\]),/g, "$1 ,").split(/[^\\],/).map(folder => folder.replace(/\\,/g, ",")),
 			ignoredBookmarkFolders: ignoredBookmarkFoldersInput.value.replace(/([^\\]),/g, "$1 ,").split(/[^\\],/).map(folder => folder.replace(/\\,/g, ",")),
+			compressContent: fileFormatSelectInput.value.includes("zip"),
+			createRootDirectory: createRootDirectoryInput.checked,
+			selfExtractingArchive: fileFormatSelectInput.value.includes("self-extracting"),
+			extractDataFromPage: fileFormatSelectInput.value == "self-extracting-zip-universal",
+			password: passwordInput.value,
 			groupDuplicateImages: groupDuplicateImagesInput.checked,
 			infobarTemplate: infobarTemplateInput.value,
 			blockMixedContent: blockMixedContentInput.checked,

+ 229 - 65
src/ui/content/content-ui-editor-web.js

@@ -21,7 +21,11 @@
  *   Source.
  */
 
-/* global globalThis, window, document, fetch, DOMParser, getComputedStyle, setTimeout, clearTimeout, NodeFilter, Readability, isProbablyReaderable, matchMedia, TextDecoder, Node, URL, MouseEvent, Blob */
+/* global globalThis, window, document, fetch, DOMParser, getComputedStyle, setTimeout, clearTimeout, NodeFilter, Readability, isProbablyReaderable, matchMedia, TextDecoder, Node, URL, MouseEvent, Blob, prompt, MutationObserver, FileReader, Worker */
+
+import * as zip from "single-file-core/vendor/zip/zip.js";
+import { extract } from "single-file-core/processors/compression/compression-extract.js";
+import { display } from "single-file-core/processors/compression/compression-display.js";
 
 (globalThis => {
 
@@ -974,8 +978,9 @@ pre code {
 
 	let NOTES_WEB_STYLESHEET, MASK_WEB_STYLESHEET, HIGHLIGHTS_WEB_STYLESHEET;
 	let selectedNote, anchorElement, maskNoteElement, maskPageElement, highlightSelectionMode, removeHighlightMode, resizingNoteMode, movingNoteMode, highlightColor, collapseNoteTimeout, cuttingOuterMode, cuttingMode, cuttingTouchTarget, cuttingPath, cuttingPathIndex, previousContent;
-	let removedElements = [], removedElementIndex = 0, initScriptContent, includeInfobar;
+	let removedElements = [], removedElementIndex = 0, initScriptContent, pageResources, pageUrl, pageCompressContent, includeInfobar;
 
+	globalThis.zip = zip;
 	window.onmessage = async event => {
 		const message = JSON.parse(event.data);
 		if (message.method == "init") {
@@ -1069,16 +1074,33 @@ pre code {
 			if (initScriptContent) {
 				content = content.replace(/<script data-template-shadow-root src.*?<\/script>/g, initScriptContent);
 			}
-			if (message.foregroundSave) {
-				if (message.filename && message.filename.length) {
-					const link = document.createElement("a");
-					link.download = message.filename;
-					link.href = URL.createObjectURL(new Blob([content], { type: "text/html" }));
-					link.dispatchEvent(new MouseEvent("click"));
-				}
-				return new Promise(resolve => setTimeout(resolve, 1));
+			debugger;
+			if (pageCompressContent) {
+				const viewport = document.head.querySelector("meta[name=viewport]");
+				window.parent.postMessage(JSON.stringify({
+					method: "setContent",
+					content,
+					title: document.title,
+					doctype: singlefile.helper.getDoctypeString(document),
+					url: pageUrl,
+					viewport: viewport ? viewport.content : null,
+					compressContent: true
+				}), "*");
 			} else {
-				window.parent.postMessage(JSON.stringify({ method: "setContent", content }), "*");
+				if (message.foregroundSave) {
+					if (message.filename && message.filename.length) {
+						const link = document.createElement("a");
+						link.download = message.filename;
+						link.href = URL.createObjectURL(new Blob([content], { type: "text/html" }));
+						link.dispatchEvent(new MouseEvent("click"));
+					}
+					return new Promise(resolve => setTimeout(resolve, 1));
+				} else {
+					window.parent.postMessage(JSON.stringify({
+						method: "setContent",
+						content
+					}), "*");
+				}
 			}
 		}
 		if (message.method == "printPage") {
@@ -1095,67 +1117,150 @@ pre code {
 			const file = event.dataTransfer.files[0];
 			event.preventDefault();
 			const content = new TextDecoder().decode(await file.arrayBuffer());
-			await init({ content }, { filename: file.name });
+			const compressContent = /<html[^>]* data-sfz[^>]*>/i.test(content);
+			if (compressContent) {
+				await init({ content: file, compressContent }, { filename: file.name });
+			} else {
+				await init({ content }, { filename: file.name });
+			}
 		}
 	};
 
-	async function init({ content }, { filename, reset } = {}) {
+	async function init({ content, password, compressContent }, { filename, reset } = {}) {
 		await initConstants();
-		const initScriptContentMatch = content.match(/<script data-template-shadow-root.*<\/script>/);
-		if (initScriptContentMatch && initScriptContentMatch[0]) {
-			initScriptContent = initScriptContentMatch[0];
-		}
-		content = content.replace(/<script data-template-shadow-root.*<\/script>/g, "<script data-template-shadow-root src=/lib/single-file-extension-editor-init.js></script>");
-		const contentDocument = (new DOMParser()).parseFromString(content, "text/html");
-		if (detectSavedPage(contentDocument)) {
-			if (contentDocument.doctype) {
-				if (document.doctype) {
-					document.replaceChild(contentDocument.doctype, document.doctype);
-				} else {
-					document.insertBefore(contentDocument.doctype, document.documentElement);
+		if (compressContent) {
+			const zipOptions = {
+				workerScripts: { inflate: ["/lib/single-file-z-worker.js"] }
+			};
+			try {
+				const worker = new Worker(zipOptions.workerScripts);
+				worker.terminate();
+			} catch (error) {
+				delete zipOptions.workerScripts;
+			}
+			const { docContent, origDocContent, resources, url } = await extract(content, {
+				password,
+				prompt,
+				shadowRootScriptURL: new URL("/lib/single-file-extension-editor-init.js", document.baseURI).href,
+				zipOptions
+			});
+			pageResources = resources;
+			pageUrl = url;
+			pageCompressContent = true;
+			const contentDocument = (new DOMParser()).parseFromString(docContent, "text/html");
+			if (detectSavedPage(contentDocument)) {
+				await display(document, docContent, { disableFramePointerEvents: true });
+				const infobarElement = document.querySelector(singlefile.helper.INFOBAR_TAGNAME);
+				if (infobarElement) {
+					infobarElement.remove();
+				}
+				await initPage();
+				let icon;
+				const origContentDocument = (new DOMParser()).parseFromString(origDocContent, "text/html");
+				const iconElement = origContentDocument.querySelector("link[rel*=icon]");
+				if (iconElement) {
+					const iconResource = resources.find(resource => resource.filename == iconElement.getAttribute("href"));
+					if (iconResource && iconResource.blob) {
+						const reader = new FileReader();
+						reader.readAsDataURL(iconResource.blob);
+						icon = await new Promise((resolve, reject) => {
+							reader.addEventListener("load", () => resolve(reader.result), false);
+							reader.addEventListener("error", reject, false);
+						});
+					} else {
+						icon = iconElement.href;
+					}
 				}
-			} else if (document.doctype) {
-				document.doctype.remove();
+				window.parent.postMessage(JSON.stringify({
+					method: "onInit",
+					title: document.title,
+					icon,
+					filename,
+					reset,
+					formatPageEnabled: isProbablyReaderable(document)
+				}), "*");
 			}
-			const infobarElement = contentDocument.querySelector(singlefile.helper.INFOBAR_TAGNAME);
-			if (infobarElement) {
-				infobarElement.remove();
+		} else {
+			const initScriptContentMatch = content.match(/<script data-template-shadow-root.*<\/script>/);
+			if (initScriptContentMatch && initScriptContentMatch[0]) {
+				initScriptContent = initScriptContentMatch[0];
+			}
+			content = content.replace(/<script data-template-shadow-root.*<\/script>/g, "<script data-template-shadow-root src=/lib/single-file-extension-editor-init.js></script>");
+			const contentDocument = (new DOMParser()).parseFromString(content, "text/html");
+			if (detectSavedPage(contentDocument)) {
+				if (contentDocument.doctype) {
+					if (document.doctype) {
+						document.replaceChild(contentDocument.doctype, document.doctype);
+					} else {
+						document.insertBefore(contentDocument.doctype, document.documentElement);
+					}
+				} else if (document.doctype) {
+					document.doctype.remove();
+				}
+				const infobarElement = contentDocument.querySelector(singlefile.helper.INFOBAR_TAGNAME);
+				if (infobarElement) {
+					infobarElement.remove();
+				}
+				contentDocument.querySelectorAll("noscript").forEach(element => {
+					element.setAttribute(DISABLED_NOSCRIPT_ATTRIBUTE_NAME, element.innerHTML);
+					element.textContent = "";
+				});
+				contentDocument.querySelectorAll("iframe").forEach(element => {
+					const pointerEvents = "pointer-events";
+					element.style.setProperty("-sf-" + pointerEvents, element.style.getPropertyValue(pointerEvents), element.style.getPropertyPriority(pointerEvents));
+					element.style.setProperty(pointerEvents, "none", "important");
+				});
+				document.replaceChild(contentDocument.documentElement, document.documentElement);
+				document.querySelectorAll("[data-single-file-note-refs]").forEach(noteRefElement => noteRefElement.dataset.singleFileNoteRefs = noteRefElement.dataset.singleFileNoteRefs.replace(/,/g, " "));
+				deserializeShadowRoots(document);
+				document.querySelectorAll(NOTE_TAGNAME).forEach(containerElement => attachNoteListeners(containerElement, true));
+				document.documentElement.appendChild(getStyleElement(HIGHLIGHTS_WEB_STYLESHEET));
+				maskPageElement = getMaskElement(PAGE_MASK_CLASS, PAGE_MASK_CONTAINER_CLASS);
+				maskNoteElement = getMaskElement(NOTE_MASK_CLASS);
+				document.documentElement.onmousedown = onMouseDown;
+				document.documentElement.onmouseup = document.documentElement.ontouchend = onMouseUp;
+				document.documentElement.onmouseover = onMouseOver;
+				document.documentElement.onmouseout = onMouseOut;
+				document.documentElement.onkeydown = onKeyDown;
+				document.documentElement.ontouchstart = document.documentElement.ontouchmove = onTouchMove;
+				window.onclick = event => event.preventDefault();
+				const iconElement = document.querySelector("link[rel*=icon]");
+				window.parent.postMessage(JSON.stringify({
+					method: "onInit",
+					title: document.title,
+					icon: iconElement && iconElement.href,
+					filename,
+					reset,
+					formatPageEnabled: isProbablyReaderable(document)
+				}), "*");
 			}
-			contentDocument.querySelectorAll("noscript").forEach(element => {
-				element.setAttribute(DISABLED_NOSCRIPT_ATTRIBUTE_NAME, element.innerHTML);
-				element.textContent = "";
-			});
-			contentDocument.querySelectorAll("iframe").forEach(element => {
-				const pointerEvents = "pointer-events";
-				element.style.setProperty("-sf-" + pointerEvents, element.style.getPropertyValue(pointerEvents), element.style.getPropertyPriority(pointerEvents));
-				element.style.setProperty(pointerEvents, "none", "important");
-			});
-			document.replaceChild(contentDocument.documentElement, document.documentElement);
-			document.querySelectorAll("[data-single-file-note-refs]").forEach(noteRefElement => noteRefElement.dataset.singleFileNoteRefs = noteRefElement.dataset.singleFileNoteRefs.replace(/,/g, " "));
-			deserializeShadowRoots(document);
-			document.querySelectorAll(NOTE_TAGNAME).forEach(containerElement => attachNoteListeners(containerElement, true));
-			document.documentElement.appendChild(getStyleElement(HIGHLIGHTS_WEB_STYLESHEET));
-			maskPageElement = getMaskElement(PAGE_MASK_CLASS, PAGE_MASK_CONTAINER_CLASS);
-			maskNoteElement = getMaskElement(NOTE_MASK_CLASS);
-			document.documentElement.onmousedown = onMouseDown;
-			document.documentElement.onmouseup = document.documentElement.ontouchend = onMouseUp;
-			document.documentElement.onmouseover = onMouseOver;
-			document.documentElement.onmouseout = onMouseOut;
-			document.documentElement.onkeydown = onKeyDown;
-			document.documentElement.ontouchstart = document.documentElement.ontouchmove = onTouchMove;
-			window.onclick = event => event.preventDefault();
-			const iconElement = document.querySelector("link[rel*=icon]");
-			window.parent.postMessage(JSON.stringify({
-				method: "onInit",
-				title: document.title,
-				icon: iconElement && iconElement.href,
-				filename,
-				reset,
-				formatPageEnabled: isProbablyReaderable(document)
-			}), "*");
 		}
 	}
 
+	async function initPage() {
+		document.querySelectorAll("iframe").forEach(element => {
+			const pointerEvents = "pointer-events";
+			element.style.setProperty("-sf-" + pointerEvents, element.style.getPropertyValue(pointerEvents), element.style.getPropertyPriority(pointerEvents));
+			element.style.setProperty(pointerEvents, "none", "important");
+		});
+		document.querySelectorAll("[data-single-file-note-refs]").forEach(noteRefElement => noteRefElement.dataset.singleFileNoteRefs = noteRefElement.dataset.singleFileNoteRefs.replace(/,/g, " "));
+		deserializeShadowRoots(document);
+		reflowNotes();
+		await waitResourcesLoad();
+		reflowNotes();
+		document.querySelectorAll(NOTE_TAGNAME).forEach(containerElement => attachNoteListeners(containerElement, true));
+		document.documentElement.appendChild(getStyleElement(HIGHLIGHTS_WEB_STYLESHEET));
+		maskPageElement = getMaskElement(PAGE_MASK_CLASS, PAGE_MASK_CONTAINER_CLASS);
+		maskNoteElement = getMaskElement(NOTE_MASK_CLASS);
+		document.documentElement.onmousedown = onMouseDown;
+		document.documentElement.onmouseup = document.documentElement.ontouchend = onMouseUp;
+		document.documentElement.onmouseover = onMouseOver;
+		document.documentElement.onmouseout = onMouseOut;
+		document.documentElement.onkeydown = onKeyDown;
+		document.documentElement.ontouchstart = document.documentElement.ontouchmove = onTouchMove;
+		window.onclick = event => event.preventDefault();
+	}
+
 	async function initConstants() {
 		[NOTES_WEB_STYLESHEET, MASK_WEB_STYLESHEET, HIGHLIGHTS_WEB_STYLESHEET] = await Promise.all([
 			minifyText(await ((await fetch("../pages/editor-note-web.css")).text())),
@@ -1824,7 +1929,13 @@ pre code {
 	}
 
 	function formatPage(applySystemTheme) {
-		previousContent = getContent(false, []);
+		if (pageCompressContent) {
+			serializeShadowRoots(document);
+			previousContent = document.documentElement.cloneNode(true);
+			deserializeShadowRoots(document);
+		} else {
+			previousContent = getContent(false, []);
+		}
 		const shadowRoots = {};
 		const classesToPreserve = ["single-file-highlight", "single-file-highlight-yellow", "single-file-highlight-green", "single-file-highlight-pink", "single-file-highlight-blue"];
 		document.querySelectorAll(NOTE_TAGNAME).forEach(containerElement => {
@@ -1888,7 +1999,13 @@ pre code {
 	async function cancelFormatPage() {
 		if (previousContent) {
 			const contentEditable = document.body.contentEditable;
-			await init({ content: previousContent }, { reset: true });
+			if (pageCompressContent) {
+				document.replaceChild(previousContent, document.documentElement);
+				deserializeShadowRoots(document);
+				await initPage();
+			} else {
+				await init({ content: previousContent }, { reset: true });
+			}
 			document.body.contentEditable = contentEditable;
 			onUpdate(false);
 			previousContent = null;
@@ -1935,13 +2052,56 @@ pre code {
 			doc.body.appendChild(element);
 			element.textContent = resource.content;
 		});
-		return singlefile.helper.serialize(doc, compressHTML) + "<script " + SCRIPT_TEMPLATE_SHADOW_ROOT + ">" + getEmbedScript() + "</script>";
+		if (pageCompressContent) {
+			const pageFilename = pageResources
+				.filter(resource => resource.filename.endsWith("index.html"))
+				.sort((resourceLeft, resourceRight) => resourceLeft.filename.length - resourceRight.filename.length)[0].filename;
+			const resources = pageResources.filter(resource => resource.parentResources.includes(pageFilename));
+			doc.querySelectorAll("[src]").forEach(element => resources.forEach(resource => {
+				if (element.src == resource.content) {
+					element.src = resource.name;
+				}
+			}));
+			let content = singlefile.helper.serialize(doc, compressHTML);
+			const REGEXP_ESCAPE = /([{}()^$&.*?/+|[\\\\]|\]|-)/g;
+			resources.forEach(resource => {
+				const searchRegExp = new RegExp(resource.content.replace(REGEXP_ESCAPE, "\\$1"), "g");
+				const position = content.search(searchRegExp);
+				if (position != -1) {
+					content = content.replace(searchRegExp, resource.name);
+				}
+			});
+			return content + "<script " + SCRIPT_TEMPLATE_SHADOW_ROOT + ">" + getEmbedScript() + "</script>";
+		} else {
+			return singlefile.helper.serialize(doc, compressHTML) + "<script " + SCRIPT_TEMPLATE_SHADOW_ROOT + ">" + getEmbedScript() + "</script>";
+		}
 	}
 
 	function onUpdate(saved) {
 		window.parent.postMessage(JSON.stringify({ "method": "onUpdate", saved }), "*");
 	}
 
+	function waitResourcesLoad() {
+		return new Promise(resolve => {
+			let counterMutations = 0;
+			const done = () => {
+				observer.disconnect();
+				resolve();
+			};
+			let timeoutInit = setTimeout(done, 100);
+			const observer = new MutationObserver(() => {
+				if (counterMutations < 20) {
+					counterMutations++;
+					clearTimeout(timeoutInit);
+					timeoutInit = setTimeout(done, 100);
+				} else {
+					done();
+				}
+			});
+			observer.observe(document, { subtree: true, childList: true, attributes: true });
+		});
+	}
+
 	function reflowNotes() {
 		document.querySelectorAll(NOTE_TAGNAME).forEach(containerElement => {
 			const noteElement = containerElement.shadowRoot.querySelector("." + NOTE_CLASS);
@@ -2086,6 +2246,7 @@ pre code {
 			const getPosition = ${minifyText(getPosition.toString())};
 			const onMouseUp = ${minifyText(onMouseUp.toString())};
 			const getShadowRoot = ${minifyText(getShadowRoot.toString())};
+			const waitResourcesLoad = ${minifyText(waitResourcesLoad.toString())};
 			const maskNoteElement = getMaskElement(${JSON.stringify(NOTE_MASK_CLASS)});
 			const maskPageElement = getMaskElement(${JSON.stringify(PAGE_MASK_CLASS)}, ${JSON.stringify(PAGE_MASK_CONTAINER_CLASS)});
 			let selectedNote, highlightSelectionMode, removeHighlightMode, resizingNoteMode, movingNoteMode, collapseNoteTimeout, cuttingMode, cuttingOuterMode;
@@ -2095,6 +2256,9 @@ pre code {
 			processNode(document);
 			reflowNotes();
 			document.querySelectorAll(${JSON.stringify(NOTE_TAGNAME)}).forEach(noteElement => attachNoteListeners(noteElement));
+			if (document.documentElement.dataset.sfz !== undefined) {
+				waitResourcesLoad().then(reflowNotes);
+			}
 		})()`);
 	}
 

+ 50 - 8
src/ui/pages/help.html

@@ -113,10 +113,10 @@
 						<p class="notice">It is recommended to <u>check</u> this option</p>
 					</li>
 					<li data-options-label="openSavedPageLabel" id="openSavedPageOption"> <span class="option">Option:
-							open saved pages in a new
-							tab</span>
+							open saved pages in a new tab</span>
 						<p>Check this option to display the saved page in a new tab. This option can be useful, for
-							example, in order to check the page is properly saved.</p>
+							example, in order to check the page is properly saved. This option does not work with
+							archive formats based on ZIP files.</p>
 					</li>
 					<li data-options-label="autoCloseLabel"> <span class="option">Option: auto-close the tab after the
 							page is saved</span>
@@ -178,6 +178,47 @@
 						</p>
 					</li>
 				</ul>
+				<p>File format</p>
+				<ul>
+					<li data-options-label="fileFormatSelectLabel">
+						<span class="option">Option: format</span>
+						<p>Select the ouput format of the saved file:
+						</p>
+						<ul>
+							<li><code>"HTML"</code>: HTML file (default format)</li>
+							<li><code>"self-extracting ZIP (universal)"</code>: self-extracting ZIP file that can be
+								opened on any platform. This format produces files smaller than the "HTML" format but
+								requires JavaScript to be enabled to open the file.
+							</li>
+							<li><code>"self-extracting ZIP"</code>: self-extracting ZIP file that can be
+								opened on any platform from HTTP but not from the filesystem in some browsers (e.g.
+								in browsers based on Chromium or WebKit). This format produces files smaller (approx.
+								1%) than the "self-extracting ZIP (universal)" format.</li>
+							<li><code>"ZIP"</code>: ZIP file</li>
+						</ul>
+					</li>
+					<li data-options-label="passwordLabel">
+						<span class="option">Option: password</span>
+						<p>Type a password to encrypt the ZIP file with AES-256 (compatible with Winzip). Be careful, if
+							you lose the password, it will be impossible to open the file. Enabling this option
+							increases the CPU consumption and the time needed to save or read a page.</p>
+						</p>
+					</li>
+					<li data-options-label="createRootDirectoryLabel">
+						<span class="option">Option: create a root directory</span>
+						<p>Check this option to create a root directory in the ZIP file. The directory name consists of
+							a timestamp and the identifier of the tab where the saved page is displayed.
+						</p>
+					</li>
+					<li data-options-label="insertTextBodyLabel">
+						<span class="option">Option: make text searchable</span>
+						<p>Check this option to insert the text content of the saved page into the self-extracting ZIP
+							file. This makes it possible, for example, to search for pages from the text. Note that if
+							the selected fomrmat is "self-extracting ZIP (universal)", the text content is
+							encoded in UTF-8 but the page is declared in ISO-8859-1.
+						</p>
+					</li>
+				</ul>
 				<p>HTML content</p>
 				<ul>
 					<li data-options-label="compressHTMLLabel"> <span class="option">Option: compress HTML
@@ -298,7 +339,7 @@
 							images together</span>
 						<p>Check this option to avoid saving multiple times duplicate images. Checking this option
 							should not alter the document in modern browsers and can considerably reduce the size of the
-							file.</p>
+							file. This option is ignored with archive formats based on ZIP files.</p>
 						<p class="notice">It is recommended to <u>check</u> this option</p>
 					</li>
 					<li data-options-label="loadDeferredImagesLabel"> <span class="option">Option: save deferred
@@ -369,7 +410,8 @@
 					</li>
 					<li data-options-label="saveToClipboardLabel" id="saveToClipboardOption"> <span
 							class="option">Option: copy to clipboard</span>
-						<p>Check this option to copy the page to the clipboard.</p>
+						<p>Check this option to copy the page to the clipboard. This option does not work with
+							archive formats based on ZIP files.</p>
 						<p class="notice">It is recommended to <u>uncheck</u> this option</p>
 					</li>
 					<li data-options-label="saveToGitHubLabel"> <span class="option">Option: upload to GitHub</span>
@@ -532,11 +574,11 @@
 						<p class="notice">It is recommended to <u>uncheck</u> this option</p>
 					</li>
 					<li data-options-label="displayInfobarInEditorLabel"> <span class="option">Option:
-						display the infobar</span>
+							display the infobar</span>
 						<p>Check this option to display the infobar when displaying a page in the annotation editor.</p>
 						<p class="notice">It is recommended to <u>uncheck</u> this option</p>
-					</p>
-				</li>
+						</p>
+					</li>
 				</ul>
 				<p id="bookmarksSection">Bookmarks</p>
 				<ul id="bookmarksOptions">

+ 24 - 0
src/ui/pages/options.html

@@ -85,6 +85,30 @@
 				<input type="checkbox" id="replaceEmojisInFilenameInput">
 			</div>
 		</details>
+		<details>
+			<summary id="fileFormatLabel"></summary>
+			<div class="option">
+				<label for="fileFormatSelectInput" id="fileFormatSelectLabel"></label>
+				<select id="fileFormatSelectInput">
+					<option id="fileFormatSelectHTMLLabel" value="html"></option>
+					<option id="fileFormatSelectSelfExtractingUniversalLabel" value="self-extracting-zip-universal"></option>
+					<option id="fileFormatSelectSelfExtractingLabel" value="self-extracting-zip"></option>
+					<option id="fileFormatSelectZIPLabel" value="zip"></option>
+				</select>
+			</div>
+			<div class="option vertical">
+				<label for="passwordInput" id="passwordLabel"></label>
+				<input type="text" id="passwordInput">
+			</div>
+			<div class="option">
+				<label for="createRootDirectoryInput" id="createRootDirectoryLabel"></label>
+				<input type="checkbox" id="createRootDirectoryInput">
+			</div>
+			<div class="option">
+				<label for="insertTextBodyInput" id="insertTextBodyLabel"></label>
+				<input type="checkbox" id="insertTextBodyInput">
+			</div>
+		</details>
 		<details>
 			<summary id="htmlContentLabel"></summary>
 			<div class="option">