ConnorWiseman/jcink-custom-structure

View on GitHub
src/cs.js

Summary

Maintainability
F
2 wks
Test Coverage
/**
 * @file
 * A DOM manipilation utility library for the version of IPB running on the
 * free forum hosting service, Jcink, that reads table information and accepts
 * a user-defined template for text replacement. Allows for the structuring
 * of forums in nontraditional, table-less layouts. Visible credits are not
 * required provided this entire comment block remains intact.
 * @author      Connor Wiseman
 * @copyright   2012-2016 Connor Wiseman
 * @version     1.8.2 (September 2016)
 * @license
 * Copyright (c) 2012-2016 Connor Wiseman
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the 'Software'), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */

 // Enforce strict interpretation for compatibility reasons.
'use strict';


/**
 * @namespace
 */
var $cs = $cs || {
    /**
     * Extends the prototype of a child object given a parent object to
     * inherit from.
     * @arg {object} child
     * @arg {object} parent
     * @readonly
     */
    extendModule: function(child, parent) {
        child.prototype = new parent;
        child.prototype.constructor = child.prototype.myParent;
    },


    /**
     * @namespace
     * @property {object} module
     * @property {object} module.Default
     * @property {object} module.Index
     * @property {object} module.Stats
     * @property {object} module.Profile
     * @property {object} module.Topics
     * @property {object} module.Posts
     * @readonly
     */
    module: {
        Default: function() {},
        Index:   function() {},
        Stats:   function() {},
        Profile: function() {},
        Topics:  function() {},
        Posts:   function() {}
    }
};


/**
 * @property {boolean} time     - Whether or not to run performance timers on script execution.
 */
$cs.module.Default.prototype.time = false;


/**
 * @property {string} html      - User-defined HTML markup for replacement.
 */
$cs.module.Default.prototype.html = '';


/**
 * @property {object} values    - Script-defined keys mapped to user-defined values for replacement.
 */
$cs.module.Default.prototype.values = {};


/**
 * Returns whether a given element contains the specified class name.
 * @arg {object} el
 * @arg {string} class
 * @return {boolean}
 * @link http://stackoverflow.com/a/5898748/2301088
 */



/**
 * Retrieves the value of the specified key from the existing values.
 * @arg {string} key            - The name of the key to retrieve.
 * @return {string}             - The value associated with the key.
 * @readonly
 */
$cs.module.Default.prototype.getValue = function(key) {
    var key = this.config.keyPrefix + key + this.config.keySuffix;
    return this.values[key];
};


/**
 * Checks the existing values for the presence of a specified key.
 * @arg {string} key            - The name of the key to check for.
 * @return {boolean}            - True if value exists, false otherwise.
 * @readonly
 */
$cs.module.Default.prototype.hasValue = function(key) {
    var key = this.config.keyPrefix + key + this.config.keySuffix;
    return (this.values[key] && this.values[key] !== '');
};


/**
 * Initialization function. Reads user-defined settings in for processing and begins script execution.
 * @arg {object} settings       - An object with user-defined settings as properties.
 * @readonly
 */
$cs.module.Default.prototype.initialize = function(settings) {
    // Make sure we have an object to work with.
    settings = settings || {};

    // If we have an empty settings object, display an error message and return false.
    if (!Object.keys(settings).length) {
        console.error(this.name + ': init method missing required argument "settings"');
        return false;
    }

    /*
        For each key in our settings object, if it's not in the list of reserved names,
        overwrite the properties (and methods, I suppose) in the module.
     */
    for (var key in settings) {
        if (this.reserved.indexOf(key) === -1) {
            // Go one level deeper if one of the properties is an object.
            if (typeof settings[key] == 'object') {
                for (var subkey in settings[key]) {
                    if (key in this) {
                        this[key][subkey] = settings[key][subkey];
                    }
                }
            } else if (key in this) {
                this[key] = settings[key];
            }
        }
    }

    /*
        If this.html isn't null or empty, execute the script. Otherwise, display an
        error message and return false.
     */
    if (this.html && this.html !== '') {
        // Execution timers.
        if (this.time) {
            console.time(this.name);
        }

        // Reinitialize the values object so it's blank for the next pass.
        this.values = {};
        this.execute();

        // Execution timers.
        if (this.time) {
            console.timeEnd(this.name);
        }
    } else {
        console.error(this.name + ': required property "html" is undefined');
        return false;
    }
};


/**
 * String replacement function.
 * @arg {*} string|object       - A text string, or function that returns a text string, for replacement.
 * @arg {object} object         - An object of keys and values to use during replacement.
 * @return {string}
 * @readonly
 */
$cs.module.Default.prototype.replaceValues = function(string, object) {
    string = (typeof string == 'function') ? string.call(this) : string;
    if (typeof string == 'undefined') {
        console.error(this.name + ': function "html" returns null');
        return null;
    }
    // Join the keys with the pipe character for regular expression matching.
    var regex = new RegExp(Object.keys(object).join('|'), 'g');
    // Find and replace the keys with their associated values, then return the string.
    return string.replace(regex, function(matched) {
        return object[matched];
    });
};


/**
 * Sets a specified key to a specified value.
 * @arg {string} key            - The key to set.
 * @arg {*}      value          - The value to be set.
 * @readonly
 */
$cs.module.Default.prototype.setValue = function(key, value) {
    this.values[this.config.keyPrefix + key + this.config.keySuffix] = value;
}


// Extend the custom index module with the default properties and methods.
$cs.extendModule($cs.module.Index, $cs.module.Default);


/**
 * @namespace
 * @property {object} config                   - Default configuration values.
 * @property {string} config.target            - The default container element.
 * @property {string} config.keyPrefix         - The default prefix for value keys.
 * @property {string} config.keySuffix         - The default suffix for value keys.
 * @property {string} config.insertBefore      - The default content to be inserted before a new category.
 * @property {string} config.insertAfter       - The default content to be inserted after a new category.
 * @property {string} config.subforumSeparator - The default subforum separator.
 * @property {string} config.subforumsNone     - The default indicator for no subforums.
 * @property {string} config.moderatorsNone    - The default indicator for no moderators.
 * @property {string} config.dateDefault       - The default date for last posts.
 * @property {string} config.titleDefault      - The default title for last posts.
 * @property {string} config.urlDefault        - The default URL for last posts.
 * @property {string} config.authorDefault     - The default author for last posts.
 * @property {string} config.passwordTitle     - The default title of topics in password-protected forums.
 */
$cs.module.Index.prototype.config = {
    target:             'board',
    keyPrefix:          '{{',
    keySuffix:          '}}',
    insertBefore:       '',
    insertAfter:        '',
    viewingDefault:     '0',
    subforumSeparator:  ', ',
    subforumsNone:      '',
    moderatorsNone:     '',
    dateDefault:        '--',
    titleDefault:       '----',
    urlDefault:         '#',
    authorDefault:      '',
    passwordTitle:      'Protected Forum'
};


/**
 * @property {string} name      - The name of this module.
 */
$cs.module.Index.prototype.name = '$cs.module.Index';


/**
 * @property {object} reserved  - An array of reserved names.
 */
$cs.module.Index.prototype.reserved = [
    'values',
    'makeLink',
    'execute',
    'getValue',
    'hasValue',
    'initialize',
    'readTable',
    'replaceValues',
    'setValue'
];


/**
 * Executes the checks and loops needed to complete the script.
 * @readonly
 */
$cs.module.Index.prototype.execute = function() {
    /*
        Acquire the container, if one is specified, and then see which kind
        of page we're looking at.
     */
    var container = document.getElementById(this.config.container),
        subforumlist = document.getElementById('subforum-list'),
        categories = (container || document).getElementsByClassName('category');
    if (categories.length) {
        /*
            If there are categories, we're looking at the forum index.
            Loop through each category's table individually.
         */
        for (var i = 0; i < categories.length; i++) {
            var category = categories[i].lastChild.previousSibling,
                table = category.firstElementChild;
            this.readTable(table, i)
        }
    } else if (subforumlist) {
        /*
            If there's a subforumlist, we're inside a forum with only
            one category and table to deal with. No looping necessary.
         */
        var table = subforumlist.firstElementChild;
        this.readTable(table, 0);
    }
};


/**
 * Initialization function. Reads user-defined settings in for processing and begins script execution.
 * @arg {object} settings       - An object with user-defined settings as properties.
 * @readonly
 */
$cs.module.Index.prototype.initialize = function(settings) {
    // Call $cs.module.Default's initialize method instead.
    $cs.module.Default.prototype.initialize.call(this, settings);
};


/**
 * Converts an anchor element to flat HTML markup.
 * @arg {object} element
 * @return {string}
 * @readonly
 */
$cs.module.Index.prototype.makeLink = function(element) {
    return '<a href=\'' + element + '\'>' + element.innerHTML + '<\/a>';
};


/**
 * Reads the rows from a supplied table, generates a new category from the user-defined HTML template, and
 * injects the new category into the page.
 * @arg {object} table          - A reference to an HTML table object.
 * @arg {number} index          - The numerical index of the current category.
 * @readonly
 */
$cs.module.Index.prototype.readTable = function(table, index) {
    // Acquire all the rows in the table.
    var rows = table.getElementsByTagName('tr');

    // Temporarily hide the table. It will be removed altogether later on.
    table.style.display = 'none';

    // Create a variable to store the HTML output of the following loop.
    var categoryContent = '';

    // Add any content intended to be inserted before every category.
    if (this.config.insertBefore && this.config.insertBefore !== '') {
        categoryContent += '<div class="new-category-before">' + this.config.insertBefore + '</div>';
    }

    /*
        Loop through each row in the table except the first and the last,
        which are only used for layout and are useless for this script.
     */
    for (var j = 1, numRows = rows.length - 1; j < numRows; j++) {
        // Acquire all the cells in the row, then begin reading in the necessary values.
        var cells = rows[j].getElementsByTagName('td');
        this.setValue('forumMarker', cells[0].innerHTML);
        this.setValue('forumTitle', cells[1].firstElementChild.innerHTML);

        // If "(X Viewing)" is enabled, make sure to account for it.
        var viewing = cells[1].getElementsByClassName('x-viewing-forum')[0];
        if (viewing) {
            this.setValue('forumViewing', viewing.innerHTML.split('(')[1].split(' Vi')[0]);
        } else {
            this.setValue('forumViewing', this.config.viewingDefault);
        }

        this.setValue('forumId', this.values['{{forumTitle}}'].split('showforum=')[1].split('" alt="')[0]);
        this.setValue('forumDescription', cells[1].getElementsByClassName('forum-desc')[0].innerHTML);

        // Subforums need a bit of extra processing.
        var subforums = cells[1].getElementsByClassName('subforums')[0],
            subforumList = '';
        if (subforums) {
            /*
                If this row contains subforums acquire all the anchors in the subforum
                element and loop over them to build a list. For some reason Jcink tosses
                in empty links here, so skip every other link.
             */
            var subforumLinks = subforums.getElementsByTagName('a');
            for (var k = 0, numLinks = subforumLinks.length; k < numLinks; k++) {
                if(subforumLinks[k].classList[0] == 'subforums-macro') {
                    continue;
                }

                //if (!this.elemHasClass(link, '
                // Build an HTML string out of the anchor object.
                var link = this.makeLink(subforumLinks[k]);

                /*
                    If this is the last link in the list (not counting the extra empty link),
                    then don't add the separator. Otherwise, the separator is necessary.
                 */
                if(k === numLinks - 1) {
                    subforumList += link;
                } else {
                    subforumList += link + this.config.subforumSeparator;
                }
            }
            this.setValue('subforums', subforumList);
        } else {
            // If this row does not contain any subforums, use the default instead.
            this.setValue('subforums', this.config.subforumsNone);
        }

        // Moderators also get processed a little differently.
        var moderators = cells[1].getElementsByClassName('forum-led-by')[0];
        if (moderators && moderators.textContent) {
            // If this row contains moderators, acquire everything after the useless intro string.
            this.setValue('moderators', moderators.innerHTML.split('Forum Led by:  ')[1]);
            this.setValue('redirectHits', 0);
        } else if (moderators) {
            // If it doesn't, but it could, set the number of redirects to zero.
            this.setValue('redirectHits', 0);
        } else {
            // If it doesn't, use the default instead.
            this.setValue('moderators', this.config.moderatorsNone);
            // Moderators are never shown on redirect forms, so it's convenient to do this:
            this.setValue('redirectHits', cells[4].textContent.split('Redirected Hits: ')[1]);
        }

        this.setValue('topicCount', cells[2].textContent);
        this.setValue('replyCount', cells[3].textContent);

        /*
            Last post content is processed together in a batch. If the cell contains a link,
            there's last post information. If not, use the default values.
         */
        var lastPost = cells[4],
            lastPostLinks = lastPost.getElementsByTagName('a');
        if (lastPostLinks.length > 0) {
            // If there are links, read the values on the page.
            this.setValue('lastPostDate', lastPost.childNodes[0].nodeValue);
            if (lastPost.textContent.indexOf('Protected Forum') === -1) {
                // If there are no italics, this forum is not password-protected.
                this.setValue('lastPostTitle', this.makeLink(lastPostLinks[1]));
                this.setValue('lastPostURL', lastPostLinks[1].href.slice(0, -16));
                if (lastPostLinks[2]) {
                    this.setValue('lastPostAuthor', this.makeLink(lastPostLinks[2]));
                } else {
                    this.setValue('lastPostAuthor', lastPost.textContent.split('By: ')[1]);
                }
            } else {
                // If there are italics, this forum is password-protected.
                this.setValue('lastPostTitle', this.config.passwordTitle);
                this.setValue('lastPostURL', this.config.urlDefault);
                this.setValue('lastPostAuthor', this.makeLink(lastPostLinks[0]));
            }
        } else {
            // If there are no links, use the default values.
            this.setValue('lastPostDate', this.config.dateDefault);
            this.setValue('lastPostTitle', this.config.titleDefault);
            this.setValue('lastPostURL', this.config.urlDefault);
            this.setValue('lastPostAuthor', this.config.authorDefault);
        }

        // If the forum marker contains a link, prepare to append a useful utility class to the output.
        var newPosts = '';
        if(this.values['{{forumMarker}}'].indexOf('<a ') !== -1) {
            newPosts = ' has-new-posts';
        }

        // Replace the keys in the user-defined HTML with the values read in by the script.
        categoryContent += '<div id="row-' + this.values['{{forumId}}'] + '" class="new-row' + newPosts + '">' +
                           this.replaceValues(this.html, this.values) + '<\/div>';
    }

    // Add any content intended to be inserted after every category.
    if (this.config.insertAfter && this.config.insertAfter !== '') {
        categoryContent += '<div class="new-category-after">' + this.config.insertAfter + '</div>';
    }

    // Create a new HTML element, set the appropriate attributes, and inject it into the page.
    var newCategory = document.createElement('div');
    newCategory.innerHTML = categoryContent;
    newCategory.id = 'category-' + (index + 1);
    newCategory.className = 'new-category';
    table.parentNode.appendChild(newCategory);

    // Remove the original table.
    table.parentNode.removeChild(table);
};


// Extend the custom stats module with the default properties and methods.
$cs.extendModule($cs.module.Stats, $cs.module.Default);


/**
 * @namespace
 * @property {object} config                   - Default configuration values.
 * @property {string} config.keyPrefix         - The default prefix for value keys.
 * @property {string} config.keySuffix         - The default suffix for value keys.
 */
$cs.module.Stats.prototype.config = {
    keyPrefix:          '{{',
    keySuffix:          '}}'
};


/**
 * @property {string} name      - The name of this module.
 */
$cs.module.Stats.prototype.name = '$cs.module.Stats';


/**
 * @property {object} reserved  - An array of reserved names.
 */
$cs.module.Stats.prototype.reserved = [
    'values',
    'execute',
    'getValue',
    'hasValue',
    'initialize',
    'replaceValues',
    'setValue'
];


/**
 * Executes the checks and loops needed to complete the script.
 * @readonly
 */
$cs.module.Stats.prototype.execute = function() {
    var boardStats = document.getElementById('boardstats');
    if (boardStats) {
        var table = boardStats.lastElementChild;
        // Hide the original table.
        table.style.display = 'none';

        // Acquire all the cells in the table.
        var cells = table.getElementsByTagName('td');

        /*
            Loop through all of the cells in the table in groups of three and read
            in the values. The order of the statistics can be arbitrary thanks to
            feature bloat, so a switch statement is used inside the loop to check
            which set of cells we're working with before proceeding. This does mean,
            unfortunately, that some of these case expressions are checked more than
            once. The redundant execution overhead is a small price to pay for
            accuracy.
         */
        for (var i = 0, numCells = cells.length; i < numCells; i += 3) {
            switch (true) {
                case (cells[i].textContent.indexOf('active in the past') !== -1):
                    // Total number of users online.
                    this.setValue('totalUsers', cells[i].textContent.split(' user')[0]);

                    // Individual online totals are divided into three parts.
                    var individualTotals = cells[i + 2].getElementsByTagName('b');
                    this.setValue('totalUsersGuests', individualTotals[0].textContent);
                    this.setValue('totalUsersRegistered', individualTotals[1].textContent);
                    this.setValue('totalUsersAnonymous', individualTotals[2].textContent);

                    // List of online users.
                    var onlineList = cells[i + 2].getElementsByClassName('thin')[0];
                    this.setValue('onlineList', onlineList.innerHTML.split('<br><br>')[0]);

                    // Member legend.
                    this.setValue('onlineLegend', onlineList.innerHTML.split('<br><br>')[1]);

                    // Useful links.
                    this.setValue('activityLinkClick', '<a href="/?act=Online&amp;CODE=listall&amp;sort_key=click">Last Click</a>');
                    this.setValue('activityLinkMemberName', '<a href="?act=Online&amp;CODE=listall&amp;sort_key=name&amp;sort_order=asc&amp;show_mem=reg">Member Name</a>');
                    break;

                case (cells[i].textContent.slice(0, 7) === 'Today\'s'):
                    // Today's birthdays.
                    var numBirthdays = cells[i + 2].getElementsByTagName('b');
                    if (numBirthdays.length > 1) {
                        this.setValue('birthdays', numBirthdays[0].textContent);
                        this.setValue('birthdaysList', cells[i + 2].innerHTML.split('<br>')[1]);
                    } else {
                        this.setValue('birthdays', '0');
                        this.setValue('birthdaysList', cells[i + 2].textContent);
                    }
                    break;

                case (cells[i].textContent.slice(0, 11) === 'Forthcoming'):
                    // Forthcoming events.
                    this.setValue('events', cells[i + 2].innerHTML);
                    break;

                case (cells[i].textContent.slice(0, 5) === 'Board'):
                    // The post and member statistics.
                    var statisticsItems = cells[i + 2].getElementsByTagName('b');
                    this.setValue('totalPosts', statisticsItems[0].textContent);
                    this.setValue('totalMembers', statisticsItems[1].textContent);
                    this.setValue('newestMember', statisticsItems[2].innerHTML);
                    this.setValue('mostOnline', statisticsItems[3].textContent);
                    this.setValue('mostOnlineDate', statisticsItems[4].textContent);
                    break;

                case (cells[i].textContent.slice(0, 7) === 'Members'):
                    // Online today total.
                    this.setValue('onlineToday', cells[i].textContent.split(': ')[1].split(' [')[0]);

                    // Members online today list.
                    this.setValue('onlineTodayList', cells[i + 2].innerHTML.split(':</span><br>')[1]);

                    // Members online today statistics.
                    var membersOnlineItems = cells[i + 2].getElementsByTagName('b');
                    this.setValue('mostOnlineOneDay', membersOnlineItems[0].textContent);
                    this.setValue('mostOnlineDateOneDay', membersOnlineItems[1].textContent);
                    break;

                case (cells[i].textContent.slice(0, 7) === 'IBStore'):
                    // IBStore totals.
                    var ibstoreItems = cells[i + 2].getElementsByTagName('b');
                    this.setValue('storeProducts', ibstoreItems[0].textContent);
                    this.setValue('storeValue', ibstoreItems[1].textContent);
                    this.setValue('moneyTotal', ibstoreItems[2].textContent);
                    this.setValue('moneyBanked', ibstoreItems[3].textContent);
                    this.setValue('moneyCirculating', ibstoreItems[4].textContent);
                    this.setValue('richestMember', ibstoreItems[5].innerHTML);
                    this.setValue('richestMemberValue', ibstoreItems[6].textContent);
                    break;
            }
        }

        // Create a new HTML element, set the appropriate attributes, and inject it into the page.
        var newStats = document.createElement('div');
        newStats.innerHTML = this.replaceValues(this.html, this.values);
        newStats.id = 'new-statistics';
        table.parentNode.appendChild(newStats);

        // Remove the original table.
        table.parentNode.removeChild(table);
    }
};


/**
 * Initialization function. Reads user-defined settings in for processing and begins script execution.
 * @arg {object} settings       - An object with user-defined settings as properties.
 * @readonly
 */
$cs.module.Stats.prototype.initialize = function(settings) {
    // Call $cs.module.Default's initialize method instead.
    $cs.module.Default.prototype.initialize.call(this, settings);
};


// Extend the custom profile module with the default properties and methods.
$cs.extendModule($cs.module.Profile, $cs.module.Default);


/**
 * @namespace
 * @property {object}  config                       - Default configuration values.
 * @property {boolean} config.htmlEnabled           - Whether or not HTML is enabled in the interests field.
 * @property {string}  config.keyPrefix             - The default prefix for value keys.
 * @property {string}  config.keySuffix             - The default suffix for value keys.
 * @property {string}  config.emailDefault          - The default email link content.
 * @property {string}  config.messageDefault        - The default personal message link content.
 * @property {string}  config.reputationIncrease    - The default increase reputation link content.
 * @property {string}  config.reputationDecrease    - The default decrease reputation link content.
 * @property {string}  config.warnIncrease          - The default increase warning link content.
 * @property {string}  config.warnDecrease          - The default decrease warning link content.
 * @property {string}  config.reputationDetails     - The default reputation details link content.
 * @property {string}  config.avatarDefault         - The URL of a default avatar.
 * @property {string}  config.userPhotoDefault      - The URL of a default user photo.
 * @property {string}  config.onlineActivityDefault - The default online activity text.
 */
$cs.module.Profile.prototype.config = {
    htmlEnabled:           false,
    keyPrefix:             '{{',
    keySuffix:             '}}',
    emailDefault:          'Click here',
    messageDefault:        'Click here',
    reputationIncrease:    '+',
    reputationDecrease:    '-',
    warnIncrease:          '+',
    warnDecrease:          '-',
    reputationDetails:     '[details >>]',
    avatarDefault:         '',
    userPhotoDefault:      '',
    onlineActivityDefault: '',
    customFieldsInnerHTML: false
};


/**
 * @property {string} name      - The name of this module.
 */
$cs.module.Profile.prototype.name = '$cs.module.Profile';


/**
 * @property {object} reserved  - An array of reserved names.
 */
$cs.module.Profile.prototype.reserved = [
    'values',
    'execute',
    'getValue',
    'hasValue',
    'initialize',
    'replaceValues',
    'setValue',
    'stringToMarkup'
];


/**
 * Executes the checks and loops needed to complete the script.
 * @readonly
 */
$cs.module.Profile.prototype.execute = function() {
    var portalStyle = document.getElementById('profile-heading'),
        defaultStyle = document.getElementById('profilename');
    // Check for personal portal style profiles first, since those are the default.
    if (portalStyle) {
        var table = portalStyle.parentNode.parentNode.parentNode.parentNode;

        // Acquire the elements needed to read the values in.
        var personalInfo = document.getElementById('profile-personalinfo'),
            customFields = document.getElementById('profile-customfields'),
            statistics   = document.getElementById('profile-statistics'),
            contactInfo  = document.getElementById('profile-contactinfo'),
            signature    = document.getElementById('sig_popup'),
            profileTop   = document.getElementById('profile-header');

        // Hide the original profile.
        table.style.display = 'none';

        // Get the user's id.
        var userId = location.href.split("?showuser=")[1];
        this.setValue('userId', userId);

        // personalInfo
        var personalInfoDivs = personalInfo.getElementsByTagName('div'),
            userPhoto = personalInfoDivs[2].getElementsByTagName('img')[0];
        if (userPhoto) {
            this.setValue('userPhoto', userPhoto.src);
        } else {
            this.setValue('userPhoto', this.config.userPhotoDefault);
        }
        var warnLevel = personalInfoDivs[4].textContent.split('%')[0];
        if (warnLevel !== '') {
            this.setValue('warnLevel', warnLevel);
            this.setValue('warnLevelIncrease', '<a href="/?act=warn&type=add&mid=' + userId + '">' + this.config.warnIncrease + '</a>');
            this.setValue('warnLevelDecrease', '<a href="/?act=warn&type=minus&mid=' + userId + '">' + this.config.warnDecrease + '</a>');
        }

        /*
            Reputation was wrapped in a div of its own for some reason between
            versions 1.5.3 and 1.5.4, so another offset is required here.
         */
        var reputationOffset = 0;
        if (personalInfoDivs[5].textContent !== 'Options') {
            reputationOffset = 1;
            var reputationTotal = personalInfoDivs[5].textContent.split('] ')[1].split(' pts')[0];
            if (reputationTotal !== '') {
                this.setValue('reputationTotal', reputationTotal);
                this.setValue('reputationIncrease', '<a href="/?act=rep&CODE=01&mid=' + userId + '&t=p">' + this.config.reputationIncrease + '</a>');
                this.setValue('reputationDecrease', '<a href="/?act=rep&CODE=02&mid=' + userId + '&t=p">' + this.config.reputationDecrease + '</a>');
                this.setValue('reputationDetails', '<a href="/?act=rep&CODE=03&mid=' + userId +'">' + this.config.reputationDetails + '</a>');
            }
        }
        this.setValue('userTitle', personalInfoDivs[10 + reputationOffset].textContent);
        this.setValue('location', personalInfoDivs[12 + reputationOffset].textContent.split('Location: ')[1]);
        this.setValue('birthday', personalInfoDivs[13 + reputationOffset].textContent.split('Born: ')[1]);
        this.setValue('homePage', personalInfoDivs[14 + reputationOffset].innerHTML.split('Website: ')[1]);
        if (this.config.htmlEnabled) {
            this.setValue('interests', this.stringToMarkup(personalInfoDivs[16 + reputationOffset].innerHTML));
        } else {
            this.setValue('interests', personalInfoDivs[16 + reputationOffset].innerHTML);
        }

        // customFields
        var customFieldsDivs = customFields.getElementsByTagName('div');
        for (var i = 1, numCustomFieldsDivs = customFieldsDivs.length; i < numCustomFieldsDivs; i++) {
            if (this.config.customFieldsInnerHTML) {
                var customFieldContent = customFieldsDivs[i].innerHTML.split(': ')[1];
            } else {
                var customFieldContent = customFieldsDivs[i].textContent.split(': ')[1];
            }
            this.setValue('customField' + i, customFieldContent);
        }

        // statistics
        var statisticsDivs = statistics.getElementsByTagName('div');
        this.setValue('joinDate', statisticsDivs[1].textContent.split('Joined: ')[1]);
        this.setValue('onlineStatus', statisticsDivs[2].textContent.split('(')[1].split(')')[0]);
        if (statisticsDivs[2].textContent.split('(')[1].split(')')[0].indexOf('Offline') === -1) {
            this.setValue('onlineActivity', statisticsDivs[2].textContent.split(') (')[1].split(')')[0]);
        } else {
            this.setValue('onlineActivity', this.config.onlineActivityDefault);
        }
        this.setValue('lastActivity', statisticsDivs[3].textContent.split(': ')[1]);
        this.setValue('localTime', statisticsDivs[4].textContent.split(': ')[1]);
        this.setValue('postCount', statisticsDivs[5].textContent.split('posts')[0]);
        this.setValue('postsPerDay', statisticsDivs[5].textContent.split('(')[1].split(' per')[0]);

        // Create a new HTML element, set the appropriate attributes, and inject it into the page.

        // contactInfo
        var contactInfoDivs = contactInfo.getElementsByTagName('div');
        this.setValue('userAIM', contactInfoDivs[1].textContent);
        this.setValue('userYahoo', contactInfoDivs[2].textContent);
        this.setValue('userGtalk', contactInfoDivs[3].textContent);
        this.setValue('userMSN', contactInfoDivs[4].textContent);
        this.setValue('userSkype', contactInfoDivs[5].textContent);
        if (contactInfoDivs[6].textContent.indexOf('Click') !== -1) {
            this.setValue('sendMessage', '<a href="/?act=Msg&amp;CODE=00&amp;MID=' + userId + '">' + this.config.messageDefault + '</a>');
        } else {
            this.setValue('sendMessage', 'Private');
        }
        if (contactInfoDivs[7].textContent.indexOf('Click') !== -1) {
            this.setValue('sendEmail', '<a href="/?act=Mail&amp;CODE=00&amp;MID=' + userId + '">' + this.config.emailDefault + '</a>');
        } else {
            this.setValue('sendEmail', 'Private');
        }

        // signature
        var signatureContainer = signature.getElementsByTagName('td');
        this.setValue('signature', signatureContainer[2].innerHTML);

        // profileTop
        var usernameContainer = profileTop.getElementsByTagName('h3');
        this.setValue('userName', usernameContainer[0].textContent);
        var groupContainer = profileTop.getElementsByTagName('strong');
        this.setValue('userGroup', groupContainer[0].textContent);
        var avatar =  profileTop.previousElementSibling.getElementsByTagName('img')[0];
        if (avatar) {
            this.setValue('avatar', avatar.src);
        } else {
            this.setValue('avatar', this.config.avatarDefault);
        }

        // Create a new HTML element, set the appropriate attributes, and inject it into the page.
        var newProfile = document.createElement('div');
        newProfile.innerHTML = this.replaceValues(this.html, this.values);
        newProfile.id = 'new-profile';
        table.parentNode.appendChild(newProfile);

        // Remove the original profile.
        table.parentNode.removeChild(table);
    }
    else if (defaultStyle) {
        // Acquire the elements needed to read the values in.
        var topTable = defaultStyle.parentNode.parentNode.parentNode.parentNode,
            lineBreak = topTable.nextElementSibling,
            middleTable = lineBreak.nextElementSibling,
            bottomTable = middleTable.nextElementSibling,
            finalDiv = bottomTable.nextElementSibling;

        // Hide the original profile.
        topTable.style.display = 'none';
        lineBreak.style.display = 'none';
        middleTable.style.display = 'none';
        bottomTable.style.display = 'none';
        finalDiv.style.display = 'none';

        // Get the user's id.
        var userId = location.href.split("?showuser=")[1];
        this.setValue('userId', userId);

        // Read the values from the top table.
        var topTableCells = topTable.getElementsByTagName('td'),
            userPhoto = topTableCells[0].getElementsByTagName('img')[0],
            topTableDivs = topTableCells[1].getElementsByTagName('div');
        if (userPhoto) {
            this.setValue('userPhoto', userPhoto.src);
        } else {
            this.setValue('userPhoto', this.config.userPhotoDefault);
        }
        this.setValue('userName', topTableDivs[0].textContent);
        /*
            Profile links don't appear on the personal portal style profiles,
            so it's been commented out for compatibility between the two halves
            of this module.

        this.setValue('profileLinks', topTableDivs[1].innerHTML);
         */

        // Read the values from the middle table.
        var middleTables = middleTable.getElementsByTagName('table'),
            topLeftTable = middleTables[0],
            topRightTable = middleTables[1],
            bottomLeftTable = middleTables[2],
            bottomRightTable = middleTables[3];

        // Read the values in the top left table.
        var topLeftCells = topLeftTable.getElementsByTagName('td');
        this.setValue('postCount', topLeftCells[2].textContent.split('(')[0]);
        this.setValue('postsPerDay', topLeftCells[4].textContent);
        this.setValue('joinDate', topLeftCells[6].textContent);
        this.setValue('localTime', topLeftCells[8].textContent);
        var onlineStatus = topLeftCells[10].textContent;
        this.setValue('onlineStatus', onlineStatus.split('(')[1].split(')')[0]);
        if (onlineStatus.split('(')[1].split(')')[0].indexOf('Offline') === -1) {
            this.setValue('onlineActivity', onlineStatus.split(') (')[1].split(')')[0]);
        } else {
            this.setValue('onlineActivity', this.config.onlineActivityDefault);
        }

        // Read the values in the top right table.
        var topRightCells = topRightTable.getElementsByTagName('td');
        if (topRightCells[2].textContent.indexOf('Click') !== -1) {
            this.setValue('sendEmail', '<a href="/?act=Mail&amp;CODE=00&amp;MID=' + userId + '">Click here</a>');
        } else {
            this.setValue('sendEmail', 'Private');
        }
        this.setValue('userSkype', topRightCells[4].textContent);
        this.setValue('userAIM', topRightCells[6].textContent);
        this.setValue('userGtalk', topRightCells[8].textContent);
        this.setValue('userYahoo', topRightCells[10].textContent);
        this.setValue('userMSN', topRightCells[12].textContent);
        if (topRightCells[14].textContent.indexOf('Click') !== -1) {
            this.setValue('sendMessage', '<a href="/?act=Msg&amp;CODE=04&amp;MID=' + userId + '">Click here</a>');
        } else {
            this.setValue('sendMessage', 'Private');
        }

        // Read the values in the bottom left table.
        var bottomLeftCells = bottomLeftTable.getElementsByTagName('td');
        this.setValue('homePage', bottomLeftCells[2].innerHTML);
        this.setValue('birthday', bottomLeftCells[4].textContent);
        this.setValue('location', bottomLeftCells[6].textContent);

        // If HTML in the interests field is enabled, make sure to parse it correctly.
        if (this.config.htmlEnabled) {
            this.setValue('interests', this.stringToMarkup(bottomLeftCells[8].innerHTML));
        } else {
            this.setValue('interests', bottomLeftCells[8].innerHTML);
        }

        /*
            More trouble caused by feature bloat. Awards get inserted at this point,
            but instead of using a switch statement here it's more efficient to
            declare an offset and watch it carefully since the rows following this
            one are not presented in an arbitrary order.
         */
        var awardOffset = 0;
        if (bottomLeftCells[9].textContent.indexOf('Awards') !== -1) {
            awardOffset = 2;
            this.setValue('awards', bottomLeftCells[10].innerHTML);
        }

        // Wrap up the default profile fields.
        /*
            Last post doesn't appear on the personal portal style profiles,
            so it's been commented out for compatibility between the two halves
            of this module.

        this.setValue('lastPost', bottomLeftCells[10 + awardOffset].textContent);
         */
        this.setValue('lastActivity', bottomLeftCells[12 + awardOffset].textContent);

        // Custom profile fields are simple- just iterate over the remainder in a loop.
        for (var i = 14 + awardOffset, fieldNum = 1; i < bottomLeftCells.length; i += 2, fieldNum++) {
            if (this.config.customFieldsInnerHTML) {
                var customFieldContent = bottomLeftCells[i].innerHTML;
            } else {
                var customFieldContent = bottomLeftCells[i].textContent;
            }
            this.setValue('customField' + fieldNum, customFieldContent);
        }

        // Read the values in the bottom right table.
        var bottomRightCells = bottomRightTable.getElementsByTagName('td');
        this.setValue('userGroup', bottomRightCells[2].textContent);
        this.setValue('userTitle', bottomRightCells[4].textContent);
        var avatar =  bottomRightCells[6].getElementsByTagName('img')[0];
        if (avatar) {
            this.setValue('avatar', avatar.src);
        } else {
            this.setValue('avatar', this.config.avatarDefault);
        }

        /*
            Another switch statement is used for the final potential three rows.
            Their order may be arbitrary, so it's required.
         */
        for (var i = 7; i < bottomRightCells.length; i++) {
            switch (true) {
                case (bottomRightCells[i].textContent === 'Rep:'):
                    var reputation = bottomRightCells[i + 1].textContent;
                    if (reputation.indexOf('pts') !== -1) {
                        this.setValue('reputationTotal', reputation.split('pts [')[0]);
                    } else {
                        this.setValue('reputationTotal', '0');
                    }
                    this.setValue('reputationIncrease', '<a href="/?act=rep&CODE=01&mid=' + userId + '&t=p">' + this.config.reputationIncrease + '</a>');
                    this.setValue('reputationDecrease', '<a href="/?act=rep&CODE=02&mid=' + userId + '&t=p">' + this.config.reputationDecrease + '</a>');
                    this.setValue('reputationDetails', '<a href="/?act=rep&CODE=03&mid=' + userId +'">' + this.config.reputationDetails + '</a>');
                    break;
                case (bottomRightCells[i].textContent === 'Warn Level'):
                    this.setValue('warnLevel', bottomRightCells[i + 1].textContent.split('%')[0]);
                    this.setValue('warnLevelIncrease', '<a href="/?act=warn&type=add&mid=' + userId + '">' + this.config.warnIncrease + '</a>');
                    this.setValue('warnLevelDecrease', '<a href="/?act=warn&type=minus&mid=' + userId + '">' + this.config.warnDecrease + '</a>');
                    break;
                /*
                    Moderator notes don't appear on the personal portal style profiles,
                    so they've been commented out for compatibility between the two halves
                    of this module.

                case (bottomRightCells[i].textContent === 'Moderator Notes:'):
                    this.setValue('moderatorNotes', bottomRightCells[i + 1].getElementsByTagName('textarea')[0].value);
                    break;
                 */
            }
        }

        // Get the user's signature.
        var bottomTableCells = bottomTable.getElementsByTagName('td');
        this.setValue('signature', bottomTableCells[2].innerHTML);

        // Create a new HTML element, set the appropriate attributes, and inject it into the page.
        var newProfile = document.createElement('div');
        newProfile.innerHTML = this.replaceValues(this.html, this.values);
        newProfile.id = 'new-profile';
        topTable.parentNode.appendChild(newProfile);

        // Remove the original profile.
        topTable.parentNode.removeChild(topTable);
        lineBreak.parentNode.removeChild(lineBreak);
        middleTable.parentNode.removeChild(middleTable);
        bottomTable.parentNode.removeChild(bottomTable);
        finalDiv.parentNode.removeChild(finalDiv);
    }
};


/**
 * Initialization function. Reads user-defined settings in for processing and begins script execution.
 * @arg {object} settings       - An object with user-defined settings as properties.
 * @readonly
 */
$cs.module.Profile.prototype.initialize = function(settings) {
    // Call $cs.module.Default's initialize method instead.
    $cs.module.Default.prototype.initialize.call(this, settings);
};


/**
 * Converts a string containing encoded HTML markup into actual HTML markup.
 * @arg {string} string         - A string containing encoded HTML markup.
 * @return {string}
 * @readonly
 */
$cs.module.Profile.prototype.stringToMarkup = function(string) {
    // Create a throwaway element and set its innerHTML.
    var temp = document.createElement('div');
    temp.innerHTML = string;
    var result = '';
    for(var i = 0, length = temp.childNodes.length; i < length; i++) {
        if(temp.childNodes[i].nodeValue) {
            result += temp.childNodes[i].nodeValue;
        }
    }
    temp = '';
    return result;
}


// Extend the custom topics module with the default properties and methods.
$cs.extendModule($cs.module.Topics, $cs.module.Default);


/**
 * @namespace
 * @property {object}  config                      - Default configuration values.
 * @property {string}  config.keyPrefix            - The default prefix for value keys.
 * @property {string}  config.keySuffix            - The default suffix for value keys.
 * @property {string}  config.announcementsDefault - The default title row text for announcements.
 * @property {string}  config.pinnedDefault        - The default title row text for pinned topics.
 * @property {string}  config.regularDefault       - The default title row text for regular topics.
 * @property {string}  config.noTopics             - The default message displayed when a forum contains no topics.
 * @property {string}  config.noActiveTopics       - The default message displayed when the active topics page is blank.
 * @property {string}  config.paginationDefault    - The default text displayed when pagination is blank.
 * @property {boolean} config.activeTopics         - Whether to apply changes the to the active topics page.
 */
$cs.module.Topics.prototype.config = {
    keyPrefix:              '{{',
    keySuffix:              '}}',
    announcementsDefault:   'Announcements',
    pinnedDefault:          'Important Topics',
    regularDefault:         'Forum Topics',
    noTopics:               'No topics were found. This is either because there are no topics in this forum, or the topics are older than the current age cut-off.',
    noActiveTopics:         'There were no active topics during those date ranges',
    paginationDefault:      '',
    activeTopics:           false
};


/**
 * @property {string} name      - The name of this module.
 */
$cs.module.Topics.prototype.name = '$cs.module.Topics';


/**
 * @property {object} reserved  - An array of reserved names.
 */
$cs.module.Topics.prototype.reserved = [
    'values',
    'execute',
    'getValue',
    'hasValue',
    'initialize',
    'replaceValues',
    'setValue',
];


/**
 * Executes the checks and loops needed to complete the script.
 * @readonly
 */
$cs.module.Topics.prototype.execute = function() {
    var topicList = document.getElementById('topic-list');
    // If we couldn't find the default topic list, check what page we're on.
    if (!topicList) {
        if (this.config.activeTopics && window.location.href.indexOf('act=Search&CODE=getactive') > -1) {
            var forms = document.getElementsByTagName('form');
            for (var i = 0; i < forms.length; i++) {
                if (forms[i].action.indexOf('act=Search&CODE=getactive') > -1) {
                    topicList = forms[i].nextElementSibling.nextElementSibling;
                }
            }

            // I don't like flags, but this is the best way to handle this here.
            var viewingActiveTopics = true;
        }
    }
    if (topicList) {
        /*
            Acquire the elements needed to read the values in and initialize some variables
            for formatting the script output.
         */
        var table = topicList.getElementsByTagName('table')[0],
            rows = [],
            topicsContent = '',
            rowClass = ' regular-topic';

        /*
            Loop through the direct children of the table to get the correct row count.
            This is necessary due to extraneous tables on the active topics view.
         */
        for (var t = 0; t < table.firstElementChild.childNodes.length; t++) {
            if (table.firstElementChild.childNodes[t].nodeType === 1 && table.firstElementChild.childNodes[t].tagName === 'TR') {
                rows.push(table.firstElementChild.childNodes[t]);
            }
        }

        // Hide the original table.
        table.style.display = 'none';

        // Loop through each row and either read the values or output a title row.
        for (var i = 1, numRows = rows.length; i < numRows; i++) {
            // Get all the cells in each row. If a fourth cell exists, read the values in.
            var cells = rows[i].getElementsByTagName('td');
            if (cells[3]) {
                // Regular topic listing.
                if (!viewingActiveTopics) {
                    this.setValue('folder', cells[0].innerHTML);
                    this.setValue('marker', cells[1].innerHTML);
                    var topicTitle = cells[2].getElementsByTagName('a')[0];
                    if (!topicTitle.getAttribute('title')) {
                        topicTitle = cells[2].getElementsByTagName('a')[1];
                    }
                    this.setValue('topicId', topicTitle.getAttribute('href').split('showtopic=')[1]);
                    this.setValue('topicTitle', '<a href="' + topicTitle + '">' + topicTitle.textContent + '</a>');
                    var topicSpans = cells[2].getElementsByTagName('span');
                    if (topicSpans[0].textContent.indexOf('(Pages ') !== -1) {
                        this.setValue('pagination', topicSpans[0].innerHTML);
                        this.setValue('topicDescription', topicSpans[1].textContent);
                    } else {
                        this.setValue('pagination', this.config.paginationDefault);
                        this.setValue('topicDescription', topicSpans[0].textContent);
                    }
                    var forumName = topicList.firstElementChild.textContent.trim(),
                        parentForum = '<a href="' + window.location.href + '">' + forumName + '</a>';
                    this.setValue('parentForum', parentForum);
                    this.setValue('topicAuthor', cells[3].innerHTML);
                    this.setValue('replyCount', cells[4].textContent);
                    this.setValue('viewCount', cells[5].textContent);

                    this.setValue('lastReplyDate', cells[6].getElementsByTagName('span')[0].firstChild.nodeValue);
                    this.setValue('lastReplyAuthor', cells[6].getElementsByTagName('b')[0].innerHTML);
                    this.setValue('moderatorCheckbox', cells[7].innerHTML);
                }
                // Active topics search page listing.
                else if (this.config.activeTopics) {
                    this.setValue('folder', cells[0].innerHTML);
                    this.setValue('marker', cells[1].innerHTML);
                    var topicTitleLinks = cells[2].getElementsByTagName('a');
                    if (topicTitleLinks[0].href.indexOf('view=getnewpost') === -1) {
                        var topicTitle = topicTitleLinks[0];
                    } else {
                        var topicTitle = topicTitleLinks[1];
                    }
                    this.setValue('topicId', topicTitle.getAttribute('href').split('showtopic=')[1].split('&')[0]);
                    this.setValue('topicTitle', '<a href="' + topicTitle + '">' + topicTitle.textContent + '</a>');
                    var topicSpans = cells[2].getElementsByTagName('span');
                    if (topicSpans[0].textContent.indexOf('(Pages ') !== -1) {
                        this.setValue('pagination', topicSpans[0].innerHTML);
                        this.setValue('topicDescription', topicSpans[1].textContent);
                    } else {
                        this.setValue('pagination', this.config.paginationDefault);
                        this.setValue('topicDescription', topicSpans[0].textContent);
                    }
                    this.setValue('parentForum', cells[5].innerHTML);
                    this.setValue('topicAuthor', cells[6].innerHTML);
                    this.setValue('replyCount', cells[7].textContent);
                    this.setValue('viewCount', cells[8].textContent);

                    this.setValue('lastReplyDate', cells[9].firstChild.nodeValue);
                    this.setValue('lastReplyDate', cells[9].firstChild.nodeValue);
                    var author = cells[9].getElementsByTagName('b');
                    if (author[1]) {
                        this.setValue('lastReplyAuthor', author[1].innerHTML);
                    } else {
                        this.setValue('lastReplyAuthor', author[0].innerHTML);
                    }
                    this.setValue('moderatorCheckbox', '');
                }

                // Perform string replacement and append the new row to the output.
                topicsContent += '<div class="topic-row' + rowClass + '">' +
                                 this.replaceValues(this.html, this.values) +
                                 '</div>';
            } else if (i !== numRows - 1 && !viewingActiveTopics) {
                // Output the appropriate title row for the topics that follow.
                var titleContents = cells[2].getElementsByTagName('b')[0].textContent;
                switch (titleContents) {
                    case 'Announcements':
                        rowClass = ' announcement-topic';
                        topicsContent += '<div class="topic-title-row">' + this.config.announcementsDefault + '</div>';
                        break;
                    case 'Important Topics':
                        rowClass = ' pinned-topic';
                        topicsContent += '<div class="topic-title-row">' + this.config.pinnedDefault + '</div>';
                        break;
                    default:
                        rowClass = ' regular-topic';
                        topicsContent += '<div class="topic-title-row">' + this.config.regularDefault + '</div>';
                        break;
                }
            } else {
                if (!viewingActiveTopics) {
                    // This forum contains no topics. Display a message and call it good.
                    topicsContent += '<div class="no-topics">' + this.config.noTopics + '</div>';
                } else if (this.config.activeTopics) {
                    // This active topics list is blank. Display a message and call it good.
                    topicsContent += '<div class="no-topics">' + this.config.noActiveTopics + '</div>';
                }
            }
        }

        // Create a new HTML element, set the appropriate attributes, and inject it into the page.
        var newTopics = document.createElement('div');
        newTopics.id = 'new-topics';
        newTopics.innerHTML = topicsContent;
        table.parentNode.insertBefore(newTopics, table);

        table.parentNode.removeChild(table);
        // Hide that last, useless search element down below.
        if (this.config.activeTopics && viewingActiveTopics) {
            topicList.removeChild(topicList.lastElementChild);
        }
    }
}


/**
 * Initialization function. Reads user-defined settings in for processing and begins script execution.
 * @arg {object} settings       - An object with user-defined settings as properties.
 * @readonly
 */
$cs.module.Topics.prototype.initialize = function(settings) {
    // Call $cs.module.Default's initialize method instead.
    $cs.module.Default.prototype.initialize.call(this, settings);
};


// Extend the custom posts module with the default properties and methods.
$cs.extendModule($cs.module.Posts, $cs.module.Default);


/**
 * @namespace
 * @property {object}  config                      - Default configuration values.
 * @property {string}  config.keyPrefix            - The default prefix for value keys.
 * @property {string}  config.keySuffix            - The default suffix for value keys.
 * @property {string}  config.permaLinkDefault     - The default text used in permalinks.
 * @property {string}  config.postSignatureDefault - The default text used for signatures.
 * @property {boolean} config.quickEdit            - Whether or not to use the quick edit feature.
 * @property {boolean} config.formatQuoteCodeTags  - Whether or not to use the formatted quote/code tags feature.
 */
$cs.module.Posts.prototype.config = {
    keyPrefix:              '{{',
    keySuffix:              '}}',
    permaLinkDefault:       'Permalink',
    postSignatureDefault:   '',
    quickEdit:              false,
    formatQuoteCodeTags:    false
};


/**
 * @property {string} name      - The name of this module.
 */
$cs.module.Posts.prototype.name = '$cs.module.Posts';


/**
 * @property {object} reserved  - An array of reserved names.
 */
$cs.module.Posts.prototype.reserved = [
    'attachCodeEventListeners',
    'createEditForm',
    'execute',
    'Fetch',
    'formatQuoteCodeTags',
    'getValue',
    'hasValue',
    'initialize',
    'queryString',
    'replaceValues',
    'setValue',
    'values'
];


/**
 * Executes the checks and loops needed to complete the script.
 * @readonly
 */
$cs.module.Posts.prototype.queryString = function(url, query) {
    query = query.replace(/[\[]/, '\\[').replace(/[\]]/, '\\]');
    var regex = new RegExp('[\\?&]' + query + '=([^&#]*)'),
        result = regex.exec(url);
    return (result === null) ? '' : decodeURIComponent(result[1].replace(/\+/g, ' '));
}


/**
 * Provides some basic AJAX functionality.
 * @namespace
 * @readonly
 */
$cs.module.Posts.prototype.Fetch = {


    /**
     * Creates a new XMLHttpRequest object.
     * @return {Object} a new XMLHttpRequest object.
     */
    request: function() {
        return new (XMLHttpRequest || ActiveXObject)('MSXML2.XMLHTTP.3.0');
    },


    /**
     * Performs a GET request to the specified URL.
     * @arg {string} url      - The URL to GET from.
     * @arg {object} callback - A callback to execute on request success.
     */
    get: function(url, callback) {
        var request = this.request();
        request.open('GET', url);
        request.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
        request.onreadystatechange = function() {
            if (request.readyState > 3 && callback && typeof(callback) == 'function') {
                callback(request.responseText);
            }
        };
        request.send();
    },


    /**
     * Performs a POST request to the specified URL.
     * @arg {string} url      - The URL to POST to.
     * @arg {object} data     - An associative array of parameters to POST.
     * @arg {object} callback - A callback to execute on request success.
     */
    post: function(url, data, callback) {
        var request = this.request(),
            data = this.formatData(data);
        request.open('POST', url);
        request.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
        request.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
        request.setRequestHeader('Content-length', data.length);
        request.onreadystatechange = function() {
            if (request.readyState > 3 && callback && typeof(callback) == 'function') {
                callback(request.responseText);
            }
        };
        request.send(data);
    },


    /**
     * A filter function for outgoing POST requests. Expected by internal
     * IPB1.3.1 form handlers. Partial credit to a user "sk89q" for their
     * work on the original quick edit feature, upon which this function
     * is heavily based.
     * @arg {string} string - The string to be filtered.
     * @readonly
     */
    filter: function(string) {
        var result = '';
        for (var i = 0; i < string.length; i++){
            var currentCharacter = string.charCodeAt(i);
            if(currentCharacter > 127) {
                result += '&#' + currentCharacter + ';';
            } else {
                result += string.charAt(i);
            }
        }
        return encodeURIComponent ? encodeURIComponent(result) : escape(result);
    },


    /**
     * A utility function that breaks a parameter object into a POST-friendly
     * query string.
     * @arg {object} parameters - An associative array of parameters.
     * @return {string} - A POST-friendly query string.
     */
    formatData: function(parameters) {
        var temp = [];
        for (var key in parameters) {
            temp.push(this.filter(key) + '=' + this.filter(parameters[key]));
        }
        return temp.join('&');
    }
};


/**
 * An edit form constructor, called dynamically when a user clicks on any post edit link.
 * already.
 * @arg {string} forumId          - The forum ID.
 * @arg {string} topicId          - The topic ID.
 * @arg {string} postId           - The post ID.
 * @arg {string} pageId           - The page ID. Varies depending on board settings.
 * @arg {string} response         - The HTML response to an AJAX request containing the quick edit form.
 * @arg {object} contentContainer - A reference to the HTML element where the post is contained.
 * @readonly
 */
$cs.module.Posts.prototype.createEditForm = function(forumId, topicId, postId, pageId, response, contentContainer) {
    // Create all our elements.
    var form = document.createElement('form'),
        textarea = document.createElement('textarea'),
        buttons = document.createElement('div'),
        edit = document.createElement('button'),
        cancel = document.createElement('button'),
        fullEdit = document.createElement('button');

    // The form should never just submit, so make sure it can't:
    form.addEventListener('submit', function(event) {
        event.preventDefault();
    });

    // Acquire the user's authorization key from the response.
    var responseContainer = document.createElement('div');
    responseContainer.innerHTML = response;

    var authKey = responseContainer.firstElementChild.value;

     // Acquire the raw contents of the post and place it in our form.
    var rawContent = responseContainer.lastElementChild.value;

    // Set the textarea's attributes.
    textarea.innerHTML = rawContent;
    textarea.style.boxSizing = 'border-box';

    // Set our button labels.
    edit.innerHTML = 'Edit';
    cancel.innerHTML = 'Cancel';
    fullEdit.innerHTML = 'Full Edit';


    /**
     * @todo Put this somewhere else. Nested functions like this are ugly.
     */
    var loadEditedPost = function(result) {
        // Put a regular expression together.
        var regex = '(?:<!\-\- THE POST ' + postId + ' \-\->\\s*' + // start
            '<div class=[\'"]postcolor[\'"]>)' + // the post container
            '((?:.|\n)*?)' + // the post content - target text
            '(?: <\/div>\n {8}' + // a closing div tag...
            // ... either right before the user's signature, or...
            '(?:<br(?:\\s*\/)>){2}(?:.|\n)*?(?:<br(?:\\s*\/)>' +
            '\n<div class=[\'"]signature[\'"]>)|' +
            // ... right before the end of the post.
            '(?:<\/div>\\s*<!-- THE POST -->))' //end

        // Acquire the edited post.
        var editedPost = new RegExp(regex).exec(result);

        /* If we can't find it, though, there was a problem. Show an error,
           then return. */
        if (!editedPost) {
            console.error('Could not GET edited post. Read failed.');
            return;
        }

        /* Construct the final post, set its attributes, and replace the edit
           form with it. */
        var finalPost = document.createElement('div');
        // Add the all-important class to the final post.
        finalPost.classList.add('cs-quick-edit');
        finalPost.innerHTML = editedPost[1];

        // Replace the edit form with the edited post.
        edit.parentNode.parentNode.parentNode.replaceChild(
            finalPost,
            edit.parentNode.parentNode
        );

        // Format quote and code tags, if applicable.
        if (this.config.formatQuoteCodeTags) {
            var tags = finalPost.getElementsByTagName('table');
            this.formatQuoteCodeTags(tags);
            this.attachCodeEventListeners();
        }
    };

    /**
     * @todo Put this somewhere else. Nested functions like this are ugly.
     */
    var editPost = function(event) {
        event.preventDefault();
        var editURL = '/?act=Post&quickedit=1&CODE=09&f=' + forumId + '&t=' +
            topicId + '&p=' + postId + '&st=' + pageId + '&auth_key=' +
            authKey;

        /**
         * POST parameters to send.
         * @namespace
         */
        var params = {
            enablesig: "yes",
            Post: textarea.value
        };

        // POST our edited post to the forum.
        this.Fetch.post(editURL, params, function(response) {
            // Create a container for the response markup.
            var responseContainer = document.createElement('div');
            responseContainer.innerHTML = response;

            /*
                If successful, the response will contain a single link to skip
                the redirection. Let's grab it; we'll neet to GET the page
                there to read in the edited post.
             */
            var responseLinks = responseContainer.getElementsByTagName('a');

            if (responseLinks.length === 1) {
                /* Add a timestamp to the URL to prevent browsers from caching
                   the AJAX call. */
                var redirectUrl = responseLinks[0].href + '&nocache=' +
                    new Date().getTime();;
            }
            /* If the redirect URL doesn't exist, we've got a problem. Show an
               error, then return early. */
            else {
                console.error('Couldn\'t POST changes. Edit failed.');
                return;
            }

            // GET the edited post from our redirect URL.
            this.Fetch.get(redirectUrl, loadEditedPost.bind(this));
        }.bind(this));
    };

    // Attach our event listeners.
    edit.addEventListener('click', editPost.bind(this));
    cancel.addEventListener('click', function(event) {
        event.preventDefault();
        this.parentNode.parentNode.parentNode.replaceChild(
            contentContainer,
            this.parentNode.parentNode
        );
    });
    fullEdit.addEventListener('click', function(event) {
        event.preventDefault();
        window.location.href = '/?act=Post&CODE=08&f=' + forumId + '&t=' +
            topicId + '&p=' + postId + '&st=' + pageId;
    }.bind(this));

    // Append the buttons to the button container.
    buttons.appendChild(edit);
    buttons.appendChild(cancel);
    buttons.appendChild(fullEdit);

    // Append the textarea and the button container to the form.
    form.appendChild(textarea);
    form.appendChild(buttons);

    // Return the form for use elsewhere.
    return form;
};


/**
 * Formats code and quote tags inside a given element, typically series of tables.
 * @arg {object}  tags         - An array of HTMLObjects- tables.
 * @arg {boolean} includeFirst - Whether or not to include the first
 * @readonly
 */
$cs.module.Posts.prototype.formatQuoteCodeTags = function(tags) {
    for (var m = tags.length; m > -1; m--) {
        if (typeof tags[m] !== 'undefined' && tags[m].id == 'QUOTE-WRAP') {
            tags[m].style.display = 'none';
            var quoteTitleContents = tags[m].firstElementChild.firstElementChild.firstElementChild.innerHTML.slice(14, -1).split(' @ ');
            var quoteAuthor = quoteTitleContents[0],
                quoteTimestamp = quoteTitleContents[1];
            if (!quoteAuthor) {
                quoteAuthor = '';
            }
            if (!quoteTimestamp) {
                quoteTimestamp = '';
            }
            var originalQuote = tags[m].firstElementChild.lastElementChild.firstElementChild.innerHTML;
            var quoteContainer = document.createElement('div');
            quoteContainer.classList.add('quote-wrapper');
            quoteContainer.innerHTML = '<div class="quote-title"><span class="quote-author">' + quoteAuthor + '</span><span class="quote-timestamp">' + quoteTimestamp + '</span></div><div class="quote-contents"><blockquote>' + originalQuote + '</blockquote></div>';
            tags[m].parentNode.insertBefore(quoteContainer, tags[m].nextSibling);
            tags[m].parentNode.removeChild(tags[m]);
        }
        else if (typeof tags[m] !== 'undefined' && tags[m].id == 'CODE-WRAP') {
            tags[m].style.display = 'none';
            var originalCode = tags[m].firstElementChild.lastElementChild.firstElementChild.innerHTML;
            var codeContainer = document.createElement('div');
            codeContainer.classList.add('code-wrapper');
            var codeTitle = document.createElement('code');
            codeTitle.classList.add('code-title');
            codeTitle.style.cursor = 'pointer';
            codeTitle.appendChild(document.createTextNode('Code (Click to highlight)'));
            codeContainer.appendChild(codeTitle);
            var codeContents = document.createElement('div'),
                codeContentsPre = document.createElement('pre'),
                codeContentsCode = document.createElement('code');
            codeContentsCode.innerHTML = originalCode;
            codeContentsPre.appendChild(codeContentsCode);
            codeContents.appendChild(codeContentsPre);
            codeContents.classList.add('code-contents');
            codeContainer.appendChild(codeContents);
            tags[m].parentNode.insertBefore(codeContainer, tags[m].nextSibling);
            tags[m].parentNode.removeChild(tags[m]);
        }
    }
};


/**
 * Attaches click-to-highlight events to newly created code containers.
 * @readonly
 */
$cs.module.Posts.prototype.attachCodeEventListeners = function() {
    var newCode = document.getElementsByClassName('code-wrapper');
    for (var n = 0, newCodeCount = newCode.length; n < newCodeCount; n++) {
        newCode[n].firstElementChild.addEventListener('click', function(event) {
            event.preventDefault();
            var range, selection;
            if (document.body.createTextRange) {
                range = document.body.createTextRange();
                range.moveToElementText(this.nextElementSibling);
                range.select();
            } else if (window.getSelection) {
                selection = window.getSelection();
                range = document.createRange();
                range.selectNodeContents(this.nextElementSibling);
                selection.removeAllRanges();
                selection.addRange(range);
            }
        });
    };
};


/**
 * Executes the checks and loops needed to complete the script.
 * @readonly
 */
$cs.module.Posts.prototype.execute = function() {
    // Make sure we're viewing a topic before executing.
    if (window.location.href.indexOf('showtopic') !== -1 || window.location.href.indexOf('ST') !== -1) {
        var posts = document.getElementsByClassName('post-normal');

        // Create a new HTML element and set the appropriate attributes.
        var newPosts = document.createElement('div');
        newPosts.id = 'new-posts';

        // Loop through each post being displayed.
        for (var i = 0, numPosts = posts.length; i < numPosts; i++) {
            // Hide each post.
            posts[i].style.display = 'none';

            // Acquire the elements necessary to read in the values.
            var table = posts[i].firstElementChild,
                rows = table.getElementsByTagName('tr'),
                cells = [];

            /*
                To avoid collisions with doHTML and custom miniprofiles, we need to
                check the direct children of each row. This takes some extra work.
             */
            for (var j = 0, numRows = rows.length; j < numRows; j++) {
                var directChildrenOfRow = rows[j].childNodes;
                for (var k = 0, numCells = directChildrenOfRow.length; k < numCells; k++) {
                    var child = directChildrenOfRow[k];
                    if (child.nodeType === 1 && child.tagName === 'TD') {
                        if (child.parentNode.parentNode.parentNode === table) {
                            cells.push(child);
                        }
                    }
                }
            }

            // Read the values in.
            var postLinks = cells[0].getElementsByTagName('a'),
                postId = postLinks[0].name.split('entry')[1],
                topicId;

            /*
                IPB 1.3.1 has two different ways of displaying topics using URL query strings.
                If we don't have a match for the usual one, check the other possible URL query.
                Internally consistent, IPB 1.3.1 ain't.
             */
            if (window.location.search.indexOf('showtopic') !== -1) {
                topicId = this.queryString(window.location.search, 'showtopic');
            } else {
                topicId = this.queryString(window.location.search, 't');
            }

            this.setValue('postId', postId);

            // The author names for guests and users have to be read differently.
            if (postLinks.length > 1) {
                this.setValue('postAuthor', cells[0].innerHTML.split('normalname">')[1].slice(0, -7));
            } else {
                this.setValue('postAuthor', cells[0].innerHTML.split('unreg">')[1].slice(0, -7));
            }
            this.setValue('permaLink', '<a href="/?showtopic=' + topicId + '&amp;view=findpost&amp;p=' + postId + '">' + this.config.permaLinkDefault + '</a>');
            this.setValue('postDate', cells[1].firstElementChild.textContent.split('Posted: ')[1]);
            this.setValue('postButtonsTop', cells[1].lastElementChild.innerHTML);

            /*
                The topic starter will always be missing the checkbox, so use an offset to
                properly count the cells from this point onward.
             */
            var cellOffset = 0;
            if (cells[2].innerHTML.indexOf('input') !== -1) {
                this.setValue('postCheckbox', cells[2].innerHTML);
                cellOffset = 1;
            } else {
                this.setValue('postCheckbox', '');
            }
            this.setValue('postMiniprofile', cells[2 + cellOffset].firstElementChild.innerHTML);

            // If the quick edit feature is enabled, make sure to wrap the post contents here.
            if (this.config.quickEdit) {
                this.setValue('postContent', '<div class="cs-quick-edit">' + cells[3 + cellOffset].firstElementChild.innerHTML + '</div>');
            } else {
                this.setValue('postContent', cells[3 + cellOffset].firstElementChild.innerHTML);
            }

            var postSignature = cells[3 + cellOffset].lastElementChild;
            if (postSignature.previousElementSibling) {
                this.setValue('postSignature', postSignature.innerHTML);
            } else {
                this.setValue('postSignature', this.config.postSignatureDefault);
            }
            this.setValue('postIp', cells[4 + cellOffset].textContent);
            this.setValue('postButtonsBottom', cells[5 + cellOffset].firstElementChild.innerHTML);

            // Create a new element for this post and append it to the new posts container.
            var newPost = document.createElement('div');
            newPost.id = 'entry' + postId;
            newPost.classList.add('new-post', posts[i].classList[1]);
            newPost.innerHTML = this.replaceValues(this.html, this.values);
            newPosts.appendChild(newPost);

            // Handle the quick edit feature, if applicable.
            if (this.config.quickEdit) {
                // Get all the links in the new post.
                var postLinks = newPost.getElementsByTagName('a');

                // Iterate over each of the links.
                for (var l = 0; l < postLinks.length; l++) {

                    // If it's an edit link we're looking at...
                    if (postLinks[l].href.indexOf('act=Post&CODE=08') !== -1) {

                        // ... attach an event listener.
                        postLinks[l].addEventListener('click', function(event) {
                            event.preventDefault();

                            var forumId = this.queryString(event.currentTarget.href, 'f'),
                                topicId = this.queryString(event.currentTarget.href, 't'),
                                postId = this.queryString(event.currentTarget.href, 'p'),
                                pageId = this.queryString(event.currentTarget.href, 'st') || '0';

                            var quickEditURL = '/?&act=Post&CODE=08&f=' + forumId + '&t=' + topicId + '&p=' + postId + '&st=' + pageId + '&quickedit=1';

                            var editableContent = document.getElementById('entry' + postId).getElementsByClassName('cs-quick-edit')[0];

                            this.Fetch.get(quickEditURL, function(response) {
                                var editForm = this.createEditForm(forumId, topicId, postId, pageId, response, editableContent);
                                editableContent.parentNode.replaceChild(editForm, editableContent);
                            }.bind(this));
                        }.bind(this));
                    }
                }
            }

            // Formatted code/quote tags
            if (this.config.formatQuoteCodeTags) {
                var tags = newPost.getElementsByTagName('table');
                this.formatQuoteCodeTags(tags);
            }
        }

        // Inject the new posts container into the page.
        posts[0].parentNode.insertBefore(newPosts, posts[0]);

        // Hide the original posts container.
        table.parentNode.removeChild(table);
    }

    if (this.config.formatQuoteCodeTags) {
        this.attachCodeEventListeners();
    }
};


/**
 * Initialization function. Reads user-defined settings in for processing and begins script execution.
 * @arg {object} settings       - An object with user-defined settings as properties.
 * @readonly
 */
$cs.module.Posts.prototype.initialize = function(settings) {
    // Call $cs.module.Default's initialize method instead.
    $cs.module.Default.prototype.initialize.call(this, settings);
};


// Expose some familiar, user-friendly objects for public use.
window.customIndex   = new $cs.module.Index(),
window.customStats   = new $cs.module.Stats(),
window.customProfile = new $cs.module.Profile(),
window.customTopics  = new $cs.module.Topics(),
window.customPosts   = new $cs.module.Posts();