Source: src/variables.js

/**
 * @file Configuration, localisation and policy loaded from the user's site
 * @author Andrew Sayers
 */

/**
 * @summary Generic class for any type of bulletin board
 * @constructor
 * @abstract
 * @extends Cacheable
 * @description
 * Each variable has a language, namespace and value.
 * Variable resolution is necessarily quite complex:
 * + A default language is passed in
 * + If a "thread languages" variable exists, it is used for thread-specific language overrides
 * + If a "forum languages" variable exists, it is used for forum-specific language overrides
 * + Variable names of the form "foo: bar: baz" are treated specially - the part before the first ": " must be matched, others are optional (so a request for ['foo', 'bar'] would match a variable named 'foo)
 * Variable values can include {{keys}}, which are resolved recursively.  So {{foo: {{something}}}} will first resolve {{something}}, then {{foo: (that thing)}}
 */
function Variables(args) {

    this.bb               = args.bb;
    this.default_language = args.default_language;
    this.default_keys     = args.default_keys || {};
    this.error_callback   = args.error_callback;

    this.thread_languages = {};
    this.forum_languages  = {};

    Cacheable.call( this, args );

    var v = this;
    this.promise = this.promise.then(function() {
        v.set_namespaces();
        if ( v.check( 'policy', 'thread languages' ) ) {
            var tl = v.resolve( 'policy', 'thread languages', {}, 'hash of arrays' );
            Object.keys(tl).forEach(function(language) { tl[language].forEach(function(thread) { if ( thread.type == 'thread' ) v.thread_languages[thread.thread_id] = language; }); });
        }
        if ( v.check( 'policy', 'forum languages' ) ) {
            var fl = v.resolve( 'policy',  'forum languages', {}, 'hash of arrays' );
            Object.keys(fl).forEach(function(language) { fl[language].forEach(function(forum ) { if (  forum.type == 'forum'  ) v. forum_languages[ forum. forum_id] = language; }); });
        }
    });

}

Variables.prototype = Object.create(Cacheable.prototype, {

    bb               : { writable: true, configurable: false },
    default_language : { writable: true, configurable: false },
    default_keys     : { writable: true, configurable: false },

    // main list of variables (filled in by child classes):
    namespaces: { writable: true, configurable: false },

    // thread- and forum-specific language overrides:
    thread_languages: { writable: true, configurable: false },
    forum_languages : { writable: true, configurable: false },

    error_callback: { writable: true, configurable: false, default: function(message) { alert(message) } }

});
Variables.prototype.constructor = Variables;

/**
 * @summary get the target language
 * @param {Number} thread_id thread ID
 * @param {Number}  forum_id forum ID
 * @description thread language overrides forum language, which overrides user language,
 * EXCEPT when the user language is a specialism of the other language.
 * So a user language 'en-GB' overrides a thread language 'en',
 * but a user language 'en-GB' is overridden by a thread language 'fr'.
 */
Variables.prototype.get_language = function(thread_id, forum_id) {
    var language = this.default_language, thread_languages = this.thread_languages, forum_languages = this.forum_languages;
    if      ( thread_id && thread_languages.hasOwnProperty(thread_id) && language.search(thread_languages[thread_id]+'-')!=0 ) language = thread_languages[thread_id];
    else if (  forum_id &&  forum_languages.hasOwnProperty( forum_id) && language.search( forum_languages[ forum_id]+'-')!=0 ) language =  forum_languages[ forum_id];
    return language;
}


/**
 * @summary update the complete list of variables, grouped by namespace
 * @return {Array.<Object>}
 */
Variables.prototype.set_namespaces = function() {/*
    this.namespaces = this.cache.namespaces; // or however the child class builds namespaces
*/}


/**
 * @summary get a variable
 * @param {string}                namespace namespace to find the variable in
 * @param {string|Array.<string>} names     name(s) of variable within the namespace
 * @param {Number=}               forum_id  ID of forum to instantiate for
 * @param {Number=}               thread_id ID of thread to instantiate for
 * @return {Object} variable (if found) or error information (otherwise)
 *
 * @description
 * Variables need to follow some fairly specific rules to produce useful results:
 * Variables are defined per-language, then the language is decided based on the forum, thread, and default language.
 * Names are defined with sections split up by colons (e.g. "greeting message: newbie", "greeting message: old hand").
 * We always try to return the most specific name for the language, but can default to less-specific ones
 *
 * Variables can be set to special values to override normal handling:
 *
 * "OVERRIDE: pretend this variable exists but has no contents" treats a variable as an empty string.
 * Use this when you want to define a variable as an empty string.  Some WYSIWYG editors will delete
 * empty [quote] blocks - this is the recommended way to avoid those weird errors.
 *
 * "OVERRIDE: pretend this variable doesn't exist" treats a variable as if it didn't exist.
 * Use this when you want to define a general variable, then un-define it again in a more
 * specific variable.
 *
 * "NO OVERRIDE: ..." treats a variable as if the initial "NO OVERRIDE: " didn't exist.
 * Use this to include the above strings literally in a variable.
 *
 * The return object contains 'text' and 'error', one null and one non-null.
 * If there is an error, the object will also contain 'resolutions'.
 * See the source of .resolve() for an example of how to use this output.
 */
Variables.prototype.get = function( namespace, names, forum_id, thread_id ) {

    var root_name;
    if ( typeof(names) == 'string' ) {
        root_name = names;
        names = [];
    } else { // array of possible names
        root_name = names[0];
        names = names.slice(1);
    }

    var target_namespace = namespace + ': ' + this.get_language( thread_id, forum_id );

    // STEP TWO: get the best matching variable given the target language
    var text = null, old_match_quality = 0, matching_namespaces = [];
    this.namespaces.forEach(function(namespace) {
        var match_quality = Math.max.apply( Math, namespace.names.map(function(namespace) {
            return (
                  ( target_namespace == namespace )                   ? 3 // exact match
                : ( target_namespace.search( namespace + '-' ) == 0 ) ? 2 // want 'en-GB', have 'en'
                : ( namespace.search( target_namespace + '-' ) == 0 ) ? 2 // want 'en', have 'en-GB'
                : ( namespace       .replace( /: default$/, ': ' ) ==
                    target_namespace.replace( /: [-a-z]+$/, ': ' )  ) ? 1 // want 'en', have 'default'
                : 0
            );
        }));
        if ( match_quality ) matching_namespaces.push(namespace);
        if ( old_match_quality < match_quality ) {
            var name = root_name;
            for ( var n=1; n<Math.pow(2,names.length); ++n ) {
                var new_name = root_name + ': ' + names.filter(function(value,index) { return n & (1<<index) }).join(': ');
                if ( namespace.variables.hasOwnProperty(new_name.toLowerCase()) ) name = new_name;
            }
            if ( namespace.variables.hasOwnProperty(name.toLowerCase()) ) {
                text = namespace.variables[name.toLowerCase()];
                old_match_quality = match_quality;
            }
        }
    });

    var full_name = [ root_name ].concat(names).join(': ');

    var ret = {
        matching_namespaces: matching_namespaces,
        name: full_name
    };

    if ( text === null ) {
        return $.extend(ret, {
            text: null,
            error: "Couldn't find variable \"" + full_name + '" in namespace "' + target_namespace + '"',
            resolutions: this.suggest_resolutions(matching_namespaces),
        });
    } else {
        switch ( text ) {
        case "OVERRIDE: pretend this variable exists but has no contents": return $.extend(ret, { text: ''  , error: null });
        case "OVERRIDE: pretend this variable doesn't exist"             : return $.extend(ret, { text: null, error: 'variable overriden' });
        case '': console.log(
            'Warning: "' + full_name + '" in namespace "' + target_namespace + '" is an empty string.\n' +
            'These are deprecated for compatibility, and will become an error in a future version of the moderators\' etension.'
        );
            // FALL THROUGH
        default                                                          : return $.extend(ret, { text: text.replace( /^NO OVERRIDE: /, "" ), error: null });
        };
    }

}

/**
 * @summary check if a variable exists
 * @param {string}                namespace namespace to find the variable in
 * @param {string|Array.<string>} names     name(s) of variable within the namespace
 * @param {Number=}               forum_id  ID of forum to instantiate for
 * @param {Number=}               thread_id ID of thread to instantiate for
 * @param {boolean}
 */
Variables.prototype.check = function( namespace, names, forum_id, thread_id ) {
    return this.get( namespace, names, forum_id, thread_id ).text !== null;
}

/**
 * @summary parse the contents of a variable
 * @param {string}             text      string to parse
 * @param {Object.<string,*>=} keys      keys used to instantiate the variable
 * @param {string=}            parser    parser used for the variable ('string', 'array of items' or 'hash of arrays', default: 'string')
 * @param {Number=}            forum_id  ID of forum to instantiate for
 * @param {Number=}            thread_id ID of thread to instantiate for
 * @return {string|Array|Object} parsed output
 *
 * @description
 * Parse a string as a variable.
 *
 * Keys should either be strings or arrays of strings.  Arrays are automatically converted to sentences,
 * e.g. [1,2,3] becomes "1, 2 and 3".
 *
 * All parsers treat text {{between two curly brackets}} specially.  They're converted
 * either to one of the keys passed in, or alternatively to another matching variable.
 * Curly bracket blocks can be nested (e.g. {{variable for: {{key}}}}), and references
 * within referenced variables are themselves parsed for references.  To include a
 * literal that resembles a variable, put brackets between the key {{(like this)}}.
 *
 * See get() for special override values variables can have.
 *
 * The "string" parser returns plain string output, the "array of items" parser
 * expects the variable to be a BBCode [list], and the "hash of arrays" parser
 * expects the variable to be a BBCode [list] with a single [post], [thread] or [url]
 * element per list item.
 *
 * @example
 * v.parse( 'Hello {{username}}', { username: 'some user' }, 'string', 12, 345 );
 */
Variables.prototype.parse = function( text, keys, parser, forum_id, thread_id ) {

    var v = this;

    // resolve {{keys}}:
    keys = $.extend( {}, this.default_keys, keys || {} );
    var has_changed;
    do {
        has_changed = false;
        text = text.replace( /{{([^{}\n]+)}}/g, function(match, key) {
            if ( !key.search( /^\(.*\)$/ ) ) return match;
            key = key.toLowerCase();
            if ( keys.hasOwnProperty(key) ) {
                has_changed = true;
                var values = keys[key];
                if ( $.isArray(values) ) {
                    switch ( values.length ) {
                    case 0: return '';
                    case 1: return values[0];
                    default:
                        var last_joiner = v.get('other templates', 'localisation: list end joiner', forum_id, thread_id);
                        if ( last_joiner.error ) {
                            v.error_callback( last_joiner.error, last_joiner.resolutions );
                            throw last_joiner.error;
                        } else {
                            return values.slice( 0, values.length-1 ).join(', ') + last_joiner.text + values[values.length-1];
                        }
                    }
                } else {
                    return values;
                }
            } else if ( key.search(':') != -1 ) {
                var names = key.split(/:\s*/);
                var root_name = names.shift();
                if ( v.check( root_name, names, forum_id, thread_id ) ) {
                    has_changed = true;
                    return v.resolve( root_name, names, keys, 'string', forum_id, thread_id );
                }
            }
            return match;
        })
    } while ( has_changed );

    text = text.replace( /{{\(([^{}\n]+)\)}}/g, "{{$1}}" );


    // parse:
    function parse_array() {
        var array = [];
        text.replace( /\[list\]\s*\[\*\]\s*((?:.|\n)*?)\s*\[\/list\]/i, function(text, list) {
            array = list.split( /\s*\[\*\]\s*/);
        });
        return array;
    }

    function parse_item(item) {
        var ret = { type: 'text', value: item };
        item
            .replace( /^\[thread=([0-9]+)](.*)\[\/thread\]$/i, function(text, thread_id, value) {
                ret = { type: 'thread', value: value, thread_id: parseInt( thread_id, 10 ) };
                return '';
            }).replace(/^\[post=([0-9]+)](.*)\[\/post\]$/i, function(text, thread_id, value) {
                ret = { type: 'post', value: value, post_id: parseInt( thread_id, 10 ) };
                return '';
            }).replace(/^\[URL=\"[^"]*\/forumdisplay.php\?f=([0-9]+)"\](.*)\[\/URL\]$/i, function(text, forum_id, value) {
                ret = { type: 'forum', value: value, forum_id: parseInt( forum_id, 10 ) };
                return '';
            }).replace(/^\[URL=\"(.+)"\](.*)\[\/URL\]$/i, function(text, url, value) {
                ret = { type: 'url', value: value, url: url };
                return '';
            });
        return ret;
    }

    switch ( parser || 'string' ) {
    case 'string': return text;
    case 'array of items':
        return parse_array().map(parse_item);
    case 'hash of arrays':
        var ret = {};
        parse_array().forEach(function(item) {
            item.replace( /^\s*(.*?):\s*(.*?)\s*$/, function( text, key, values ) {
                if ( !ret.hasOwnProperty(key) ) ret[key] = [];
                ret[key] = ret[key].concat( values.split(/\s*,\s*/).map(parse_item) );
            });
        });
        return ret;
    default:
        throw "Please pass a known parser, not '" + parser + '"';
    }

}

/**
 * @summary escape a string for use in .parse()
 * @param {string} text string to escape
 * @return {string} escaped output
 *
 * @description
 * .parse() recursively parses keys passed to it.  If you want to include a key
 * that includes literal text {{between two curly brackets}}, you will need to
 * escape it.
 *
 */
Variables.prototype.escape = function( value ) { return value.replace( /{{([^{}\n]+)}}/g, "{{($1)}}") }

/**
 * @summary get() and parse() a variable
 * @param {string}                namespace namespace to find the variable in
 * @param {string|Array.<string>} names     name(s) of variable within the namespace
 * @param {Object.<string,*>=}    keys      keys used to instantiate the variable
 * @param {string=}               parser    parser used for the variable ('string', 'array of items' or 'hash of arrays', default: 'string')
 * @param {Number=}               forum_id  ID of forum to instantiate for
 * @param {Number=}               thread_id ID of thread to instantiate for
 *
 * @example
 * v.resolve( 'policy', [ 'greeting message', 'newbie' ], { name: username }, 'string', 12, 345 );
 */
Variables.prototype.resolve = function( namespace, names, keys, parser, forum_id, thread_id ) {

    var variable = this.get(namespace, names, forum_id, thread_id);

    if ( variable.error ) {
        this.error_callback( variable.error, variable.resolutions );
        throw variable.error;
    } else {
        return this.parse( variable.text, keys, parser, forum_id, thread_id );
    }

}

/*
 * Suggest ways to resolve a "missing variable" error
 * @abstract
 * @private
 * @param {Array} matching_namespaces namespace objects that could contain the variable
 * @param {Array} suggested resolutions
 */
Variables.prototype.suggest_resolutions = function( matching_namespaces ) {/*
    return [
        {
            message: 'edit some thread',
            href   : '/showthread.php?t=1234'
        }
    ];
*/}

/*
 * Suggest ways to resolve an error by editing it
 * @param {string}                namespace namespace to find the variable in
 * @param {string|Array.<string>} names     name(s) of variable within the namespace
 * @param {Number=}               forum_id  ID of forum to instantiate for
 * @param {Number=}               thread_id ID of thread to instantiate for
 */
Variables.prototype.suggest_resolutions_edit = function( namespace, names, forum_id, thread_id ) {

    var variable = this.get( namespace, names, forum_id, thread_id );

    var resolutions = this.suggest_resolutions( variable.matching_namespaces );

    resolutions.forEach(function(resolution) { resolution.message = ('edit ' + variable.name).replace( / +/g, '\xA0' ) });

    return resolutions;

}

/*
 * Build a namespace from a set of posts
 * @param {Array.<Number>} posts posts to retrieve
 * @param {jQuery.Promise}
 */
Variables.prototype.posts_namespace = function( posts, names ) {

    var bb = this.bb;
    var variables = {};
    return $.when.apply(
        $,
        posts.map(function(post_id) {
            return bb.post_info(post_id).then(function(info) {
                bb.quotes_process(info.bbcode).forEach(function(variable) {
                    variables[variable.author.toLowerCase()] = variable.text;
                })
            });
        })
    ).then(function() {
        return { variables: variables, names: names.map(function(name) { return name.name + ': ' + name.language }) };
    });

}


/*
 * VARIABLES FROM POST
 */

/**
 * @summary variables loaded from the first post in a thread
 * @constructor
 * @extends Variables
 */
function VariablesFromFirstPost(args) {
    args.default_language = 'default';
    Variables.prototype.constructor.call(this, args);
}
VariablesFromFirstPost.prototype = Object.create(Variables.prototype);

VariablesFromFirstPost.prototype.set_namespaces = function() { this.namespaces = this.cache.namespaces }

VariablesFromFirstPost.prototype.refresh = function(args) {
    var v = this;
    return this.bb.thread_posts( args.thread_id, args.first_page ).then(function (posts) {
        return v.posts_namespace( [ posts[0].post_id ], [{ name: args.namespace, language: 'default' }] ).then(function(namespace) { v.cache.namespaces = [namespace] });
    });
}

/*
 * VARIABLES FROM FORUM
 */

/**
 * @summary variables loaded from a forum full of variables
 * @constructor
 * @extends Variables
 */
function VariablesFromForum(args) {
    Variables.prototype.constructor.call(this, args);
}
VariablesFromForum.prototype = Object.create(Variables.prototype);

VariablesFromForum.prototype.suggest_resolutions = function( matching_namespaces ) {
    var v = this;
    return matching_namespaces.map(function(namespace) {
        var thread = Object.keys(v.cache.threads).filter(function(thread_id) { return v.cache.threads[thread_id].namespace.names.join(' / ') === namespace.names.join(' / ') })[0];
        thread = v.cache.threads[ thread ];
        return {
            message: 'Add variable to ' + thread.title,
            href   : '/showthread.php?t=' + thread.thread_id
        }
    });
}

VariablesFromForum.prototype.set_namespaces = function() {
    var threads = this.cache.threads;
    var keys = Object.keys( threads || {} );
    keys.sort(function(a,b) { return parseInt(a.thread_id,10) - parseInt(b.thread_id,10) });
    this.namespaces = keys.map(function(key) { return threads[key].namespace });
}

/**
 * Get namespace associated with a thread
 * @param {Object}       thread     thread to retrieve
 * @param {HTMLElement=} first_page first page of the thread
 * @return {jQuery.Promise}
 * @private
 */
VariablesFromForum.prototype.thread_namespace = function( thread, first_page ) {
    var v = this;
    if ( thread.replies && !first_page )
        return v.posts_namespace( thread.replies, thread.names ).then(function(namespace) { thread.namespace = namespace });
    else
        return this.bb.thread_posts( thread.thread_id, first_page ).then(function (posts) {
            thread.replies = posts.map(function(post) { return post.post_id });
            thread.replies.shift(); // ignore the first post
            return v.posts_namespace( thread.replies, thread.names ).then(function(namespace) { thread.namespace = namespace });
        });
}

/**
 * @summary Refresh the list of variables
 * @param {Object} args arguments passed to the constructor
 * @return {jQuery.promise}
 * @description downloads the variables forum and converts it to metadata
 */
VariablesFromForum.prototype.refresh = function(args) {

    var v = this;

    return this.bb.forum_threads(args.forum_id).then(function(threads) {

        // we cache thread modification times and reply lists to reduce the number of downloads:
        var old_threads = v.cache.threads || {};
        var new_threads = {};

        if ( !threads.length ) return; // in case we're logged out or something

        // reset namespaces:
        Object.keys(old_threads).forEach(function(thread_id) { old_threads[thread_id].names = [] });

        var promises = [];
        threads.forEach(function(thread) {
            thread.title.replace( /^([^ :]*):\s*([^:]*)$/, function( text, language, namespace ) { // thread titles should be of the form "language: namespace"

                var thread_data = old_threads[thread.thread_id];

                if ( !thread_data )
                    old_threads[thread.thread_id] = thread_data = { thread_id: thread.thread_id, last_modified: 'new', replies: [], names: [] };

                if ( thread.status == 'moved' ) language += '-'; // slightly reduce the match quality for redirects

                thread_data.names.push({ name: namespace, language: language });

                if ( !thread.reply_count ) return; // only add unmoved threads with at least one reply

                thread_data.title = thread.title;
                new_threads[thread.thread_id] = thread_data;

                // reload thread data
                if ( thread.last_modified && thread.last_modified != thread_data.last_modified ) {
                    thread_data.last_modified = thread.last_modified;
                    switch ( thread.reply_count - thread_data.replies.length ) {
                    case 0 :                                                break; // no new replies
                    case 1 : thread_data.replies.push(thread.last_post_id); break; // one new reply
                    default: delete thread_data.replies;                    break; // many new replies
                    }
                    promises.push(thread_data);
                }

            });

        });

        return $.when.apply( $, promises.map(function(thread) { return v.thread_namespace(thread) }) ).then(function() {
            v.cache.threads = new_threads;
            return v.update_cache();
        });

    }, function() {
        args.error_callback( "Could not refresh the list of variables", 'log in');
    });

}

/**
 * Refresh the list of variables associated with the specified thread
 * @param {Number}       thread_id thread to refresh
 * @param {HTMLElement=} body      page for the current thread
 * @param {jQuery.Promise}
 */
VariablesFromForum.prototype.refresh_thread = function(thread_id, body) {
    var v = this;
    if ( this.cache.threads.hasOwnProperty(thread_id) )
        return v.thread_namespace( this.cache.threads[thread_id], body ).then(function() { return v.update_cache() });
    else
        // TODO: handle this properly:
        return $.Deferred().reject('thread not initialised').promise();
}

/**
 * Serialise a variables thread for backup/later upload
 * @param {Number}      thread_id thread to refresh
 * @param {string}      title     title for the current thread
 * @param {HTMLElement} body      page for the current thread
 * @param {jQuery.Promise}
 */
VariablesFromForum.prototype.serialise_thread = function(thread_id, title, body) {

    var post_contents = [];
    var bb = this.bb;

    return bb.thread_posts( thread_id, body ).then(function(posts) {

        return $.when.apply( $, posts.map(function(post, index) {
            if ( post.post_id != '0' && !post.is_deleted ) {
                return bb.post_info(post.post_id).then(function(info) {
                    var root = $('<root><post></post></root>');
                    if ( post.title ) root.children().attr( 'title', post.title );
                    root.children().text( info.bbcode );
                    post_contents[index] = '	' + root.html() + "\n";
                });
            }
        }))
            .then(function() {
                var root = $('<root><thread></thread></root>');
                root.children()
                    .attr( 'id'   , thread_id )
                    .attr( 'title', title )
                    .html( '\n' + post_contents.filter(function(post) { return post }).join('') )
                ;
                return root.html().replace(/&nbsp;/g, '&#xA0;');
            });

    });

}