├── .gitattributes ├── .gitignore ├── .prettierrc ├── .resources ├── check.png ├── icon.png ├── nix │ └── bin │ │ └── .keep ├── strings.json └── win │ └── bin │ └── .keep ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── build ├── background.png ├── background@2x.png ├── icon.icns └── icon.png ├── electron-webpack.json ├── package.json ├── src ├── common │ ├── app.ts │ ├── binaries.ts │ ├── browser_window.ts │ ├── env.ts │ ├── exif_tool_processes.ts │ ├── i18n.ts │ ├── platform.ts │ └── resources.ts ├── main │ ├── app_setup.ts │ ├── context_menu.ts │ ├── dock.ts │ ├── file_open.ts │ ├── i18n.ts │ ├── index.ts │ ├── init.ts │ ├── menu.ts │ ├── menu_app.ts │ ├── menu_app_about.ts │ ├── menu_dock.ts │ ├── menu_edit.ts │ ├── menu_file.ts │ ├── menu_file_open.ts │ ├── menu_help.ts │ ├── menu_item_open_url.ts │ ├── menu_view.ts │ ├── menu_window.ts │ └── window_setup.ts ├── renderer │ ├── add_files.ts │ ├── display_exif.ts │ ├── drag.ts │ ├── empty_pane.ts │ ├── exif_get.ts │ ├── exif_remove.ts │ ├── i18n.ts │ ├── index.html │ ├── index.ts │ ├── menu_select_files.ts │ ├── sanitize.ts │ ├── select_files.ts │ ├── selected_files.ts │ ├── table_add_row.ts │ └── table_update_row.ts ├── styles │ ├── base.css │ ├── card.css │ ├── dark_mode.css │ ├── display.css │ ├── empty.css │ ├── file_list.css │ ├── icon.css │ ├── popover.css │ ├── tables.css │ ├── typography.css │ └── vars.css └── types │ └── node-exiftool │ └── index.d.ts ├── static └── icon.svg ├── tsconfig.json ├── update_exiftool.pl └── yarn.lock /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ELECTRON/NODE 2 | node_modules 3 | /dist 4 | .env 5 | *.tgz 6 | 7 | # MORE ELECTRON 8 | .DS_Store 9 | .env 10 | .gclient_done 11 | **/.npmrc 12 | .tags* 13 | .vs/ 14 | .vscode/ 15 | *.log 16 | *.pyc 17 | *.sln 18 | *.swp 19 | *.VC.db 20 | *.VC.VC.opendb 21 | *.vcxproj 22 | *.vcxproj.filters 23 | *.vcxproj.user 24 | *.xcodeproj 25 | /.idea/ 26 | /dist/ 27 | /external_binaries/ 28 | /out/ 29 | /vendor/.gclient 30 | /vendor/debian_jessie_mips64-sysroot/ 31 | /vendor/debian_stretch_amd64-sysroot/ 32 | /vendor/debian_stretch_arm-sysroot/ 33 | /vendor/debian_stretch_arm64-sysroot/ 34 | /vendor/debian_stretch_i386-sysroot/ 35 | /vendor/gcc-4.8.3-d197-n64-loongson/ 36 | /vendor/readme-gcc483-loongson.txt 37 | /vendor/download/ 38 | /vendor/llvm-build/ 39 | /vendor/llvm/ 40 | /vendor/npm/ 41 | /vendor/python_26/ 42 | /vendor/native_mksnapshot 43 | /vendor/LICENSES.chromium.html 44 | /vendor/pyyaml 45 | node_modules/ 46 | SHASUMS256.txt 47 | **/package-lock.json 48 | compile_commands.json 49 | .envrc 50 | 51 | # npm package 52 | /npm/dist 53 | /npm/path.txt 54 | 55 | .npmrc 56 | 57 | # Generated API definitions 58 | electron-api.json 59 | electron.d.ts 60 | 61 | # Spec hash calculation 62 | spec/.hash 63 | 64 | # Eslint Cache 65 | .eslintcache 66 | 67 | # Generated native addon files 68 | /spec-main/fixtures/native-addon/echo/build/ 69 | 70 | # If someone runs tsc this is where stuff will end up 71 | ts-gen 72 | 73 | # Used to accelerate CI builds 74 | .depshash 75 | .depshash-target 76 | 77 | #LINUX 78 | # 79 | # NOTE! Don't add files that are generated in specific 80 | # subdirectories here. Add them in the ".gitignore" file 81 | # in that subdirectory instead. 82 | # 83 | # NOTE! Please use 'git ls-files -i --exclude-standard' 84 | # command after changing this file, to see if there are 85 | # any tracked files which get ignored after the change. 86 | # 87 | # Normal rules (sorted alphabetically) 88 | # 89 | .* 90 | *.a 91 | *.asn1.[ch] 92 | *.bin 93 | *.bz2 94 | *.c.[012]*.* 95 | *.dt.yaml 96 | *.dtb 97 | *.dtb.S 98 | *.dwo 99 | *.elf 100 | *.gcno 101 | *.gz 102 | *.i 103 | *.ko 104 | *.lex.c 105 | *.ll 106 | *.lst 107 | *.lz4 108 | *.lzma 109 | *.lzo 110 | *.mod 111 | *.mod.c 112 | *.o 113 | *.o.* 114 | *.patch 115 | *.s 116 | *.so 117 | *.so.dbg 118 | *.su 119 | *.symtypes 120 | *.tab.[ch] 121 | *.tar 122 | *.xz 123 | Module.symvers 124 | modules.builtin 125 | modules.order 126 | 127 | # 128 | # Top-level generic files 129 | # 130 | /tags 131 | /TAGS 132 | /linux 133 | /vmlinux 134 | /vmlinux.32 135 | /vmlinux-gdb.py 136 | /vmlinuz 137 | /System.map 138 | /Module.markers 139 | /modules.builtin.modinfo 140 | /modules.nsdeps 141 | 142 | # 143 | # RPM spec file (make rpm-pkg) 144 | # 145 | /*.spec 146 | 147 | # 148 | # Debian directory (make deb-pkg) 149 | # 150 | /debian/ 151 | 152 | # 153 | # Snap directory (make snap-pkg) 154 | # 155 | /snap/ 156 | 157 | # 158 | # tar directory (make tar*-pkg) 159 | # 160 | /tar-install/ 161 | 162 | # 163 | # We don't want to ignore the following even if they are dot-files 164 | # 165 | !.clang-format 166 | !.cocciconfig 167 | !.get_maintainer.ignore 168 | !.gitattributes 169 | !.gitignore 170 | !.mailmap 171 | 172 | # 173 | # Generated include files 174 | # 175 | /include/config/ 176 | /include/generated/ 177 | /include/ksym/ 178 | /arch/*/include/generated/ 179 | 180 | # stgit generated dirs 181 | patches-* 182 | 183 | # quilt's files 184 | patches 185 | series 186 | 187 | # cscope files 188 | cscope.* 189 | ncscope.* 190 | 191 | # gnu global files 192 | GPATH 193 | GRTAGS 194 | GSYMS 195 | GTAGS 196 | 197 | # id-utils files 198 | ID 199 | 200 | *.orig 201 | *~ 202 | \#*# 203 | 204 | # 205 | # Leavings from module signing 206 | # 207 | extra_certificates 208 | signing_key.pem 209 | signing_key.priv 210 | signing_key.x509 211 | x509.genkey 212 | 213 | # Kconfig presets 214 | /all.config 215 | /alldef.config 216 | /allmod.config 217 | /allno.config 218 | /allrandom.config 219 | /allyes.config 220 | 221 | # Kdevelop4 222 | *.kdev4 223 | 224 | # Clang's compilation database file 225 | /compile_commands.json 226 | 227 | 228 | # WINDOWS 229 | # Windows thumbnail cache files 230 | Thumbs.db 231 | Thumbs.db:encryptable 232 | ehthumbs.db 233 | ehthumbs_vista.db 234 | 235 | # Dump file 236 | *.stackdump 237 | 238 | # Folder config file 239 | [Dd]esktop.ini 240 | 241 | # Recycle Bin used on file shares 242 | $RECYCLE.BIN/ 243 | 244 | # Windows Installer files 245 | *.cab 246 | *.msi 247 | *.msix 248 | *.msm 249 | *.msp 250 | 251 | # Windows shortcuts 252 | *.lnk 253 | 254 | 255 | # MAC 256 | # General 257 | .DS_Store 258 | .AppleDouble 259 | .LSOverride 260 | 261 | # Icon must end with two \r 262 | Icon 263 | 264 | 265 | # Thumbnails 266 | ._* 267 | 268 | # Files that might appear in the root of a volume 269 | .DocumentRevisions-V100 270 | .fseventsd 271 | .Spotlight-V100 272 | .TemporaryItems 273 | .Trashes 274 | .VolumeIcon.icns 275 | .com.apple.timemachine.donotpresent 276 | 277 | # Directories potentially created on remote AFP share 278 | .AppleDB 279 | .AppleDesktop 280 | Network Trash Folder 281 | Temporary Items 282 | .apdisk 283 | 284 | # git repo for ExifTool, used to pull down latest binary 285 | gitignore 286 | exiftool_downloads 287 | .resources/nix/bin/** 288 | .resources/win/bin/** 289 | 290 | yarn-error.log 291 | 292 | 293 | # Explicitly include the exiftool binaries 294 | # that were being hit by the .gitignore rule .* 295 | !.resources 296 | !.resources/nix/bin/.keep 297 | !.resources/win/bin/.keep 298 | 299 | # Explicitly include Travis CI configuration 300 | # that were being hit by the .gitignore rule .* 301 | !.travis.yml 302 | 303 | # Explicitly include Prettier configuration 304 | # that was being hit by the .gitignore rule .* 305 | !.prettierrc -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true 3 | } 4 | -------------------------------------------------------------------------------- /.resources/check.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szTheory/exifcleaner/ae09e1a2eb19f12083fa83f308ebeb5fecea2685/.resources/check.png -------------------------------------------------------------------------------- /.resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szTheory/exifcleaner/ae09e1a2eb19f12083fa83f308ebeb5fecea2685/.resources/icon.png -------------------------------------------------------------------------------- /.resources/nix/bin/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szTheory/exifcleaner/ae09e1a2eb19f12083fa83f308ebeb5fecea2685/.resources/nix/bin/.keep -------------------------------------------------------------------------------- /.resources/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "empty.title": { 3 | "en": "No files selected", 4 | "da": "Ingen filer valgt", 5 | "fr": "Aucun fichier sélectionné", 6 | "pl": "Nie wybrano plików", 7 | "ja": "ファイルが選択されていません", 8 | "es": "No hay archivos seleccionados", 9 | "de": "Keine Datei ausgewählt", 10 | "pt-BR": "Nenhum arquivo selecionado", 11 | "sk": "Nie sú vybraté žiadne súbory", 12 | "ru": "Нет выбранных файлов", 13 | "uk": "Немає вибраних файлів", 14 | "ar": "لا تُوجَدُ أيُّ ملفَّاتٍ مُحدَّدة", 15 | "nl": "Geen bestanden geselecteerd", 16 | "it": "Nessun file selezionato", 17 | "zh": "未选择文件", 18 | "tr": "Henüz dosya seçilmedi", 19 | "hr": "Nijedna datoteka nije odabrana", 20 | "hu": "Nincs kiválasztva fájl", 21 | "sv": "Inga filer valda", 22 | "ml": "ഫയലുകളൊന്നും തിരഞ്ഞെടുത്തിട്ടില്ല", 23 | "cs": "Nejsou vybrány žádné soubory", 24 | "vn": "Không có tập tin nào được chọn" 25 | }, 26 | "empty.subtitle": { 27 | "en": "Drag and drop images, videos, or PDF files to automatically remove metadata.", 28 | "da": "Træk og slip billeder, videoer eller PDF filer for automatisk at fjerne metadata", 29 | "fr": "Faites glisser-déposer des images, des vidéos ou des fichiers PDF pour supprimer automatiquement les métadonnées.", 30 | "pl": "Przeciągnij i upuść zdjęcia, filmy lub pliki PDF, aby automatycznie usunąć z nich metadane.", 31 | "ja": "画像、動画、PDF をドラッグ&ドロップすると、メタデータが自動的に削除されます。", 32 | "es": "Arrastre y suelte imágenes, videos, o documentos PDF para automáticamente eliminar metadatos.", 33 | "de": "Ziehen Sie Bilder, Videos oder PDF-Dateien per Drag & Drop, um Metadaten automatisch zu entfernen.", 34 | "pt-BR": "Arraste e solte imagens, vídeos ou arquivos PDF para remover metadados automaticamente.", 35 | "sk": "Presuňte a pustite fotografie, videá alebo PDF súbory sem a automaticky z nich odstránia metadáta.", 36 | "ru": "Перетащите изображения, видео или PDF-файлы для автоматического удаления метаданных.", 37 | "uk": "Перетягніть зображення, відео або PDF-файли для автоматичного видалення метаданих.", 38 | "ar": "اِسحب وارمِ الصُّور، المقاطع المرئية أو ملفَّات البي-دي-اف لإزالة البيانات الوصفيَّة بشكل تلقائيّ.", 39 | "nl": "Sleep en plaats afbeeldingen, video's of PDF-bestanden om metagegevens automatisch te verwijderen.", 40 | "it": "Trascina e rilascia immagini, video o file PDF qui per rimuovere automaticamente i metadati.", 41 | "zh": "拖放图像,视频或PDF文件以自动删除元数据。", 42 | "tr": "Meta verileri otomatik olarak kaldırmak için görüntü, video veya PDF dosyalarını sürükleyip bırakabilirsin.", 43 | "hr": "Povuci i ispusti slike, videa ili PDF datoteke za automatsko uklanjanje metapodataka.", 44 | "hu": "Húzd át a képeket, videókat vagy PDF fájlokat a metaadatatok automatikus eltávolításához.", 45 | "sv": "Dra och släpp bilder, videor eller PDF filer för att ta bort metadata automatiskt", 46 | "ml": "മെറ്റാഡാറ്റ സ്വയമേവ നീക്കം ചെയ്യാൻ ചിത്രങ്ങൾ, വീഡിയോകൾ അല്ലെങ്കിൽ PDF ഫയലുകൾ വലിച്ചിടുക.", 47 | "cs": "Přetáhněte sem fotografie, videa nebo soubory PDF a automaticky z nich odstraníte metadata.", 48 | "vn": "Kéo và thả tập tin hình ảnh, video hoặc PDF để tự động xóa siêu dữ liệu." 49 | }, 50 | 51 | "table.header.filename": { 52 | "en": "Selected files", 53 | "da": "Valgte filer", 54 | "fr": "Fichiers sélectionnés", 55 | "pl": "Wybrane pliki", 56 | "ja": "選択されたファイル", 57 | "es": "Archivos seleccionados", 58 | "de": "Ausgewählte Dateien", 59 | "pt-BR": "Arquivos selecionados", 60 | "sk": "Vybrané súbory", 61 | "ru": "Выбранные файлы", 62 | "uk": "Вибрані файли", 63 | "ar": "الملفَّات المُحدَّدة", 64 | "nl": "Geselecteerde bestanden", 65 | "it": "File selezionati", 66 | "zh": "选中的文件", 67 | "tr": "Seçilen dosyalar", 68 | "hr": "Odabrane datoteke", 69 | "hu": "Kiválasztott fájlok", 70 | "sv": "valda filer", 71 | "ml": "തിരഞ്ഞെടുത്ത ഫയലുകൾ", 72 | "cs": "Vybrané soubory", 73 | "vn": "Những tập tin đã được chọn" 74 | }, 75 | "table.header.exif-before": { 76 | "en": "# Exif Before", 77 | "da": "Exif Før", 78 | "fr": "# Exif Avant", 79 | "pl": "# Exif przed", 80 | "ja": "# 処理前の Exif", 81 | "es": "Exif antes", 82 | "de": "# Exif vorher", 83 | "pt-BR": "# Exif antes", 84 | "sk": "# Exif predtým", 85 | "ru": "# Exif до", 86 | "uk": "# Exif до", 87 | "ar": "# قَبل Exif", 88 | "nl": "# Exif Voor", 89 | "it": "Nº di dati Exif prima", 90 | "zh": "# Exif 处理前", 91 | "tr": "# Exif'den önce", 92 | "hr": "Broj Exifa prije", 93 | "hu": "# Exif előtt", 94 | "sv": "# Exif Innan", 95 | "ml": "എക്സിഫ് മുമ്പ്", 96 | "cs": "Exif předtím", 97 | "vn": "#Trước Exif" 98 | }, 99 | "table.header.exif-after": { 100 | "en": "# Exif After", 101 | "da": "Exif Efter", 102 | "fr": "# Exif Après", 103 | "pl": "# Exif po", 104 | "ja": "# 処理後の Exif", 105 | "es": "Exif después", 106 | "de": "# Exif nachher", 107 | "pt-BR": "# Exif depois", 108 | "sk": "# Exif po", 109 | "ru": "# Exif после", 110 | "uk": "# Exif після", 111 | "ar": "# بَعد Exif", 112 | "nl": "# Exif Na", 113 | "it": "Nº di dati Exif dopo", 114 | "zh": "# Exif 处理后", 115 | "tr": "# Exif'den Sonra", 116 | "hr": "Broj Exifa poslije", 117 | "hu": "# Exif után", 118 | "sv": "# Exif Efter", 119 | "ml": "എക്സിഫ് പിന്നീട്", 120 | "cs": "Exif poté", 121 | "vn": "#Sau Exif" 122 | }, 123 | 124 | "contextmenu.copy": { 125 | "en": "Copy", 126 | "da": "Kopier", 127 | "fr": "Copier", 128 | "pl": "Kopiuj", 129 | "ja": "コピー", 130 | "es": "Copiar", 131 | "de": "Kopie", 132 | "pt-BR": "Copiar", 133 | "sk": "Kopírovať", 134 | "ru": "Скопировать", 135 | "uk": "Скопіювати", 136 | "ar": "نَسخ", 137 | "nl": "Kopieer", 138 | "it": "Copia", 139 | "zh": "拷贝", 140 | "tr": "Kopyala", 141 | "hr": "Kopiraj", 142 | "hu": "Másolat", 143 | "sv": "Kopiera", 144 | "ml": "പകർത്തുക", 145 | "cs": "Kopírovat", 146 | "vn": "Sao chép" 147 | }, 148 | "contextmenu.select-all": { 149 | "en": "Select All", 150 | "da": "Vælg Alle", 151 | "fr": "Tout sélectionner", 152 | "pl": "Zaznacz wszystko", 153 | "ja": "全て選択", 154 | "es": "Seleccionar todos", 155 | "de": "Alle markieren", 156 | "pt-BR": "Selecionar todos", 157 | "sk": "Vybrať všetko", 158 | "ru": "Выделить всё", 159 | "uk": "Виділити все", 160 | "ar": "تحديدُ الكُل", 161 | "nl": "Alles Selecteren", 162 | "it": "Seleziona tutto", 163 | "zh": "全选", 164 | "tr": "Hepsini seç", 165 | "hr": "Odaberi sve", 166 | "hu": "Összes kijelölés", 167 | "sv": "Välj Alla", 168 | "ml": "എല്ലാം തിരഞ്ഞെടുക്കൂ", 169 | "cs": "Vybrat vše", 170 | "vn": "Chọn tất cả" 171 | }, 172 | 173 | "menu.app.about": { 174 | "en": "About ", 175 | "da": "Om ", 176 | "fr": "À propos d'", 177 | "pl": "O programie ", 178 | "ja": "概要 ", 179 | "es": "Acerca de ", 180 | "de": "Über ", 181 | "pt-BR": "Sobre ", 182 | "sk": "O aplikácii ", 183 | "ru": "О программе ", 184 | "uk": "Про програму ", 185 | "ar": " حَول ", 186 | "nl": "Over ", 187 | "it": "Chi siamo", 188 | "zh": "关于", 189 | "tr": "Hakkında", 190 | "hr": "Informacije", 191 | "hu": "Névjegy", 192 | "sv": "Om", 193 | "ml": "കുറിച്ച്", 194 | "cs": "O aplikaci ", 195 | "vn": "Giới thiệu" 196 | }, 197 | "menu.app.services": { 198 | "en": "Services", 199 | "da": "Services", 200 | "fr": "Services", 201 | "pl": "Usługi", 202 | "ja": "サービス", 203 | "es": "Servicios", 204 | "de": "Services", 205 | "pt-BR": "Serviços", 206 | "sk": "Služby", 207 | "ru": "Службы", 208 | "uk": "Служби", 209 | "ar": "الخدمات", 210 | "nl": "Services", 211 | "it": "Servizi", 212 | "zh": "服务", 213 | "tr": "Servisler", 214 | "hr": "Usluge", 215 | "hu": "Szolgáltatások", 216 | "sv": "Tjänster", 217 | "ml": "സർവീസുകൾ", 218 | "cs": "Služby", 219 | "vn": "Dịch vụ" 220 | }, 221 | "menu.app.hide": { 222 | "en": "Hide", 223 | "da": "Skjul", 224 | "fr": "Masquer", 225 | "pl": "Ukryj", 226 | "ja": "最小化", 227 | "es": "Ocultar", 228 | "de": "Ausblenden", 229 | "pt-BR": "Ocultar", 230 | "sk": "Skryť", 231 | "ru": "Скрыть", 232 | "uk": "Сховати", 233 | "ar": "إخفاء", 234 | "nl": "Verbergen", 235 | "it": "Nascondi", 236 | "zh": "最小化", 237 | "tr": "Gizle", 238 | "hr": "Sakrij", 239 | "hu": "Elrejt", 240 | "sv": "Göm", 241 | "ml": "മറയ്ക്കുക", 242 | "cs": "Skrýt", 243 | "vn": "Ẩn" 244 | }, 245 | "menu.app.hide-others": { 246 | "en": "Hide Others", 247 | "da": "Skjul Andre", 248 | "fr": "Masquer les autres", 249 | "pl": "Ukryj pozostałe", 250 | "ja": "他のウィンドウを最小化", 251 | "es": "Ocultar otros", 252 | "de": "Andere ausblenden", 253 | "pt-BR": "Ocultar outros", 254 | "sk": "Skryť ostatné", 255 | "ru": "Скрыть остальные", 256 | "uk": "Сховати решту", 257 | "ar": "إخفاء البقيَّة", 258 | "nl": "Verbergen Andere", 259 | "it": "Nascondi tutto il resto", 260 | "zh": "隐藏其他", 261 | "tr": "Diğerlerini gizle", 262 | "hr": "Sakrij ostalo", 263 | "hu": "Többi elrejtése", 264 | "sv": "Göm Andra", 265 | "ml": "മറ്റ് ഇനങ്ങൾ മറയ്ക്കുക", 266 | "cs": "Skrýt ostatní", 267 | "vn": "Ẩn những tập tin khác" 268 | }, 269 | "menu.app.show-all": { 270 | "en": "Show All", 271 | "da": "Vis Alle", 272 | "fr": "Tout afficher", 273 | "pl": "Pokaż wszystkie", 274 | "ja": "全て表示", 275 | "es": "Mostrar todos", 276 | "de": "Alle anzeigen", 277 | "pt-BR": "Mostrar todos", 278 | "sk": "Ukázať všetko", 279 | "ru": "Показать все", 280 | "uk": "Показати все", 281 | "ar": "إظهار الكُل", 282 | "nl": "Toon Alles", 283 | "it": "Mostra tutti", 284 | "zh": "全部显示", 285 | "tr": "Hepsini göster", 286 | "hr": "Pokaži sve", 287 | "hu": "Összes mutatása", 288 | "sv": "Visa Alla", 289 | "ml": "എല്ലാം കാണിക്കൂ", 290 | "cs": "Zobrazit vše", 291 | "vn": "Hiện tất cả" 292 | }, 293 | "menu.app.quit": { 294 | "en": "Quit", 295 | "da": "Afslut", 296 | "fr": "Quitter", 297 | "pl": "Zakończ", 298 | "ja": "終了", 299 | "es": "Salir de", 300 | "de": "Beenden", 301 | "pt-BR": "Sair", 302 | "sk": "Ukončiť", 303 | "ru": "Завершить", 304 | "uk": "Завершити", 305 | "ar": "خُروج", 306 | "nl": "Afsluiten", 307 | "it": "Esci", 308 | "zh": "退出", 309 | "tr": "Çık", 310 | "hr": "Zatvori program", 311 | "hu": "Kilépés", 312 | "sv": "Avsluta", 313 | "ml": "അടയ്ക്കുക", 314 | "cs": "Ukončit", 315 | "vn": "Thoát" 316 | }, 317 | 318 | "menu.file.name": { 319 | "en": "File", 320 | "da": "Fil", 321 | "fr": "Fichier", 322 | "pl": "Plik", 323 | "ja": "ファイル", 324 | "es": "Archivo", 325 | "de": "Datei", 326 | "pt-BR": "Arquivo", 327 | "sk": "Súbor", 328 | "ru": "Файл", 329 | "uk": "Файл", 330 | "ar": "اسم الملفّ", 331 | "nl": "Bestand", 332 | "it": "File", 333 | "zh": "文件", 334 | "tr": "Dosya", 335 | "hr": "Datoteka", 336 | "hu": "Fájl", 337 | "sv": "Fil", 338 | "ml": "ഫയൽ", 339 | "cs": "Soubor", 340 | "vn": "Tập tin" 341 | }, 342 | "menu.file.open": { 343 | "en": "Open", 344 | "da": "Åbn", 345 | "fr": "Ouvrir", 346 | "pl": "Otwórz", 347 | "ja": "開く", 348 | "es": "Abrir", 349 | "de": "Öffnen", 350 | "pt-BR": "Abrir arquivo", 351 | "sk": "Otvoriť", 352 | "ru": "Открыть", 353 | "uk": "Відкрити", 354 | "ar": "فَتح", 355 | "nl": "Openen", 356 | "it": "Apri", 357 | "zh": "打开", 358 | "tr": "Aç", 359 | "hr": "Otvori", 360 | "hu": "Megnyitás", 361 | "sv": "Öppna", 362 | "ml": "തുറക്കുക", 363 | "cs": "Otevřít", 364 | "vn": "Mở để chọn file" 365 | }, 366 | "menu.file.close": { 367 | "en": "Close", 368 | "da": "Luk", 369 | "fr": "Fermer", 370 | "pl": "Zamknij", 371 | "ja": "閉じる", 372 | "es": "Cerrar", 373 | "de": "Schliesen", 374 | "pt-BR": "Fechar", 375 | "sk": "Zavrieť", 376 | "ru": "Закрыть", 377 | "uk": "Закрити", 378 | "ar": "إغلاق", 379 | "nl": "Sluiten", 380 | "it": "Chiudi", 381 | "zh": "关闭", 382 | "tr": "Kapat", 383 | "hr": "Zatvori", 384 | "hu": "Bezárás", 385 | "sv": "Stäng", 386 | "ml": "അടയ്ക്കുക", 387 | "cs": "Zavřít", 388 | "vn": "Đóng" 389 | }, 390 | "menu.file.quit": { 391 | "en": "Quit", 392 | "da": "Afslut", 393 | "fr": "Quitter", 394 | "pl": "Zakończ", 395 | "ja": "終了", 396 | "es": "Salir", 397 | "de": "Beenden", 398 | "pt-BR": "Sair", 399 | "sk": "Ukončiť", 400 | "ru": "Завершить", 401 | "uk": "Вийти", 402 | "ar": "خُروج", 403 | "nl": "Afsluiten", 404 | "it": "Esci", 405 | "zh": "退出", 406 | "tr": "Çık", 407 | "hr": "Zatvori program", 408 | "hu": "Kilépés", 409 | "sv": "Avsluta", 410 | "ml": "അടയ്ക്കുക", 411 | "cs": "Ukončit", 412 | "vn": "Thoát" 413 | }, 414 | 415 | "menu.edit.name": { 416 | "en": "Edit", 417 | "da": "Rediger", 418 | "fr": "Édition", 419 | "pl": "Edytuj", 420 | "ja": "編集", 421 | "es": "Editar", 422 | "de": "Editieren", 423 | "pt-BR": "Editar nome", 424 | "sk": "Upraviť", 425 | "ru": "Правка", 426 | "uk": "Редагування", 427 | "ar": "تحرير", 428 | "nl": "Bewerken", 429 | "it": "Modifica", 430 | "zh": "编辑", 431 | "tr": "Düzenle", 432 | "hr": "Uredi", 433 | "hu": "Szerkesztés", 434 | "sv": "Redigera", 435 | "ml": "തിരുത്തുക", 436 | "cs": "Upravit", 437 | "vn": "Chỉnh sửa" 438 | }, 439 | "menu.edit.copy": { 440 | "en": "Copy", 441 | "da": "Kopier", 442 | "fr": "Copier", 443 | "pl": "Kopiuj", 444 | "ja": "コピー", 445 | "es": "Copiar", 446 | "de": "Kopieren", 447 | "pt-BR": "Copiar", 448 | "sk": "Kopírovať", 449 | "ru": "Скопировать", 450 | "uk": "Скопіювати", 451 | "ar": "نَسخ", 452 | "nl": "Kopiëren", 453 | "it": "Copia", 454 | "zh": "拷贝", 455 | "tr": "Kopyala", 456 | "hr": "Kopiraj", 457 | "hu": "Másolás", 458 | "sv": "Kopiera", 459 | "ml": "പകർത്തുക", 460 | "cs": "Kopírovat", 461 | "vn": "Sao chép" 462 | }, 463 | "menu.edit.select-all": { 464 | "en": "Select All", 465 | "da": "Vælg Alle", 466 | "fr": "Tout sélectionner", 467 | "pl": "Zaznacz wszystko", 468 | "ja": "全て選択", 469 | "es": "Seleccionar todos", 470 | "de": "Alle markieren", 471 | "pt-BR": "Selecionar todos", 472 | "sk": "Vybrať všetko", 473 | "ru": "Выбрать все", 474 | "uk": "Вибрати все", 475 | "ar": "تحديدُ الكُل", 476 | "nl": "Alles Selecteren", 477 | "it": "Seleziona tutto", 478 | "zh": "全选", 479 | "tr": "Hepsini seç", 480 | "hr": "Odaberi sve", 481 | "hu": "Összes kijelölés", 482 | "sv": "Välj Alla", 483 | "ml": "എല്ലാം തിരഞ്ഞെടുക്കൂ", 484 | "cs": "Vybrat vše", 485 | "vn": "Chọn tất cả" 486 | }, 487 | "menu.edit.speech": { 488 | "en": "Speech", 489 | "da": "Diktér", 490 | "fr": "Dictée", 491 | "pl": "Mowa", 492 | "ja": "スピーチ", 493 | "es": "Dictado", 494 | "de": "Sprachausgabe", 495 | "pt-BR": "Discurso", 496 | "sk": "Reč", 497 | "ru": "Проговаривание текста", 498 | "uk": "Читання вголос", 499 | "ar": "النُّطق", 500 | "nl": "Spraak", 501 | "it": "Pronuncia", 502 | "zh": "语音", 503 | "tr": "Konuş", 504 | "hr": "Govor", 505 | "hu": "Beszéd", 506 | "sv": "Diktera", 507 | "ml": "വാചകം", 508 | "cs": "Řeč", 509 | "vn": "Ngôn ngữ" 510 | }, 511 | "menu.edit.speech.start-speaking": { 512 | "en": "Start Speaking", 513 | "da": "Start Diktéring", 514 | "fr": "Commencer la dictée", 515 | "pl": "Rozpocznij mówienie", 516 | "ja": "話し始める", 517 | "es": "Comience a hablar", 518 | "de": "Sprachausgabe starten", 519 | "pt-BR": "Comece a falar", 520 | "sk": "Začnite rozprávať", 521 | "ru": "Начать", 522 | "uk": "Розпочати читання", 523 | "ar": "اِبدأ النُّطق", 524 | "nl": "Start Spraakuitvoer", 525 | "it": "Inizia a parlare", 526 | "zh": "开始说话", 527 | "tr": "Konuşmaya Başla", 528 | "hr": "Pokreni govor", 529 | "hu": "Beszéd elkezdése", 530 | "sv": "Börja Prata", 531 | "ml": "വാചകം തുടങ്ങുക", 532 | "cs": "Začněte mluvit", 533 | "vn": "Bắt đầu nói" 534 | }, 535 | "menu.edit.speech.stop-speaking": { 536 | "en": "Stop Speaking", 537 | "da": "Stop Diktéring", 538 | "fr": "Arrêter la dictée", 539 | "pl": "Zakończ mówienie", 540 | "ja": "話し終わる", 541 | "es": "Pare de hablar", 542 | "de": "Sprachausgabe stoppen", 543 | "pt-BR": "Parar de falar", 544 | "sk": "Prestaňte hovoriť", 545 | "ru": "Остановить", 546 | "uk": "Зупинити читання", 547 | "ar": "إيقاف النُّطق", 548 | "nl": "Stop Spraakuitvoer", 549 | "it": "Smetti di parlare", 550 | "zh": "停止说话", 551 | "tr": "Konuşmayı Bitir", 552 | "hr": "Prekini govor", 553 | "hu": "Beszéd befejezése", 554 | "sv": "Sluta Prata", 555 | "ml": "വാചകം നിർത്തുക", 556 | "cs": "Přestaňte mluvit", 557 | "vn": "Ngừng nói" 558 | }, 559 | 560 | "menu.view.name": { 561 | "en": "View", 562 | "da": "Vis", 563 | "fr": "Affichage", 564 | "pl": "Widok", 565 | "ja": "表示", 566 | "es": "Vista", 567 | "de": "Anzeigen", 568 | "pt-BR": "Ver", 569 | "sk": "Zobrazenie", 570 | "ru": "Вид", 571 | "uk": "Перегляд", 572 | "ar": "العَرض", 573 | "nl": "Beeld", 574 | "it": "Visualizza", 575 | "zh": "视图", 576 | "tr": "Görünüm", 577 | "hr": "Prikaz", 578 | "hu": "Nézet", 579 | "sv": "Visa", 580 | "ml": "ദൃശ്യത", 581 | "cs": "Zobrazení", 582 | "vn": "Xem thêm" 583 | }, 584 | "menu.view.toggle-dev-tools": { 585 | "en": "Toggle Developer Tools", 586 | "da": "Vis Udvikler Værktøjer", 587 | "fr": "Outils de développment", 588 | "pl": "Przełącz narzędzia developerskie", 589 | "ja": "開発者ツールの切り替え", 590 | "es": "Habilitar herramientas de desarrollador", 591 | "de": "Entwicklertools aktivieren", 592 | "pt-BR": "Ativar modo do desenvolvedor", 593 | "sk": "Prepnúť vývojárske nástroje", 594 | "ru": "Переключить инструменты разработчика", 595 | "uk": "Переключити інструменти розробника", 596 | "ar": "تبديل أدوات المُطورين", 597 | "nl": "Open Developer Tools", 598 | "it": "Apri/chiudi gli strumenti per sviluppatori", 599 | "zh": "启动开发者工具", 600 | "tr": "Geliştirici Araçlarını Aç/Kapat", 601 | "hr": "Uklj/Isklj programerske alate", 602 | "hu": "Fejlesztői eszközök", 603 | "sv": "Växla utvecklarverktyg", 604 | "ml": "ഡെവലപ്പർ ടൂളുകൾ മാറ്റുക", 605 | "cs": "Přepnout vývojářské nástroje", 606 | "vn": "Bật/Tắt công cụ dành cho nhà phát triển" 607 | }, 608 | "menu.view.zoom-reset": { 609 | "en": "Actual Size", 610 | "da": "Virkelig Størrelse", 611 | "fr": "Zoom par défaut", 612 | "pl": "Domyślny rozmiar", 613 | "ja": "実サイズ", 614 | "es": "Tamaño actual", 615 | "de": "Originalgröße", 616 | "pt-BR": "Tamanho atual", 617 | "sk": "Predvolená veľkosť", 618 | "ru": "Исходный размер", 619 | "uk": "Справжній розмір", 620 | "ar": "الحجم الأصلي", 621 | "nl": "Originele grootte", 622 | "it": "Reimposta la dimensione originale", 623 | "zh": "实际大小", 624 | "tr": "Gerçek boyut", 625 | "hr": "Stvarna veličina", 626 | "hu": "Valódi méret", 627 | "sv": "Riktig Storlek", 628 | "ml": "യഥാർത്ഥ വലുപ്പം", 629 | "cs": "Výchozí velikost", 630 | "vn": "Kích cỡ thực sự" 631 | }, 632 | "menu.view.zoom-in": { 633 | "en": "Zoom In", 634 | "da": "Zoom Ind", 635 | "fr": "Zoom avant", 636 | "pl": "Powiększ", 637 | "ja": "ズームイン", 638 | "es": "Acercar", 639 | "de": "Vergrößern", 640 | "pt-BR": "Ampliar", 641 | "sk": "Priblížiť", 642 | "ru": "Увеличить масштаб", 643 | "uk": "Збільшити", 644 | "ar": "تكبير", 645 | "nl": "Inzoomen", 646 | "it": "Aumenta zoom", 647 | "zh": "放大", 648 | "tr": "Yakınlaştır", 649 | "hr": "Povećaj", 650 | "hu": "Nagyítás", 651 | "sv": "Zooma in", 652 | "ml": "വലുതാക്കുക", 653 | "cs": "Přiblížit", 654 | "vn": "Phóng to màn hình chính" 655 | }, 656 | "menu.view.zoom-out": { 657 | "en": "Zoom Out", 658 | "da": "Zoom Ud", 659 | "fr": "Zoom arrière", 660 | "pl": "Pomniejsz", 661 | "ja": "ズームアウト", 662 | "es": "Alejar", 663 | "de": "Verkleinern", 664 | "pt-BR": "Reduzir", 665 | "sk": "Oddialiť", 666 | "ru": "Уменьшить масштаб", 667 | "uk": "Зменьшити", 668 | "ar": "تصغير", 669 | "nl": "Uitzoomen", 670 | "it": "Diminuisci zoom", 671 | "zh": "缩小", 672 | "tr": "Uzaklaştır", 673 | "hr": "Smanji", 674 | "hu": "Kicsinyítés", 675 | "sv": "Zooma ut", 676 | "ml": "ചെറുതാക്കുക", 677 | "cs": "Oddálit", 678 | "vn": "Thu nhỏ màn hình chính" 679 | }, 680 | "menu.view.toggle-full-screen": { 681 | "en": "Toggle Full Screen", 682 | "da": "Vis Fuld Skærm", 683 | "fr": "Activer le mode plein écran", 684 | "pl": "Przełącz tryb pełnoekranowy", 685 | "ja": "フルスクリーンの切り替え", 686 | "es": "Activar el modo pantalla completa", 687 | "de": "Vollbildmodus", 688 | "pt-BR": "Tela cheia", 689 | "sk": "Prepnúť režim celej obrazovky", 690 | "ru": "Перейти в полноэкранный режим", 691 | "uk": "Увійти до повноекранного режиму", 692 | "ar": "تبديل ملء الشَّاشة", 693 | "nl": "Volledig Scherm", 694 | "it": "Attiva/disattiva la modalità a schermo intero", 695 | "zh": "进入全屏幕", 696 | "tr": "Tam ekran Aç/Kapa", 697 | "hr": "Uklj/Isklj prikaz preko cijelog ekrana", 698 | "hu": "Váltás teljes képernyőre", 699 | "sv": "Växla Fullskärm", 700 | "ml": "ഫുൾ സ്ക്രീൻ മാറ്റുക", 701 | "cs": "Přepnout režim celé obrazovky", 702 | "vn": "Toàn màn hình" 703 | }, 704 | 705 | "menu.window.name": { 706 | "en": "Window", 707 | "da": "Vindue", 708 | "fr": "Fenêtre", 709 | "pl": "Okno", 710 | "ja": "ウィンドウ", 711 | "es": "Ventana", 712 | "de": "Fenster", 713 | "pt-BR": "Janela", 714 | "sk": "Okno", 715 | "ru": "Окно", 716 | "uk": "Вікно", 717 | "ar": "النَّافِذة", 718 | "nl": "Venster", 719 | "it": "Finestra", 720 | "zh": "窗口", 721 | "tr": "Pencere", 722 | "hr": "Prozor", 723 | "hu": "Ablak", 724 | "sv": "Fönster", 725 | "ml": "ജാലകം", 726 | "cs": "Okno", 727 | "vn": "Màn cửa sổ" 728 | }, 729 | "menu.window.minimize-mac": { 730 | "en": "Minimize", 731 | "da": "Minimér", 732 | "fr": "Réduire", 733 | "pl": "Minimalizuj", 734 | "ja": "最小化", 735 | "es": "Minimizar", 736 | "de": "Im Dock ablegen", 737 | "pt-BR": "Minimizar", 738 | "sk": "Minimalizovať", 739 | "ru": "Убрать в Dock", 740 | "uk": "Згорнути", 741 | "ar": "تقليص", 742 | "nl": "Minimaliseren", 743 | "it": "Minimizza", 744 | "zh": "最小化", 745 | "tr": "Küçült", 746 | "hr": "Smanji", 747 | "hu": "Minimalizálás", 748 | "sv": "Förringa", 749 | "ml": "മിനിമൈസ്", 750 | "cs": "Minimalizovat", 751 | "vn": "Thu nhỏ" 752 | }, 753 | "menu.window.minimize": { 754 | "en": "Minimize", 755 | "da": "Minimér", 756 | "fr": "Réduire", 757 | "pl": "Minimalizuj", 758 | "ja": "最小化", 759 | "es": "Minimizar", 760 | "de": "Minimieren", 761 | "pt-BR": "Minimizar", 762 | "sk": "Minimalizovať", 763 | "ru": "Свернуть", 764 | "uk": "Згорнути", 765 | "ar": "تقليص", 766 | "nl": "Minimaliseren", 767 | "it": "Minimizza", 768 | "zh": "最小化", 769 | "tr": "Küçült", 770 | "hr": "Smanji", 771 | "hu": "Minimalizálás", 772 | "sv": "Förringa", 773 | "ml": "മിനിമൈസ്", 774 | "cs": "Minimalizovat", 775 | "vn": "Thu nhỏ" 776 | }, 777 | "menu.window.zoom-mac": { 778 | "en": "Zoom", 779 | "da": "Zoom", 780 | "fr": "Réduire/Agrandir", 781 | "pl": "Zoom", 782 | "ja": "ズーム", 783 | "es": "Maximizar", 784 | "de": "Zoomen", 785 | "pt-BR": "Ampliar", 786 | "sk": "Zväčšiť", 787 | "ru": "Изменить масштаб", 788 | "uk": "Змінити масштаб", 789 | "ar": "تكبير", 790 | "nl": "Zoomen", 791 | "it": "Riduci/Ingrandisci", 792 | "zh": "缩放", 793 | "tr": "Yakınlaştır", 794 | "hr": "Zumiraj", 795 | "hu": "Zoomolás", 796 | "sv": "Zooma", 797 | "ml": "സൂം ചെയ്യുക", 798 | "cs": "Zvětšit", 799 | "vn": "Phóng to" 800 | }, 801 | "menu.window.zoom": { 802 | "en": "Zoom", 803 | "da": "Zoom", 804 | "fr": "Agrandir", 805 | "pl": "Zoom", 806 | "ja": "ズーム", 807 | "es": "Maximizar", 808 | "de": "Zoomen", 809 | "pt-BR": "Ampliar", 810 | "sk": "Zväčšiť", 811 | "ru": "Развернуть", 812 | "uk": "Розгорнути", 813 | "ar": "تكبير", 814 | "nl": "Zoomen", 815 | "it": "Massimizza", 816 | "zh": "缩放", 817 | "tr": "Yakınlaştır", 818 | "hr": "Zumiraj", 819 | "hu": "Zoomolás", 820 | "sv": "Zooma", 821 | "ml": "സൂം ചെയ്യുക", 822 | "cs": "Zvětšit", 823 | "vn": "Phóng to" 824 | }, 825 | "menu.window.front": { 826 | "en": "Bring All to Front", 827 | "da": "Bring Alle I Front", 828 | "fr": "Tout ramener au premier plan", 829 | "pl": "Umieść wszystko na wierzchu", 830 | "ja": "全てを前面に表示", 831 | "es": "Traer al frente", 832 | "de": "Alle nach vorn bringen", 833 | "pt-BR": "Trazer tudo para frente", 834 | "sk": "Presunúť všetko do popredia", 835 | "ru": "Все окна — на передний план", 836 | "uk": "Усі вікна наперед", 837 | "ar": "إحضار الكُل إلى المُقدِّمة", 838 | "nl": "Breng alles naar de voorgrond", 839 | "it": "Porta tutte le finestre in primo piano", 840 | "zh": "全部置于顶层", 841 | "tr": "Öne Çıkart", 842 | "hr": "Postavi sve naprijed", 843 | "hu": "Hozz mindent előre", 844 | "sv": "Ta alla fram", 845 | "ml": "എല്ലാത്തിനെയും മുന്നിലേക്ക് കൊണ്ടുവരിക", 846 | "cs": "Přesunout vše do popředí", 847 | "vn": "Mang tất cả tập tin lên trước" 848 | }, 849 | "menu.window.window": { 850 | "en": "Window", 851 | "da": "Vindue", 852 | "fr": "Fenêtre", 853 | "pl": "Okno", 854 | "ja": "ウィンドウ", 855 | "es": "Ventana", 856 | "de": "Fenster", 857 | "pt-BR": "Janela", 858 | "sk": "Okno", 859 | "ru": "Окно", 860 | "uk": "Вікно", 861 | "ar": "نافِذة", 862 | "nl": "Venster", 863 | "it": "Finestra", 864 | "zh": "窗口", 865 | "tr": "Pencere", 866 | "hr": "Prozor", 867 | "hu": "Ablak", 868 | "sv": "Fönster", 869 | "ml": "വിന്ഡോ", 870 | "cs": "Okno", 871 | "vn": "Màn cửa sổ" 872 | }, 873 | "menu.window.close": { 874 | "en": "Close", 875 | "da": "Luk", 876 | "fr": "Fermer", 877 | "pl": "Zamknij", 878 | "ja": "閉じる", 879 | "es": "Cerrar", 880 | "de": "Schließen", 881 | "pt-BR": "Fechar", 882 | "sk": "Zavrieť", 883 | "ru": "Закрыть", 884 | "uk": "Вийти", 885 | "ar": "إغلاق", 886 | "nl": "Sluiten", 887 | "it": "Chiudi", 888 | "zh": "关闭", 889 | "tr": "Kapat", 890 | "hr": "Zatvori", 891 | "hu": "Bezárás", 892 | "sv": "Stäng", 893 | "ml": "അടയ്ക്കുക", 894 | "cs": "Zavřít", 895 | "vn": "Đóng" 896 | }, 897 | 898 | "menu.help.name": { 899 | "en": "Help", 900 | "da": "Hjælp", 901 | "fr": "Aide", 902 | "pl": "Pomoc", 903 | "ja": "ヘルプ", 904 | "es": "Ayuda", 905 | "de": "Hilfe", 906 | "pt-BR": "Ajuda", 907 | "sk": "Pomoc", 908 | "ru": "Справка", 909 | "uk": "Довідка", 910 | "ar": "المُساعدة", 911 | "nl": "Help", 912 | "it": "Aiuto", 913 | "zh": "帮助", 914 | "tr": "Yardım", 915 | "hr": "Pomoć", 916 | "hu": "Súgó", 917 | "sv": "Hjälp", 918 | "ml": "സഹായം", 919 | "cs": "Pomoc", 920 | "vn": "Hỗ trợ" 921 | }, 922 | "menu.help.website": { 923 | "en": "Website", 924 | "da": "Webside", 925 | "fr": "Site Web", 926 | "pl": "Przejdź do strony internetowej", 927 | "ja": "ウェブサイト", 928 | "es": "Sitio Web", 929 | "de": "Webseite", 930 | "pt-BR": "Website", 931 | "sk": "Webová stránka", 932 | "ru": "Сайт", 933 | "uk": "Сайт", 934 | "ar": "المُساعدة", 935 | "nl": "Website", 936 | "it": "Sito web", 937 | "zh": "转到网站", 938 | "tr": "Website", 939 | "hr": "Web stranica", 940 | "hu": "Weboldal", 941 | "sv": "Hemsida", 942 | "ml": "വെബ്സൈറ്റ്", 943 | "cs": "Webová stránka", 944 | "vn": "Dịa chỉ web của chúng tôi" 945 | }, 946 | "menu.help.source-code": { 947 | "en": "Source Code", 948 | "da": "Kilde Kode", 949 | "fr": "Code source", 950 | "pl": "Przejdź do źródeł programu", 951 | "ja": "ソースコード", 952 | "es": "Código fuente", 953 | "de": "Quelltext", 954 | "pt-BR": "Código-fonte", 955 | "sk": "Zdrojový kód", 956 | "ru": "Исходный код", 957 | "uk": "Програмний код", 958 | "ar": "كود المَصدر", 959 | "nl": "Broncode", 960 | "it": "Codice sorgente", 961 | "zh": "源码", 962 | "tr": "Kaynak Kod", 963 | "hr": "Izvorni kod", 964 | "hu": "Forráskód", 965 | "sv": "Källkod", 966 | "ml": "സോഴ്സ് കോഡ്", 967 | "cs": "Zdrojový kód", 968 | "vn": "Mã nguồn của Exif Cleaner" 969 | }, 970 | "menu.help.report-issue": { 971 | "en": "Report an Issue", 972 | "da": "Rapportér et problem ", 973 | "fr": "Signaler un problème", 974 | "pl": "Zgłoś problem", 975 | "ja": "バグを報告", 976 | "es": "Reportar un problema", 977 | "de": "Problem melden", 978 | "pt-BR": "Informar um problema", 979 | "sk": "Nahlásiť problém", 980 | "ru": "Сообщить о проблеме", 981 | "uk": "Повідомити про проблему", 982 | "ar": "الإبلاغ عن خطأ", 983 | "nl": "Meld een Probleem", 984 | "it": "Segnala un problema", 985 | "zh": "报告错误", 986 | "tr": "Sorun bildir", 987 | "hr": "Prijavi problem", 988 | "hu": "Hiba jelentése", 989 | "sv": "Rappotera Ett Problem", 990 | "ml": "ഒരു പ്രശ്നം രേഖപ്പെടുത്തുക", 991 | "cs": "Nahlásit problém", 992 | "vn": "Báo cáo lỗi" 993 | }, 994 | "menu.help.about": { 995 | "en": "About ", 996 | "da": "Om", 997 | "fr": "À propos d'", 998 | "pl": "O programie ", 999 | "ja": "概要 ", 1000 | "es": "Acerca de ", 1001 | "de": "Über ", 1002 | "pt-BR": "Sobre ", 1003 | "sk": "O programe ", 1004 | "ru": "О ", 1005 | "uk": "Про ", 1006 | "ar": "حَول", 1007 | "nl": "Over ", 1008 | "it": "Informazioni su ", 1009 | "zh": "关于", 1010 | "tr": "Hakkında", 1011 | "hr": "Informacije", 1012 | "hu": "Névjegy", 1013 | "sv": "Om", 1014 | "ml": "കുറിച്ച്", 1015 | "cs": "O programu", 1016 | "vn": "Về phần mềm" 1017 | }, 1018 | 1019 | "aboutwindow:copyright": { 1020 | "en": "Copyright", 1021 | "da": "Copyright", 1022 | "fr": "Copyright", 1023 | "pl": "Copyright", 1024 | "ja": "著作", 1025 | "es": "Derechos de Autor", 1026 | "de": "Urheberrecht", 1027 | "pt-BR": "Direitos autorais", 1028 | "sk": "Autorské práva", 1029 | "ru": "Авторские права", 1030 | "uk": "Авторське право", 1031 | "ar": "حقوق النَّشر", 1032 | "nl": "Copyright", 1033 | "it": "Copyright", 1034 | "zh": "版权", 1035 | "tr": "Telif hakkı", 1036 | "hr": "Autorska prava", 1037 | "hu": "Szerzői jog", 1038 | "sv": "Copyright", 1039 | "ml": "പകർപ്പവകാശം", 1040 | "cs": "Autorská práva", 1041 | "vn": "Bản quyền" 1042 | }, 1043 | 1044 | "usertasks:open-file.label": { 1045 | "en": "Open files", 1046 | "da": "Åbn filer", 1047 | "fr": "Ouvrir les fichiers", 1048 | "pl": "Otwórz pliki", 1049 | "de": "Öffne Dateien", 1050 | "pt-BR": "Abrir arquivos", 1051 | "sk": "Otvoriť súbory", 1052 | "ru": "Открыть файлы", 1053 | "uk": "Відкрити файли", 1054 | "ar": "الملفَّات المفتوحة", 1055 | "nl": "Bestanden openen", 1056 | "it": "Apri files", 1057 | "zh": "打开文件", 1058 | "tr": "Dosyaları aç", 1059 | "hr": "Otvori datoteke", 1060 | "hu": "Fájlok megnyitása", 1061 | "sv": "Öppna Filer", 1062 | "ml": "ഫയലുകൾ തുറക്കുക", 1063 | "cs": "Otevřít soubory", 1064 | "vn": "Mở tập tin" 1065 | }, 1066 | "usertasks:open-file.description": { 1067 | "en": "Remove metadata from selected files", 1068 | "da": "Fjern metadata fra valgte filer", 1069 | "fr": "Supprimer les métadonnées des fichiers sélectionnés", 1070 | "pl": "Usuń metadane z wybranych plików", 1071 | "de": "Entferne Metadaten von ausgewählten Dateien", 1072 | "pt-BR": "Remover metadados dos arquivos selecionados", 1073 | "sk": "Odstrániť metadáta z vybraných súborov", 1074 | "ru": "Удалить метаданные из выбранных файлов", 1075 | "uk": "Видалити метадані з вибранних файлів", 1076 | "ar": "إزالة البيانات الوصفيَّة من الملفَّات المُحدَّدة", 1077 | "nl": "Verwijder metagegevens van de geselecteerde bestanden", 1078 | "it": "Rimuovi i metadati dai file selezionati", 1079 | "zh": "从所选文件中删除元数据", 1080 | "tr": "Seçili dosyalardan meta verileri kaldır", 1081 | "hr": "Ukloni metapodatke iz odabranih datoteka", 1082 | "hu": "Metaadatok eltávolítása a kijelölt fájlokból", 1083 | "sv": "Ta bort metadata från valda filer", 1084 | "ml": "തിരഞ്ഞെടുത്ത ഫയലുകളിൽ നിന്ന് മെറ്റാഡാറ്റ ഇല്ലാതാക്കുക", 1085 | "cs": "Odstranit metadata z vybraných souborů", 1086 | "vn": "Xóa siêu dữ liệu từ tập tin được chọn" 1087 | } 1088 | } 1089 | -------------------------------------------------------------------------------- /.resources/win/bin/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szTheory/exifcleaner/ae09e1a2eb19f12083fa83f308ebeb5fecea2685/.resources/win/bin/.keep -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # os: osx 2 | # osx_image: xcode10.2 3 | language: node_js 4 | node_js: 5 | - "14" 6 | - "16" 7 | script: 8 | - yarn run lint 9 | - tsc 10 | # - yarn run update-exiftool 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Next release (WIP) 4 | 5 | - Add CI pipeline with Travis CI 6 | 7 | ### Features 8 | 9 | - Improved dark mode styling and start screen icon 10 | - Add translations for Croatian and Turkish 11 | 12 | ### Infrastructure 13 | 14 | - Remove node-sass and sass-loader dev dependencies 15 | - Remove Spectre Sass framework dependency and replace it with plain CSS using CSS variables 16 | 17 | ## 3.6.0 - 4 May 2021 18 | 19 | ### Security 20 | 21 | - Fix for XSS and Electron reverse shell vulnerabilities by sanitizing `exiftool` HTML output in the UI. To take advantage of this, an attacker would have had to write image metadata containing malicious script code to a file that you then download and run through ExifCleaner. Proofs of concept: 22 | 23 | XSS: 24 | 25 | ```bash 26 | exiftool -Comment='OverJT' -PixelUnits='meters' image.png 27 | ``` 28 | 29 | Electron reverse shell: 30 | 31 | ```bash 32 | exiftool -Comment='OverJT' -PixelUnits='meters' image.png 33 | ``` 34 | 35 | ## 3.5.1 - 1 May 2021 36 | 37 | ## Infrastructure 38 | 39 | - Add support for Windows "portable" releases that don't require installation 40 | 41 | ## 3.5.0 - 1 May 2021 42 | 43 | If you are running a previous version of ExifCleaner, update immediately due to a security vulnerability found in exiftool (the command-line tool that ExifCleaner uses under the hood). Thank you to all contributors for this release. As always, credits are listed in the README. 44 | 45 | ### Security 46 | 47 | - Update exiftool to 12.25 to mitigate [CVE-2021-22204 arbitrary code execution](https://twitter.com/wcbowling/status/1385803927321415687) 48 | 49 | ### Features 50 | 51 | - Add translations for Slovak, Russian, Ukranian, Danish, Arabic, Italian, Chinese (Mandarin) 52 | - Add support for the new Mac M1 ARM processors 53 | 54 | ### Infrastructure 55 | 56 | - Upgrade to Electron 11 57 | - Update some NPM dependencies 58 | - Start maintaining a CHANGELOG file in source control 59 | 60 | ### Fixes 61 | 62 | - Translation fixes for Portuguese (Brazil) and French 63 | - Update Linux AppImage category to fix exit status 1 issue 64 | 65 | ## 3.4.0 - 19 Oct 2020 66 | 67 | ### Features 68 | 69 | - Huge speed increase for file processing, especially when batch processing many files with multiple CPUs (more efficient process pool algorithm, better integration with exiftool process keep-alive) 70 | - Multilingual support with translations for French, Polish, Japanese, Spanish (Spain), German, and Portuguese (Brazil) 71 | - Mac/Windows: show progress in dock when batch processing files 72 | - Linux: fix app icon in dock 73 | - Linux: dark mode works with Ubuntu 74 | 75 | ### Bug Fixes 76 | 77 | - Linux: fix issue where icon.png was not found on startup with .deb installs 78 | 79 | ### Infrastructure 80 | 81 | - Upgrade to Electron 10 82 | - Upgrade to exiftool 12.08 83 | - Add update_exiftool.pl Perl script to automate pulling down latest ExifTool binaries and verifying their checksums 84 | - Remove a bunch of NPM dependencies 85 | 86 | ## 3.3.1 - 11 Jul 2020 87 | 88 | - Change from JavaScript to TypeScript for improved stability of compiler static analysis. 89 | - Fix Windows UTF-8 filename bug. 90 | - Remove several NPM dependencies to simplify code. 91 | - Upgrade to Electron 9. 92 | - Minor UI polish. 93 | 94 | ## 3.2.0 - 27 Apr 2020 95 | 96 | - Fix Linux version (was not using correct ExifTool binary path) 97 | - Add File -> Open menu item 98 | - Add dock icon for Linux AppImage 99 | - Mac quit entire app when File -> Close menu item is selected 100 | - Linux clean up About screen 101 | - Update app start text to show that ExifCleaner also supports video and PDF files. 102 | 103 | ## 3.1.0 - 3 Feb 2020 104 | 105 | - Drop target should follow window size when you resize it to be taller 106 | - Set a minimium window size in BrowserWindow 107 | - On macOS, when you close the window, the app should quit. 108 | - night mode better icon display opacity 109 | - night mode font not so thin 110 | - remove Automatic updates from README (feature removed) 111 | 112 | ## 3.0.0 - 18 Jan 2020 113 | 114 | - properly clean up after exiftool perl5.18 processes 115 | - disable auto update 116 | - remove esm dep. fix dev env 117 | - disable unused preferences menu item. esm modules for import with node 118 | 119 | ## 2.1.0 - 10 Jan 2020 120 | 121 | - electron 7.1.8 which should fix auto update issue in electron-build, according to some developer reports 122 | 123 | ## 2.0.0 - 4 Jan 2020 124 | 125 | - electron 7.1.2 to fix electron-builder auto update regression 126 | 127 | ## 1.5.1 - 10 Dec 2019 128 | 129 | - fix node url require 130 | 131 | ## 1.5.0 - 10 Dec 2019 132 | 133 | - drastically simplify dark mode code 134 | - debugging dark mode in Electron 6. clean up js functions/modules 135 | 136 | ## 1.4.0 - 10 Dec 2019 137 | 138 | - downgrade to Electron 6 to fix auto-update 139 | 140 | ## 1.3.5 - 10 Dec 2019 141 | 142 | - fix mainwindow callback null error 143 | 144 | ## 1.3.4 - 10 Dec 2019 145 | 146 | - Automatic updates logger fix 147 | 148 | ## 1.3.3 - 10 Dec 2019 149 | 150 | - Auto-updater debug logging 151 | 152 | ## 1.3.1 - 10 Dec 2019 153 | 154 | - Fix popover hover bounds 155 | 156 | ## 1.3.0 - 8 Dec 2019 157 | 158 | - Fix popover transparency 159 | - Fix dark mode font color for exif values 160 | 161 | ## 1.1.0 - 8 Dec 2019 162 | 163 | - First release. 164 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) szTheory (https://exifcleaner.app) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ExifCleaner 2 | 3 | ![Version](https://img.shields.io/github/v/release/szTheory/exifcleaner) ![Total Downloads](https://img.shields.io/github/downloads/szTheory/exifcleaner/total) 4 | 5 | > Desktop app to clean metadata from images, videos, PDFs, and other files. 6 | 7 | ![ExifCleaner demo](https://user-images.githubusercontent.com/28652/71770980-f04e8b80-2f2b-11ea-90f1-4393ec57adc0.gif) 8 | 9 | ## !!!!! NOTE - UPGRADE TO 3.6.0+ ASAP !!!!! 10 | 11 | If you are running a version of ExifCleaner before 3.6.0, upgrade immediately! A security vulnerability was found in exiftool, the command-line application that powers ExifCleaner under the hood, and this was updated in ExifCleaner 3.5.0. There was also an XSS and Electron remote shell vulnerability due to unsanitized HTML output that was fixed in ExifCleaner 3.6.0. 12 | 13 | ## Benefits 14 | 15 | - Fast 16 | - Drag & Drop 17 | - Free and open source (MIT) 18 | - Windows, Mac, and Linux 19 | - Supports popular image formats such as PNG, JPG, GIF, and TIFF 20 | - Supports popular video formats such as M4A, MOV, and MP4 21 | - Supports PDF documents\* (partial, [see discussion](https://github.com/szTheory/exifcleaner/issues/111)) 22 | - Batch-processing 23 | - Multi-core support 24 | - Dark mode (automatic) 25 | - No automatic updates or network traffic 26 | - Multi-language support 27 | - Relatively few NPM dependencies (no JS frameworks) 28 | 29 | ## Drawbacks 30 | 31 | - Executable size `~200MB` (Electron app) 32 | - Memory usage `~120MB` (Electron app) 33 | - PDF metadata removal is only partial ([see discussion](https://github.com/szTheory/exifcleaner/issues/111)) 34 | - Does not remove extended filesystem attributes ([see discussion](https://github.com/szTheory/exifcleaner/issues/86)) 35 | 36 | ## Download and Install 37 | 38 | Linux, macOS 10.10+, and Windows 7+ are supported (64-bit only). 39 | 40 | - **Linux**: [Download the .AppImage, .deb, or .rpm file](https://github.com/szTheory/exifcleaner/releases/latest) 41 | - **macOS**: [Download the .dmg file](https://github.com/szTheory/exifcleaner/releases/latest) 42 | - **Windows**: [Download the .exe file](https://github.com/szTheory/exifcleaner/releases/latest) 43 | 44 | For Linux, The AppImage needs to be [made executable](https://discourse.appimage.org/t/how-to-make-an-appimage-executable/80) after download. 45 | 46 | Arch Linux users can install the app from the AUR using an AUR helper (such as `yay` or `paru`): 47 | 48 | ```bash 49 | paru -S exifcleaner-bin 50 | ``` 51 | 52 | ## Links 53 | 54 | - [Official Website](https://exifcleaner.com) 55 | - [Download](https://github.com/szTheory/exifcleaner/releases) 56 | - [Source Code](https://github.com/szTheory/exifcleaner) 57 | - [Issue Tracker](https://github.com/szTheory/exifcleaner/issues) 58 | - [Translations file](https://github.com/szTheory/exifcleaner/blob/master/.resources/strings.json) 59 | 60 | ## Supported File Types 61 | 62 | Below is a full list of supported file types that ExifCleaner will remove metadata for. It's based on which file types [ExifTool](https://exiftool.org/) supports write operations for. 63 | 64 | - **3G2, 3GP2** – 3rd Gen. Partnership Project 2 a/v (QuickTime-based) 65 | - **3GP, 3GPP** – 3rd Gen. Partnership Project a/v (QuickTime-based) 66 | - **AAX** – Audible Enhanced Audiobook (QuickTime-based) 67 | - **AI, AIT** – Adobe Illustrator [Template] (PS or PDF) 68 | - **ARQ** – Sony Alpha Pixel-Shift RAW (TIFF-based) 69 | - **ARW** – Sony Alpha RAW (TIFF-based) 70 | - **AVIF** – AV1 Image File Format (QuickTime-based) 71 | - **CR2** – Canon RAW 2 (TIFF-based) (CR2 spec) 72 | - **CR3** – Canon RAW 3 (QuickTime-based) (CR3 spec) 73 | - **CRM** – Canon RAW Movie (QuickTime-based) 74 | - **CRW, CIFF** – Canon RAW Camera Image File Format (CRW spec) 75 | - **CS1** – Sinar CaptureShop 1-shot RAW (PSD-based) 76 | - **DCP DNG** – Camera Profile (DNG-like) 77 | - **DNG** – Digital Negative (TIFF-based) 78 | - **DR4** – Canon DPP version 4 Recipe 79 | - **DVB** – Digital Video Broadcasting (QuickTime-based) 80 | - **EPS, EPSF, PS** – [Encapsulated] PostScript Format 81 | - **ERF** – Epson RAW Format (TIFF-based) 82 | - **EXIF** – Exchangeable Image File Format metadata (TIFF-based) 83 | - **EXV** – Exiv2 metadata file (JPEG-based) 84 | - **F4A, F4B, F4P, F4V** – Adobe Flash Player 9+ Audio/Video (QuickTime-based) 85 | - **FFF** – Hasselblad Flexible File Format (TIFF-based) 86 | - **FLIF** – Free Lossless Image Format 87 | - **GIF** – Compuserve Graphics Interchange Format 88 | - **GPR** – GoPro RAW (DNG-based) 89 | - **HDP, WDP, JXR** – Windows HD Photo / Media Photo / JPEG XR (TIFF-based) 90 | - **HEIC, HEIF** – High Efficiency Image Format (QuickTime-based) 91 | - **ICC, ICM** – International Color Consortium color profile 92 | - **IIQ** – Phase One Intelligent Image Quality RAW (TIFF-based) 93 | - **IND, INDD, INDT** – Adobe InDesign Document/Template 94 | - **INSP** – Insta360 Picture (JPEG-based) 95 | - **JP2, JPF, JPM, JPX** – JPEG 2000 image [Compound/Extended] 96 | - **JPEG, JPG, JPE** – Joint Photographic Experts Group image 97 | - **LRV** – Low-Resolution Video (QuickTime-based) 98 | - **M4A, M4B, M4P, M4V** – MPEG-4 Audio/Video (QuickTime-based) 99 | - **MEF** – Mamiya (RAW) Electronic Format (TIFF-based) 100 | - **MIE** – Meta Information Encapsulation (MIE specification) 101 | - **MOS** – Creo Leaf Mosaic (TIFF-based) 102 | - **MOV, QT** – Apple QuickTime Movie 103 | - **MP4** – Motion Picture Experts Group version 4 (QuickTime-based) 104 | - **MPO** – Extended Multi-Picture format (JPEG with MPF extensions) 105 | - **MQV** – Sony Mobile QuickTime Video 106 | - **NEF** – Nikon (RAW) Electronic Format (TIFF-based) 107 | - **NRW** – Nikon RAW (2) (TIFF-based) 108 | - **ORF** – Olympus RAW Format (TIFF-based) 109 | - **PDF** – Adobe Portable Document Format 110 | - **PEF** – Pentax (RAW) Electronic Format (TIFF-based) 111 | - **PNG, JNG, MNG** – Portable/JPEG/Multiple-image Network Graphics 112 | - **PPM, PBM, PGM** – Portable Pixel/Bit/Gray Map 113 | - **PSD, PSB, PSDT** – PhotoShop Document / Large Document / Template 114 | - **QTIF, QTI, QIF** – QuickTime Image File 115 | - **RAF** – FujiFilm RAW Format 116 | - **RAW** – Panasonic RAW (TIFF-based) 117 | - **RW2** – Panasonic RAW 2 (TIFF-based) 118 | - **RWL** – Leica RAW (TIFF-based) 119 | - **SR2** – Sony RAW 2 (TIFF-based) 120 | - **SRW** – Samsung RAW format (TIFF-based) 121 | - **THM** – Thumbnail image (JPEG) 122 | - **TIFF, TIF** – Tagged Image File Format 123 | - **VRD** – Canon DPP Recipe Data 124 | - **X3F** – Sigma/Foveon RAW 125 | - **XMP** – Extensible Metadata Platform sidecar file 126 | 127 | ## File writer limitations 128 | 129 | ExifCleaner has the same writer limitations as the underlying `exiftool` it depends on. Taken from the [official website](https://exiftool.org/#limitations): 130 | 131 | - ExifTool will not rewrite a file if it detects a significant problem with the file format. 132 | - ExifTool has been tested with a wide range of different images, but since it is not possible to test it with every known image type, there is the possibility that it will corrupt some files. Be sure to keep backups of your files. 133 | - Even though ExifTool does some validation of the information written, it is still possible to write illegal values which may cause problems when reading the images with other software. So take care to validate the information you are writing. 134 | - ExifTool is not guaranteed to remove metadata completely from a file when attempting to delete all metadata. For JPEG images, all APP segments (except Adobe APP14, which is not removed by default) and trailers are removed which effectively removes all metadata, but for other formats the results are less complete: 135 | - JPEG - APP segments (except Adobe APP14) and trailers are removed. 136 | - TIFF - XMP, IPTC, ICC_Profile and the ExifIFD are removed, but some EXIF may remain in IFD0. (The CommonIFD0 Shortcut tag is provided to simplify removal of common metadata tags from IFD0.) 137 | - PNG - Only XMP, EXIF, ICC_Profile and native PNG textual data chunks are removed. 138 | - PDF - The original metadata is never actually removed. 139 | - PS - Only XMP and some native PostScript tags may be deleted. 140 | - MOV/MP4 - Most top-level metadata is removed. 141 | - RAW formats - It is not recommended to remove all metadata from RAW images because this will likely remove some proprietary information that is necessary for proper rendering of the image. 142 | 143 | ## Translations 144 | 145 | New translations and corrections to existing translations are welcome! See the [Adding a Translation](https://github.com/szTheory/exifcleaner/#adding-a-translation) section if there is a language you would like to add. Here is the current translations status: 146 | 147 | - Arabic ✅ by [@ZER0-X](https://github.com/ZER0-X) 148 | - Chinese (Mandarin) ✅ by [MarcusPierce](https://github.com/MarcusPierce) 149 | - Croatian ✅ by [@milotype](https://github.com/milotype) 150 | - Czech ✅ by [@t0mzSK](https://github.com/t0mzSK) 151 | - Danish ✅ by [@zlatco](https://github.com/zlatco) 152 | - Dutch ✅ by [@rvl-code](https://github.com/rvl-code) 153 | - French (France) ✅ by [@NathanBnm](https://github.com/NathanBnm) (Nathan Bonnemains) 154 | - French (Quebec) ❌ needs translation if France version is not sufficient 155 | - German ✅ by [@tayfuuun](https://github.com/tayfuuun), with updates by [@philippsandhaus](https://github.com/philippsandhaus) 156 | - Hungarian ✅ by [@icetee](https://github.com/icetee) (Tamás András Horváth) 157 | - Italian ✅ by [@PolpOnline](https://github.com/PolpOnline) 158 | - Japanese ✅ by @AKKED 159 | - Malayalam by ✅ by [@theunknownKiran](https://github.com/theunknownKiran) 160 | - Polish ✅ by [@m1chu](https://github.com/m1chu) 161 | - Portuguese (Brazil) ✅ by [@iraamaro](https://github.com/iraamaro), with updates by @dadodollabela 162 | - Portuguese (Portugal) ❌ needs translation if Brazil version is not sufficient 163 | - Russian ✅ by [@likhner](https://github.com/likhner) (Arthur Likhner) 164 | - Spanish (Spain) ✅ by [@ff-ss](https://github.com/ff-ss) (Francisco) 165 | - Spanish (Latin America) ❌ needs translation if Spain version is not sufficient 166 | - Swedish ✅ by [@sastofficial](https://github.com/sastofficial) 167 | - Slovak ✅ by [@LiJu09](https://github.com/LiJu09) 168 | - Turkish ✅ by [@bsonmez](https://github.com/bsonmez) (Burak Sonmez) 169 | - Ukranian ✅ by [@hugonote](https://github.com/hugonote) (Alexander Berger) 170 | - Vietnamese ✅ by [@tensingnightco](https://github.com/tensingnightco) 171 | 172 | ## Verifying checksum of downloads from the Github releases page 173 | 174 | Download the `latest.yml` (Windows), `latest-mac.yml` (Mac), or `latest-linux.yml` (Linux) file from the release page that corresponds to your operating system. Then run the following command to generate a sha checksum. ExifCleaner 3.5.0 is used here as an example. 175 | 176 | On Mac, Linux, and on Windows using the Linux Subsystem for Windows: 177 | 178 | ```bash 179 | sha512sum ExifCleaner-Setup-3.5.0.exe | cut -f1 -d\ | xxd -r -p | base64 180 | ``` 181 | 182 | The output should match the sha512 value in the latest.yml file for the version you downloaded. As of now there is no checksum generated for the Linux RPM version (appears to be an electron-build issue, see [Github issue here](https://github.com/szTheory/exifcleaner/issues/141)). 183 | 184 | ## Development 185 | 186 | Built with [Electron](https://electronjs.org). Uses [node-exiftool](https://www.npmjs.com/package/node-exiftool) as a wrapper for [Exiftool](https://exiftool.org/) binaries. To see the current list of NPM dependencies, run: 187 | 188 | ```bash 189 | yarn list --production 190 | ``` 191 | 192 | ### Run the app in dev mode 193 | 194 | Clone the repository and cd into the directory. 195 | 196 | ```bash 197 | git clone https://github.com/szTheory/exifcleaner.git 198 | cd exifcleaner 199 | ``` 200 | 201 | Next, install the NPM package dependencies. 202 | 203 | ```bash 204 | yarn install 205 | ``` 206 | 207 | Pull down the latest ExifTool binaries (in Windows, run this within the Linux Subsystem for Windows): 208 | 209 | ```bash 210 | yarn run update-exiftool 211 | ``` 212 | 213 | Finally, launch the application. This supports Hot Module Reload (HMR) so you will automatically see your changes every time you save a file. 214 | 215 | ```bash 216 | yarn run dev 217 | ``` 218 | 219 | ### Contributing 220 | 221 | This app is mostly feature complete. I want to keep it simple and not add a bunch of bloat to it. And I want to avoid release churn. That said, there are a couple small features that might be worth adding. And there are a few minor bugs or points of cleanup that would be worth polishing. If you'd like to help check out the [Issue Tracker](https://github.com/szTheory/exifcleaner/issues) which contains an exhaustive list of known issues. Just pick one and submit a Pull Request or leave a comment and I can provide guidance or help if you need it. Make sure to test the app out to see if it still works though. There isn't much going on in this app so it should be easy enough to do. I might add some automated tests later on to help with this. For now it's just been me working on the app so manual testing has worked out fine. 222 | 223 | TypeScript code is formatted using Prettier. 224 | 225 | ### Adding a Translation 226 | 227 | Adding a translation is easy. All you have to do is go to [the translation list](https://github.com/szTheory/exifcleaner/blob/master/.resources/strings.json), click on "Edit this file", and add an entry for the new language underneath the other ones. So for example if you wanted to add a Spanish translation, where it says: 228 | 229 | ```json 230 | "empty.title": { 231 | "en": "No files selected", 232 | "fr": "Aucun fichier sélectionné" 233 | }, 234 | ``` 235 | 236 | You just add a line for `"es"` (list of language codes [here](https://www.electronjs.org/docs/api/locales)) underneath the other ones: 237 | 238 | ```json 239 | "empty.title": { 240 | "en": "No files selected", 241 | "fr": "Aucun fichier sélectionné", 242 | "es": "Spanish translation here" 243 | }, 244 | ``` 245 | 246 | and repeat that pattern for each of the entries. That's probably the easiest way to contribute. If you want to be able to see all of your translations working in a live app before submitting, you can also do this: 247 | 248 | 1. Fork the project on Github 249 | 2. Follow the directions [here](https://github.com/szTheory/exifcleaner#run-the-app-in-dev-mode) to get ExifCleaner running in development mode on your computer 250 | 3. Then update the `strings.json` file as mentioned above, and quit the program and relaunch it to see your changes. When you're finished, commit your changes from the command line with for example `git commit -am "Finished adding translations"`. Then run `git push origin master`, and go to the project URL your forked it to (for example ) and click the button to open a new Pull Request. 251 | 252 | If you want to run the app with a specific locale without changing your system preferences, use one of the following commands with the correct language code. If you don't see your language listed below, just follow the pattern and plug in your own language code [from this list](https://www.electronjs.org/docs/api/locales). 253 | 254 | ```bash 255 | yarn run dev --lang=en #English 256 | yarn run dev --lang=fr #French 257 | yarn run dev --lang=pl #Polish 258 | yarn run dev --lang=ja #Japanese 259 | yarn run dev --lang=es #Spanish 260 | yarn run dev --lang=de #German 261 | ``` 262 | 263 | Let me know if you run into any issues, I can guide you through the process if you get stuck. 264 | 265 | ### Linux AppImage Notes 266 | 267 | To mount the AppImage and inspect it's contents: 268 | 269 | ```bash 270 | ./ExifCleaner-x.y.z.AppImage --appimage-mount 271 | ``` 272 | 273 | Where `x.y.z` is the release version number 274 | 275 | ### Smoke test checklist for new releases 276 | 277 | On all platforms: 278 | 279 | - Linux 280 | - Windows 281 | - Mac 282 | 283 | Perform the following manual tests before a release: 284 | 285 | - Drag and drop hundreds of files 286 | - File -> Open dialog 287 | - Switch locale to each language and check translations 288 | - Switch between light and dark mode 289 | - Open "About" dialog 290 | 291 | ### Publishing a new release 292 | 293 | This section is really for my own reference when publishing a new release. 294 | 295 | Bump the version with `release` (choose a "pre" release for point releases for testing): 296 | 297 | ```bash 298 | yarn run release 299 | ``` 300 | 301 | Check the [Github release page](https://github.com/szTheory/exifcleaner/releases) and confirm a new draft release was created. Then run the publish command: 302 | 303 | ```bash 304 | yarn run publish 305 | ``` 306 | 307 | Once you're happy with the release and want to finalize it, remove the draft flag on the Github releases page. 308 | 309 | ### Contributors 310 | 311 | Thanks to all the people who submitted bug reports and fixes. I've tried to include everyone so if I've missed you it was by accident, just let me know and I'll add you. 312 | 313 | - [@m1chu](https://github.com/m1chu) - Polish translation, fix for Mac dock bug on non-Mac platforms, help debugging Unicode filename bug 314 | - [@LukasThyWalls](https://github.com/LukasThyWalls) - help debugging Unicode filename bug, feature suggestions 315 | - @AKKED - Japanese translation, help debugging Unicode filename bug 316 | - [@TomasGutierrez0](https://github.com/TomasGutierrez0) - help auditing ExifTool dependency 317 | - [@5a384507-18ce-417c-bb55-d4dfcc8883fe](https://github.com/5a384507-18ce-417c-bb55-d4dfcc8883fe) - help debugging initial Linux version 318 | - [@totoroot](https://github.com/totoroot) - help debugging Linux AppImage installer, usability feedback, feature suggestions 319 | - [@Scopuli](https://github.com/Scopuli) - help debugging Linux AppImage installer 320 | - [@Tox86](https://github.com/Tox86) - found broken Settings menu item bug 321 | - [@ff-ss](https://github.com/ff-ss) (Francisco) - Spanish translation 322 | - [@tayfuuun](https://github.com/tayfuuun) - German translation 323 | - [@philippsandhaus](https://github.com/philippsandhaus) - German translation fixes 324 | - [@airvue](https://github.com/airvue) - Help debugging Ubuntu .deb package error 325 | - [@Goblin80](https://github.com/Goblin80) - Help debugging Ubuntu .deb package error 326 | - [@zahroc](https://github.com/zahroc) - Help diagnosing error when adding bulk directories 327 | - [@iraamaro](https://github.com/iraamaro) - Portuguese (Brazil) translation. Fix for update_exiftool.pl when building from source on Debian and Slackware 328 | - [@LiJu09](https://github.com/LiJu09) - Slovak translation 329 | - [@likhner](https://github.com/likhner) (Arthur Likhner) - Russian translation 330 | - [@hugonote](https://github.com/hugonote) (Alexander Berger) - Ukranian translation 331 | - @dadodollabela - Portuguese (Brazil) translation fixes 332 | - [@zlatco](https://github.com/zlatco) - Danish translation 333 | - [@ZER0-X](https://github.com/ZER0-X) - Arabic translation 334 | - [@rvl-code](https://github.com/rvl-code) - Dutch translation 335 | - [@PolpOnline](https://github.com/PolpOnline) - Italian translation, Arch Linux distribution maintainer 336 | - [@NathanBnm](https://github.com/NathanBnm) (Nathan Bonnemains) - French translation 337 | - [@Dyrimon](https://github.com/Dyrimon) - Linux AppImage error notification fix 338 | - [@MarcusPierce](https://github.com/MarcusPierce) - Chinese (Mandarin) translation 339 | - [@brandonlou](https://github.com/brandonlou) - Heads up on updating exiftool to 12.24+ to mitigate [CVE-2021-22204 arbitrary code execution](https://twitter.com/wcbowling/status/1385803927321415687) 340 | - [@v4k0nd](https://github.com/v4k0nd) (Szabó Krisztián) - Help building instructions on verifying release checksums 341 | - [@papb](https://github.com/papb) - Help setting up Windows portable build 342 | - [@Bellisario](https://github.com/Bellisario) - Help setting up Windows portable build 343 | - [@overjt](https://github.com/overjt) (Jonathan Toledo) - Proof of concept for XSS and Electron remote shell vulnerability 344 | - [@bsonmez](https://github.com/bsonmez) (Burak Sonmez) - Turkish translation 345 | - [@milotype](https://github.com/milotype) - Croatian translation 346 | - [@icetee](https://github.com/icetee) - Hungarian translation 347 | - [@sastofficial](https://github.com/sastofficial) - Swedish translation 348 | - [@theunknownKiran](https://github.com/theunknownKiran) - Malayalam translation 349 | - [@t0mzSK](https://github.com/t0mzSK) - Czech translation 350 | - [@tensingnightco](https://github.com/tensingnightco) - Vietnamese translation 351 | -------------------------------------------------------------------------------- /build/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szTheory/exifcleaner/ae09e1a2eb19f12083fa83f308ebeb5fecea2685/build/background.png -------------------------------------------------------------------------------- /build/background@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szTheory/exifcleaner/ae09e1a2eb19f12083fa83f308ebeb5fecea2685/build/background@2x.png -------------------------------------------------------------------------------- /build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szTheory/exifcleaner/ae09e1a2eb19f12083fa83f308ebeb5fecea2685/build/icon.icns -------------------------------------------------------------------------------- /build/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szTheory/exifcleaner/ae09e1a2eb19f12083fa83f308ebeb5fecea2685/build/icon.png -------------------------------------------------------------------------------- /electron-webpack.json: -------------------------------------------------------------------------------- 1 | { 2 | "renderer": { 3 | "template": "src/renderer/index.html" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "exifcleaner", 3 | "productName": "ExifCleaner", 4 | "version": "3.6.0", 5 | "description": "Clean exif metadata from images, videos, and PDF documents", 6 | "license": "MIT", 7 | "repository": "github:szTheory/exifcleaner", 8 | "main": "src/main/index.js", 9 | "author": { 10 | "name": "szTheory", 11 | "email": "szTheory@users.noreply.github.com", 12 | "url": "https://exifcleaner.com" 13 | }, 14 | "scripts": { 15 | "update-exiftool": "./update_exiftool.pl", 16 | "postinstall": "electron-builder install-app-deps", 17 | "format": "yarn prettier --write 'src/**/*.ts'", 18 | "lint": "prettier --check 'src/**/*.ts'", 19 | "start": "electron .", 20 | "packmactest": "yarn run compile && electron-builder --dir -c.compression=store -c.mac.identity=null", 21 | "packwin": "yarn run compile && electron-builder --windows", 22 | "packlinux": "yarn run compile && electron-builder --linux", 23 | "packmac": "yarn run compile && electron-builder --macos -c.mac.identity=null", 24 | "build": "yarn run compile && electron-builder --macos --linux --windows", 25 | "publish": "yarn run compile && electron-builder --macos --linux --windows -p always", 26 | "release": "np", 27 | "dev": "electron-webpack dev", 28 | "compile": "electron-webpack" 29 | }, 30 | "dependencies": { 31 | "node-exiftool": "2.3.0", 32 | "source-map-support": "^0.5" 33 | }, 34 | "devDependencies": { 35 | "@types/node": "^12.0", 36 | "electron": "^11.0", 37 | "electron-builder": "^22.8", 38 | "electron-webpack": "^2.8", 39 | "electron-webpack-ts": "^4.0", 40 | "prettier": "2.1", 41 | "typescript": "^3.8", 42 | "webpack": "^4.41" 43 | }, 44 | "np": { 45 | "publish": false, 46 | "releaseDraft": false 47 | }, 48 | "build": { 49 | "publish": { 50 | "provider": "github", 51 | "owner": "szTheory", 52 | "repo": "exifcleaner", 53 | "protocol": "https" 54 | }, 55 | "appId": "com.exifcleaner", 56 | "mac": { 57 | "category": "public.app-category.graphics-and-images", 58 | "darkModeSupport": true, 59 | "extraResources": [ 60 | { 61 | "from": ".resources/nix/bin", 62 | "to": "nix/bin", 63 | "filter": [ 64 | "**/*" 65 | ] 66 | } 67 | ] 68 | }, 69 | "dmg": { 70 | "iconSize": 160, 71 | "contents": [ 72 | { 73 | "x": 180, 74 | "y": 170 75 | }, 76 | { 77 | "x": 480, 78 | "y": 170, 79 | "type": "link", 80 | "path": "/Applications" 81 | } 82 | ] 83 | }, 84 | "linux": { 85 | "target": [ 86 | "appImage", 87 | "deb", 88 | "rpm" 89 | ], 90 | "icon": "build/icon.icns", 91 | "category": "Graphics", 92 | "extraResources": [ 93 | { 94 | "from": ".resources/nix/bin", 95 | "to": "nix/bin", 96 | "filter": [ 97 | "**/*" 98 | ] 99 | } 100 | ] 101 | }, 102 | "win": { 103 | "target": [ 104 | { 105 | "target": "nsis", 106 | "arch": [ 107 | "x64", 108 | "ia32" 109 | ] 110 | }, 111 | { 112 | "target": "portable" 113 | } 114 | ], 115 | "extraResources": [ 116 | { 117 | "from": ".resources/win/bin", 118 | "to": "win/bin", 119 | "filter": [ 120 | "**/*" 121 | ] 122 | } 123 | ] 124 | }, 125 | "extraResources": [ 126 | { 127 | "from": ".resources/", 128 | "to": "", 129 | "filter": [ 130 | "strings.json", 131 | "*.png" 132 | ] 133 | } 134 | ] 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/common/app.ts: -------------------------------------------------------------------------------- 1 | import { app, remote } from "electron"; 2 | 3 | export function currentApp(): Electron.App { 4 | return app ?? remote.app; 5 | } 6 | 7 | export function currentAppPath(): string { 8 | return currentApp().getAppPath(); 9 | } 10 | -------------------------------------------------------------------------------- /src/common/binaries.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { getPlatform, Platform } from "./platform"; 3 | import { resourcesPath } from "./resources"; 4 | 5 | enum BinaryPlatformSubpath { 6 | Win = "win", 7 | Nix = "nix", 8 | } 9 | 10 | enum BinFilename { 11 | Win = "exiftool.exe", 12 | Nix = "exiftool", 13 | } 14 | 15 | function binariesPath(): string { 16 | return path.join(resourcesPath(), binaryPlatformSubpath(), "bin"); 17 | } 18 | 19 | function binaryPlatformSubpath(): BinaryPlatformSubpath { 20 | const platform = getPlatform(); 21 | 22 | switch (getPlatform()) { 23 | case Platform.WIN: 24 | return BinaryPlatformSubpath.Win; 25 | case Platform.NIX: 26 | case Platform.MAC: 27 | return BinaryPlatformSubpath.Nix; 28 | default: 29 | throw `Could not determine dev Exiftool binary subpath for platform ${platform}`; 30 | } 31 | } 32 | 33 | function binaryFilename(): string { 34 | const platform = getPlatform(); 35 | 36 | switch (platform) { 37 | case Platform.WIN: 38 | return BinFilename.Win; 39 | case Platform.NIX: 40 | case Platform.MAC: 41 | return BinFilename.Nix; 42 | default: 43 | throw `Could not determine the ExifTool binary path for platform ${platform}`; 44 | } 45 | } 46 | 47 | function getExifToolBinPath(): string { 48 | return path.resolve(binariesPath(), binaryFilename()); 49 | } 50 | 51 | export const exiftoolBinPath = getExifToolBinPath(); 52 | -------------------------------------------------------------------------------- /src/common/browser_window.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow } from "electron"; 2 | 3 | export function currentBrowserWindow( 4 | browserWindow: BrowserWindow | null | undefined 5 | ): BrowserWindow | null { 6 | if (!browserWindow) { 7 | browserWindow = BrowserWindow.getAllWindows()[0]; 8 | } 9 | 10 | return browserWindow; 11 | } 12 | 13 | export function defaultBrowserWindow( 14 | browserWindow: BrowserWindow | null | undefined 15 | ): BrowserWindow { 16 | if (!browserWindow) { 17 | browserWindow = currentBrowserWindow(browserWindow); 18 | if (!browserWindow) { 19 | throw new Error( 20 | "Could not load file open menu because browser window was not initialized." 21 | ); 22 | } 23 | } 24 | 25 | return browserWindow; 26 | } 27 | 28 | export function restoreWindowAndFocus( 29 | browserWindow: BrowserWindow | null | undefined 30 | ): void { 31 | browserWindow = defaultBrowserWindow(browserWindow); 32 | if (browserWindow.isMinimized()) { 33 | browserWindow.restore(); 34 | } 35 | browserWindow.show(); 36 | browserWindow.focus(); 37 | } 38 | -------------------------------------------------------------------------------- /src/common/env.ts: -------------------------------------------------------------------------------- 1 | import electron from "electron"; 2 | 3 | if (typeof electron === "string") { 4 | throw new TypeError("Not running in an Electron environment!"); 5 | } 6 | 7 | export function isProd(): boolean { 8 | return process.env.NODE_ENV === "production"; 9 | } 10 | 11 | export function isDev(): boolean { 12 | return !isProd(); 13 | } 14 | -------------------------------------------------------------------------------- /src/common/exif_tool_processes.ts: -------------------------------------------------------------------------------- 1 | import os from "os"; 2 | import { ExiftoolProcess } from "node-exiftool"; 3 | import { exiftoolBinPath } from "../common/binaries"; 4 | 5 | export function spawnExifToolProcesses( 6 | maxNumProcesses: number 7 | ): ExiftoolProcess[] { 8 | const numProcesses = Math.min(os.cpus().length, maxNumProcesses); 9 | 10 | return [...Array(numProcesses)].map((n) => { 11 | return newExifToolProcess(); 12 | }); 13 | } 14 | 15 | function newExifToolProcess(): ExiftoolProcess { 16 | return new ExiftoolProcess(exiftoolBinPath); 17 | } 18 | -------------------------------------------------------------------------------- /src/common/i18n.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import { resourcesPath } from "./resources"; 4 | 5 | // Locales list: https://www.electronjs.org/docs/api/locales 6 | export enum Locale { 7 | Chinese = "zh", 8 | English = "en", 9 | French = "fr", 10 | German = "de", 11 | Italian = "it", 12 | Japanese = "ja", 13 | Polish = "pl", 14 | Portuguese = "pt", 15 | Russian = "ru", 16 | Spanish = "es", 17 | Hungarian = "hu", 18 | } 19 | 20 | type I18nStringSet = { 21 | [locale: string]: string; 22 | }; 23 | 24 | type I18nStringsDictionary = { 25 | [key: string]: I18nStringSet; 26 | }; 27 | 28 | let strings: I18nStringsDictionary | null = null; 29 | 30 | export function i18n(key: string, locale: string): string { 31 | if (!strings) { 32 | throw new Error("i18n strings file not loaded"); 33 | } 34 | 35 | const i18nString = strings[key]; 36 | if (!i18nString) { 37 | throw new Error( 38 | `Could not find localization strings while reading text for ${key}` 39 | ); 40 | } 41 | // prefer locale, then fallback locale, then default to English 42 | const text = 43 | i18nString[locale] || 44 | i18nString[fallbackLocale(locale)] || 45 | i18nString[Locale.English]; 46 | if (!text) { 47 | throw new Error(`Could not find interface text for ${key}`); 48 | } 49 | 50 | return text; 51 | } 52 | 53 | export function preloadI18nStrings(): void { 54 | if (strings) { 55 | return; 56 | } 57 | 58 | strings = JSON.parse(stringsFile()); 59 | } 60 | 61 | // Select a fallback for each "dialect" if it doesn't already 62 | // have its own translation more specific than the main entry 63 | // Locales list: https://www.electronjs.org/docs/api/locales 64 | export function fallbackLocale(locale: string): string { 65 | switch (locale) { 66 | case "zh-CN": //Chinese (Simplified) 67 | case "zh-TW": //Chinese (Traditional) 68 | return Locale.Chinese; 69 | 70 | case "fr-CA": //French (Canada) 71 | case "fr-CH": //French (Switzerland) 72 | case "fr-FR": //French (France) 73 | return Locale.French; 74 | 75 | case "de-AT": //German (Austria) 76 | case "de-CH": //German (Switzerland) 77 | case "de-DE": //German (Germany) 78 | return Locale.German; 79 | 80 | case "pt-BR": //Portuguese (Brazil) 81 | case "pt-PT": //Portuguese (Portugal) 82 | return Locale.Portuguese; 83 | 84 | case "it-CH": //Italian (Switzerland) 85 | case "it-IT": //Italian (Italy) 86 | return Locale.Italian; 87 | 88 | case "es-419": //Spanish (Latin America) 89 | return Locale.Spanish; 90 | 91 | default: 92 | //default to English 93 | return Locale.English; 94 | } 95 | } 96 | 97 | function stringsFile() { 98 | return fs.readFileSync(stringsFilePath(), "utf8"); 99 | } 100 | 101 | function stringsFilePath(): string { 102 | return path.join(resourcesPath(), "strings.json"); 103 | } 104 | -------------------------------------------------------------------------------- /src/common/platform.ts: -------------------------------------------------------------------------------- 1 | import { platform } from "os"; 2 | 3 | export enum Platform { 4 | NIX, 5 | WIN, 6 | MAC, 7 | } 8 | 9 | export function isMac(): boolean { 10 | return getPlatform() == Platform.MAC; 11 | } 12 | export function isWindows(): boolean { 13 | return getPlatform() == Platform.WIN; 14 | } 15 | export function isLinux(): boolean { 16 | return getPlatform() == Platform.NIX; 17 | } 18 | 19 | export function getPlatform(): Platform { 20 | const currentPlatform = platform(); 21 | 22 | switch (currentPlatform) { 23 | case "aix": 24 | case "freebsd": 25 | case "linux": 26 | case "openbsd": 27 | case "android": 28 | case "sunos": 29 | return Platform.NIX; 30 | case "darwin": 31 | return Platform.MAC; 32 | case "win32": 33 | return Platform.WIN; 34 | default: 35 | throw `Did not recognize platform ${currentPlatform}`; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/common/resources.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { isProd } from "./env"; 3 | 4 | const ICON_FILENAME = "icon.png"; 5 | const CHECKMARK_FILENAME = "check.png"; 6 | 7 | const DevResourcesDirName = ".resources"; 8 | 9 | export function resourcesPath(): string { 10 | return isProd() 11 | ? process.resourcesPath 12 | : path.join(process.cwd(), DevResourcesDirName); 13 | } 14 | 15 | export function iconPath(): string { 16 | const basePath = path.join(resourcesPath(), ICON_FILENAME); 17 | // Fix for Linux 18 | const pathFixed = basePath.replace(/\\/g, "\\\\"); 19 | 20 | return pathFixed; 21 | } 22 | 23 | export function checkmarkPath(): string { 24 | return path.join(resourcesPath(), CHECKMARK_FILENAME); 25 | } 26 | -------------------------------------------------------------------------------- /src/main/app_setup.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow } from "electron"; 2 | import { 3 | currentBrowserWindow, 4 | restoreWindowAndFocus, 5 | } from "../common/browser_window"; 6 | import { createMainWindow } from "./window_setup"; 7 | import { isWindows } from "../common/platform"; 8 | import { fileOpen } from "./file_open"; 9 | 10 | function preventMultipleAppInstances(): void { 11 | if (!app.requestSingleInstanceLock()) { 12 | app.quit(); 13 | } 14 | } 15 | 16 | function openMinimizedIfAlreadyExists( 17 | browserWindow: BrowserWindow | null 18 | ): void { 19 | app.on( 20 | "second-instance", 21 | (_event: Event, argv: string[], _workingDirectory: string) => { 22 | console.log(argv); 23 | if (isWindows() && argv.length > 0 && argv.includes("--open-file")) { 24 | fileOpen(browserWindow); 25 | return; 26 | } 27 | 28 | restoreWindowAndFocus(browserWindow); 29 | } 30 | ); 31 | } 32 | 33 | function quitOnWindowsAllClosed(): void { 34 | app.on("window-all-closed", () => { 35 | app.quit(); 36 | }); 37 | } 38 | 39 | function createWindowOnActivate(browserWindow: BrowserWindow | null): void { 40 | app.on("activate", () => { 41 | browserWindow = currentBrowserWindow(browserWindow); 42 | if (!browserWindow) { 43 | browserWindow = createMainWindow(); 44 | } 45 | }); 46 | } 47 | 48 | export function setupApp(browserWindow: BrowserWindow | null): void { 49 | preventMultipleAppInstances(); 50 | openMinimizedIfAlreadyExists(browserWindow); 51 | quitOnWindowsAllClosed(); 52 | createWindowOnActivate(browserWindow); 53 | } 54 | -------------------------------------------------------------------------------- /src/main/context_menu.ts: -------------------------------------------------------------------------------- 1 | import { app, Menu, MenuItem, BrowserWindow } from "electron"; 2 | import { i18n } from "./i18n"; 3 | 4 | function buildMenu(canCopy: boolean): Menu { 5 | const menu = new Menu(); 6 | 7 | if (canCopy) { 8 | menu.append( 9 | new MenuItem({ 10 | label: i18n("contextmenu.copy"), 11 | role: "copy", 12 | visible: canCopy, 13 | enabled: canCopy, 14 | }) 15 | ); 16 | } 17 | menu.append( 18 | new MenuItem({ label: i18n("contextmenu.select-all"), role: "selectAll" }) 19 | ); 20 | return menu; 21 | } 22 | 23 | export function setupContextMenu(): void { 24 | app.on( 25 | "browser-window-created", 26 | (event: Event, browserWindow: BrowserWindow) => { 27 | browserWindow.webContents.on( 28 | "context-menu", 29 | (_event: Event, params: Electron.ContextMenuParams) => { 30 | const isTextSelected = params.selectionText.trim().length > 0; 31 | buildMenu(params.editFlags.canCopy && isTextSelected).popup(); 32 | } 33 | ); 34 | } 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/main/dock.ts: -------------------------------------------------------------------------------- 1 | import { app, ipcMain, BrowserWindow, nativeImage } from "electron"; 2 | import { defaultBrowserWindow } from "../common/browser_window"; 3 | import { isMac, isWindows } from "../common/platform"; 4 | import { checkmarkPath } from "../common/resources"; 5 | 6 | export const EVENT_FILES_ADDED = "files-added"; 7 | export const EVENT_FILE_PROCESSED = "file-processed"; 8 | export const EVENT_ALL_FILES_PROCESSED = "all-files-processed"; 9 | 10 | let batchCount = 0; 11 | let remainingCount = 0; 12 | 13 | export function setupDockEventHandlers( 14 | browserWindow: BrowserWindow | null 15 | ): void { 16 | ipcMain.on(EVENT_FILES_ADDED, (_event, filesCount) => { 17 | storeBatchCount(filesCount); 18 | 19 | updateDockAndProgressBar(browserWindow); 20 | windowsOverlayIcon(browserWindow, false); 21 | }); 22 | 23 | ipcMain.on(EVENT_FILE_PROCESSED, (_event, _arg) => { 24 | storeFilesCount(remainingCount - 1); 25 | 26 | // if there are none remaining, let the all finished 27 | // event take care of it so we don't double up 28 | if (remainingCount > 0) { 29 | updateDockAndProgressBar(browserWindow); 30 | } 31 | }); 32 | 33 | ipcMain.on(EVENT_ALL_FILES_PROCESSED, (_event, _arg) => { 34 | storeBatchCount(0); 35 | 36 | updateDockAndProgressBar(browserWindow); 37 | updateDockBounce(browserWindow); 38 | windowsFlashFrame(browserWindow); 39 | windowsOverlayIcon(browserWindow, true); 40 | }); 41 | } 42 | 43 | function storeBatchCount(filesCount: number) { 44 | batchCount = filesCount; 45 | storeFilesCount(batchCount); 46 | } 47 | 48 | function storeFilesCount(filesCount: number): void { 49 | remainingCount = filesCount > 0 ? filesCount : 0; 50 | } 51 | 52 | function updateDockAndProgressBar(browserWindow: BrowserWindow | null) { 53 | updateDockCount(); 54 | updateProgressBar(browserWindow); 55 | } 56 | 57 | function updateDockCount(): void { 58 | if (!isMac()) { 59 | return; 60 | } 61 | 62 | if (!app.dock) { 63 | throw new Error("Could not get a handle on the Mac Dock"); 64 | } 65 | 66 | // update badge count 67 | app.dock.setBadge(remainingCount > 0 ? remainingCount.toString() : ""); 68 | } 69 | 70 | function updateProgressBar(browserWindow: BrowserWindow | null): void { 71 | browserWindow = defaultBrowserWindow(null); 72 | let ratio = 73 | remainingCount <= 0 ? -1 : (batchCount - remainingCount) / batchCount; 74 | 75 | browserWindow.setProgressBar(ratio); 76 | } 77 | 78 | function updateDockBounce(browserWindow: BrowserWindow | null): void { 79 | if (!isMac()) { 80 | return; 81 | } 82 | browserWindow = defaultBrowserWindow(null); 83 | if (browserWindow.isFocused()) { 84 | // don't bother if the window is already focused 85 | return; 86 | } 87 | 88 | app.dock.bounce("critical"); 89 | } 90 | 91 | // Window is flashed to inform the user that the window requires 92 | // attention but that it does not currently have the keyboard focus. 93 | // https://www.electronjs.org/docs/tutorial/windows-taskbar#flash-frame 94 | function windowsFlashFrame(browserWindow: BrowserWindow | null): void { 95 | if (!isWindows()) { 96 | return; 97 | } 98 | browserWindow = defaultBrowserWindow(browserWindow); 99 | if (browserWindow.isFocused()) { 100 | // don't bother if the window is already focused 101 | return; 102 | } 103 | 104 | browserWindow.flashFrame(true); 105 | } 106 | 107 | function windowsOverlayIcon( 108 | browserWindow: BrowserWindow | null, 109 | enabled: boolean 110 | ): void { 111 | if (!isWindows()) { 112 | return; 113 | } 114 | browserWindow = defaultBrowserWindow(browserWindow); 115 | 116 | const icon = enabled ? nativeImage.createFromPath(checkmarkPath()) : null; 117 | 118 | browserWindow.setOverlayIcon(icon, "Finished processing all files"); 119 | } 120 | -------------------------------------------------------------------------------- /src/main/file_open.ts: -------------------------------------------------------------------------------- 1 | import { dialog, BrowserWindow } from "electron"; 2 | import { 3 | defaultBrowserWindow, 4 | restoreWindowAndFocus, 5 | } from "../common/browser_window"; 6 | 7 | export const EVENT_FILE_OPEN_ADD_FILES = "file-open-add-files"; 8 | 9 | export function fileOpen( 10 | browserWindow: BrowserWindow | undefined | null 11 | ): void { 12 | browserWindow = defaultBrowserWindow(browserWindow); 13 | restoreWindowAndFocus(browserWindow); 14 | 15 | dialog 16 | .showOpenDialog(browserWindow, { 17 | properties: ["openFile", "multiSelections"], 18 | }) 19 | .then((result) => { 20 | if (result.filePaths) { 21 | defaultBrowserWindow(browserWindow).webContents.send( 22 | EVENT_FILE_OPEN_ADD_FILES, 23 | result.filePaths 24 | ); 25 | } 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /src/main/i18n.ts: -------------------------------------------------------------------------------- 1 | import { app, ipcMain } from "electron"; 2 | import { i18n as i18nCommon } from "../common/i18n"; 3 | 4 | export const IPC_EVENT_NAME_GET_LOCALE = "get-locale"; 5 | 6 | export function i18n(key: string): string { 7 | return i18nCommon(key, locale()); 8 | } 9 | 10 | export function setupI18nHandlers() { 11 | ipcMain.handle(IPC_EVENT_NAME_GET_LOCALE, async (_event, _path) => { 12 | return locale(); 13 | }); 14 | } 15 | 16 | function locale() { 17 | return app.getLocale(); 18 | } 19 | -------------------------------------------------------------------------------- /src/main/index.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow } from "electron"; 2 | import { isDev } from "../common/env"; 3 | 4 | // electron-webpack HMR for development 5 | if (isDev() && module.hot) { 6 | module.hot.accept(); 7 | } 8 | 9 | import { app } from "electron"; 10 | import { setupMenus } from "./menu"; 11 | import { init } from "./init"; 12 | import { createMainWindow, setupMainWindow } from "./window_setup"; 13 | import { currentBrowserWindow } from "../common/browser_window"; 14 | 15 | // Maintain reference to window to 16 | // prevent it from being garbage collected 17 | var browserWindow = null as BrowserWindow | null; 18 | 19 | async function setup(): Promise { 20 | init(browserWindow); 21 | await app.whenReady(); 22 | setupMenus(); 23 | 24 | // keep reference to main window to prevent losing it on GC 25 | browserWindow = currentBrowserWindow(browserWindow); 26 | if (!browserWindow) { 27 | browserWindow = createMainWindow(); 28 | } 29 | setupMainWindow(browserWindow); 30 | } 31 | 32 | setup(); 33 | -------------------------------------------------------------------------------- /src/main/init.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow } from "electron"; 2 | import packageJson from "../../package.json"; 3 | import { setupApp } from "./app_setup"; 4 | import { setupContextMenu } from "./context_menu"; 5 | import { setupDockEventHandlers } from "./dock"; 6 | import { preloadI18nStrings } from "../common/i18n"; 7 | import { setupI18nHandlers } from "../main/i18n"; 8 | 9 | function setupUserModelId(): void { 10 | app.setAppUserModelId(packageJson.build.appId); 11 | } 12 | 13 | export function init(browserWindow: BrowserWindow | null): void { 14 | preloadI18nStrings(); 15 | setupI18nHandlers(); 16 | setupContextMenu(); 17 | setupDockEventHandlers(browserWindow); 18 | setupUserModelId(); 19 | setupApp(browserWindow); 20 | } 21 | -------------------------------------------------------------------------------- /src/main/menu.ts: -------------------------------------------------------------------------------- 1 | import { app, Menu, MenuItemConstructorOptions } from "electron"; 2 | import { isMac, isWindows } from "../common/platform"; 3 | import { appMenuTemplate } from "./menu_app"; 4 | import { dockMenuTemplate } from "./menu_dock"; 5 | import { editMenuTemplate } from "./menu_edit"; 6 | import { fileMenuTemplate } from "./menu_file"; 7 | import { helpMenuTemplate } from "./menu_help"; 8 | import { viewMenuTemplate } from "./menu_view"; 9 | import { windowMenuTemplate } from "./menu_window"; 10 | import { i18n } from "./i18n"; 11 | 12 | const APP_ARG_WINDOWS_TASK_OPEN_FILE = "--open-file"; 13 | 14 | function menuTemplate(): MenuItemConstructorOptions[] { 15 | return [ 16 | ...(isMac() ? [appMenuTemplate()] : []), 17 | fileMenuTemplate(), 18 | editMenuTemplate(), 19 | viewMenuTemplate(), 20 | windowMenuTemplate(), 21 | helpMenuTemplate(), 22 | ]; 23 | } 24 | 25 | function menu(): Menu { 26 | return Menu.buildFromTemplate(menuTemplate()); 27 | } 28 | 29 | function dockMenu(): Menu { 30 | return Menu.buildFromTemplate(dockMenuTemplate()); 31 | } 32 | 33 | function setupMainMenu(): void { 34 | Menu.setApplicationMenu(menu()); 35 | } 36 | 37 | function setupDockMenu(): void { 38 | if (!isMac()) { 39 | return; 40 | } 41 | 42 | app.dock.setMenu(dockMenu()); 43 | } 44 | 45 | function setupUserTasksMenu(): void { 46 | if (!isWindows()) { 47 | return; 48 | } 49 | 50 | app.setUserTasks([ 51 | { 52 | program: process.execPath, 53 | arguments: APP_ARG_WINDOWS_TASK_OPEN_FILE, 54 | iconPath: process.execPath, 55 | iconIndex: 0, 56 | title: i18n("usertasks:open-file.label"), 57 | description: i18n("usertasks:open-file.description"), 58 | }, 59 | ]); 60 | } 61 | 62 | export function setupMenus(): void { 63 | setupMainMenu(); 64 | setupDockMenu(); 65 | setupUserTasksMenu(); 66 | } 67 | -------------------------------------------------------------------------------- /src/main/menu_app.ts: -------------------------------------------------------------------------------- 1 | import { MenuItemConstructorOptions, app } from "electron"; 2 | import { i18n } from "./i18n"; 3 | import { isMac } from "../common/platform"; 4 | 5 | export function appMenuTemplate(): MenuItemConstructorOptions { 6 | return { 7 | label: app.getName(), 8 | submenu: [ 9 | { 10 | label: `${i18n("menu.app.about")}${app.getName()}`, 11 | role: "about", 12 | }, 13 | { 14 | type: "separator", 15 | }, 16 | { 17 | label: i18n("menu.app.services"), 18 | role: "services", 19 | }, 20 | { 21 | type: "separator", 22 | }, 23 | { 24 | label: `${i18n("menu.app.hide")} ${app.getName()}`, 25 | role: "hide", 26 | }, 27 | { 28 | label: i18n("menu.app.hide-others"), 29 | role: "hideOthers", 30 | }, 31 | { 32 | label: i18n("menu.app.show-all"), 33 | role: "unhide", 34 | }, 35 | { 36 | type: "separator", 37 | }, 38 | { 39 | label: isMac() 40 | ? `${i18n("menu.app.quit")} ${app.getName()}` 41 | : i18n("menu.app.quit"), 42 | role: "quit", 43 | }, 44 | ], 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /src/main/menu_app_about.ts: -------------------------------------------------------------------------------- 1 | import { app } from "electron"; 2 | import { iconPath } from "../common/resources"; 3 | import { i18n } from "./i18n"; 4 | 5 | export function showAboutWindow(author: string, websiteUrl: string): void { 6 | let aboutPanelOptions = { 7 | applicationName: app.getName(), 8 | applicationVersion: app.getVersion(), 9 | copyright: `${i18n("aboutwindow:copyright")} © ${author}`, 10 | version: app.getVersion(), 11 | credits: author, 12 | authors: [author], 13 | website: websiteUrl, 14 | iconPath: iconPath(), 15 | }; 16 | 17 | app.setAboutPanelOptions(aboutPanelOptions); 18 | app.showAboutPanel(); 19 | } 20 | -------------------------------------------------------------------------------- /src/main/menu_dock.ts: -------------------------------------------------------------------------------- 1 | import { MenuItemConstructorOptions } from "electron"; 2 | import { fileMenuOpenItem } from "./menu_file_open"; 3 | 4 | export function dockMenuTemplate(): MenuItemConstructorOptions[] { 5 | return [fileMenuOpenItem()]; 6 | } 7 | -------------------------------------------------------------------------------- /src/main/menu_edit.ts: -------------------------------------------------------------------------------- 1 | import { MenuItemConstructorOptions } from "electron"; 2 | import { i18n } from "./i18n"; 3 | import { isMac } from "../common/platform"; 4 | 5 | export function editMenuTemplate(): MenuItemConstructorOptions { 6 | return { 7 | label: i18n("menu.edit.name"), 8 | submenu: [ 9 | { 10 | label: i18n("menu.edit.copy"), 11 | role: "copy", 12 | }, 13 | { 14 | label: i18n("menu.edit.select-all"), 15 | role: "selectAll", 16 | }, 17 | ...(isMac() ? macSubmenu() : []), 18 | ], 19 | }; 20 | } 21 | 22 | function macSubmenu(): MenuItemConstructorOptions[] { 23 | return [ 24 | { 25 | type: "separator", 26 | }, 27 | { 28 | label: i18n("menu.edit.speech"), 29 | submenu: [ 30 | { 31 | label: i18n("menu.edit.speech.start-speaking"), 32 | role: "startSpeaking", 33 | }, 34 | { 35 | label: i18n("menu.edit.speech.stop-speaking"), 36 | role: "stopSpeaking", 37 | }, 38 | ], 39 | }, 40 | ]; 41 | } 42 | -------------------------------------------------------------------------------- /src/main/menu_file.ts: -------------------------------------------------------------------------------- 1 | import { MenuItemConstructorOptions } from "electron"; 2 | import { i18n } from "./i18n"; 3 | import { fileMenuOpenItem } from "./menu_file_open"; 4 | import { isMac } from "../common/platform"; 5 | 6 | export function fileMenuTemplate(): MenuItemConstructorOptions { 7 | return { 8 | label: i18n("menu.file.name"), 9 | role: "fileMenu", 10 | type: "submenu", 11 | submenu: [ 12 | fileMenuOpenItem(), 13 | { 14 | type: "separator", 15 | }, 16 | fileQuitTemplate(), 17 | ], 18 | }; 19 | } 20 | 21 | function fileQuitTemplate(): MenuItemConstructorOptions { 22 | return isMac() 23 | ? { 24 | label: i18n("menu.file.close"), 25 | role: "close", 26 | } 27 | : { 28 | label: i18n("menu.file.quit"), 29 | role: "quit", 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/main/menu_file_open.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BrowserWindow, 3 | MenuItemConstructorOptions, 4 | MenuItem, 5 | KeyboardEvent, 6 | } from "electron"; 7 | import { i18n } from "./i18n"; 8 | import { fileOpen } from "./file_open"; 9 | 10 | export function fileMenuOpenItem(): MenuItemConstructorOptions { 11 | return { 12 | label: `${i18n("menu.file.open")}…`, 13 | accelerator: "CmdOrCtrl+O", 14 | click: fileOpenClick, 15 | }; 16 | } 17 | 18 | function fileOpenClick( 19 | _menuItem: MenuItem, 20 | browserWindow: BrowserWindow | undefined, 21 | _event: KeyboardEvent 22 | ): void { 23 | fileOpen(browserWindow); 24 | } 25 | -------------------------------------------------------------------------------- /src/main/menu_help.ts: -------------------------------------------------------------------------------- 1 | import { shell, app, MenuItemConstructorOptions } from "electron"; 2 | import os from "os"; 3 | import { isMac } from "../common/platform"; 4 | import { showAboutWindow } from "./menu_app_about"; 5 | import { openUrlMenuItem } from "./menu_item_open_url"; 6 | import { i18n } from "./i18n"; 7 | 8 | const WEBSITE_URL = "https://exifcleaner.com"; 9 | const GITHUB_USERNAME = "szTheory"; 10 | const GITHUB_PROJECTNAME = "exifcleaner"; 11 | const SOURCE_CODE_URL = `https://github.com/${GITHUB_USERNAME}/${GITHUB_PROJECTNAME}`; 12 | 13 | export function helpMenuTemplate(): MenuItemConstructorOptions { 14 | return { 15 | label: i18n("menu.help.name"), 16 | role: "help", 17 | submenu: buildHelpSubmenu(), 18 | }; 19 | } 20 | 21 | function buildHelpSubmenu(): MenuItemConstructorOptions[] { 22 | let submenu = [ 23 | openUrlMenuItem(i18n("menu.help.website"), WEBSITE_URL), 24 | openUrlMenuItem(i18n("menu.help.source-code"), SOURCE_CODE_URL), 25 | { 26 | label: `${i18n("menu.help.report-issue")}…`, 27 | click() { 28 | const url = newGithubIssueUrl( 29 | GITHUB_USERNAME, 30 | GITHUB_PROJECTNAME, 31 | newGithubIssueBody() 32 | ); 33 | shell.openExternal(url); 34 | }, 35 | }, 36 | ]; 37 | 38 | if (!isMac()) { 39 | submenu.push( 40 | { 41 | type: "separator", 42 | }, 43 | { 44 | label: `${i18n("menu.help.about")}${app.getName()}`, 45 | click() { 46 | showAboutWindow(GITHUB_USERNAME, WEBSITE_URL); 47 | }, 48 | } 49 | ); 50 | } 51 | 52 | return submenu; 53 | } 54 | 55 | function newGithubIssueUrl(user: string, repo: string, body: string): string { 56 | const url = new URL(`https://github.com/${user}/${repo}/issues/new`); 57 | url.searchParams.set("body", body); 58 | 59 | return url.toString(); 60 | } 61 | 62 | function newGithubIssueBody(): string { 63 | return ` 64 | 65 | 66 | 67 | --- 68 | 69 | ${debugInfo()}`; 70 | } 71 | 72 | function debugInfo(): string { 73 | return ` 74 | ${app.getName()} ${app.getVersion()} 75 | Electron ${process.versions.electron} 76 | ${process.platform} ${os.release()} 77 | Locale: ${app.getLocale()} 78 | `.trim(); 79 | } 80 | -------------------------------------------------------------------------------- /src/main/menu_item_open_url.ts: -------------------------------------------------------------------------------- 1 | import { shell, MenuItemConstructorOptions } from "electron"; 2 | 3 | export function openUrlMenuItem( 4 | label: string, 5 | url: string 6 | ): MenuItemConstructorOptions { 7 | return { 8 | label: label, 9 | click: function () { 10 | shell.openExternal(url); 11 | }, 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /src/main/menu_view.ts: -------------------------------------------------------------------------------- 1 | import { MenuItemConstructorOptions } from "electron"; 2 | import { i18n } from "./i18n"; 3 | 4 | export function viewMenuTemplate(): MenuItemConstructorOptions { 5 | return { 6 | label: i18n("menu.view.name"), 7 | submenu: [ 8 | { 9 | label: i18n("menu.view.toggle-dev-tools"), 10 | role: "toggleDevTools", 11 | }, 12 | { type: "separator" }, 13 | { 14 | label: i18n("menu.view.zoom-reset"), 15 | role: "resetZoom", 16 | }, 17 | { 18 | label: i18n("menu.view.zoom-in"), 19 | role: "zoomIn", 20 | }, 21 | { 22 | label: i18n("menu.view.zoom-out"), 23 | role: "zoomOut", 24 | }, 25 | { type: "separator" }, 26 | { 27 | label: i18n("menu.view.toggle-full-screen"), 28 | role: "togglefullscreen", 29 | }, 30 | ], 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/main/menu_window.ts: -------------------------------------------------------------------------------- 1 | import { MenuItemConstructorOptions } from "electron"; 2 | import { i18n } from "./i18n"; 3 | import { isMac } from "../common/platform"; 4 | 5 | export function windowMenuTemplate(): MenuItemConstructorOptions { 6 | return { 7 | label: i18n("menu.window.name"), 8 | submenu: [ 9 | { 10 | label: isMac() 11 | ? i18n("menu.window.minimize-mac") 12 | : i18n("menu.window.minimize"), 13 | role: "minimize", 14 | }, 15 | { 16 | label: isMac() 17 | ? i18n("menu.window.zoom-mac") 18 | : i18n("menu.window.zoom"), 19 | role: "zoom", 20 | }, 21 | ...(isMac() ? macSubmenu() : defaultSubmenu()), 22 | ], 23 | }; 24 | } 25 | 26 | function macSubmenu(): MenuItemConstructorOptions[] { 27 | return [ 28 | { type: "separator" }, 29 | { 30 | label: i18n("menu.window.front"), 31 | role: "front", 32 | }, 33 | { type: "separator" }, 34 | { 35 | label: i18n("menu.window.window"), 36 | role: "window", 37 | }, 38 | ]; 39 | } 40 | 41 | function defaultSubmenu(): MenuItemConstructorOptions[] { 42 | return [ 43 | { 44 | label: i18n("menu.window.close"), 45 | role: "close", 46 | }, 47 | ]; 48 | } 49 | -------------------------------------------------------------------------------- /src/main/window_setup.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, app } from "electron"; 2 | import url from "url"; 3 | import path from "path"; 4 | import { isDev } from "../common/env"; 5 | import { isMac, isWindows } from "../common/platform"; 6 | import { iconPath } from "../common/resources"; 7 | 8 | const DEFAULT_WINDOW_WIDTH = 580; 9 | const DEFAULT_WINDOW_HEIGHT = 312; 10 | 11 | function setupMainWindowClose(browserWindow: BrowserWindow) { 12 | browserWindow.on("closed", () => { 13 | // on Mac, the convention is to leave the app 14 | // open even when all windows are closed. so that for 15 | // example they can relaunch the app from the dock 16 | // or still use the drag to dock features 17 | if (!isMac()) { 18 | // quit application on window close 19 | app.quit(); 20 | } 21 | }); 22 | } 23 | 24 | function showWindowOnReady(browserWindow: BrowserWindow) { 25 | browserWindow.once("ready-to-show", () => { 26 | browserWindow.show(); 27 | browserWindow.focus(); 28 | }); 29 | } 30 | 31 | // On Windows, stop flashing the frame once the window comes into focus. 32 | // More: https://www.electronjs.org/docs/tutorial/windows-taskbar#flash-frame 33 | function windowsStopFlashingFrameOnFocus(browserWindow: BrowserWindow) { 34 | if (!isWindows()) { 35 | return; 36 | } 37 | 38 | browserWindow.once("focus", () => browserWindow.flashFrame(false)); 39 | } 40 | 41 | function urlForLoad() { 42 | if (isDev()) { 43 | const port = process.env.ELECTRON_WEBPACK_WDS_PORT; 44 | if (!port) { 45 | throw "No Electron webpack WDS port set for dev. Try running `yarn run dev` instead for development mode."; 46 | } 47 | 48 | return `http://localhost:${port}`; 49 | } else { 50 | return url.format({ 51 | pathname: path.join(__dirname, "index.html"), 52 | protocol: "file", 53 | slashes: true, 54 | }); 55 | } 56 | } 57 | 58 | function mainWindowLoadUrl(browserWindow: BrowserWindow) { 59 | browserWindow.loadURL(urlForLoad()); 60 | } 61 | 62 | const WINDOW_BACKGROUND_COLOR = "#F5F6F8"; 63 | 64 | export function createMainWindow(): BrowserWindow { 65 | let options = { 66 | title: app.name, 67 | show: false, 68 | width: DEFAULT_WINDOW_WIDTH, 69 | height: DEFAULT_WINDOW_HEIGHT + 25, 70 | minWidth: DEFAULT_WINDOW_WIDTH, 71 | minHeight: DEFAULT_WINDOW_HEIGHT + 25, 72 | webPreferences: { 73 | nodeIntegration: true, 74 | // TODO: need to get this working with "true" to upgrade to Electron 12, 75 | // but electron-webpack depends on it being "false" and it's been abandonded 76 | // contextIsolation: true, 77 | }, 78 | //set specific background color eliminate white flicker on content load 79 | backgroundColor: WINDOW_BACKGROUND_COLOR, 80 | icon: iconPath(), 81 | }; 82 | 83 | return new BrowserWindow(options); 84 | } 85 | 86 | export function setupMainWindow(browserWindow: BrowserWindow): void { 87 | setupMainWindowClose(browserWindow); 88 | // load URL before showing the window to avoid flash of unloaded content 89 | mainWindowLoadUrl(browserWindow); 90 | showWindowOnReady(browserWindow); 91 | windowsStopFlashingFrameOnFocus(browserWindow); 92 | } 93 | -------------------------------------------------------------------------------- /src/renderer/add_files.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import { ExiftoolProcess } from "node-exiftool"; 3 | import { EVENT_FILE_PROCESSED, EVENT_FILES_ADDED } from "../main/dock"; 4 | import { displayExifBeforeClean, displayExifAfterClean } from "./display_exif"; 5 | import { removeExif } from "./exif_remove"; 6 | import { addTableRow } from "./table_add_row"; 7 | import { updateRowWithCleanerSpinner } from "./table_update_row"; 8 | 9 | export async function addFiles( 10 | filePaths: string[], 11 | exifToolProcesses: ExiftoolProcess[] 12 | ): Promise { 13 | ipcRenderer.send(EVENT_FILES_ADDED, filePaths.length); 14 | 15 | const filePathsIterator = filePath(filePaths); 16 | 17 | const promises = exifToolProcesses.map((exifToolProcess) => { 18 | return processFile( 19 | filePathsIterator, 20 | exifToolProcess, 21 | exifToolProcess.open() 22 | ).catch(() => { 23 | exifToolProcess.close(); 24 | }); 25 | }); 26 | 27 | return Promise.all(promises); 28 | } 29 | 30 | async function processFile( 31 | filePathsIterator: Generator, 32 | exifToolProcess: ExiftoolProcess, 33 | exifToolPromise: Promise 34 | ): Promise { 35 | return exifToolPromise.then(() => { 36 | const iteratorResult = filePathsIterator.next(); 37 | if (iteratorResult.done) { 38 | return exifToolProcess.close(); 39 | } 40 | 41 | const filePath = iteratorResult.value; 42 | const promise = addFile(filePath, exifToolProcess); 43 | 44 | return processFile(filePathsIterator, exifToolProcess, promise); 45 | }); 46 | } 47 | 48 | function* filePath(filePaths: string[]): Generator { 49 | for (const filePath of filePaths) { 50 | yield filePath; 51 | } 52 | } 53 | 54 | async function addFile( 55 | filePath: string, 56 | exifToolProcess: ExiftoolProcess 57 | ): Promise { 58 | const tableRow = addTableRow(filePath); 59 | 60 | return displayExifBeforeClean(exifToolProcess, tableRow, filePath) 61 | .then((_exifData) => { 62 | updateRowWithCleanerSpinner(tableRow); 63 | 64 | return removeExif(exifToolProcess, filePath); 65 | }) 66 | .then((_stdOutErrOutput) => { 67 | ipcRenderer.send(EVENT_FILE_PROCESSED); 68 | 69 | return displayExifAfterClean(exifToolProcess, tableRow, filePath); 70 | }); 71 | } 72 | -------------------------------------------------------------------------------- /src/renderer/display_exif.ts: -------------------------------------------------------------------------------- 1 | import { ExiftoolProcess } from "node-exiftool"; 2 | import { getExif } from "./exif_get"; 3 | import { updateRowWithExif } from "./table_update_row"; 4 | 5 | export async function displayExifBeforeClean( 6 | exifToolProcess: ExiftoolProcess, 7 | trNode: HTMLTableRowElement, 8 | filePath: string 9 | ): Promise { 10 | const tdBeforeNode = trNode.querySelector("td:nth-child(2)"); 11 | if (!(tdBeforeNode instanceof HTMLTableCellElement)) { 12 | throw new Error("Expected table data cell element"); 13 | } 14 | 15 | return getExif(exifToolProcess, filePath).then((exifData) => { 16 | updateRowWithExif(tdBeforeNode, exifData); 17 | 18 | return exifData; 19 | }); 20 | } 21 | 22 | export async function displayExifAfterClean( 23 | exifToolProcess: ExiftoolProcess, 24 | trNode: HTMLTableRowElement, 25 | filePath: string 26 | ): Promise { 27 | const tdAfterNode = trNode.querySelector("td:nth-child(3)"); 28 | if (!(tdAfterNode instanceof HTMLTableCellElement)) { 29 | throw new Error("Expected table data cell element"); 30 | } 31 | 32 | return getExif(exifToolProcess, filePath).then((exifDataAfterClean) => { 33 | updateRowWithExif(tdAfterNode, exifDataAfterClean); 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /src/renderer/drag.ts: -------------------------------------------------------------------------------- 1 | import { selectFiles } from "./select_files"; 2 | 3 | export function setupDragAndDrop(): void { 4 | document.addEventListener("drop", (event) => { 5 | handleDropEvent(event); 6 | }); 7 | 8 | document.addEventListener("dragover", (event) => { 9 | handleDragOverEvent(event); 10 | }); 11 | } 12 | 13 | function handleDropEvent(event: DragEvent): void { 14 | event.preventDefault(); 15 | event.stopPropagation(); 16 | 17 | const dataTransfer = event.dataTransfer; 18 | if (!dataTransfer) { 19 | throw "Error getting data transfer for drop event"; 20 | } 21 | const fileList = dataTransfer.files; 22 | const paths = filePaths(fileList); 23 | 24 | selectFiles(paths); 25 | } 26 | 27 | function handleDragOverEvent(event: DragEvent): void { 28 | event.preventDefault(); 29 | event.stopPropagation(); 30 | } 31 | 32 | function filePaths(fileList: FileList): string[] { 33 | let paths = []; 34 | for (const file of fileList) { 35 | paths.push(file.path); 36 | } 37 | 38 | return paths; 39 | } 40 | -------------------------------------------------------------------------------- /src/renderer/empty_pane.ts: -------------------------------------------------------------------------------- 1 | export function hideEmptyPane(): void { 2 | const pane = emptyPane(); 3 | if (!pane) { 4 | throw "Could not find empty pane to hide"; 5 | } 6 | 7 | pane.classList.add("d-none"); 8 | } 9 | 10 | function emptyPane(): HTMLElement | null { 11 | return document.getElementById("empty"); 12 | } 13 | -------------------------------------------------------------------------------- /src/renderer/exif_get.ts: -------------------------------------------------------------------------------- 1 | import { ExiftoolProcess } from "node-exiftool"; 2 | 3 | const EXIFTOOL_ARGS_GET_EXIF = [ 4 | "charset filename=UTF8", 5 | "-File:all", 6 | "-ExifToolVersion", 7 | ]; 8 | 9 | // Read exif data using the ExifTool binary 10 | // and clean up after the process when done 11 | export async function getExif( 12 | exiftoolProcess: ExiftoolProcess, 13 | filePath: string 14 | ): Promise { 15 | const exifData = exiftoolProcess 16 | .readMetadata(filePath, EXIFTOOL_ARGS_GET_EXIF) 17 | .then((exifData) => { 18 | if (exifData.data === null) { 19 | return {}; 20 | } 21 | 22 | const hash = exifData.data[0]; 23 | return cleanExifDataOutput(hash); 24 | }); 25 | 26 | return exifData; 27 | } 28 | 29 | function cleanExifDataOutput(exifHash: any): any { 30 | // remove basic file info that is part of 31 | // exiftools output, but not metadata 32 | if (exifHash.SourceFile) { 33 | delete exifHash.SourceFile; 34 | } 35 | if (exifHash.ImageSize) { 36 | delete exifHash.ImageSize; 37 | } 38 | if (exifHash.Megapixels) { 39 | delete exifHash.Megapixels; 40 | } 41 | 42 | return exifHash; 43 | } 44 | -------------------------------------------------------------------------------- /src/renderer/exif_remove.ts: -------------------------------------------------------------------------------- 1 | import { ExiftoolProcess } from "node-exiftool"; 2 | 3 | const EXIFTOOL_ARGS_REMOVE_EXIF = [ 4 | "charset filename=UTF8", 5 | "overwrite_original", 6 | ]; 7 | 8 | // The heart of the app, removing exif data from the image. 9 | // This uses the Perl binary "exiftool" the app's `.resources` dir 10 | export async function removeExif( 11 | exifToolProcess: ExiftoolProcess, 12 | filePath: string 13 | ): Promise { 14 | return exifToolProcess.writeMetadata( 15 | filePath, 16 | { all: "" }, 17 | EXIFTOOL_ARGS_REMOVE_EXIF, 18 | false 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/renderer/i18n.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import { preloadI18nStrings, i18n } from "../common/i18n"; 3 | import { IPC_EVENT_NAME_GET_LOCALE } from "../main/i18n"; 4 | 5 | const ATTRIBUTE_I18N = "i18n"; 6 | 7 | export function setupI18n(): void { 8 | preloadI18nStrings(); 9 | translateHtml(); 10 | } 11 | 12 | async function translateHtml() { 13 | const locale = await getLocale(); 14 | const elements = document.querySelectorAll(`[${ATTRIBUTE_I18N}]`); 15 | 16 | elements.forEach((element) => { 17 | if (!(element instanceof HTMLElement)) { 18 | throw new Error("Tried to localize a non-HTML element"); 19 | } 20 | 21 | const key = element.getAttribute(ATTRIBUTE_I18N); 22 | if (!key) { 23 | throw new Error(`Could not find an HTML element to localize for: ${key}`); 24 | } 25 | 26 | element.innerText = i18n(key, locale); 27 | }); 28 | } 29 | 30 | async function getLocale() { 31 | return await ipcRenderer.invoke(IPC_EVENT_NAME_GET_LOCALE); 32 | } 33 | -------------------------------------------------------------------------------- /src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ExifCleaner 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |
18 |
19 | 20 | 21 | 22 |
23 |
24 |
25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |
37 |
38 | 39 | 40 | -------------------------------------------------------------------------------- /src/renderer/index.ts: -------------------------------------------------------------------------------- 1 | import { isDev } from "../common/env"; 2 | 3 | // electron-webpack HMR (Hot Module Reload) 4 | // to automatically reload code on save when 5 | // in development mode 6 | if (isDev() && module.hot) { 7 | module.hot.accept(); 8 | } 9 | 10 | // stylesheets 11 | // vars first 12 | import "../styles/vars.css"; 13 | 14 | // app 15 | import "../styles/base.css"; 16 | import "../styles/card.css"; 17 | import "../styles/display.css"; 18 | import "../styles/empty.css"; 19 | import "../styles/file_list.css"; 20 | import "../styles/icon.css"; 21 | import "../styles/popover.css"; 22 | import "../styles/dark_mode.css"; 23 | import "../styles/tables.css"; 24 | import "../styles/typography.css"; 25 | 26 | // app 27 | import { setupDragAndDrop } from "./drag"; 28 | import { setupSelectFilesMenu } from "./menu_select_files"; 29 | import { setupI18n } from "./i18n"; 30 | 31 | function setup(): void { 32 | setupI18n(); 33 | setupDragAndDrop(); 34 | setupSelectFilesMenu(); 35 | } 36 | 37 | setup(); 38 | -------------------------------------------------------------------------------- /src/renderer/menu_select_files.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import { EVENT_FILE_OPEN_ADD_FILES } from "../main/file_open"; 3 | import { selectFiles } from "./select_files"; 4 | 5 | export function setupSelectFilesMenu(): void { 6 | ipcRenderer.on(EVENT_FILE_OPEN_ADD_FILES, (_event, filePaths) => { 7 | selectFiles(filePaths); 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /src/renderer/sanitize.ts: -------------------------------------------------------------------------------- 1 | // Sanitize HTMl to prevent XSS and Electron remote shell attacks 2 | export function sanitizeHTML(text: string): string { 3 | const element = document.createElement("div"); 4 | element.innerText = text; 5 | 6 | return element.innerHTML; 7 | } 8 | -------------------------------------------------------------------------------- /src/renderer/select_files.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | import { spawnExifToolProcesses } from "../common/exif_tool_processes"; 3 | import { EVENT_ALL_FILES_PROCESSED } from "../main/dock"; 4 | import { addFiles } from "./add_files"; 5 | import { hideEmptyPane } from "./empty_pane"; 6 | import { 7 | showSelectedFilesPane, 8 | eraseSelectedFilesPane, 9 | } from "./selected_files"; 10 | 11 | export function selectFiles(filePaths: string[]): void { 12 | if (filePaths.length == 0) { 13 | return; 14 | } 15 | 16 | // show selected files display panel 17 | hideEmptyPane(); 18 | eraseSelectedFilesPane(); 19 | showSelectedFilesPane(); 20 | 21 | processFiles(filePaths); 22 | } 23 | 24 | async function processFiles(filePaths: string[]): Promise { 25 | const exifToolProcesses = spawnExifToolProcesses(filePaths.length); 26 | 27 | addFiles(filePaths, exifToolProcesses).finally(() => { 28 | ipcRenderer.send(EVENT_ALL_FILES_PROCESSED); 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /src/renderer/selected_files.ts: -------------------------------------------------------------------------------- 1 | export function selectedFilesList(): HTMLTableSectionElement | null { 2 | const pane = selectedFilesPane(); 3 | if (!pane) { 4 | throw "Could not find file list pane element for selected files list"; 5 | } 6 | 7 | return pane.querySelector("tbody"); 8 | } 9 | 10 | export function showSelectedFilesPane(): void { 11 | const pane = selectedFilesPane(); 12 | if (!pane) { 13 | throw "Could not find file list pane element to show"; 14 | } 15 | 16 | pane.classList.remove("d-none"); 17 | } 18 | 19 | export function eraseSelectedFilesPane(): void { 20 | const filesListElement = selectedFilesList(); 21 | if (!filesListElement) { 22 | throw "Could not find file list element to erase"; 23 | } 24 | 25 | if (filesListElement) { 26 | filesListElement.innerHTML = ""; 27 | } 28 | } 29 | 30 | function selectedFilesPane(): HTMLElement | null { 31 | return document.getElementById("file-list"); 32 | } 33 | -------------------------------------------------------------------------------- /src/renderer/table_add_row.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { selectedFilesList } from "./selected_files"; 3 | 4 | export function addTableRow(filePath: string): HTMLTableRowElement { 5 | const label = path.basename(filePath); 6 | 7 | // tr node 8 | const trNode = document.createElement("tr"); 9 | 10 | // td name node 11 | const tdNode = document.createElement("td"); 12 | tdNode.setAttribute("title", label); 13 | trNode.appendChild(tdNode); 14 | 15 | // td icon 16 | const useNode = document.createElementNS("http://www.w3.org/2000/svg", "use"); 17 | useNode.setAttribute("href", "#icon-images"); 18 | const iconNode = document.createElementNS( 19 | "http://www.w3.org/2000/svg", 20 | "svg" 21 | ); 22 | iconNode.appendChild(useNode); 23 | iconNode.classList.add("icon"); 24 | tdNode.appendChild(iconNode); 25 | 26 | // td text 27 | var textSpanNode = document.createElement("span"); 28 | textSpanNode.textContent = " " + label; 29 | textSpanNode.classList.add("file-path"); 30 | tdNode.appendChild(textSpanNode); 31 | 32 | // td num exif before node 33 | const tdNumExifBeforeNode = document.createElement("td"); 34 | // tdNumExifBeforeNode.setAttribute("align", "center") 35 | // spinner 36 | const tdNumExifBeforeSpinner = document.createElement("span"); 37 | tdNumExifBeforeSpinner.classList.add("loading"); 38 | tdNumExifBeforeNode.appendChild(tdNumExifBeforeSpinner); 39 | // append 40 | trNode.appendChild(tdNumExifBeforeNode); 41 | 42 | // td num exif after node 43 | const tdNumExifAfterNode = document.createElement("td"); 44 | trNode.appendChild(tdNumExifAfterNode); 45 | 46 | // add tr to list 47 | const list = selectedFilesList(); 48 | if (!list) { 49 | throw "Error while retrieving selected files list element"; 50 | } 51 | list.appendChild(trNode); 52 | 53 | return trNode; 54 | } 55 | -------------------------------------------------------------------------------- /src/renderer/table_update_row.ts: -------------------------------------------------------------------------------- 1 | import { sanitizeHTML } from "./sanitize"; 2 | 3 | export function updateRowWithExif( 4 | tdNode: HTMLTableDataCellElement, 5 | exifData: any 6 | ): void { 7 | // td 8 | tdNode.textContent = ""; 9 | 10 | // label 11 | const exifCount = Object.keys(exifData).length; 12 | const label = exifCount; 13 | 14 | // text 15 | const textNode = document.createElement("div"); 16 | textNode.textContent = label.toString(); 17 | textNode.classList.add("popover", "popover-bottom"); 18 | tdNode.appendChild(textNode); 19 | 20 | if (exifCount > 0) { 21 | // popover container 22 | const popoverContainerNode = document.createElement("div"); 23 | popoverContainerNode.classList.add("popover-container"); 24 | textNode.appendChild(popoverContainerNode); 25 | // card 26 | const cardNode = document.createElement("div"); 27 | cardNode.classList.add("card"); 28 | popoverContainerNode.appendChild(cardNode); 29 | // card body 30 | const cardBodyNode = document.createElement("div"); 31 | cardBodyNode.classList.add("card-body"); 32 | cardBodyNode.innerHTML = buildExifString({ exifData: exifData }); 33 | cardNode.appendChild(cardBodyNode); 34 | } 35 | } 36 | 37 | function buildExifString({ exifData }: { exifData: any }): string { 38 | let str = ""; 39 | 40 | for (const [key, value] of Object.entries(exifData)) { 41 | if (typeof value !== "string") { 42 | continue; 43 | } 44 | str += key + " " + "" + sanitizeHTML(value) + "" + "
"; 45 | } 46 | 47 | return str; 48 | } 49 | 50 | export function updateRowWithCleanerSpinner(trNode: HTMLTableRowElement): void { 51 | // td 52 | const tdNode = trNode.querySelector("td:nth-child(3)"); 53 | if (!tdNode) { 54 | throw `Could not find table data cell element for row ${trNode}`; 55 | } 56 | 57 | // spinner 58 | const spinnerNode = document.createElement("div"); 59 | spinnerNode.classList.add("loading"); 60 | tdNode.appendChild(spinnerNode); 61 | } 62 | -------------------------------------------------------------------------------- /src/styles/base.css: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | box-sizing: inherit; 5 | } 6 | 7 | html { 8 | box-sizing: border-box; 9 | font-size: var(--html-font-size); 10 | line-height: var(--html-line-height); 11 | -webkit-tap-highlight-color: transparent; 12 | } 13 | 14 | body { 15 | background: var(--body-bg); 16 | color: var(--body-font-color); 17 | font-family: var(--body-font-family); 18 | font-size: var(--font-size); 19 | overflow-x: hidden; 20 | text-rendering: optimizeLegibility; 21 | margin: 0; 22 | } 23 | 24 | main { 25 | padding-top: var(--size); 26 | } 27 | -------------------------------------------------------------------------------- /src/styles/card.css: -------------------------------------------------------------------------------- 1 | .card { 2 | background: var(--bg-color-light); 3 | border: var(--border-width) solid var(--border-color); 4 | border-radius: var(--border-radius); 5 | display: flex; 6 | flex-direction: column; 7 | } 8 | 9 | .card-body { 10 | padding: var(--layout-spacing-lg); 11 | padding-bottom: 0; 12 | flex: 1 1 auto; 13 | } 14 | 15 | .card-body:last-child { 16 | padding-bottom: var(--layout-spacing-lg); 17 | } 18 | -------------------------------------------------------------------------------- /src/styles/dark_mode.css: -------------------------------------------------------------------------------- 1 | @media (prefers-color-scheme: dark) { 2 | :root { 3 | /* colors */ 4 | --dark-color: #303742; 5 | --bg-color: #383838; 6 | --bg-color-light: var(--light-color); 7 | --bg-color-dark: #303742; 8 | --gray-color-dark: #eef0f3; 9 | --light-color: #121212; 10 | --body-bg: var(--bg-color-light); 11 | --body-font-color: #eef0f3; 12 | 13 | /* empty */ 14 | --empty-border-width: 0; 15 | --empty-border-radius: 2px; 16 | 17 | /* table */ 18 | --table-header-font-weight: 200; 19 | --table-row-bg-color: #323232; 20 | --table-row-hover-bg-color: #585858; 21 | --table-icon-color: #818181; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/styles/display.css: -------------------------------------------------------------------------------- 1 | .d-none { 2 | display: none; 3 | } 4 | -------------------------------------------------------------------------------- /src/styles/empty.css: -------------------------------------------------------------------------------- 1 | /* Empty state launch screen */ 2 | .empty { 3 | background: var(--bg-color); 4 | color: var(--gray-color-dark); 5 | text-align: center; 6 | 7 | border-radius: var(--empty-border-radius); 8 | border: var(--empty-border-width) dashed var(--empty-border-color); 9 | 10 | /* launch screen */ 11 | height: calc(100% - 14px); 12 | position: absolute; 13 | top: 0; 14 | left: 0; 15 | right: 0; 16 | bottom: 0; 17 | margin-top: 6px; 18 | margin-left: 8px; 19 | margin-right: 8px; 20 | margin-bottom: 6px; 21 | padding: 0; 22 | } 23 | 24 | .empty-title, 25 | .empty-subtitle { 26 | margin: var(--layout-spacing) auto; 27 | } 28 | 29 | .empty-inner { 30 | position: absolute; 31 | 32 | /* position the top+left edges of the element 33 | at the middle of the parent */ 34 | top: 50%; 35 | left: 50%; 36 | 37 | /* This is a shorthand of translateX(-50%) and translateY(-50%) */ 38 | transform: translate(-50%, -50%); 39 | } 40 | -------------------------------------------------------------------------------- /src/styles/file_list.css: -------------------------------------------------------------------------------- 1 | #file-list tr td:first-child { 2 | max-width: 20em; 3 | white-space: nowrap; 4 | overflow: hidden; 5 | text-overflow: ellipsis; 6 | } 7 | 8 | #file-list tr td:nth-child(2) > div, 9 | #file-list tr td:nth-child(3) > div { 10 | width: 100%; 11 | height: 100%; 12 | } 13 | 14 | #file-list .card-body { 15 | text-align: left; 16 | } 17 | -------------------------------------------------------------------------------- /src/styles/icon.css: -------------------------------------------------------------------------------- 1 | .icon { 2 | width: 1rem; 3 | height: 1rem; 4 | fill: var(--gray-color-dark); 5 | } 6 | 7 | .icon-3x { 8 | width: 3rem; 9 | height: 3rem; 10 | } 11 | -------------------------------------------------------------------------------- /src/styles/popover.css: -------------------------------------------------------------------------------- 1 | .popover { 2 | display: inline-block; 3 | position: relative; 4 | } 5 | 6 | .popover-container { 7 | left: 50%; 8 | opacity: 0; 9 | padding: var(--layout-spacing); 10 | position: absolute; 11 | top: 0; 12 | transform: translate(-50%, -50%) scale(0); 13 | transition: transform 0.2s; 14 | width: var(--control-width-sm); 15 | z-index: var(--zindex-3); 16 | } 17 | 18 | .popover *:focus + .popover-container, 19 | .popover:hover .popover-container { 20 | display: block; 21 | opacity: 1; 22 | transform: translate(-50%, -100%) scale(1); 23 | } 24 | 25 | .popover-bottom .popover-container { 26 | left: 50%; 27 | top: 100%; 28 | } 29 | 30 | .popover-bottom *:focus + .popover-container, 31 | .popover-bottom:hover .popover-container { 32 | transform: translate(-50%, 0) scale(1); 33 | } 34 | 35 | .popover .card { 36 | box-shadow: 0 0.2rem 0.5rem var(--popover-shadow-color); 37 | border: 0; 38 | } 39 | -------------------------------------------------------------------------------- /src/styles/tables.css: -------------------------------------------------------------------------------- 1 | .table { 2 | border-collapse: collapse; 3 | border-spacing: 0; 4 | width: 100%; 5 | text-align: left; 6 | } 7 | 8 | .table tbody tr:nth-of-type(odd) { 9 | background: var(--table-row-bg-color); 10 | } 11 | 12 | .table tbody tr.active, 13 | .table tbody tr:hover { 14 | background: var(--table-row-hover-bg-color); 15 | } 16 | 17 | .table td, 18 | .table th { 19 | border-bottom: var(--border-width) solid var(--border-color); 20 | padding: var(--unit-3) var(--unit-2); 21 | } 22 | .table th { 23 | border-bottom-width: var(--border-width-lg); 24 | font-weight: var(--table-header-font-weight); 25 | } 26 | 27 | .table .icon { 28 | position: relative; 29 | top: 4px; 30 | margin-right: 2px; 31 | fill: var(--table-icon-color); 32 | } 33 | -------------------------------------------------------------------------------- /src/styles/typography.css: -------------------------------------------------------------------------------- 1 | .h1, 2 | .h2, 3 | .h3, 4 | .h4, 5 | .h5, 6 | .h6 { 7 | font-weight: 500; 8 | } 9 | .h1 { 10 | font-size: 2rem; 11 | } 12 | .h2 { 13 | font-size: 1.6rem; 14 | } 15 | .h3 { 16 | font-size: 1.4rem; 17 | } 18 | .h4 { 19 | font-size: 1.2rem; 20 | } 21 | .h5 { 22 | font-size: 1rem; 23 | } 24 | .h6 { 25 | font-size: 0.8rem; 26 | } 27 | 28 | .p { 29 | margin: 0 0 var(--line-height); 30 | } 31 | -------------------------------------------------------------------------------- /src/styles/vars.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* colors */ 3 | --dark-color: #303742; 4 | --bg-color: #f7f8f9; 5 | --bg-color-light: var(--light-color); 6 | --bg-color-dark: #eef0f3; 7 | --gray-color-dark: #66758c; 8 | --light-color: #fff; 9 | --body-bg: var(--bg-color-light); 10 | --body-font-color: #3b4351; 11 | 12 | /* units */ 13 | --unit-o: 0.05rem; 14 | --unit-h: 0.1rem; 15 | --unit-1: 0.2rem; 16 | --unit-2: 0.4rem; 17 | --unit-3: 0.6rem; 18 | --unit-4: 0.8rem; 19 | --unit-8: 1.6rem; 20 | --unit-16: 3.2rem; 21 | --size: var(--unit-2); 22 | 23 | /* layout */ 24 | --layout-spacing: var(--unit-2); 25 | --layout-spacing-lg: var(--unit-4); 26 | 27 | /* typography */ 28 | --body-font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", 29 | Roboto; 30 | --line-height: 1.2rem; 31 | --html-font-size: 20px; 32 | --html-line-height: 1.5; 33 | --font-size: 0.8rem; 34 | 35 | /* border */ 36 | --border-radius: var(--$unit-h); 37 | --border-width: var(--unit-o); 38 | --border-width-lg: var(--unit-h); 39 | --border-color: rgb(218, 222, 228); 40 | 41 | /* empty */ 42 | --empty-border-color: #3b4351; 43 | --empty-border-width: 1px; 44 | --empty-border-radius: var(--border-radius); 45 | 46 | /* controls */ 47 | --control-width-sm: 320px; 48 | 49 | /* popover shadow */ 50 | --popover-shadow-color: rgba(48, 55, 66, 0.3); 51 | 52 | /* table */ 53 | --table-header-font-weight: 700; 54 | --table-row-bg-color: var(--bg-color); 55 | --table-row-hover-bg-color: var(--bg-color-dark); 56 | --table-icon-color: var(--gray-color-dark); 57 | 58 | /* z-index */ 59 | --zindex-3: 300; 60 | } 61 | -------------------------------------------------------------------------------- /src/types/node-exiftool/index.d.ts: -------------------------------------------------------------------------------- 1 | // Original declaration file generated by dts-gen, 2 | // modified afterwards to specify proper typings 3 | // for used methods from the public interface 4 | 5 | declare module "node-exiftool" { 6 | import { Readable, Writable } from "stream"; 7 | 8 | type ExifToolPid = number; 9 | 10 | export class ExiftoolProcess { 11 | constructor(bin: string); 12 | 13 | // resolve: (value?: any) => void, reject: (reason?: any 14 | 15 | close(): Promise<{ success: any; error: Error }>; 16 | 17 | open(encoding?: string, options?: object): Promise; 18 | 19 | readMetadata( 20 | file: string | Readable, 21 | args: string[] 22 | ): Promise<{ data: object[] | null; error: string | null }>; 23 | 24 | writeMetadata( 25 | file: string | Writable, 26 | metadata: object, 27 | extraArgs: string[], 28 | debug: boolean 29 | ): Promise<{ data: object[] | null; error: string | null }>; 30 | 31 | // writeMetadata(...args: any[]): void; 32 | 33 | // static captureRejectionSymbol: any; 34 | 35 | // static captureRejections: boolean; 36 | 37 | // static defaultMaxListeners: number; 38 | 39 | // static errorMonitor: any; 40 | 41 | // static init(opts: any): void; 42 | 43 | // static listenerCount(emitter: any, type: any): any; 44 | 45 | // static on(emitter: any, event: any): any; 46 | 47 | // static once(emitter: any, name: any): any; 48 | 49 | // static usingDomains: boolean; 50 | // } 51 | 52 | // export const EXIFTOOL_PATH: string; 53 | 54 | // export const events: { 55 | // EXIT: string; 56 | // OPEN: string; 57 | // }; 58 | 59 | // export namespace ExiftoolProcess { 60 | // class EventEmitter { 61 | // constructor(opts: any); 62 | 63 | // addListener(type: any, listener: any): any; 64 | 65 | // emit(type: any, args: any): any; 66 | 67 | // eventNames(): any; 68 | 69 | // getMaxListeners(): any; 70 | 71 | // listenerCount(type: any): any; 72 | 73 | // listeners(type: any): any; 74 | 75 | // off(type: any, listener: any): any; 76 | 77 | // on(type: any, listener: any): any; 78 | 79 | // once(type: any, listener: any): any; 80 | 81 | // prependListener(type: any, listener: any): any; 82 | 83 | // prependOnceListener(type: any, listener: any): any; 84 | 85 | // rawListeners(type: any): any; 86 | 87 | // removeAllListeners(type: any, ...args: any[]): any; 88 | 89 | // removeListener(type: any, listener: any): any; 90 | 91 | // setMaxListeners(n: any): any; 92 | 93 | // static EventEmitter: any; 94 | 95 | // static captureRejectionSymbol: any; 96 | 97 | // static captureRejections: boolean; 98 | 99 | // static defaultMaxListeners: number; 100 | 101 | // static errorMonitor: any; 102 | 103 | // static init(opts: any): void; 104 | 105 | // static listenerCount(emitter: any, type: any): any; 106 | 107 | // static on(emitter: any, event: any): any; 108 | 109 | // static once(emitter: any, name: any): any; 110 | 111 | // static usingDomains: boolean; 112 | // } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /static/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/electron-webpack/tsconfig-base.json", 3 | "compilerOptions": { 4 | "target": "es2019", 5 | "outDir": "./dist/", 6 | "resolveJsonModule": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "importHelpers": true, 9 | "removeComments": true 10 | }, 11 | "include": ["./src/**/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /update_exiftool.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env perl 2 | 3 | # Download the latest version of ExifTool for Unix and Windows 4 | # and verify their checksums. 5 | # 6 | # The Unix version is taken from the source archive, removes 7 | # extra help files to reduce filesize, and squashes it down into 8 | # a single Perl file. 9 | # 10 | # The Windows version is a prepacked EXE that is simply extracted 11 | # from the zip archive. 12 | package UpdateExifTool 1.0; 13 | 14 | use strict; #complain when a variable is used before declaration 15 | use warnings; #output run-time warnings to catch bugs early 16 | use diagnostics; #verbose warnings, consumes memory so disable in production 17 | use autodie; #functions throw exception on failure instead of returning false 18 | use utf8; #enable UTF-8 in source code 19 | use open qw(:std :utf8); #set default encoding of filehandles to UTF-8 20 | 21 | use constant EXIFTOOL_BASE_URL => 'https://exiftool.org/'; 22 | use constant CHECKSUMS_URL => EXIFTOOL_BASE_URL . 'checksums.txt'; 23 | use constant DOWNLOADS_WORKING_DIR => 'exiftool_downloads'; 24 | use constant RESOURCES_DIR => '.resources'; 25 | use constant BIN_DIR_UNIX => RESOURCES_DIR . '/nix/bin'; 26 | use constant BIN_DIR_WINDOWS => RESOURCES_DIR . '/win/bin'; 27 | use constant COMMAND_PRINT_SIGNAL => '------> '; 28 | use constant COMMAND_SIGNAL_COLOR => 'bright_green'; 29 | use constant COMMAND_SUCCESS_COLOR => 'bright_green'; 30 | use constant COMMAND_ERROR_COLOR => 'bright_red'; 31 | use constant COMMAND_OUTPUT_COLOR => 'bold blue'; 32 | use constant BANNER_OUTPUT_COLOR => 'bold cyan'; 33 | 34 | use File::Path qw(make_path remove_tree); 35 | use Term::ANSIColor; 36 | 37 | sub print_output { 38 | my $output = shift; 39 | 40 | print color(COMMAND_OUTPUT_COLOR); 41 | print "$output"; 42 | print color('reset'); 43 | 44 | return; 45 | } 46 | 47 | sub print_success { 48 | my $text = shift; 49 | 50 | print color(COMMAND_SUCCESS_COLOR); 51 | print "$text\n"; 52 | print color('reset'); 53 | 54 | return; 55 | } 56 | 57 | sub print_error { 58 | my $text = shift; 59 | 60 | print color(COMMAND_ERROR_COLOR); 61 | print "$text\n"; 62 | print color('reset'); 63 | 64 | return; 65 | } 66 | 67 | sub header { 68 | my $text = shift; 69 | 70 | my $banner = q{-} x length($text); 71 | 72 | print "\n"; 73 | print color(BANNER_OUTPUT_COLOR); 74 | print "$banner\n"; 75 | print "$text\n"; 76 | print "$banner\n"; 77 | print color('reset'); 78 | 79 | return; 80 | } 81 | 82 | sub print_command_signal { 83 | print color(COMMAND_SIGNAL_COLOR); 84 | print COMMAND_PRINT_SIGNAL; 85 | print color('reset'); 86 | 87 | return; 88 | } 89 | 90 | sub print_command { 91 | my @command = @_; 92 | 93 | print_command_signal(); 94 | print_output( join( ' ', @command ) . "\n" ); 95 | 96 | return; 97 | } 98 | 99 | sub run_command { 100 | my @command = @_; 101 | 102 | print_command(@command); 103 | system(@command) == 0 or die "system @command failed: $?"; 104 | 105 | return; 106 | } 107 | 108 | sub make_dir { 109 | my $dir_path = shift; 110 | 111 | print_command_signal(); 112 | print color(COMMAND_OUTPUT_COLOR); 113 | make_path( $dir_path, { verbose => 1 } ); 114 | print color('reset'); 115 | 116 | return; 117 | } 118 | 119 | sub remove_dir { 120 | my $dir_path = shift; 121 | 122 | print_command( 'remove_tree(' . $dir_path . ')' ); 123 | remove_tree($dir_path); 124 | 125 | return; 126 | } 127 | 128 | # Example checksum file output: 129 | # 130 | # SHA1(Image-ExifTool-12.01.tar.gz)= 140f014e7686ed80528b919d64c4de0a869e59aa 131 | # SHA1(exiftool-12.01.zip)= a28c3f943165d1eec3ff69bb665390e340686ec6 132 | # SHA1(ExifTool-12.01.dmg)= 327fd67f60fd7f62742d4ddb2f9999da13dc785f 133 | # MD5 (Image-ExifTool-12.01.tar.gz) = 6980a6d435f83c0af060148a354acf24 134 | # MD5 (exiftool-12.01.zip) = e11260548ebff70a3ce27d48e46dfe94 135 | # MD5 (ExifTool-12.01.dmg) = 7a41e56901564f9bd4eb3f907846c118 136 | sub get_checksum_file_text { 137 | my $command = 'curl ' . CHECKSUMS_URL; 138 | 139 | print_command($command); 140 | return qx($command); 141 | } 142 | 143 | sub get_code_zip_info { 144 | my $checksum_file_text = shift; 145 | 146 | my ( $filename, $sha1 ) = 147 | $checksum_file_text =~ /SHA1\((Image-ExifTool-[\w.]+tar[.]gz)\)= (\w+)/m; 148 | 149 | return ( $filename, $sha1 ); 150 | } 151 | 152 | sub get_windows_exe_info { 153 | my $checksum_file_text = shift; 154 | 155 | my ( $filename, $sha1 ) = 156 | $checksum_file_text =~ /SHA1\((exiftool-[\w.]+zip)\)= (\w+)/m; 157 | 158 | return ( $filename, $sha1 ); 159 | } 160 | 161 | sub download_file { 162 | my $filename = shift; 163 | 164 | my $url = EXIFTOOL_BASE_URL . $filename; 165 | my @command = ( 166 | 'wget', '--no-clobber', '--directory-prefix', DOWNLOADS_WORKING_DIR, $url 167 | ); 168 | run_command(@command); 169 | 170 | return; 171 | } 172 | 173 | sub verify_checksum { 174 | my ( $filename, $sha1 ) = @_; 175 | 176 | my $command = 'shasum ' . DOWNLOADS_WORKING_DIR . "/$filename"; 177 | print_command($command); 178 | my $output = qx($command); 179 | my ($calculated_sha1) = split( ' ', $output ); 180 | 181 | print $calculated_sha1; 182 | my $is_match = $sha1 eq $calculated_sha1; 183 | 184 | if ( $sha1 eq $calculated_sha1 ) { 185 | print_success(" ... Match!\n"); 186 | } 187 | else { 188 | die "\n!!! Did NOT match SHA1 from ExifTool website: $sha1 !!!\n"; 189 | } 190 | 191 | return; 192 | } 193 | 194 | sub extract_source_code { 195 | my $gzip_filename = shift; 196 | 197 | my @command = ( 198 | 'tar', '-xvf', DOWNLOADS_WORKING_DIR . "/$gzip_filename", 199 | '-C', DOWNLOADS_WORKING_DIR 200 | ); 201 | run_command(@command); 202 | 203 | return; 204 | } 205 | 206 | sub extract_windows_exe { 207 | my $zip_filename = shift; 208 | 209 | my @command = ( 210 | 'unzip', '-d', DOWNLOADS_WORKING_DIR, '-o', 211 | DOWNLOADS_WORKING_DIR . "/$zip_filename" 212 | ); 213 | run_command(@command); 214 | 215 | return; 216 | } 217 | 218 | sub remove_old_binaries { 219 | 220 | # remove old Unix lib dir 221 | remove_dir( BIN_DIR_UNIX . '/lib' ); 222 | 223 | # remove old Unix `exiftool` bin 224 | my $remove_path_bin_unix = BIN_DIR_UNIX . '/exiftool'; 225 | if ( -e $remove_path_bin_unix ) { 226 | my @command = ( 'rm', $remove_path_bin_unix ); 227 | run_command(@command); 228 | } 229 | else { 230 | print_output("No pre-existing Unix binary to remove\n"); 231 | } 232 | 233 | # remove old Windows `exiftool.exe` 234 | my $remove_path_bin_win = BIN_DIR_WINDOWS . '/exiftool.exe'; 235 | if ( -e $remove_path_bin_win ) { 236 | my @command = ( 'rm', $remove_path_bin_win ); 237 | run_command(@command); 238 | } 239 | else { 240 | print_output("No pre-existing Windows binary to remove\n"); 241 | } 242 | 243 | return; 244 | } 245 | 246 | # The Unix version of ExifTool only needs `exiftool` and the `lib` dir. 247 | # In order to keep package size down we only copy these over to the 248 | # ExifCleaner bin dir. 249 | sub copy_unix_binary { 250 | my $code_archive_filename = shift; 251 | 252 | my ($code_dir_name) = $code_archive_filename =~ /^(.+)[.]tar[.]gz$/; 253 | my $from_dir = DOWNLOADS_WORKING_DIR . "/$code_dir_name"; 254 | 255 | # move lib dir 256 | my @command = ( 'cp', '-R', "$from_dir/lib", BIN_DIR_UNIX ); 257 | run_command(@command); 258 | 259 | # move `exiftool` base Perl file 260 | @command = ( 'cp', "$from_dir/exiftool", BIN_DIR_UNIX ); 261 | run_command(@command); 262 | 263 | return; 264 | } 265 | 266 | sub verify_successful_install { 267 | my $command = BIN_DIR_UNIX . '/exiftool -ver'; 268 | my $version = qx($command); 269 | if ($version) { 270 | print "\n"; 271 | print_success("Success! Updated to ExifTool $version\n"); 272 | } 273 | else { 274 | print_error( 275 | "Error while attempting to verify ExifTool install with $command\n"); 276 | } 277 | 278 | return; 279 | } 280 | 281 | # The Windows ExifTool binary is just an .exe file. We have to 282 | # rename it from `exiftool(-k).exe` to `exiftool.exe` and move 283 | # it to the ExifCleaner Windows bin dir. 284 | sub copy_windows_binary { 285 | my $from_path = DOWNLOADS_WORKING_DIR . '/exiftool(-k).exe'; 286 | my $to_path = BIN_DIR_WINDOWS . '/exiftool.exe'; 287 | 288 | my @command = ( 'cp', $from_path, $to_path ); 289 | run_command(@command); 290 | 291 | return; 292 | } 293 | 294 | sub is_exiftool_already_downloaded { 295 | my ( $code_filename, $windows_version_filename ) = @_; 296 | 297 | my $code_path = DOWNLOADS_WORKING_DIR . "/$code_filename"; 298 | my $windows_path = DOWNLOADS_WORKING_DIR . "/$windows_version_filename"; 299 | 300 | my $download_folder_exists = -d DOWNLOADS_WORKING_DIR; 301 | my $code_downloaded = -e $code_path; 302 | my $windows_version_downloaded = -e $windows_path; 303 | 304 | return 305 | $download_folder_exists 306 | && $code_downloaded 307 | && $windows_version_downloaded; 308 | } 309 | 310 | sub run { 311 | my $cache_downloads_working_dir = shift; 312 | 313 | header('Fetching ExifTool SHA1 checksums from website'); 314 | my $checksum_file_text = get_checksum_file_text(); 315 | my ( $code_filename, $code_sha1 ) = get_code_zip_info($checksum_file_text); 316 | my ( $windows_version_filename, $windows_sha1 ) = 317 | get_windows_exe_info($checksum_file_text); 318 | my $exiftool_already_downloaded = 319 | is_exiftool_already_downloaded( $code_filename, $windows_version_filename ); 320 | print_output("$code_filename - $code_sha1\n"); 321 | print_output("$windows_version_filename - $windows_sha1\n"); 322 | 323 | header('Recreate downloads working directory'); 324 | if ( $cache_downloads_working_dir && $exiftool_already_downloaded ) { 325 | print_command( 326 | "Keeping existing downloads working directory since download caching is enabled" 327 | ); 328 | } 329 | else { 330 | remove_dir(DOWNLOADS_WORKING_DIR); 331 | make_dir(DOWNLOADS_WORKING_DIR); 332 | } 333 | 334 | header('Downloading files'); 335 | 336 | if ( $cache_downloads_working_dir && $exiftool_already_downloaded ) { 337 | print_command( "Skipping download since the downloads working directory '" 338 | . DOWNLOADS_WORKING_DIR 339 | . "' already exists and download caching is enabled" ); 340 | } 341 | else { 342 | download_file($code_filename); 343 | download_file($windows_version_filename); 344 | } 345 | 346 | header('Verifying SHA1 checksums'); 347 | verify_checksum( $code_filename, $code_sha1 ); 348 | verify_checksum( $windows_version_filename, $windows_sha1 ); 349 | 350 | header('Extracting archives'); 351 | extract_source_code($code_filename); 352 | extract_windows_exe($windows_version_filename); 353 | 354 | header('Removing old binaries'); 355 | remove_old_binaries(); 356 | 357 | header('Moving fresh binaries'); 358 | copy_unix_binary($code_filename); 359 | copy_windows_binary(); 360 | 361 | header('Clean up downloads working directory'); 362 | if ($cache_downloads_working_dir) { 363 | print_command( 364 | "Keeping downloads working directory since caching is enabled."); 365 | } 366 | else { 367 | remove_dir(DOWNLOADS_WORKING_DIR); 368 | } 369 | 370 | return; 371 | } 372 | 373 | # Pass the command line argument --cache-downloads-working-dir 374 | # to cache the downloads working directory to avoid repeated 375 | # downloads from the exiftool server. Useful for CI 376 | my $cache_downloads_working_dir = $ARGV[0] eq "--cache-downloads-working-dir"; 377 | 378 | run($cache_downloads_working_dir); 379 | verify_successful_install(); 380 | 381 | 1; 382 | 383 | --------------------------------------------------------------------------------