Source: src/action.js

/**
 * @file Manage a directed acyclic graph of moderation actions
 * @author Andrew Sayers
 * @description Manage actions that could be taken while e.g. resolving a report
 * Most actions are represented by a tree of Promise objects - the root set of promises fires first, followed by branch promises, towards leaf promises.
 * We occasionally need branches to join up again, so in the general case it's not a tree but a directed acyclic graph.
 *
 * See action-explanation.js for an overview of how to use Actions
 */

/**
 * @summary Manage a node and associated subset of the action graph
 * @param {...(Action|Array|Object)} var_args (arrays of) promises to fire
 * @constructor
 *
 * @example
 * var my_action = new Action(
 *     'Action title', // used to make debugging data more readable
 *     // pass a list of promises that will be executed
 *     {
 *         fire: function() { // called by action.fire()
 *             return $.post(...);
 *         },
 *         description: function() { // optional: return debugging information
 *             return [
 *                 { type: "PM", target: {username: 'Joe Bloggs', user_id: 12345  } },
 *             ];
 *         },
 *         blockers: function() {
 *             return [ 'Message describing how to resolve the thing blocking the action' ];
 *         },
 *     },
 *     // further promises will be executed in parallel:
 *     new Action(...),
 *     [ { ... }, new Action(...) ], // arrays are expanded automatically
 *     // ...
 * );
 */
function Action(title) {

    var promises  = this._promises  = [];
    var contained = this._contained = [];
    this._children  = [];
    this._level     = null;
    this._title     = title;

    function add(action) {
        if ( typeof(action) == 'undefined' ) {
            // ignore e.g. undefined values returned from a 'map' function
        } else if ( action instanceof Action ) {
            contained.push(action);
        } else if ( action !== null ) {
            promises .push(action);
        }
    }

    for ( var n=1; n!=arguments.length; ++n ) {
        if ( $.isArray(arguments[n]) )
            arguments[n].forEach(add);
        else
            add(arguments[n]);
    }

}

Action.prototype = Object.create(Object, {
    _promises  : { writable: true , configurable: false },
    _contained : { writable: true , configurable: false },
    _children  : { writable: true , configurable: false },
    _level     : { writable: true , configurable: false },
    _fire_count: { writable: true , configurable: false },
    _title     : { writable: true , configurable: false },
    _debug     : { writable: true , configurable: false, value: false } // check for programming errors in actions
});
Action.prototype.constructor = Action;

/**
 * @summary add another action that will fire after this completes
 * @param {...Action} actions actions to add
 * @return {Action} the current action
 * @description
 * This mimics jQuery's "then" API, but we currently modify the existing action instead of returning a new one.
 * This was an implementation shortcut and might be fixed if we find a use for the full behaviour
 */
Action.prototype.then = function() {

    var children = this._children;

    function add(action) {
        if ( typeof(action) == 'undefined' ) {
            // ignore e.g. undefined values returned from a 'map' function
        } else if ( action !== null ) {
            children.push(action);
        }
    }

    for ( var n=0; n!=arguments.length; ++n ) {
        if ( $.isArray(arguments[n]) )
            arguments[n].forEach(add);
        else
            add(arguments[n]);
    }

    return this;

}

/**
 * @summary fire the graph of actions starting with this one
 * @param {BulletinBoard} bb   BulletinBoard
 * @param {Object}        keys keys to pass to all actions
 * @return {jQuery.Promise} promise representing the full graph of actions
 *
 * @description the promise will be resolved when the final action is resolved,
 * or rejected shortly after the first action fails (ongoing requests will be allowed to finish).
 * progress() will be called regularly with a fraction representing the completeness of the complete set of actions.
 */
Action.prototype.fire = function(bb, keys) {

    /*
     * STEP ONE: calculate "progress levels"
     *
     * We calculate progress using the polite fiction that all actions at each level in the graph are fired at the same time.
     * Actions actually fire as soon as they're ready, but the progress bar looks prettier if we think of it that way
     *
     * 1. Actions are assigned "levels" based on their depth in the action graph
     * 2. The total number of promises at each level is calculated
     * 3. Each time an action's promise completes, progress increases by 100 / (number of promises at this level * total number of levels)
     *
     */

    var blockers = this.blockers();
    if ( blockers.length ) {
        alert( ['Blocked - please resolve the following issues:\n'].concat(blockers).join( "\n* " ) );
        return $.Deferred().reject().promise();
    }

    var promises_per_level = [ 0 ];

    function set_level(action, level) {

        action._fire_count = 0;

        if ( action._level != null ) throw "Please fix your action graph so this Action only appears at one point: " + JSON.stringify(action);
        action._level = level;

        if ( promises_per_level.length <= level ) promises_per_level[level] = 0;
        promises_per_level[level] += action._promises.length;

        // set level for contained actions, and calculate level at which child actions will fire
        var child_level = level;
        if ( action._promises.length ) ++child_level;
        action._contained.forEach(function(action) {
            var action_level = set_level( action, level );
            if ( child_level < action_level ) child_level = action_level;
        });

        var deepest_leaf_level = child_level; // level of the deepest leaf node fired by the action subgraph rooted at this node
        action._children.forEach(function(action) {
            var action_level = set_level(action, child_level);
            if ( deepest_leaf_level < action_level ) deepest_leaf_level = action_level;
        });

        return deepest_leaf_level;

    }
    set_level( this, 0 );

    function get_title(promise) {
        if ( !promise.hasOwnProperty('description') ) return;
        var descriptions = promise.description();
        if ( !descriptions ) return;
        descriptions = descriptions.map(function(desc) {
            switch ( desc.type ) {

            case 'PM'        : return      'PM [URL="' + location.origin + bb.url_for.user_show({ user_id: desc.target.user_id }) + '"]' + desc.target.username + '[/URL]';
            case 'warning'   : return    'warn [URL="' + location.origin + bb.url_for.user_show({ user_id: desc.target.user_id }) + '"]' + desc.target.username + '[/URL]';
            case 'infraction': return 'infract [URL="' + location.origin + bb.url_for.user_show({ user_id: desc.target.user_id }) + '"]' + desc.target.username + '[/URL]';

            case 'usernote'  : return 'update [URL="' + location.origin + bb.url_for.user_notes({ user_id: desc.target.user_id }) + '"]user notes for ' + desc.target.username + '[/URL]';

            case 'close'     : return 'close [thread=' + desc.target.thread_id + ']' + desc.target.thread_desc + '[/thread]';
            case 'post'      : return 'reply to ' + (
                desc.target.post_id
                ?   '[post=' + desc.target.  post_id + ']' + desc.target.thread_desc + '[/post]'
                : '[thread=' + desc.target.thread_id + ']' + desc.target.thread_desc + '[/thread]'
            );

            case 'create thread': return (
                desc.target.thread_id
                ? 'Create [thread=' + desc.target.thread_id + ']' + desc.target.title + '[/thread]'
                : 'Create '                                       + desc.target.title
            );

            case 'move posts':
                var posts = desc.target.posts.map(function(post) { return '[post=' + post + ']post #' + post + '[/post]' });
                switch ( posts.length ) {
                case 0 : posts = '(an empty list of posts)'; break;
                case 1 : posts = posts[0]; break;
                default: posts = posts.join(', ').replace( /(.*),/, '$1 and' ); break;
                };
                return 'Move ' + posts + ' to [thread=' + desc.target.thread.thread_id + ']' + ( desc.target.thread.title + '[/thread]' );

            case 'change thread forum' : return 'change forum for [thread='  + desc.target.thread_id + ']' + desc.target.thread_desc + '[/thread]';
            case 'change thread title' : return 'change title for [thread='  + desc.target.thread_id + ']' + desc.target.thread_desc + '[/thread]';
            case 'change thread status': return 'change status for [thread=' + desc.target.thread_id + ']' + desc.target.thread_desc + '[/thread]';
            case 'change thread prefix': return 'change prefix for [thread=' + desc.target.thread_id + ']' + desc.target.thread_desc + '[/thread]';
            case 'change thread icon'  : return 'change icon for [thread='   + desc.target.thread_id + ']' + desc.target.thread_desc + '[/thread]';

            case 'user IPs'  : return 'Download [URL="' + location.origin + bb.url_for.moderation_ipsearch($.extend( {depth:2}, desc.target )) + '"]IP address report for ' + desc.target.username + '[/URL]';
            case 'IP users'  : return 'Download user reports for ' + desc.target.length + ' IP address(es)';

            default          : return desc.type;

            }
        });
        switch ( descriptions.length ) {
        case 0: return;
        case 1: return descriptions[0];
        default:
            var last = descriptions.pop();
            return descriptions.join(', ') + ' and ' + last;
        }
    }

    /*
     * STEP TWO: fire actions in turn
     */

    var graph_dfd = $.Deferred();

    var progress = 0;

    var completed_promises = [];
    var failure_count = 0;

    function fire_action( action, keys, done_cb ) {

        if ( action._fire_count++ ) {
            throw "Giving up: cycle detected in action graph";
        }

        if ( failure_count ) return done_cb({ keys: keys }); // on failure, exit at the earliest convenience

        keys = $.extend( {}, keys ); // clone keys

        var start_time = new Date();
        var in_progress = 1; // in case of actions that return instantly, (see the last line in this function)

        // called when a child action completes:
        function child_completed(ret) {
            $.extend( keys, ret.keys );
            if ( !--in_progress ) done_cb({ keys: keys });
        }

        // called when all contained promises/actions have completed:
        function node_completed() {
            if ( action._children.length ) {
                in_progress = action._children.length;
                action._children.forEach(function(action) { fire_action( action, keys, child_completed ) });
            } else {
                done_cb({ keys: keys });
            }
        }

        // called when a contained promise or action completes:
        function contained_completed(ret, promise, result, error) {

            if ( ret && ret.hasOwnProperty('keys') ) $.extend( keys, ret.keys );

            if ( promise ) {
                promise.result     = result;
                promise.start_time = start_time;
                promise.end_time   = new Date();
                promise.error      = ( typeof(error) == 'string' ) ? error : '';
                promise.title      = get_title(promise);
                if ( !promise.title ) delete promise.title;
                completed_promises.push(promise);

                progress += 100 / promises_per_level[action._level];
                graph_dfd.notify( Math.floor( progress / promises_per_level.length ) );
            }

            if ( !--in_progress ) node_completed();

        }

        action._promises.forEach(function(promise) {
            if ( promise ) {
                if ( Action.prototype._debug ) {
                    try {
                        promise.promise = promise.fire($.extend( {}, keys ) );
                    } catch (error) {
                        console.log( action._title + ': ' + error, promise );
                        alert      ( action._title + ': ' + error );
                        throw error;
                    };
                } else {
                    promise.promise = promise.fire($.extend( {}, keys ) );
                }

                if ( promise.promise) {
                    ++in_progress;
                    if ( promise.promise.then ) { // looks like a promise
                        promise.promise = promise.promise.then(
                            function(ret) {                  contained_completed(ret , promise, 'success', null) },
                            function(err) { ++failure_count; contained_completed(null, promise, 'fail'   , err ) }
                        );
                    } else if ( promise.promise.keys ) { // looks like keys
                        contained_completed( promise.promise, null, 'success', null);
                    }
                }
            }
        });

        in_progress += action._contained.length;
        action._contained.forEach(function(action) { fire_action( action, keys, contained_completed ) });

        if ( !--in_progress ) node_completed(); // subtract the initial 'in progress' action

    }

    fire_action( this, keys, function(keys) {
        if ( failure_count )
            graph_dfd.reject ( completed_promises, keys.keys );
        else
            graph_dfd.resolve( completed_promises, keys.keys );
    });

    return graph_dfd.promise();

}

/**
 * @summary call fire(), with values logged to a journal post
 * @param {BulletinBoard}         bb        BulletinBoard
 * @param {Object}                keys      keys to pass to all actions
 * @param {Variables}             v         object to retrieve variables from
 * @param {Number}                thread_id thread to post the journal in
 * @param {string}                namespace namespace to retrieve journal variables from
 * @param {name}                  name      unique name of this action
 * @param {Array.<BulletinBoard>} extra_bbs other BulletinBoards to check before firing
 * @return {jQuery.Promise} promise representing the full graph of actions
 *
 * @description To improve the audit trail for large actions, you might want to
 * record the action you're about to perform, perform the action, then record
 * the outcome.
 *
 * This function posts a reply to a thread, calls .fire(), then edits the
 * post when .fire() completes.  Posts will be constructed using variables
 * named [ name + ( ' title' or ' body' ), 'before' or 'after' ], e.g.
 * [ name+' title', 'before' ] will be used to get the title for the "before" post.
 */
Action.prototype.fire_with_journal = function(bb, keys, v, thread_id, namespace, name, extra_bbs) {

    var action = this;

    var sort_order = {
        title    : 0,
        promises : 1,
        contained: 2,
        children : 3
    }

    keys['debug info'] = v.escape(
        bb.stringify(
            namespace + ': ' + name,
            this.long_description(),
            function(a,b) { return sort_order[a.key] < sort_order[b.key] ? -1 : 1 }
        )
    );

    function finalise(completed_promises, journal_post_id, keys, result) {

        var start_time = completed_promises[0].start_time;

        keys['action result data'] =
            'Action started at: ' + start_time + "\n" +
            '[table]'
        ;

        completed_promises = completed_promises.filter(function(promise) { return promise.hasOwnProperty('title') });

        var has_errors = completed_promises.reduce(function(prev,promise) { return prev + promise.error }, '' ) != '';
        keys['action result data'] += (
            has_errors
            ? '[tr][th]Result[/th][th]Time[/th][th]Duration[/th][th]title[/th][th]error[/th][/tr]'
            : '[tr][th]Result[/th][th]Time[/th][th]Duration[/th][th]title[/th][/tr]'
        );

        var escape_div = $('<div></div>');

        keys['action result data'] += completed_promises.map(function(promise) {
            return '[tr]' +
                ( ( promise.result == 'success' ) ? '[td]:) success' : '[td]:o failure' ) + '[/td]' +
                '[td]' + ( ( promise.start_time.getTime() - start_time.getTime() ) / 1000 ) + 's[/td]' +
                '[td]' + ( ( promise.end_time.getTime() - promise.start_time.getTime() ) / 1000 ) + 's[/td]' +
                '[td]' + promise.title + '[/td]' +
                ( has_errors
                  ? '[td][noparse]' + escape_div.text( promise.error ).html() + '[/noparse][/td]'
                  : ''
                ) +
                '[/tr]';
        }).join('');

        var end_time = completed_promises.reduce(function(prev, p) { return prev.getTime() > p.end_time ? prev : p.end_time }, start_time );
        keys['action result data'] +=
            '[/table]\n' +
            'Action completed at: ' + end_time + ' (total duration: ' + ( ( end_time.getTime() - start_time.getTime() ) / 1000 ) + 's)'
        ;

        return bb.post_edit({
            post_id: journal_post_id,
            title : v.resolve(namespace, [ name + ' title', 'after' ], keys),
            bbcode: v.resolve(namespace, [ name + ' body' , 'after' ], keys),
            reason: 'action ' + result
        });

    }

    var blockers = this.blockers();
    if ( blockers.length ) {
        alert( ['Blocked - please resolve the following issues:\n'].concat(blockers).join( "\n* " ) );
        return $.Deferred().reject().promise();
    }

    var promises = [
        bb.ping().then(function(data) {
            if ( data.result == 'success' ) {
                if ( ( data.duration < 1000 ) ||
                     (typeof(prompt(
                         'This might make things worse!\n' +
                         "Recommended: click 'cancel' to stop the action, then try again in a few hours\n" +
                         "Alternative: read the link below then click 'OK' to continue anyway\n",
                         location.origin + bb.url_for.thread_show({ thread_id: v.resolve( 'frequently used posts/threads', 'Slow Server explanation thread' ) })
                     )) == 'string')
                   )
                    return; // successful return
            } else {
                alert(
                    'The server could not be contacted.\n' +
                    "Please make sure you and the server are online, then try again."
                );
            }
            return $.Deferred().reject().promise(); // only reached if we don't get the successful return above
        }),
        bb.check_login().fail(function(message) {
            alert(message + "\nPlease resolve this problem, then try again.");
        })
    ];
    if ( extra_bbs ) extra_bbs.forEach(function(extra_bb) {
        promises.push(extra_bb.check_login().fail(function(message) {
            alert(message + "\nPlease resolve this problem, then try again.");
        }));
    });

    return $.when.apply( $, promises ).then(function() {
        return bb.thread_reply({
            thread_id: thread_id,
            title    : v.resolve(namespace, [ name + ' title', 'before' ], keys),
            bbcode   : v.resolve(namespace, [ name + ' body' , 'before' ], keys)
        }).then(function(journal_post_id) {

            keys['journal thread id'] = thread_id;
            keys['journal post id' ] = journal_post_id;

            return action.fire(bb, keys).then(
                function(completed_promises, keys) { return finalise( completed_promises, journal_post_id, keys, 'succeeded' ) },
                function(completed_promises, keys) { return finalise( completed_promises, journal_post_id, keys, 'failed'    ) }
            );

        });
    });

}


/**
 * @summary Long description of actions that will be performed (suitable for debugging use)
 * @return {string} long description
 */
Action.prototype.long_description = function() {

    // Get all the descriptions in the subgraph rooted at this node:
    var root_description = [], all_descriptions = [];
    var actions = [ [ root_description, this ] ];
    while ( actions.length ) {
        var action = actions.shift();
        var parent_description = action[0];
        action = action[1];
        var description = {
            title   : action._title,
            promises: action._promises.map(function(promise) {
                if ( promise.hasOwnProperty('description') ) return promise.description();
            }),
            contained: [],
            children: []
        };
        action._contained.forEach(function(action) { actions.push([ description.contained, action ]) });
        action._children .forEach(function(action) { actions.push([ description.children , action ]) });
        parent_description.push(description);
        all_descriptions.push(description);
    }

    // have to do this once .contained is fully populated:
    all_descriptions.forEach(function(description) {
        if ( !description.promises .reduce(function(prev, item) { return prev || item }, false ) ) delete description.promises;
        if ( !description.children .reduce(function(prev, item) { return prev || item }, false ) ) delete description.children;
        if ( !description.contained.reduce(function(prev, item) { return prev || item }, false ) ) delete description.contained;
    });

    root_description[0].description_build_time = new Date().getTime();

    return root_description[0];

}

/**
 * @summary describe the graph of actions starting with this one
 * @return {Array.<string>} short descriptions
 */
Action.prototype.title = function() {

    var descriptions = [];
    // Get all the descriptions in the subgraph rooted at this node:
    function get_descriptions(action) {
        action._promises.forEach(function(promise) {
            if ( !promise.hasOwnProperty('description') ) return;
            var desc = promise.description();
            if ( desc ) descriptions = descriptions.concat( desc );
        });
        action._contained.forEach(get_descriptions);
        action._children .forEach(get_descriptions);
    }
    get_descriptions(this);

    // Convert the list of actions to a user-friendly string:

    // STEP ONE: group together actions on a common target:
    var descriptions_by_target = { user: [], thread: [], 'change thread': [], 'create thread': [], posts: [] }, target_types = {

        'PM'        : 'user',
        'warning'   : 'user',
        'infraction': 'user',
        'usernote'  : 'user',
        'user IPs'  : 'user',

        'post' : 'thread',
        'close': 'thread',

        'create': 'create thread',
        'posts': 'posts',

        'change thread forum' : 'change thread',
        'change thread title' : 'change thread',
        'change thread status': 'change thread',
        'change thread prefix': 'change thread',
        'change thread icon'  : 'change thread'

    };
    descriptions.forEach(function(description, index) {

        if ( !target_types.hasOwnProperty(description.type) ) return;

        var target_type = target_types[description.type];
        var target = target_type == 'user' ? description.target.user_id : description.target.thread_id;
        if ( descriptions_by_target[target_type].hasOwnProperty(target) )
            descriptions_by_target[target_type][target].push( description );
        else
            descriptions_by_target[target_type][target] = [ description ];
    });
    Object.keys(descriptions_by_target).forEach(function(target) {
        descriptions_by_target[target].forEach(function(desc_list) {
            if ( desc_list.length == 1 ) return;
            desc_list[desc_list.length-1].type = desc_list.map(function(desc) {
                desc.ignore = true;
                switch ( desc.type ) {
                case 'PM'        : return 'PM';
                case 'warning'   : return 'warn';
                case 'infraction': return 'infract';
                case 'usernote'  : return 'add a note for';
                case 'user IPs'  : return 'build IP address report for';
                case 'post'      : return 'reply to';
                case 'close'     : return 'close';
                case 'create thread': return 'create';
                case 'move posts': return 'move';
                case 'change thread forum' : return 'forum';
                case 'change thread title' : return 'title';
                case 'change thread status': return 'status';
                case 'change thread prefix': return 'prefix';
                case 'change thread icon'  : return 'icon';
                default: throw 'impossible: ' + desc.type;
                }
            }).join(', ').replace( /, ([^,]*)$/, " and $1 " ).replace( / for,/, ',' );
            desc_list[desc_list.length-1].multiple_target = target;
        });
    });
    descriptions = descriptions.filter(function(desc) { return desc.multiple_target || !desc.ignore });

    // STEP TWO: GROUP ACTIONS BY TYPE
    var descriptions_by_type = {}, descriptions_list = [];

    descriptions.forEach(function(description, index) {
        if ( descriptions_by_type.hasOwnProperty(description.type) ) {
            descriptions_by_type[ description.type ].highest_index = index;
            descriptions_by_type[ description.type ].targets.push( description.target );
        } else {
            descriptions_list.push(
                descriptions_by_type[ description.type ] = { type: description.type, multiple_target: description.multiple_target, targets: [ description.target ], highest_index: index }
            );
        }
    });

    // STEP THREE: BUILD THE LIST
    return descriptions_list.sort(function(a,b) { return b.highest_index < a.highest_index }).map(function(desc_type) {
        var targets = desc_type.targets;
        switch ( desc_type.multiple_target ) {
        case 'user'  : return ( targets.length == 1 ) ? desc_type.type + targets[0].username : desc_type.type + targets.length + ' users';
        case 'thread': return ( targets.length == 1 ) ? desc_type.type + targets[0].thread_desc : desc_type.type + targets.length + ' threads';
        case 'change thread': return ( targets.length == 1 ) ? 'change ' + desc_type.type + 'for ' + targets[0].thread_desc : 'change ' + desc_type.type + 'for ' + targets.length + ' threads';
        case undefined:
            switch ( desc_type.type ) {

            case 'PM'        : return ( targets.length == 1 ) ? 'PM '      +          targets[0].username    : 'send '    + targets.length + ' PMs';
            case 'warning'   : return ( targets.length == 1 ) ? 'warn '    +          targets[0].username    : 'warn '    + targets.length + ' accounts';
            case 'infraction': return ( targets.length == 1 ) ? 'infract ' +          targets[0].username    : 'infract ' + targets.length + ' accounts';
            case 'usernote'  : return ( targets.length == 1 ) ? 'update notes for ' + targets[0].username    : 'update '  + targets.length + ' user notes';

            case 'post'      : return ( targets.length == 1 ) ? 'reply to ' +         targets[0].thread_desc : 'post '    + targets.length + ' replies';
            case 'close'     : return ( targets.length == 1 ) ? 'close '    +         targets[0].thread_desc : 'close '   + targets.length + ' threads';

            case 'create thread': return ( targets.length == 1 ) ? 'create a new thread' : 'create '   + targets.length + ' threads';
            case 'move posts'   :
                var post_count = targets.reduce( function(prev, t) { return prev + t.posts.length }, 0 );
                return ( post_count == 1 ) ? 'move one post'       : 'move '     + post_count + ' posts';

            case 'change thread forum' :
            case 'change thread title' :
            case 'change thread status':
            case 'change thread prefix':
            case 'change thread icon'  :
                var prefix = 'change ' + desc_type.type.substr(14) + ' for ';
                return ( targets.length == 1 ) ? prefix + targets[0].thread_desc : prefix + targets.length + ' threads';

            case 'user IPs'  :
                if ( targets.length == 1 )
                    return 'Build IP address report for ' + targets[0].username;
                else
                    return 'Build IP address report(s) for ' + targets.length + ' users';
            default:
                if ( targets.length == 1 )
                    return desc_type.type
                else
                    return desc_type.type + ' x ' + desc_type.targets.length;
            }
        }
    });

}

/**
 * @summary list of things blocking the action from firing
 * @return {Array.<string>} list of blockers
 */
Action.prototype.blockers = function() {

    var blockers = [];
    // Get all the blockers in the subgraph rooted at this node:
    function get_blockers(action) {
        action._promises.forEach(function(promise) {
            if ( !promise.hasOwnProperty('blockers') ) return;
            var blocker = promise.blockers();
            if ( blocker ) blockers = blockers.concat( blocker );
        });
        action._contained.forEach(get_blockers);
        action._children .forEach(get_blockers);
    }
    get_blockers(this);

    return blockers;

}