Source: dialects/otree/convert.js

const fs = require('fs-extra');
const path = require('path');
const runtime = require('./runtime.js');
const staticFiles = require('./static.js');

/*
 * Converting an oTree app to a jtree app (plans/ztree-otree-dialects.md, section 4.7): a folder with
 * - app.jtt: the app's settings, constants, fields and functions, translated from Python, and its
 *   pages as stages;
 * - pages/<Page>.html: each page's screen, translated from its template to Vue;
 * - otree.cjs: what the translated code uses for oTree's and Python's functions (convert-runtime.js);
 * - CONVERSION.md: what was converted, and what needs work by hand (TODOs in the code).
 * The translation is Python's (python/otree_convert.py), from the app's syntax tree.
 *
 * From the command line:
 *     node server/source/dialects/otree/convert.js <oTree app folder> [<output folder>]
 */

const RUNTIME = path.join(__dirname, 'convert-runtime.js');
const STATIC_MARK = '@@jtree-static@@'; // around a static file's path, in what Python gives
const MAX_STATIC = 1024 * 1024;
const TYPES = {
    '.css': 'text/css', '.js': 'text/javascript', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
    '.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.mp3': 'audio/mpeg', '.mp4': 'video/mp4',
    '.pdf': 'application/pdf', '.woff2': 'font/woff2',
};

/** The file an oTree app's folder is loaded from: its __init__.py (or older models.py). */
function appFile(dir) {
    for (const name of ['__init__.py', 'models.py']) {
        if (fs.existsSync(path.join(dir, name))) return path.join(dir, name);
    }
    throw new Error(dir + ' is not an oTree app (it has no __init__.py)');
}

/**
 * Converts the oTree app in appDir to a jtree app in outDir.
 * @return {Promise<{name, level, report, files}>} level: 'Converts' or 'Converts with TODOs';
 * report: [{part, status ('converted' or 'todo'), note, line}]; files: the paths written.
 */
async function convertApp(appDir, outDir) {
    await runtime.start();
    appDir = path.resolve(appDir);
    const { packageFor } = require('./index.js');
    const pkg = packageFor(appFile(appDir));
    runtime.putApp(pkg, appDir);
    const bridge = runtime.getBridge();
    bridge.load(pkg, path.basename(appDir));
    let result;
    try {
        result = JSON.parse(runtime.py.pyimport('otree_convert').convert(pkg));
    } catch (err) {
        // Python's error, without its traceback.
        const lines = String(err.message || err).trim().split('\n');
        throw new Error(lines[lines.length - 1].replace(/^\w+Error: /, ''));
    }
    staticFiles.register(pkg, appDir);
    const report = result.report;
    const files = [];
    for (const [rel, text] of Object.entries(result.files)) {
        const page = rel.replace(/^pages\/|\.html$/g, '');
        fs.outputFileSync(path.join(outDir, rel), withStatics(text, pkg, page, report));
        files.push(rel);
    }
    fs.copyFileSync(RUNTIME, path.join(outDir, 'otree.cjs'));
    files.push('otree.cjs');
    const level = report.some((i) => i.status === 'todo') ? 'Converts with TODOs' : 'Converts';
    fs.outputFileSync(path.join(outDir, 'CONVERSION.md'), reportMarkdown(result.name, level, report));
    files.push('CONVERSION.md');
    return { name: result.name, level, report, files };
}

/** text with its static files ({{ static 'x' }}) as data: URLs, which work wherever the app is. */
function withStatics(text, pkg, page, report) {
    const re = new RegExp(STATIC_MARK + '(.*?)' + STATIC_MARK, 'g');
    return text.replace(re, (all, rel) => {
        const file = staticFiles.file(pkg, rel);
        const where = 'Page ' + page;
        if (file == null || fs.statSync(file).size > MAX_STATIC || TYPES[path.extname(file).toLowerCase()] == null) {
            const why = file == null ? 'not found' : fs.statSync(file).size > MAX_STATIC ? 'too large to put in the page' : 'of a type not put in pages';
            report.push({ part: where, status: 'todo', note: 'static file ' + rel + ': ' + why + '; give its URL', line: null });
            return rel;
        }
        if (!report.some((i) => i.part === where && i.note.includes(rel))) {
            report.push({ part: where, status: 'converted', note: 'static file ' + rel + ', put in the page as a data: URL', line: null });
        }
        return 'data:' + TYPES[path.extname(file).toLowerCase()] + ';base64,' + fs.readFileSync(file).toString('base64');
    });
}

/** CONVERSION.md: what was converted, and what needs work by hand. */
function reportMarkdown(name, level, report) {
    const lines = ['# ' + name + ': converted from oTree', '',
        '**' + level + '.** jtree converted the oTree app ' + name + ' to this jtree app:', '',
        '- `app.jtt`: its settings, constants, fields and functions (translated from Python), and its',
        '  pages, as stages;',
        '- `pages/`: each page\'s screen, translated from its template to Vue;',
        '- `otree.cjs`: what the translated code uses in place of oTree\'s and Python\'s functions.', ''];
    const todos = report.filter((i) => i.status === 'todo');
    if (todos.length > 0) {
        lines.push('## Needs work by hand', '', 'Each is a `TODO` in the code (which throws if it runs) or in a screen.', '');
        for (const item of todos) lines.push('- **' + item.part + '**: ' + item.note);
        lines.push('');
    }
    lines.push('## Converted', '');
    for (const item of report.filter((i) => i.status === 'converted')) {
        lines.push('- ' + item.part + (item.note ? ': ' + item.note : ''));
    }
    lines.push('', '## Differences from oTree', '',
        '- Amounts are numbers: currency is not rounded, nor shown as points.',
        '- A page\'s data (`player.page`) is worked out when the player starts the page, as oTree renders it then.',
        '- Pages\' scripts run when the participant\'s page loads, not when the page shows.', '');
    return lines.join('\n');
}

module.exports = { convertApp };

if (require.main === module) {
    const [appDir, outArg] = process.argv.slice(2);
    if (appDir == null) {
        console.log('Usage: node convert.js <oTree app folder> [<output folder>]');
        process.exit(1);
    }
    const outDir = outArg || path.resolve(appDir) + '-jtree';
    convertApp(appDir, outDir).then((result) => {
        console.log(result.name + ': ' + result.level + ', in ' + outDir);
        for (const item of result.report.filter((i) => i.status === 'todo')) {
            console.log('  TODO ' + item.part + ': ' + item.note);
        }
        process.exit(0);
    }, (err) => {
        console.log(String(err.message || err));
        process.exit(1);
    });
}