Source: core/Data.js

const fs = require('fs-extra');
const Utils = require('../Utils.js');
const path = require('path');
const Session = require('../Session.js');
const App = require('../App.js');
const Room = require('../Room.js');
const Queue = require('../Queue.js');
const User = require('../User.js');
const { loadDefaultApp } = require('./data/loadDefaultApp.js')
const { getIdFromDirectory } = require('./data/getIdFromDirectory.js')
const { dialectFor } = require('../dialects/index.js')
const otreeProject = require('../dialects/otree/project.js')
/** The data object. */
class Data {

    /*
     * FUNCTIONALITY
     * - initialize fields
     * - commence storing time information {@link Data#storeTimeInfo}.
     * @param  {Object} jt The server.
     */
    constructor(jt) {

        /*
         * The server.
         * @type Object
         */
        this.jt = jt;
        jt.data = this;

        this.lastOpenedSession = null;

        /*
         * The last time the server was on, {@link Data#loadLastTimeOn}.
         * @type number
         */
        this.lastTimeOn = this.loadLastTimeOn();

                /*
         * The list of available apps, loaded from the contents of the {@link Settings#appsFolder} folder.
         * @type Object
         */

        this.reloadApps();

        /*
         * Available [Sessions]{@link Session}, loaded from the contents of the 'sessions' folder.
         * Sorted in ascending order according to time created.
         * @type Array of {@link Session}.
         */
        this.sessions = [];
        if (jt.settings.loadSessions) {
            this.sessions = this.loadSessions();
        }

        this.rooms = this.loadRooms();
        for (const room of this.pendingRooms || []) {
            this.addRoom(room);
        }
        this.pendingRooms = [];

        this.users = this.loadUsers();

        this.storeTimeInfo();

        // Participant clients with no session IDs.
        // Reload them when a new session is opened.
        this.participantClients = [];

    }

    /*
     * Set a timeout to write the time info {@link Data#storeTimeInfo}. Timeout length is {@link Settings#autoSaveFreq} milliseconds.
     *
     * CALLED FROM
     * - {@link Data#storeTimeInfo}
     *
     */
    callStoreTimeInfoFunc() {
        if (this.stopped) {
            return;
        }
        this.storeTimeInfoTimer = setTimeout(this.storeTimeInfo.bind(this), this.jt.settings.autoSaveFreq);
    }

    /**
     * Stops what keeps running in the background: saving the time info, and each session's
     * timers and data file. Resolves once the files are written. CALLED FROM jt.stop (see jtree.js).
     * @return {Promise}
     */
    stop() {
        this.stopped = true;
        clearTimeout(this.storeTimeInfoTimer);
        // When jtree was last on, for stage timers of sessions loaded later (see Group.load).
        try {
            fs.writeJSONSync(path.join(this.jt.path, this.jt.settings.serverTimeInfoFilename), Date.now());
        } catch (err) {
            console.log('Error saving the time: ' + err);
        }
        for (const session of this.sessions) {
            for (const timer of session.timers()) {
                timer.clear();
            }
            for (const participant of Object.values(session.participants)) {
                if (participant.appTimer != null) {
                    participant.appTimer.clear();
                }
            }
        }
        return Promise.all(this.sessions
            .filter((session) => session.fileStream != null)
            .map((session) => new Promise((resolve) => session.fileStream.end(resolve))));
    }

    /*
     * getMostRecentActiveSession -
     *
     * CALLED FROM:
     * - {@link StaticServer.sendClientPage}.
     *
     * @return {Session|null}  The session, or null if none exists.
     */
    getMostRecentActiveSession() {
        if (this.lastOpenedSession !== null) {
            return this.lastOpenedSession;
        }
        for (var i = this.sessions.length - 1; i >= 0; i--) {
            if (this.sessions[i].active === true) {
                return this.sessions[i];
            }
        }
        return null;
    }

    sessionsForUser(userId) {
        var out = [];
        for (var i in this.sessions) {
            var session = this.sessions[i];
            if (session.canUserManage(userId)) {
                out.push(session.shell());
            }
        }
        return out;
    }

    getClients(sessionId) {
        let out = [];
        let session = this.getSession(sessionId);
        if (sessionId != null) {
            if (session != null) {
                for (let i=0; i<session.clients.length; i++) {
                    out.push(session.clients[i].toShell());
                }
            }
        } else {
            for (let i in this.participantClients) {
                out.push(this.participantClients[i].shell());
            }
        }
        return out;
    }

    /*
     * FUNCTIONALITY
     * write current time to disk
     * set timeout for next write {@link Data#callStoreTimeInfoFunc}.
     *
     * CALLED FROM:
     * - [Data()]{@link Data}
     * - {@link Data#callStoreTimeInfoFunc}
     *
     */
    storeTimeInfo() {
        var fn = path.join(this.jt.path, this.jt.settings.serverTimeInfoFilename);
        var now = Date.now();
        fs.writeJSON(fn, now, this.callStoreTimeInfoFunc.bind(this));
    }

    app(id, options) {
        if (this.jt.settings.reloadApps) {
            var appPath = this.appsMetaData[id].appPath;
            if (Queue.isQueueId(appPath)) {
                return Queue.load(appPath, this.jt);
            }
            return this.loadApp(id, null, appPath, options);
        } else {
            return this.apps[id];
        }
    }
    
  loadApp(id, session, appPath, options) {
    console.log('load', appPath, options)
        var app = null;
        app = new App.new(session, this.jt, appPath);
        app.givenOptions = options;
     //   app.shortId = id;

        // Set options before running code.
         for (var i in options) {
            app.setOptionValue(i, options[i]);
        }

        let filePath = appPath;
        const dialect = dialectFor(filePath);
        if (dialect == null) {
            return null;
        }

        try {
            if (dialect.name === 'jtree' && Utils.readJS(filePath).startsWith('//NOTSTANDALONEAPP')) {
                return null;
            }
            dialect.define(app);
            this.jt.log('loaded app ' + filePath);
        } catch (err) {
            // A .js file that is not an app, e.g. a page's script.
            if (filePath.endsWith('.js')) {
                return null;
            }
            if (app.isStandaloneApp) {
                app.hasError = true;
                // Where: in this file, or in one of a folder app's stage files (see App#addStage).
                const pos = err.jtreePosition || { file: filePath, line: 'unknown', column: 'unknown' };
                app.errorFile = pos.file;
                app.errorLine = pos.line;
                app.errorPosition = pos.column;
                this.jt.log('Error in app ' + pos.file + ', line ' + pos.line + ', position ' + pos.column + ': ' + err);
            }
        }
        return app;
    }

    // Search for *.js and *.jtt files, and load them as apps, and *.jtq files, as queues.
    // Search subfolders.
    loadAppDir(dir) {
        if (Utils.isDirectory(dir)) {
          var appDirContents = fs.readdirSync(dir);
          const loadedDefaultApp = loadDefaultApp(dir)
            // Load individual apps and queues.
            for (var i in appDirContents) {
                var curPath = path.join(dir, appDirContents[i]);
                var curPathIsFile = fs.lstatSync(curPath).isFile();
                var curPathIsFolder = fs.lstatSync(curPath).isDirectory();
                if (curPathIsFile) {
                    var id = appDirContents[i];

                    // In a folder app, the other files are its stages and client files, not apps.
                    if (loadedDefaultApp != null && !['app.js', 'app.jtt'].includes(id) && !id.endsWith('.jtq')) {
                        continue;
                    }

                    let isApp = false;
                    // Treatment / App
                    if (id == 'app.js' || id == 'app.jtt') {
                      isApp = true;
                      id = getIdFromDirectory(dir)
                    }
                    if (id === '__init__.py' || id === 'models.py') {
                        // An oTree app (see dialects/otree), if it is one; models.py in its older format.
                        isApp = dialectFor(curPath) != null;
                        id = getIdFromDirectory(dir);
                    } else if (id.endsWith('.app.json')) {
                        isApp = true;
                        id = id.substring(0, id.length - '.app.json'.length);
                    } else if (id.endsWith('.js')) {
                        isApp = true;
                        id = id.substring(0, id.length - '.js'.length);
                    } else if (/\.zt[tq]$/i.test(id) || (/\.txt$/i.test(id) && require('../dialects/ztree/ztext.js').isTextFile(curPath))) {
                        // A z-Tree treatment or questionnaire (see dialects/ztree), or z-Tree's text export of one.
                        isApp = true;
                        id = id.substring(0, id.length - '.ztt'.length);
                    } else if (id.endsWith('.jtt')) {
                        isApp = true;
                        id = id.substring(0, id.length - '.jtt'.length);
                    }
                    if (isApp) {
                        let app = this.loadApp(id, {}, curPath, {});
                        if (app != null) {
                            this.apps[curPath] = app;
                            this.appsMetaData[curPath] = app.metaData();
                        }
                    }

                    // An oTree project: each of its session configs is a queue.
                    if (id === 'settings.py' && otreeProject.isProject(curPath)) {
                        this.loadOtreeProject(curPath);
                    }

                    // Queue: an app made of other apps.
                    if (id.endsWith('.jtq')) {
                        var queue = Queue.load(curPath, this.jt);
                        this.jt.log('loading queue ' + curPath + ' with ' + queue.apps.length + ' apps');
                        this.apps[curPath] = queue;
                        this.appsMetaData[curPath] = queue.metaData();
                    }
                } else if (curPathIsFolder) {
                    this.loadAppDir(curPath);
                }
            }

        }
    }

    /** Adds the session configs of the oTree project of settingsPath to the catalogue, as queues. */
    loadOtreeProject(settingsPath) {
        let configs;
        try {
            configs = otreeProject.sessionConfigs(settingsPath);
        } catch (err) {
            if (err.name === 'PythonStarting') {
                require('../dialects/otree/runtime.js').whenReady(() => this.reloadApps());
            } else {
                this.jt.log('Error reading oTree project ' + settingsPath + ': ' + err);
            }
            return;
        }
        for (const config of configs) {
            const id = otreeProject.queueId(settingsPath, config.name);
            const queue = Queue.load(id, this.jt);
            this.apps[id] = queue;
            this.appsMetaData[id] = queue.metaData();
        }
        // Its ROOMS, as rooms (not saved in the rooms folder).
        try {
            for (const r of otreeProject.rooms(settingsPath)) {
                const room = new Room.new(r.id, this.jt);
                room.displayName = r.displayName;
                room.useSecureURLs = false;
                room.allowNewPIds = r.labels == null;
                room.labels = r.labels || [];
                room.fromOtreeProject = settingsPath;
                this.addRoom(room);
            }
        } catch (err) {
            this.jt.log('Error reading the rooms of oTree project ' + settingsPath + ': ' + err);
        }
    }

    /** Adds room to the rooms, in place of one with its id (rooms found loading apps wait for the rooms). */
    addRoom(room) {
        if (this.rooms == null) {
            this.pendingRooms = (this.pendingRooms || []).concat([room]);
            return;
        }
        const i = this.rooms.findIndex((r) => r.id === room.id);
        if (i >= 0) {
            // Keep who is waiting in it, and the session open in it.
            room.participants = this.rooms[i].participants;
            room.sessionId = this.rooms[i].sessionId;
            this.rooms[i] = room;
        } else {
            this.rooms.push(room);
        }
    }

    saveRoom(room) {
        this.deleteRoom(room.originalId);
        var newRoom = new Room.new(room.id, this.jt);
        newRoom.displayName = room.displayName;
        newRoom.labels = room.labels;
        newRoom.useSecureURLs = room.useSecureURLs;
        this.createRoomFromRoom(newRoom);
    }

    deleteApp(id) {
        try {
            if (!id.startsWith(this.jt.path)) {
                id = this.appPath(id);                
            }
            fs.removeSync(id);
            delete this.apps[id];
            delete this.appsMetaData[id];
        } catch (err) {
            this.jt.log(err);
        }
    }

    deleteRoom(id) {
        try {
            fs.removeSync(this.roomPath(id));
            for (var i in this.rooms) {
                if (this.rooms[i].id === id) {
                    this.rooms.splice(i, 1);
                    break;
                }
            }
        } catch (err) {

        }
    }

    reloadApps() {
        this.apps = {};
        this.appsMetaData = {};
        this.loadApps();
    }

    loadApps() {
        for (var i in this.jt.settings.appFolders) {
            var folder = this.jt.settings.appFolders[i];
            // Relative to jtree's folder, or absolute.
            this.loadAppDir(path.resolve(this.jt.path, folder));
        }
    }

    /*
     * loadSessions - description
     *
     * CALLED FROM:
     * - {@link Data.constructor}.
     *
     * @return {type}  Array of sessions.
     */
    loadSessions() {
        var out = [];

        const sessPath = path.join(this.jt.path, this.jt.settings.sessionsFolder);
        fs.ensureDirSync(sessPath);

        var dirContents = fs.readdirSync(sessPath);
        for (var i in dirContents) {
            try {
                var folder = dirContents[i];
                var session = this.loadSession(folder);
                if (session !== null) {
                    out.push(session);
                } else {
                    var pathToFolder = path.join(this.jt.path, this.jt.settings.sessionsFolder + '/' + folder + '/');
                    var contents = fs.readdirSync(pathToFolder);
                    if (contents.length === 0) {
                        console.log('completing delete of session ' + folder);
                        fs.removeSync(pathToFolder);
                    }
                }
            } catch (err) {
                jt.log(err);
            }
        }
        return out;
    }

    roomPath(id) {
        return path.join(this.roomsPath(), id);
    }

    userPath(id) {
        return path.join(this.usersPath(), id + '.json');
    }

    roomsPath() {
        return path.join(this.jt.path, this.jt.settings.roomsPath);
    }

    usersPath() {
        return path.join(this.jt.path, this.jt.settings.usersPath);
    }

    room(id) {
        return Utils.findByIdWOJQ(this.rooms, id);
    }

    /** The queues in the app catalogue. */
    get queues() {
        return Object.values(this.apps).filter((app) => app instanceof Queue.new);
    }

    queue(id) {
        var app = this.apps[id];
        return app instanceof Queue.new ? app : null;
    }

    loadRooms() {
        var out = [];
        var fullPath = this.roomsPath();

        if (Utils.isDirectory(fullPath)) {
            var dirContents = fs.readdirSync(fullPath);
            for (var i in dirContents) {
                try {
                    var id = dirContents[i];
                    var room = Room.load(this.roomPath(id), id, this.jt);
                    out.push(room);
                } catch (err) {
                    console.log(err);
                }
            }
        }

        return out;
    }

    loadUsers() {
        var out = [];
        var fullPath = this.usersPath();

        if (Utils.isDirectory(fullPath)) {
            var dirContents = fs.readdirSync(fullPath);
            for (var i in dirContents) {
                try {
                    var id = dirContents[i];
                    var user = User.load(this.usersPath(), id, this.jt);
                    out.push(user);
                } catch (err) {
                    console.log(err);
                }
            }
        }

        return out;
    }

    /**
     * Creates an empty queue at apps/<id>.jtq, and adds it to the app catalogue.
     * @return {Queue|null} The queue, or null if the file already exists.
     */
    createQueue(id) {
        if (!id.endsWith('.jtq')) {
            id += '.jtq';
        }
        var queuePath = path.join(path.resolve(this.jt.path, this.jt.settings.appFolders[0]), id);
        if (fs.existsSync(queuePath)) {
            return null;
        }
        fs.outputFileSync(queuePath, '// Each line adds an app to the session. Paths are relative to this file.\n');
        return this.reloadQueue(queuePath);
    }

    /** Reads the queue at queuePath again, and replaces it in the app catalogue. */
    reloadQueue(queuePath) {
        var queue = Queue.load(queuePath, this.jt);
        this.apps[queuePath] = queue;
        this.appsMetaData[queuePath] = queue.metaData();
        return queue;
    }

    createRoom(id) {
        // If already exists, return null.
        if (fs.existsSync(this.roomPath(id))) {
            return null;
        }

        var room = new Room.new(id, this.jt);
        this.createRoomFromRoom(room);
        return room;
    }

    createUser(id, type) {
        // If already exists, return null.
        if (fs.existsSync(this.userPath(id))) {
            return null;
        }

        var user = new User.new(id, this.jt);
        user.type = type;

        fs.ensureDirSync(this.usersPath());
        Utils.writeJSON(this.userPath(user.id), user.shell());
        return user;
    }

    user(id) {
        for (var i in this.users) {
            var user = this.users[i];
            if (user.id === id) {
                return user;
            }
        }
        return null;
    }

    appPath(id) {
        return path.join(this.jt.path, 'apps/' + id);
    }

    createApp(id) {
        if (fs.existsSync(this.appPath(id))) {
            return null;
        }

        var session = null;
        var appPath = this.appPath(id);
        var app = new App.new(session, this.jt, appPath);

        fs.writeFileSync(this.appPath(id), '');

        this.apps[app.id] = app;
        this.appsMetaData[app.id] = app.metaData();

        return app.metaData();
    }

    createRoomFromRoom(room) {
        try {
            fs.mkdirSync(this.roomPath(room.id));
        } catch (err) {}

        try {
            var config = {};
            config.displayName = room.displayName;
            config.useSecureURLs = room.useSecureURLs;
            Utils.writeJSON(path.join(this.roomPath(room.id), 'room.json'), config);
        } catch (err) {}

        try {
            for (var i in room.labels) {
                var label = room.labels[i].trim();
                // After removing leading and trailing white space, must have length > 0
                if (label.length > 0) {
                    fs.appendFileSync(path.join(this.roomPath(room.id), 'labels.txt'), label + '\n');
                }
            }
        } catch (err) {}

        try {
            room.genHashes();
        } catch (err) {}

        this.rooms.push(room);
    }

    loadSession(folder) {
        var out = null;
        try {
            var pathToFolder = path.join(this.jt.path, this.jt.settings.sessionsFolder + '/' + folder + '/');
            this.jt.log('loading session: ' + folder);
            if (folder !== null && fs.lstatSync(pathToFolder).isDirectory()) {
                out = Session.load(this.jt, folder, this);
            }
        } catch (err) {
            console.log('error loading session WS: ' + folder);
            //        console.log(err);
        }
        return out;
    }

    loadLastTimeOn() {
        var out = Date.now();
        try {
            out = fs.readJSONSync(path.join(this.jt.path, this.jt.settings.serverTimeInfoFilename));
        } catch (err) {}
        this.jt.log("last time on: " + out);
        return out;
    }

    getActiveSession(sessionId) {
        var session = null;
        if (sessionId == null || sessionId == undefined) {
            // return first active session
            for (var i in this.sessions) {
                if (this.sessions[i].active === true) {
                    session = this.sessions[i];
                    break;
                }
            }
        } else {
            session = this.getSession(sessionId);
        }
        return session;
    }

    getSession(sessionId) {
        return Utils.findByIdWOJQ(this.sessions, sessionId);
    }

    /*
     * createSession - description
     *
     * CALLED FROM:
     * - {@link Data#constructor}.
     *
     * @param  {type} save   description
     * @param  {type} active description
     * @return {Session}        description
     */
    createSession(userId) {
        var session = new Session.new(this.jt, null);
        session.setNumParticipants(session.suggestedNumParticipants);
        if (userId != null && userId.length > 0) {
            session.addUser(userId);
        }
        session.save();
        this.jt.socketServer.emitToAdmins('addSession', session.shell());
        return session;
    }

    session(id) {
        return Utils.findByIdWOJQ(this.sessions, id);
    }

}

var exports = module.exports = {};
exports.new = Data;