@amxts/menu-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,686 @@
1
+ /**
2
+ * Menu Core's menu file: INI as menu_core reads it, or YAML and JSON with
3
+ * named fields, read into one description of each menu. What does not fit -
4
+ * an unknown key, a value of the wrong kind - is said in the server console
5
+ * with the file and the line. Not part of the API.
6
+ */
7
+ import * as configs from "@amxts/config-core";
8
+ import { ConfigKind, ConfigNode } from "@amxts/config-core";
9
+ import { FilterSpec, ItemSpec, MenuFile, MenuSpec, NameKind, RequirementSpec, Variant } from "./internal";
10
+
11
+ /** The keys of a YAML or JSON menu file, each place its own. */
12
+ const FILE_KEYS = ["chatPrefix", "labels", "menus"];
13
+ const LABEL_KEYS = ["exit", "back", "next", "number", "disabled", "page", "time"];
14
+ const MENU_KEYS = ["title", "activeOn", "hideBack", "hideExit", "locked", "sharedTimer", "time", "onTimeout", "items", "fixedItems", "filters", "view"];
15
+ const ITEM_KEYS = ["name", "placeholder", "action", "visible", "enabled", "message", "spaceBefore", "spaceAfter", "variants"];
16
+ const FIXED_KEYS = ["slot", "name", "placeholder", "action", "visible", "enabled", "message", "spaceBefore", "spaceAfter", "variants"];
17
+ const VIEW_KEYS = ["name", "action", "enabled", "message", "variants"];
18
+ const VARIANT_KEYS = ["name", "when", "action"];
19
+ const REQUIREMENT_KEYS = ["when", "message"];
20
+ const FILTER_KEYS = ["when", "message"];
21
+
22
+ /** The keys of an INI menu file, as menu_core knows them. */
23
+ const INI_MAIN_KEYS = ["PREFIX", "KEY"];
24
+ const INI_LABEL_KEYS = ["EXIT", "BACK", "NEXT", "NUMBER", "DISABLED", "PAGE", "TIME"];
25
+ const INI_MENU_KEYS = ["TITLE", "ACTIVE_ON", "HIDE_BACK", "HIDE_EXIT", "TIME", "ON_TIMEOUT", "LOCKED", "GLOBAL", "ITEMS", "FIXED_ITEMS", "FILTER", "VIEW"];
26
+
27
+ const DIGITS = "0123456789";
28
+ const NAME_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_";
29
+ const BACKSLASH = "\\";
30
+ /** The letters of the colours a menu has: `!y` in a menu file, and a backslash before them in Pawn's text. */
31
+ const COLOUR_LETTERS = ["y", "r", "w", "d", "R"];
32
+
33
+ /** Where a value is: "configs/menu.yaml:12:9" - without the column where the file does not keep it (INI). */
34
+ export function whereOf(node: ConfigNode) {
35
+ return node.column > 0 ? `${node.file}:${node.line}:${node.column}` : `${node.file}:${node.line}`;
36
+ }
37
+
38
+ export function warn(where: string, message: string) {
39
+ console.warn(`[MenuCore] ${where}: ${message}`);
40
+ }
41
+
42
+ /**
43
+ * A text of the file, as it is. A colour in it is a tag, `!y`: Pawn's code -
44
+ * a backslash and the letter - is warned of with the tag to write, and is
45
+ * left out when the menu is drawn.
46
+ */
47
+ function fileText(text: string, where: string) {
48
+ const codes = COLOUR_LETTERS.filter(letter => text.includes(`${BACKSLASH}${letter}`));
49
+ if (codes.length == 0) return text;
50
+ const fixes = codes.map(letter => `!${letter} for ${BACKSLASH}${letter}`).join(", ");
51
+ warn(where, `"${text}": a colour is a tag in a menu file - write ${fixes}; the codes are left out`);
52
+ return text;
53
+ }
54
+
55
+ /** What may be meant but is worth a look: said in the console, not warned of. */
56
+ function note(where: string, message: string) {
57
+ console.info(`[MenuCore] ${where}: ${message}`);
58
+ }
59
+
60
+ /** An item of a file with no action - in no variant either: choosing it does nothing. */
61
+ function noteIfIdle(item: ItemSpec, where: string) {
62
+ const variants = item.variants;
63
+ const actions = variants != null ? variants.map(variant => variant.action) : item.ini ? item.action.split("|") : [item.action];
64
+ const idle = actions.every(action => action.trim().length == 0);
65
+ if (idle) note(where, `the item "${item.name}" has no action: choosing it does nothing`);
66
+ }
67
+
68
+ /** How many letters to add, remove, change or swap to turn one word into the other, case aside. */
69
+ function distance(a: string, b: string) {
70
+ const x = a.toLowerCase().split("");
71
+ const y = b.toLowerCase().split("");
72
+ let before: number[] = [];
73
+ let previous: number[] = [];
74
+ for (let j = 0; j <= y.length; j++) previous.push(j);
75
+ for (let i = 1; i <= x.length; i++) {
76
+ const current = [i];
77
+ for (let j = 1; j <= y.length; j++) {
78
+ const cost = x[i - 1] == y[j - 1] ? 0 : 1;
79
+ let best = Math.min(Math.min(previous[j] + 1, current[j - 1] + 1), previous[j - 1] + cost);
80
+ const swapped = i > 1 && j > 1 && x[i - 1] == y[j - 2] && x[i - 2] == y[j - 1];
81
+ if (swapped) best = Math.min(best, before[j - 2] + 1);
82
+ current.push(best);
83
+ }
84
+ before = previous;
85
+ previous = current;
86
+ }
87
+ return previous[y.length];
88
+ }
89
+
90
+ /** ` - did you mean "NAME"?` for the known name a slip away from `name`; "" when none is that close. */
91
+ export function suggestion(name: string, known: string[]) {
92
+ let best = "";
93
+ let bestDistance = Infinity;
94
+ for (const each of known) {
95
+ const away = distance(name, each);
96
+ if (away >= bestDistance) continue;
97
+ best = each;
98
+ bestDistance = away;
99
+ }
100
+ const limit = name.length <= 4 ? 1 : Math.max(2, Math.floor(name.length / 4));
101
+ if (bestDistance <= limit) return ` - did you mean "${best}"?`;
102
+ const longer = known.filter(each => wordAdded(name, each)).sort((a, b) => a.length - b.length);
103
+ return longer.length > 0 ? ` - did you mean "${longer[0]}"?` : "";
104
+ }
105
+
106
+ /** Whether `longer` is `name` with a word added before or after it, case aside: ADMIN - IS_ADMIN. */
107
+ function wordAdded(name: string, longer: string) {
108
+ const upper = name.toUpperCase();
109
+ const other = longer.toUpperCase();
110
+ return other.endsWith(`_${upper}`) || other.startsWith(`${upper}_`);
111
+ }
112
+
113
+ /** "a list", "true or false", ... - a value's kind as a message says it. */
114
+ function kindWords(kind: ConfigKind) {
115
+ if (kind == "object") return "an object";
116
+ if (kind == "array") return "a list";
117
+ if (kind == "boolean") return "true or false";
118
+ if (kind == "number") return "a number";
119
+ if (kind == "string") return "text";
120
+ return "null";
121
+ }
122
+
123
+ function emptyItem(slot: number, ini: boolean) {
124
+ const item: ItemSpec = { name: "", placeholder: "", action: "", ini, condition: "", restriction: "", restrictionMessage: "", visible: "", enabled: [], message: "", spaceBefore: 0, spaceAfter: 0, slot, variants: null };
125
+ return item;
126
+ }
127
+
128
+ function emptyMenu(name: string) {
129
+ const menu: MenuSpec = { name, title: "", activeOn: "", hideBack: false, hideExit: false, locked: false, sharedTimer: false, time: 0, onTimeout: "", items: [], fixed: [], filters: [], names: [] };
130
+ return menu;
131
+ }
132
+
133
+ /** Records the names a line uses, for the check once every plugin has registered its own. */
134
+ function use(menu: MenuSpec, kind: NameKind, line: string, where: string, variants = false) {
135
+ if (line.trim().length > 0) menu.names.push({ kind, name: line, where, variants });
136
+ }
137
+
138
+ /** Records the %placeholders% a text uses. */
139
+ function usePlaceholders(menu: MenuSpec, text: string, where: string) {
140
+ const parts = text.split("%");
141
+ // Between two % signs - the odd parts - a name of letters, digits and _.
142
+ for (let i = 1; i < parts.length - 1; i += 2) {
143
+ const name = parts[i];
144
+ const isName = name.length > 0 && name.split("").every(letter => NAME_LETTERS.includes(letter));
145
+ if (isName) menu.names.push({ kind: "placeholder", name, where, variants: false });
146
+ }
147
+ }
148
+
149
+ /** Warns of the keys of an object that are not among `known`; `folded` compares them in capitals (INI). */
150
+ function checkKeys(node: ConfigNode, known: string[], what: string, folded: boolean) {
151
+ for (const child of node.values()) {
152
+ const key = folded ? child.key.toUpperCase() : child.key;
153
+ if (known.includes(key)) continue;
154
+ const hint = folded ? "" : iniName(child.key, known);
155
+ warn(whereOf(child), `unknown key "${child.key}" in ${what}${hint.length > 0 ? hint : suggestion(child.key, known)}`);
156
+ }
157
+ }
158
+
159
+ /**
160
+ * " - did you mean ..." for an INI column's name where a YAML or JSON file
161
+ * has another: an item's condition is `visible` or `enabled`, its
162
+ * restriction `enabled`, a variant's or a filter's condition `when`.
163
+ */
164
+ function iniName(key: string, known: string[]) {
165
+ const names = key == "condition" ? ["visible", "enabled", "when"] : key == "restriction" ? ["enabled"] : key == "restrictionMessage" ? ["message"] : [];
166
+ const meant = names.filter(name => known.includes(name)).map(name => `"${name}"`);
167
+ return meant.length > 0 ? ` - did you mean ${meant.join(" or ")}?` : "";
168
+ }
169
+
170
+ /** Where a member of an object is, or the object when it has none of that key. */
171
+ function whereIn(node: ConfigNode, key: string) {
172
+ const found = node.get(key);
173
+ return whereOf(found ?? node);
174
+ }
175
+
176
+ function textField(node: ConfigNode, key: string) {
177
+ const value = node.get(key);
178
+ if (value == null || value.kind == "null") return "";
179
+ if (value.kind == "string" || value.kind == "number") return fileText(value.getString(), whereOf(value));
180
+ warn(whereOf(value), `"${key}" is text, not ${kindWords(value.kind)}`);
181
+ return "";
182
+ }
183
+
184
+ function flagField(node: ConfigNode, key: string) {
185
+ const value = node.get(key);
186
+ if (value == null || value.kind == "null") return false;
187
+ if (value.kind == "boolean") return value.getBoolean();
188
+ warn(whereOf(value), `"${key}" is true or false, not ${kindWords(value.kind)}`);
189
+ return false;
190
+ }
191
+
192
+ function numberField(node: ConfigNode, key: string) {
193
+ const value = node.get(key);
194
+ if (value == null || value.kind == "null") return 0;
195
+ if (value.kind == "number") return Math.trunc(value.getNumber());
196
+ warn(whereOf(value), `"${key}" is a number, not ${kindWords(value.kind)}`);
197
+ return 0;
198
+ }
199
+
200
+ function listField(node: ConfigNode, key: string) {
201
+ const value = node.get(key);
202
+ const none: ConfigNode[] = [];
203
+ if (value == null || value.kind == "null") return none;
204
+ if (value.kind == "array") return value.values();
205
+ warn(whereOf(value), `"${key}" is a list, not ${kindWords(value.kind)}`);
206
+ return none;
207
+ }
208
+
209
+ /** A line of names: one name, several space-separated, or a list of them. */
210
+ function namesField(node: ConfigNode, key: string, kind: NameKind, menu: MenuSpec) {
211
+ const value = node.get(key);
212
+ if (value == null || value.kind == "null") return "";
213
+ const listed = value.kind == "array" && value.values().every(each => each.kind == "string");
214
+
215
+ if (value.kind != "string" && !listed) {
216
+ warn(whereOf(value), `"${key}" is a name or a list of names, not ${kindWords(value.kind)}`);
217
+ return "";
218
+ }
219
+
220
+ const line = value.getStrings().join(" ");
221
+ use(menu, kind, line, whereOf(value));
222
+ if (kind == "requirement") checkParamLast(value);
223
+ return line;
224
+ }
225
+
226
+ /** A list of requirements is one line: "NAME:param" takes the rest of it, so it goes last. */
227
+ function checkParamLast(value: ConfigNode) {
228
+ const entries = value.values();
229
+ const at = entries.findIndex(each => each.getString().includes(":"));
230
+ if (at >= 0 && at < entries.length - 1) warn(whereOf(entries[at]), `"${entries[at].getString()}" takes the rest of the line - NAME:param goes last`);
231
+ }
232
+
233
+ /**
234
+ * `enabled`: a line of names, the item's `message` beside it - or a list of
235
+ * requirements, each a line of names or `{ when, message }` with its own.
236
+ */
237
+ function enabledField(node: ConfigNode, menu: MenuSpec) {
238
+ const value = node.get("enabled");
239
+ const requirements: RequirementSpec[] = [];
240
+ if (value == null || value.kind == "null") return requirements;
241
+
242
+ if (value.kind == "string") {
243
+ const line = namesField(node, "enabled", "requirement", menu);
244
+ if (line.trim().length > 0) requirements.push({ when: line, message: "" });
245
+ return requirements;
246
+ }
247
+
248
+ if (value.kind != "array") {
249
+ warn(whereOf(value), `"enabled" is a name, a list of names or a list of { when, message }, not ${kindWords(value.kind)}`);
250
+ return requirements;
251
+ }
252
+
253
+ for (const each of value.values()) {
254
+ const requirement = requirementOf(each, menu);
255
+ if (requirement != null) requirements.push(requirement);
256
+ }
257
+ return requirements;
258
+ }
259
+
260
+ /** One of `enabled`'s list: a line of names, or `{ when, message }`; null for one that says nothing. */
261
+ function requirementOf(node: ConfigNode, menu: MenuSpec) {
262
+ if (node.kind == "string") {
263
+ const line = node.getString();
264
+ use(menu, "requirement", line, whereOf(node));
265
+ const named: RequirementSpec = { when: line, message: "" };
266
+ return line.trim().length > 0 ? named : null;
267
+ }
268
+
269
+ if (node.kind != "object") {
270
+ warn(whereOf(node), `a requirement is a name or { when: ..., message: ... }, not ${kindWords(node.kind)}`);
271
+ return null;
272
+ }
273
+
274
+ checkKeys(node, REQUIREMENT_KEYS, "a requirement", false);
275
+ const requirement: RequirementSpec = { when: namesField(node, "when", "requirement", menu), message: textField(node, "message") };
276
+
277
+ if (requirement.when.length == 0) {
278
+ warn(whereOf(node), `a requirement without "when"`);
279
+ return null;
280
+ }
281
+
282
+ return requirement;
283
+ }
284
+
285
+ /** An object where one is wanted; a warning for anything else. */
286
+ function isObject(node: ConfigNode, what: string, shape: string) {
287
+ if (node.kind == "object") return true;
288
+ warn(whereOf(node), `${what} is an object: ${shape}, not ${kindWords(node.kind)}`);
289
+ return false;
290
+ }
291
+
292
+ function treeVariant(node: ConfigNode, menu: MenuSpec) {
293
+ if (!isObject(node, "a variant", "{ name: ..., when: ..., action: ... }")) return null;
294
+ checkKeys(node, VARIANT_KEYS, "a variant", false);
295
+ const variant: Variant = { name: textField(node, "name"), when: namesField(node, "when", "requirement", menu), action: namesField(node, "action", "action", menu) };
296
+ usePlaceholders(menu, variant.name, whereIn(node, "name"));
297
+ if (variant.name.length == 0) warn(whereOf(node), "a variant without a name");
298
+ return variant.name.length > 0 ? variant : null;
299
+ }
300
+
301
+ /** An item, a fixed item or a list menu's view, by `keys`; null for one that cannot be drawn. */
302
+ function treeItem(node: ConfigNode, keys: string[], what: string, menu: MenuSpec) {
303
+ if (!isObject(node, what, "{ name: ..., action: ... }")) return null;
304
+ checkKeys(node, keys, what, false);
305
+ const item = emptyItem(-1, false);
306
+ item.name = textField(node, "name");
307
+ if (keys.includes("placeholder")) item.placeholder = textField(node, "placeholder");
308
+ item.action = namesField(node, "action", "action", menu);
309
+ if (keys.includes("visible")) item.visible = namesField(node, "visible", "requirement", menu);
310
+ item.enabled = enabledField(node, menu);
311
+ item.message = textField(node, "message");
312
+ if (keys.includes("spaceBefore")) item.spaceBefore = numberField(node, "spaceBefore");
313
+ if (keys.includes("spaceAfter")) item.spaceAfter = numberField(node, "spaceAfter");
314
+ usePlaceholders(menu, item.name, whereIn(node, "name"));
315
+ usePlaceholders(menu, item.placeholder, whereIn(node, "placeholder"));
316
+ const listed = listField(node, "variants");
317
+
318
+ if (listed.length > 0) {
319
+ const named = item.name.length > 0 || item.action.length > 0;
320
+ if (named) warn(whereOf(node), `${what} with variants takes its name and action from them`);
321
+ const variants: Variant[] = [];
322
+ for (const each of listed) {
323
+ const variant = treeVariant(each, menu);
324
+ if (variant != null) variants.push(variant);
325
+ }
326
+ item.variants = variants.length > 0 ? variants : null;
327
+ item.name = variants.length > 0 ? variants[0].name : "";
328
+ item.action = "";
329
+ }
330
+
331
+ if (item.name.length == 0) warn(whereOf(node), `${what} without a name`);
332
+ // A list menu's rows may bring actions of their own.
333
+ if (item.name.length > 0 && keys != VIEW_KEYS) noteIfIdle(item, whereOf(node));
334
+ return item.name.length > 0 ? item : null;
335
+ }
336
+
337
+ function treeFilter(node: ConfigNode, menu: MenuSpec) {
338
+ if (!isObject(node, "a filter", "{ when: ..., message: ... }")) return null;
339
+ checkKeys(node, FILTER_KEYS, "a filter", false);
340
+ const filter: FilterSpec = { condition: "", when: namesField(node, "when", "requirement", menu), message: textField(node, "message") };
341
+ if (filter.when.length == 0) warn(whereOf(node), `a filter without "when"`);
342
+ return filter.when.length > 0 ? filter : null;
343
+ }
344
+
345
+ function treeFixed(node: ConfigNode, menu: MenuSpec) {
346
+ const item = treeItem(node, FIXED_KEYS, "a fixed item", menu);
347
+ if (item == null) return null;
348
+ const slot = numberField(node, "slot");
349
+
350
+ if (slot < 1 || slot > 7) {
351
+ warn(whereOf(node), `"slot" is the item's key, 1 to 7`);
352
+ return null;
353
+ }
354
+
355
+ item.slot = slot - 1;
356
+ return item;
357
+ }
358
+
359
+ /** A menu of a YAML or JSON file: its named fields. */
360
+ function treeMenu(node: ConfigNode) {
361
+ const name = node.key;
362
+ if (!isObject(node, `the menu "${name}"`, "{ title: ..., items: [...] }")) return null;
363
+ checkKeys(node, MENU_KEYS, `the menu "${name}"`, false);
364
+ const menu = emptyMenu(name);
365
+ menu.title = textField(node, "title");
366
+
367
+ if (menu.title.length == 0) {
368
+ warn(whereOf(node), `the menu "${name}" has no title`);
369
+ return null;
370
+ }
371
+
372
+ usePlaceholders(menu, menu.title, whereIn(node, "title"));
373
+ menu.activeOn = namesField(node, "activeOn", "condition", menu);
374
+ menu.hideBack = flagField(node, "hideBack");
375
+ menu.hideExit = flagField(node, "hideExit");
376
+ menu.locked = flagField(node, "locked");
377
+ menu.sharedTimer = flagField(node, "sharedTimer");
378
+ menu.time = numberField(node, "time");
379
+ menu.onTimeout = namesField(node, "onTimeout", "action", menu);
380
+ readTreeContent(node, menu);
381
+
382
+ for (const each of listField(node, "fixedItems")) {
383
+ const item = treeFixed(each, menu);
384
+ if (item != null) menu.fixed.push(item);
385
+ }
386
+
387
+ return withItems(menu, whereOf(node));
388
+ }
389
+
390
+ /** A menu with nothing in it is no menu: said once, as the file is read, and left out of it. */
391
+ function withItems(menu: MenuSpec, where: string) {
392
+ if (menu.items.length + menu.fixed.length > 0) return menu;
393
+ warn(where, `the menu "${menu.name}" has no items, so it is not a menu`);
394
+ return null;
395
+ }
396
+
397
+ /** An items menu's items; a list menu's view and filters. */
398
+ function readTreeContent(node: ConfigNode, menu: MenuSpec) {
399
+ const list = menu.name.startsWith("LIST_");
400
+ const items = node.get("items");
401
+ const view = node.get("view");
402
+ const filters = node.get("filters");
403
+ if (list && items != null) warn(whereOf(items), `a list menu draws its rows with "view" - "items" is not read`);
404
+ if (!list && view != null) warn(whereOf(view), `"view" is for a list menu, whose name starts with LIST_`);
405
+ if (!list && filters != null) warn(whereOf(filters), `"filters" are for a list menu, whose name starts with LIST_`);
406
+
407
+ if (!list) {
408
+ for (const each of listField(node, "items")) {
409
+ const item = treeItem(each, ITEM_KEYS, "an item", menu);
410
+ if (item != null) menu.items.push(item);
411
+ }
412
+ return;
413
+ }
414
+
415
+ const row = view != null ? treeItem(view, VIEW_KEYS, "the view", menu) : null;
416
+ if (row != null) menu.items.push(row);
417
+ for (const each of listField(node, "filters")) {
418
+ const filter = treeFilter(each, menu);
419
+ if (filter != null) menu.filters.push(filter);
420
+ }
421
+ }
422
+
423
+ /** A YAML or JSON menu file: `chatPrefix`, `labels` and `menus`. */
424
+ function readTree(root: ConfigNode, file: MenuFile) {
425
+ if (!isObject(root, "a menu file", "{ menus: { ... } }")) return;
426
+ checkKeys(root, FILE_KEYS, "the menu file", false);
427
+ file.labels.prefix = textField(root, "chatPrefix");
428
+ const labels = root.get("labels");
429
+
430
+ if (labels != null && isObject(labels, "\"labels\"", "{ exit: ..., back: ... }")) {
431
+ checkKeys(labels, LABEL_KEYS, "\"labels\"", false);
432
+ file.labels.exit = textField(labels, "exit");
433
+ file.labels.back = textField(labels, "back");
434
+ file.labels.next = textField(labels, "next");
435
+ file.labels.number = textField(labels, "number");
436
+ file.labels.disabled = textField(labels, "disabled");
437
+ file.labels.page = textField(labels, "page");
438
+ file.labels.time = textField(labels, "time");
439
+ }
440
+
441
+ const menus = root.get("menus");
442
+ if (menus == null || !isObject(menus, "\"menus\"", "{ MAIN_MENU: { ... } }")) return;
443
+ for (const node of menus.values()) {
444
+ const menu = treeMenu(node);
445
+ if (menu != null) file.menus.push(menu);
446
+ }
447
+ }
448
+
449
+ /** An INI value as menu_core reads it: the first of a line of values. */
450
+ function first(node: ConfigNode | null) {
451
+ if (node == null) return "";
452
+ const values = node.getStrings();
453
+ return values.length > 0 ? values[0] : "";
454
+ }
455
+
456
+ /** Every value of an INI line, space-separated. */
457
+ function all(node: ConfigNode | null) {
458
+ return node != null ? node.getStrings().join(" ") : "";
459
+ }
460
+
461
+ function toInt(text: string) {
462
+ const value = parseInt(text, 10);
463
+ return isNaN(value) ? 0 : value;
464
+ }
465
+
466
+ function isInteger(text: string) {
467
+ const digits = text.startsWith("-") ? text.slice(1) : text;
468
+ return digits.length > 0 && digits.split("").every(letter => DIGITS.includes(letter));
469
+ }
470
+
471
+ /** A flag of an INI menu: YES or NO. Anything else is warned of, with the word to write, and is NO. */
472
+ function iniFlag(section: ConfigNode, key: string) {
473
+ const node = section.get(key);
474
+ const value = first(node);
475
+ if (node == null || value.length == 0 || value == "NO") return false;
476
+ if (value == "YES") return true;
477
+ warn(whereOf(node), `${key} is YES or NO, not "${value}"${flagFix(value)}`);
478
+ return false;
479
+ }
480
+
481
+ /** " - write YES" for a flag written some other way that means yes, " - write NO" for no; "" for anything else. */
482
+ function flagFix(value: string) {
483
+ const lower = value.toLowerCase();
484
+ if (["yes", "true", "on", "1"].includes(lower)) return " - write YES";
485
+ if (["no", "false", "off", "0"].includes(lower)) return " - write NO";
486
+ return "";
487
+ }
488
+
489
+ /**
490
+ * Whether a block has nothing in it. Config Core reads `{` and `}` on lines
491
+ * of their own as an object - no row in it is not `key = value` - and
492
+ * `ITEMS = { }` on one line as the values "{" and "}".
493
+ */
494
+ function isEmptyBlock(block: ConfigNode) {
495
+ if (block.kind == "object") return block.keys().length == 0;
496
+ return block.getStrings().join("") == "{}";
497
+ }
498
+
499
+ /** The rows of a block, each as its columns; a warning for anything that is not a block of rows. */
500
+ function iniRows(section: ConfigNode, key: string) {
501
+ const block = section.get(key);
502
+ const rows: ConfigNode[] = [];
503
+ if (block == null || isEmptyBlock(block)) return rows;
504
+
505
+ if (block.kind != "array") {
506
+ warn(whereOf(block), `${key} is a block of rows in quotes: ${key} = { "..." "..." }`);
507
+ return rows;
508
+ }
509
+
510
+ for (const row of block.values()) {
511
+ if (row.kind == "object") warn(whereOf(row), `a row of ${key} is values in quotes, not "key = value"`);
512
+ else rows.push(row);
513
+ }
514
+ return rows;
515
+ }
516
+
517
+ function column(row: string[], index: number) {
518
+ return index < row.length ? row[index] : "";
519
+ }
520
+
521
+ /** Whether an item name gives an item - e.g. "A|B" gives two variants; "" and "|" give none. */
522
+ function givesItem(name: string) {
523
+ return name.split("|").some(part => part.trim().length > 0);
524
+ }
525
+
526
+ /** "2" is two blank lines after the item; "1 2" one before and two after. */
527
+ function setSpacing(item: ItemSpec, spacing: string) {
528
+ const parts = spacing.split(" ").filter(part => part.trim().length > 0);
529
+
530
+ if (parts.length == 1) {
531
+ item.spaceAfter = toInt(parts[0]);
532
+ return;
533
+ }
534
+
535
+ item.spaceBefore = parts.length > 0 ? toInt(parts[0]) : 0;
536
+ item.spaceAfter = parts.length > 1 ? toInt(parts[1]) : 0;
537
+ }
538
+
539
+ /** An INI row's item: its columns from `at` - name, placeholder (when `placeholder`), condition, action, restriction, message. */
540
+ function iniItem(row: ConfigNode, columns: string[], at: number, placeholder: boolean, menu: MenuSpec) {
541
+ const where = whereOf(row);
542
+ const item = emptyItem(-1, true);
543
+ let next = at;
544
+ item.name = fileText(column(columns, next++), where);
545
+ if (placeholder) item.placeholder = fileText(column(columns, next++), where);
546
+ item.condition = column(columns, next++);
547
+ item.action = column(columns, next++);
548
+ item.restriction = column(columns, next++);
549
+ item.restrictionMessage = fileText(column(columns, next++), where);
550
+ usePlaceholders(menu, item.name, where);
551
+ usePlaceholders(menu, item.placeholder, where);
552
+ use(menu, "condition", item.condition, where, true);
553
+ use(menu, "action", item.action, where, true);
554
+ use(menu, "restriction", item.restriction, where);
555
+ return item;
556
+ }
557
+
558
+ /** ITEMS, or a list menu's FILTER and VIEW. */
559
+ function readIniContent(section: ConfigNode, menu: MenuSpec) {
560
+ const list = menu.name.startsWith("LIST_");
561
+ const items = section.get("ITEMS");
562
+ if (list && items != null) warn(whereOf(items), "a list menu draws its rows with VIEW - ITEMS is not read");
563
+ for (const key of ["VIEW", "FILTER"]) {
564
+ const found = section.get(key);
565
+ if (!list && found != null) warn(whereOf(found), `${key} is for a list menu, whose name starts with LIST_`);
566
+ }
567
+
568
+ if (!list) {
569
+ for (const row of iniRows(section, "ITEMS")) {
570
+ const columns = row.getStrings();
571
+ if (!givesItem(column(columns, 0))) continue;
572
+ const item = iniItem(row, columns, 0, true, menu);
573
+ noteIfIdle(item, whereOf(row));
574
+ setSpacing(item, column(columns, 6));
575
+ menu.items.push(item);
576
+ }
577
+ return;
578
+ }
579
+
580
+ for (const row of iniRows(section, "FILTER")) {
581
+ const columns = row.getStrings();
582
+ const filter: FilterSpec = { condition: column(columns, 0), when: "", message: fileText(column(columns, 1), whereOf(row)) };
583
+ use(menu, "condition", filter.condition, whereOf(row));
584
+ if (filter.condition.length > 0) menu.filters.push(filter);
585
+ }
586
+
587
+ const views = iniRows(section, "VIEW");
588
+ if (views.length == 0) return;
589
+ const columns = views[0].getStrings();
590
+ if (givesItem(column(columns, 0))) menu.items.push(iniItem(views[0], columns, 0, false, menu));
591
+ }
592
+
593
+ /** A [section] with a TITLE: a menu, read as menu_core reads it. */
594
+ function iniMenu(section: ConfigNode) {
595
+ const name = section.key;
596
+ const title = first(section.get("TITLE"));
597
+ const content = section.has("ITEMS") || section.has("VIEW") || section.has("FIXED_ITEMS");
598
+
599
+ if (title.length == 0) {
600
+ if (content) warn(whereOf(section), `[${name}] has no TITLE, so it is not a menu`);
601
+ return null;
602
+ }
603
+
604
+ checkKeys(section, INI_MENU_KEYS, `[${name}]`, true);
605
+ const menu = emptyMenu(name);
606
+ menu.title = fileText(title, whereOf(section));
607
+ usePlaceholders(menu, title, whereOf(section));
608
+ menu.activeOn = all(section.get("ACTIVE_ON"));
609
+ use(menu, "condition", menu.activeOn, whereOf(section));
610
+ menu.hideBack = iniFlag(section, "HIDE_BACK");
611
+ menu.hideExit = iniFlag(section, "HIDE_EXIT");
612
+ menu.locked = iniFlag(section, "LOCKED");
613
+ menu.sharedTimer = iniFlag(section, "GLOBAL");
614
+ const time = section.get("TIME");
615
+ if (time != null && !isInteger(first(time))) warn(whereOf(time), `TIME is a number of seconds, not "${first(time)}"`);
616
+ menu.time = toInt(first(time));
617
+ menu.onTimeout = first(section.get("ON_TIMEOUT"));
618
+ use(menu, "action", menu.onTimeout, whereOf(section));
619
+ readIniContent(section, menu);
620
+
621
+ for (const row of iniRows(section, "FIXED_ITEMS")) {
622
+ const columns = row.getStrings();
623
+ if (!givesItem(column(columns, 1))) continue;
624
+ const item = iniItem(row, columns, 1, true, menu);
625
+ noteIfIdle(item, whereOf(row));
626
+ const slot = toInt(column(columns, 0));
627
+ if (slot < 1 || slot > 7) warn(whereOf(row), `the slot of a fixed item is its key, 1 to 7, not "${column(columns, 0)}"`);
628
+ item.slot = slot - 1;
629
+ setSpacing(item, column(columns, 7));
630
+ menu.fixed.push(item);
631
+ }
632
+
633
+ return withItems(menu, whereOf(section));
634
+ }
635
+
636
+ /** An INI menu file: [MAIN] with the words, a [section] a menu. */
637
+ function readIni(root: ConfigNode, file: MenuFile) {
638
+ const main = root.get("MAIN");
639
+
640
+ if (main != null && main.kind == "object") {
641
+ checkKeys(main, INI_MAIN_KEYS, "[MAIN]", true);
642
+ file.labels.prefix = fileText(first(main.get("PREFIX")), whereOf(main));
643
+ const key = main.get("KEY");
644
+ if (key != null && key.kind == "object") checkKeys(key, INI_LABEL_KEYS, "KEY", true);
645
+ if (key != null) readIniLabels(key, file);
646
+ }
647
+
648
+ for (const section of root.values()) {
649
+ if (section.key.toUpperCase() == "MAIN" || section.kind != "object") continue;
650
+ const menu = iniMenu(section);
651
+ if (menu != null) file.menus.push(menu);
652
+ }
653
+ }
654
+
655
+ function readIniLabels(key: ConfigNode, file: MenuFile) {
656
+ file.labels.exit = iniLabel(key, "EXIT");
657
+ file.labels.back = iniLabel(key, "BACK");
658
+ file.labels.next = iniLabel(key, "NEXT");
659
+ file.labels.number = iniLabel(key, "NUMBER");
660
+ file.labels.disabled = iniLabel(key, "DISABLED");
661
+ file.labels.page = iniLabel(key, "PAGE");
662
+ file.labels.time = iniLabel(key, "TIME");
663
+ }
664
+
665
+ /** A word of KEY: a lang key, or the text itself. */
666
+ function iniLabel(key: ConfigNode, name: string) {
667
+ const node = key.get(name);
668
+ return fileText(first(node), whereOf(node ?? key));
669
+ }
670
+
671
+ /**
672
+ * The menu file of that name, from configs/ - name.ini, name.yaml, name.yml,
673
+ * name.json or name.jsonc, whichever is there - with its menus described.
674
+ */
675
+ export function readMenuFile(name: string) {
676
+ const root = configs.read(name);
677
+ const file: MenuFile = {
678
+ file: root.file,
679
+ empty: root.keys().length == 0,
680
+ labels: { exit: "", back: "", next: "", number: "", disabled: "", page: "", time: "", prefix: "" },
681
+ menus: [],
682
+ };
683
+ if (root.format == "ini") readIni(root, file);
684
+ else readTree(root, file);
685
+ return file;
686
+ }