Source: dialects/otree/index.js

const crypto = require('crypto');
const fs = require('fs-extra');
const path = require('path');
const runtime = require('./runtime.js');
const staticFiles = require('./static.js');
const otreeExport = require('./export.js');
const Group = require('../../Group.js');
const Player = require('../../Player.js');

/*
 * oTree apps: a folder with an __init__.py in oTree's format (one file: C, Subsession, Group,
 * Player, functions and pages), or in its older format (models.py and pages.py, methods with
 * self, Django templates in templates/<app>/), and its pages' templates. Its Python runs in Pyodide
 * (runtime.js), with jtree's otree.api (python/otree/api.py), whose models stand for jtree's
 * objects. See plans/ztree-otree-dialects.md, section 4.
 *
 * How an oTree app becomes a jtree app:
 * - rounds are periods; PLAYERS_PER_GROUP the group size, grouped in order and kept in later
 *   rounds (oTree's default); creating_session runs when each round starts (app.periodStart).
 * - each Page is a stage players go through on their own: form_fields are its form's fields
 *   (checked as app.fields, with <field>_min/_max/_choices, then <field>_error_message and the
 *   page's error_message), is_displayed who plays it, before_next_page its playerEnd, timeouts
 *   each player's time. Its template is rendered by Python when the player starts it.
 * - each WaitPage is a stage that waits for the group (or all groups, wait_for_all_groups) and
 *   ends straight away, running after_all_players_arrive; group_by_arrival_time groups players
 *   by arrival.
 */

const KINDS = { int: 'int', number: 'number', currency: 'number', string: 'string', bool: 'bool' };

// oTree's pages are written for Bootstrap 5. jtree moves <link>s in screens to the page's head.
const STYLES = '<link rel="stylesheet" href="/shared/bootstrap-5.3.8/bootstrap.min.css">' +
    '<link rel="stylesheet" href="/participant/otree.css">' +
    '<script src="/participant/otree.js"></script>';

/** The Python package an app's folder is loaded as. */
function packageFor(appPath) {
    return 'otree_' + crypto.createHash('sha1').update(path.resolve(appPath)).digest('hex').substring(0, 12);
}

// Load the app catalogue again once Python has started, for each jtree that waited for it.
const reloaders = new WeakMap();
function reloadWhenReady(jt) {
    if (jt == null || jt.data == null) return;
    if (!reloaders.has(jt)) {
        reloaders.set(jt, () => {
            jt.data.reloadApps();
            if (jt.socketServer != null) jt.socketServer.refreshAdmins();
        });
    }
    runtime.whenReady(reloaders.get(jt));
}

/** Where in the app's files a Python error happened, from its traceback. */
function errorPosition(err, pkg, dir) {
    const re = new RegExp('File "/apps/' + pkg + '/([^"]+)", line (\\d+)', 'g');
    let last = null;
    for (const m of String(err.message || err).matchAll(re)) last = m;
    if (last == null) return null;
    return { file: path.join(dir, last[1]), line: last[2], column: 'unknown' };
}

function fieldFor(model, name, f, pkg) {
    const field = {};
    if (f.label != null) field.label = f.label;
    field.type = KINDS[f.kind] || 'string';
    if (f.choices != null || f.callbacks.includes('choices')) {
        field.type = 'choice';
        field.choices = f.choices;
    }
    for (const k of ['min', 'max']) {
        if (f[k] != null) field[k] = f[k];
    }
    if (f.blank) field.blank = true;
    for (const cb of ['min', 'max', 'choices']) {
        if (f.callbacks.includes(cb)) {
            field[cb] = (player) => runtime.getBridge().field_callback(pkg, model, name, cb, player);
        }
    }
    return field;
}

/** The session's chat with key: {messages, members (participant ids)}. */
function chatFor(session, key) {
    if (session.otreeChats == null) session.otreeChats = {};
    if (session.otreeChats[key] == null) session.otreeChats[key] = { messages: [], members: [] };
    return session.otreeChats[key];
}

/**
 * The round's initial values and creating_session, for an app that groups players by arrival: in
 * oTree they are every participant's player's, which jtree makes only as they arrive
 * (Period#arrivalGroupId). So they go into players of their own, in groups in the participants'
 * order, and what they set there is given to the participants' players when they arrive
 * (period.arrivalFields).
 */
function creatingSessionBeforeArrival(app, pkg, period, creatingSession) {
    const size = app.groupSize || 1;
    const groups = [];
    const players = [];
    for (const participant of Object.values(period.app.session.participants)) {
        if (groups.length === 0 || groups[groups.length - 1].players.length >= size) {
            groups.push(new Group.new(groups.length + 1, period));
        }
        const group = groups[groups.length - 1];
        const player = new Player.new(participant.id, participant, group, group.players.length + 1);
        group.players.push(player);
        players.push(player);
    }
    // The round's own (subsession) initial values on the round itself; the players' on theirs.
    runtime.getBridge().init_fields(pkg, period);
    const before = Object.create(period);
    before.groups = groups;
    runtime.getBridge().init_fields(pkg, before);
    if (creatingSession) runtime.getBridge().creating_session(pkg, before);
    period.arrivalFields = {};
    for (const player of players) {
        const fresh = new Player.new(player.id, player.participant, player.group, player.idInGroup);
        const fields = {};
        for (const key of Object.keys(player)) {
            if (!['group', 'participant'].includes(key) && JSON.stringify(player[key]) !== JSON.stringify(fresh[key])) {
                fields[key] = player[key];
            }
        }
        period.arrivalFields[player.participant.id] = fields;
    }
}

/** End player's (wait) stage, after what is running now. */
function endSoon(player) {
    player.session().pushMessage(player, true, 'endStage');
}

function definePage(app, page, pkg) {
    const stage = app.newStage(page.name);
    const has = (m) => page.methods.includes(m);
    const call = (method, player, ...args) => runtime.getBridge().call_page(pkg, page.name, method, player, ...args);
    if (has('is_displayed')) {
        stage.canPlayerParticipate = (player) => !!call('is_displayed', player);
    }

    // Pages have their own Next button (next_button), wait pages none.
    stage.addOKButtonIfNone = false;

    if (page.kind === 'wait') {
        stage.wrapPlayingScreenInFormTag = 'no';
        stage.waitToStart = true;
        stage.waitToEnd = true;
        stage.activeScreen = STYLES + '<div class="otree-page"><h2 class="otree-title">' + page.title_text +
            '</h2><p>' + page.body_text + '</p></div>';
        if (page.group_by_arrival_time) {
            app.groupByArrival = true;
        }
        if (page.wait_for_all_groups) {
            stage.waitForAllGroups = true;
            if (page.after_all_players_arrive) {
                stage.allGroupsStart = (period) => runtime.getBridge().after_all_players_arrive(pkg, page.name, period, 'subsession');
            }
        } else if (page.after_all_players_arrive) {
            stage.groupStart = (group) => runtime.getBridge().after_all_players_arrive(pkg, page.name, group, 'group');
        }
        stage.playerStart = endSoon;
        return;
    }

    // A page: each player on their own.
    stage.wrapPlayingScreenInFormTag = 'yes';
    stage.waitToStart = false;
    stage.waitToEnd = false;
    const model = page.form_model || 'player';
    stage.formFields = page.form_fields.map((f) => model + '.' + f);
    if (page.timeout_seconds != null) stage.clientDuration = page.timeout_seconds;
    if (has('get_timeout_seconds')) stage.getClientDuration = (player) => call('get_timeout_seconds', player) || 0;
    stage.validate = (player, values) => runtime.getBridge().validate(pkg, page.name, player, values) || undefined;
    stage.playerStart = (player) => {
        try {
            player.otreeHtml = runtime.getBridge().render(pkg, page.name, player, staticFiles.urlFor(player.jt().basePath, pkg));
            player.otreeJsVars = has('js_vars') ? runtime.getBridge().js_vars(pkg, page.name, player) : null;
            player.otreeError = undefined;
        } catch (err) {
            // Show that the page failed, rather than the page before it.
            const message = String(err.message || err).trim().split('\n').pop();
            player.otreeError = String(err.message || err);
            player.otreeHtml = '<div class="alert alert-danger">This page could not be shown: ' +
                message.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]) + '</div>';
            console.log('Error showing page ' + page.name + ' to ' + player.roomId() + ': ' + err.message);
        }
    };
    if (has('live_method')) {
        stage.otreeLive = true;
    }
    if (has('before_next_page') || has('app_after_this_page')) {
        stage.playerEnd = (player) => {
            if (has('before_next_page')) {
                call('before_next_page', player, !!player.timedOut);
            }
            if (has('app_after_this_page')) {
                // The app to go on to, by name, from the session's apps after this one.
                const apps = player.session().apps;
                const upcoming = apps.slice(player.app().indexInSession()).map((a) => a.shortId);
                const name = call('app_after_this_page', player, upcoming);
                if (name != null) {
                    const index = apps.findIndex((a, i) => i >= player.app().indexInSession() && a.shortId === name);
                    if (index < 0) {
                        throw new Error('app_after_this_page: ' + name + ' is not one of the apps after this one');
                    }
                    player.participant.skipToAppIndex = index + 1;
                }
            }
        };
    }
    // Only in the current stage's screen: jtree's page holds every stage's (see App#stageContentStart).
    stage.activeScreen = STYLES + '<div class="otree-page otree-content" v-if="stage.id == \'' + page.name + '\'" v-html="player.otreeHtml"></div>';
}

module.exports = {
    packageFor,
    name: 'otree',

    detect(appPath) {
        // An app's __init__.py, or the models.py of an app in oTree's older format.
        if (!['__init__.py', 'models.py'].includes(path.basename(appPath))) return false;
        try {
            return /\bfrom\s+otree\.api\s+import\b|\bimport\s+otree\.api\b/.test(fs.readFileSync(appPath, 'utf8'));
        } catch (err) {
            return false;
        }
    },

    /** Makes app the oTree app in appPath's folder. */
    define(app) {
        app.appjs = fs.readFileSync(app.appPath, 'utf8');
        let bridge;
        try {
            bridge = runtime.getBridge();
        } catch (err) {
            reloadWhenReady(app.jt);
            throw err;
        }
        const dir = path.dirname(app.appPath);
        const pkg = packageFor(app.appPath);
        runtime.putApp(pkg, dir);
        staticFiles.register(pkg, dir);
        let info;
        try {
            info = JSON.parse(bridge.load(pkg, path.basename(dir)));
        } catch (err) {
            err.jtreePosition = errorPosition(err, pkg, dir) || undefined;
            throw err;
        }
        app.otree = { pkg, constants: info.constants, roles: info.roles, fields: info.fields };
        app.playerFieldsNotInOutput = ['otreeHtml', 'otreeJsVars', 'otreeError', 'otreeChats'];
        app.shortId = info.name || path.basename(dir);
        app.title = info.name || path.basename(dir);
        if (info.doc) app.description = info.doc;
        app.numPeriods = info.num_rounds;
        if (info.players_per_group != null) app.groupSize = info.players_per_group;
        app.groupMatchingType = 'PARTNER_1122';
        app.fields = {};
        for (const model of ['player', 'group']) {
            for (const [name, f] of Object.entries(info.fields[model])) {
                app.fields[model + '.' + name] = fieldFor(model, name, f, pkg);
            }
        }
        // Downloads of this app's data: as oTree's per-app CSV, and its custom_export if it has one.
        app.exporters = (session) => {
            const own = session.apps.find((a) => a.otree != null && a.otree.pkg === pkg) || app;
            const out = [{
                id: 'otree-app-' + app.shortId,
                name: 'oTree CSV of ' + app.shortId,
                filename: () => app.shortId + '-' + session.id + '.csv',
                contentType: 'text/csv',
                write: () => otreeExport.appCSV(session, own),
            }];
            if (runtime.getBridge().has_custom_export(pkg)) {
                out.push({
                    id: 'otree-custom-' + app.shortId,
                    name: 'Custom export of ' + app.shortId,
                    filename: () => app.shortId + '-custom-' + session.id + '.csv',
                    contentType: 'text/csv',
                    write: () => otreeExport.csv(JSON.parse(runtime.getBridge().custom_export(pkg, own))),
                });
            }
            return out;
        };
        // Each round: its fields' initial values, then creating_session (if the app has one).
        app.periodStart = (period) => {
            if (app.groupByArrival) {
                creatingSessionBeforeArrival(app, pkg, period, info.creating_session);
            } else {
                runtime.getBridge().init_fields(pkg, period);
                if (info.creating_session) runtime.getBridge().creating_session(pkg, period);
            }
        };
        for (const page of info.pages) {
            definePage(app, page, pkg);
        }
        // liveSend(data) on a page with a live_method: what it returns goes to the group's
        // players' pages, to liveRecv(data); 0 is everyone.
        app.messages.liveSend = function(data) {
            const player = this.participant.player;
            if (player == null || !player.stage.otreeLive || player.status !== 'playing') {
                return;
            }
            const sends = runtime.getBridge().live(pkg, player.stage.id, player, data);
            const io = player.session().io();
            for (const [id, value] of sends || []) {
                for (const p of player.group.players) {
                    if (id === 0 || p.idInGroup === id) {
                        io.to(p.participant.roomId()).emit('liveRecv', value);
                    }
                }
            }
        };
        // Its data as the page sent it, not with numbers parsed (see Session#processMessage).
        app.messages.liveSend.convertsOwnValues = true;

        // {{ chat }}: a page joins the chats it was rendered with (player.otreeChats, by key, with
        // the player's nickname), gets what was said, and sends; everyone in the chat gets it.
        app.messages.otreeChatJoin = function(data) {
            const player = this.participant.player;
            const key = data && data.channel;
            if (player == null || player.otreeChats == null || player.otreeChats[key] == null) return;
            const chat = chatFor(player.session(), key);
            if (!chat.members.includes(this.participant.id)) chat.members.push(this.participant.id);
            player.session().io().to(this.participant.roomId()).emit('otreeChatHistory', { channel: key, messages: chat.messages });
        };
        app.messages.otreeChat = function(data) {
            const player = this.participant.player;
            const key = data && data.channel;
            const body = data && typeof data.body === 'string' ? data.body.trim().slice(0, 500) : '';
            if (player == null || player.otreeChats == null || player.otreeChats[key] == null || body === '') return;
            const session = player.session();
            const chat = chatFor(session, key);
            const message = { nickname: player.otreeChats[key], body, participant: this.participant.id, time: Date.now() };
            chat.messages.push(message);
            for (const id of chat.members) {
                const member = session.participants[id];
                if (member != null) session.io().to(member.roomId()).emit('otreeChatMessage', { channel: key, message });
            }
        };
        app.messages.otreeChatJoin.convertsOwnValues = true;
        app.messages.otreeChat.convertsOwnValues = true;
    },
};