E-Hentai Highlighter

Highlighter for E-Hentai (e-hentai.org/exhentai.org). Supports regular expressions.

As of 10. 02. 2015. See the latest version.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name           E-Hentai Highlighter
// @namespace      http://userscripts.org/users/106844
// @description    Highlighter for E-Hentai (e-hentai.org/exhentai.org). Supports regular expressions.
// @include        http://g.e-hentai.org/*
// @include        http://exhentai.org/*
// @grant          GM_getValue
// @grant          GM_setValue
// @version        0.5.5.1
// ==/UserScript==

// -------------------- DEFAULTS -------------------

var defaults = {
    defaultColor       : '#ec7e7e' ,
    exColor            : '#ed6464' ,
    highlighterEnabled : false     ,
    filterEnabled      : false     ,
    opacityEnabled     : false     ,
    opacity            : 0.1       ,
    showTags           : true      ,
    highlightTags      : true
};

// -------------------- /DEFAULTS -------------------

var EHH = {
    
    init: function() {

        EHH.augmentJS();

        // settings

        EHH.settings = { };
        for (var key in defaults)
            EHH.settings[key] = Utils.load(key,defaults[key]);

        EHH.dontWalk     = false;
        EHH.onPanda      = document.URL.indexOf('e-hentai') == -1;
        EHH.defaultColor = EHH.settings[EHH.onPanda ? 'exColor' : 'defaultColor'];
        EHH.thumbnails   = document.querySelector('.itg .id1') !== null;
        EHH.gallery      = document.querySelector('#taglist')  !== null;

        // User data
    
        EHH.keywords = Utils.load('keywords',[ ]);
        EHH.filters  = Utils.load('filters',[ ]);

        if (Utils.onFirefox()) Utils.migrateSettings();

        // Colors

        var colors = 
            EHH.onPanda ? { toggle: 'lightblue', toggleHover: 'lightcyan', disable: 'lightgreen',
                            disableHover: 'springgreen', enable: 'salmon', enableHover: 'lightsalmon',
                            row1: '#363940', row2: '#4F535B' }
                        : { toggle: 'slateblue', toggleHover: 'skyblue', disable: 'forestgreen',
                            disableHover: 'mediumseagreen', enable: 'indianred', enableHover: 'darkred',
                            row1: '#F2F0E4', row2: '#EDEBDF' };

        var format = function(text) { return text.replace(/%(\w+)/g,function(x) { return colors[x.slice(1)]; }); };
            
        // Permanent style
        
        var style = document.createElement('style');
        style.innerHTML = format(
            // popup (general)
            '#e-HentaiPopup {' +
                'position: fixed; top: 0; right: 0; padding: 3px; border-radius: 0 !important;' +
                'border: 1px black solid; z-index: 10; margin: 0 !important; min-width: 0 !important; width: auto !important;' +
            '}' +
            '#e-HentaiPopup:not(:hover) *:not(:first-child) { display: none; }' +
            '#e-HentaiPopup * {' +
                'font-family: Verdana, Tahoma, Georgia, Dejavu, "Times New Roman", Serif;' +
                'font-size: 10px;' +
            '} #e-Header { text-align: center; position: relative; }' +
            '[mode="default"] [mode="settings"], [mode="settings"] [mode="default"] { display: none; }' +
            '#e-ToggleMode {' +
                'cursor: pointer; color: %toggle !important; font-weight: bold;' +
                'position: absolute; right: 5px; border-bottom: 1px dotted;' +
            '}' +
            '#e-ToggleMode:hover { color: %toggleHover !important; }' +
            '#e-HentaiPopup div[mode] { width: 350px; text-align: left; }' +
            // popup (default view)
            '#e-HentaiPopup td:nth-child(2) { text-align: right; }' +
            '#e-HentaiPopup td:nth-child(2) a, #e-HentaiPopup tr:last-child a {' +
                'cursor: pointer; font-weight: bold; border-bottom: 1px dotted;' +
            '}' +
            '#e-HentaiPopup .e-Disable { color: %disable; }' +
            '#e-HentaiPopup .e-Disable:hover { color: %disableHover; }' +
            '#e-HentaiPopup .e-Enable { color: %enable; }' +
            '#e-HentaiPopup .e-Enable:hover { color: %enableHover; }' +
            '#e-HentaiPopup tr:last-child a:hover { color: black; }' +
            '#e-HentaiPopup td > span { margin-right: 5px; float: right; }' +
            '#e-HentaiPopup table { width: 100%; }' +
            '#e-HentaiPopup textarea { width: 100%; height: 200px; box-sizing: border-box; -moz-box-sizing: border-box; }' +
            // popup (settings view)
            '#e-HentaiPopup label { display: block; padding: 2px; }' +
            '#e-HentaiPopup input[type="checkbox"] { margin: 0 5px 0 0; float: left; }' +
            '[name="slider"]:not([visible="true"]), [name="slider"]:not([visible="true"]) + span { display: none; }' +
            '[name="slider"] { margin-left: 20px; width: 250px; }' +
            '[name="slider"] + span { position: relative; bottom: 7px; }' +
            '#e-HentaiPopup [mode="settings"] { padding: 10px; box-sizing: border-box; -moz-box-sizing: border-box; }' +
            '#e-Buttons { text-align: center; padding-top: 20px; }' +
            '.e-Button {' +
                'min-width: 100px; height: 25px; line-height: 25px; text-align: center; color: white;' +
                'background: black; display: inline-block; cursor: pointer; margin-right: 10px;' +
            '}' +
            '.e-Button:hover { text-decoration: underline; }' +
            '.e-Button + input { display: none; }' +
            '#e-PickerLabel { margin-top: 10px; }' +
            '#e-ColorPicker + div { width: 30px; height: 18px; display: inline-block; margin-left: 10px; vertical-align: top; }' +
            // highlight/filter style
            '.e-Highlighted b { font-weight: inherit; }' +
            '.e-Highlighted, .e-Highlighted a, [id^="ta_"][style*="background"] { color: black !important; }' +
            '.e-Highlighted b { font-weight: bold !important; font-size: 115%; text-decoration: underline; }' +
            // tag divs
            '.e-Tags { position: absolute; top: 0px; left: 0px; text-align: left; color: black; ' +
                'margin-left: 1px; text-shadow: -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff;' +
                'font-weight: bold; font-family: "Segoe UI"; font-size: 12px; line-height: 11px;' +
            '}' +
            '.e-Tags > div { padding: 3px; max-width: 70px; overflow: hidden; transition: max-width .5s linear;' +
                'white-space: nowrap; }' +
            '.id3:hover .e-Tags > div { max-width: 200px !important; }'

        );
        document.head.appendChild(style);

        // Mutable style
            
        EHH.opaqueFilterCSS = document.createElement('style');
        EHH.opaqueFilterCSS.id = 'e-OpaqueFilter';
        EHH.opaqueFilterCSSMask = format('{0}#toppane ~ .c, .e-Filtered { display: none !important; }\n' +
            '{0}tr.color1 { background: %row1 }\n' +
            '{0}tr.color0 { background: %row2 }\n' +
            '{1}#toppane ~ .c, .e-Filtered { opacity: {opacity} !important;}\n' +
            '{1}.e-Filtered:hover { opacity: 1 !important; -webkit-transition: opacity .1s linear;' +
                '-moz-transition: opacity .1s linear; -o-transition: opacity .1s linear; }');
        document.head.appendChild(EHH.opaqueFilterCSS);

        // Popup

        EHH.generatePopup();

        // Events
        
        document.addEventListener('DOMNodeInserted',function(e) {
            if (e.target.nodeName == 'TBODY')
                EHH.walk(e.target);
        },false);

        if (EHH.gallery)
            document.addEventListener('DOMNodeInserted',EHH.updateTagList,false);

        if (!EHH.gallery && !EHH.thumbnails)
            EHH.interceptMouseHover();

        // Data synchronization

        EHH.link('keywords'     , 'keywords' , EHH.updatePopup , EHH.clearRegexes  , EHH.walk);
        EHH.link('filters'      , 'filters'  , EHH.updatePopup , EHH.clearRegexes  , EHH.walk);
        EHH.link('defaultColor' , null       , EHH.updatePopup , EHH.walk);

        EHH.settings.link('defaultColor'       , 'defaultColor');
        EHH.settings.link('exColor'            , 'exColor'     );
        EHH.settings.link('filterEnabled'      , 'filterEnabled'      , EHH.updatePopup , EHH.toggleOpacity , EHH.walk);
        EHH.settings.link('highlighterEnabled' , 'highlighterEnabled' , EHH.updatePopup , EHH.walk);
        EHH.settings.link('opacityEnabled'     , 'opacityEnabled'     , EHH.updatePopup , EHH.toggleOpacity , EHH.resize);
        EHH.settings.link('opacity'            , 'opacity'            , EHH.updatePopup , EHH.toggleOpacity , EHH.resize);
        EHH.settings.link('showTags'           , 'showTags'           , EHH.toggleTagDivs);
        EHH.settings.link('highlightTags'      , 'highlightTags'      , EHH.highlightTags);

        // Start
        
        EHH.toggleOpacity();
        EHH.walk();

    },
    
    augmentJS: function() {

        /*Object.getOwnPropertyNames(Array.prototype).forEach(function(x) {
            NodeList.prototype[x] = Array.prototype[x];
        });*/

        var linkedObjects = { };
        Object.defineProperty(Object.prototype,'link', {
            enumerable   : false,
            configurable : false,
            writable     : false,
            value        : function(localProperty,storedProperty,onChangeCallbacks) {
                var currentValue = this[localProperty], args = arguments;
                var get = function() { return currentValue; };
                var set = function(value)  {
                    currentValue = value;
                    if (storedProperty) Utils.save(storedProperty,currentValue);
                    for (var i=2;i<args.length;++i) {
                        if (args[i])
                            args[i](currentValue);
                    }
                };
                delete this[localProperty];
                var descriptor = { get: get, set: set, enumerable: true, configurable: true };
                Object.defineProperty(this,localProperty,descriptor);
                linkedObjects[storedProperty] = { object: this, key: localProperty };
            }
        });

        if (!Utils.onFirefox()) {
            window.addEventListener('storage',function(e) {
                if (!linkedObjects.hasOwnProperty(e.key)) return;
                var target = linkedObjects[e.key];
                target.object[target.key] = JSON.parse(e.newValue);
            },false);
        }


    },

    generatePopup: function() {
            
        EHH.popup = document.createElement('div');
        EHH.popup.id = 'e-HentaiPopup';
        EHH.popup.className = 'ido';
        EHH.popup.setAttribute('mode','default');
        EHH.popup.innerHTML =
            '<div id="e-Header">' +
                '<b>E-H Highlighter</b>' +
                '<a id="e-ToggleMode" target="settings">Show settings</a>' +
            '</div><hr/>' +
            '<div mode="default">' +
                '<table align="right">' +
                    '<tr><td style="text-align:left">Keywords:</td><td><a id="e-HighlighterSwitch">Highlighter: enabled</a></tr>' +
                    '<tr><td colspan="2"><textarea></textarea></td></tr>' +
                    '<tr><td style="text-align:left">Filters:</td><td><a id="e-FilterSwitch">Filter: enabled</a></td></tr>' +
                    '<tr><td colspan="2"><textarea></textarea></td></tr>' +
                    '<tr><td colspan="2"><a id="e-PopupSave">Save changes</a><span><b>Filtered items:</b> <span id="e-FilteredItems"></span></span></td></tr>' +
                '</table>' +
            '</div>' +
            '<div mode="settings">' +
                '<label><input type="checkbox" id="opacitySwitch"> Enable opacity mode for filtered items</label>' +
                '<input type="range" name="slider" min="0" max="100"> <span></span>' +
                '<label><input type="checkbox" id="tagDivSwitch">Display any tags matching one or more highlight keywords in front of the gallery thumbnails</label>' +
                '<label><input type="checkbox" id="highlightTagSwitch">Apply highlighting and filters to each gallery\'s tag list</label>' +
                '<label id="e-PickerLabel"><input type="checkbox" style="visibility: hidden">Default highlight color: <input type="color" id="e-ColorPicker"> <div></div></label>' + 
                '<div id="e-Buttons">' +
                    '<div class="e-Button" id="e-Export">Export data</div>' +
                    '<div class="e-Button" id="e-Import">Import data</div><input type="file" accept="application/json">' +
                '</div>' +
            '</div>';
        document.body.appendChild(EHH.popup);
        
        // Popup elements

        EHH.highlighterSwitch = document.getElementById('e-HighlighterSwitch');
        EHH.filterSwitch      = document.getElementById('e-FilterSwitch');

        var textareas         = Utils.query('#e-HentaiPopup textarea');
        EHH.highlighterArea   = textareas[0];
        EHH.filterArea        = textareas[1];
        
        // Events (default view)

        Utils.onClick(document.getElementById('e-ToggleMode'),function() {
            EHH.popup.setAttribute('mode',this.getAttribute('target'));
            var showSettings = this.getAttribute('target') == 'settings';
            this.innerHTML = (showSettings ? 'Show keywords' : 'Show settings');
            this.setAttribute('target',showSettings ? 'default' : 'settings');
        });
        
        [EHH.highlighterSwitch,EHH.filterSwitch].forEach(function(x) {
            Utils.onClick(x,function() {
                var target = /Highlighter/.test(this.textContent) ? 'highlighterEnabled' : 'filterEnabled';
                var status = /enabled/.test(this.textContent);
                EHH.settings[target] = !status;
            });
        });
        
        Utils.onClick(document.getElementById('e-PopupSave'),function() {
            var validate = function(regexes) { for (var i=0;i<regexes.length;++i) new RegExp(regexes[i]); };
            var keywords = EHH.highlighterArea.value.split(/[;\n]/).filter(function(x) { return x.length > 0; });
            var filters  = EHH.filterArea.value.split(/[;\n]/).filter(function(x) { return x.length > 0; });
            try {
                validate(keywords);
                validate(filters);
                EHH.dontWalk = true;
                EHH.keywords = keywords;
                EHH.filters = filters;
                EHH.dontWalk = false;
                EHH.walk();
            } catch (e) {
                alert('Couldn\'t parse keyword. ' + e.message + '\nSettings have NOT been saved.');
            }
        });

        // Events (settings view)
        
        Utils.linkCheckbox(document.getElementById('opacitySwitch'),EHH.settings,'opacityEnabled');
        Utils.linkCheckbox(document.getElementById('tagDivSwitch'),EHH.settings,'showTags');
        Utils.linkCheckbox(document.getElementById('highlightTagSwitch'),EHH.settings,'highlightTags');

        // Opacity slider

        EHH.slider = EHH.popup.querySelector('[name="slider"]');
        if (EHH.slider.type != 'range') EHH.slider = null; // not supported
        else {
            EHH.slider.value = EHH.settings.opacity * 100;
            EHH.slider.nextElementSibling.innerHTML = (Math.floor(EHH.settings.opacity * 10000) / 100) + '%';
            EHH.slider.addEventListener('change',function(e) {
                e.target.nextElementSibling.innerHTML = e.target.value + '%';
                EHH.settings.opacity = parseInt(e.target.value,10) / 100;
            },false);
        }

        // Color picker

        var picker    = document.getElementById('e-ColorPicker'),
            preview   = picker.nextElementSibling,
            supported = picker.type == 'color';

        picker.value = preview.style.backgroundColor = EHH.defaultColor;
        if (supported) picker.style.cssText = 'padding: 0px; border: 0px; background: none; position: relative; top: 3px';
        else picker.style.cssText = 'width: 60px; color: black';
        preview.style.cssText = (supported ? 'display: none;' : 'background-color: ' + EHH.defaultColor);

        var lastColor = preview.style.backgroundColor;
        picker.addEventListener(supported ? 'change' : 'input',function() {
            preview.style.backgroundColor = picker.value;
            if (preview.style.backgroundColor == lastColor) return;
            lastColor = preview.style.backgroundColor;
            EHH.settings[EHH.onPanda ? 'exColor' : 'defaultColor'] = picker.value;
            EHH.defaultColor = picker.value;
        },false);

        // Import-export functions

        var importButton = document.getElementById('e-Import'), importInput = importButton.nextElementSibling;
        Utils.onClick(importButton,function() { importInput.click(); });
        importInput.addEventListener('change',function(e) {
            var reader = new FileReader();
            reader.onerror = function(e) { alert('Couldn\'t read the selected file.'); };
            reader.onload = function(e) {
                try {
                    var data = JSON.parse(this.result);
                    if (!data.keywords || !data.filters || !data.settings) throw null;
                    var confirmation = confirm('This will overwrite your data. Are you sure you want to proceed?');
                    if (confirmation) {
                        EHH.dontWalk = true;
                        EHH.keywords = data.keywords;
                        EHH.filters  = data.filters;
                        for (var key in EHH.settings) EHH.settings[key] = data.settings[key];
                        setTimeout(function() { window.location.reload(); },50);
                    }
                }
                catch (_) { alert('Couldn\'t recognize the selected file.'); }
            };
            reader.readAsText(this.files[0]);
        },false);

        Utils.onClick(document.getElementById('e-Export'),function() {
            var result = { keywords: EHH.keywords, filters: EHH.filters, settings: EHH.settings };
            var blob = new Blob([JSON.stringify(result,null,2)],{ type: 'application/json' });
            var a = document.createElement('a');
            a.href = URL.createObjectURL(blob);
            a.download = 'EHH.settings.' + (new Date().valueOf()) + '.json';
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
        });

        EHH.updatePopup();

    },

    updatePopup: function() {

        var updateSwitch = function(target,enable) {
            target.textContent = target.textContent.replace(/[^\s]+$/,enable ? 'enabled' : 'disabled');
            target.className   = enable ? 'e-Disable' : 'e-Enable';
            var filtered = document.getElementsByClassName('e-Filtered').length;
            document.getElementById('e-FilteredItems').textContent = filtered;
        };

        EHH.highlighterArea.textContent = EHH.keywords.join('\n');
        EHH.filterArea.textContent      = EHH.filters.join('\n');
        updateSwitch(EHH.highlighterSwitch,EHH.settings.highlighterEnabled);
        updateSwitch(EHH.filterSwitch,EHH.settings.filterEnabled);

        document.getElementById('opacitySwitch').checked = EHH.settings.opacityEnabled;

    },

    toggleOpacity: function() {
        // changes the mutable style to enable or disable opacity
        EHH.opaqueFilterCSS.innerHTML = EHH.opaqueFilterCSSMask
                                           .replace(/\{0\}/g,EHH.settings.opacityEnabled ? '//' : '')
                                           .replace(/\{1\}/g,EHH.settings.opacityEnabled ? '' : '//')
                                           .replace(/\{opacity\}/,EHH.settings.opacity);
        if (EHH.slider) EHH.slider.setAttribute('visible',EHH.settings.opacityEnabled);
    },

    toggleTagDivs: function() {
        Utils.query('.e-Tags').forEach(function(x) {
            x.style.display = EHH.settings.showTags ? null : 'none';
        });
    },

    clearRegexes: function() {
        EHH.parse.regexes = null;
    },
    
    prepareRegexes: function() {
    
        /* returns an object containing two properties:
         *     highlight : an array of keywords; each element is an object with a "type" property ("tag" or "title"),
         *                 a "regex" property and a "color" property (null if no color is specified for that keyword)
         *                 keywords will be checked sequentially; the first matching keyword with a color specified
         *                 will decide the item's color; if no color is found but the item still has to be
         *                 highlighted, EHH.defaultColor will be used instead
         *     filters   : an object with two properties (tag and title), each one a regular expression to
         *                 be applied to the relevant target
         */

        var splitFilter = function(x,target) {
            if (x.length > 0 && x[0] == ':') target.tag.push(x.slice(1));
            else if (x.length > 0) target.title.push(x);
        };

        var highlight = EHH.keywords.map(function(x) {
            var temp = x[0] == ':' ? { keyword : x.slice(1), type : 'tag'   } :
                                     { keyword : x,          type : 'title' };
            var tokens = temp.keyword.match(/^(.+?)(\/[^\/]+)?$/);
            return { regex: new RegExp(tokens[1],'gi'), type: temp.type, color: tokens[2] ? tokens[2].slice(1) : null };
        });

        var filters = { title: [ ], tag: [ ] };
        EHH.filters.forEach(function(x) {
            if (x[0] == ':') filters.tag.push(x.slice(1));
            else filters.title.push(x);
        });

        if (filters.title.length === 0) filters.title.push('EHH: no active title filter');
        if (filters.tag.length === 0) filters.tag.push('EHH: no active tag filter');

        filters.title = new RegExp('(' + filters.title.join('|') + ')','i');
        filters.tag   = new RegExp('(' + filters.tag.join('|') + ')','i');

        return { highlight: highlight, filters: filters };

    },

    parse: function(title,tags) {

        /* title : a string, the title of the gallery item to be parsed
         * tags  : an array of objects, each one a tag contained in a tag flag
         * uses the object built by EHH.prepareRegexes to decide the course of action for each gallery
         * returns an object with a "result" property indicating what has to be done
         * possible values are "highlighted", "filtered" or null for no action
         * filters take precedence over highlight keywords
         * if the gallery item is to be highlighted, the result will also contain three additional properties:
         * - titleKeywords : a list of title substrings that match one or more keywords (can be empty)
         * - tagKeywords   : a list of matching tags (to be passed to EHH.addTagDiv if EHH.settings.showTags is set, can be empty)
         * - color         : the color the gallery needs to be highlighted in (EHH.defaultColor if the user did not specify any color)
         */

        if (!EHH.parse.regexes)
            EHH.parse.regexes = EHH.prepareRegexes();

        var regexes = EHH.parse.regexes;

        if (EHH.settings.filterEnabled) {
            var filtered = regexes.filters.title.test(title) || tags.some(function(x) { return regexes.filters.tag.test(x.tag); });
            if (filtered) return { result: 'filtered' };
        }

        if (EHH.settings.highlighterEnabled) {
            var titleKeywords = { }, tagKeywords = { }, color = null;
            regexes.highlight.forEach(function(data) {
                if (data.type == 'tag') {
                    tags.forEach(function(tag) {
                        if (tagKeywords.hasOwnProperty(tag.tag) || !tag.tag.match(data.regex)) return;
                        tagKeywords[tag.tag] = tag.color;
                        if (!color) color = data.color;
                    });
                } 
                else {
                    var tokens = title.match(data.regex);
                    if (!tokens) return;
                    tokens = tokens.length == 1 ? tokens : tokens.slice(1);
                    for (var i=0;i<tokens.length;++i) titleKeywords[tokens[i]] = true;
                    if (!color) color = data.color;
                }
            });

            titleKeywords = Object.keys(titleKeywords);
            
            if (titleKeywords.length === 0 && Object.keys(tagKeywords).length === 0) return { result: null };
            return { result: 'highlighted', titleKeywords: titleKeywords, tagKeywords: tagKeywords, color: color || EHH.defaultColor };
        }

        return { result: null };

    },

    computeTagColor: function(tag) {
        // don't use style.backgroundPositionY
        var y = parseInt(tag.style.cssText.split(/\s/).slice(-1)[0],10);
        y = -(y+1) / 17;
        return ['salmon','darkorange','gold','mediumaquamarine','skyblue','mediumorchid'][y];
    },

    // extracts both the title and a list of tags for a given target
    // tags are parsed into objects with "tag" and "color" properties representing
    // respectively the tag itself and the color of their associated tag flag
    extractData: function(target) {

        if (!target) return null;

        var title = target.querySelector('.it5 > a, .id2 > a, [class^="t2"] > a') || target.querySelector('.itd a'),
            tags  = Utils.query(target,'.tft, .tfl');

        if (!title && target.className.indexOf('t2') === 0) title = target.firstChild;
        if (!title) return null;

        if (tags.length > 0)
            tags = tags
                .map(function(x) {
                    var color = EHH.computeTagColor(x);
                    return x.title.split(/, /).map(function(y) { return { tag: y, color: color }; });
                })
                .reduce(function(x,y) { return x.concat(y); });

        return { title: title, tags: tags };

    },

    addTagDiv: function(target,tags) {
        if (!tags || Object.keys(tags).length === 0) return;
        var div = document.createElement('div');
        div.className = 'e-Tags';
        var html = '';
        for (t in tags) {
            var tag = t.replace(/^.+:/,''), color = tags[t];
            html += '<div style="background-color: ' + color + '">' + tag + '</div>';
        }
        div.innerHTML = html;
        if (!EHH.settings.showTags) div.style.display = 'none';
        var temp = target.getElementsByClassName('id3')[0];
        if (temp) temp.firstElementChild.appendChild(div);
        else target.appendChild(div);
    },
    
    walk: function(root) {

        /* walks the DOM to highlight and filter gallery items (or the taglist) */

        if (EHH.gallery) {
            EHH.highlightTags();
            return;
        }

        if (EHH.dontWalk) return;

        var removeTagDiv = function(target) {
            Utils.query(target,'.e-Tags').forEach(function(x) { x.parentNode.removeChild(x); });
        };

        var editTitle = function(target,keywords) {
            var temp = target.innerHTML;
            keywords.forEach(function(keyword) {
                var length = keyword.length, n = target.innerHTML.indexOf(keyword);
                while (n != -1) {
                    temp = temp.slice(0,n) + new Array(length+1).join('\0') + temp.slice(n+length);
                    n = target.innerHTML.indexOf(keyword,n+1); 
                }
            });
            return temp.replace(/\0+/g,function(match,start) {
                return '<b>' + target.innerHTML.slice(start,start+match.length) + '</b>';
            });
        };

        // ----------
 
        var flip    = 1,
            targets = Utils.query('[class^="gtr"], [class^="id1"], [class^="t2"]');

        targets.forEach(function(target) {

            var data = EHH.extractData(target);
            if (data === null) return;

            // reset element
            target.className     = target.className.replace(/\s?e-\w+/g,'');
            target.style.cssText = target.style.cssText.replace(/background-color:.+?;/,'');
            data.title.innerHTML      = data.title.innerHTML = data.title.innerHTML.replace(/<\/?b>/g,'');
            removeTagDiv(target);

            var parsed = EHH.parse(data.title.textContent,data.tags);

            if (parsed.result == 'filtered') target.className += ' e-Filtered';

            if (parsed.result == 'highlighted') {
                target.className     += ' e-Highlighted';
                target.style.cssText += 'background-color: ' + parsed.color + ' !important;'; 
                data.title.innerHTML  = editTitle(data.title,parsed.titleKeywords);
                if (EHH.thumbnails) EHH.addTagDiv(target,parsed.tagKeywords);
            }

            if (!/^gtr/.test(target.className)) return;
            if (parsed.result == 'filtered' && !EHH.opaque) return;
            flip = (flip+1)%2;
            if (target.className.indexOf('color') == -1) target.className += ' color' + flip;
            else target.className = target.className.replace(/color\d/,'color' + flip);

        });
        
        var filtered = document.getElementsByClassName('e-Filtered').length;
        document.getElementById('e-FilteredItems').textContent = filtered;

        EHH.resize();
        
    },

    resize: function() {

        /* makes sure that thumbnails are displayed correctly (only necessary when filtering is enabled) */

        if (!EHH.thumbnails) return;

        var targets = document.getElementsByClassName('id1'), n = targets.length;
        var queue = [], map = function(x) { return [x,parseInt(x.style.height)]; };

        for (var i=0;i<n;++i) {

            if (!EHH.settings.filterEnabled || EHH.opaque) {
                targets[i].style.marginBottom = null;
                continue;
            }

            if (targets[i].className.indexOf('e-Filtered') != -1) continue;

            queue.push(targets[i]);
            if (queue.length < 5) continue;

            queue = queue.map(map);
            var max = queue[0][1];
            for (var j=1;j<5;++j) max = Math.max(max,queue[j][1]);
            for (j=0;j<5;++j) {
                if (queue[j][1] == max) queue[j][0].style.marginBottom = null; 
                else queue[j][0].style.marginBottom = (max-queue[j][1]+2) + 'px';
            }
            queue = [];

        }

    },

    highlightTags: function() {
        Utils.query('[id^="ta_"]').forEach(function(x) {
            if (!EHH.settings.highlightTags) x.style.cssText = '';
            else {
                var fullName = x.id.slice(3).replace(/_/g,' ');
                var data = EHH.parse(fullName,[{ tag: fullName, color: null }]);
                x.style.cssText = data.result == 'filtered'    ? 'text-decoration: line-through'                   :
                                  data.result == 'highlighted' ? 'background-color: ' + data.color + ' !important' :
                                                                 '';
            }
        });
    },

    updateTagList: function(e) {
        if (e.target.nodeName != 'TABLE' || e.target.parentNode.id != 'taglist') return;
        if (EHH.settings.highlightTags) EHH.highlightTags();
    },

    interceptMouseHover: function(e) {
        var observer = new MutationObserver(function(e) {
            var thumbnail = e[0].target;
            if (thumbnail.style.visibility == 'hidden')
                Utils.query(thumbnail,'.e-Tags').forEach(function(x) { x.parentNode.removeChild(x); });
            else if (EHH.settings.showTags) {
                var row  = document.evaluate('ancestor::tr[1]',thumbnail,null,9,null).singleNodeValue,
                    data = EHH.extractData(row);
                if (data === null) return;
                parsed = EHH.parse(data.title.textContent,data.tags);
                if (parsed.result == 'highlighted') EHH.addTagDiv(thumbnail,parsed.tagKeywords);
            }
        });
        Utils.query('.it2[id^="i"]').forEach(function(x) {
            observer.observe(x,{ attributes: true });
        });
    }

};

var Utils = {

    onFirefox: function() {
        return (window.chrome === undefined);
    },

    migrateSettings: function() { // this is only run on Firefox
        if (localStorage.getItem('migrationDone') !== null) return;
        for (var key in EHH.settings) {
            if (localStorage.getItem(key) === null) continue;
            EHH.settings[key] = JSON.parse(localStorage.getItem(key));
            Utils.save(key,EHH.settings[key]);
            localStorage.removeItem(key);
        }
        ['keywords','filters'].forEach(function(key) {
            if (localStorage.getItem(key) === null) return;
            EHH[key] = JSON.parse(localStorage.getItem(key));
            Utils.save(key,EHH[key]);
           localStorage.removeItem(key);
        });
        localStorage.setItem('migrationDone','true');
    },

    save: function(key,value) {
        if (Utils.onFirefox()) GM_setValue(key,JSON.stringify(value));
        else localStorage.setItem(key,JSON.stringify(value));
    },

    load: function(key,def) {
        var result = Utils.onFirefox() ? GM_getValue(key,null) : localStorage.getItem(key);
        return result ? JSON.parse(result) : def;
    },

    onClick: function(element,f) {
        element.addEventListener('click',function(e) {
            if (e.which != 1) return;
            if (f) f.call(this);
        },false);
    },

    linkCheckbox: function(checkbox,object,property) {
        if (object && object.hasOwnProperty(property))
            checkbox.checked = object[property];
        Utils.onClick(checkbox,function() { object[property] = checkbox.checked; });
    },

    query: function(root,selector) {
        if (!selector)
            return Array.prototype.slice.call(document.querySelectorAll(root),0);
        else
            return Array.prototype.slice.call(root.querySelectorAll(selector),0);
    }
    
};

EHH.init();