Source: Queue.js

const fs        = require('fs-extra');
const path      = require('path');
const Utils     = require('./Utils.js');
const Session   = require('./Session.js');
const otreeProject = require('./dialects/otree/project.js');


/**
 * A queue is an app made of other apps: a .jtq file that, when added to a session, adds its apps
 * to that session in its place. It sits in the app catalogue next to the apps, and is added to a
 * session with {@link Session#addApp} like any app.
 *
 * A .jtq file is a script run with the session in scope as `session`. Paths it gives to
 * `session.addApp` are relative to the .jtq file, and may name other queues. The script can also
 * declare session options (`session.addNumberOption(...)`) and set session fields and hooks
 * (`session.getApp = ...`), as these act on the whole session.
 *
 * An older format is JSON, `{displayName, apps: [appId | {appId, options}]}`, where an appId
 * without an extension is a .jtt file next to the queue.
 *
 * A queue the admin should not list among the apps says so: `queue.listed = false;` in a script,
 * `"listed": false` in JSON, `jtree_listed=False` in an oTree session config.
*/
class Queue {

    constructor(id, jt) {
        this.jt             = jt;
        this.id             = id;
        this.appPath        = id;
        this.shortId        = path.basename(id, '.jtq');
        this.displayName    = this.shortId;
        this.description    = undefined;
        /** The file's contents. */
        this.appjs          = '';
        /** For a JSON queue, its parsed contents, otherwise null. */
        this.json           = null;
        /** The apps this queue adds, as {appId, options}, with appId resolved to a full path. */
        this.apps           = [];
        /** The session options this queue declares. */
        this.options        = [];
        this.optionValues   = {};
        this.hasError       = false;
        /** Whether the admin lists this queue among the apps (see App#listed). */
        this.listed         = true;
    }

    /** Reads the .jtq file at filePath, and lists the apps and options it declares. */
    static load(filePath, jt) {
        var queue = new Queue(filePath, jt);
        // An oTree project's session config (see dialects/otree/project.js).
        const otreeConfig = otreeProject.parseQueueId(filePath);
        try {
            if (otreeConfig != null) {
                const { script, config } = otreeProject.queueScript(otreeConfig.settingsPath, otreeConfig.name);
                queue.appjs = script;
                queue.shortId = config.name;
                queue.displayName = config.display_name || config.name;
                queue.description = config.doc;
                queue.dirPath = path.dirname(otreeConfig.settingsPath);
                queue.otreeConfig = config;
                if (config.jtree_listed === false) queue.listed = false;
            } else {
                queue.appjs = Utils.readJS(filePath);
            }
        } catch (err) {
            queue.setError(err);
            return queue;
        }
        if (queue.appjs.trim().startsWith('{')) {
            try {
                queue.json = JSON.parse(queue.appjs);
                if (queue.json.displayName != null) {
                    queue.displayName = queue.json.displayName;
                }
                if (queue.json.listed === false) {
                    queue.listed = false;
                }
            } catch (err) {
                // Not JSON after all: run it as a script.
            }
        }
        var recorder = new QueueRecorder(queue);
        try {
            queue.run(recorder);
        } catch (err) {
            queue.setError(err);
        }
        queue.apps = recorder.apps;
        queue.options = recorder.options;
        queue.optionValues = recorder.optionValues;
        return queue;
    }

    /** The folder this queue's app paths are relative to. */
    dir() {
        return this.dirPath || path.dirname(this.id);
    }

    /** Whether id is a queue's: a .jtq file, or an oTree project's session config. */
    static isQueueId(id) {
        return String(id).endsWith('.jtq') || otreeProject.parseQueueId(id) != null;
    }

    resolve(appPath) {
        return path.isAbsolute(appPath) ? appPath : path.join(this.dir(), appPath);
    }

    /**
     * Runs this queue against target, a {@link Session} or a {@link QueueRecorder}, whose addApp
     * resolves paths relative to this queue.
     */
    run(target) {
        if (this.json != null) {
            var entries = this.json.apps || [];
            for (let i=0; i<entries.length; i++) {
                let appId = entries[i];
                let options = {};
                if (appId.appId != null) {
                    options = appId.options || {};
                    appId = appId.appId;
                }
                if (path.extname(appId) === '') {
                    appId += '.jtt';
                }
                target.addApp(appId, options);
            }
        } else {
            // The script refers to its target as `session`, and to this queue as `queue`.
            let session = target; // jshint ignore:line
            let queue = this; // jshint ignore:line
            eval(this.appjs); // jshint ignore:line
        }
    }

    /** Adds an app at the end of this queue's file. Reload the queue to see it. */
    addApp(appPath, options) {
        if (options == null) {
            options = {};
        }
        var relPath = path.relative(this.dir(), appPath).split(path.sep).join('/');
        if (this.json != null) {
            if (this.json.apps == null) {
                this.json.apps = [];
            }
            this.json.apps.push(Object.keys(options).length > 0 ? {appId: relPath, options: options} : relPath);
            fs.writeJSONSync(this.id, this.json, {spaces: 4});
        } else {
            var line = 'session.addApp(' + JSON.stringify(relPath);
            if (Object.keys(options).length > 0) {
                line += ', ' + JSON.stringify(options);
            }
            line += ');\n';
            var contents = this.appjs;
            if (contents.length > 0 && !contents.endsWith('\n')) {
                contents += '\n';
            }
            fs.writeFileSync(this.id, contents + line);
        }
    }

    setFileContents(contents) {
        fs.writeFileSync(this.id, contents);
    }

    reload() {
        return Queue.load(this.id, this.jt);
    }

    setError(err) {
        this.hasError = true;
        this.errorMessage = String(err);
        this.jt.log('Error loading queue ' + this.id + ': ' + err);
    }

    parentFolderName() {
        let x = this.id.split('\\');
        if (x.length < 2) {
            return 'noFolderSeparatorFound';
        }
        return x[x.length-2];
    }

    parentFolderFullName() {
        let x = this.id.lastIndexOf('\\');
        if (x === -1) {
            return this.id;
        }
        return this.id.substring(0, x);
    }

    /** What the admin interfaces read about an app, see {@link App#metaData}. */
    metaData() {
        return {
            id:             this.id,
            shortId:        this.shortId,
            title:          this.displayName,
            description:    this.description,
            appPath:        this.appPath,
            isQueue:        true,
            listed:         this.listed !== false,
            apps:           this.apps,
            options:        this.options,
            hasError:       this.hasError,
            errorMessage:   this.errorMessage,
            isStandaloneApp: true,
            stages:         [],
            appjs:          this.appjs,
            // An oTree project's session config (see dialects/otree/project.js).
            otreeConfig:    this.otreeConfig,
        };
    }

    shellWithChildren() {
        return this.metaData();
    }

    /** The shape older admin interfaces read from their list of queues. */
    shell() {
        var out = {}
        out.id              = this.id;
        out.displayName     = this.displayName;
        out.apps            = this.apps;
        out.options         = this.options;
        out.optionValues    = this.optionValues;
        return out;
    }

}

/**
 * Stands in for a session when a queue is read for the catalogue: records the apps and options
 * the queue declares, without loading them or writing anything to disk.
 */
class QueueRecorder {

    constructor(queue) {
        this.queue          = queue;
        this.apps           = [];
        this.options        = [];
        this.optionValues   = {};
    }

    addApp(appPath, options) {
        this.apps.push({
            appId: this.queue.resolve(appPath),
            options: options || {},
            indexInQueue: this.apps.length + 1,
        });
    }

    setNumParticipants(num) {
        this.suggestedNumParticipants = num;
    }

}

for (const name of ['addNumberOption', 'addTextOption', 'addSelectOption', 'setOptionValue']) {
    QueueRecorder.prototype[name] = Session.new.prototype[name];
}

var exports = module.exports = {};
exports.new = Queue;
exports.load = Queue.load;
exports.isQueueId = Queue.isQueueId;