├── .gitignore ├── LICENSE ├── PRIVACYPOLICY.md ├── README.md └── WebExtension ├── AUTHORS ├── LICENSE ├── _locales └── en │ └── messages.json ├── backgroundscript.js ├── binExif.js ├── binIptc.js ├── boarding ├── boarding.css ├── boarding.js ├── onboard.html └── upboard.html ├── contentscript.js ├── context.js ├── fxifUtils.js ├── icons ├── background.png ├── check-64.png ├── copy-48.png ├── copytext-48.png ├── error-64.png ├── error-7-32w.png ├── flickr-dots-128.png ├── globe-32.png ├── info-32w.png ├── info-64.png ├── new27x14.png ├── settings-48.png ├── warn-32w.png ├── warn-64.png ├── wheel-48.png ├── xIFr-128.png ├── xIFr-24.png ├── xIFr-256.png ├── xIFr-32.png ├── xIFr-48.png ├── xIFr-512.png ├── xIFr-64.png └── xIFr-96.png ├── manifest.json ├── options ├── options.css ├── options.html └── options.js ├── parseJpeg.js ├── popup ├── popup.css ├── popup.html └── popup.js ├── stringBundle.js └── xmp.js /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | 44 | # other 45 | notes.txt 46 | noter.txt 47 | **/*.iml 48 | **/target/ 49 | **/*.zip 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /PRIVACYPOLICY.md: -------------------------------------------------------------------------------- 1 | Privacy Policy for xIFr 2 | 3 | As the developer and provider of xIFr, I have no interest in or intent to collect data about users or usage of xIFr or websites, besides the basic add-on statistics automatically collected by Mozilla. In general xIFr ain't running until either launched from context-menu or by opening addon's Options page, and no data is processed, collected or shared unless needed for the addon's described functionality. 4 | 5 | xIFr reads and presents data about image files embedded on webpages, and does so no matter if an image file is located on same domain as the webpage itself or embedded from some other domain. To show position of geotagged photos, xIFr will embed a map from - and communicate geolocation data to - a 3rd party map-provider (Currently OpenStreetMap). But besides the needed data shared to show a map with photo-location, xIFr does not share any data with 3rd parties. And communication with the map-provider does not happen until the "Map" tab in xIFr is selected for a geotagged photo. 6 | 7 | If you have questions or feedback to privacy policy or xIFr in general, please see contact and feedback options at www.rockland.dk/xIFr. 8 | 9 | Regards\ 10 | Stig Nygaard 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # xIFr 2 | 3 | xIFr is a Firefox browser extension for viewing EXIF, IPTC and XMP metadata in image files, including a map-view of 4 | geolocation. It features "deep search" to target images that normally can't be selected by a simple right-click. 5 | 6 | * [Install xIFr from Mozilla Firefox Add-ons](https://addons.mozilla.org/firefox/addon/xifr/?utm_source=github.com) 7 | 8 | ![Screenshot](https://www.rockland.dk/img/xIFr100-1-1400x1050.jpg) 9 | 10 | ### Why another Exif viewer? 11 | 12 | Because I felt other Exif readers annoyed me, or I felt they were missing something. It is probably a matter of 13 | personal preferences, but you should really check the _"deep search" feature_. It works sooo well - in my own 14 | very humble opinion :-) 15 | 16 | ![Screenshot](https://www.rockland.dk/img/xIFr100-2-1400x1050.jpg) 17 | 18 | ### "Deep Search" feature? 19 | Most other Exif viewers for Firefox only works if you can right-click directly on an html _img_ element. 20 | But with "Deep Search" xIFr finds the image you want to see details about, 21 | no matter if it is below a layer or is defined as a background-image of another element. 22 | In 95% of the times, it just works as you expect. You won't even know if you were right-clicking directly on an 23 | img element or not. This is in my opinion the most important feature distinguishing xIFr from other Exif-viewers. 24 | 25 | Also, with Deep Search you can avoid overlayered logos and icons. By shift-clicking when selecting xIFr in browser's 26 | context-menu, you will force xIFr to look for images larger than a specified size (The size is configurable). 27 | 28 | You can get a little introduction to xIFr's features, including what 29 | Deep Search does, at [www.rockland.dk/xIFr/start](https://www.rockland.dk/xIFr/start/). 30 | 31 | ### Dark Theme support 32 | 33 | ![Screenshot](https://www.rockland.dk/img/xIFr100-3-1400x1050.jpg) 34 | 35 | ### A handy Firefox tip! 36 | Some websites override the browser's default right-click context menu. But often you can just hold down the 37 | _shift_ key while right-clicking, to get the browser's native context menu back - and thus launch xIFr... 38 | 39 | ### Creating extension from repository 40 | 41 | In principle xIFr is a "cross-browser compatible" webextension. While it currently _ain't_ available via Google Chrome 42 | or Microsoft Edge Web Stores, it still works with Chromium based browsers if you install the webextension directly 43 | from your local filesystem. There are however differences in functionality supported, and mostly limitations when 44 | using xIFr in Chromium based browsers. So for now, only "officially" available and supported for Firefox. 45 | 46 | To create a browser extension from this repository, simply create a zip-file with content of the _WebExtension_ folder. 47 | 48 | ### A lot of credit to... 49 | Vital parts of xIFr, is inherited work by various [people](https://raw.githubusercontent.com/StigNygaard/xIFr/master/WebExtension/AUTHORS) 50 | involved with development of [wxIF](https://github.com/gcp/wxif) (xIFr is a fork of wxIF) and 51 | [FxIF](https://code.google.com/archive/p/fxif/). Without their initial work, xIFr wouldn't be. 52 | 53 | Also thanks to [crimx](https://github.com/crimx), and his ["Get All Images in DOM" coding-post](https://blog.crimx.com/2017/03/09/get-all-images-in-dom-including-background-en/) which was great help getting started 54 | on the Deep Search feature. 55 | 56 | ### License 57 | 58 | [MPL 2.0 - Mozilla Public License Version 2.0](https://raw.githubusercontent.com/StigNygaard/xIFr/master/LICENSE) 59 | 60 | ### Flickr Fixr 61 | Are you a Flickr user? Also take a look at my [Flickr Fixr](https://github.com/StigNygaard/Stigs_Flickr_Fixr) ! 62 | -------------------------------------------------------------------------------- /WebExtension/AUTHORS: -------------------------------------------------------------------------------- 1 | Matthias Wandel (JHead developer) 2 | Chris Beaven (FxIF icon) 3 | Ted Mielczarek 4 | Christian Eyrich 5 | Gian-Carlo Pascutto 6 | Martin Arndt 7 | Stig Nygaard 8 | -------------------------------------------------------------------------------- /WebExtension/LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /WebExtension/_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensionDescription": { 3 | "message": "EXIF reader. A viewer to see EXIF, IPTC and XMP metadata in images.", 4 | "description": "Description of the extension." 5 | }, 6 | 7 | "contextMenuText": { 8 | "message": "View E&XIF data" 9 | }, 10 | 11 | "Copy": { 12 | "message": "Copy" 13 | }, 14 | 15 | "CameraMake": { 16 | "message": "Camera Maker" 17 | }, 18 | 19 | "CameraModel": { 20 | "message": "Camera Model" 21 | }, 22 | 23 | "CameraLens": { 24 | "message": "Lens" 25 | }, 26 | 27 | "Software": { 28 | "message": "Software" 29 | }, 30 | 31 | "PreservedFileName": { 32 | "message": "Preserved Filename" 33 | }, 34 | 35 | "PersonInImage": { 36 | "message": "Persons in Image" 37 | }, 38 | 39 | "ImageDate": { 40 | "message": "Image Date" 41 | }, 42 | 43 | "ImageOrientation": { 44 | "message": "Orientation" 45 | }, 46 | 47 | "ImageBW": { 48 | "message": "Black and White" 49 | }, 50 | 51 | "FlashUsed": { 52 | "message": "Flash Fired" 53 | }, 54 | 55 | "FocalLength": { 56 | "message": "Focal Length" 57 | }, 58 | 59 | "DigitalZoom": { 60 | "message": "Digital Zoom" 61 | }, 62 | 63 | "CCDWidth": { 64 | "message": "CCD Width" 65 | }, 66 | 67 | "ExposureTime": { 68 | "message": "Exposure Time" 69 | }, 70 | 71 | "Aperture": { 72 | "message": "Aperture" 73 | }, 74 | 75 | "FocusDist": { 76 | "message": "Focus Distance" 77 | }, 78 | 79 | "ISOequivalent": { 80 | "message": "ISO equivalent" 81 | }, 82 | 83 | "ExposureBias": { 84 | "message": "Exposure Bias" 85 | }, 86 | 87 | "WhiteBalance": { 88 | "message": "White Balance" 89 | }, 90 | 91 | "LightSource": { 92 | "message": "Light Source" 93 | }, 94 | 95 | "MeteringMode": { 96 | "message": "Metering Mode" 97 | }, 98 | 99 | "ExposureProgram": { 100 | "message": "Exposure" 101 | }, 102 | 103 | "ExposureMode": { 104 | "message": "Exposure Mode" 105 | }, 106 | 107 | "ImageColorSpace": { 108 | "message": "Color Space" 109 | }, 110 | 111 | "GPSCoord": { 112 | "message": "GPS Coordinate" 113 | }, 114 | 115 | "MapLink": { 116 | "message": "Map Link" 117 | }, 118 | 119 | "GPSAlt": { 120 | "message": "GPS Altitude" 121 | }, 122 | 123 | "GPSImgDir": { 124 | "message": "Viewing Direction" 125 | }, 126 | 127 | "Creator": { 128 | "message": "Creator" 129 | }, 130 | 131 | "CreatorAddress": { 132 | "message": "Creator Address" 133 | }, 134 | 135 | "CreatorCity": { 136 | "message": "Creator City" 137 | }, 138 | 139 | "CreatorRegion": { 140 | "message": "Creator Region" 141 | }, 142 | 143 | "CreatorPostalCode": { 144 | "message": "Creator Postal Code" 145 | }, 146 | 147 | "CreatorCountry": { 148 | "message": "Creator Country" 149 | }, 150 | 151 | "CreatorPhoneNumbers": { 152 | "message": "Creator Phone" 153 | }, 154 | 155 | "CreatorEmails": { 156 | "message": "Creator Email" 157 | }, 158 | 159 | "CreatorURLs": { 160 | "message": "Creator URL" 161 | }, 162 | 163 | "Creditline": { 164 | "message": "Credit Line" 165 | }, 166 | 167 | "City": { 168 | "message": "City" 169 | }, 170 | 171 | "Sublocation": { 172 | "message": "Sublocation" 173 | }, 174 | 175 | "ProvinceState": { 176 | "message": "Province/State" 177 | }, 178 | 179 | "CountryName": { 180 | "message": "Country" 181 | }, 182 | 183 | "Copyright": { 184 | "message": "Copyright" 185 | }, 186 | 187 | "UsageTerms": { 188 | "message": "Usage Terms" 189 | }, 190 | 191 | "LicenseURL": { 192 | "message": "License URL" 193 | }, 194 | 195 | "Title": { 196 | "message": "Title" 197 | }, 198 | 199 | "Headline": { 200 | "message": "Title" 201 | }, 202 | 203 | "Caption": { 204 | "message": "Description" 205 | }, 206 | 207 | "ObjectName": { 208 | "message": "Object Name" 209 | }, 210 | 211 | "Comment": { 212 | "message": "Comment" 213 | }, 214 | 215 | "DocumentNotes": { 216 | "message": "Notes" 217 | }, 218 | 219 | "Keywords": { 220 | "message": "Keywords" 221 | }, 222 | 223 | "Instructions": { 224 | "message": "Instructions" 225 | }, 226 | 227 | "TransmissionRef": { 228 | "message": "Transmission Ref." 229 | }, 230 | 231 | "NoData": { 232 | "message": "No meta data found" 233 | }, 234 | 235 | "GPSFormat": { 236 | "message": "Coordinates format to use" 237 | }, 238 | 239 | "DD_label": { 240 | "message": "Decimal degree" 241 | }, 242 | 243 | "DM_label": { 244 | "message": "Degree Minute" 245 | }, 246 | 247 | "DMS_label": { 248 | "message": "Degree Minutes Second" 249 | }, 250 | 251 | "windowtitle": { 252 | "message": "FxIF Data for" 253 | }, 254 | 255 | "orientation0": { 256 | "message": "Undefined" 257 | }, 258 | 259 | "orientation1": { 260 | "message": "Normal" 261 | }, 262 | 263 | "orientation2": { 264 | "message": "Flip Horizontal" 265 | }, 266 | 267 | "orientation3": { 268 | "message": "Rotate 180" 269 | }, 270 | 271 | "orientation4": { 272 | "message": "Flip Vertical" 273 | }, 274 | 275 | "orientation5": { 276 | "message": "Transpose" 277 | }, 278 | 279 | "orientation6": { 280 | "message": "Rotate 90" 281 | }, 282 | 283 | "orientation7": { 284 | "message": "Transverse" 285 | }, 286 | 287 | "orientation8": { 288 | "message": "Rotate 270" 289 | }, 290 | 291 | "infinite": { 292 | "message": "Infinite" 293 | }, 294 | 295 | "meters": { 296 | "message": "m" 297 | }, 298 | 299 | "millimeters": { 300 | "message": "mm" 301 | }, 302 | 303 | "seconds": { 304 | "message": "s" 305 | }, 306 | 307 | "celsius": { 308 | "message": "°C" 309 | }, 310 | 311 | "ev": { 312 | "message": "EV" 313 | }, 314 | 315 | "yes": { 316 | "message": "Yes" 317 | }, 318 | 319 | "no": { 320 | "message": "No" 321 | }, 322 | 323 | "enforced": { 324 | "message": "enforced" 325 | }, 326 | 327 | "manual": { 328 | "message": "Manual" 329 | }, 330 | 331 | "auto": { 332 | "message": "Auto" 333 | }, 334 | 335 | "semiauto": { 336 | "message": "semi-auto" 337 | }, 338 | 339 | "none": { 340 | "message": "none" 341 | }, 342 | 343 | "daylight": { 344 | "message": "Daylight" 345 | }, 346 | 347 | "fluorescent": { 348 | "message": "Fluorescent" 349 | }, 350 | 351 | "incandescent": { 352 | "message": "Incandescent" 353 | }, 354 | 355 | "flash": { 356 | "message": "Flash" 357 | }, 358 | 359 | "fineweather": { 360 | "message": "Fine Weather" 361 | }, 362 | 363 | "cloudy": { 364 | "message": "Cloudy" 365 | }, 366 | 367 | "shade": { 368 | "message": "Shade" 369 | }, 370 | 371 | "daylightfluorescent": { 372 | "message": "Daylight Fluorescent" 373 | }, 374 | 375 | "daywhitefluorescent": { 376 | "message": "Day White Fluorescent" 377 | }, 378 | 379 | "coolwhitefluorescent": { 380 | "message": "Cool White Fluorescent" 381 | }, 382 | 383 | "whitefluorescent": { 384 | "message": "White Fluorescent" 385 | }, 386 | 387 | "studiotungsten": { 388 | "message": "ISO Studio Tungsten" 389 | }, 390 | 391 | "noflash": { 392 | "message": "No Flash available" 393 | }, 394 | 395 | "noreturnlight": { 396 | "message": "return light not detected" 397 | }, 398 | 399 | "returnlight": { 400 | "message": "return light detected" 401 | }, 402 | 403 | "redeye": { 404 | "message": "red eye reduction mode" 405 | }, 406 | 407 | "average": { 408 | "message": "Average" 409 | }, 410 | 411 | "centerweight": { 412 | "message": "Center Weight" 413 | }, 414 | 415 | "spot": { 416 | "message": "Spot" 417 | }, 418 | 419 | "multispot": { 420 | "message": "Multispot" 421 | }, 422 | 423 | "matrix": { 424 | "message": "Matrix" 425 | }, 426 | 427 | "partial": { 428 | "message": "Partial" 429 | }, 430 | 431 | "autobracketing": { 432 | "message": "Auto Bracketing" 433 | }, 434 | 435 | "35mmequiv": { 436 | "message": "mm (35mm equivalent)" 437 | }, 438 | 439 | "program": { 440 | "message": "program" 441 | }, 442 | 443 | "apriority": { 444 | "message": "aperture priority" 445 | }, 446 | 447 | "spriority": { 448 | "message": "shutter priority" 449 | }, 450 | 451 | "creative": { 452 | "message": "Creative Program (based towards depth of field)" 453 | }, 454 | 455 | "action": { 456 | "message": "Action program (based towards fast shutter speed)" 457 | }, 458 | 459 | "portrait": { 460 | "message": "Portrait Mode" 461 | }, 462 | 463 | "landscape": { 464 | "message": "Landscape Mode" 465 | }, 466 | 467 | "latlondms": { 468 | "message": " (deg, min, sec)" 469 | }, 470 | 471 | "latlondm": { 472 | "message": " (deg, min)" 473 | }, 474 | 475 | "latlondd": { 476 | "message": " (deg )" 477 | }, 478 | 479 | "dirT": { 480 | "message": "° (true north)" 481 | }, 482 | 483 | "dirM": { 484 | "message": "° (magnetic north)" 485 | }, 486 | 487 | "noTZ": { 488 | "message": "(no TZ)" 489 | }, 490 | 491 | "unknown": { 492 | "message": "Unknown" 493 | }, 494 | 495 | "generalError": { 496 | "message": " Error while interpreting meta data. Might not show all or any meta data. " 497 | }, 498 | 499 | "generalWarning": { 500 | "message": " There was an issue interpreting meta data. " 501 | }, 502 | 503 | "specialError": { 504 | "message": " Error while interpreting %S data. Might not show all or any %S data. " 505 | }, 506 | 507 | "fetchImageAbortError": { 508 | "message": "Aborted - likely timeout - when load image-file for parsing." 509 | }, 510 | 511 | "fetchImageError": { 512 | "message": "Error trying to load image-file for parsing of the metadata!" 513 | }, 514 | 515 | "fetchFileWorkAroundInfo": { 516 | "message": "Possible work-around for error: Copy above image URL. Open URL directly in browser, and try open xIFr again from the displayed image" 517 | }, 518 | 519 | "displayFileTrouble": { 520 | "message": "Image from your file system probably doesn't display in this popup, but any meta-data might still be read and displayed correctly." 521 | }, 522 | 523 | "displayBlobTrouble": { 524 | "message": "Images defined by blob: URLs probably doesn't display in this popup, but any meta-data might still be read and displayed correctly." 525 | }, 526 | 527 | "UserComment": { 528 | "message": "User Comment" 529 | }, 530 | 531 | "ColorSpace": { 532 | "message": "Color Space" 533 | }, 534 | 535 | "FocalLengthText": { 536 | "message": "Focal Length" 537 | }, 538 | 539 | "ApertureFNumber": { 540 | "message": "Aperture" 541 | }, 542 | 543 | "FocalLength35mmEquiv": { 544 | "message": "Focal Length (35mm)" 545 | }, 546 | 547 | "GPSLat": { 548 | "message": "GPS Latitude" 549 | }, 550 | 551 | "GPSLon": { 552 | "message": "GPS Longitude" 553 | }, 554 | 555 | "MergedCaptures": { 556 | "message": "Multicapture" 557 | }, 558 | 559 | "DigitalSourceType": { 560 | "message": "Digital Source Type" 561 | }, 562 | 563 | "CameraOwnerName": { 564 | "message": "Camera Owner" 565 | }, 566 | 567 | "GPano_UsePanoramaViewer": { 568 | "message": "GPano:UsePanoramaViewer" 569 | }, 570 | 571 | "GPano_CaptureSoftware": { 572 | "message": "GPano:CaptureSoftware" 573 | }, 574 | 575 | "GPano_StitchingSoftware": { 576 | "message": "GPano:StitchingSoftware" 577 | }, 578 | 579 | "GPano_ProjectionType": { 580 | "message": "GPano:ProjectionType" 581 | }, 582 | 583 | "GPano_PoseHeadingDegrees": { 584 | "message": "GPano:PoseHeadingDegrees" 585 | }, 586 | 587 | "GPano_PosePitchDegrees": { 588 | "message": "GPano:PosePitchDegrees" 589 | }, 590 | 591 | "GPano_PoseRollDegrees": { 592 | "message": "GPano:PoseRollDegrees" 593 | }, 594 | 595 | "GPano_InitialViewHeadingDegrees": { 596 | "message": "GPano:InitialViewHeadingDegrees" 597 | }, 598 | 599 | "GPano_InitialViewPitchDegrees": { 600 | "message": "GPano:InitialViewPitchDegrees" 601 | }, 602 | 603 | "GPano_InitialViewRollDegrees": { 604 | "message": "GPano:InitialViewRollDegrees" 605 | }, 606 | 607 | "GPano_InitialHorizontalFOVDegrees": { 608 | "message": "GPano:InitialHorizontalFOVDegrees" 609 | }, 610 | 611 | "GPano_FirstPhotoDate": { 612 | "message": "GPano:FirstPhotoDate" 613 | }, 614 | 615 | "GPano_LastPhotoDate": { 616 | "message": "GPano:LastPhotoDate" 617 | }, 618 | 619 | "GPano_SourcePhotosCount": { 620 | "message": "GPano:SourcePhotosCount" 621 | }, 622 | 623 | "GPano_ExposureLockUsed": { 624 | "message": "GPano:ExposureLockUsed" 625 | }, 626 | 627 | "GPano_CroppedAreaImageWidthPixels": { 628 | "message": "GPano:CroppedAreaImageWidthPixels" 629 | }, 630 | 631 | "GPano_CroppedAreaImageHeightPixels": { 632 | "message": "GPano:CroppedAreaImageHeightPixels" 633 | }, 634 | 635 | "GPano_FullPanoWidthPixels": { 636 | "message": "GPano:FullPanoWidthPixels" 637 | }, 638 | 639 | "GPano_FullPanoHeightPixels": { 640 | "message": "GPano:FullPanoHeightPixels" 641 | }, 642 | 643 | "GPano_CroppedAreaLeftPixels": { 644 | "message": "GPano:CroppedAreaLeftPixels" 645 | }, 646 | 647 | "GPano_CroppedAreaTopPixels": { 648 | "message": "GPano:CroppedAreaTopPixels" 649 | }, 650 | 651 | "GPano_InitialCameraDolly": { 652 | "message": "GPano:InitialCameraDolly" 653 | }, 654 | 655 | "noEXIFdata": { 656 | "message": "No metadata found" 657 | } 658 | } 659 | -------------------------------------------------------------------------------- /WebExtension/backgroundscript.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | * 5 | * This Source Code Form is "Incompatible With Secondary Licenses", as 6 | * defined by the Mozilla Public License, v. 2.0. 7 | */ 8 | 9 | import './context.js'; 10 | 11 | globalThis.browser ??= chrome; 12 | 13 | if (browser.menus?.getTargetElement) { // An easy way to use Firefox extended API while preserving Chrome (and older Firefox) compatibility. 14 | browser.contextMenus = browser.menus; 15 | } 16 | 17 | context.log(" *** xIFr backgroundscript has (re)started! *** "); 18 | 19 | browser.runtime.onInstalled.addListener( 20 | function handleInstalled({reason, temporary, previousVersion}) { 21 | context.getOptions().then( 22 | function (options) { 23 | createMenuItem(!options.devDisableDeepSearch && browser.contextMenus.getTargetElement); 24 | }); 25 | const upboardUrl = new URL(browser.runtime.getURL('boarding/upboard.html')); 26 | const onboardUrl = new URL(browser.runtime.getURL('boarding/onboard.html')); 27 | switch (reason) { 28 | case "update": // "upboarding" 29 | if (versionnumber.compare(previousVersion, '3.0.0') < 0) { // clear old "mv2-sessionStorage" if previous version LESS than 3.0.0... 30 | browser.storage.local.remove("sessionstorage") 31 | .then(function () { 32 | console.log("xIFr: Old homemade mv2 sessionStorage was cleared on installation/upgrade!"); 33 | }) 34 | .catch((err) => { 35 | console.warn(`xIFr: Failed clearing old mv2 sessionStorage: ${err}`); 36 | }); 37 | } 38 | if (versionnumber.compare(previousVersion, '3.1.1') < 0) { // Show "upboarding" if previous version LESS than 3.1.1... 39 | if (temporary) { 40 | upboardUrl.searchParams.set('temporary', temporary); 41 | } 42 | upboardUrl.searchParams.set('previousVersion', previousVersion); 43 | // Show the "upboarding" window: 44 | browser.tabs.create({url: upboardUrl.pathname + upboardUrl.search}); 45 | } 46 | break; 47 | case "install": // "onboarding" 48 | if (temporary) { 49 | onboardUrl.searchParams.set('temporary', temporary); 50 | } 51 | onboardUrl.searchParams.set('initialOnboard', '1'); 52 | browser.tabs.create({url: onboardUrl.pathname + onboardUrl.search}); 53 | break; 54 | } 55 | } 56 | ); 57 | 58 | browser.runtime.onStartup.addListener(() => { 59 | context.getOptions().then(function (options) { 60 | // Try re-define menuitem because of Firefox bug https://bugzilla.mozilla.org/show_bug.cgi?id=1817287 61 | createMenuItem(!options.devDisableDeepSearch && browser.contextMenus.getTargetElement); 62 | if (options?.initialOnboard === '1') { 63 | browser.extension.isAllowedIncognitoAccess().then(function (allowsPrivate) { 64 | if (allowsPrivate && context.isFirefox()) { 65 | // Re-show onboarding if risk of was force-closed first time (https://bugzilla.mozilla.org/show_bug.cgi?id=1558336) 66 | // TODO: 1558336 should be fixed in Firefox 129 ! 67 | browser.tabs.create({url: "boarding/onboard.html?initialOnboard=2"}); // show second time 68 | } else { 69 | context.setOption('initialOnboard', 3); // second time not needed 70 | } 71 | }); 72 | } 73 | }); 74 | }); 75 | 76 | // Attempt to fix missing menu-item right after an installation where support for use in Private mode was enabled. 77 | // Probably https://bugzilla.mozilla.org/show_bug.cgi?id=1771328 // TODO: Fixed in 128? 78 | context.getOptions().then( 79 | function (options) { 80 | createMenuItem(!options.devDisableDeepSearch && browser.contextMenus.getTargetElement); 81 | } 82 | ); 83 | 84 | // action.onClicked used with "action":{} in manifest.json... 85 | // https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/action/onClicked 86 | browser.action.onClicked.addListener(() => { 87 | browser.runtime.openOptionsPage(); 88 | }); 89 | browser.action.setTitle({ title: "Open Options-page" }); 90 | 91 | browser.contextMenus.onClicked.addListener((info, tab) => { 92 | if (info.menuItemId === "viewexif") { 93 | context.debug("Context menu clicked. mediaType=" + info.mediaType); 94 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/OnClickData 95 | if ((info.mediaType && info.mediaType === "image" && info.srcUrl) || info.targetElementId) { 96 | // console.log(' *** tab.id: ' + tab.id + ' *** '); 97 | // console.log(' *** tab.status: ' + tab.status + ' *** '); 98 | // console.log(' *** tab.title: ' + tab.title + ' *** '); 99 | // console.log(' *** tab.url: ' + tab.url + ' *** '); 100 | // console.log(' *** info.frameId: ' + info.frameId + ' *** '); 101 | // console.log(' *** info.srcUrl: ' + info.srcUrl + ' *** '); 102 | // console.log(' *** info.frameUrl: ' + info.frameUrl + ' *** '); 103 | // console.log(' *** info.targetElementId: ' + info.targetElementId + ' *** '); 104 | const scripts = [ 105 | "context.js", // Some state and options handling, utility functions 106 | "stringBundle.js", // Translation handling 107 | "fxifUtils.js", // Some utility functions 108 | "binExif.js", // Interpreter for binary EXIF data 109 | "binIptc.js", // Interpreter for binary IPTC-NAA data 110 | "xmp.js", // Interpreter for XML XMP data 111 | "parseJpeg.js", // "Master parser" for header-data 112 | "contentscript.js" // "Conductor" frontend script (frontend-fetch of image, communication with backend, "deep search" functionality) 113 | ]; 114 | // For CSS, see: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/insertCSS 115 | 116 | if (browser.scripting?.executeScript) { 117 | // Requires "scripting" (or "activeTab") included in "permission" of the manifest! 118 | const scriptsInjecting = browser.scripting.executeScript({ 119 | target: { 120 | tabId: tab.id, 121 | frameIds: [info.frameId] // For a "flat" webpage, frameId is typically 0. 122 | }, 123 | files: scripts, 124 | injectImmediately: true 125 | }); 126 | Promise.all([context.getOptions(), scriptsInjecting]) 127 | .then((values) => { 128 | context.debug("All scripts started from background is ready..."); 129 | const options = values[0]; 130 | if (!values[1].length || !values[1][0]) { 131 | console.error('xIFr: There was an error loading contentscripts: ' + JSON.stringify(values[1])); 132 | // TODO: throw? 133 | } 134 | browser.storage.session.set({"winpop": options.popupPos}) 135 | .then( 136 | () => { 137 | browser.tabs.sendMessage( 138 | tab.id, 139 | { 140 | message: "parseImage", 141 | imageURL: info.srcUrl, 142 | mediaType: info.mediaType, 143 | targetId: info.targetElementId, 144 | supportsDeepSearch: !!info.targetElementId, // "deep-search" supported in Firefox 63+ 145 | goDeepSearch: !!info.targetElementId && !options.devDisableDeepSearch, 146 | supportsDeepSearchModifier: !!info.modifiers, 147 | deepSearchBigger: !!info.modifiers?.includes("Shift"), 148 | deepSearchBiggerLimit: options.deepSearchBiggerLimit, 149 | fetchMode: options.devFetchMode, 150 | frameId: info.frameId, 151 | frameUrl: info.frameUrl, 152 | tabId: tab.id, 153 | tabUrl: tab.url 154 | } 155 | ) 156 | .then ((r) => { 157 | // console.log(`xIFr: ${r}`) 158 | }) 159 | .catch((e) => { 160 | console.error('xIFr: Sending parseImage message failed! \n' + e) 161 | }); 162 | } 163 | ) 164 | .catch((err) => { 165 | console.error(`xIFr: storage.session or sendMessage(parseImage) error: ${err}`); 166 | }); 167 | } 168 | ) 169 | .catch((err) => { 170 | console.error(`xIFr: Failed getting data or injecting scripts: ${err}`); 171 | }); 172 | } else { 173 | console.error(`xIFr: Can't run browser.scripting.executeScript. Missing "scripting" or "activeTab" permission?`); 174 | } 175 | } 176 | } 177 | }); 178 | 179 | /** 180 | * @param blob 181 | * @returns {Promise} 182 | */ 183 | function convertBlobToBase64(blob) { 184 | return new Promise(resolve => { 185 | const reader = new FileReader(); 186 | reader.onloadend = () => { 187 | const base64data = reader.result; 188 | resolve(base64data); 189 | }; 190 | reader.readAsDataURL(blob); 191 | }); 192 | } 193 | 194 | browser.runtime.onMessage.addListener( 195 | function messageHandler(message, sender, sendResponse) { 196 | 197 | if (["fetchdata", "fetchdataBase64"].includes(message.message)) { // backend fetch 198 | 199 | // TODO: Maybe consider how this could be refactored? ... 200 | 201 | const result = {}; 202 | const url = new URL(message.href); // image.src 203 | function fetchdata_error(error) { 204 | console.error('xIFr: Background ' + message.message + ' error!', error); 205 | if (error.name === 'TimeoutError' || error.name === 'AbortError') { 206 | console.error("xIFr: Aborted - likely timeout - when reading image-data from " + url); 207 | result.error = browser.i18n.getMessage('fetchImageAbortError'); 208 | } else { 209 | console.error(`xIFr: fetch-ERROR trying to read image-data from ${url} :\n`, error); 210 | result.error = browser.i18n.getMessage('fetchImageError'); 211 | result.info = browser.i18n.getMessage('fetchFileWorkAroundInfo'); 212 | } 213 | // context.debug("xIFr: fetch-ERROR Event.lengthComputable:" + error.lengthComputable); 214 | result.byteLength = ''; 215 | result.contentType = ''; 216 | result.lastModified = ''; 217 | sendResponse(result); 218 | } 219 | const fetchOptions = message.fetchOptions; 220 | fetchOptions.credentials = 'omit'; // Recommended by Mozilla 221 | fetchOptions.cache = 'no-cache'; // Recommended by Mozilla 222 | const fetchTimeout = 8000; // 8 seconds 223 | if (AbortSignal?.timeout) { 224 | fetchOptions.signal = AbortSignal.timeout(fetchTimeout); 225 | } 226 | 227 | if (message.message === "fetchdata") { // Probably Firefox 228 | 229 | fetch(url.href, fetchOptions) 230 | .then( 231 | function(response) { 232 | if (response.ok) { // 200ish 233 | result.byteLength = response.headers.get('Content-Length') || ''; 234 | result.contentType = response.headers.get('Content-Type') || ''; // https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types 235 | result.lastModified = response.headers.get('Last-Modified') || ''; 236 | return response.arrayBuffer(); // Promise 237 | } else { 238 | console.error('xIFr: Network response was not ok.'); 239 | throw new Error('Network response was not ok.'); 240 | } 241 | } 242 | ) 243 | .then( 244 | function(arrayBuffer) { 245 | if (arrayBuffer) { 246 | context.debug("Looking at the fetch response (arrayBuffer)..."); 247 | context.info("headers.byteLength: " + result.byteLength); 248 | context.info("arraybuffer.byteLength: " + arrayBuffer.byteLength); 249 | result.byteArray = new Uint8Array(arrayBuffer); 250 | result.byteLength = arrayBuffer.byteLength || result.byteLength; 251 | } 252 | sendResponse(result); 253 | } 254 | ) 255 | .catch(fetchdata_error); 256 | return true; // Tell it to expect a later response (to be sent with sendResponse()) 257 | 258 | } else if (message.message === "fetchdataBase64") { // Probably a Chromium browser 259 | 260 | fetch(url.href, fetchOptions) 261 | .then( 262 | function(response) { 263 | if (response.ok) { // 200ish 264 | result.byteLength = response.headers.get('Content-Length') || ''; 265 | result.contentType = response.headers.get('Content-Type') || ''; // https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types 266 | result.lastModified = response.headers.get('Last-Modified') || ''; 267 | return response.blob(); // Promise 268 | } else { 269 | console.error('xIFr: Network response was not ok.'); 270 | throw new Error('Network response was not ok.'); 271 | } 272 | } 273 | ) 274 | .then(convertBlobToBase64) 275 | .then( 276 | /** 277 | * @param base64 {string} data-url 278 | */ 279 | function(base64) { 280 | if (base64) { 281 | context.debug("Looking at the fetch response (base64)..."); 282 | context.info("headers.byteLength: " + result.byteLength); 283 | result.byteLength = result.byteLength || Math.round(base64.length / 1.36667); // Approximation 284 | result.base64 = base64; 285 | } else { 286 | console.error('xIFr: base64 data missing'); 287 | } 288 | sendResponse(result); 289 | } 290 | ) 291 | .catch(fetchdata_error); 292 | return true; // Tell it to expect a later response (to be sent with sendResponse()) 293 | 294 | } 295 | 296 | } else if (message.message === "EXIFready") { // 1st msg, create popup 297 | 298 | const popupData = {}; 299 | popupData.infos = message.infos; 300 | popupData.warnings = message.warnings; 301 | popupData.errors = message.errors; 302 | popupData.properties = message.properties; 303 | if (Object.keys(message.data).length === 0) { 304 | popupData.infos.push(browser.i18n.getMessage("noEXIFdata")); 305 | } 306 | if (popupData.properties.URL?.startsWith('blob:http')) { 307 | popupData.warnings.push(browser.i18n.getMessage('displayBlobTrouble')); 308 | } 309 | if (popupData.properties.URL?.startsWith('file:') && context.isFirefox()) { 310 | popupData.warnings.push(browser.i18n.getMessage('displayFileTrouble')); 311 | // TODO: Er det faktisk muligt at vise lokalt image med URL.createObjectURL(blob) ? 312 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Working_with_files#retrieving_stored_images_for_display 313 | } 314 | popupData.data = message.data; 315 | browser.storage.session.set({"popupData": popupData}).then(() => { 316 | browser.storage.session.get() 317 | .then(({previous, winpop}) => { 318 | if (previous?.imgURL && previous.imgURL === message.properties.URL && !message.properties.URL.startsWith('file:')) { 319 | context.debug("Previous popup was same - Focus to previous if still open..."); 320 | browser.windows.update(previous.winId, {focused: true}) 321 | .then(() => { 322 | context.debug("Existing popup was attempted REfocused.") 323 | } 324 | ) 325 | .catch(() => { 326 | context.debug("REfocusing didn't succeed. Creating a new popup..."); 327 | createPopup(message, winpop) 328 | } 329 | ); 330 | } else { 331 | if (previous?.winId) { // But it would be smarter to just re-use an existing popup than to close previous and open a new? 332 | browser.windows.remove(previous.winId) 333 | .then(() => { 334 | context.debug("Popup with id=" + previous.winId + " was closed.") 335 | }) 336 | .catch((err) => { 337 | context.debug("Closing xIFr popup with id=" + previous.winId + " failed: " + err) 338 | }); 339 | } 340 | createPopup(message, winpop); 341 | } 342 | }); 343 | }); 344 | 345 | } else if (message.message === "popupReady") { // 2nd msg, populate popup 346 | 347 | browser.storage.session.get("popupData") 348 | .then ( 349 | function (data) { 350 | sendResponse(data.popupData); 351 | } 352 | ); 353 | return true; // tell it to expect a later response (sent with sendResponse()) 354 | 355 | } 356 | 357 | } 358 | ); 359 | 360 | function createPopup(request, popupPos) { // Called when 'EXIFready' 361 | const win = request.properties.wprop.win; 362 | const scr = request.properties.wprop.scr; 363 | let pos = {}; 364 | const width = 650; 365 | const height = 500; 366 | if (!win?.width) { 367 | console.error('xIFr: Current browser-window properties not received by backgroundscript'); 368 | } 369 | if (!scr?.availWidth) { 370 | console.error('xIFr: window.screen properties not received by backgroundscript'); 371 | } 372 | switch (popupPos) { 373 | case "center": 374 | if (scr?.availWidth) { 375 | pos = { 376 | left: Math.floor(scr.availWidth / 2) - 325, 377 | top: Math.floor(scr.availHeight / 2) - 250 378 | }; 379 | } 380 | break; 381 | case "centerBrowser": 382 | if (win?.width) { 383 | pos = { 384 | left: win.left + Math.floor(win.width / 2) - 325, 385 | top: win.top + Math.floor(win.height / 2) - 250 386 | }; 387 | } 388 | break; 389 | case "topLeft": 390 | pos = {left: 10, top: 10}; 391 | break; 392 | case "topRight": 393 | if (scr?.availWidth) { 394 | pos = {left: scr.availWidth - 650 - 10, top: 10}; 395 | } 396 | break; 397 | case "topLeftBrowser": 398 | if (win?.width) { 399 | pos = {left: win.left + 10, top: win.top + 10}; 400 | } 401 | break; 402 | case "topRightBrowser": 403 | if (win?.width) { 404 | pos = {left: win.left + win.width - 650 - 10, top: win.top + 10}; 405 | } 406 | break; 407 | case "leftish": 408 | if (win?.width) { 409 | pos = { 410 | left: Math.max(win.left - 200, 10), 411 | top: Math.max(win.top + Math.floor(win.height / 2) - 350, 10) 412 | }; 413 | } 414 | break; 415 | case "rightish": 416 | if (win?.width || scr?.availWidth) { 417 | pos = { 418 | left: Math.min(win.left + win.width - 450, scr.availWidth - 650 - 10), 419 | top: Math.max(win.top + Math.floor(win.height / 2) - 350, 10) 420 | }; 421 | } 422 | break; 423 | case "snapLeft": 424 | if (scr?.availWidth) { 425 | pos = {left: 0, top: 0, height: scr.availHeight}; 426 | } 427 | break; 428 | case "snapRight": 429 | if (scr?.availWidth) { 430 | pos = {left: scr.availWidth - width, top: 0, height: scr.availHeight}; 431 | } 432 | break; 433 | } 434 | // TODO: Use an object spread instead of Object.assign!? (also elsewhere?...):... 435 | browser.windows.create(Object.assign( 436 | { 437 | url: browser.runtime.getURL("/popup/popup.html"), 438 | type: "popup", 439 | width: width, 440 | height: height 441 | }, pos)) 442 | .then((popwin) => { 443 | browser.storage.session.set({"previous": {"winId": popwin.id, "imgURL": request.properties.URL}}); 444 | } 445 | ); 446 | } 447 | 448 | function createMenuItem(useDeepSearch) { 449 | // TODO: Remove multiple call to this when possible. 450 | // But for now, run "frequently" because: 451 | // https://bugzilla.mozilla.org/show_bug.cgi?id=1771328, 452 | // https://bugzilla.mozilla.org/show_bug.cgi?id=1817287, 453 | // https://discourse.mozilla.org/t/strange-mv3-behaviour-browser-runtime-oninstalled-event-and-menus-create/111208/11 454 | // TODO: Also see https://bugzilla.mozilla.org/show_bug.cgi?id=1527979 455 | browser.contextMenus.create({ 456 | id: "viewexif", 457 | title: browser.i18n.getMessage("contextMenuText"), 458 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/ContextType 459 | contexts: useDeepSearch ? ["image", "link", "page", "frame", "editable", "video", "audio"] : ["image"] 460 | }, 461 | () => { 462 | if (browser.runtime.lastError) { 463 | context.log('Menu-item probably already created: ' + browser.runtime.lastError.message); 464 | } else { 465 | context.log('Menu-item created.'); 466 | } 467 | } 468 | ); 469 | } 470 | -------------------------------------------------------------------------------- /WebExtension/binExif.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | * 5 | * This Source Code Form is "Incompatible With Secondary Licenses", as 6 | * defined by the Mozilla Public License, v. 2.0. 7 | */ 8 | 9 | /* 10 | * Interpreter for binary EXIF data. 11 | */ 12 | 13 | function exifClass() { 14 | var loopDetectorArray = new Array(); 15 | 16 | // data formats 17 | const FMT_BYTE = 1; 18 | const FMT_STRING = 2; 19 | const FMT_USHORT = 3; 20 | const FMT_ULONG = 4; 21 | const FMT_URATIONAL = 5; 22 | const FMT_SBYTE = 6; 23 | const FMT_UNDEFINED = 7; 24 | const FMT_SSHORT = 8; 25 | const FMT_SLONG = 9; 26 | const FMT_SRATIONAL = 10; 27 | const FMT_SINGLE = 11; 28 | const FMT_DOUBLE = 12; 29 | 30 | // EXIF tags 31 | const TAG_DESCRIPTION = 0x010E; 32 | const TAG_MAKE = 0x010F; 33 | const TAG_MODEL = 0x0110; 34 | const TAG_ORIENTATION = 0x0112; 35 | const TAG_SOFTWARE = 0x0131; 36 | const TAG_DATETIME = 0x0132; 37 | const TAG_ARTIST = 0x013B; 38 | const TAG_THUMBNAIL_OFFSET = 0x0201; 39 | const TAG_THUMBNAIL_LENGTH = 0x0202; 40 | const TAG_COPYRIGHT = 0x8298; 41 | const TAG_EXPOSURETIME = 0x829A; 42 | const TAG_FNUMBER = 0x829D; 43 | const TAG_EXIF_OFFSET = 0x8769; 44 | const TAG_EXPOSURE_PROGRAM = 0x8822; 45 | const TAG_GPSINFO = 0x8825; 46 | const TAG_ISO_EQUIVALENT = 0x8827; 47 | const TAG_DATETIME_ORIGINAL = 0x9003; 48 | const TAG_DATETIME_DIGITIZED = 0x9004; 49 | const TAG_SHUTTERSPEED = 0x9201; 50 | const TAG_APERTURE = 0x9202; 51 | const TAG_EXPOSURE_BIAS = 0x9204; 52 | const TAG_MAXAPERTURE = 0x9205; 53 | const TAG_SUBJECT_DISTANCE = 0x9206; 54 | const TAG_METERING_MODE = 0x9207; 55 | const TAG_LIGHT_SOURCE = 0x9208; 56 | const TAG_FLASH = 0x9209; 57 | const TAG_FOCALLENGTH = 0x920A; 58 | const TAG_MAKER_NOTE = 0x927C; 59 | const TAG_USERCOMMENT = 0x9286; 60 | const TAG_EXIF_IMAGEWIDTH = 0xa002; 61 | const TAG_EXIF_IMAGELENGTH = 0xa003; 62 | const TAG_INTEROP_OFFSET = 0xa005; 63 | const TAG_FOCALPLANEXRES = 0xa20E; 64 | const TAG_FOCALPLANEUNITS = 0xa210; 65 | const TAG_EXPOSURE_INDEX = 0xa215; 66 | const TAG_EXPOSURE_MODE = 0xa402; 67 | const TAG_WHITEBALANCE = 0xa403; 68 | const TAG_DIGITALZOOMRATIO = 0xa404; 69 | const TAG_FOCALLENGTH_35MM = 0xa405; 70 | const TAG_LENS = 0xfdea; 71 | const TAG_LENSINFO = 0xa432; 72 | const TAG_LENSMAKE = 0xa433; 73 | const TAG_LENSMODEL = 0xa434; 74 | const TAG_COLORSPACE = 0xa001; 75 | const TAG_INTEROPINDEX = 0x0001; 76 | 77 | // https://jmoliver.wordpress.com/2018/07/07/capturing-the-moment-and-the-ambient-weather-information-in-photos/ todo ? 78 | const TAG_TEMPERATURE = 0x9400; // Ambient temperature in degrees C, called Temperature by the EXIF spec. 79 | const TAG_HUMIDITY = 0x9401; // Ambient relative humidity in percent 80 | const TAG_PRESSURE = 0x9402; // Air pressure in hPa or mbar 81 | const TAG_WATER_DEPTH = 0x9403; // Depth under water in metres, negative for above water. 82 | 83 | const TAG_GPS_LAT_REF = 0x0001; 84 | const TAG_GPS_LAT = 0x0002; 85 | const TAG_GPS_LON_REF = 0x0003; 86 | const TAG_GPS_LON = 0x0004; 87 | const TAG_GPS_ALT_REF = 0x0005; 88 | const TAG_GPS_ALT = 0x0006; 89 | const TAG_GPS_IMG_DIR_REF = 0x0010; 90 | const TAG_GPS_IMG_DIR = 0x0011; 91 | 92 | // https://exiftool.org/TagNames/EXIF.html 93 | 94 | var BytesPerFormat = [0, 1, 1, 2, 4, 8, 1, 1, 2, 4, 8, 4, 8]; 95 | 96 | // Decodes arrays carrying UTF-8 sequences into Unicode strings. 97 | // It also validates sequences and throws an error if it encounters 98 | // invalid encodings. 99 | function utf8BytesToString(utf8data, offset, num) // NOTICE, this is different from equally named in binIptc.js!!! 100 | { 101 | var s = ""; 102 | var c = c1 = c2 = 0; 103 | 104 | // Could String.fromCodePoint() be used instead !!?? 105 | for (var i = offset; i < offset + num; ) { 106 | c = utf8data[i]; 107 | if (c <= 127) { 108 | if (c !== 0) 109 | s += String.fromCharCode(c); 110 | i++; 111 | } else if ((c >= 194) && (c <= 223)) { 112 | c2 = utf8data[i + 1]; 113 | if (c2 >> 6 !== 2) 114 | throw "No valid UTF8-Sequence"; 115 | s += String.fromCharCode(((c & 31) << 6) | (c2 & 63)); 116 | i += 2; 117 | } else if ((c >= 224) && (c <= 239)) { 118 | c2 = utf8data[i + 1]; 119 | if (c2 >> 6 !== 2) 120 | throw "No valid UTF8-Sequence"; 121 | c3 = utf8data[i + 2]; 122 | if (c3 >> 6 !== 2) 123 | throw "No valid UTF8-Sequence"; 124 | s += String.fromCharCode(((c & 15) << 12) | ((c2 & 63) << 6) | (c3 & 63)); 125 | i += 3; 126 | } else if ((c >= 240) && (c <= 244)) { 127 | c2 = utf8data[i + 1]; 128 | if (c2 >> 6 !== 2) 129 | throw "No valid UTF8-Sequence"; 130 | c3 = utf8data[i + 2]; 131 | if (c3 >> 6 !== 2) 132 | throw "No valid UTF8-Sequence"; 133 | c4 = utf8data[i + 3]; 134 | if (c4 >> 6 !== 2) 135 | throw "No valid UTF8-Sequence"; 136 | s += String.fromCharCode(((c & 7) << 18) | ((c2 & 63) << 12) | ((c3 & 63) << 6) | (c4 & 63)); 137 | i += 4; 138 | } else { 139 | throw "No valid UTF8-Sequence"; 140 | } 141 | } 142 | 143 | return s; 144 | } 145 | 146 | // Checks if the loopDetectorArray already contains an entry 147 | // which would mean we did already jump there once. 148 | function checkForLoop(val) { 149 | for (var i = 0; i < loopDetectorArray.length; i++) { 150 | if (loopDetectorArray[i] == val) 151 | return true; 152 | } 153 | return false; 154 | } 155 | 156 | function dir_entry_addr(start, entry) { 157 | return start + 2 + entry * 12; 158 | } 159 | 160 | function ConvertAnyFormat(data, format, offset, components, numbytes, swapbytes, charWidth) { 161 | // centralised check if the data lays within the data array 162 | if (offset + numbytes > data.length) { 163 | console.error("xIFr: Data outside array."); 164 | // throw "Data outside array."; 165 | return; 166 | } 167 | 168 | var value = 0; 169 | 170 | switch (format) { 171 | case FMT_STRING: 172 | // try decoding strings as UTF-8, if it fails, handle them 1:1. 173 | try { 174 | if (charWidth === 1) // don’t try handling Unicode strings as UTF-8 175 | value = utf8BytesToString(data, offset, numbytes); 176 | else 177 | value = fxifUtils.bytesToString(data, offset, numbytes, swapbytes, charWidth); 178 | } catch (e) { 179 | context.debug("catch!"); 180 | value = fxifUtils.bytesToString(data, offset, numbytes, swapbytes, charWidth); 181 | } 182 | // strip trailing whitespace 183 | value = value.replace(/\s+$/, ''); 184 | break; 185 | 186 | case FMT_UNDEFINED: // treat as string 187 | value = fxifUtils.bytesToString(data, offset, numbytes, swapbytes, charWidth); 188 | // strip trailing whitespace 189 | value = value.replace(/\s+$/, ''); 190 | break; 191 | 192 | case FMT_SBYTE: 193 | value = data[offset]; 194 | break; 195 | case FMT_BYTE: 196 | value = data[offset]; 197 | break; 198 | 199 | case FMT_USHORT: 200 | value = fxifUtils.read16(data, offset, swapbytes); 201 | break; 202 | case FMT_ULONG: 203 | value = fxifUtils.read32(data, offset, swapbytes); 204 | break; 205 | 206 | case FMT_URATIONAL: 207 | case FMT_SRATIONAL: { 208 | // It sometimes happens that there are multiple rationals contained. 209 | // So go for multiple here and convert back later. 210 | var values = new Array(); 211 | 212 | for (var i = 0; i < components; i++) { 213 | var Num, Den; 214 | Num = fxifUtils.read32(data, offset + i * 8, swapbytes); 215 | Den = fxifUtils.read32(data, offset + i * 8 + 4, swapbytes); 216 | if (Den === 0) { 217 | values[i] = 0; 218 | } else { 219 | values[i] = Num / Den; 220 | } 221 | } 222 | 223 | if (components === 1) 224 | value = values[0]; 225 | else 226 | value = values; 227 | break; 228 | } 229 | 230 | case FMT_SSHORT: 231 | value = fxifUtils.read16(data, offset, swapbytes); 232 | break; 233 | case FMT_SLONG: 234 | value = fxifUtils.read32(data, offset, swapbytes); 235 | break; 236 | 237 | // ignore, probably never used 238 | case FMT_SINGLE: 239 | value = 0; 240 | break; 241 | case FMT_DOUBLE: 242 | value = 0; 243 | break; 244 | } 245 | 246 | return value; 247 | } 248 | 249 | function cardinal16Direction(val) { 250 | // https://en.wikipedia.org/wiki/Points_of_the_compass 251 | // http://snowfence.umn.edu/Components/winddirectionanddegrees.htm 252 | const card16 = [ {min: 0, max: 11.25, dir: 'N'}, 253 | {min: 11.25, max: 33.75, dir: 'NNE'}, 254 | {min: 33.75, max: 56.25, dir: 'NE'}, 255 | {min: 56.25, max: 78.75, dir: 'ENE'}, 256 | {min: 78.75, max: 101.25, dir: 'E'}, 257 | {min: 101.25, max: 123.75, dir: 'ESE'}, 258 | {min: 123.75, max: 146.25, dir: 'SE'}, 259 | {min: 146.25, max: 168.75, dir: 'SSE'}, 260 | {min: 168.75, max: 191.25, dir: 'S'}, 261 | {min: 191.25, max: 213.75, dir: 'SSW'}, 262 | {min: 213.75, max: 236.25, dir: 'SW'}, 263 | {min: 236.25, max: 258.75, dir: 'WSW'}, 264 | {min: 258.75, max: 281.25, dir: 'W'}, 265 | {min: 281.25, max: 303.75, dir: 'WNW'}, 266 | {min: 303.75, max: 326.25, dir: 'NW'}, 267 | {min: 326.25, max: 348.75, dir: 'NNW'}, 268 | {min: 348.75, max: 360, dir: 'N'} ]; 269 | for (const cardinal of card16) { 270 | if (cardinal.min <= val && cardinal.max >= val) { 271 | return cardinal.dir; 272 | } 273 | } 274 | return ''; 275 | } 276 | 277 | function readGPSDir(dataObj, data, dirStart, swapbytes) { 278 | var numEntries = fxifUtils.read16(data, dirStart, swapbytes); 279 | // check if all entries lay within the data array 280 | if (dirStart + 2 + numEntries * 12 > data.length) 281 | // so something is wrong – limit numEntries or bail out? 282 | // let’s try with limiting it 283 | numEntries = Math.floor((data.length - (dirStart + 2)) / (numEntries * 12)); 284 | var gpsLatHemisphere = 'N', 285 | gpsLonHemisphere = 'E', 286 | gpsAltReference = 0, 287 | gpsImgDirReference = 'M'; 288 | var vals = new Array(); 289 | 290 | for (var i = 0; i < numEntries; i++) { 291 | var entry = dir_entry_addr(dirStart, i); 292 | var tag = fxifUtils.read16(data, entry, swapbytes); 293 | var format = fxifUtils.read16(data, entry + 2, swapbytes); 294 | var components = fxifUtils.read32(data, entry + 4, swapbytes); 295 | 296 | if (format >= BytesPerFormat.length) 297 | continue; 298 | 299 | var nbytes = components * BytesPerFormat[format]; 300 | var valueoffset; 301 | 302 | if (nbytes <= 4) // stored in the entry 303 | valueoffset = entry + 8; 304 | else 305 | valueoffset = fxifUtils.read32(data, entry + 8, swapbytes); 306 | 307 | // try 308 | // { 309 | let val = ConvertAnyFormat(data, format, valueoffset, components, nbytes, swapbytes, 1); 310 | // If ConvertAnyFormat() encounters that data lies 311 | // outside of the data array, it returns undefined. 312 | // We don’t want to assign this but to try again with 313 | // the next tag. 314 | if (val === undefined) { 315 | context.info("binExif [GPS] UNDEFINED val(ue) for tag=" + tag + " (" + tag.toString(16) + ") ... SKIP!"); 316 | continue; 317 | } 318 | 319 | context.info("binExif [GPS] tag=" + tag + " (" + tag.toString(16) + "), value=" + val); 320 | switch (tag) { 321 | case TAG_GPS_LAT_REF: 322 | gpsLatHemisphere = val; 323 | break; 324 | 325 | case TAG_GPS_LON_REF: 326 | gpsLonHemisphere = val; 327 | break; 328 | 329 | case TAG_GPS_ALT_REF: 330 | gpsAltReference = val; 331 | break; 332 | 333 | case TAG_GPS_IMG_DIR_REF: 334 | gpsImgDirReference = val; 335 | break; 336 | 337 | case TAG_GPS_LAT: 338 | case TAG_GPS_LON: 339 | // data is saved as three 64bit rationals -> 24 bytes 340 | // so we've to do another two ConvertAnyFormat() ourself 341 | // e.g. 0x0b / 0x01, 0x07 / 0x01, 0x011c4d / 0x0c92 342 | // but can also be only 0x31 / 0x01, 0x3d8ba / 0x2710, 0x0 / 0x01 343 | var gpsval = val[0] * 3600 + val[1] * 60 + val[2]; 344 | // var gpsval = val * 3600; 345 | // gpsval += ConvertAnyFormat(data, format, valueoffset+8, nbytes, swapbytes) * 60; 346 | // gpsval += ConvertAnyFormat(data, format, valueoffset+16, nbytes, swapbytes); 347 | vals[tag] = gpsval; 348 | break; 349 | 350 | case TAG_GPS_ALT: 351 | vals[tag] = val; 352 | break; 353 | 354 | case TAG_GPS_IMG_DIR: 355 | vals[tag] = val; 356 | break; 357 | 358 | default: 359 | context.info("binExif [GPS] UNHANDLED TAG: tag=" + tag + " (" + tag.toString(16) + "), value=" + val); 360 | break; 361 | } 362 | // } catch(e){} 363 | // so something is wrong – bail out or step over this value? 364 | // let’s try with just stepping over this value 365 | } 366 | 367 | // use dms format by default 368 | var degFormat = "dms"; 369 | var degFormatter = fxifUtils.dd2dms; 370 | try { 371 | // 0 = DMS, 1 = DD, 2 = DM 372 | if (fxifUtils.getPreferences().getIntPref("gpsFormat") == 1) { 373 | // but dd if the user wants that 374 | degFormat = "dd"; 375 | degFormatter = fxifUtils.dd2dd; 376 | } else if (fxifUtils.getPreferences().getIntPref("gpsFormat") == 2) { 377 | // but dd if the user wants that 378 | degFormat = "dm"; 379 | degFormatter = fxifUtils.dd2dm; 380 | } 381 | } catch (e) {} 382 | // now output all existing values 383 | if (vals[TAG_GPS_LAT] !== undefined) { 384 | let gpsArr = degFormatter(vals[TAG_GPS_LAT]); 385 | gpsArr.push(gpsLatHemisphere); 386 | dataObj.GPSLat = stringBundle.getFormattedString("latlon" + degFormat, gpsArr); 387 | } 388 | if (vals[TAG_GPS_LON] !== undefined) { 389 | let gpsArr = degFormatter(vals[TAG_GPS_LON]); 390 | gpsArr.push(gpsLonHemisphere); 391 | dataObj.GPSLon = stringBundle.getFormattedString("latlon" + degFormat, gpsArr); 392 | } 393 | if (vals[TAG_GPS_ALT] !== undefined) { 394 | dataObj.GPSAlt = stringBundle.getFormattedString("meters", [vals[TAG_GPS_ALT] * (gpsAltReference ? -1.0 : 1.0)]); 395 | } 396 | if (vals[TAG_GPS_IMG_DIR] !== undefined && (gpsImgDirReference === 'M' || gpsImgDirReference === 'T')) { 397 | dataObj.GPSImgDir = cardinal16Direction(vals[TAG_GPS_IMG_DIR]) + ' ' + stringBundle.getFormattedString("dir" + gpsImgDirReference, [vals[TAG_GPS_IMG_DIR]]); 398 | } 399 | // Get the straight decimal values without rounding. 400 | // For creating links to map services. 401 | if (vals[TAG_GPS_LAT] !== undefined && 402 | vals[TAG_GPS_LON] !== undefined) { 403 | dataObj.GPSPureDdLat = vals[TAG_GPS_LAT] / 3600 * (gpsLatHemisphere === 'N' ? 1.0 : -1.0); 404 | dataObj.GPSPureDdLon = vals[TAG_GPS_LON] / 3600 * (gpsLonHemisphere === 'E' ? 1.0 : -1.0); 405 | } 406 | } 407 | 408 | /* Reads the Canon Tags IFD. 409 | EOS60D_YIMG_0007.JPG 410 | 0x927c at pos 0x22a 411 | Type undef (7) 412 | Components 0x1e2c 413 | Offset 0x0382 414 | Real MN-Offset: 0x038e 415 | 416 | */ 417 | /* CanonExifDir (MakerNotes) are always in Intel (little endian) byte order, 418 | * so always do swapbytes. 419 | */ 420 | function readCanonExifDir(dataObj, data, dirStart, makerNotesLen) { 421 | // Canon EXIF tags 422 | const TAG_CAMERA_INFO = 0x000d; 423 | const TAG_LENS_MODEL = 0x0095; 424 | const TAG_CANON_MODEL_ID = 0x0010; 425 | 426 | var ntags = 0; 427 | // Canon MakerNotes are always in Intel (little endian) byte order 428 | var swapbytes = true; 429 | var numEntries = fxifUtils.read16(data, dirStart, swapbytes); 430 | // check if all entries lay within the data array 431 | if (dirStart + 2 + numEntries * 12 > data.length) 432 | // so something is wrong – limit numEntries or bail out? 433 | // let’s try with limiting it 434 | numEntries = Math.floor((data.length - (dirStart + 2)) / (numEntries * 12)); 435 | 436 | var entryOffset = FixCanonMakerNotesBase(data, dirStart, makerNotesLen); 437 | 438 | for (var i = 0; i < numEntries; i++) { 439 | var entry = dir_entry_addr(dirStart, i); 440 | var tag = fxifUtils.read16(data, entry, swapbytes); 441 | var format = fxifUtils.read16(data, entry + 2, swapbytes); 442 | var components = fxifUtils.read32(data, entry + 4, swapbytes); 443 | 444 | if (format >= BytesPerFormat.length) 445 | continue; 446 | 447 | var nbytes = components * BytesPerFormat[format]; 448 | var valueoffset; 449 | if (nbytes <= 4) { 450 | // stored in the entry 451 | valueoffset = entry + 8; 452 | } else { 453 | // stored in data area 454 | valueoffset = fxifUtils.read32(data, entry + 8, swapbytes) + entryOffset; 455 | } 456 | 457 | let val = ConvertAnyFormat(data, format, valueoffset, components, nbytes, swapbytes, 1); 458 | // If ConvertAnyFormat() encounters that data lies 459 | // outside of the data array, it returns undefined. 460 | // We don’t want to assign this but to try again with 461 | // the next tag. 462 | if (val === undefined) { 463 | context.info("binExif [Canon] UNDEFINED val(ue) for tag=" + tag + " (" + tag.toString(16) + ") ... SKIP!"); 464 | continue; 465 | } 466 | 467 | ntags++; 468 | context.info("binExif [Canon] tag=" + tag + " (" + tag.toString(16) + "), value=" + val); 469 | switch (tag) { 470 | case TAG_CAMERA_INFO: 471 | dataObj.CameraInfo = val; 472 | break; 473 | case TAG_CANON_MODEL_ID: 474 | dataObj.ModelID = val; 475 | break; 476 | case TAG_LENS_MODEL: 477 | dataObj.Lens = val; 478 | break; 479 | default: 480 | context.info("binExif [Canon] UNHANDLED TAG: tag=" + tag + " (" + tag.toString(16) + "), value=" + val); 481 | ntags--; 482 | } 483 | } 484 | 485 | /* List of Canon Model IDs 486 | 0x80000001 = EOS-1D 487 | 0x80000167 = EOS-1DS 488 | 0x80000168 = EOS 10D 489 | 0x80000169 = EOS-1D Mark III 490 | 0x80000170 = EOS Digital Rebel / 300D / Kiss Digital 491 | 0x80000174 = EOS-1D Mark II 492 | 0x80000175 = EOS 20D 493 | 0x80000176 = EOS Digital Rebel XSi / 450D / Kiss X2 494 | 0x80000188 = EOS-1Ds Mark II 495 | 0x80000189 = EOS Digital Rebel XT / 350D / Kiss Digital N 496 | 0x80000190 = EOS 40D 497 | 0x80000213 = EOS 5D 498 | 0x80000215 = EOS-1Ds Mark III 499 | 0x80000218 = EOS 5D Mark II 500 | 0x80000232 = EOS-1D Mark II N 501 | 0x80000234 = EOS 30D 502 | 0x80000236 = EOS Digital Rebel XTi / 400D / Kiss Digital X 503 | 0x80000250 = EOS 7D 504 | 0x80000252 = EOS Rebel T1i / 500D / Kiss X3 505 | 0x80000254 = EOS Rebel XS / 1000D / Kiss F 506 | 0x80000261 = EOS 50D 507 | 0x80000270 = EOS Rebel T2i / 550D / Kiss X4 508 | 0x80000281 = EOS-1D Mark IV 509 | 0x80000287 = EOS 60D 510 | 511 | More: 512 | https://sno.phy.queensu.ca/~phil/exiftool/TagNames/Canon.html 513 | 514 | */ 515 | 516 | return ntags; 517 | } 518 | 519 | // This functions code is derived from Phil Harveys Image::ExifTool::MakerNotes FixBase() 520 | function FixCanonMakerNotesBase(data, dirStart, makerNotesLen) { 521 | var swapbytes = true; 522 | var fix = 0; 523 | 524 | if (makerNotesLen > 8) { 525 | var footerPtr = dirStart + makerNotesLen - 8; 526 | var footer = fxifUtils.bytesToStringWithNull(data, footerPtr, 8); 527 | if (footer.search(/^(II\x2a\0|MM\0\x2a)/) !== -1) // check for TIFF footer 528 | // footer.substr(0, 2) == GetByteOrder()) # validate byte ordering 529 | { 530 | var offsetFromFooter = ConvertAnyFormat(data, FMT_ULONG, footerPtr + 4, 0, 0, swapbytes, 1); 531 | fix = dirStart - offsetFromFooter; 532 | if (fix === 0) 533 | return 0; 534 | // Picasa and ACDSee have a bug where they update other offsets without 535 | // updating the TIFF footer (PH - 2009/02/25), so test for this case: 536 | // validate Canon maker note footer fix by checking offset of last value 537 | // var maxPt = $valPtrs[-1] + $$valBlock{$valPtrs[-1]}; 538 | // compare to end of maker notes, taking 8-byte footer into account 539 | //Autumn1.jpg 540 | //dirStart: 4814, dirLen: 4876, maxPt: 5526, dataPos: 0 541 | // var endDiff = footerPtr - (maxPt - dataPos); 542 | // ignore footer offset only if end difference is exactly correct 543 | // (allow for possible padding byte, although I have never seen this) 544 | /* if (endDiff == 0 || endDiff == 1) { 545 | alert('Canon maker note footer may be invalid (ignored)'); 546 | // $exifTool->Warn('Canon maker note footer may be invalid (ignored)', 1); 547 | return 0; 548 | } 549 | */ 550 | } 551 | } 552 | 553 | return fix; 554 | } 555 | 556 | /* 557 | D3S_141805.jpg 558 | 0x927c at pos 0x24b 559 | Type undef (7) 560 | Components 0x8612 561 | Offset 0x0337 562 | Real MN-Offset: 0x0356 563 | 564 | */ 565 | 566 | /* Offsets to Nikon Maker Notes point to the five bytes "Nikon" followed 567 | by a null. This is followed by two bytes denoting the Exif Version as 568 | text, e.g. 0x0210, followed by two null. 569 | Then (after the above 0x0a bytes) a normal TIFF header follows with an IFD 570 | and all the data. The data in this block is has its own byte order which 571 | might be different from the one in the rest of the Exif header as denoted 572 | in this TIFF header (which typically is Motorola byte order for Nikon). 573 | */ 574 | function readNikonExifDir(dataObj, data, dirStart, swapbytes) { 575 | // Is it really Nikon? 576 | if (header === 'Nikon\0') { 577 | // step over next four bytes denoting the version (e.g. 0x02100000) 578 | // 8 byte TIFF header 579 | var exifData = bis.readByteArray(len - 6); 580 | 581 | // first two determine byte order 582 | swapbytes = fxifUtils.read16(exifData, 0, false) === INTEL_BYTE_ORDER; 583 | 584 | // next two bytes are always 0x002A 585 | // offset to Image File Directory (includes the previous 8 bytes) 586 | var ifd_ofs = fxifUtils.read32(exifData, 4, swapbytes); 587 | var exifReader = new exifClass(stringBundle); 588 | try { 589 | exifReader.readExifDir(dataObj, exifData, ifd_ofs, swapbytes); 590 | } catch (ex) { 591 | pushError(dataObj, "[EXIF]", ex); 592 | } 593 | fxifUtils.exifDone = true; 594 | context.info(" *** exifDone !!!"); 595 | } 596 | } 597 | 598 | /* Reads the actual EXIF tags. 599 | Also extracts tags for textual informations like 600 | By, Caption, Headline, Copyright. 601 | But doesn't overwrite those fields when already populated 602 | by IPTC-NAA or IPTC4XMP. 603 | */ 604 | this.readExifDir = function (dataObj, data, dirStart, swapbytes) { 605 | loopDetectorArray.push(dirStart); 606 | 607 | var ntags = 0; 608 | var numEntries = fxifUtils.read16(data, dirStart, swapbytes); 609 | // check if all entries lay within the data array 610 | var tst = dirStart + 2 + numEntries * 12; 611 | if (dirStart + 2 + numEntries * 12 > data.length) { 612 | // so something is wrong – limit numEntries or bail out? 613 | // let’s try with limiting it 614 | numEntries = Math.floor((data.length - (dirStart + 2)) / (numEntries * 12)); 615 | } 616 | 617 | var interopIndex = ""; 618 | var colorSpace = 0; 619 | var exifDateTime = 0; 620 | var exifDateTimeOrig = 0; 621 | var lensInfo; 622 | for (var i = 0; i < numEntries; i++) { 623 | var entry = dir_entry_addr(dirStart, i); 624 | var tag = fxifUtils.read16(data, entry, swapbytes); 625 | 626 | var format = fxifUtils.read16(data, entry + 2, swapbytes); 627 | var components = fxifUtils.read32(data, entry + 4, swapbytes); 628 | 629 | if (format >= BytesPerFormat.length) 630 | continue; 631 | 632 | var nbytes = components * BytesPerFormat[format]; 633 | var valueoffset; 634 | 635 | if (nbytes <= 4) { 636 | // stored in the entry 637 | valueoffset = entry + 8; 638 | } else { 639 | // stored in data area 640 | valueoffset = fxifUtils.read32(data, entry + 8, swapbytes); 641 | } 642 | 643 | let val = ConvertAnyFormat(data, format, valueoffset, components, nbytes, swapbytes, 1); 644 | // If ConvertAnyFormat() encounters that data lies 645 | // outside of the data array, it returns undefined. 646 | // We don’t want to assign this but to try again with 647 | // the next tag. 648 | if (val === undefined) { 649 | context.info("binExif UNDEFINED val(ue) for tag=" + tag + " (" + tag.toString(16) + ") ... SKIP!"); 650 | continue; 651 | } 652 | 653 | ntags++; 654 | context.info("binExif tag=" + tag + " (" + tag.toString(16) + "), value=" + val); 655 | switch (tag) { 656 | case TAG_MAKE: 657 | dataObj.Make = val; 658 | break; 659 | 660 | case TAG_MODEL: 661 | dataObj.Model = val; 662 | break; 663 | 664 | case TAG_SOFTWARE: 665 | dataObj.Software = val; 666 | break; 667 | 668 | case TAG_DATETIME_ORIGINAL: 669 | exifDateTimeOrig = val; 670 | break; 671 | 672 | case TAG_DATETIME_DIGITIZED: 673 | case TAG_DATETIME: 674 | exifDateTime = val; 675 | break; 676 | 677 | case TAG_USERCOMMENT: 678 | var charWidth = 1; 679 | if (val.search(/^UNICODE\s*/) >= 0) { 680 | charWidth = 2; 681 | } 682 | // strip leading character code string 683 | var ccStringLen = 0; 684 | if (nbytes >= 8) { 685 | ccStringLen = 8; 686 | } 687 | dataObj.UserComment = ConvertAnyFormat(data, format, valueoffset + ccStringLen, components, nbytes - ccStringLen, swapbytes, charWidth); 688 | break; 689 | 690 | case TAG_FNUMBER: 691 | dataObj.ApertureFNumber = "ƒ/" + parseFloat(val).toFixed(1); 692 | break; 693 | 694 | // only use these if we don't have the previous 695 | case TAG_APERTURE: 696 | if (!dataObj.ApertureFNumber) { 697 | dataObj.ApertureFNumber = "ƒ/" + Math.exp((parseFloat(val) * Math.LN2 * 0.5)).toFixed(1); 698 | } 699 | break; 700 | 701 | // only use these if we don't have the previous 702 | case TAG_MAXAPERTURE: 703 | if (!dataObj.ApertureFNumber) { 704 | dataObj.ApertureFNumber = "ƒ/" + Math.exp((parseFloat(val) * Math.LN2 * 0.5)).toFixed(1); 705 | } 706 | break; 707 | 708 | case TAG_FOCALLENGTH: 709 | if (val > 0.01) { // ignore extreme low values (0 with rounding error?) 710 | dataObj.FocalLength = parseFloat(val); 711 | } 712 | break; 713 | 714 | case TAG_SUBJECT_DISTANCE: 715 | if (val < 0) { 716 | dataObj.Distance = stringBundle.getString("infinite"); 717 | } else { 718 | dataObj.Distance = stringBundle.getFormattedString("meters", [val]); 719 | } 720 | break; 721 | 722 | case TAG_EXPOSURETIME: 723 | var et = ""; 724 | val = parseFloat(val); 725 | if (val < 0.010) { 726 | et = stringBundle.getFormattedString("seconds", [val.toFixed(4)]); 727 | } else { 728 | et = stringBundle.getFormattedString("seconds", [val.toFixed(3)]); 729 | } 730 | if (val <= 0.5) { 731 | et += " (1/" + Math.floor(0.5 + 1 / val).toFixed(0) + ")"; 732 | } 733 | dataObj.ExposureTime = et; 734 | break; 735 | 736 | case TAG_SHUTTERSPEED: 737 | if (!dataObj.ExposureTime) { 738 | dataObj.ExposureTime = stringBundle.getFormattedString("seconds", [(1.0 / Math.exp(parseFloat(val) * Math.log(2))).toFixed(4)]); 739 | } 740 | break; 741 | 742 | case TAG_FLASH: 743 | // Bit 0 indicates the flash firing status, 744 | // bits 1 and 2 indicate the flash return status, 745 | // bits 3 and 4 indicate the flash mode, 746 | // bit 5 indicates whether the flash function is present, 747 | // bit 6 indicates "red eye" mode. 748 | if (val >= 0) { 749 | var fu; 750 | var addfunc = new Array(); 751 | if (val & 0x01) { 752 | fu = stringBundle.getString("yes"); 753 | 754 | if (val & 0x18 === 0x18) 755 | addfunc.push(stringBundle.getString("auto")); 756 | else if (val & 0x8) 757 | addfunc.push(stringBundle.getString("enforced")); 758 | 759 | if (val & 0x40) 760 | addfunc.push(stringBundle.getString("redeye")); 761 | 762 | if (val & 0x06 === 0x06) 763 | addfunc.push(stringBundle.getString("returnlight")); 764 | else if (val & 0x04) 765 | addfunc.push(stringBundle.getString("noreturnlight")); 766 | } else { 767 | fu = stringBundle.getString("no"); 768 | 769 | if (val & 0x20) 770 | addfunc.push(stringBundle.getString("noflash")); 771 | else if (val & 0x18 === 0x18) 772 | addfunc.push(stringBundle.getString("auto")); 773 | else if (val & 0x10) 774 | addfunc.push(stringBundle.getString("enforced")); 775 | } 776 | 777 | if (addfunc.length) 778 | fu += " (" + addfunc.join(", ") + ")"; 779 | 780 | dataObj.FlashUsed = fu; 781 | } 782 | break; 783 | 784 | case TAG_ORIENTATION: 785 | if (!dataObj.Orientation && val > 0) { 786 | if (val <= 8) 787 | dataObj.Orientation = stringBundle.getString("orientation" + val); 788 | else 789 | dataObj.Orientation = stringBundle.getString("unknown") + " (" + val + ")"; 790 | } 791 | break; 792 | /* 793 | case TAG_EXIF_IMAGELENGTH: 794 | dataObj.Length = val; 795 | break; 796 | 797 | case TAG_EXIF_IMAGEWIDTH: 798 | dataObj.Width = val; 799 | break; 800 | */ 801 | case TAG_FOCALPLANEXRES: 802 | dataObj.FocalPlaneXRes = val; 803 | break; 804 | 805 | case TAG_FOCALPLANEUNITS: 806 | switch (val) { 807 | case 1: 808 | dataObj.FocalPlaneUnits = 25.4; 809 | break; // inch 810 | case 2: 811 | // According to the information I was using, 2 means meters. 812 | // But looking at the Cannon powershot's files, inches is the only 813 | // sensible value. 814 | dataObj.FocalPlaneUnits = 25.4; 815 | break; 816 | 817 | case 3: 818 | dataObj.FocalPlaneUnits = 10; 819 | break; // centimeter 820 | case 4: 821 | dataObj.FocalPlaneUnits = 1; 822 | break; // millimeter 823 | case 5: 824 | dataObj.FocalPlaneUnits = .001; 825 | break; // micrometer 826 | } 827 | break; 828 | 829 | case TAG_EXPOSURE_BIAS: 830 | val = parseFloat(val); 831 | if (val === 0) 832 | dataObj.ExposureBias = stringBundle.getString("none"); 833 | else 834 | // add a + sign before positive values 835 | dataObj.ExposureBias = (val > 0 ? '+' : '') + stringBundle.getFormattedString("ev", [val.toFixed(2)]); 836 | break; 837 | 838 | case TAG_WHITEBALANCE: 839 | switch (val) { 840 | case 0: 841 | dataObj.WhiteBalance = stringBundle.getString("auto"); 842 | break; 843 | case 1: 844 | dataObj.WhiteBalance = stringBundle.getString("manual"); 845 | break; 846 | } 847 | break; 848 | 849 | case TAG_LIGHT_SOURCE: 850 | switch (val) { 851 | case 1: 852 | dataObj.LightSource = stringBundle.getString("daylight"); 853 | break; 854 | case 2: 855 | dataObj.LightSource = stringBundle.getString("fluorescent"); 856 | break; 857 | case 3: 858 | dataObj.LightSource = stringBundle.getString("incandescent"); 859 | break; 860 | case 4: 861 | dataObj.LightSource = stringBundle.getString("flash"); 862 | break; 863 | case 9: 864 | dataObj.LightSource = stringBundle.getString("fineweather"); 865 | break; 866 | case 10: 867 | dataObj.LightSource = stringBundle.getString("cloudy"); 868 | break; 869 | case 11: 870 | dataObj.LightSource = stringBundle.getString("shade"); 871 | break; 872 | case 12: 873 | dataObj.LightSource = stringBundle.getString("daylightfluorescent"); 874 | break; 875 | case 13: 876 | dataObj.LightSource = stringBundle.getString("daywhitefluorescent"); 877 | break; 878 | case 14: 879 | dataObj.LightSource = stringBundle.getString("coolwhitefluorescent"); 880 | break; 881 | case 15: 882 | dataObj.LightSource = stringBundle.getString("whitefluorescent"); 883 | break; 884 | case 24: 885 | dataObj.LightSource = stringBundle.getString("studiotungsten"); 886 | break; 887 | default: //Quercus: 17-1-2004 There are many more modes for this, check Exif2.2 specs 888 | // If it just says 'unknown' or we don't know it, then 889 | // don't bother showing it - it doesn't add any useful information. 890 | } 891 | break; 892 | 893 | case TAG_METERING_MODE: 894 | switch (val) { 895 | case 0: 896 | dataObj.MeteringMode = stringBundle.getString("unknown"); 897 | break; 898 | case 1: 899 | dataObj.MeteringMode = stringBundle.getString("average"); 900 | break; 901 | case 2: 902 | dataObj.MeteringMode = stringBundle.getString("centerweight"); 903 | break; 904 | case 3: 905 | dataObj.MeteringMode = stringBundle.getString("spot"); 906 | break; 907 | case 4: 908 | dataObj.MeteringMode = stringBundle.getString("multispot"); 909 | break; 910 | case 5: 911 | dataObj.MeteringMode = stringBundle.getString("matrix"); 912 | break; 913 | case 6: 914 | dataObj.MeteringMode = stringBundle.getString("partial"); 915 | break; 916 | } 917 | break; 918 | 919 | case TAG_EXPOSURE_PROGRAM: 920 | switch (val) { 921 | case 1: 922 | dataObj.ExposureProgram = stringBundle.getString("manual"); 923 | break; 924 | case 2: 925 | dataObj.ExposureProgram = stringBundle.getString("program") + " (" 926 | + stringBundle.getString("auto") + ")"; 927 | break; 928 | case 3: 929 | dataObj.ExposureProgram = stringBundle.getString("apriority") 930 | + " (" + stringBundle.getString("semiauto") + ")"; 931 | break; 932 | case 4: 933 | dataObj.ExposureProgram = stringBundle.getString("spriority") 934 | + " (" + stringBundle.getString("semiauto") + ")"; 935 | break; 936 | case 5: 937 | dataObj.ExposureProgram = stringBundle.getString("creative"); 938 | break; 939 | case 6: 940 | dataObj.ExposureProgram = stringBundle.getString("action"); 941 | break; 942 | case 7: 943 | dataObj.ExposureProgram = stringBundle.getString("portrait"); 944 | break; 945 | case 8: 946 | dataObj.ExposureProgram = stringBundle.getString("landscape"); 947 | break; 948 | default: 949 | break; 950 | } 951 | break; 952 | 953 | case TAG_EXPOSURE_INDEX: 954 | if (!dataObj.ExposureIndex) { 955 | try { 956 | // I know of at least one image where this information 957 | // is present as string instead of number. 958 | dataObj.ExposureIndex = val.toFixed(0); 959 | } catch (e) { 960 | let tmp = parseInt(val, 10); 961 | if (!Number.isNaN(tmp)) 962 | dataObj.ExposureIndex = tmp; 963 | } 964 | } 965 | break; 966 | 967 | case TAG_EXPOSURE_MODE: 968 | switch (val) { 969 | case 0: //Automatic 970 | break; 971 | case 1: 972 | dataObj.ExposureMode = stringBundle.getString("manual"); 973 | break; 974 | case 2: 975 | dataObj.ExposureMode = stringBundle.getString("autobracketing"); 976 | break; 977 | } 978 | break; 979 | 980 | case TAG_ISO_EQUIVALENT: 981 | try { 982 | // I know of at least one image where this information 983 | // is present as string instead of number. 984 | dataObj.ISOequivalent = val.toFixed(0); 985 | } catch (e) { 986 | let tmp = parseInt(val, 10); 987 | if (!Number.isNaN(tmp)) 988 | dataObj.ISOequivalent = tmp; 989 | } 990 | break; 991 | 992 | case TAG_DIGITALZOOMRATIO: 993 | if (val > 1) { 994 | dataObj.DigitalZoomRatio = val.toFixed(3) + "x"; 995 | } 996 | break; 997 | 998 | case TAG_THUMBNAIL_OFFSET: 999 | break; 1000 | 1001 | case TAG_THUMBNAIL_LENGTH: 1002 | break; 1003 | 1004 | case TAG_FOCALLENGTH_35MM: 1005 | dataObj.FocalLength35mmEquiv = val; 1006 | break; 1007 | 1008 | case TAG_LENSINFO: 1009 | lensInfo = val; 1010 | break; 1011 | 1012 | case TAG_LENSMAKE: 1013 | dataObj.LensMake = val; 1014 | break; 1015 | 1016 | case TAG_LENSMODEL: 1017 | dataObj.LensModel = val; 1018 | break; 1019 | 1020 | case TAG_EXIF_OFFSET: 1021 | case TAG_INTEROP_OFFSET: 1022 | // Prevent loops, where we directly or indirectly point to an EXIF directory where 1023 | // we've already been. It has happened that we recursed thousands of times because 1024 | // this tag pointed to its own start. 1025 | if (!checkForLoop(val)) { 1026 | // check if it jumps at least to the beginning of actual data 1027 | // and at most on the last byte of the array 1028 | if (val >= 8 && val < data.length) 1029 | ntags += this.readExifDir(dataObj, data, val, swapbytes); 1030 | } 1031 | break; 1032 | 1033 | case TAG_GPSINFO: 1034 | // check if jumps at least at the beginning of actual data 1035 | // and at most on the last byte of the array 1036 | if (val >= 8 && val < data.length) 1037 | readGPSDir(dataObj, data, val, swapbytes); 1038 | break; 1039 | 1040 | case TAG_ARTIST: 1041 | if (!dataObj.Creator) 1042 | dataObj.Creator = val; 1043 | break; 1044 | 1045 | case TAG_COPYRIGHT: 1046 | if (!dataObj.Copyright) 1047 | dataObj.Copyright = val; 1048 | break; 1049 | 1050 | case TAG_DESCRIPTION: 1051 | if (!dataObj.Caption) 1052 | dataObj.Caption = val; 1053 | break; 1054 | 1055 | case TAG_COLORSPACE: 1056 | if (!dataObj.ColorSpace) { 1057 | context.debug("binExif readExifDir (1) colorspace val="+val); 1058 | if (val == 1) 1059 | dataObj.ColorSpace = "sRGB" + (context.INFO || context.DEBUG ? " (Exif)" : ""); 1060 | else 1061 | colorSpace = val; 1062 | } 1063 | context.debug("binExif readExifDir (1.1) colorspace dataObj.ColorSpace="+dataObj.ColorSpace); 1064 | break; 1065 | 1066 | case TAG_MAKER_NOTE: 1067 | // Currently only Canon MakerNotes are supported, so filter for this 1068 | // maker. 1069 | if (dataObj.Make === 'Canon') { 1070 | // This tags format is often given as undefined or zero with 1071 | // some weird numbers or zeros as components. This makes the 1072 | // code before this switch to generate strange offsets. 1073 | // Therefore use value at entry + 8 directly. 1074 | // This should work in any case and really does for the available test images. 1075 | let mval = ConvertAnyFormat(data, FMT_ULONG, entry + 8, 0, 0, swapbytes, 1); 1076 | var dirLen = ConvertAnyFormat(data, FMT_ULONG, entry + 4, 0, 0, swapbytes, 1); 1077 | 1078 | // check if it jumps at least at the beginning of the actual 1079 | // data and at most on the last byte of the array 1080 | if (mval >= 8 && mval + dirLen < data.length) 1081 | ntags += readCanonExifDir(dataObj, data, mval, dirLen); 1082 | } 1083 | break; 1084 | 1085 | case TAG_INTEROPINDEX: 1086 | interopIndex = val; 1087 | break; 1088 | 1089 | default: 1090 | context.info("binExif UNHANDLED TAG: tag=" + tag + " (" + tag.toString(16) + "), value=" + val); 1091 | ntags--; 1092 | } 1093 | } 1094 | 1095 | // Now we can be sure to have read all data. So fill 1096 | // some properties which depend on more than one field 1097 | // or a field by various fields ordered by priority. 1098 | 1099 | if (!dataObj.Date) { 1100 | if (exifDateTimeOrig) 1101 | dataObj.Date = exifDateTimeOrig; 1102 | else if (exifDateTime) 1103 | dataObj.Date = exifDateTime; 1104 | 1105 | if (dataObj.Date) 1106 | dataObj.Date = dataObj.Date.replace(/:(\d{2}):/, "-$1-") + " " + stringBundle.getString("noTZ"); 1107 | } 1108 | 1109 | context.debug("binExif readExifDir (2) colorSpace=" + colorSpace + ", interopIndex.search=" + interopIndex.search(/^R03$/)); 1110 | if (colorSpace != 0) { 1111 | if (dataObj.ColorSpace == 2 || 1112 | dataObj.ColorSpace == 65535 && interopIndex.search(/^R03$/)) 1113 | dataObj.ColorSpace = "Adobe RGB" + (context.INFO || context.DEBUG ? " (Exif)" : ""); 1114 | } 1115 | 1116 | if (dataObj.FocalLength) { 1117 | dataObj.FocalLength = parseFloat(dataObj.FocalLength); 1118 | var fl = stringBundle.getFormattedString("millimeters", [dataObj.FocalLength.toFixed(1)]); 1119 | if (dataObj.FocalLength35mmEquiv) { 1120 | dataObj.FocalLength35mmEquiv = parseFloat(dataObj.FocalLength35mmEquiv); 1121 | fl += ", " + stringBundle.getFormattedString("35mmequiv", [dataObj.FocalLength35mmEquiv.toFixed(0)]); // Todo: Keep this? Or redo how focal length data is handled? 1122 | } 1123 | 1124 | dataObj.FocalLengthText = fl; 1125 | } 1126 | 1127 | if (dataObj.LensMake) { 1128 | dataObj.Lens = dataObj.LensMake; 1129 | } 1130 | 1131 | if (!dataObj.Lens) { 1132 | if (dataObj.LensModel) { 1133 | if (dataObj.Lens) 1134 | dataObj.Lens += " "; 1135 | else 1136 | dataObj.Lens = ""; 1137 | 1138 | dataObj.Lens += dataObj.LensModel; 1139 | } else 1140 | // 4 rationals giving focal and aperture ranges 1141 | if (lensInfo) { 1142 | if (dataObj.Lens) 1143 | dataObj.Lens += " "; 1144 | else 1145 | dataObj.Lens = ""; 1146 | 1147 | dataObj.Lens += lensInfo[0]; 1148 | if (lensInfo[1] > 0) 1149 | dataObj.Lens += "-" + lensInfo[1]; 1150 | dataObj.Lens += "mm"; 1151 | 1152 | if (lensInfo[2] > 0) { 1153 | dataObj.Lens += " ƒ/" + lensInfo[2]; 1154 | if (lensInfo[3] > 0) 1155 | dataObj.Lens += "-" + lensInfo[3]; 1156 | } 1157 | } 1158 | } 1159 | 1160 | return ntags; 1161 | } 1162 | } 1163 | -------------------------------------------------------------------------------- /WebExtension/binIptc.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | * 5 | * This Source Code Form is "Incompatible With Secondary Licenses", as 6 | * defined by the Mozilla Public License, v. 2.0. 7 | */ 8 | 9 | /* 10 | * Interpreter for binary IPTC-NAA data. 11 | */ 12 | 13 | function iptcClass(stringBundle) { 14 | const BIM_MARKER = 0x3842494D; // 8BIM segment marker 15 | const UTF8_INDICATOR = "\u001B%G"; // indicates usage of UTF8 in IPTC-NAA strings 16 | 17 | // IPTC tags 18 | // https://exiftool.org/TagNames/IPTC.html 19 | // https://www.exiv2.org/metadata.html 20 | // https://www.exiv2.org/iptc.html 21 | 22 | 23 | // IPTC_SUPLEMENTAL_CATEGORIES 0x14 // SuplementalCategories 24 | // IPTC_AUTHOR 0x7A // Author 25 | // IPTC_CATEGORY 0x0F // Category 26 | // IPTC_COPYRIGHT 0x0A // (C)Flag 27 | // IPTC_COUNTRY_CODE 0x64 // Ref. Service (?) 28 | // IPTC_REFERENCE_SERVICE 0x2D // Country Code (?) 29 | // IPTC_IMAGE_TYPE 0x82 // Image type 30 | // TAG_IPTC_COUNTRY_CODE 0x64 // Ref. Service / Country Code (?) // (ISO 3 COUNTRY CODE?) 31 | // IPTC_SOURCE 0x73 // Source (Could be an Agency ?) 32 | // IPTC_BYLINE_TITLE 0x55 // Byline Title ( ~ Creator Title ?) 33 | 34 | const TAG_IPTC_OBJECT_NAME = 0x05; // Object Name (One of the tags Flickr can parse as the title) 35 | const TAG_IPTC_CODEDCHARSET = 0x5A; 36 | const TAG_IPTC_INSTRUCTIONS = 0x28; // Spec. Instr. 37 | const IPTC_TRANSMISSION_REFERENCE = 0x67; // OriginalTransmissionReference 38 | const TAG_IPTC_BYLINE = 0x50; // Byline 39 | const TAG_IPTC_CITY = 0x5A; // City 40 | const TAG_IPTC_SUBLOCATION = 0x5C; // Sub Location 41 | const TAG_IPTC_PROVINCESTATE = 0x5F; // State 42 | const TAG_IPTC_COUNTRYNAME = 0x65; // Country 43 | const TAG_IPTC_HEADLINE = 0x69; // Headline 44 | const TAG_IPTC_COPYRIGHT = 0x74; // (C)Notice 45 | const TAG_IPTC_CAPTION = 0x78; // Caption ( ~ description) 46 | const TAG_IPTC_DATECREATED = 0x37; // Date Created 47 | const TAG_IPTC_TIMECREATED = 0x3C; // Time Created 48 | const TAG_IPTC_KEYWORDS = 0x19; // Keywords (Multiple - Max 64 bytes each) 49 | const TAG_IPTC_CREDIT = 0x6E; // Credit Line 50 | const TAG_IPTC_DOC_NOTES = 0xE6; // DocumentNotes 51 | 52 | // Decodes arrays carrying UTF-8 sequences into Unicode strings. 53 | // Filters out illegal bytes with values between 128 and 191, 54 | // but doesn't validate sequences. 55 | function utf8BytesToString(utf8data, offset, num) // NOTICE, this is different from equally named in binExif.js!!! 56 | { 57 | var s = ""; 58 | var c = c1 = c2 = 0; 59 | 60 | // Can we use String.fromCodePoint() instead !!?? 61 | for (var i = offset; i < offset + num; ) { 62 | c = utf8data[i]; 63 | if (c <= 127) { 64 | s += String.fromCharCode(c); 65 | i++; 66 | } else if ((c >= 192) && (c <= 223)) { 67 | c2 = utf8data[i + 1]; 68 | s += String.fromCharCode(((c & 31) << 6) | (c2 & 63)); 69 | i += 2; 70 | } else if ((c >= 224) && (c <= 239)) { 71 | c2 = utf8data[i + 1]; 72 | c3 = utf8data[i + 2]; 73 | s += String.fromCharCode(((c & 15) << 12) | ((c2 & 63) << 6) | (c3 & 63)); 74 | i += 3; 75 | } else if (c >= 240) { 76 | c2 = utf8data[i + 1]; 77 | c3 = utf8data[i + 2]; 78 | c4 = utf8data[i + 3]; 79 | s += String.fromCharCode(((c & 7) << 18) | ((c2 & 63) << 12) | ((c3 & 63) << 6) | (c4 & 63)); 80 | i += 4; 81 | } else { 82 | i++; 83 | } 84 | } 85 | 86 | return s; 87 | } 88 | 89 | /* Reads the actual IPTC/NAA tags. 90 | Overwrites information from EXIF tags for textual informations like 91 | By, Caption, Headline, Copyright. 92 | But doesn't overwrite those fields when already populated by IPTC4XMP. 93 | The tag CodedCharacterSet in record 1 is read and interpreted to detect 94 | if the string data in record 2 is supposed to be UTF-8 coded. For now 95 | we assume record 1 comes before 2 in the file. 96 | */ 97 | function readIptcDir(dataObj, data) { 98 | var pos = 0; 99 | var utf8Strings = false; 100 | 101 | // keep until we're through the whole date because 102 | // only then we have both values. 103 | var iptcDate; 104 | var iptcTime; 105 | var iptcKeywords = new Set(); 106 | 107 | // Don't read outside the array, take the 5 bytes into account 108 | // since they are mandatory for a proper entry. 109 | while (pos + 5 <= data.length) { 110 | var entryMarker = data[pos]; 111 | var entryRecord = data[pos + 1]; 112 | var tag = data[pos + 2]; 113 | // dataLen is really only the length of the data. 114 | // There are signs, that the highest bit of this int 115 | // indicates an extended tag. Be aware of this. 116 | var dataLen = fxifUtils.read16(data, pos + 3, false); 117 | if (entryMarker == 0x1C) { 118 | if (entryRecord == 0x01) { 119 | // Only use tags with length > 0, tags without actual data are common. 120 | if (dataLen > 0) { 121 | if (pos + 5 + dataLen > data.length) { // Don't read outside the array. 122 | let read = pos + 5 + dataLen; 123 | alert("Read outside of array, read to: " + read + ", array length: " + data.length); // todo: Shouldn't report error with an alert (But have never seen it) 124 | break; 125 | } 126 | if (tag == TAG_IPTC_CODEDCHARSET) { 127 | var val = fxifUtils.bytesToString(data, pos + 5, dataLen, false, 1); 128 | // ESC %G 129 | if (val == UTF8_INDICATOR) { 130 | utf8Strings = true; 131 | } 132 | } 133 | } 134 | } else 135 | if (entryRecord == 0x02) { 136 | // Only use tags with length > 0, tags without actual data are common. 137 | if (dataLen > 0) { 138 | if (pos + 5 + dataLen > data.length) { // Don't read outside the array. 139 | let read = pos + 5 + dataLen; 140 | alert("Read outside of array, read to: " + read + ", array length: " + data.length); // todo: Shouldn't report error with an alert (But have never seen it) 141 | break; 142 | } 143 | let val = utf8Strings ? utf8BytesToString(data, pos + 5, dataLen) : fxifUtils.bytesToString(data, pos + 5, dataLen, false, 1); 144 | context.info("binIptc tag=" + tag + " (" + tag.toString(16) + "), value=" + val); 145 | switch (tag) { 146 | case TAG_IPTC_DATECREATED: 147 | iptcDate = val; 148 | break; 149 | 150 | case TAG_IPTC_TIMECREATED: 151 | iptcTime = val; 152 | break; 153 | 154 | case TAG_IPTC_KEYWORDS: 155 | iptcKeywords.add(val); 156 | break; 157 | 158 | case TAG_IPTC_DOC_NOTES: 159 | dataObj.DocumentNotes = val; // Might be used similar to CreatorContactInfo in XMP - sometimes... 160 | break; 161 | 162 | case TAG_IPTC_BYLINE: 163 | if (!dataObj.Creator || !fxifUtils.xmpDone) 164 | dataObj.Creator = val; 165 | break; 166 | 167 | case TAG_IPTC_CREDIT: 168 | if (!dataObj.Creditline || !fxifUtils.xmpDone) 169 | dataObj.Creditline = val; 170 | break; 171 | 172 | case TAG_IPTC_CITY: 173 | if (!dataObj.City || !fxifUtils.xmpDone) 174 | dataObj.City = val; 175 | break; 176 | 177 | case TAG_IPTC_SUBLOCATION: 178 | if (!dataObj.Sublocation || !fxifUtils.xmpDone) 179 | dataObj.Sublocation = val; 180 | break; 181 | 182 | case TAG_IPTC_PROVINCESTATE: 183 | if (!dataObj.ProvinceState || !fxifUtils.xmpDone) 184 | dataObj.ProvinceState = val; 185 | break; 186 | 187 | case TAG_IPTC_COUNTRYNAME: 188 | if (!dataObj.CountryName || !fxifUtils.xmpDone) 189 | dataObj.CountryName = val; 190 | break; 191 | 192 | case TAG_IPTC_HEADLINE: // title 193 | if (!dataObj.Headline || !fxifUtils.xmpDone) { 194 | dataObj.Headline = val; 195 | if (dataObj.Headline === dataObj.ObjectName) { 196 | delete dataObj.ObjectName; 197 | } 198 | } 199 | break; 200 | 201 | case TAG_IPTC_CAPTION: // ~ description 202 | if (!dataObj.Caption || !fxifUtils.xmpDone) { 203 | dataObj.Caption = val; 204 | if (dataObj.Caption === dataObj.ObjectName) { 205 | delete dataObj.ObjectName 206 | } 207 | } 208 | break; 209 | 210 | case TAG_IPTC_OBJECT_NAME: 211 | if ((!dataObj.Object || !fxifUtils.xmpDone) && val !== dataObj.Headline && val !== dataObj.Caption) 212 | dataObj.ObjectName = val; 213 | break; 214 | 215 | case TAG_IPTC_COPYRIGHT: 216 | if (!dataObj.Copyright || !fxifUtils.xmpDone) 217 | dataObj.Copyright = val; 218 | break; 219 | 220 | case TAG_IPTC_INSTRUCTIONS: 221 | dataObj.Instructions = val; 222 | break; 223 | 224 | case IPTC_TRANSMISSION_REFERENCE: 225 | dataObj.TransmissionRef = val; 226 | break; 227 | 228 | default: 229 | context.info("binIptc UNHANDLED TAG: tag=" + tag + " (" + tag.toString(16) + "), value=" + val); 230 | } 231 | } 232 | } else { 233 | context.info("binIptc UNHANDLED TAG: tag=" + tag + " (" + tag.toString(16) + "), dataLen=" + dataLen); 234 | } 235 | } else { 236 | context.info("binIptc Wrong entryMarker UNHANDLED TAG: tag=" + tag + " (" + tag.toString(16) + ")"); 237 | break; 238 | } 239 | 240 | pos += 5 + dataLen; 241 | } 242 | 243 | // only overwrite existing date if XMP data not already parsed 244 | if ((!dataObj.Date || !fxifUtils.xmpDone) && (iptcDate || iptcTime)) { 245 | // if IPTC only contains either date or time, only use it if there’s 246 | // no date already set 247 | if ((iptcDate && iptcTime) || !dataObj.Date && (iptcDate && !iptcTime || !iptcDate && iptcTime)) { 248 | var date; 249 | var matches; 250 | if (iptcDate) { 251 | matches = iptcDate.match(/^(\d{4})(\d{2})(\d{2})$/); 252 | if (matches) 253 | date = matches[1] + '-' + matches[2] + '-' + matches[3]; 254 | } 255 | if (iptcTime) { 256 | matches = iptcTime.match(/^(\d{2})(\d{2})(\d{2})([+-]\d{4})?$/); 257 | if (matches) { 258 | if (date) 259 | date += ' '; 260 | date += matches[1] + ':' + matches[2] + ':' + matches[3]; 261 | if (matches[4]) 262 | date += ' ' + matches[4]; 263 | else 264 | date += ' ' + stringBundle.getString("noTZ"); 265 | } 266 | } 267 | 268 | dataObj.Date = date; 269 | } 270 | } 271 | if (iptcKeywords.size && (!dataObj.Keywords || !fxifUtils.xmpDone || dataObj.Keywords.size < iptcKeywords.size)) { 272 | dataObj.Keywords = iptcKeywords; 273 | } 274 | } 275 | 276 | /* Looks for 8BIM markers in this image resources block. 277 | The format is defined by Adobe and stems from its PSD 278 | format. 279 | */ 280 | this.readPsSection = function (dataObj, psData) { 281 | var pointer = 0; 282 | 283 | var segmentMarker = fxifUtils.read32(psData, pointer, false); 284 | pointer += 4; 285 | while (segmentMarker == BIM_MARKER && 286 | pointer < psData.length) { 287 | var segmentType = fxifUtils.read16(psData, pointer, false); 288 | pointer += 2; 289 | // Step over 8BIM header. 290 | // It's an even length pascal string, i.e. one byte length information 291 | // plus string. The whole thing is padded to have an even length. 292 | var headerLen = psData[pointer]; 293 | headerLen = 1 + headerLen + ((headerLen + 1) % 2); 294 | pointer += headerLen; 295 | 296 | var segmentLen = 0; 297 | if (pointer + 4 <= psData.length) { 298 | // read dir length excluding length field 299 | segmentLen = fxifUtils.read32(psData, pointer, false); 300 | pointer += 4; 301 | } 302 | 303 | // IPTC-NAA record as IIM 304 | if (segmentType == 0x0404 && segmentLen > 0) { 305 | // Check if the next bytes are what we expect. 306 | // I’ve seen files where the segment length field is just missing 307 | // and so we’re bytes to far. 308 | if (pointer + 2 <= psData.length) { 309 | var entryMarker = psData[pointer]; 310 | var entryRecord = psData[pointer + 1]; 311 | if (entryMarker != 0x1C || entryRecord >= 0x0F) { 312 | // Go back 4 bytes since we can’t be sure this header is ok. 313 | pointer -= 4; 314 | 315 | // Something’s wrong. Try to recover by searching 316 | // the last bytes for the expect markers. 317 | var i = 0; 318 | while (i < 4) { // find first tag 319 | if (psData[pointer + i] == 0x1C && psData[pointer + i + 1] < 0x0F) 320 | break; 321 | else 322 | i++; 323 | } 324 | if (i < 4) // found 325 | { 326 | // calculate segmentLen since that’s the field missing 327 | segmentLen = psData.length - (4 + 2 + headerLen + i); 328 | pointer += i; 329 | } else 330 | throw "No entry marker found."; 331 | } 332 | 333 | readIptcDir(dataObj, psData.slice(pointer, pointer + segmentLen)); 334 | break; 335 | } 336 | } 337 | 338 | // Dir data, variable length padded to even length. 339 | pointer += segmentLen + (segmentLen % 2); 340 | segmentMarker = fxifUtils.read32(psData, pointer, false); 341 | pointer += 4; 342 | } 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /WebExtension/boarding/boarding.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #fff; 3 | color: #242424; 4 | font-family: Verdana, Arial, Helvetica, sans-serif; 5 | font-size: 16px; 6 | line-height: 1.5; 7 | } 8 | #all { 9 | width: 720px; 10 | margin: 4em auto; 11 | } 12 | img#logo { 13 | display: block; 14 | float: right; 15 | width: 128px; 16 | height: 128px; 17 | margin: 0 0 1em 1em; 18 | padding: 0; 19 | } 20 | .secondOnboard { 21 | display: none; 22 | background-color: rgb(255, 240, 110); 23 | padding: .1em 1em; 24 | margin: .5em 0; 25 | border-radius: 1em; 26 | } 27 | .secondOnboard a, .secondOnboard a:visited { 28 | color: #242424; 29 | } 30 | img.settings { 31 | display: inline-block; 32 | width: 24px; 33 | height: 24px; 34 | border: none; 35 | padding: 0; 36 | margin: 0 0; 37 | opacity: 0.2; 38 | cursor: pointer; 39 | vertical-align: bottom; 40 | } 41 | .introlink { 42 | font-style: italic; 43 | text-align: center; 44 | margin-bottom: 2em; 45 | } 46 | .circlebadge { 47 | display: inline-block; 48 | border-radius: 50%; 49 | margin: 0; 50 | padding: 0; 51 | vertical-align: middle; 52 | height: calc(1lh - 2px); 53 | } 54 | table { 55 | border: none; 56 | } 57 | table td, table th { 58 | text-align: left; 59 | padding: 0 1em 0 .5em; 60 | } 61 | .new::before {content: url(../icons/new27x14.png);margin:0 6px 0 2px;vertical-align:bottom} 62 | @media (prefers-color-scheme: dark) { 63 | body { 64 | background-color: rgb(43, 43, 43); 65 | color: rgb(220, 220, 220); 66 | scrollbar-color: rgb(43, 43, 43) black; 67 | } 68 | .secondOnboard { 69 | background-color: rgb(210, 110, 30); 70 | } 71 | .secondOnboard a, .secondOnboard a:visited { 72 | color: rgb(249, 249, 249); 73 | } 74 | a, a:hover, a:active, a:visited { 75 | color: rgb(69, 161, 255); 76 | } 77 | img.settings { 78 | filter: invert(100%); 79 | } 80 | h1, h2, h3, strong { 81 | color: rgb(255, 255, 255); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /WebExtension/boarding/boarding.js: -------------------------------------------------------------------------------- 1 | globalThis.browser ??= chrome; 2 | 3 | function init() { 4 | 5 | const initialOnboard = (new URL(window.location.href)).searchParams.get('initialOnboard'); 6 | const currentVersion = versionnumber.validOr(versionnumber.current(), ''); 7 | const previousVersion = versionnumber.validOr((new URL(window.location.href)).searchParams.get('previousVersion'), ''); 8 | 9 | document.querySelectorAll('.verstr').forEach((elm) => { 10 | elm.textContent = currentVersion; 11 | }); 12 | document.querySelectorAll('.settings').forEach((elm) => { 13 | elm.addEventListener('click', (ev) => { 14 | ev.preventDefault(); 15 | browser.runtime.openOptionsPage(); 16 | }) 17 | }); 18 | let vboarding = currentVersion; 19 | if (previousVersion) vboarding += (',' + previousVersion); 20 | document.querySelectorAll('.introlink a').forEach((elm) => { 21 | const url = new URL(elm.href); 22 | if (context.isFirefox() && (context.firefoxExtId() !== browser.runtime.id) && !browser.runtime.id.endsWith('@temporary-addon')) { 23 | url.searchParams.set('extid', browser.runtime.id); 24 | } 25 | url.searchParams.set(elm.dataset.context, vboarding); 26 | elm.href = url.href; 27 | }); 28 | 29 | if (document.querySelector('body.onboard')) { 30 | // onboarding only... 31 | if (initialOnboard) { 32 | context.setOption('initialOnboard', initialOnboard); 33 | if (initialOnboard === '2') { 34 | document.querySelector('.secondOnboard').style.display = 'revert'; 35 | } 36 | } 37 | } else { 38 | // upboarding only... 39 | if (previousVersion) { 40 | // ... 41 | } 42 | } 43 | 44 | } 45 | 46 | window.addEventListener('DOMContentLoaded', init); 47 | -------------------------------------------------------------------------------- /WebExtension/boarding/onboard.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Welcome to latest version of xIFr 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 |
15 |

Note, you are shown this "onboarding-page" a second time after your initial installation of xIFr, because 16 | there's a Firefox bug that might have 17 | closed this page the first time, just as you where about to read it.

18 |
19 | 20 | 21 | 22 | 23 |

Congratulations!

24 | 25 |

xIFr version has been installed.

26 | 27 |

With this tool you can see a lot of the metadata that can be embedded in JPEG files. Various data of types EXIF, 28 | IPTC and XMP are supported.

29 | 30 |

To see (if there are) any recognized metadata in an image, you can usually just right-click the image and choose 31 | "View EXIF data" in your browser's contextmenu. But to get the best of xIFr, I suggest you to read the introduction:

32 | 33 | 34 | 35 |

Also, make sure to make a visit to xIFr's Options Page 36 | where you can adjust some aspects of how xIFr looks and behaves. Look for the wheel 37 | in the xIFr window to access the options. 38 | My personal favorite settings are "Dark Theme" and the "Snap right" popup positioning.

39 | 40 |

By the way, be aware of copies on xIFr made available for install under other names. Sometimes made for spam 41 | (or worse?) purposes. Sometimes maybe more "innocent", but not offering anything on top of what xIFr offers, 42 | and typically with all credits to xIFr and original developers removed.

43 | 44 |

I hope you'll find xIFr useful and enjoy using it...

45 | 46 |

/Stig

47 | 48 |
49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /WebExtension/boarding/upboard.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Welcome to latest version of xIFr 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 |

xIFr has been updated!

17 | 18 |

xIFr, your EXIF-viewer, has been updated to version .

19 | 20 |

If you haven't already, I suggest you to take quick look at the introduction. You might learn a thing or two 21 | about xIFr that you didn't already know:

22 | 23 | 24 | 25 |

Also, make sure to make a visit to xIFr's Options Page 26 | where you can adjust some aspects of how xIFr looks and behaves. Look for the wheel 27 | in xIFr window to access the options. 28 | My personal favorite settings are "Dark Theme" and the "Snap right" popup positioning.

29 | 30 |

By the way, be aware of copies on xIFr made available for install under other names. Sometimes made for spam 31 | (or worse?) purposes. Sometimes maybe more "innocent", but not offering anything on top of what xIFr offers, 32 | and typically with all credits to xIFr and original developers removed.

33 | 34 |

I hope you are happy with and enjoy using xIFr. If you have questions or comments, there are 35 | various ways you can provide feedback.

36 | 37 |

/Stig

38 | 39 |
40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /WebExtension/context.js: -------------------------------------------------------------------------------- 1 | globalThis.context = globalThis.context || (function Context() { 2 | 3 | globalThis.browser ??= chrome; 4 | 5 | // Console: 6 | const ERROR = true; 7 | const WARN = true; 8 | const DEBUG = false; // false 9 | const INFO = false; // false 10 | const LOG = false; // false 11 | function log(...arg) { 12 | if (console && LOG) console.log(...arg); 13 | } 14 | function info(...arg) { 15 | if (console && INFO) console.info(...arg); 16 | } 17 | function debug(...arg) { 18 | if (console && DEBUG) console.debug(...arg); 19 | } 20 | function warn(...arg) { 21 | if (console && WARN) console.warn(...arg); 22 | } 23 | function error(...arg) { 24 | if (console && ERROR) console.error(...arg); 25 | } 26 | 27 | // Options: 28 | let defaults = { 29 | dispMode: "defaultMode", 30 | popupPos: "defaultPos", 31 | deepSearchBiggerLimit: 175 * 175, // 30625 32 | mlinkOSM: true, 33 | mlinkGoogle: true, 34 | mlinkBing: true, 35 | mlinkMapQuest: false, 36 | mlinkHere: false, 37 | mlinkFlickr: true, 38 | mlinkGeoHack: false, 39 | devDisableDeepSearch: false, 40 | devClickThumbnailBeta: false, 41 | devFetchMode: "devAutoFetch", 42 | initialOnboard: 0 43 | }; 44 | function setOptions(o) { 45 | return browser.storage.local.set(o); 46 | } 47 | function setOption(prop, value) { 48 | const o = {}; 49 | o[prop] = value; 50 | return setOptions(o); 51 | } 52 | function getOptions() { 53 | function onError(error) { 54 | console.warning(`xIFr: getOptions() error: ${error}`); 55 | return defaults; 56 | } 57 | function setCurrentChoice(result) { 58 | // Merge default with loaded values: 59 | return Object.assign(defaults, result); // updates defaults and returns it 60 | } 61 | return browser.storage.local.get().then(setCurrentChoice, onError); 62 | } 63 | 64 | // Misc: 65 | function supportsDeepSearch() { 66 | return !!(typeof browser !== 'undefined' && browser?.menus?.getTargetElement); // Well, might not be enough. But for the time being this check should tell. In practice Firefox 63+ supports, Chrome does not... 67 | } 68 | function prefersDark(dispMode) { 69 | return dispMode === "darkMode" || window?.matchMedia('(prefers-color-scheme: dark)')?.matches; 70 | } 71 | function isFirefox() { 72 | return !!((typeof browser !== 'undefined') && browser?.runtime?.getURL("./")?.startsWith("moz-extension://")); 73 | } 74 | function firefoxExtId() { 75 | return '{5e71bed2-2b15-40b8-a15b-ba89563aaf73}'; 76 | } 77 | function isChromium() { // Besides Chrome, this also includes Edge & Opera. And likely most/all other Chromium based browsers too(?) 78 | return !!((typeof browser !== 'undefined') && browser?.runtime?.getURL("./")?.startsWith("chrome-extension://")); 79 | } 80 | 81 | // API: 82 | return { 83 | LOG: LOG, 84 | INFO: INFO, 85 | DEBUG: DEBUG, 86 | ERROR: ERROR, 87 | log: log, 88 | info: info, 89 | debug: debug, 90 | warn: warn, 91 | error: error, 92 | setOptions: setOptions, 93 | setOption: setOption, 94 | getOptions: getOptions, 95 | isFirefox: isFirefox, 96 | firefoxExtId : firefoxExtId, 97 | isChromium: isChromium, 98 | supportsDeepSearch: supportsDeepSearch, 99 | prefersDark: prefersDark 100 | }; 101 | 102 | })(); 103 | 104 | globalThis.versionnumber = globalThis.versionnumber || (function Versionnumber() { // major.minor.revision 105 | function current() { 106 | if (browser?.runtime) { // webextension versionnumber 107 | return browser.runtime.getManifest().version; 108 | } 109 | // undefined 110 | } 111 | function compare(v1, v2) { 112 | if (typeof v2 === 'undefined') { 113 | v2 = v1; 114 | v1 = current(); 115 | } 116 | const v1parts = v1.split('.'); 117 | const v2parts = v2.split('.'); 118 | while (v1parts.length < v2parts.length) v1parts.push(0); 119 | while (v2parts.length < v1parts.length) v2parts.push(0); 120 | for (let i = 0; i < v1parts.length; ++i) { 121 | if (parseInt(v1parts[i], 10) > parseInt(v2parts[i], 10)) { 122 | return 1; 123 | } else if (parseInt(v1parts[i], 10) < parseInt(v2parts[i], 10)) { 124 | return -1; 125 | } 126 | } 127 | return 0; 128 | } 129 | function parts(v, n) { 130 | if (typeof n === 'undefined') { 131 | n = v; 132 | v = current(); 133 | } 134 | const vparts = v.split('.', n); 135 | vparts.map(function (item) { 136 | return String(parseInt(item.trim(), 10)) 137 | }); 138 | while (vparts.length < n) vparts.push('0'); 139 | return vparts.join('.'); 140 | } 141 | function major(v) { 142 | if (typeof v === 'undefined') { 143 | v = current(); 144 | } 145 | return parts(v, 1); 146 | } 147 | function minor(v) { 148 | if (typeof v === 'undefined') { 149 | v = current(); 150 | } 151 | return parts(v, 2); 152 | } 153 | function revision(v) { 154 | if (typeof v === 'undefined') { 155 | v = current(); 156 | } 157 | return parts(v, 3); 158 | } 159 | function validate(v) { 160 | if (typeof v === 'string' || v instanceof String) { 161 | const vparts = v.split('.'); 162 | if (vparts.length >= 1 && vparts.length <= 3) { 163 | for (const part of vparts) { 164 | const parsed = parseInt(part, 10); 165 | if (isNaN(parsed)) return false; 166 | if (part !== String(parsed)) return false; // This doesn't allow leading 0s like in yyyy.mm.dd versionnumbers (ok?) 167 | } 168 | } 169 | return true; 170 | } 171 | return false; 172 | } 173 | function validOr(v, alt) { 174 | if (validate(v)) { 175 | return v; 176 | } else { 177 | return alt; 178 | } 179 | } 180 | 181 | // API: 182 | return { 183 | current: current, 184 | compare: compare, 185 | major: major, 186 | minor: minor, 187 | revision: revision, 188 | validate: validate, 189 | validOr: validOr 190 | }; 191 | 192 | })(); 193 | -------------------------------------------------------------------------------- /WebExtension/fxifUtils.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | * 5 | * This Source Code Form is "Incompatible With Secondary Licenses", as 6 | * defined by the Mozilla Public License, v. 2.0. 7 | */ 8 | 9 | "use strict"; 10 | 11 | /* 12 | * Some utility functions for FxIF/xIFr. 13 | */ 14 | 15 | globalThis.browser ??= chrome; 16 | 17 | function fxifUtilsClass() { 18 | // let prefInstance = null; 19 | 20 | this.exifDone = false; 21 | this.iptcDone = false; 22 | this.xmpDone = false; 23 | 24 | this.read16 = function (data, offset, swapbytes) { 25 | if (!swapbytes) 26 | return (data[offset] << 8) | data[offset + 1]; 27 | 28 | return data[offset] | (data[offset + 1] << 8); 29 | }; 30 | 31 | this.read32 = function (data, offset, swapbytes) { 32 | if (!swapbytes) 33 | return (data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3]; 34 | 35 | return data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24); 36 | }; 37 | 38 | /* charWidth should normally be 1 and this function thus reads 39 | * the bytes one by one. But reading Unicode needs reading 40 | * 16 Bit values. 41 | * Stops at the first null byte. 42 | */ 43 | this.bytesToString = function (data, offset, num, swapbytes, charWidth) { 44 | let s = ""; 45 | 46 | if (charWidth == 1) { 47 | for (let i = offset; i < offset + num; i++) { 48 | const charval = data[i]; 49 | if (charval == 0) 50 | break; 51 | 52 | s += String.fromCharCode(charval); 53 | } 54 | } else { 55 | for (let i = offset; i < offset + num; i += 2) { 56 | const charval = this.read16(data, i, swapbytes); 57 | if (charval == 0) 58 | break; 59 | 60 | s += String.fromCharCode(charval); 61 | } 62 | } 63 | 64 | return s; 65 | }; 66 | 67 | /* Doesn’t stop at null bytes. */ 68 | this.bytesToStringWithNull = function (data, offset, num) { 69 | let s = ""; 70 | 71 | for (let i = offset; i < offset + num; i++) 72 | s += String.fromCharCode(data[i]); 73 | 74 | return s; 75 | }; 76 | 77 | this.dd2dms = function (gpsval) { 78 | // a bit unconventional calculation to get input edge cases 79 | // like 0x31 / 0x01, 0x0a / 0x01, 0x3c / 0x01 to 49°11'0" instead of 49°10'60" 80 | const gpsDeg = Math.floor(gpsval / 3600); 81 | gpsval -= gpsDeg * 3600.0; 82 | const gpsMin = Math.floor(gpsval / 60); 83 | // round to 2 digits after the comma 84 | const gpsSec = (gpsval - gpsMin * 60.0).toFixed(2); 85 | return [gpsDeg, gpsMin, gpsSec]; 86 | }; 87 | 88 | this.dd2dm = function (gpsval) { 89 | // a bit unconventional calculation to get input edge cases 90 | // like 0x31 / 0x01, 0x0a / 0x01, 0x3c / 0x01 to 49°11'0" instead of 49°10'60" 91 | const gpsDeg = Math.floor(gpsval / 3600); 92 | gpsval -= gpsDeg * 3600.0; 93 | // round to 2 digits after the comma 94 | const gpsMin = (gpsval / 60).toFixed(4); 95 | return [gpsDeg, gpsMin]; 96 | }; 97 | 98 | this.dd2dd = function (gpsval) { 99 | // round to 6 digits after the comma 100 | const gpsArr = []; 101 | gpsArr.push((gpsval / 3600).toFixed(6)); 102 | return gpsArr; 103 | }; 104 | 105 | this.getPreferences = function () { 106 | // console.log("getPreferences"); 107 | }; 108 | 109 | // Retrieves the language which is likely to be the users favourite one. 110 | // Currently we end up using only the first language code. 111 | this.getLang = function () { 112 | return browser.i18n.getUILanguage(); 113 | } 114 | } 115 | 116 | globalThis.fxifUtils = new fxifUtilsClass(); 117 | -------------------------------------------------------------------------------- /WebExtension/icons/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StigNygaard/xIFr/6dc529ab81d036869a4b3125bf46347b4de9a1b6/WebExtension/icons/background.png -------------------------------------------------------------------------------- /WebExtension/icons/check-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StigNygaard/xIFr/6dc529ab81d036869a4b3125bf46347b4de9a1b6/WebExtension/icons/check-64.png -------------------------------------------------------------------------------- /WebExtension/icons/copy-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StigNygaard/xIFr/6dc529ab81d036869a4b3125bf46347b4de9a1b6/WebExtension/icons/copy-48.png -------------------------------------------------------------------------------- /WebExtension/icons/copytext-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StigNygaard/xIFr/6dc529ab81d036869a4b3125bf46347b4de9a1b6/WebExtension/icons/copytext-48.png -------------------------------------------------------------------------------- /WebExtension/icons/error-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StigNygaard/xIFr/6dc529ab81d036869a4b3125bf46347b4de9a1b6/WebExtension/icons/error-64.png -------------------------------------------------------------------------------- /WebExtension/icons/error-7-32w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StigNygaard/xIFr/6dc529ab81d036869a4b3125bf46347b4de9a1b6/WebExtension/icons/error-7-32w.png -------------------------------------------------------------------------------- /WebExtension/icons/flickr-dots-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StigNygaard/xIFr/6dc529ab81d036869a4b3125bf46347b4de9a1b6/WebExtension/icons/flickr-dots-128.png -------------------------------------------------------------------------------- /WebExtension/icons/globe-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StigNygaard/xIFr/6dc529ab81d036869a4b3125bf46347b4de9a1b6/WebExtension/icons/globe-32.png -------------------------------------------------------------------------------- /WebExtension/icons/info-32w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StigNygaard/xIFr/6dc529ab81d036869a4b3125bf46347b4de9a1b6/WebExtension/icons/info-32w.png -------------------------------------------------------------------------------- /WebExtension/icons/info-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StigNygaard/xIFr/6dc529ab81d036869a4b3125bf46347b4de9a1b6/WebExtension/icons/info-64.png -------------------------------------------------------------------------------- /WebExtension/icons/new27x14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StigNygaard/xIFr/6dc529ab81d036869a4b3125bf46347b4de9a1b6/WebExtension/icons/new27x14.png -------------------------------------------------------------------------------- /WebExtension/icons/settings-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StigNygaard/xIFr/6dc529ab81d036869a4b3125bf46347b4de9a1b6/WebExtension/icons/settings-48.png -------------------------------------------------------------------------------- /WebExtension/icons/warn-32w.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StigNygaard/xIFr/6dc529ab81d036869a4b3125bf46347b4de9a1b6/WebExtension/icons/warn-32w.png -------------------------------------------------------------------------------- /WebExtension/icons/warn-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StigNygaard/xIFr/6dc529ab81d036869a4b3125bf46347b4de9a1b6/WebExtension/icons/warn-64.png -------------------------------------------------------------------------------- /WebExtension/icons/wheel-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StigNygaard/xIFr/6dc529ab81d036869a4b3125bf46347b4de9a1b6/WebExtension/icons/wheel-48.png -------------------------------------------------------------------------------- /WebExtension/icons/xIFr-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StigNygaard/xIFr/6dc529ab81d036869a4b3125bf46347b4de9a1b6/WebExtension/icons/xIFr-128.png -------------------------------------------------------------------------------- /WebExtension/icons/xIFr-24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StigNygaard/xIFr/6dc529ab81d036869a4b3125bf46347b4de9a1b6/WebExtension/icons/xIFr-24.png -------------------------------------------------------------------------------- /WebExtension/icons/xIFr-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StigNygaard/xIFr/6dc529ab81d036869a4b3125bf46347b4de9a1b6/WebExtension/icons/xIFr-256.png -------------------------------------------------------------------------------- /WebExtension/icons/xIFr-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StigNygaard/xIFr/6dc529ab81d036869a4b3125bf46347b4de9a1b6/WebExtension/icons/xIFr-32.png -------------------------------------------------------------------------------- /WebExtension/icons/xIFr-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StigNygaard/xIFr/6dc529ab81d036869a4b3125bf46347b4de9a1b6/WebExtension/icons/xIFr-48.png -------------------------------------------------------------------------------- /WebExtension/icons/xIFr-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StigNygaard/xIFr/6dc529ab81d036869a4b3125bf46347b4de9a1b6/WebExtension/icons/xIFr-512.png -------------------------------------------------------------------------------- /WebExtension/icons/xIFr-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StigNygaard/xIFr/6dc529ab81d036869a4b3125bf46347b4de9a1b6/WebExtension/icons/xIFr-64.png -------------------------------------------------------------------------------- /WebExtension/icons/xIFr-96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StigNygaard/xIFr/6dc529ab81d036869a4b3125bf46347b4de9a1b6/WebExtension/icons/xIFr-96.png -------------------------------------------------------------------------------- /WebExtension/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "xIFr", 4 | "version": "3.1.1", 5 | "default_locale": "en", 6 | 7 | "description": "__MSG_extensionDescription__", 8 | "author": "Stig Nygaard", 9 | "homepage_url": "https://github.com/StigNygaard/xIFr", 10 | 11 | "icons": { 12 | "24": "icons/xIFr-24.png", 13 | "32": "icons/xIFr-32.png", 14 | "48": "icons/xIFr-48.png", 15 | "64": "icons/xIFr-64.png", 16 | "96": "icons/xIFr-96.png", 17 | "128": "icons/xIFr-128.png", 18 | "256": "icons/xIFr-256.png", 19 | "512": "icons/xIFr-512.png" 20 | }, 21 | 22 | "action" : { 23 | 24 | }, 25 | 26 | "browser_specific_settings": { 27 | "gecko": { 28 | "strict_min_version": "128.0", 29 | "id": "{5e71bed2-2b15-40b8-a15b-ba89563aaf73}" 30 | } 31 | }, 32 | "minimum_chrome_version": "121.0", 33 | "minimum_opera_version": "107.0", 34 | "minimum_edge_version": "121.0", 35 | 36 | "host_permissions": [ 37 | "" 38 | ], 39 | "permissions": [ 40 | "scripting", 41 | "contextMenus", 42 | "menus", 43 | "storage" 44 | ], 45 | "content_security_policy": { 46 | "extension_pages": "script-src 'self'; object-src 'none'" 47 | }, 48 | 49 | "background": { 50 | "scripts": ["backgroundscript.js"], 51 | "service_worker": "backgroundscript.js", 52 | "type": "module" 53 | }, 54 | 55 | "options_ui": { 56 | "page": "options/options.html", 57 | "open_in_tab": true 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /WebExtension/options/options.css: -------------------------------------------------------------------------------- 1 | 2 | /* Dark or Light mode: */ 3 | body.dark { 4 | background-color: #151515; 5 | color: rgb(220, 220, 220); 6 | } 7 | body.light { 8 | background-color: rgb(248, 248, 248); 9 | color: rgb(32, 32, 32); 10 | } 11 | body.dark form { 12 | background-color: rgb(43, 43, 43); 13 | color: rgb(220, 220, 220); 14 | } 15 | body.light form { 16 | background-color: rgb(255, 255, 255); 17 | color: rgb(32, 32, 32); 18 | } 19 | body.dark a, body.dark a:hover, body.dark a:active, body.dark a:visited { 20 | color: rgb(69, 161, 255); 21 | } 22 | body.dark input { 23 | background-color: rgb(43, 43, 43); 24 | color: white; 25 | border: 1px solid white; 26 | } 27 | body.light b.up { 28 | color: #900; 29 | } 30 | body.dark b.up { 31 | color: #F55; 32 | } 33 | body.light #deepSearchSettings b.up { 34 | color: #090; 35 | } 36 | body.dark #deepSearchSettings b.up { 37 | color: #5F5; 38 | } 39 | 40 | /* General layout and visibility: */ 41 | body { 42 | margin-bottom: 3em; 43 | line-height: 1.5; 44 | } 45 | #xIFroptionspage { 46 | margin: 1em auto 1em auto; 47 | max-width: 980px; 48 | font-family: Verdana, Arial, Helvetica, sans-serif; 49 | } 50 | #version { 51 | float: right; 52 | } 53 | #logo { 54 | width: 128px; 55 | height: auto; 56 | margin: -10px 0 -30px 0; 57 | } 58 | #xIFroptionspage > p { 59 | margin-left: 1em; 60 | } 61 | .note { 62 | font-style: italic; 63 | } 64 | .up { 65 | font-style: normal; 66 | position: relative; 67 | top: -0.2em; 68 | margin: 0 .1em; 69 | } 70 | .ffo { 71 | display: none; 72 | } 73 | .isFirefox .ffo { 74 | display: revert; 75 | } 76 | .hideInFirefox { 77 | display: none; 78 | } 79 | .notFirefox .hideInFirefox { 80 | display: revert; 81 | } 82 | #hostPermissionsSettings img.status { 83 | display: block; 84 | float: right; 85 | width: 64px; 86 | margin: 0 1rem; 87 | } 88 | #hostPermissionsSettings.fullHostPermissions .fullHostPermissions, 89 | #hostPermissionsSettings.restrictedHostPermissions .restrictedHostPermissions, 90 | #hostPermissionsSettings.missingHostPermissions .missingHostPermissions { 91 | display: revert; 92 | } 93 | #hostPermissionsSettings.fullHostPermissions .restrictedHostPermissions, 94 | #hostPermissionsSettings.fullHostPermissions .missingHostPermissions, 95 | #hostPermissionsSettings.fullHostPermissions .notFullHostPermissions, 96 | #hostPermissionsSettings.restrictedHostPermissions .fullHostPermissions, 97 | #hostPermissionsSettings.restrictedHostPermissions .missingHostPermissions, 98 | #hostPermissionsSettings.missingHostPermissions .fullHostPermissions, 99 | #hostPermissionsSettings.missingHostPermissions .restrictedHostPermissions { 100 | display: none; 101 | } 102 | #deepSearchSettings, #developerSettings { 103 | display: none; 104 | } 105 | .supportsDeepSearch #deepSearchSettings { 106 | display: revert; 107 | } 108 | .supportsDeepSearch #noDeepSearchSettings { 109 | display: none; 110 | } 111 | .developermode #developerSettings { 112 | display: revert; 113 | } 114 | #xIFroptions { 115 | padding: .4em; 116 | border-radius: .4em; 117 | box-shadow: rgba(12, 12, 13, 0.1) 0 1px 4px 0; 118 | } 119 | #xIFroptions fieldset { 120 | position: relative; 121 | margin: 0 0 .3em 0; 122 | padding-left: 1em; 123 | padding-right: 1em; 124 | } 125 | #xIFroptions input, #xIFroptions select, #xIFroptions textarea { 126 | font-family: inherit; 127 | font-size: 100%; 128 | } 129 | div.threeColumns > p:first-child { 130 | margin-top: 0; 131 | } 132 | legend + div.threeColumns { 133 | padding-top: 1em; 134 | } 135 | div.threeColumns { 136 | column-count: 3; 137 | column-fill: balance; 138 | margin-bottom: 1em; 139 | } 140 | .tagline { 141 | text-align: right; 142 | } 143 | .smaller { 144 | font-size: smaller; 145 | } 146 | -------------------------------------------------------------------------------- /WebExtension/options/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | xIFr Options 6 | 7 | 8 | 9 |
10 |

xIFr version

11 | 12 | 14 |
15 |
16 |  'Host permissions' check  17 |

18 | [OK] 19 | [!] 20 | [!!] 21 | The host-permissions required for full functionality are granted. 22 | Host-permissions are restricted by user. Restricted functionality. 23 | No host-permissions has been granted. xIFr needs access to data from 24 | websites to fully work (Though, it might still work on some sites and images). 25 |

26 |

27 | 28 |

29 |

30 | If wanted, granted permissions can always be revoked or restricted 31 | in browser's extension management pages. 32 |

33 |

34 | xIFr only processes data needed for the described functionality of the extension. 35 | See the Privacy Policy. 36 |

37 |
38 |
39 |  Display mode  40 |

41 | 45 | 46 |

47 |

48 | 52 | 53 |

54 |
55 |
56 |  Popup position  57 |
58 |

59 | 63 | 64 |

65 |

66 | 70 | 71 |

72 |

73 | 77 | 78 |

79 |

80 | 84 | 85 |

86 |

87 | 91 | 92 |

93 |

94 | 98 | 99 |

100 |

101 | 105 | 106 |

107 |

108 | 112 | 113 |

114 |

115 | 119 | 120 |

121 |

122 | 126 | 127 |

128 |

129 | 133 | 134 |

135 |
136 |

*In a multi-display setup, these options might not work as intended on all 137 | the connected displays.

138 |
139 |
140 |  Deep Search  141 |

Deep Search is the name given to the method that xIFr uses to find/guess which image you where thinking 142 | of when right-clicking on a webpage to open context-menu (No, it's not always as obvious as it may seem).

143 |

A special "force big" mode* of Deep Search makes it possible to ignore smaller images:

144 |

145 | 146 | 147 | "squarepixels" big (Equivalent to f.ex: px). 148 |

149 |

*You initiate the "force big" image search by using shift-click to select xIFr 150 | in browser's context menu. NOTE! Sorry, your currently used browser might not 151 | support initiating "force big" using shift-click.

152 |
153 |
154 |  Deep Search  155 |

Deep Search is the name given to the method that xIFr uses to find/guess which image you where thinking 156 | of when right-clicking on a webpage to open context-menu (No, it's not always as obvious as it may seem).

157 |

"Deep Search" is unfortunately not supported in your browser!, but xIFr falls 158 | back to a more basic image selection method that still works in many (most?) cases.

159 |
160 |
161 |  Map links  162 |

Besides the integrated Map tab available for geotagged photos, xIFr can offer links to see position using 163 | various map providers.

164 |
165 |

166 | 169 | 170 |

171 |

172 | 175 | 176 |

177 |

178 | 181 | 182 |

183 |

184 | 187 | 188 |

189 |

190 | 193 | 194 |

195 |

196 | 199 | 200 |

201 |

202 | 205 | 206 |

207 |
208 |
209 |
210 |  Developer settings and info  211 |

These - normally hidden - settings are primarily for developing and debugging purposes. You hide or unhide 212 | these settings by double-clicking the logo in top of the page.

213 |

214 |

215 |

216 |
217 |

218 | 221 | 222 |

223 |
224 |
225 |

226 | 229 | 230 |

231 |
232 |
233 |

234 | 238 | 239 |

240 |

241 | 245 | 246 |

247 |

248 | 252 | 253 |

254 |
255 |
256 |
257 | 258 |

xIFr is open source, the repository is on GitHub. 259 | xIFr is a "fork" of wxIF which again is a WebExtension fork/port of the 260 | even older "legacy type extension" FxIF. 261 | A lot has happened since, but without the foundation for EXIF parsing made by the authors of FxIF and wxIF, 262 | xIFr wouldn't be here today.

263 | 264 |

Love photos? Flickr user? Also try my Flickr Fixr! 265 | /Stig - rockland.dk, flickr.com/photos/stignygaard

266 | 267 |
268 | 269 | 270 | 271 | 272 | -------------------------------------------------------------------------------- /WebExtension/options/options.js: -------------------------------------------------------------------------------- 1 | globalThis.browser ??= chrome; 2 | 3 | function setDisplayMode(dispMode) { 4 | if (context.prefersDark(dispMode)) { 5 | document.body.classList.replace("light", "dark"); // If light, then swap with dark 6 | document.body.classList.add("dark"); // But also set dark if light wasn't set 7 | } else { 8 | document.body.classList.replace("dark", "light"); // If dark, then swap with light 9 | document.body.classList.add("light"); // But also set light if dark wasn't set 10 | } 11 | } 12 | 13 | function updateDeepSearchSize() { 14 | const inp = document.getElementById("deepSearchBiggerLimit"); 15 | const out = document.getElementById("deepSearchBiggerLimitEx"); 16 | out.textContent = ""; 17 | let d = parseInt(inp.value, 10); 18 | if (!Number.isNaN(d)) { 19 | d = Math.sqrt(d); 20 | if (!Number.isNaN(d)) { 21 | d = Math.floor(d); 22 | out.textContent = d + "x" + d; 23 | } 24 | } 25 | } 26 | 27 | function saveOptions(e) { 28 | e.preventDefault(); 29 | context.setOptions({ 30 | dispMode: document.forms[0].dispMode.value, 31 | popupPos: document.forms[0].popupPos.value, 32 | deepSearchBiggerLimit: document.querySelector("form#xIFroptions #deepSearchBiggerLimit").value, 33 | mlinkOSM: document.querySelector("form#xIFroptions #mlinkOSM").checked, 34 | mlinkGoogle: document.querySelector("form#xIFroptions #mlinkGoogle").checked, 35 | mlinkBing: document.querySelector("form#xIFroptions #mlinkBing").checked, 36 | mlinkMapQuest: document.querySelector("form#xIFroptions #mlinkMapQuest").checked, 37 | mlinkHere: document.querySelector("form#xIFroptions #mlinkHere").checked, 38 | mlinkFlickr: document.querySelector("form#xIFroptions #mlinkFlickr").checked, 39 | mlinkGeoHack: document.querySelector("form#xIFroptions #mlinkGeoHack").checked, 40 | devDisableDeepSearch: document.querySelector("form#xIFroptions #devDisableDeepSearch").checked, 41 | devClickThumbnailBeta: document.querySelector("form#xIFroptions #devClickThumbnailBeta").checked, 42 | devFetchMode: document.forms[0].devFetchMode.value 43 | // initialOnboard: 0 44 | }).then( 45 | () => { 46 | setDisplayMode(document.forms[0].dispMode.value); 47 | updateDeepSearchSize() 48 | }, (error) => { 49 | console.error('xIFr: Failed saving options: ' + error) 50 | } 51 | ); 52 | } 53 | 54 | function handlerInitOptionsForm(options) { 55 | setDisplayMode(options["dispMode"]); 56 | [...document.forms[0].elements].forEach((input) => { 57 | if (input.type === "radio" && options[input.name] === input.value) { 58 | input.checked = true; 59 | } else if (input.type === "checkbox" && options[input.value] !== undefined) { 60 | input.checked = options[input.value]; 61 | } else if (input.type === "text" && options[input.id] !== undefined) { 62 | input.value = options[input.id]; 63 | } 64 | }); 65 | updateDeepSearchSize(); 66 | document.getElementById("initialOnboard").textContent = options["initialOnboard"]; 67 | document.getElementById("logo").addEventListener("dblclick", function () { 68 | document.body.classList.toggle("developermode") 69 | }); 70 | // save on input event: 71 | document.querySelector("form#xIFroptions").addEventListener("input", saveOptions); 72 | } 73 | 74 | function updateAllowsPrivate(allows) { // Allowed to run in private/incognito-mode ? 75 | document.getElementById('allowsPrivate').textContent = allows; 76 | } 77 | 78 | function initializeOptionsPage() { 79 | 80 | // Host permissions... 81 | renderPermissions(); 82 | const bt = document.querySelector('#hostPermissionsSettings button'); 83 | if (bt) { 84 | bt.addEventListener('click', requestHostPermissions); 85 | } 86 | browser.permissions.onAdded.addListener(renderPermissions); 87 | browser.permissions.onRemoved.addListener(renderPermissions); 88 | 89 | // Layout/content... 90 | document.querySelector('div#xIFroptionspage #verstr').textContent = browser.runtime.getManifest().version; 91 | if (context.isFirefox()) { 92 | document.body.classList.add("isFirefox"); 93 | } else { 94 | document.body.classList.add("notFirefox"); 95 | } 96 | document.getElementById('supportsDeepSearch').textContent = context.supportsDeepSearch(); 97 | if (context.supportsDeepSearch()) { 98 | document.body.classList.add("supportsDeepSearch"); 99 | } 100 | document.querySelectorAll('.aboutlinks a').forEach((elm) => { 101 | const url = new URL(elm.href); 102 | if (context.isFirefox() && (context.firefoxExtId() !== browser.runtime.id) && !browser.runtime.id.endsWith('@temporary-addon')) { 103 | url.searchParams.set('extid', browser.runtime.id); 104 | } 105 | url.searchParams.set('version', browser.runtime.getManifest().version); 106 | elm.href = url.href; 107 | }); 108 | context.getOptions().then(handlerInitOptionsForm); 109 | browser.extension.isAllowedIncognitoAccess().then(updateAllowsPrivate) 110 | } 111 | 112 | function requestHostPermissions(ev) { 113 | ev.preventDefault(); 114 | const permissions = { 115 | origins: [""] 116 | }; 117 | browser.permissions.request(permissions); 118 | } 119 | 120 | async function renderPermissions() { 121 | const origins = ""; 122 | const currentPermissions = await browser.permissions.getAll(); 123 | const hasOrigins = currentPermissions.origins?.includes(origins); 124 | // console.log('JSON permissions: ' + JSON.stringify(currentPermissions)); 125 | // console.log('Has required origins: ' + hasOrigins); 126 | const bt = document?.querySelector('#hostPermissionsSettings button'); 127 | const hostSettings = document?.getElementById("hostPermissionsSettings"); 128 | if (hostSettings && bt) { 129 | if (hasOrigins) { 130 | hostSettings.className = 'fullHostPermissions'; 131 | bt.disabled = true; 132 | } else if (currentPermissions.origins?.length) { 133 | hostSettings.className = 'restrictedHostPermissions'; 134 | bt.disabled = false; 135 | } else { 136 | hostSettings.className = 'missingHostPermissions'; 137 | bt.disabled = false; 138 | } 139 | } 140 | } 141 | 142 | window.addEventListener("DOMContentLoaded", initializeOptionsPage); 143 | -------------------------------------------------------------------------------- /WebExtension/parseJpeg.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | * 5 | * This Source Code Form is "Incompatible With Secondary Licenses", as 6 | * defined by the Mozilla Public License, v. 2.0. 7 | */ 8 | 9 | function fxifClass() { 10 | const SOI_MARKER = 0xFFD8; // start of image 11 | const SOS_MARKER = 0xFFDA; // start of stream 12 | const EOI_MARKER = 0xFFD9; // end of image 13 | const APP1_MARKER = 0xFFE1; // start of binary EXIF data (or XMP?) 14 | const APP13_MARKER = 0xFFED; // start of IPTC-NAA data 15 | const COM_MARKER = 0xFFFE; // start of JFIF comment data 16 | 17 | const INTEL_BYTE_ORDER = 0x4949; 18 | 19 | this.gatherData = bis => { 20 | 21 | context.debug("gatherData(bis): Entering fxifClass.gatherData(bis)..."); 22 | 23 | const dataObj = {}; 24 | let swapbytes = false; 25 | let marker = bis.read16(); 26 | let len; 27 | 28 | context.debug("gatherData(bis): Initial bis.read16() done."); 29 | 30 | if (marker == SOI_MARKER) { 31 | marker = bis.read16(); 32 | context.debug("gatherData(bis): Second bis.read16() done (for marker==SOI_MARKER)."); 33 | // reading SOS marker indicates start of image stream 34 | while (marker != SOS_MARKER && (!fxifUtils.exifDone || !fxifUtils.iptcDone || !fxifUtils.xmpDone)) { 35 | // length includes the length bytes 36 | len = bis.read16() - 2; 37 | context.debug("gatherData(bis): Start iteration with len=" + len); 38 | 39 | if (marker == APP1_MARKER && len >= 6) { 40 | context.debug("gatherData(bis): Found marker==APP1_MARKER."); 41 | // for EXIF the first 6 bytes should be 'Exif\0\0' 42 | let header = bis.readBytes(6); 43 | // Is it EXIF? 44 | if (header == 'Exif\0\0') { 45 | context.debug("gatherData(bis): Found EXIF."); 46 | // 8 byte TIFF header 47 | // first two determine byte order 48 | const exifData = bis.readByteArray(len - 6); 49 | 50 | swapbytes = fxifUtils.read16(exifData, 0, false) == INTEL_BYTE_ORDER; 51 | 52 | // next two bytes are always 0x002A 53 | // offset to Image File Directory (includes the previous 8 bytes) 54 | const ifd_ofs = fxifUtils.read32(exifData, 4, swapbytes); 55 | const exifReader = new exifClass(); 56 | try { 57 | exifReader.readExifDir(dataObj, exifData, ifd_ofs, swapbytes); 58 | } catch (ex) { 59 | pushError(dataObj, "[EXIF]", ex); 60 | } 61 | 62 | context.debug("dataObj: \n" + JSON.stringify(dataObj)); 63 | 64 | fxifUtils.exifDone = true; 65 | context.info(" *** exifDone !!!"); 66 | } else { 67 | context.debug("gatherData(bis): Didn't find EXIF, maybe XMP?..."); 68 | if (len > 28) { 69 | // Maybe it's XMP. If it is, it starts with the XMP namespace URI 70 | // 'http://ns.adobe.com/xap/1.0/\0'. 71 | // see http://partners.adobe.com/public/developer/en/xmp/sdk/XMPspecification.pdf 72 | header += bis.readBytes(22); // 6 bytes read means 22 more to go 73 | if (header === 'http://ns.adobe.com/xap/1.0/') { 74 | // There is at least one programm which writes spaces behind the namespace URI. 75 | // Overread up to 5 bytes of such garbage until a '\0'. I deliberately don't read 76 | // until reaching len bytes. 77 | let a; 78 | let j = 0; 79 | do { 80 | a = bis.readBytes(1); 81 | } while (++j < 5 && a == ' '); 82 | if (a == '\0') { 83 | const xmpData = bis.readByteArray(len - (28 + j)); 84 | try { 85 | const xmpReader = new xmpClass(); 86 | xmpReader.parseXML(dataObj, xmpData); 87 | } catch (ex) { 88 | pushError(dataObj, "[XMP]", ex); 89 | } 90 | fxifUtils.xmpDone = true; 91 | context.info(" *** xmpDone !!!"); 92 | } else { 93 | try { 94 | bis.readBytes(len - (28 + j)); 95 | } catch (ex) { 96 | pushError(dataObj, "[ParseJpegHeader]", ex); 97 | // break; // ? 98 | } 99 | } 100 | } else { 101 | try { 102 | bis.readBytes(len - 28); 103 | } catch (ex) { 104 | pushError(dataObj, "[ParseJpegHeader]", ex); 105 | // break; // ? 106 | } 107 | } 108 | } else { 109 | try { 110 | bis.readBytes(len - 6); 111 | } catch (ex) { 112 | pushError(dataObj, "[ParseJpegHeader]", ex); 113 | // break; // ? 114 | } 115 | 116 | } 117 | } 118 | } else 119 | // Or is it IPTC-NAA record as IIM? 120 | if (marker == APP13_MARKER && len > 14) { 121 | context.debug("gatherData(bis): Found marker==APP13_MARKER."); 122 | // 6 bytes, 'Photoshop 3.0\0' 123 | const psString = bis.readBytes(14); 124 | const psData = bis.readByteArray(len - 14); 125 | if (psString === 'Photoshop 3.0\0') { 126 | const iptcReader = new iptcClass(stringBundle); 127 | try { 128 | iptcReader.readPsSection(dataObj, psData); 129 | } catch (ex) { 130 | pushError(dataObj, "[IPTC]", ex); 131 | } 132 | fxifUtils.iptcDone = true; 133 | context.info(" *** iptcDone !!!"); 134 | } 135 | } else 136 | // Or perhaps a JFIF comment? 137 | if (marker == COM_MARKER && len >= 1) { 138 | context.debug("gatherData(bis): Found marker==COM_MARKER."); 139 | dataObj.UserComment = fxifUtils.bytesToString(bis.readByteArray(len), 0, len, false, 1); 140 | } else if (len >= 1) { 141 | context.debug("gatherData(bis): Unknown marker=0x" + marker.toString(16).padStart(4, "0") + ". Discarding some data (read " + len + " bytes)..."); 142 | try { 143 | bis.readBytes(len); 144 | } catch (ex) { 145 | pushError(dataObj, "[ParseJpegHeader]", ex); 146 | // break; // ? 147 | } 148 | } else { 149 | pushError(dataObj, "[ParseJpegHeader]", "Unexpected value of len=" + len + "."); 150 | break; 151 | } 152 | 153 | marker = bis.read16(); 154 | } 155 | } else { 156 | context.debug("gatherData(bis): First marker found wasn't the expected SOI_MARKER"); 157 | } 158 | 159 | if (dataObj.Keywords) { // For some reason Chrome can't handle this sent as a Set via message API? (Would probably also work sent as an Array?) 160 | dataObj.Keywords = (Array.from(dataObj.Keywords)).join("; "); 161 | } 162 | if (dataObj.Depicted) { // For some reason Chrome can't handle this sent as a Set via message API? (Would probably also work sent as an Array?) 163 | dataObj.Depicted = (Array.from(dataObj.Depicted)).join("; "); 164 | } 165 | 166 | context.debug("gatherData(bis): returning dataObj..."); 167 | return dataObj; 168 | }; 169 | 170 | function pushError(dataObj, type, message) { 171 | if (dataObj.error) 172 | dataObj.error.push(stringBundle.getFormattedString("generalError", type) + ' ' + message); 173 | else 174 | dataObj.error = [stringBundle.getFormattedString("generalError", type) + ' ' + message]; 175 | } 176 | } 177 | 178 | globalThis.fxifObj = new fxifClass(); 179 | -------------------------------------------------------------------------------- /WebExtension/popup/popup.css: -------------------------------------------------------------------------------- 1 | 2 | /* Dark or Light mode: */ 3 | body.dark { 4 | background-color: rgb(43, 43, 43); 5 | color: rgb(249, 249, 249); 6 | scrollbar-color: rgb(43, 43, 43) black; 7 | } 8 | body.light { 9 | background-color: #fff; 10 | color: #242424; 11 | } 12 | body.dark img#settings, body.dark img#cpClipboard{ 13 | filter: invert(100%) ; 14 | } 15 | body.dark #tabs { 16 | background-color: black; 17 | color: rgba(249, 249, 249, 0.8); 18 | } 19 | body.light #tabs { 20 | background-color: #f1f1f1; 21 | } 22 | body.dark #tabs button, body.mainmode.dark #maintab, body.mapmode.dark #maptab { 23 | color: white; 24 | } 25 | body.dark a, body.dark a:hover, body.dark a:active, body.dark a:visited { 26 | color: rgb(69, 161, 255); 27 | } 28 | body.dark #tabs button:hover { 29 | background-color: rgb(29, 29, 29); 30 | } 31 | body.light #tabs button:hover { 32 | background-color: #f8f8f8; 33 | } 34 | body.dark.mapmode #maptab, body.dark.mainmode #maintab { 35 | background-color: rgb(43, 43, 43); 36 | } 37 | body.light.mapmode #maptab, body.light.mainmode #maintab { 38 | background-color: #fff; 39 | } 40 | body.dark #data tr:hover { 41 | background-color: rgb(49, 49, 49); 42 | } 43 | body.light #data tr:hover { 44 | background-color: rgb(248, 248, 248); 45 | } 46 | body.dark #data tr.clickable:hover { 47 | color: #FD2; 48 | } 49 | body.light #data tr.clickable:hover { 50 | background-color: #FFFADF; 51 | } 52 | 53 | 54 | /* General layout and visibility: */ 55 | html, body { 56 | box-sizing: border-box; 57 | margin: 0; 58 | overflow: auto; 59 | padding: 0; 60 | width: 100%; 61 | height: 100%; 62 | } 63 | body { 64 | cursor: default; 65 | font: caption; 66 | font-size: 14px; 67 | } 68 | #main { 69 | padding: 1.5rem 1.3em .5rem 1.5rem; 70 | height: auto; 71 | background-image: url("../icons/xIFr-32.png"); 72 | background-position: right 1.3rem top 1.5rem; 73 | background-repeat: no-repeat; 74 | } 75 | img#settings, img#cpClipboard { 76 | display: block; 77 | position: absolute; 78 | width: 24px; 79 | height: 24px; 80 | border: none; 81 | padding: 0; 82 | margin: 0; 83 | opacity: 0.2; 84 | right: 1.3rem; 85 | bottom: .5rem; 86 | cursor: pointer; 87 | } 88 | img#cpClipboard { 89 | right: 3rem; 90 | } 91 | body.mapmode img#cpClipboard, body.copyUnsupported img#cpClipboard { 92 | display: none; 93 | } 94 | #map { 95 | height: calc(100% - 5rem); /* room for the larger-map link */ 96 | padding: 0; 97 | } 98 | .mainmode #main, .mapmode #map { 99 | display: block; 100 | } 101 | .mainmode #map, .mapmode #main { 102 | display: none; 103 | } 104 | #map iframe { 105 | width: 100%; 106 | height: 100%; 107 | border: none; 108 | margin: 0; 109 | padding: 0; 110 | } 111 | #image, #properties { 112 | position: relative; 113 | margin: 0 0 1ch 0; 114 | overflow: hidden; 115 | } 116 | #filename { 117 | white-space: nowrap; 118 | } 119 | #orig_shown { 120 | display: none; 121 | font-style: italic; 122 | white-space: nowrap; 123 | } 124 | #image /* , #properties */ { 125 | float: left; 126 | margin-right: 1rem; 127 | } 128 | #image #thumbnail { 129 | width: auto; 130 | height: auto; 131 | max-width: 200px; 132 | max-height: 200px; 133 | border: 1px solid #777; 134 | margin: 0; 135 | padding: 0; 136 | display: block; 137 | background-image: url("../icons/background.png"); 138 | } 139 | #image #flickrlogo { 140 | display: none; 141 | position: absolute; 142 | right: 2px; 143 | top: 2px; 144 | width: 25px; 145 | border-radius: 50%; 146 | border: 1px solid #000; 147 | } 148 | #image a[href^="https://"] #flickrlogo { 149 | display: block; 150 | } 151 | #image #flickrlogo.gray { 152 | filter: grayscale(100%); 153 | } 154 | #messages { 155 | margin: 1ch 0; 156 | clear: both; 157 | display: none; 158 | } 159 | #messages img { 160 | border: none; 161 | margin: 0; 162 | padding: 0; 163 | width: 16px; 164 | height: 16px; 165 | vertical-align: sub; 166 | } 167 | #data { 168 | margin: 1ch 0 0 0; 169 | clear: both; 170 | width: 100%; 171 | border: none; 172 | padding: 0; 173 | border-collapse: collapse; 174 | } 175 | #data .labels { 176 | width: 19ch; 177 | } 178 | #data tr.scsv td:last-child { 179 | /*overflow: hidden;*/ 180 | /*white-space: nowrap;*/ 181 | } 182 | #data tr.notice td:first-child::after { 183 | content: " †"; 184 | } 185 | #data td { 186 | vertical-align: top; 187 | padding: 2px 1px; 188 | } 189 | #data td:first-child { 190 | padding-right: .5rem; 191 | } 192 | .expandable { 193 | display: none; 194 | } 195 | .expandGps .expandable.gps, .expandSoftware .expandable.software { 196 | display: inline; 197 | } 198 | #maplinks { 199 | margin: 0; 200 | padding: 0; 201 | border: none; 202 | float: right; 203 | width: auto; 204 | } 205 | #maplinks > div { 206 | float: left; 207 | color: white; 208 | width: 16px; 209 | height: 16px; 210 | border: none; 211 | padding: 0; 212 | margin: 0 0 0 .5ch; 213 | background-image: url("../icons/globe-32.png"); 214 | background-size: 16px 16px; 215 | } 216 | #maplinks > div > a { 217 | position: relative; 218 | float: left; 219 | top: 50%; 220 | left: 50%; 221 | transform: translate(-50%,-50%); 222 | font-weight: bold; 223 | } 224 | #maplinks a, #maplinks a:hover, #maplinks a:active, #maplinks a:visited { 225 | color: white; 226 | text-decoration: none; 227 | } 228 | body.copypastemode #maplinks { 229 | display: none; 230 | } 231 | .OSM, .Google, .Bing, .MapQuest, .Here, .Flickr, .GeoHack { 232 | display: none; 233 | } 234 | .showOSM .OSM, .showGoogle .Google, .showBing .Bing, .showMapQuest .MapQuest, .showHere .Here, .showFlickr .Flickr, .showGeoHack .GeoHack { 235 | display: block; 236 | } 237 | body.mainmode #maptab.disabled { 238 | color: transparent; 239 | cursor: inherit; 240 | } 241 | 242 | /* Tabs: */ 243 | #tabs { 244 | overflow: hidden; 245 | border: none; 246 | min-width: 25rem; 247 | position: relative; 248 | } 249 | #tabs button { 250 | background: transparent; 251 | float: left; 252 | border: none; 253 | outline: none; 254 | cursor: pointer; 255 | padding: 14px 16px; 256 | transition: 0.3s; 257 | width: 12rem; 258 | } 259 | .mapmode #maptab, .mainmode #maintab { 260 | font-weight: bold; 261 | } 262 | .mapmode #maptab:focus, .mainmode #maintab:focus { 263 | outline: none; /* why doesn't this work ? */ 264 | } 265 | #maintab, #maptab { 266 | animation: fadeEffect 1s; 267 | } 268 | @keyframes fadeEffect { 269 | from {opacity: 0;} 270 | to {opacity: 1;} 271 | } 272 | -------------------------------------------------------------------------------- /WebExtension/popup/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | xIFr - Image meta data 6 | 7 | 8 | 9 | Copy 10 | Options 11 |
12 | 13 | 14 |
15 |
16 |
17 | 18 | 19 |
20 |
21 |
Content-type:
22 |
Size: bytes ()
23 |
Last modified:
24 |
Dimensions:
25 |
Image shown on page:
26 |
27 |
28 |
29 |
30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /WebExtension/popup/popup.js: -------------------------------------------------------------------------------- 1 | globalThis.browser ??= chrome; 2 | 3 | function createRichElement(tagName, attributes = {}, ...content) { 4 | const element = document.createElement(tagName); 5 | for (const [attr, value] of Object.entries(attributes)) { 6 | if (value === false) { 7 | // Ignore - Don't create attribute (the attribute is "disabled") 8 | } else if (value === true) { 9 | element.setAttribute(attr, attr); // xhtml-style "enabled" attribute 10 | } else { 11 | element.setAttribute(attr, value); 12 | } 13 | } 14 | if (content?.length) { 15 | element.append(...content); 16 | } 17 | return element; 18 | } 19 | 20 | // Safari 16.3 doesn't support regexp-lookbehind: https://caniuse.com/js-regexp-lookbehind 21 | // (But support are on the way in Safari too: https://github.com/WebKit/WebKit/pull/7109) 22 | function supportsRegexLookAheadLookBehind() { 23 | try { 24 | return ( 25 | "hibyehihi" 26 | .replace(/(?<=hi)hi/gu, "hello") 27 | .replace(/hi(?!bye)/gu, "hey") === "hibyeheyhello" 28 | ); 29 | } catch (error) { 30 | return false; 31 | } 32 | } 33 | const PURLsource = '(?<=[\\[:;,({\\s]|^)((http|https):\\/\\/)?[a-z0-9][-a-z0-9.]{1,249}\\.[a-z][a-z0-9]{1,62}\\b([-a-z0-9@:%_+.~#?&/=]*)'; // PURL.source 34 | const PURLflags = 'imu'; // PURL.flags 35 | const PEMAILsource = '(?<=[\\[:;,({\\s]|^)(mailto:)?([a-z0-9._-]+@[a-z0-9][-a-z0-9.]{1,249}\\.[a-z][a-z0-9]{1,62})'; // PEMAIL.source 36 | const PEMAILflags = 'imu'; // PEMAIL.flags 37 | // Return "Linkified content" as a structure of DOMStrings and Nodes. 38 | // Usage: (Spread and) insert result with methods like ParentNode.append(), ParentNode.replaceChildren() and ChildNode.replaceWith() 39 | function linkifyWithNodeAppendables(str, anchorattributes, emailanchorattributes) { 40 | // The big magical text to rich-DOM-structure "transmogrifier"... 41 | const PURL = new RegExp(PURLsource, PURLflags); 42 | const PEMAIL = new RegExp(PEMAILsource, PEMAILflags); 43 | function httpLinks(str) { 44 | const a = str.match(PURL); // look for webadresses 45 | if (a === null) { 46 | return [str]; 47 | } else { 48 | const webattrib = anchorattributes || {}; 49 | const durl = a[0].replace(/[:.]+$/u, ""); // remove trailing dots and colons 50 | webattrib.href = durl.search(/^https?:\/\//u) === -1 ? "http://" + durl : durl; 51 | const begin = str.substring(0, str.indexOf(durl)); 52 | const end = str.substring(begin.length + durl.length); 53 | return [begin, createRichElement('a', webattrib, durl), ...httpLinks(end)]; // (recursive) 54 | } 55 | } 56 | function mailtoAndHttpLinks(str) { 57 | const e = str.match(PEMAIL); // look for emails 58 | if (e === null) { 59 | return [...httpLinks(str)]; 60 | } else { 61 | const emailattrib = emailanchorattributes || anchorattributes || {}; 62 | const demail = e[0]; 63 | emailattrib.href = demail.search(/^mailto:/u) === -1 ? "mailto:" + demail : demail; 64 | const begin = str.substring(0,str.indexOf(demail)); 65 | const end = str.substring(begin.length + demail.length); 66 | return [...httpLinks(begin), createRichElement('a', emailattrib, demail), ...mailtoAndHttpLinks(end)]; // (recursive) 67 | } 68 | } 69 | return mailtoAndHttpLinks(str); 70 | } 71 | if (!supportsRegexLookAheadLookBehind()) { 72 | linkifyWithNodeAppendables = function(str, ...ignore) {return str}; 73 | console.warn('Current webbrowser doesn\'t support regexp-lookbehind (or lookahead)! DOMUtil.linkifyWithNodeAppendables(str) will return str parameter unchanged.'); 74 | } 75 | // Return "line-breaked and linkified content" as array of DOMStrings and Nodes/subtrees. 76 | // Use with "spread result" like: ParentNode.append(...result), ParentNode.replaceChildren(...result) and ChildNode.replaceWith(...result) 77 | function formatWithNodeAppendables(s, linesplitter, anchorattributes, emailanchorattributes) { 78 | let lines = [s]; 79 | if (linesplitter) { 80 | lines = s.split(linesplitter); 81 | } 82 | for (let i = lines.length - 1; i > 0; i--) { 83 | lines.splice(i, 0, document.createElement('br')); 84 | lines.splice(i + 1, 1, ...linkifyWithNodeAppendables(lines[i + 1], anchorattributes, emailanchorattributes)); 85 | } 86 | return [...linkifyWithNodeAppendables(lines[0], anchorattributes, emailanchorattributes), ...lines.slice(1)]; 87 | } 88 | // Return "linebreaked and linkified content" as list of DOMStrings and Nodes, to (spread and) insert with ParentNode.append(), ParentNode.replaceChildren() and ChildNode.replaceWith() 89 | // (Will convert both "symbolic" and real linefeeds to actual
DOM elements) 90 | function cleanAndFormatWithNodeAppendables(s) { // Needs a better name? :-) 91 | s = s.replace(/\x00/gu, ""); // Remove confusing nulls 92 | if (s.indexOf("\\r") > -1) { 93 | s = s.split("\\n").join(""); 94 | } else { 95 | s = s.split("\\n").join("\\r"); 96 | } 97 | s = s.split("\n").join("\\r"); 98 | return formatWithNodeAppendables(s, "\\r"); 99 | } 100 | let keyShortcuts = (function KeyShortcuts() { 101 | const shortcuts = new Map(); 102 | window.addEventListener("keydown", function keydownListener(event) { 103 | if (event.defaultPrevented) { 104 | return; // Do nothing if the event was already processed 105 | } 106 | if (!event.repeat && !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey) { 107 | if (shortcuts.has(event.key)) { 108 | shortcuts.get(event.key)(event); // Do the shortcut handler 109 | event.preventDefault(); 110 | } 111 | } 112 | }, true); 113 | function register(key, handler) { 114 | // Add key/handler 115 | shortcuts.set(key, handler); 116 | } 117 | // API: 118 | return {register}; 119 | })(); 120 | 121 | /** 122 | * Return detected photo-id for a photo embedded from (hosted at) Flickr. 123 | * Returns undefined if not embedded from Flickr or photo-id is not detected. 124 | * @param {string} imageUrl 125 | * @returns {string | undefined} 126 | */ 127 | function flickrEmbeddedId(imageUrl) { 128 | return (/^https?:\/\/(?:farm|live|c)\d*\.static\.?flickr\.com\/(\d\/)?\d+\/(?\d+)_[0-9a-f]{4,}(?:_[a-z0-9]{1,3})?\.[a-z]{3,4}(?:$|\?|#|\/)/iu).exec(imageUrl)?.groups.imageId; 129 | // https://www.flickr.com/services/api/misc.urls.html 130 | } 131 | 132 | /** 133 | * Return detected (possible) photo-id for a filename that LOOKS LIKE it could be a photo that origins from Flickr. 134 | * Otherwise return undefined. 135 | * @param {string} imageUrl 136 | * @returns {string | undefined} 137 | */ 138 | function flickrOriginId(imageUrl) { 139 | imageUrl = new URL(imageUrl); 140 | let path = imageUrl.pathname; 141 | return (/\/(?\d+)_[0-9a-f]{4,}_[a-z0-9]{1,3}\.[a-z]{3,4}(?:$|\?|#|\/)/iu).exec(path)?.groups.imageId; 142 | } 143 | 144 | function populate(response) { 145 | keyShortcuts.register("Escape", function closePopup(){self.close()}); 146 | function thumbsize(fullwidth, fullheight) { 147 | let w; 148 | let h; 149 | if (fullwidth > fullheight) { 150 | w = Math.min(200, fullwidth); 151 | h = Math.round((w / fullwidth) * fullheight); 152 | } else { 153 | h = Math.min(200, fullheight); 154 | w = Math.round((h / fullheight) * fullwidth); 155 | } 156 | return {width: w + 'px', height: h + 'px'}; 157 | } 158 | // console.log('xIFr: POPUP with \n' + JSON.stringify(response)); 159 | if (response.properties.URL) { 160 | const image = document.querySelector("#thumbnail"); 161 | if (response.properties.naturalWidth) { 162 | const ts = thumbsize(response.properties.naturalWidth, response.properties.naturalHeight); 163 | image.style.width = ts.width; 164 | image.style.height = ts.height; 165 | } 166 | image.addEventListener("error", (ev) => { 167 | // TODO if this happens, I should show an error message in popup saying "try to open image via link"... 168 | console.error(`xIFr: Load image error for ${response.properties.URL} `, ev); 169 | }); 170 | image.addEventListener("load", function() { 171 | response.properties.naturalWidth = image.naturalWidth; 172 | response.properties.naturalHeight = image.naturalHeight; 173 | // Redo calculation if successfully loaded (Loading might fail if from file:): 174 | const ts = thumbsize(response.properties.naturalWidth, response.properties.naturalHeight); 175 | image.style.width = ts.width; 176 | image.style.height = ts.height; 177 | if (typeof response.properties.naturalWidth === 'number') { 178 | document.getElementById("dimensions").textContent = response.properties.naturalWidth + "x" + response.properties.naturalHeight + " pixels"; 179 | } 180 | }); 181 | if (response.properties.crossOrigin) { 182 | image.crossOrigin = response.properties.crossOrigin; 183 | } 184 | image.src = response.properties.URL; 185 | 186 | 187 | function linkProperties(imageUrl) { 188 | const linkElem = createRichElement("a", {href: imageUrl}); // TODO: Can we use URL object here instead? 189 | let textContent; 190 | let title = ""; 191 | if (imageUrl.startsWith("data:")) { 192 | textContent = "[ inline imagedata ]"; 193 | } else if (imageUrl.startsWith("blob:")) { 194 | textContent = "[ blob imagedata ]"; 195 | title = imageUrl; 196 | } else { 197 | textContent = (linkElem.pathname.length > 1 ? linkElem.pathname.substring(linkElem.pathname.lastIndexOf("/") + 1) : linkElem.hostname || linkElem.host) || "[ ./ ]"; 198 | title = imageUrl; 199 | } 200 | return { 201 | url: imageUrl, 202 | name: textContent, 203 | title: title 204 | } 205 | } 206 | 207 | const linkProps = linkProperties(image.src); 208 | document.getElementById("filename").textContent = linkProps.name; 209 | document.getElementById("filename").title = linkProps.title; 210 | document.getElementById("filename").href = linkProps.url; 211 | if (response.properties.pageShownURL) { 212 | const origLinkProps = linkProperties(response.properties.pageShownURL); 213 | document.getElementById("orig_filename").textContent = origLinkProps.name; 214 | document.getElementById("orig_filename").title = origLinkProps.title; 215 | document.getElementById("orig_filename").href = origLinkProps.url; 216 | if (response.properties.pageShownType) { 217 | document.getElementById("orig_type").textContent = "(" + response.properties.pageShownType + ")"; 218 | } 219 | document.getElementById("orig_shown").style.display = 'inline'; 220 | } 221 | document.getElementById("imgsize").textContent = response.properties.byteLength; 222 | document.getElementById("imgsize2").textContent = response.properties.byteLength >= 1048576 ? ((response.properties.byteLength / 1048576).toFixed(2)).toString() + " MB" : ((response.properties.byteLength / 1024).toFixed(2)).toString() + " KB"; 223 | if (response.properties.URL.startsWith("file:")) { 224 | document.getElementById("contenttype").textContent = ""; 225 | document.getElementById("lastmodified").textContent = ""; 226 | } else { 227 | document.getElementById("contenttype").textContent = response.properties.contentType; 228 | document.getElementById("lastmodified").textContent = response.properties.lastModified; 229 | } 230 | if (typeof response.properties.naturalWidth === 'number') { 231 | document.getElementById("dimensions").textContent = response.properties.naturalWidth + "x" + response.properties.naturalHeight + " pixels"; 232 | } 233 | const badge = document.querySelector('#image a'); 234 | let flickrId = flickrEmbeddedId(image.src); 235 | if (flickrId && badge) { 236 | badge.href = `https://flickr.com/photo.gne?id=${flickrId}`; 237 | } else if (badge?.firstChild) { 238 | flickrId = flickrOriginId(image.src); 239 | if (flickrId) { 240 | badge.href = `https://flickr.com/photo.gne?id=${flickrId}`; 241 | badge.firstElementChild.title = 'Filename hints it might originate from Flickr. Click to look for photopage...'; 242 | badge.firstElementChild.classList.add('gray'); 243 | } 244 | } 245 | } 246 | function addMessages(list, icon, alt) { 247 | list.forEach(function (item) { 248 | const msg = createRichElement('i', {}, item); 249 | const sign = createRichElement('img', {src: icon, alt: alt}); 250 | document.getElementById('messages').appendChild(createRichElement('div', {}, sign, ' ', msg)); 251 | }); 252 | } 253 | if (response.errors.length > 0 || response.warnings.length > 0 || response.infos.length > 0) { 254 | addMessages(response.errors, '/icons/error-7-32w.png', '!'); 255 | addMessages(response.warnings, '/icons/warn-32w.png', '!'); 256 | addMessages(response.infos, '/icons/info-32w.png', 'i'); 257 | document.getElementById('messages').style.display = 'block'; 258 | } 259 | 260 | function gpsRowClick(event) { 261 | event.preventDefault(); 262 | document.body.classList.add('expandGps'); 263 | document.querySelectorAll('.gps.expandable').forEach( 264 | function(elm) { 265 | const row = elm.parentNode.parentNode; 266 | row.removeAttribute('title'); 267 | row.classList.remove('clickable', 'notice'); 268 | row.removeEventListener("click", gpsRowClick, {capture: true, once: true}); 269 | }); 270 | } 271 | function softwareRowClick(event) { 272 | event.preventDefault(); 273 | document.body.classList.add('expandSoftware'); 274 | const elm = document.querySelector('.software.expandable'); 275 | if (elm) { 276 | const row = elm.parentNode.parentNode; 277 | row.removeAttribute('title'); 278 | row.classList.remove('clickable', 'notice'); 279 | } 280 | } 281 | function listArrayWithNodeAppendables(arr) { // Inserting linebreaks to get one item pr. line 282 | const ret = []; 283 | arr.forEach(function(item) {ret.push(item); ret.push(document.createElement('br'))}); 284 | return ret; 285 | } 286 | const table = document.getElementById("data"); 287 | function addDataRow(key_v) { 288 | if (key_v !== "GPSPureDdLat" && key_v !== "GPSPureDdLon" && key_v !== "AdditionalSoftware" && response.data[key_v].value !== null && response.data[key_v].value !== "") { 289 | const row = table.insertRow(-1); 290 | const label = row.insertCell(0); 291 | const value = row.insertCell(1); 292 | label.textContent = response.data[key_v].label; 293 | label.id = key_v + "LabelCell"; 294 | value.textContent = response.data[key_v].value; 295 | value.id = key_v + "ValueCell"; 296 | if (["LicenseURL", "CreditLine", "Copyright", "CreatorEmails", "CreatorURLs", "DigitalSourceType"].includes(key_v)) { 297 | const text = value.textContent.trim(); 298 | value.replaceChildren(...linkifyWithNodeAppendables(text)); // Text with links - ParentNode.replaceChildren() requires Firefox 78+ (or Chrome/Edge 86+) 299 | } else 300 | if (["Caption", "UsageTerms", "DocumentNotes", "UserComment", "Comment", "Instructions"].includes(key_v)) { 301 | const text = value.textContent.trim(); 302 | value.replaceChildren(...cleanAndFormatWithNodeAppendables(text)); // Text with linebreaks - ParentNode.replaceChildren() requires Firefox 78+ (or Chrome/Edge 86+) 303 | } else if (key_v === "Keywords") { 304 | row.classList.add('scsv'); 305 | } else if (key_v === 'GPSLat') { 306 | value.insertBefore(createRichElement('div', {id: 'maplinks'}), value.firstChild); 307 | value.insertAdjacentElement("beforeend", createRichElement('span', {class: 'gps expandable'}, document.createElement('br'), response.data['GPSPureDdLat'].value + " (decimal)")); 308 | row.title = "Click for decimal latitude and longitude values"; 309 | row.classList.add('clickable', 'notice'); 310 | row.addEventListener("click", gpsRowClick, {capture: true, once: true}); 311 | } else if (key_v === 'GPSLon') { 312 | value.insertAdjacentElement("beforeend", createRichElement('span', {class: 'gps expandable'}, document.createElement('br'), response.data['GPSPureDdLon'].value + " (decimal)")); 313 | row.title = "Click for decimal latitude and longitude values"; 314 | row.addEventListener("click", gpsRowClick, {capture: true, once: true}); 315 | row.classList.add('clickable', 'notice'); 316 | } else if (key_v === "Software" && response.data['AdditionalSoftware']?.value?.length) { 317 | value.insertAdjacentElement("afterbegin", createRichElement('span', {class: 'software expandable'}, ...listArrayWithNodeAppendables(response.data['AdditionalSoftware'].value))); 318 | row.title = "Click for additional software used"; 319 | row.addEventListener("click", softwareRowClick, {capture: true, once: true}); 320 | row.classList.add('clickable', 'notice'); 321 | } else if (key_v === 'ColorSpace') { 322 | row.title = "Notice: Color space given in Exif and XMP meta-data, might not be the same as actual image color space used!"; 323 | row.classList.add('notice'); 324 | } 325 | } 326 | } 327 | function showDataTab(event) { 328 | document.body.classList.replace("mapmode", "mainmode"); 329 | } 330 | function showMapTab(event) { 331 | document.body.classList.replace("mainmode", "mapmode"); 332 | // bbox calculation is a hack. Can do better with: 333 | // Destination point given distance and bearing from start point 334 | // https://www.movable-type.co.uk/scripts/latlong.html 335 | // Bearing 336 | // https://rechneronline.de/geo-coordinates/ 337 | document.getElementById("osmap").src = "https://www.openstreetmap.org/export/embed.html?bbox=" + (response.data.GPSPureDdLon.value - 0.003) + "%2C" + (response.data.GPSPureDdLat.value - 0.007) + "%2C" + (response.data.GPSPureDdLon.value + 0.003) + "%2C" + (response.data.GPSPureDdLat.value + 0.007) + "&layer=mapnik&marker=" + response.data.GPSPureDdLat.value + "%2C" + response.data.GPSPureDdLon.value; 338 | document.getElementById("largermap").href = "https://www.openstreetmap.org/?mlat=" + response.data.GPSPureDdLat.value + "&mlon=" + response.data.GPSPureDdLon.value + "#map=15/" + response.data.GPSPureDdLat.value + "/" + response.data.GPSPureDdLon.value; 339 | } 340 | function openLargeMap(event) { 341 | if (document.body.classList.contains("mapmode")) { 342 | document.getElementById("largermap").click(); 343 | } 344 | } 345 | function maplink(title, className, url, letter) { 346 | const link = createRichElement('a', {href: url}, letter); 347 | return createRichElement('div', {title: title, class: className}, link); 348 | } 349 | function displayInPage(event) { 350 | if (document.querySelector('#image img.toggleInPage')) { 351 | browser.tabs.sendMessage( 352 | response.properties.tabId, 353 | { 354 | message: 'displayInPage', 355 | data: { 356 | 'URL': response.properties.URL, 357 | 'pageURL': response.properties.tabUrl, 358 | 'crossOrigin': response.properties.crossOrigin, 359 | 'referrerPolicy': response.properties.referrerPolicy 360 | } 361 | }) 362 | .then((r) => { 363 | // console.log(`xIFr: ${r}`) 364 | }) 365 | .catch((e) => { 366 | console.warn('xIFr: Unable to display image "in-page" \n' + e) 367 | }); 368 | } 369 | } 370 | 371 | const orderedKeys = ["Headline", "Caption", "ObjectName", "Date", "Creditline", "Copyright", "UsageTerms", "LicenseURL", 372 | "Creator", "CreatorAddress", "CreatorCity", "CreatorRegion", "CreatorPostalCode", "CreatorCountry", "CreatorPhoneNumbers", "CreatorEmails", "CreatorURLs", "DigitalSourceType", 373 | "Make", "Model", "Lens", "FocalLengthText", "DigitalZoomRatio", "ApertureFNumber", "ExposureTime", "ISOequivalent", "FlashUsed", "WhiteBalance", "Distance", 374 | "GPSLat", "GPSLon", "GPSAlt", "GPSImgDir", "CountryName", "ProvinceState", "City", "Sublocation" ]; 375 | const foundKeys = Object.keys(response.data); 376 | orderedKeys.filter(x => foundKeys.includes(x)).forEach(addDataRow); // First the orderedKeys (Headline, Description, Creator, Copyright, Credit Line,...) 377 | foundKeys.filter(x => !orderedKeys.includes(x)).forEach(addDataRow); // Then the rest... 378 | if (response.data.GPSPureDdLat && response.data.GPSPureDdLon && typeof response.data.GPSPureDdLat.value === 'number' && typeof response.data.GPSPureDdLon.value === 'number') { 379 | document.getElementById("maintab").onclick = showDataTab; 380 | keyShortcuts.register("i", showDataTab); 381 | keyShortcuts.register("I", showDataTab); 382 | document.getElementById("maptab").onclick = showMapTab; 383 | keyShortcuts.register("m", showMapTab); 384 | keyShortcuts.register("M", showMapTab); 385 | keyShortcuts.register("l", openLargeMap); 386 | keyShortcuts.register("L", openLargeMap); 387 | const maplinks = document.getElementById('maplinks'); 388 | if (maplinks) { 389 | const lat = response.data.GPSPureDdLat.value; 390 | const lon = response.data.GPSPureDdLon.value; 391 | const lang = browser.i18n.getUILanguage(); 392 | const titleString = encodeURIComponent('Photo location').replace(/_/gu, ' '); // Used by Bing. Could potentially be filename or title, but underscores means trouble :-/ ... 393 | maplinks.appendChild(maplink('Locate on OpenStreetMap', 'OSM', `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}&layers=M`, 'O')); 394 | maplinks.appendChild(maplink('Locate on Google Maps', 'Google', `https://www.google.com/maps/search/?api=1&query=${lat},${lon}`, 'G')); 395 | maplinks.appendChild(maplink('Locate on Bing Maps', 'Bing', `https://www.bing.com/maps/?cp=%lat%~%lon%&lvl=16&sp=point.${lat}_${lon}_${titleString}`, 'B')); 396 | maplinks.appendChild(maplink('Locate on MapQuest', 'MapQuest', `https://www.mapquest.com/latlng/${lat},${lon}`, 'Q')); 397 | maplinks.appendChild(maplink('Locate on Here WeGo', 'Here', `https://share.here.com/l/${lat},${lon}`, 'H')); 398 | maplinks.appendChild(maplink('Explore nearby on Flickr', 'Flickr', `https://www.flickr.com/map/?fLat=${lat}&fLon=${lon}&zl=15`, 'F')); // "https://www.flickr.com/map/?fLat=${lat}&fLon=${lon}&zl=15&everyone_nearby=1" - &zl=15&min_upload_date=2019-06-07%2000%3A00%3A00&max_upload_date=2019-07-08%2000%3A00%3A00 ? 399 | maplinks.appendChild(maplink('Investigate more on GeoHack', 'GeoHack', `https://geohack.toolforge.org/geohack.php?language=en¶ms=${lat};${lon}`, 'X')); 400 | } 401 | } else { 402 | // Disable map-tab 403 | document.getElementById('maptab').classList.add('disabled'); 404 | } 405 | document.querySelectorAll('a').forEach((elem) => { 406 | elem.addEventListener('click', (event) => { 407 | event.stopPropagation(); 408 | event.preventDefault(); 409 | try { 410 | window.open(event.currentTarget.href, '_blank', 'noopener,noreferrer'); 411 | } catch (e) { 412 | console.error(e); 413 | return; 414 | } 415 | self.close(); 416 | }, true) 417 | }); 418 | 419 | let img = document.querySelector('#image #thumbnail'); 420 | if (img) { 421 | img.addEventListener('click', displayInPage, true); 422 | } 423 | } 424 | 425 | function openOptions(event) { 426 | if (event) { 427 | event.stopPropagation(); 428 | event.preventDefault(); 429 | } 430 | browser.runtime.openOptionsPage(); 431 | self.close(); 432 | } 433 | function copyPasteContent() { 434 | let s = 'FILE PROPERTIES\n\n'; 435 | s += document.getElementById('properties').innerText + '\n\n'; 436 | const rows = document.querySelectorAll('table#data tr'); 437 | if (rows?.length) { 438 | document.body.classList.add("copypastemode"); 439 | s += 'IMAGE META DATA\n\n'; 440 | rows.forEach((row) => { 441 | const tds = row.getElementsByTagName('td'); 442 | if (tds && tds.length > 1) { 443 | s += tds[0].innerText + ': ' + tds[1].innerText + '\n'; 444 | } 445 | }); 446 | document.body.classList.remove("copypastemode"); 447 | } 448 | return s; 449 | } 450 | function copyToClipboard(event) { 451 | if (event) { 452 | event.stopPropagation(); 453 | event.preventDefault(); 454 | } 455 | // Copy to clipboard 456 | navigator.clipboard.writeText(copyPasteContent()); 457 | } 458 | function setup(options) { 459 | if (context.prefersDark(options["dispMode"])) { 460 | document.body.classList.replace("light", "dark"); // If light, then swap with dark 461 | document.body.classList.add("dark"); // But also set dark if light wasn't set 462 | } else { 463 | document.body.classList.replace("dark", "light"); // If dark, then swap with light 464 | document.body.classList.add("light"); // But also set light if dark wasn't set 465 | } 466 | // Enable selected maplinks... 467 | ["OSM", "Google", "Bing", "MapQuest", "Here", "Flickr", "GeoHack"].forEach((v) => { 468 | if (options["mlink" + v]) { 469 | document.body.classList.add("show" + v); 470 | } 471 | }); 472 | if (options['devClickThumbnailBeta']) { 473 | document.querySelector('#image #thumbnail')?.classList.add('toggleInPage'); 474 | } 475 | document.getElementById("settings").addEventListener('click', openOptions, true); 476 | keyShortcuts.register("o", openOptions); 477 | keyShortcuts.register("O", openOptions); 478 | if (navigator.clipboard?.writeText) { 479 | document.getElementById("cpClipboard").addEventListener('click', copyToClipboard, true); 480 | keyShortcuts.register("c", copyToClipboard); 481 | keyShortcuts.register("C", copyToClipboard); 482 | } else { 483 | document.body.classList.add('copyUnsupported'); // Hide copy button 484 | } 485 | } 486 | function init() { 487 | context.getOptions().then(setup); 488 | browser.runtime.sendMessage({ 489 | message: "popupReady" 490 | }).then(populate); 491 | } 492 | window.addEventListener("DOMContentLoaded", init); 493 | -------------------------------------------------------------------------------- /WebExtension/stringBundle.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | * 5 | * This Source Code Form is "Incompatible With Secondary Licenses", as 6 | * defined by the Mozilla Public License, v. 2.0. 7 | */ 8 | globalThis.browser ??= chrome; 9 | 10 | globalThis.stringBundle = globalThis.stringBundle || { 11 | getString(string) { 12 | const translate = browser.i18n.getMessage(string); 13 | // WebExtension docs are lying here 14 | if (typeof translate === "undefined" || translate === "??" || translate === "") { 15 | return string; 16 | } else { 17 | return translate; 18 | } 19 | }, 20 | getFormattedString(string, val) { 21 | const xlat = stringBundle.getString(string); 22 | if (xlat !== string) { 23 | return val + xlat; 24 | } else { 25 | return string + "=" + val; 26 | } 27 | } 28 | }; 29 | --------------------------------------------------------------------------------