/*
* z-Tree's text export (File → Export → Treatment…): a treatment as nested blocks,
*
* treatment "pg.ztt"{
* background{ table globals{ } … numsubjects = 2; … program{ table = subjects; do{ … } } screen action{ … } }
* stage "Contribution Entry"{ startwaitforall = TRUE; … screen action{ standardbox "Standard"{ item{ … } button "OK"{ … } } } }
* roles{ role "S 1"{ } }
* period "1"{ subject 1{ name = ""; group = 1; } }
* }
*
* read into the same treatment as ztt.js reads from a .ztt file (so the runtime, the screens and
* the converter take either). Texts are Windows-1252, strings C-escaped (\" \\ \r \n), values
* unquoted where they are expressions or names (max = Endowment; hasframe = TRUE; top = 10%;).
* GIMS (Palan 2015) is published in this form.
*/
const { decode } = require('./ztt.js');
class ZtextError extends Error {}
/** The text, as a tree of blocks: {kind, name, entries: {key: value}, blocks: [...], code (a do block's)}. */
function parse(text) {
let i = 0;
const n = text.length;
const fail = (message) => {
const line = text.slice(0, i).split('\n').length;
throw new ZtextError(message + ' (line ' + line + ')');
};
const space = () => {
for (;;) {
while (i < n && /\s/.test(text[i])) i++;
if (text.startsWith('//', i)) { while (i < n && text[i] !== '\n') i++; continue; }
if (text.startsWith('/*', i)) { const e = text.indexOf('*/', i + 2); i = e < 0 ? n : e + 2; continue; }
return;
}
};
const string = () => {
// From an opening quote: the string, unescaped.
let out = '';
i++;
while (i < n && text[i] !== '"') {
// A line break in the string: the export indents the next line with tabs, not the text's own.
if (text[i] === '\n') {
out += '\n';
i++;
while (text[i] === '\t') i++;
continue;
}
if (text[i] === '\\' && i + 1 < n) {
const c = text[i + 1];
out += { n: '\n', r: '\r', t: '\t' }[c] || c;
i += 2;
} else out += text[i++];
}
if (i >= n) fail('a string does not end');
i++;
return out;
};
const word = () => {
const m = /^[A-Za-z_][\w]*/.exec(text.slice(i, i + 200));
if (m == null) fail('expected a name');
i += m[0].length;
return m[0];
};
/** A value: a string (unescaped), or the text up to the ; (an expression, a name, 10%, TRUE). */
const value = () => {
space();
if (text[i] === '"') {
const s = string();
space();
// "a" "b": strings in a row are one.
let more = s;
while (text[i] === '"') { more += string(); space(); }
if (text[i] !== ';') fail('expected ; after a value');
i++;
return { string: more };
}
const start = i;
let depth = 0;
while (i < n) {
const c = text[i];
if (c === '"') { string(); continue; }
if (c === '(' || c === '[') depth++;
else if (c === ')' || c === ']') depth--;
else if (c === ';' && depth <= 0) break;
i++;
}
const raw = text.slice(start, i).trim();
i++;
return { raw };
};
/** A do block's code: from its {, to the matching }, strings and comments taken as they are. */
const code = () => {
if (text[i] !== '{') fail('expected {');
const start = ++i;
let depth = 1;
while (i < n) {
const c = text[i];
if (c === '"') { string(); continue; }
if (text.startsWith('//', i)) { while (i < n && text[i] !== '\n') i++; continue; }
if (text.startsWith('/*', i)) { const e = text.indexOf('*/', i + 2); i = e < 0 ? n : e + 2; continue; }
if (c === '{') depth++;
else if (c === '}' && --depth === 0) break;
i++;
}
if (i >= n) fail('a do block does not end');
const body = text.slice(start, i);
i++;
return body;
};
const block = (kind, name) => {
const out = { kind, name, entries: {}, blocks: [], values: null };
for (;;) {
space();
if (i >= n) fail('the block ' + kind + ' does not end');
if (text[i] === '}') { i++; return out; }
// A list of strings (a scale's texts: texts{"left";"right";}).
if (text[i] === '"') {
out.values = out.values || [];
out.values.push(string());
space();
if (text[i] === ';') i++;
continue;
}
const key = word();
space();
if (text[i] === '=') {
i++;
out.entries[key] = value();
continue;
}
let blockName = null;
if (text[i] === '"') blockName = string();
else if (/[\w-]/.test(text[i])) { const m = /^[\w.-]+/.exec(text.slice(i)); blockName = m[0]; i += m[0].length; }
space();
if (key === 'do') {
out.blocks.push({ kind: 'do', code: code() });
continue;
}
if (text[i] !== '{') fail('expected { or = after ' + key);
i++;
out.blocks.push(block(key, blockName));
}
};
space();
const kind = word();
space();
const name = text[i] === '"' ? string() : null;
space();
if (text[i] !== '{') fail('expected {');
i++;
return block(kind, name);
}
// --- The tree, as ztt.js's treatment -------------------------------------------------------------
/** An entry's text: a string's, or the raw value's (as it is written), '' if missing. */
const str = (b, key) => {
const v = b.entries[key];
if (v == null) return '';
return v.string != null ? v.string : v.raw;
};
/** z-Tree writes \r\n in texts; the binary file has \r\n too, which the readers keep as \n. */
const text = (b, key) => str(b, key).replace(/\r\n/g, '\n');
const bool = (b, key) => /^true$/i.test(str(b, key));
const num = (b, key, otherwise = 0) => {
const v = Number(str(b, key));
return Number.isFinite(v) ? v : otherwise;
};
const children = (b, kind) => b.blocks.filter((c) => c.kind === kind);
/** A program: its table, owner, condition, and code. */
function program(p) {
const doBlock = p.blocks.find((c) => c.kind === 'do');
return { table: str(p, 'table'), owner: str(p, 'ownervariable'), condition: text(p, 'condition'), code: doBlock ? normalizeCode(doBlock.code) : '' };
}
/** Code as the binary file has it: the export's indentation (tabs to the block's depth) taken out. */
function normalizeCode(code) {
const lines = code.replace(/\r\n/g, '\n').split('\n');
// The first line follows the {; the others are indented by the export.
const rest = lines.slice(1).filter((l) => l.trim() !== '');
const indent = rest.length ? Math.min(...rest.map((l) => /^\t*/.exec(l)[0].length)) : 0;
const out = lines.map((l, k) => (k === 0 ? l : l.slice(Math.min(indent, /^\t*/.exec(l)[0].length))));
// The export ends the code with a line break and the closing brace's indentation.
while (out.length && out[out.length - 1].trim() === '') out.pop();
while (out.length && out[0].trim() === '') out.shift();
return out.length ? out.join('\n') + '\n' : '';
}
const BOX_TYPES = {
standardbox: 'standard', headerbox: 'header', helpbox: 'help', containerbox: 'container',
makecontractbox: 'contractCreation', selectcontractbox: 'contractList', historybox: 'history',
gridbox: 'grid', chatterbox: 'chat', calulatorbox: 'calculator', calculatorbox: 'calculator', plotbox: 'plot',
};
/**
* A box's placement flags, as ztt.js has them (options[1..7]; see render.js's place): whether
* the box takes its part of the room from the left, right, top or bottom of what is left.
*/
function boxOptions(b) {
const o = [0, 0, 0, 0, 0, 0, 0, 0];
if (bool(b, 'cutleft')) o[1] = 1;
if (bool(b, 'cutright')) o[3] = 1;
if (bool(b, 'cuttop')) o[5] = 1;
if (bool(b, 'cutbottom')) o[7] = 1;
return o;
}
function item(b) {
return {
kind: 'item', label: text(b, 'label'), variable: str(b, 'variable'), input: bool(b, 'input'),
min: str(b, 'min'), max: str(b, 'max'), layout: text(b, 'format'),
};
}
function button(b) {
// terminatestage: it ends the stage; donotterminatestage: it does not; neither: as its box's kind has it.
const leaveStage = bool(b, 'terminatestage') ? true : bool(b, 'donotterminatestage') ? false : undefined;
const out = {
name: b.name || '',
checkers: children(b, 'checker').map((c) => ({ kind: 'checker', condition: text(c, 'condition'), message: text(c, 'message') })),
programs: children(b, 'program').map(program),
};
if (leaveStage !== undefined) out.leaveStage = leaveStage;
// Its table loaders and dumpers (GIMS's "Save to disk" and "Load from disk"), if it has any.
const loaders = children(b, 'tableloader').map((c) => ({ condition: text(c, 'condition'), file: text(c, 'inputreplacefilename') }));
const dumpers = children(b, 'tabledumper').map((c) => ({ condition: text(c, 'condition'), file: text(c, 'outputfilename'), tables: text(c, 'tabledescriptions') }));
if (loaders.length) out.loaders = loaders;
if (dumpers.length) out.dumpers = dumpers;
// Its own colour (a Windows COLORREF: 0x00BBGGRR), if it has one.
if (bool(b, 'specialbuttoncolor')) {
const c = num(b, 'buttoncolor');
out.color = 'rgb(' + (c & 255) + ',' + ((c >> 8) & 255) + ',' + ((c >> 16) & 255) + ')';
}
return out;
}
const PLOT_ITEMS = { plotpoint: 'point', plotline: 'line', plotrect: 'rect', plotpie: 'pie', plottext: 'text', plotaxis: 'axis', plotgraph: 'graph', plotinput: 'input' };
/** A plot input: its action NEW (0, a click makes a record), SELECT (1) or DRAG (2); a drag's start and end in dragsnap*. */
function plotInput(b) {
return {
kind: 'input', name: b.name || '', condition: text(b, 'displaycondition') || text(b, 'condition'), count: 0, inputs: [],
programs: children(b, 'program').map(program), action: { NEW: 0, SELECT: 1, DRAG: 2 }[str(b, 'action').toUpperCase()] ?? num(b, 'action'),
table: str(b, 'table') || 'subjects', xVar: str(b, 'xvariable'), yVar: str(b, 'yvariable'),
x0Var: str(b, 'dragsnapx0'), y0Var: str(b, 'dragsnapy0'), x1Var: str(b, 'dragsnapx'), y1Var: str(b, 'dragsnapy'),
// (Only if it leaves the stage: the binary files are not read for it.)
...(bool(b, 'leavestage') ? { leaveStage: true } : {}),
};
}
function plotItem(b) {
const kind = PLOT_ITEMS[b.kind];
if (kind === 'input') return plotInput(b);
const out = { kind, name: b.name || '', condition: text(b, 'displaycondition') || text(b, 'condition'), count: 0, inputs: children(b, 'plotinput').map(plotInput) };
const s = (key) => str(b, key);
switch (kind) {
case 'point':
Object.assign(out, { x: s('x'), y: s('y'), size: s('size'), corners: s('numedges'), rotation: s('startat'), spokes: bool(b, 'isstar'),
lineColor: s('linecolor'), lineWidth: s('linewidth'), fillColor: s('fillcolor') });
break;
case 'line':
Object.assign(out, { x1: s('x1'), y1: s('y1'), x2: s('x2'), y2: s('y2'), arrowStart: s('arrowsize1'), arrowWidth: s('arrowwidth'),
arrowEnd: s('arrowsize2'), arrowFilled: bool(b, 'arrowclosed'), lineColor: s('linecolor'), lineWidth: s('linewidth') });
break;
case 'rect':
Object.assign(out, { xLeft: s('left'), xRight: s('right'), xCenter: s('x'), width: s('width'), yBottom: s('bottom'), yTop: s('top'),
yCenter: s('y'), height: s('height'), lineColor: s('linecolor'), lineWidth: s('linewidth'), fillColor: s('fillcolor') });
break;
case 'pie':
Object.assign(out, { x: s('x'), y: s('y'), rx: s('xradius'), ry: s('yradius'), angleStart: s('anglestart'), angleEnd: s('anglesize'),
lineColor: s('linecolor'), lineWidth: s('linewidth'), fillColor: s('fillcolor') });
break;
case 'text':
// As a .ztt file has them: horizontal LEFT 0, CENTER 1, RIGHT 2; vertical TOP 0, CENTER 2, BOTTOM 3.
Object.assign(out, { x: s('x'), y: s('y'), text: text(b, 'text'), hAlign: { LEFT: 0, CENTER: 1, RIGHT: 2 }[s('horizontalalignment').toUpperCase()] ?? 1,
vAlign: { TOP: 0, CENTER: 2, BOTTOM: 3 }[s('verticalalignment').toUpperCase()] ?? 2,
color: s('textcolor'), fontSize: s('fontsize') });
break;
case 'axis':
Object.assign(out, { horizontal: !/^y/i.test(s('axis')), position: s('position'), from: s('from'), to: s('to'), tickStep: s('tickdistance'),
labelStep: s('datalabeldistance'), labelLayout: text(b, 'datalabelformat'), caption: text(b, 'caption'), lineColor: s('linecolor'), lineWidth: s('linewidth') });
break;
case 'graph':
// A graph's inputs are among its items (its records' items take them).
Object.assign(out, { items: b.blocks.filter((c) => PLOT_ITEMS[c.kind]).map(plotItem), table: s('table') || 'globals',
graphCondition: text(b, 'condition'), lineColor: s('connectioncolor'), lineWidth: s('connectionwidth') });
out.condition = '';
out.inputs = [];
break;
default:
}
return out;
}
function box(b) {
const type = BOX_TYPES[b.kind] || 'unknown';
const out = {
type, name: b.name || '',
items: children(b, 'item').map(item),
buttons: children(b, 'button').map(button),
boxes: b.blocks.filter((c) => BOX_TYPES[c.kind]).map(box),
programs: children(b, 'program').map(program),
frame: bool(b, 'hasframe'), options: boxOptions(b), condition: text(b, 'displaycondition'),
left: str(b, 'left'), right: str(b, 'right'), width: str(b, 'width'), top: str(b, 'top'), bottom: str(b, 'bottom'), height: str(b, 'height'),
};
switch (type) {
case 'header':
out.texts = { period: text(b, 'periodtext'), of: text(b, 'periodoftext'), trial: text(b, 'practiceperiodprefix'), time: text(b, 'timestr'), timeout: text(b, 'pleasedecidetext') };
break;
case 'help':
out.title = text(b, 'label');
out.text = text(b, 'help');
break;
case 'contractList':
Object.assign(out, { table: str(b, 'table') || 'contracts', tableCondition: text(b, 'condition'), sorting: text(b, 'sortfields'), owner: str(b, 'ownervariable') });
break;
case 'contractCreation':
out.table = str(b, 'table') || 'contracts';
break;
case 'history':
out.table = 'subjects';
break;
case 'grid':
out.table = 'contracts';
break;
case 'chat':
Object.assign(out, { table: str(b, 'table') || 'contracts', variable: str(b, 'inputvariable'), condition2: text(b, 'condition'),
outputs: str(b, 'outputtext') ? [text(b, 'outputtext')] : [] });
break;
case 'plot':
out.range = { xLeft: str(b, 'xleft'), xRight: str(b, 'xright'), yBottom: str(b, 'ybottom'), yTop: str(b, 'ytop') };
out.keepAspect = bool(b, 'maintainaspectratio');
out.plotItems = b.blocks.filter((c) => PLOT_ITEMS[c.kind]).map(plotItem);
break;
default:
}
return out;
}
/** A stage's (or the background's) screen: its boxes. */
function screen(stage, which) {
const s = children(stage, 'screen').find((c) => c.name === which);
return s == null ? [] : s.blocks.filter((c) => BOX_TYPES[c.kind]).map(box);
}
/** The treatment in a text export (a Buffer, Windows-1252, or a string). */
function readTextTreatment(input) {
const source = typeof input === 'string' ? input : decode(input);
const root = parse(source.replace(/^/, ''));
if (root.kind !== 'treatment') throw new ZtextError('not a z-Tree treatment export (it starts with ' + root.kind + ')');
const bg = children(root, 'background')[0] || { entries: {}, blocks: [] };
const actionScreen = (s) => children(s, 'screen').find((c) => c.name === 'action');
const stages = children(root, 'stage').map((s) => {
const action = actionScreen(s);
// As a z-Tree 3 file has them: options[0] bit 0, it starts as each subject gets there (not
// waiting for all); bit 2, it is left after its timeout always (an auction), not only if
// there is no input; options[6], it shows the background's boxes.
const options = [(bool(s, 'startwaitforall') ? 0 : 1) | (str(s, 'timeouttype') === 'always' ? 4 : 0), 0, 0, 0, 0, 0, action == null || bool(action, 'usesbg') ? 1 : 0, 0];
return {
name: s.name || '', programs: children(s, 'program').map(program), timeout: str(s, 'timeout'), options,
showHeader: action == null ? true : bool(action, 'usesbg'),
startIfPossible: !bool(s, 'startwaitforall'), timeoutAlways: str(s, 'timeouttype') === 'always',
timeoutType: str(s, 'timeouttype'), startCondition: text(s, 'startConditionStr'),
active: screen(s, 'action'), waiting: screen(s, 'wait'),
loaders: children(s, 'tableloader').map((c) => ({ condition: text(c, 'condition'), file: text(c, 'inputreplacefilename') })),
dumpers: children(s, 'tabledumper').map((c) => ({ condition: text(c, 'condition'), file: text(c, 'outputfilename'), tables: text(c, 'tabledescriptions') })),
};
});
const roles = children(children(root, 'roles')[0] || { blocks: [] }, 'role');
const subjects = roles.map((r, index) => ({ name: r.name || '', index, program: (r.blocks.find((c) => c.kind === 'do') || {}).code ? normalizeCode(r.blocks.find((c) => c.kind === 'do').code) : '' }));
const periodBlocks = children(root, 'period');
const periods = periodBlocks.map((p) => ({ name: p.name || '', program: (p.blocks.find((c) => c.kind === 'do') || {}).code ? normalizeCode(p.blocks.find((c) => c.kind === 'do').code) : '' }));
const params = [];
for (const p of periodBlocks) {
for (const c of children(p, 'subject')) {
const d = c.blocks.find((x) => x.kind === 'do');
params.push({ label: text(c, 'name'), group: num(c, 'group', 1), program: d ? normalizeCode(d.code) : '' });
}
}
return {
version: 'text',
tables: children(bg, 'table').map((t) => ({ name: t.name, lifetime: str(t, 'lifetime'), execute: str(t, 'execute') })),
background: {
name: 'Background', programs: children(bg, 'program').map(program), timeout: '', options: [],
showHeader: true, startIfPossible: false, timeoutAlways: false, active: screen(bg, 'action'), waiting: screen(bg, 'wait'),
loaders: children(bg, 'tableloader').map((c) => ({ condition: text(c, 'condition'), file: text(c, 'inputreplacefilename') })),
},
stages, periods, subjects, params,
numSubjects: num(bg, 'numsubjects', subjects.length), numGroups: num(bg, 'numgroups', 1),
practicePeriods: num(bg, 'numpracticeperiods'),
texts: ['showupfeeawaytext', 'showupfeeawayyestext', 'showupfeeawaynotext', 'moneyawaytext', 'moneyawayyestext', 'moneyawaynotext', 'bancruptwaittext'].map((k) => text(bg, k)),
warnings: [],
};
}
/** Questionnaires in a text export, as ztq.js reads them from a .ztq: {version, questionnaires: [{name, title, items, buttons}]}. */
function readTextQuestionnaires(input) {
const source = typeof input === 'string' ? input : decode(input);
const root = parse(source.replace(/^\uFEFF/, ''));
if (root.kind !== 'questionnaires') throw new ZtextError('not a z-Tree questionnaires export (it starts with ' + root.kind + ')');
return {
version: 'text',
questionnaires: children(root, 'questionnaire').map((q) => ({
name: q.name || '', title: text(q, 'title'),
items: children(q, 'item').map((it) => {
const item = { label: text(it, 'label'), type: str(it, 'type'), variable: str(it, 'variable') };
if (item.variable) {
Object.assign(item, { min: num(it, 'min'), max: num(it, 'max'), step: num(it, 'inc'), layout: num(it, 'numtextlines') });
const texts = it.blocks.find((c) => c.kind === 'texts');
item.labels = ((texts && texts.values) || []).map((l) => l.trim()).filter(Boolean);
}
return item;
}),
buttons: children(q, 'button').map((b) => ({ name: b.name || text(b, 'name') })),
})),
warnings: [],
};
}
/** Whether buf (a file's start) is a z-Tree text export: a treatment or questionnaires. */
function isText(buf) {
const head = (typeof buf === 'string' ? buf : buf.slice(0, 64).toString('latin1')).replace(/^|^\xEF\xBB\xBF/, '').trimStart();
return /^(treatment|questionnaires)\s*("|\{)/.test(head);
}
/** Whether the file at path is a z-Tree text export (its first bytes say so). */
function isTextFile(file) {
const fs = require('fs');
let fd;
try {
fd = fs.openSync(file, 'r');
const buf = Buffer.alloc(64);
const n = fs.readSync(fd, buf, 0, 64, 0);
return isText(buf.subarray(0, n));
} catch (err) {
return false;
} finally {
if (fd != null) fs.closeSync(fd);
}
}
module.exports = { parse, readTextTreatment, readTextQuestionnaires, isText, isTextFile, normalizeCode, ZtextError };