Skip to content

Instantly share code, notes, and snippets.

@squio
Created June 22, 2017 12:30
Show Gist options
  • Save squio/f14ce7b68ee30b33122fc78c72d29038 to your computer and use it in GitHub Desktop.
Save squio/f14ce7b68ee30b33122fc78c72d29038 to your computer and use it in GitHub Desktop.
/* vim: ts=2 noet ai :
$Id: exifthumbnailfetcher.user.js $
LICENSE
=======
This program is free software; you can redistribute it and/or modify it
under the terms of the GNU General Public License as published by the
Free Software Foundation; either version 2 of the License, or (at your
option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
CHANGELOG
=========
version 1.30
- rewritten to use GM_xhtmlrequest and make the script e10s compatible
version 1.22
- add @grant xyz
version 1.21
- make JPEG image links stand out with different color
Version 1.20
- Fix: apply styles only when running the app
Version 1.19
- more styles make unvisited directories stand out
- added feature to add viewed image links to browser history ("mark read")
Version 1.18
- enlarge thumbnails to 260px height
- set siteIcon to loading / done symbol
Version 1.17
- added .JPE extension
- NOTE: doesn't work with NoScript scripts disabled, switch (temporarily) on for now
Version 1.16
- changed to work with page scripts disabled by NoScript extension
Version 1.15
- Changed 'no exif' image to something less obtrusive
- Don't display EXIF button if there aren't any JPG/JPEG files
- Work on pages where the title is "Directory listing"
Version 1.14
- Added "loading" icon
- Added "No exif thumbnail" image when appropriate
Version 1.13
- Added convenient "EXIF" button to Apache index listings
Version 1.12
- More robust in case a thumbnail is missing from exif data
- This was a problem for "JFIF standard 1.02" images
Version 1.11
- Use request queue to avoid browser jamming
- Start through GM Command key
- Disabled GM logging
Version 1.10
- Completely rewritten for asynchronous loading
Version 1.00
- Initial version
- Proof of concept, not optimized in any way
*/
// ==UserScript==
// @name Exif Thumbnail Fetcher
// @namespace com.squio.gm
// @description Load embedded EXIF thumbnails for quick image preview
// @include *
// @version 1.30
// @grant GM_registerMenuCommand
// @grant GM_log
// @grant GM_xmlhttpRequest
// ==/UserScript==
const SOI_MARKER = 0xFFD8; // start of image
const SOS_MARKER = 0xFFDA; // start of stream
const EXIF_MARKER = 0xFFE1; // start of EXIF data
const INTEL_BYTE_ORDER = 0x4949;
// these are the EXIF fields we're interested in
const TAG_THUMBNAIL_OFFSET = 0x0201;
const TAG_THUMBNAIL_LENGTH = 0x0202;
const TAG_DATETIME = 0x0132;
const TAG_MAKE = 0x010F;
const TAG_MODEL = 0x0110;
// EXIF data formats
const FMT_BYTE = 1;
const FMT_STRING = 2;
const FMT_USHORT = 3;
const FMT_ULONG = 4;
const FMT_URATIONAL = 5;
const FMT_SBYTE = 6;
const FMT_UNDEFINED = 7;
const FMT_SSHORT = 8;
const FMT_SLONG = 9;
const FMT_SRATIONAL = 10;
const FMT_SINGLE = 11;
const FMT_DOUBLE = 12;
var _exifImgs = {
nothumb: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAYAAABWdVznAAAAPUlEQVQokWP8%2F%2F%2F%2FfwYSAAsDAwNDY%2BcMFMH68gwGXOJMyByYQnRFyOJMGCqIcRI263GJM5LqafKcNNJCCQDOdymK85vM%2BQAAAABJRU5ErkJggg%3D%3D",
loading: "data:image/gif;base64,R0lGODlhEAAQAPQAAP///wAAAPr6+paWlr6+vnx8fIyMjOjo6NDQ0ISEhLa2tq6urvDw8MjIyODg4J6enqampgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH+FU1hZGUgYnkgQWpheExvYWQuaW5mbwAh+QQACgAAACH/C05FVFNDQVBFMi4wAwEAAAAsAAAAABAAEAAABVAgII5kaZ6lMBRsISqEYKqtmBTGkRo1gPAG2YiAW40EPAJphVCREIUBiYWijqwpLIBJWviiJGLwukiSkDiEqDUmHXiJNWsgPBMU8nkdxe+PQgAh+QQACgABACwAAAAAEAAQAAAFaCAgikfSjGgqGsXgqKhAJEV9wMDB1sUCCIyUgGVoFBIMwcAgQBEKTMCA8GNRR4MCQrTltlA1mCA8qjVVZFG2K+givqNnlDCoFq6ioY9BaxDPI0EACzxQNzAHPAkEgDAOWQY4Kg0JhyMhACH5BAAKAAIALAAAAAAQABAAAAVgICCOI/OQKNoUSCoKxFAUCS2khzHvM4EKOkPLMUu0SISC4QZILpgk2bF5AAgQvtHMBdhqCy6BV0RA3A5ZAKIwSAkWhSwwjkLUCo5rEErm7QxVPzV3AwR8JGsNXCkPDIshACH5BAAKAAMALAAAAAAQABAAAAVSICCOZGmegCCUAjEUxUCog0MeBqwXxmuLgpwBIULkYD8AgbcCvpAjRYI4ekJRWIBju22idgsSIqEg6cKjYIFghg1VRqYZctwZDqVw6ynzZv+AIQAh+QQACgAEACwAAAAAEAAQAAAFYCAgjmRpnqhADEUxEMLJGG1dGMe5GEiM0IbYKAcQigQ0AiDnKCwYpkYhYUgAWFOYCIFtNaS1AWJESLQGAKq5YWIsCo4lgHAzFmPEI7An+A3sIgc0NjdQJipYL4AojI0kIQAh+QQACgAFACwAAAAAEAAQAAAFXyAgjmRpnqhIFMVACKZANADCssZBIkmRCLCaoWAIPm6FBUkwJIgYjR5LN7INSCwHwYktdIMqgoNFGhQQpMMt0WCoiGDAAvkQMYkIGLCXQI8OQzdoCC8xBGYFXCmLjCYhADsAAAAAAAAAAAA=",
xparent: "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs="
};
function exifLog(msg) {
GM_log(msg);
}
var __q; // raw images queue
var __ok = []; // successfully processed images
function hitch(obj, methodName) {
return function() { obj[methodName].apply(obj, arguments); };
}
// Create an object type "ExifException"
function ExifException (message) {
this.message = message;
this.name="ExifException";
this.toString = function() {
return this.message;
};
}
// central exif processing utility
function ExifProcessor(url) {
this.dataSource = url;
this.exifInfo = {
isValid: false,
status: "uninitialized"
};
this.setDataSource = function(url) {
this.dataSource = url;
};
this.execute = function(func) {
// load first block and handle with parseHeader
this.getData(0, 1023, this.parseHeader);
// this.getData(0, 1023, function(res) { GM_log(JSON.stringify(res));}); // DEBUGGING
// function to call after all exif work is done
this.onLoad = func;
};
// OBSOLETE
this.getDataORIG = function(start, end, callback) {
this.req = new XMLHttpRequest();
this.callback = callback;
this.req.open('GET', this.dataSource);
// binary charset opt by Marcus Granado 2006 [mgran.blogspot.com]
this.req.overrideMimeType('text/plain; charset=x-user-defined');
// add Range header to only retrieve a data chunk
this.req.setRequestHeader("Range", "bytes=" + start + "-" + end);
var _this = this;
this.req.onreadystatechange = hitch(this, "doCallback");
this.req.send(null);
};
// get data through binary clean XHR, asynchonous mode
this.getData = function(start, end, callback) {
var _this = this;
this.callback = callback;
// https://wiki.greasespot.net/GM_xmlhttpRequest
GM_xmlhttpRequest({
binary: true,
method: "GET",
timeout: 30000,
headers: {
"User-Agent": "Mozilla/5.0", // If not specified, navigator.userAgent will be used.
"Accept": "text/plain; charset=x-user-defined",
"Range": "bytes=" + start + "-" + end
},
overrideMimeType: "text/plain; charset=x-user-defined",
url: this.dataSource,
onload: function(res) {
if (res.status == 200 || res.status == 206) {
var data = res.responseText;
var buffer = new ArrayBuffer( data.length );
var view = new Uint8Array( buffer );
var len = view.length;
for (var i = len; i--; ) {
view[i] = data[i].charCodeAt(0);
}
var arr = new byteArray(view);
_this.callback(arr);
} else {
GM_log('XHR error status=' + res.status);
_this.exifInfo.status = "Request error; status code: " + res.status;
_this.onLoad(_this.exifInfo);
}
},
onerror: function(res) { GM_log(JSON.stringify(res)); }
});
};
// OBSOLETE:
this.doCallback = function() {
if (this.req.readyState != 4) return;
stat = this.req.status;
if (! (stat == 200 || stat == 206)) {
// exifLog("Error - req.status: " + this.req.status);
// call onload func with invalid exifInfo
this.exifInfo.status = "Request error; status code: " +
this.req.status
this.onLoad(this.exifInfo);
return;
}
var data = this.req.responseText;
var arr = new byteArray();
for (var i=0; i<data.length; i++) {
var c = data.charCodeAt(i);
arr.push((c > 255) ? c - 63232 : c);
}
this.callback(arr);
}
// finds start and length of Exif block
// data is array of bytes
this.parseHeader = function(data) {
var marker = data.read16();
var len;
if (marker == SOI_MARKER) {
try {
marker = data.read16();
// reading SOS marker indicates start of image stream
while(marker != SOS_MARKER && data.hasNext()) {
// length includes the length bytes
len = data.read16() - 2;
if (marker == EXIF_MARKER) { // bingo!
// skip 6 bytes, 'Exif\0\0'
data.skipBytes(6);
// offset of exifdata from start of file
var exifStart = data.getOffset();
// get exif data and call parser from callback handler
this.getData(exifStart, exifStart + len - 6,
hitch(this, "parseExif"));
// exifLog("Exif bytes: " + (len - 6));
return;
} else {
// read and discard data...
// exifLog("Skipping " + len);
data.skipBytes(len);
}
marker = data.read16();
} // while
} catch (e) {
this.exifInfo.status = "Format error; no valid EXIF data found";
this.onLoad(this.exifInfo);
}
} else {
this.exifInfo.status = "Format error; no JPEG header found";
this.onLoad(this.exifInfo);
}
this.exifInfo.status = "Format error; no EXIF header found";
this.onLoad(this.exifInfo);
};
// parse exif data block
this.parseExif = function(exifData) {
// 8 byte TIFF header
// first two determine byte order
var bi = exifData.read16();
if (bi == INTEL_BYTE_ORDER) {
exifData.swapBytes = true;
}
// exifLog(exifData.swapBytes);
// next two bytes are always 0x002A
// offset to Image File Directory (includes the previous 8 bytes)
var ifd_ofs = exifData.read32(4);
// parse actual EXIF data
this.readExifDir(exifData, ifd_ofs);
if (this.exifInfo.thumbOffset && this.exifInfo.thumbLength) {
// finally: keep a reference to exifData
this.exifInfo.exifData = exifData;
this.exifInfo.isValid = true;
} else {
this.exifInfo.status = "Exif error; no thumbnail found";
}
// and kick off the onLoad function
this.onLoad(this.exifInfo);
};
// in this method we read the relevant EXIF fields
this.readExifDir = function(exifData, dirstart) {
try {
// exifLog(dirstart);
var numEntries = exifData.read16(dirstart);
// exifLog(numEntries);
var entryOffset;
for (var i=0; i<numEntries; i++) {
entryOffset = dirstart + 2 + 12*i;
var tag = exifData.read16(entryOffset);
switch(tag) {
case TAG_THUMBNAIL_OFFSET:
this.exifInfo.thumbOffset =
this.readTag(exifData, entryOffset);
break;
case TAG_THUMBNAIL_LENGTH:
this.exifInfo.thumbLength =
this.readTag(exifData, entryOffset);
break;
case TAG_DATETIME:
this.exifInfo.dateTime =
this.readTag(exifData, entryOffset);
break;
case TAG_MAKE:
this.exifInfo.cameraMake =
this.readTag(exifData, entryOffset);
break;
case TAG_MODEL:
this.exifInfo.cameraModel =
this.readTag(exifData, entryOffset);
break;
} // switch tag
} // for entries
} catch(e) {
this.exifInfo.status = e.toString();
return;
}
// from jhead:
// In addition to linking to subdirectories via exif tags,
// there's also a potential link to another directory at the
// end of each directory.
// This has got to be the result of a committee!
entryOffset = dirstart + 2 + 12*numEntries;
if (entryOffset < exifData.getLength() - 4) {
var offset = exifData.read32(entryOffset);
if (offset) this.readExifDir(exifData, offset);
}
};
this.readTag = function(exifData, entryOffset) {
// number of bytes per format
var BytesPerFormat = [0,1,1,2,4,8,1,1,2,4,8,4,8];
var format = exifData.read16(2 + entryOffset);
var components = exifData.read32(4 + entryOffset);
var nbytes = components * BytesPerFormat[format];
var valueoffset;
if(nbytes <= 4) { // stored in the entry
valueoffset = entryOffset + 8;
}
else {
valueoffset = exifData.read32(entryOffset + 8);
}
return exifData.exifFormat(format, valueoffset, nbytes);
}
};
// exif data store with utility methods
function byteArray(arr) {
this.arr = arr || [];
this.hex = "0 1 2 3 4 5 6 7 8 9 a b c d e f".split(/\s/);
this.pos = 0;
this.swapBytes = false;
this.push = function(a) {
this.arr.push(a);
};
this.getLength = function() {
return this.arr.length;
};
this.hasNext = function() {
return (this.pos < this.arr.length - 1);
};
this.next = function() {
return (this.hasNext()) ? this.arr[this.pos++] : null;
};
this.read16 = function(offset) {
this._check(2);
if (! offset) {
offset = this.pos;
this.pos += 2;
}
var b1 = this.arr[offset];
var b2 = this.arr[offset + 1];
return (this.swapBytes) ? (b2 << 8) | b1 : (b1 << 8) | b2;
};
this.read32 = function(offset) {
var data = this.arr;
if(!this.swapBytes)
return (data[offset] << 24) |
(data[offset+1] << 16) |
(data[offset+2] << 8) |
data[offset+3];
return data[offset] |
(data[offset+1] << 8) |
(data[offset+2] << 16) |
(data[offset+3] << 24);
};
this.skipBytes = function(n) {
this._check(n);
this.pos += n;
};
// checks for availability of N bytes, throws error
this._check = function(n) {
if (this.pos + n > this.arr.length) {
throw(new ExifException("Attempt to read past array index"));
}
};
this.getOffset = function() {
return this.pos;
};
this.toString = function(offset, num) {
if (! offset) offset = 0;
if (! num) num = this.arr.length;
var s = "";
for (var i=offset; i<offset+num; i++) {
if(this.arr[i] == 0) continue; // skip null bytes
s += String.fromCharCode(this.arr[i]);
}
return s;
};
this.toHexString = function(offset, num) {
if (! offset) offset = 0;
if (! num) num = this.arr.length;
var s = "";
for (var i=offset; i<offset+num; i++) {
s += "%";
s += this.hex[Math.floor(this.arr[i] / 16)];
s += this.hex[Math.floor(this.arr[i] % 16)];
}
return s;
};
this.exifFormat = function(format, offset, numbytes) {
var data = this.arr;
switch(format) {
case FMT_STRING:
case FMT_UNDEFINED: // treat as string
return this.toString(offset, numbytes);
break;
case FMT_SBYTE:
return data.charCodeAt(offset);
case FMT_BYTE:
return data.charCodeAt(offset);
case FMT_USHORT:
return this.read16(offset);
case FMT_ULONG:
return this.read32(offset);
case FMT_URATIONAL:
case FMT_SRATIONAL:
var Num, Den;
Num = this.read32(offset);
Den = this.read32(offset+4);
return (Den == 0) ? 0 : Num/Den;
case FMT_SSHORT:
return this.read16(offset);
case FMT_SLONG:
return this.read32(offset);
// ignore, probably never used
case FMT_SINGLE:
case FMT_DOUBLE:
return 0;
}
return 0;
}
}
// callback function to append the thumbnail image
function nailer(obj, queue) {
return function(ex) {
var imgName = obj.href;
imgName = imgName.substring(imgName.lastIndexOf("/") + 1);
var thumb = obj.firstChild;
if (ex.isValid) {
// success
thumb.src = "data:image/jpeg," +
ex.exifData.toHexString(ex.thumbOffset, ex.thumbLength);
var t = [];
if (ex.dateTime) t.push("Date: " + ex.dateTime);
if (ex.cameraMake) t.push(ex.cameraMake);
if (ex.cameraModel) t.push(ex.cameraModel);
if (t.length) thumb.setAttribute("title", t.join(" | "));
exifLog("Ready: " + imgName);
thumb.setAttribute('class', 'exifThumb');
__ok.push(obj.href); // add to successfully processed queue
// markVisited(obj.href); // Mark image link as visited and persist in browser history
} else {
thumb.src = _exifImgs.nothumb;
exifLog(ex.status + " " + imgName);
}
// window.scrollTo(0, thumb.offsetTop - 300); // scroll image into view
queue.done();
}
}
// queue runner
function exQueue() {
this.queue = [];
this.MAX_PARALLEL = 4;
this.ptr = 0;
this.reqs = 0;
this.stopped = false;
this.init = function(allatonce) {
var imgs = document.getElementsByTagName("a");
for (var i=0; i<imgs.length; i++) {
var im = imgs[i];
if (im.href && im.href.match(/\.jpe?g?$/i)) {
var thumb = document.createElement("img");
thumb.src = _exifImgs.xparent;
thumb.style.border = "0px";
im.insertBefore(thumb, im.firstChild);
if (allatonce) {
var ep = new ExifProcessor(im.href);
ep.execute(nailer(im, this));
} else {
this.queue.push(im);
}
//mark images
im.className = "isJpeg";
// mark directories
} else if (im.href && im.href.match(/\/$/i)) {
im.className = "isDir";
}
}
return this.queue.length;
};
this.next = function() {
// if (this.reqs > 2) return;
this.reqs++;
if (this.ptr >= this.queue.length) {
this.stop();
return;
}
var img = this.queue[this.ptr++];
// image "loading"...
var thumb = img.firstChild;
thumb.src = _exifImgs.loading;
var ep = new ExifProcessor(img.href);
ep.execute(nailer(img, this));
var len = this.queue.length - this.ptr;
exifLog("Queue length: " + (len) +
" req: " + img.href);
document.getElementById("btnExifFetcher").innerHTML = "EXIF (" + len + ")";
// show DONE button and add mark read action
if (len == 0) {
_exDone();
}
};
this.stop = function() {
exifLog("Stopped.");
this.stopped = true;
replaceSiteIcon(_exifImgs.nothumb, "image/png");
};
this.done = function() {
this.reqs--;
if (this.stopped) return;
for (var i=this.reqs; i<this.MAX_PARALLEL; i++) {
this.next();
}
};
this.start = function() {
this.stopped = false;
for (var i=this.reqs; i<this.MAX_PARALLEL; i++) {
this.next();
}
};
}
// execute "get exif thumbnails"...
function _exExec() {
addGlobalStyle("a.isDir { color: #c00; }");
addGlobalStyle("a.isDir:visited { color: #e8e;; }");
addGlobalStyle("a {color: #00d; } ");
addGlobalStyle("a:visited { color: #e8e; }");
addGlobalStyle(".exifThumb { height:260px; border:1px solid black }");
var bdy = document.getElementsByTagName("body")[0].firstChild;
bdy.parentNode.style.backgroundColor = "silver";
var b = document.getElementById("btnExifFetcher");
if(! __q) {
__q = new exQueue();
var len = __q.init(false);
exifLog("Queue length: " + len);
b.innerHTML = "EXIF (" + len + ")";
}
__q.start();
b.removeEventListener("click", _exExec, false);
b.addEventListener("click", _exStop, false);
replaceSiteIcon(_exifImgs.loading, "image/gif");
}
function _exStop() {
__q.stop();
var b = document.getElementById("btnExifFetcher");
b.innerHTML = "EXIF (paused)";
b.addEventListener("click", _exExec, false);
b.removeEventListener("click", _exStop, false);
}
function _exDone() {
var b = document.getElementById("btnExifFetcher");
b.innerHTML = "DONE (mark all visited)";
b.addEventListener("click", markRead, false);
b.removeEventListener("click", _exStop, false);
}
// b.addEventListener("click", _exExec, false);
function _clean_free() {
// for free.fr:
var mydiv = document.getElementById('mydiv');
if (mydiv) {
unsafeWindow.repositionit = function() {
var mydiv = document.getElementById("mydiv");
mydiv.style.left = '-3000px';
};
mydiv.parentNode.removeChild(mydiv);
GM_log('_clean_free');
}
}
function replaceSiteIcon(source, type) {
var _links = document.getElementsByTagName('link');
for (var i = _links.length - 1; i >= 0; i--) {
var _l = _links[i];
if (_l.getAttribute('rel').indexOf('icon') > -1) {
_l.parentNode.removeChild(_l);
_l = null;
}
}
setTimeout( function() {
var head = document.getElementsByTagName("head")[0].firstChild;
var link = document.createElement("link");
link.setAttribute('rel', 'icon');
link.setAttribute('href', source);
link.setAttribute('type', type);
link.setAttribute('id', 'exifImgsSiteIcon');
head.parentNode.insertBefore(link, head);
}, 1);
}
function addGlobalStyle(css) {
var head, style;
head = document.getElementsByTagName('head')[0];
if (!head) { return; }
style = document.createElement('style');
style.type = 'text/css';
style.innerHTML = css;
head.appendChild(style);
}
function markRead() {
current_url = window.location.href;
for (var i = __ok.length; i >= 0; --i) {
history.replaceState({},"",__ok[i]);
}
history.replaceState({},"",current_url);
}
function markVisited(link) {
// store the current URL
current_url = window.location.href;
// use replaceState to push a new entry into the browser's history
history.replaceState({},"",link);
// use replaceState again to reset the URL
history.replaceState({},"",current_url);
}
// init
try {
GM_registerMenuCommand("EXIF Thumbnails", _exExec,
"e", "shift control", "e");
// _clean_free();
var t = document.getElementsByTagName("title");
var links = document.getElementsByTagName ('a');
var foundJPG = false;
for (var i in links) {
if (/\.jpe?g?$/i.test (links[i].href)) {foundJPG = true; break};
}
if (t.length && ((0 == t[0].firstChild.nodeValue.indexOf("Index of")) || (0 == t[0].firstChild.nodeValue.indexOf("Directory listing"))) && foundJPG) {
var globalCss = ".exifBtn { float:left;padding:6px;display:inline-block;border:1px solid white;margin:5px;background-color:teal;color:white;font-family:sans-serif;font-weight:bold;font-size:8pt;cursor:pointer; }";
globalCss += "\na.isJpeg { color: teal; }";
// globalCss += "\ntable { max-width: 500px; }";
// globalCss += "\nbody { padding-bottom: 3em; }";
addGlobalStyle(globalCss);
var b = document.createElement("a");
b.appendChild(document.createTextNode("Exif"));
// b.style.padding = "6px";
// b.style.display = "block";
// b.style.border = "1px solid white";
// // b.style.position = "absolute";
// // b.style.top = "5px";
// // b.style.left = "5px";
// b.style.cssFloat = "left";
// b.style.marginRight = "5px";
// b.style.backgroundColor = "teal";
// b.style.color = "white";
// b.style.opacity = 0.8;
// b.style.fontWeight = "900";
// b.style.fontFamily = "Arial,Helvetica";
// b.style.fontSize = "8pt";
// b.style.cursor = "pointer";
b.setAttribute("title", "Get EXIF Thumbnails");
b.setAttribute("id", "btnExifFetcher");
b.setAttribute("class", "exifBtn");
b.addEventListener("click", _exExec, false);
var bdy = document.getElementsByTagName("body")[0].firstChild;
bdy.parentNode.insertBefore(b, bdy);
b.focus();
}
} catch (e) {
// failed, no Apache index?
//GM_log(e);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment