bmweb-cli 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.
- package/LICENSE +674 -0
- package/README.md +351 -0
- package/dist/bmweb.js +2790 -0
- package/package.json +53 -0
- package/runtime/core/bestvm/codec.js +285 -0
- package/runtime/core/bestvm/environment.js +116 -0
- package/runtime/core/bestvm/executor.js +1483 -0
- package/runtime/core/bestvm/index.js +52 -0
- package/runtime/core/bestvm/machine.js +491 -0
- package/runtime/core/bestvm/operands.js +356 -0
- package/runtime/core/bestvm/registers.js +152 -0
- package/runtime/core/bestvm/write-guard.js +111 -0
- package/runtime/core/ipofile/compile.js +364 -0
- package/runtime/core/ipofile/decls.js +187 -0
- package/runtime/core/ipofile/emit.js +708 -0
- package/runtime/core/ipofile/exec.js +164 -0
- package/runtime/core/ipofile/lex.js +243 -0
- package/runtime/core/ipofile/parse.js +550 -0
- package/runtime/core/ipofile/pool.js +404 -0
- package/runtime/core/ipofile/walk.js +431 -0
- package/runtime/core/ipovm/builtin-helpers.js +182 -0
- package/runtime/core/ipovm/builtins-api.js +610 -0
- package/runtime/core/ipovm/builtins-screen.js +493 -0
- package/runtime/core/ipovm/builtins-table.js +166 -0
- package/runtime/core/ipovm/builtins-text.js +166 -0
- package/runtime/core/ipovm/emissions.js +138 -0
- package/runtime/core/ipovm/hosts.js +191 -0
- package/runtime/core/ipovm/operators.js +229 -0
- package/runtime/core/ipovm/structures.js +250 -0
- package/runtime/core/ipovm/suspensions.js +241 -0
- package/runtime/core/ipovm/tape.js +206 -0
- package/runtime/core/ipovm/values.js +241 -0
- package/runtime/core/ipovm/vm.js +1166 -0
- package/runtime/core/translate.js +526 -0
- package/runtime/core/webshim/api-router.js +592 -0
- package/runtime/core/webshim/bus.js +95 -0
- package/runtime/core/webshim/coding.js +82 -0
- package/runtime/core/webshim/data-fetch.js +66 -0
- package/runtime/core/webshim/exchange.js +288 -0
- package/runtime/core/webshim/framing.js +331 -0
- package/runtime/core/webshim/install.js +30 -0
- package/runtime/core/webshim/job-runner.js +319 -0
- package/runtime/core/webshim/native-bus.js +108 -0
- package/runtime/core/webshim/timers.js +82 -0
- package/runtime/core/webshim/trace.js +205 -0
- package/runtime/core/webshim/transport-base.js +128 -0
- package/runtime/core/webshim/variant-resolver.js +249 -0
- package/runtime/core/webshim/web-serial-bus.js +734 -0
- package/runtime/home/bmweb-home.ips +76 -0
- package/runtime/home/bmweb.h +26 -0
- package/runtime/screens/activations.js +258 -0
- package/runtime/screens/garage/diff.js +331 -0
- package/runtime/screens/garage/share.js +276 -0
- package/runtime/screens/garage/store.js +547 -0
- package/runtime/screens/ipo-runtime/cells.js +176 -0
- package/runtime/screens/ipo-runtime/dialogs.js +254 -0
- package/runtime/screens/ipo-runtime/home.js +358 -0
- package/runtime/screens/ipo-runtime/open.js +393 -0
- package/runtime/screens/ipo-runtime/paint-grid.js +106 -0
- package/runtime/screens/ipo-runtime/paint-modern.js +424 -0
- package/runtime/screens/ipo-runtime/print.js +281 -0
- package/runtime/screens/ipo-runtime/program.js +1337 -0
- package/runtime/screens/ipo-runtime/protocol.js +464 -0
- package/runtime/screens/ipo-runtime/script-scan.js +225 -0
- package/runtime/screens/ipo-runtime/translate-sets.js +130 -0
- package/runtime/screens/ipo-runtime/ui.js +249 -0
- package/runtime/screens/ipo-runtime/wire-policy.js +113 -0
- package/runtime/screens/ir.js +324 -0
- package/runtime/screens/search/data.js +153 -0
- package/runtime/screens/search/match.js +285 -0
- package/runtime/screens/search/open.js +66 -0
- package/runtime/vendor/fflate.min.js +1 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What the live .IPO runtime (screens/ipo-runtime/) borrows from the
|
|
3
|
+
* module view: the shipped bytecode per SGBD, INPA's input dialogs, the job
|
|
4
|
+
* names a key's body can send, and the exact-dictionary caption translation
|
|
5
|
+
* (irLabel). The derived renderer that once drew screens from
|
|
6
|
+
* data/inpa-ir/<ECU>.json is gone -- the module view is the running script.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** How many helper levels irItemBodyJobs follows into calluser bodies. */
|
|
10
|
+
const IR_JOB_SCAN_DEPTH = 2;
|
|
11
|
+
|
|
12
|
+
/** How many tokens before an INPAapiJob call its job-name constant may sit. */
|
|
13
|
+
const IR_JOB_LOOKBACK = 8;
|
|
14
|
+
|
|
15
|
+
/** A job name as the scripts write them: upper-case, at least four chars. */
|
|
16
|
+
const IR_JOB_NAME_RE = /^[A-Z][A-Z0-9_]{3,}$/;
|
|
17
|
+
|
|
18
|
+
/** The INPAapiJob / INP1apiJob call names. */
|
|
19
|
+
const IR_JOB_CALL_RE = /^INP.?apiJob/;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The SGBD whose SCRIPT draws: the configured base when the car identified a
|
|
23
|
+
* variant that ships no .IPO of its own (INPA loads one script per address).
|
|
24
|
+
* @param {object} ecu - the module ({_irFrom, sgbd})
|
|
25
|
+
* @returns {string} lower-case SGBD name
|
|
26
|
+
*/
|
|
27
|
+
function irExecSgbd(ecu) {
|
|
28
|
+
return String((ecu && (ecu._irFrom || ecu.sgbd)) || '').toLowerCase();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** @type {Map<string, Promise<object|null>>} exec per SGBD, fetched once */
|
|
32
|
+
const _irExecCache = new Map();
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* {procs, byid} for an ECU, fetched once. null when the ECU ships no runnable
|
|
36
|
+
* twin (an orphan, or a pre-phase-1 archive).
|
|
37
|
+
* @param {string} sgbd - SGBD name
|
|
38
|
+
* @returns {Promise<object|null>}
|
|
39
|
+
*/
|
|
40
|
+
function irLiveExec(sgbd) {
|
|
41
|
+
const key = String(sgbd).toLowerCase();
|
|
42
|
+
if (!_irExecCache.has(key)) {
|
|
43
|
+
_irExecCache.set(
|
|
44
|
+
key,
|
|
45
|
+
api(`/api/ecu/${key}/ipoexec`)
|
|
46
|
+
.then((x) => (x && x.procs ? x : null))
|
|
47
|
+
.catch(() => null)
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return _irExecCache.get(key);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Put an exec into the cache under a name, so the runtime finds it there
|
|
55
|
+
* instead of fetching one.
|
|
56
|
+
*
|
|
57
|
+
* A script the user supplied is decoded in the browser and has no URL to be
|
|
58
|
+
* fetched from. Seeding it here is what lets it open through the ordinary
|
|
59
|
+
* module path -- same program driver, same confirmations, same write gates --
|
|
60
|
+
* rather than through a second runtime that would have to re-earn that trust.
|
|
61
|
+
* The entry lives only as long as the page.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} sgbd - the name to file it under
|
|
64
|
+
* @param {object|null} exec - {procs, byid}
|
|
65
|
+
* @returns {void}
|
|
66
|
+
*/
|
|
67
|
+
function irSeedExec(sgbd, exec) {
|
|
68
|
+
_irExecCache.set(String(sgbd).toLowerCase(), Promise.resolve(exec || null));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One INPA ask, in INPA's own words. The driven VM suspends at every input
|
|
73
|
+
* builtin; this renders the right dialog for its kind and returns what
|
|
74
|
+
* resume() stores: a number (inputint/inputnum), a hex STRING (inputhex),
|
|
75
|
+
* 1/0 from the two-choice box (inputdigital), or an ARRAY for the two-field
|
|
76
|
+
* forms (input2int's Kalenderwoche/Jahr) -- one element per out-ref.
|
|
77
|
+
* @param {IpoStep} step - the input suspension ({name, prompts, refs, lo, hi})
|
|
78
|
+
* @param {string} [fallbackTitle] - the title when the script gave no prompt
|
|
79
|
+
* @returns {Promise<number|string|Array<number|string>|null>} null =
|
|
80
|
+
* cancelled, and the keypress is abandoned
|
|
81
|
+
*/
|
|
82
|
+
async function irAskInput(step, fallbackTitle) {
|
|
83
|
+
const refs = Math.max(1, Number(step.refs || 1));
|
|
84
|
+
const name = String(step.name || '');
|
|
85
|
+
const tr = (x) => esc(irLabel(x || '') || x || '');
|
|
86
|
+
const p0 = step.prompts[0] || fallbackTitle || '';
|
|
87
|
+
const p1 = step.prompts[1] || '';
|
|
88
|
+
if (name === 'inputdigital') {
|
|
89
|
+
// (out bool, title, text, FalseStr, TrueStr): the box offers the two
|
|
90
|
+
// words, nothing else -- INPA's yes/no. Which word PROCEEDS is the
|
|
91
|
+
// script's business (IHKA46 asks "Are you sure? yes/no" with yes = 0 and
|
|
92
|
+
// sends on 0), so neither button may double as "dismiss": Esc and the
|
|
93
|
+
// backdrop cancel the run instead of answering with one of them.
|
|
94
|
+
const f = step.prompts[step.prompts.length - 2] || 'OFF';
|
|
95
|
+
const t = step.prompts[step.prompts.length - 1] || 'ON';
|
|
96
|
+
const yes = await confirmDialog({
|
|
97
|
+
title: tr(p0),
|
|
98
|
+
body: tr(p1),
|
|
99
|
+
confirmLabel: tr(t),
|
|
100
|
+
cancelLabel: tr(f),
|
|
101
|
+
dismissValue: null,
|
|
102
|
+
});
|
|
103
|
+
if (yes == null) return null;
|
|
104
|
+
return yes ? 1 : 0;
|
|
105
|
+
}
|
|
106
|
+
// (out, title, text) with nothing else: INPA's plain OK/Cancel box -- the
|
|
107
|
+
// fault-memory clear asks "Do you really want to delete the error-memory?
|
|
108
|
+
// <OK>-Key or <Return>-Key clears!" and then tests getinputstate == 0.
|
|
109
|
+
// There is no value to type; asking for a number blocked the clear.
|
|
110
|
+
if (name === 'builtin_3f' && step.prompts.length <= 2 && refs === 1) {
|
|
111
|
+
const yes = await confirmDialog({
|
|
112
|
+
title: tr(p0),
|
|
113
|
+
body: tr(p1),
|
|
114
|
+
confirmLabel: 'OK',
|
|
115
|
+
cancelLabel: 'Cancel',
|
|
116
|
+
dismissValue: null,
|
|
117
|
+
});
|
|
118
|
+
return yes ? 0 : null;
|
|
119
|
+
}
|
|
120
|
+
const hex = /hex/i.test(name);
|
|
121
|
+
// input2text and its kin ask for words (a comment to save with the
|
|
122
|
+
// protocol), and an empty line is an answer there, not a cancel
|
|
123
|
+
const text = /text/i.test(name);
|
|
124
|
+
// inputnum asks for a real: decimals stay
|
|
125
|
+
const real = name === 'inputnum';
|
|
126
|
+
const vals = [];
|
|
127
|
+
for (let k = 0; k < refs; k++) {
|
|
128
|
+
// a two-field form captions each field after the title/help pair
|
|
129
|
+
const cap =
|
|
130
|
+
refs > 1 ? step.prompts[2 + k] || `${p0} (${k + 1}/${refs})` : p1;
|
|
131
|
+
const asked = await inputDialog({
|
|
132
|
+
title: tr(p0),
|
|
133
|
+
body:
|
|
134
|
+
tr(cap) +
|
|
135
|
+
(step.lo != null && step.hi != null && !hex
|
|
136
|
+
? `<br><br>Accepted range <b>${step.lo}</b> to <b>${step.hi}</b>.`
|
|
137
|
+
: ''),
|
|
138
|
+
kind: hex || text ? 'text' : 'number',
|
|
139
|
+
example: hex || text ? '' : step.lo != null ? String(step.lo) : '',
|
|
140
|
+
confirmLabel: 'OK',
|
|
141
|
+
});
|
|
142
|
+
if (asked == null) return null;
|
|
143
|
+
if (text) {
|
|
144
|
+
vals.push(String(asked));
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (String(asked).trim() === '') return null;
|
|
148
|
+
if (hex) {
|
|
149
|
+
vals.push(String(asked).trim());
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const n = real ? Number(asked) : Math.trunc(Number(asked));
|
|
153
|
+
if (!Number.isFinite(n)) return null;
|
|
154
|
+
if (
|
|
155
|
+
refs === 1 &&
|
|
156
|
+
step.lo != null &&
|
|
157
|
+
step.hi != null &&
|
|
158
|
+
(n < step.lo || n > step.hi)
|
|
159
|
+
)
|
|
160
|
+
return null;
|
|
161
|
+
vals.push(n);
|
|
162
|
+
}
|
|
163
|
+
return refs > 1 ? vals : vals[0];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Every job name an item's body (and its helpers, one level) can send: the
|
|
168
|
+
* job-name constant pushed before each INPAapiJob call.
|
|
169
|
+
* @param {object} exec - the decoded script ({procs, byid})
|
|
170
|
+
* @param {IpoToken[]} toks - the menu proc's tokens
|
|
171
|
+
* @param {number} i0 - first token index of the body
|
|
172
|
+
* @param {number} end - token index the body ends at (exclusive)
|
|
173
|
+
* @returns {string[]}
|
|
174
|
+
*/
|
|
175
|
+
function irItemBodyJobs(exec, toks, i0, end) {
|
|
176
|
+
const out = [];
|
|
177
|
+
const scan = (tk, a, b, depth) => {
|
|
178
|
+
for (let i = a; i < Math.min(b, tk.length); i++) {
|
|
179
|
+
const t = tk[i];
|
|
180
|
+
if (t.op === 'call' && IR_JOB_CALL_RE.test(t.name || '')) {
|
|
181
|
+
for (let j = i - 1; j >= Math.max(0, i - IR_JOB_LOOKBACK); j--) {
|
|
182
|
+
const c = tk[j];
|
|
183
|
+
if (
|
|
184
|
+
c.op === 'const' &&
|
|
185
|
+
c.t === 's' &&
|
|
186
|
+
IR_JOB_NAME_RE.test(String(c.v))
|
|
187
|
+
) {
|
|
188
|
+
if (!out.includes(c.v)) out.push(c.v);
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
if (c.op === 'frame') break;
|
|
192
|
+
}
|
|
193
|
+
} else if (t.op === 'calluser' && depth < IR_JOB_SCAN_DEPTH) {
|
|
194
|
+
const nm = (exec.byid || {})[`func:${t.n}`];
|
|
195
|
+
const body = nm && exec.procs[nm];
|
|
196
|
+
if (Array.isArray(body)) scan(body, 0, body.length, depth + 1);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
scan(toks, i0, end, 0);
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---- captions: exact dictionaries only -----------------------------------
|
|
205
|
+
// Per-ECU map from data/inpa-i18n/<ECU>.json (shipped as ir.i18n), then the
|
|
206
|
+
// shared INPA chrome table (data/i18n-shared.js). No word rules: a caption
|
|
207
|
+
// with no entry shows as BMW wrote it.
|
|
208
|
+
|
|
209
|
+
/** @type {Record<string, string>|null} the current ECU's caption map */
|
|
210
|
+
let _irI18n = null;
|
|
211
|
+
/** @type {Map<string, string>|null} that map keyed by collapsed caption */
|
|
212
|
+
let _irI18nNorm = null;
|
|
213
|
+
/** @type {(Map<string, string> & {src?: object})|null} the shared table, collapsed */
|
|
214
|
+
let _irSharedNorm = null;
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Take an ECU's caption dictionary as the current one.
|
|
218
|
+
* @param {{i18n?: Record<string, string>}|null} ir - the ECU's shipped IR
|
|
219
|
+
* @returns {void}
|
|
220
|
+
*/
|
|
221
|
+
function irUseTranslations(ir) {
|
|
222
|
+
_irI18n = (ir && ir.i18n) || null;
|
|
223
|
+
_irI18nNorm = null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The shared INPA chrome table, when data/i18n-shared.js is loaded.
|
|
228
|
+
* @returns {Record<string, string>|null}
|
|
229
|
+
*/
|
|
230
|
+
function irI18nShared() {
|
|
231
|
+
return (typeof window !== 'undefined' && window.BMW_I18N_SHARED) || null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A caption's lookup form. The .IPO prints a caption padded to its column
|
|
236
|
+
* ("Drehzahl :") and the same words appear elsewhere trimmed; both mean
|
|
237
|
+
* one thing, so whitespace collapses and a trailing ':' or '=' is dropped.
|
|
238
|
+
* @param {string} s - the caption
|
|
239
|
+
* @returns {string}
|
|
240
|
+
*/
|
|
241
|
+
function irI18nKey(s) {
|
|
242
|
+
return String(s)
|
|
243
|
+
.replace(/\s+/g, ' ')
|
|
244
|
+
.trim()
|
|
245
|
+
.replace(/\s*[:=]\s*$/, '');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* A dictionary keyed by collapsed caption (first entry wins).
|
|
250
|
+
* @param {Record<string, string>} map - the dictionary
|
|
251
|
+
* @returns {Map<string, string>}
|
|
252
|
+
*/
|
|
253
|
+
function irNormMap(map) {
|
|
254
|
+
const out = new Map();
|
|
255
|
+
for (const k of Object.keys(map)) {
|
|
256
|
+
const nk = irI18nKey(k);
|
|
257
|
+
if (nk && !out.has(nk)) out.set(nk, map[k]);
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* INPA's key-legend notation: a menu screen prints "< F4 > Fehlerspeicher
|
|
264
|
+
* lesen" or "< Shift > + < F6 > EWS" as ONE string. The caption after the
|
|
265
|
+
* key is what the dictionaries carry, so it is looked up on its own and the
|
|
266
|
+
* key prefix is kept as printed. Syntax of the notation only, no word rules.
|
|
267
|
+
* Group 1 = the key prefix as printed, 2 = the caption.
|
|
268
|
+
* @type {RegExp}
|
|
269
|
+
*/
|
|
270
|
+
const IR_KEY_LEGEND =
|
|
271
|
+
/^(\s*(?:<\s*Shift\s*>\s*\+\s*)?<\s*F\s*\d+\s*>\s*)(\S.*)$/i;
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Translate a caption through the exact dictionaries: the ECU's own map,
|
|
275
|
+
* then the shared table, first as written, then by its collapsed form with
|
|
276
|
+
* the original's leading indentation and trailing ':' put back so a
|
|
277
|
+
* translated cell keeps its place on the grid. "Function labels: Original
|
|
278
|
+
* (EDIABAS)" (lang() === 'orig') shows BMW's own strings verbatim.
|
|
279
|
+
* @param {string} s - the caption as the script printed it
|
|
280
|
+
* @returns {string} the translation, or `s` when no dictionary has it
|
|
281
|
+
*/
|
|
282
|
+
function irLabel(s) {
|
|
283
|
+
if (!s) return s;
|
|
284
|
+
if (typeof lang === 'function' && lang() === 'orig') return s;
|
|
285
|
+
const shared = irI18nShared();
|
|
286
|
+
if (!_irI18n && !shared) return s;
|
|
287
|
+
const has = (m) => m && Object.prototype.hasOwnProperty.call(m, s);
|
|
288
|
+
const legend = typeof s === 'string' ? s.match(IR_KEY_LEGEND) : null;
|
|
289
|
+
if (legend && !has(_irI18n) && !has(shared)) {
|
|
290
|
+
return legend[1] + irLabel(legend[2]);
|
|
291
|
+
}
|
|
292
|
+
if (has(_irI18n)) return _irI18n[s];
|
|
293
|
+
if (has(shared)) return shared[s];
|
|
294
|
+
const str = String(s);
|
|
295
|
+
const nk = irI18nKey(str);
|
|
296
|
+
let hit = null;
|
|
297
|
+
if (_irI18n) {
|
|
298
|
+
if (!_irI18nNorm) _irI18nNorm = irNormMap(_irI18n);
|
|
299
|
+
hit = _irI18nNorm.get(nk);
|
|
300
|
+
}
|
|
301
|
+
if (hit == null && shared) {
|
|
302
|
+
if (!_irSharedNorm || _irSharedNorm.src !== shared) {
|
|
303
|
+
_irSharedNorm = irNormMap(shared);
|
|
304
|
+
_irSharedNorm.src = shared;
|
|
305
|
+
}
|
|
306
|
+
hit = _irSharedNorm.get(nk);
|
|
307
|
+
}
|
|
308
|
+
if (hit == null) return s;
|
|
309
|
+
const lead = (str.match(/^\s*/) || [''])[0];
|
|
310
|
+
const tail = (str.match(/\s*[:=]?\s*$/) || [''])[0];
|
|
311
|
+
return lead + hit + tail;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
315
|
+
module.exports = {
|
|
316
|
+
irExecSgbd,
|
|
317
|
+
irLiveExec,
|
|
318
|
+
irSeedExec,
|
|
319
|
+
irAskInput,
|
|
320
|
+
irItemBodyJobs,
|
|
321
|
+
irUseTranslations,
|
|
322
|
+
irLabel,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Loading the corpus-wide job search index, once.
|
|
3
|
+
*
|
|
4
|
+
* The index is built at export time (tools/export/search_index.py) and ships
|
|
5
|
+
* as one gzipped file: every INPA key and screen in every script the build
|
|
6
|
+
* carries, with the jobs each sends. Scanning the corpus in the browser would
|
|
7
|
+
* mean downloading every .chassis archive, so this fetches the finished
|
|
8
|
+
* answer -- ~2 MB, cached by the service worker like the rest of api/.
|
|
9
|
+
*
|
|
10
|
+
* The fetch goes through webRealFetch (the FILE, not the shim's /api/ router)
|
|
11
|
+
* because the index is a static artifact, not a route the shim computes.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/* exported SEARCH_INDEX_VERSION, searchIndexLoad, searchIndexPeek, searchIndexPresent */
|
|
15
|
+
|
|
16
|
+
/** The index format this code understands; the builder stamps it as `v`. */
|
|
17
|
+
const SEARCH_INDEX_VERSION = 1;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The whole index as the builder writes it.
|
|
21
|
+
* @typedef {object} SearchIndex
|
|
22
|
+
* @property {number} v - format version
|
|
23
|
+
* @property {SearchModule[]} modules - one per SGBD, entries point into this
|
|
24
|
+
* @property {SearchEntry[]} entries - the keys and screens
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* One module: a script, and the cars that carry it.
|
|
29
|
+
* @typedef {object} SearchModule
|
|
30
|
+
* @property {string} sgbd - the .prg name, lower-case; the deep link's module part
|
|
31
|
+
* @property {string} label - the module's human name ("MS45.1 for M54")
|
|
32
|
+
* @property {string} code - INPA's own designation for it (MS450), may be ''
|
|
33
|
+
* @property {string[]} chassis - chassis ids that carry it, upper-case
|
|
34
|
+
* @property {number} [vehicle] - 1 when this is a whole-vehicle script
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One searchable thing: a key a user can press, or a screen the script shows.
|
|
39
|
+
* Field names are one or two letters because the file holds ~90k of these.
|
|
40
|
+
* @typedef {object} SearchEntry
|
|
41
|
+
* @property {'k'|'s'} t - key or screen
|
|
42
|
+
* @property {number} i - index into `modules`
|
|
43
|
+
* @property {string} [m] - the menu proc the key sits on (keys only)
|
|
44
|
+
* @property {number} [n] - the F-key number, 11..20 = shifted (keys only)
|
|
45
|
+
* @property {string} [l] - the key's label as the script prints it
|
|
46
|
+
* @property {string} [e] - that label in English, when a dictionary has it
|
|
47
|
+
* @property {string} [s] - the screen proc this opens (keys) or is (screens)
|
|
48
|
+
* @property {string} [ti] - the screen's title
|
|
49
|
+
* @property {string} [tie] - that title in English
|
|
50
|
+
* @property {string[]} [j] - job names it sends
|
|
51
|
+
* @property {string[]} [k] - result keys the screen paints (screens only)
|
|
52
|
+
* @property {string} [c] - the screen's static captions, joined (screens only)
|
|
53
|
+
* @property {string} [ce] - those captions in English
|
|
54
|
+
* @property {number} [w] - 1 when the key writes to the module
|
|
55
|
+
* @property {string} [a] - the key's non-job action, when it has one
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/** @type {SearchIndex|null} the loaded index */
|
|
59
|
+
let searchIndex = null;
|
|
60
|
+
|
|
61
|
+
/** @type {Promise<SearchIndex|null>|null} the in-flight load, shared */
|
|
62
|
+
let searchIndexPromise = null;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Load the index, once. Concurrent callers share one fetch, and a failure is
|
|
66
|
+
* remembered as "no index" rather than retried on every keystroke.
|
|
67
|
+
* @returns {Promise<SearchIndex|null>} the index, or null when it cannot load
|
|
68
|
+
*/
|
|
69
|
+
function searchIndexLoad() {
|
|
70
|
+
if (searchIndex) return Promise.resolve(searchIndex);
|
|
71
|
+
if (searchIndexPromise) return searchIndexPromise;
|
|
72
|
+
const base = typeof WEB_BASE === 'string' ? WEB_BASE : '';
|
|
73
|
+
const real =
|
|
74
|
+
typeof webRealFetch === 'function'
|
|
75
|
+
? webRealFetch
|
|
76
|
+
: window.fetch.bind(window);
|
|
77
|
+
searchIndexPromise = (async () => {
|
|
78
|
+
try {
|
|
79
|
+
const r = await real(`${base}/api/search-index.json.gz`);
|
|
80
|
+
if (!r || !r.ok) return null;
|
|
81
|
+
const buf = new Uint8Array(await r.arrayBuffer());
|
|
82
|
+
// a host that content-decodes hands back plain JSON; both are taken
|
|
83
|
+
const gz = buf.length > 2 && buf[0] === 0x1f && buf[1] === 0x8b;
|
|
84
|
+
if (gz && typeof fflate === 'undefined') return null;
|
|
85
|
+
const text = new TextDecoder('utf-8').decode(
|
|
86
|
+
gz ? fflate.gunzipSync(buf) : buf
|
|
87
|
+
);
|
|
88
|
+
const doc = JSON.parse(text);
|
|
89
|
+
// a format the app does not understand would be read field-by-field
|
|
90
|
+
// into nonsense results; refusing it shows the empty state instead
|
|
91
|
+
if (!doc || doc.v !== SEARCH_INDEX_VERSION || !Array.isArray(doc.entries))
|
|
92
|
+
return null;
|
|
93
|
+
searchIndex = doc;
|
|
94
|
+
return doc;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
})();
|
|
99
|
+
return searchIndexPromise;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The index if it is already loaded, without starting a load.
|
|
104
|
+
* @returns {SearchIndex|null}
|
|
105
|
+
*/
|
|
106
|
+
function searchIndexPeek() {
|
|
107
|
+
return searchIndex;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** @type {Promise<boolean>|null} the in-flight presence probe, shared */
|
|
111
|
+
let searchIndexPresentPromise = null;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Whether this build ships an index at all, WITHOUT downloading it.
|
|
115
|
+
*
|
|
116
|
+
* The Apps hub greys a card whose data did not ship, and asks every app that
|
|
117
|
+
* question when it draws. Answering it with a full load would make opening
|
|
118
|
+
* the hub cost the 2 MB the app itself costs, so this asks for the headers
|
|
119
|
+
* only. A HEAD a static host does not answer falls back to the loaded index
|
|
120
|
+
* when one is already in hand, and otherwise reports present -- an app card
|
|
121
|
+
* wrongly shown opens a screen that explains itself, while one wrongly hidden
|
|
122
|
+
* cannot be found at all.
|
|
123
|
+
* @returns {Promise<boolean>}
|
|
124
|
+
*/
|
|
125
|
+
function searchIndexPresent() {
|
|
126
|
+
if (searchIndex) return Promise.resolve(true);
|
|
127
|
+
if (searchIndexPresentPromise) return searchIndexPresentPromise;
|
|
128
|
+
const base = typeof WEB_BASE === 'string' ? WEB_BASE : '';
|
|
129
|
+
const real =
|
|
130
|
+
typeof webRealFetch === 'function'
|
|
131
|
+
? webRealFetch
|
|
132
|
+
: window.fetch.bind(window);
|
|
133
|
+
searchIndexPresentPromise = (async () => {
|
|
134
|
+
try {
|
|
135
|
+
const r = await real(`${base}/api/search-index.json.gz`, {
|
|
136
|
+
method: 'HEAD',
|
|
137
|
+
});
|
|
138
|
+
return !!(r && r.ok);
|
|
139
|
+
} catch {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
})();
|
|
143
|
+
return searchIndexPresentPromise;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
147
|
+
module.exports = {
|
|
148
|
+
SEARCH_INDEX_VERSION,
|
|
149
|
+
searchIndexLoad,
|
|
150
|
+
searchIndexPeek,
|
|
151
|
+
searchIndexPresent,
|
|
152
|
+
};
|
|
153
|
+
}
|