linny-r 1.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.
Files changed (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +312 -0
  3. package/console.js +973 -0
  4. package/package.json +32 -0
  5. package/server.js +1547 -0
  6. package/static/fonts/FantasqueSansMono-Bold.ttf +0 -0
  7. package/static/fonts/FantasqueSansMono-BoldItalic.ttf +0 -0
  8. package/static/fonts/FantasqueSansMono-Italic.ttf +0 -0
  9. package/static/fonts/FantasqueSansMono-Regular.ttf +0 -0
  10. package/static/fonts/Hack-Bold.ttf +0 -0
  11. package/static/fonts/Hack-BoldItalic.ttf +0 -0
  12. package/static/fonts/Hack-Italic.ttf +0 -0
  13. package/static/fonts/Hack-Regular.ttf +0 -0
  14. package/static/fonts/Lato-Bold.ttf +0 -0
  15. package/static/fonts/Lato-BoldItalic.ttf +0 -0
  16. package/static/fonts/Lato-Italic.ttf +0 -0
  17. package/static/fonts/Lato-Regular.ttf +0 -0
  18. package/static/fonts/mplus-1m-bold.ttf +0 -0
  19. package/static/fonts/mplus-1m-light.ttf +0 -0
  20. package/static/fonts/mplus-1m-medium.ttf +0 -0
  21. package/static/fonts/mplus-1m-regular.ttf +0 -0
  22. package/static/fonts/mplus-1m-thin.ttf +0 -0
  23. package/static/images/access.png +0 -0
  24. package/static/images/actor.png +0 -0
  25. package/static/images/actors.png +0 -0
  26. package/static/images/add-selector.png +0 -0
  27. package/static/images/add.png +0 -0
  28. package/static/images/back.png +0 -0
  29. package/static/images/black-box.png +0 -0
  30. package/static/images/by-sa.svg +74 -0
  31. package/static/images/cancel.png +0 -0
  32. package/static/images/chart.png +0 -0
  33. package/static/images/check-disab.png +0 -0
  34. package/static/images/check-off.png +0 -0
  35. package/static/images/check-on.png +0 -0
  36. package/static/images/check-x.png +0 -0
  37. package/static/images/clone.png +0 -0
  38. package/static/images/close.png +0 -0
  39. package/static/images/cluster.png +0 -0
  40. package/static/images/compare.png +0 -0
  41. package/static/images/compress.png +0 -0
  42. package/static/images/constraint.png +0 -0
  43. package/static/images/copy.png +0 -0
  44. package/static/images/data-to-clpbrd.png +0 -0
  45. package/static/images/dataset.png +0 -0
  46. package/static/images/delete.png +0 -0
  47. package/static/images/diagram.png +0 -0
  48. package/static/images/down.png +0 -0
  49. package/static/images/edit-chart.png +0 -0
  50. package/static/images/edit.png +0 -0
  51. package/static/images/eq.png +0 -0
  52. package/static/images/equation.png +0 -0
  53. package/static/images/experiment.png +0 -0
  54. package/static/images/favicon.ico +0 -0
  55. package/static/images/fewer-dec.png +0 -0
  56. package/static/images/filter.png +0 -0
  57. package/static/images/find.png +0 -0
  58. package/static/images/forward.png +0 -0
  59. package/static/images/host-logo.png +0 -0
  60. package/static/images/icon.png +0 -0
  61. package/static/images/icon.svg +23 -0
  62. package/static/images/ignore.png +0 -0
  63. package/static/images/include.png +0 -0
  64. package/static/images/info-to-clpbrd.png +0 -0
  65. package/static/images/info.png +0 -0
  66. package/static/images/is-black-box.png +0 -0
  67. package/static/images/lbl.png +0 -0
  68. package/static/images/lift.png +0 -0
  69. package/static/images/link.png +0 -0
  70. package/static/images/linny-r.icns +0 -0
  71. package/static/images/linny-r.ico +0 -0
  72. package/static/images/linny-r.png +0 -0
  73. package/static/images/linny-r.svg +21 -0
  74. package/static/images/logo.png +0 -0
  75. package/static/images/model-info.png +0 -0
  76. package/static/images/module.png +0 -0
  77. package/static/images/monitor.png +0 -0
  78. package/static/images/more-dec.png +0 -0
  79. package/static/images/ne.png +0 -0
  80. package/static/images/new.png +0 -0
  81. package/static/images/note.png +0 -0
  82. package/static/images/ok.png +0 -0
  83. package/static/images/open.png +0 -0
  84. package/static/images/outcome.png +0 -0
  85. package/static/images/parent.png +0 -0
  86. package/static/images/paste.png +0 -0
  87. package/static/images/pause.png +0 -0
  88. package/static/images/print-chart.png +0 -0
  89. package/static/images/print.png +0 -0
  90. package/static/images/process.png +0 -0
  91. package/static/images/product.png +0 -0
  92. package/static/images/pwlf.png +0 -0
  93. package/static/images/receiver.png +0 -0
  94. package/static/images/redo.png +0 -0
  95. package/static/images/remove.png +0 -0
  96. package/static/images/rename.png +0 -0
  97. package/static/images/repo-logo.png +0 -0
  98. package/static/images/repository.png +0 -0
  99. package/static/images/reset.png +0 -0
  100. package/static/images/resize.png +0 -0
  101. package/static/images/restore.png +0 -0
  102. package/static/images/save-chart.png +0 -0
  103. package/static/images/save-data.png +0 -0
  104. package/static/images/save-diagram.png +0 -0
  105. package/static/images/save.png +0 -0
  106. package/static/images/sensitivity.png +0 -0
  107. package/static/images/settings.png +0 -0
  108. package/static/images/solve.png +0 -0
  109. package/static/images/solver-logo.png +0 -0
  110. package/static/images/stats-to-clpbrd.png +0 -0
  111. package/static/images/stats.png +0 -0
  112. package/static/images/stop.png +0 -0
  113. package/static/images/store.png +0 -0
  114. package/static/images/stretch.png +0 -0
  115. package/static/images/table-to-clpbrd.png +0 -0
  116. package/static/images/table.png +0 -0
  117. package/static/images/tree.png +0 -0
  118. package/static/images/tudelft.png +0 -0
  119. package/static/images/ubl.png +0 -0
  120. package/static/images/undo.png +0 -0
  121. package/static/images/up.png +0 -0
  122. package/static/images/zoom-in.png +0 -0
  123. package/static/images/zoom-out.png +0 -0
  124. package/static/index.html +3088 -0
  125. package/static/linny-r.css +4722 -0
  126. package/static/scripts/iro.min.js +7 -0
  127. package/static/scripts/linny-r-config.js +105 -0
  128. package/static/scripts/linny-r-ctrl.js +1199 -0
  129. package/static/scripts/linny-r-gui.js +14814 -0
  130. package/static/scripts/linny-r-milp.js +286 -0
  131. package/static/scripts/linny-r-model.js +10405 -0
  132. package/static/scripts/linny-r-utils.js +687 -0
  133. package/static/scripts/linny-r-vm.js +7079 -0
  134. package/static/show-diff.html +84 -0
  135. package/static/show-png.html +113 -0
  136. package/static/sounds/error.wav +0 -0
  137. package/static/sounds/notification.wav +0 -0
  138. package/static/sounds/warning.wav +0 -0
package/server.js ADDED
@@ -0,0 +1,1547 @@
1
+ /*
2
+ Linny-R is an executable graphical specification language for (mixed integer)
3
+ linear programming (MILP) problems, especially unit commitment problems (UCP).
4
+ The Linny-R language and tool have been developed by Pieter Bots at Delft
5
+ University of Technology, starting in 2009. The project to develop a browser-
6
+ based version started in 2017. See https://linny-r.org for more information.
7
+
8
+ This NodeJS script (linny-r-node.js) provides a minimalist local host web server
9
+ (URL http://127.0.0.1:5050) that will serve the Linny-R GUI (HTML, CSS,
10
+ and JavaScript files, and images), process the requests from the browser
11
+ that pass the MILP equation model to the solver, and then return the solution
12
+ to the Linny-R "virtual machine" that is running in the browser.
13
+ */
14
+ /*
15
+ Copyright (c) 2020-2022 Delft University of Technology
16
+
17
+ Permission is hereby granted, free of charge, to any person obtaining a copy
18
+ of this software and associated documentation files (the "Software"), to deal
19
+ in the Software without restriction, including without limitation the rights
20
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
21
+ copies of the Software, and to permit persons to whom the Software is
22
+ furnished to do so, subject to the following conditions:
23
+
24
+ The above copyright notice and this permission notice shall be included in
25
+ all copies or substantial portions of the Software.
26
+
27
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
28
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
29
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
30
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
31
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
32
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
33
+ SOFTWARE.
34
+ */
35
+
36
+ ///////////////////////////////////////////////////////////////////////////////
37
+ // Please do not modify code unless you *really* know what you are doing //
38
+ ///////////////////////////////////////////////////////////////////////////////
39
+
40
+ const
41
+ // The version number of this Linny-R server in Node.js
42
+ VERSION_NUMBER = '1.0.0',
43
+
44
+ // The URL of the official Linny-R website (with the most recent release)
45
+ PUBLIC_LINNY_R_URL = 'https://sysmod.tbm.tudelft.nl/linny-r',
46
+
47
+ // The current working directory (from where Node.js was started) is
48
+ // assumed to be the main directory
49
+ MAIN_DIRECTORY = process.cwd(),
50
+
51
+ // Get the required built-in Node.js modules
52
+ child_process = require('child_process'),
53
+ crypto = require('crypto'),
54
+ fs = require('fs'),
55
+ http = require('http'),
56
+ https = require('https'),
57
+ os = require('os'),
58
+ path = require('path'),
59
+
60
+ // Get the platform name (win32, macOS, linux) of the user's computer
61
+ PLATFORM = os.platform();
62
+
63
+ // Immediately output some configuration information to the console
64
+ console.log('\nNode.js server for Linny-R version', VERSION_NUMBER);
65
+ console.log('Node.js version:', process.version);
66
+ console.log('Platform:', PLATFORM, '(' + os.type() + ')');
67
+ console.log('Main directory:', MAIN_DIRECTORY);
68
+
69
+ // Only then require the Node.js modules that are not "built-in"
70
+
71
+ const
72
+ { DOMParser } = checkNodeModule('@xmldom/xmldom');
73
+
74
+ function checkNodeModule(name) {
75
+ // Catches the error if Node.js module `name` is not available
76
+ try {
77
+ return require(name);
78
+ } catch(err) {
79
+ console.log(`ERROR: Node.js module "${name}" needs to be installed first`);
80
+ process.exit();
81
+ }
82
+ }
83
+
84
+ // Load class MILPSolver
85
+ const MILPSolver = require('./static/scripts/linny-r-milp.js');
86
+
87
+ ///////////////////////////////////////////////////////////////////////////////
88
+ // Code executed at start-up continues here //
89
+ ///////////////////////////////////////////////////////////////////////////////
90
+
91
+ // Default settings are used unless these are overruled by arguments on the
92
+ // command line. Possible arguments are:
93
+ // - port=[number] will make the server listen at port [number]
94
+ // - solver=[name] will select solver [name], or warn if not found
95
+ // - workspace=[path] will create workspace in [path] instead of (main)/user
96
+ const SETTINGS = commandLineSettings();
97
+
98
+ // The workspace defines the paths to directories where Linny-R can write files
99
+ const WORKSPACE = createWorkspace();
100
+
101
+ // Initialize the solver
102
+ const SOLVER = new MILPSolver(SETTINGS, WORKSPACE);
103
+
104
+ // Create launch script
105
+ createLaunchScript();
106
+
107
+ verifyScriptFiles();
108
+
109
+ // Create the HTTP server
110
+ const SERVER = http.createServer((req, res) => {
111
+ const u = new URL(req.url, 'http://127.0.0.1:' + SETTINGS.port);
112
+ // When POST, first get all the full body
113
+ if(req.method === 'POST') {
114
+ let body = '';
115
+ req.on('data', (data) => body += data);
116
+ req.on('end', () => processRequest(req, res, u.pathname, body));
117
+ } else if(req.method === 'GET') {
118
+ processRequest(req, res, u.pathname, u.search);
119
+ }
120
+ });
121
+
122
+ // Start listening at the specified port number
123
+ console.log('Listening at: http://127.0.0.1:' + SETTINGS.port);
124
+ SERVER.listen(SETTINGS.port);
125
+
126
+ // Finally, launch the GUI if this command line argument is set
127
+ if(SETTINGS.launch) {
128
+ console.log('Launching Linny-R in the default browser');
129
+ const cmd = (PLATFORM.startsWith('win') ? 'start' : 'open');
130
+ child_process.exec(cmd + ' http://127.0.0.1:' + SETTINGS.port,
131
+ (error, stdout, stderr) => {
132
+ console.log('NOTICE: Failed to launch GUI in browser');
133
+ console.log(stdout);
134
+ console.log(stderr);
135
+ });
136
+ }
137
+
138
+ // Version check & update functionality
139
+ // ====================================
140
+ // This section of code implements server responses to requests made by the
141
+ // browser immediately after loading the GUI page (`index.html`), or when the
142
+ // user clicks on the link "Version ..." below the Linny-R logo in the upper
143
+ // left corner of the GUI page.
144
+
145
+ const
146
+
147
+ VERSION_MESSAGE = `<!DOCTYPE html>
148
+ <html lang="en-US">
149
+ <head>
150
+ <meta http-equiv="content-type" content="text/html; charset=UTF-8">
151
+ <title>Linny-R version information</title>
152
+ <link rel="shortcut icon" type="image/png" href="../images/icon.png">
153
+ <style>
154
+ body {
155
+ font-family: sans-serif;
156
+ font-size: 16px;
157
+ }
158
+ #linny-r-logo {
159
+ height: 40px;
160
+ margin-bottom: -10px;
161
+ }
162
+ </style>
163
+ </head>
164
+ <body>
165
+ <img id="linny-r-logo" src="../images/logo.png">
166
+ %1%
167
+ </body>
168
+ </html>`,
169
+
170
+ NO_INTERNET_MESSAGE = `
171
+ <h3>Version check failed</h3>
172
+ <p>
173
+ No contact with the on-line Linny-R server --
174
+ please check your internet connection.
175
+ </p>`,
176
+
177
+ UP_TO_DATE_MESSAGE = `
178
+ <h3>Version JS-%1% is up-to-date</h3>
179
+ <p>Released on %2%</p>`,
180
+
181
+ DOWNLOAD_MESSAGE = `
182
+ <h3>Latest version is %1%</h3>
183
+ <p>Released on %2%</p>`,
184
+
185
+ SHUTDOWN_MESSAGE = `<!DOCTYPE html>
186
+ <html lang="en-US">
187
+ <head>
188
+ <meta http-equiv="content-type" content="text/html; charset=UTF-8">
189
+ <title>Linny-R server shutdown</title>
190
+ <link rel="shortcut icon" type="image/png" href="../images/icon.png">
191
+ <style>
192
+ body {
193
+ font-family: sans-serif;
194
+ font-size: 15px;
195
+ }
196
+ </style>
197
+ </head>
198
+ <body>
199
+ <h3>Linny-R server (127.0.0.1) is shutting down</h3>
200
+ <p>To upgrade and/or restart Linny-R, please switch to your
201
+ ${SETTINGS.cli_name} window and there at the prompt:
202
+ <p>To upgrade to a newer version of Linny-R, first type:</p>
203
+ <p>&nbsp;&nbsp;<tt>npm update linny-r</tt><p>
204
+ <p>To restart the server, type:</p>
205
+ <p>&nbsp;&nbsp;<tt>node server</tt></p>
206
+ <p>
207
+ Then switch back to this window, and click
208
+ <button type="button"
209
+ onclick="window.location.href = 'http://127.0.0.1:${SETTINGS.port}';">
210
+ Restart
211
+ </button>
212
+ </p>
213
+ </body>
214
+ </html>`;
215
+
216
+
217
+ function compareVersions(v1, v2) {
218
+ // Robust comparison of version numbers
219
+ nrs1 = (v1 + '.0.0.0').split('.');
220
+ nrs2 = (v2 + '.0.0.0').split('.');
221
+ for(i = 0; i < 4; i++) {
222
+ nrs1[i] = nrs1[i].padStart(6, '0');
223
+ nrs2[i] = nrs2[i].padStart(6, '0');
224
+ }
225
+ v1 = nrs1.slice(0, 4).join('.');
226
+ v2 = nrs2.slice(0, 4).join('.');
227
+ if(v1 > v2) return 1;
228
+ if(v1 < v2) return -1;
229
+ return 0;
230
+ }
231
+
232
+ function checkVersion(res, version) {
233
+ // Check whether current version is the most recent
234
+ console.log('Check version:', version);
235
+ if(!version) {
236
+ serveHTML(res, '<h3>No version number specified</h3>');
237
+ return;
238
+ }
239
+ version = version.split('-').pop();
240
+ getTextFromURL(PUBLIC_LINNY_R_URL + '/check-version/?info',
241
+ // The `on_ok` function
242
+ (data, res) => {
243
+ const
244
+ info = data.split('|');
245
+ // Should be [version, release date]
246
+ if(info.length === 2) {
247
+ if(compareVersions(version, info[0]) >= 0) {
248
+ message = UP_TO_DATE_MESSAGE.replace(
249
+ '%1%', info[0]).replace('%2%', info[1]);
250
+ } else {
251
+ message = DOWNLOAD_MESSAGE.replace(
252
+ '%1%', info[0]).replace('%2%', info[1]);
253
+ }
254
+ serveHTML(res, VERSION_MESSAGE.replace('%1%', message));
255
+ }
256
+ },
257
+ // The `on_error` function
258
+ (error, res) => {
259
+ console.log(error);
260
+ serveHTML(res, NO_INTERNET_MESSAGE);
261
+ },
262
+ // The response object
263
+ res);
264
+ }
265
+
266
+ function autoCheck(res) {
267
+ // Compares the version number in the static file `index.html`
268
+ // with the version number in the corresponding file on the official
269
+ // Linny-R website, and serves a status string that indicates whether
270
+ // a newer release is available
271
+ const gpath = path.join(MAIN_DIRECTORY, 'static', 'index.html');
272
+ // Read the globals script
273
+ fs.readFile(gpath, 'utf8', (err, data) => {
274
+ let v_match = null;
275
+ if(err) {
276
+ console.log('WARNING: Failed to read file', gpath);
277
+ } else {
278
+ // Extract the version number
279
+ v_match = data.match(/LINNY_R_VERSION = '(.+?)'/);
280
+ if(!v_match) console.log('WARNING: No version number found');
281
+ }
282
+ if(!v_match) {
283
+ servePlainText(res,'no version');
284
+ return;
285
+ }
286
+ let version = v_match[1];
287
+ // Get the current `index.html` file from the official Linny-R server
288
+ getTextFromURL(PUBLIC_LINNY_R_URL + '/check-version/?info',
289
+ // The `on_ok` function: compare versions and return status
290
+ (data, res) => {
291
+ let check = 'no match';
292
+ const
293
+ info = data.split('|');
294
+ // Should be [version, release date]
295
+ if(info.length === 2) {
296
+ if(compareVersions(version, info[0]) >= 0) {
297
+ check = 'up-to-date';
298
+ } else {
299
+ check = info[0] + '|' + info[1];
300
+ }
301
+ }
302
+ servePlainText(res, check);
303
+ },
304
+ // The `on_error` function
305
+ (error, res) => {
306
+ console.log(error);
307
+ servePlainText(res, 'no match');
308
+ },
309
+ // The response object
310
+ res);
311
+ });
312
+ }
313
+
314
+ // Repository functionality
315
+ // ========================
316
+ // For repository services, the Linny-R JavaScript application communicates with
317
+ // the server via calls to the server like $.post('repo', x) where x is a JSON
318
+ // object with at least the entry `action`, which can be one of the following:
319
+ // id return the repository URL (for this script: 'local host')
320
+ // list return list with names of repositories available on the server
321
+ // add add repository (name + url) to the repository list (if allowed)
322
+ // remove remove repository (by name) from the repository list (if allowed)
323
+ // dir return list with names of modules in the named repository
324
+ // load return the specified file content from the named repository
325
+ // access obtain write access for the named repository (requires valid token)
326
+ // store write XML content to the specified file in the named repository
327
+ // delete delete the specified module file from the named repository
328
+
329
+ function repo(res, sp) {
330
+ // Processes all repository commands
331
+ const action = sp.get('action').trim();
332
+ console.log('Repository action:', action);
333
+ if(action === 'id') return repoID(res);
334
+ if(action === 'list') return repoList(res);
335
+ if(action === 'add') return repoAdd(res, sp);
336
+ const repo = sp.get('repo').trim();
337
+ if(action === 'remove') return repoRemove(res, repo);
338
+ if(action === 'dir') return repoDir(res, repo);
339
+ if(action === 'access') return repoAccess(res, repo, sp.get('token'));
340
+ const file = sp.get('file').trim();
341
+ if(action === 'info') return repoInfo(res, repo, file);
342
+ if(action === 'load') return repoLoad(res, repo, file);
343
+ if(action === 'store') return repoStore(res, repo, file, sp.get('xml'));
344
+ if(action === 'delete') return repoDelete(res, repo, file);
345
+ // Fall-through: report error
346
+ servePlainText(res, `ERROR: Invalid action: "${action}"`);
347
+ }
348
+
349
+ function asFileName(s) {
350
+ // Returns string `s` with whitespace converted to a single dash, and special
351
+ // characters converted to underscores
352
+ s = s.trim().replace(/[\s\-]+/g, '-');
353
+ return s.replace(/[^A-Za-z0-9_\-]/g, '_');
354
+ }
355
+
356
+ function repositoryByName(name) {
357
+ // Returns array [name, url, token] if `name` found in file `repository.cfg`
358
+ repo_list = fs.readFileSync(WORKSPACE.repositories, 'utf8').split('\n');
359
+ for(let i = 0; i < repo_list.length; i++) {
360
+ rbn = repo_list[i].trim().split('|');
361
+ while(rbn.length < 2) rbn.push('');
362
+ if(rbn[0] === name) return rbn;
363
+ }
364
+ console.log(`ERROR: Repository "${name}" not found`);
365
+ return false;
366
+ }
367
+
368
+ function repoId(res) {
369
+ // Returns the URL of this repository server
370
+ // NOTE: this local WSGI server should return 'local host'
371
+ servePlainText(res, 'local host');
372
+ }
373
+
374
+ function repoList(res) {
375
+ // Returns name list of registered repositories
376
+ // NOTE: on a local Linny-R server, the first name is always 'local host'
377
+ let repo_list = 'local host';
378
+ try {
379
+ if(!fs.existsSync(WORKSPACE.repositories)) {
380
+ fs.writeFileSync(WORKSPACE.repositories, repo_list);
381
+ }
382
+ repo_list = fs.readFileSync(WORKSPACE.repositories, 'utf8').split('\n');
383
+ // Return only the names!
384
+ for(let i = 0; i < repo_list.length; i++) {
385
+ const r = repo_list[i].trim().split('|');
386
+ repo_list[i] = r[0];
387
+ // Add a + to indicate that storing is permitted
388
+ if(r[0] === 'local host' || (r.length > 2 && r[2])) repo_list[i] += '+';
389
+ }
390
+ repo_list = repo_list.join('\n');
391
+ } catch(err) {
392
+ console.log('ERROR: Failed to access repository -- ' + err.message);
393
+ }
394
+ servePlainText(res, repo_list);
395
+ }
396
+
397
+ function repoAdd(res, sp) {
398
+ // Registers a remote repository on this local Linny-R server
399
+ let rname = sp.get('repo');
400
+ if(rname) rname = rname.trim();
401
+ if(!rname) return servePlainText(res, 'WARNING: Invalid name');
402
+ // Get URL without trailing slashes
403
+ let url = sp.get('url');
404
+ url = 'https://' + (url ? url.trim() : '');
405
+ let i = url.length - 1;
406
+ while(url[i] === '/') i--;
407
+ url = url.substring(0, i);
408
+ try {
409
+ test = new URL(url);
410
+ } catch(err) {
411
+ return servePlainText(res, 'WARNING: Invalid URL');
412
+ }
413
+ // Error callback function is used twice, so define it here
414
+ const noConnection = (error, res) => {
415
+ console.log(error);
416
+ servePlainText(res, 'ERROR: Failed to connect to ' + url);
417
+ };
418
+ // Verify that the URL points to a Linny-R repository
419
+ postRequest(url, {action: 'id'},
420
+ // The `on_ok` function
421
+ (data, res) => {
422
+ // Response should be the URL of the repository
423
+ if(data !== url) {
424
+ servePlainText(res, 'WARNING: Not a Linny-R repository');
425
+ return;
426
+ }
427
+ // If so, append name|url|token to the configuration file
428
+ // NOTE: token is optional
429
+ let token = sp.get('token');
430
+ if(token) token = token.trim();
431
+ if(token) {
432
+ postRequest(url, {action: 'access', repo: rname, token: token},
433
+ // The `on_ok` function
434
+ (data, res) => {
435
+ if(data !== 'Authenticated') {
436
+ servePlainText(res, data);
437
+ return;
438
+ }
439
+ list = fs.readFileSync(
440
+ WORKSPACE.repositories, 'utf8').split('\n');
441
+ for(let i = 0; i < list.length; i++) {
442
+ nu = entry.trim().split('|');
443
+ if(nu[0] !== 'local host') {
444
+ if(nu[0] == rname) {
445
+ servePlainText(res,
446
+ `WARNING: Repository name "${rname}" already in use`);
447
+ return;
448
+ }
449
+ if(nu[1] === url) {
450
+ servePlainText(res,
451
+ `WARNING: Repository already registered as "${nu[0]}"`);
452
+ return;
453
+ }
454
+ }
455
+ }
456
+ list.push([rname, url, token].join('|'));
457
+ fs.writeFileSync(WORKSPACE.repositories, list.join('\n'));
458
+ servePlainText(res, rname);
459
+ },
460
+ // The `on_error` function and the response object
461
+ noConnection, res);
462
+ }
463
+ },
464
+ // The `on_error` function and the response object
465
+ noConnection, res);
466
+ }
467
+
468
+ function repoRemove(res, rname) {
469
+ // Removes a registered repository from the repository configuration file
470
+ if(rname === 'local host') {
471
+ servePlainText(res, 'ERROR: Cannot remove local host');
472
+ return;
473
+ }
474
+ try {
475
+ // Read list of repositories registered on this local host server
476
+ list = fs.readFileSync(WORKSPACE.repositories, 'utf8').split('\n');
477
+ // Look for a repository called `rname`
478
+ let index = -1;
479
+ for(let i = 0; i < list.length; i++) {
480
+ const nu = list[i].trim().split('|');
481
+ if(nu[0] === rname) {
482
+ index = i;
483
+ break;
484
+ }
485
+ }
486
+ if(index < 0) {
487
+ // Not found => cannot remove
488
+ servePlainText(res, `ERROR: Repository "${rname}" not found`);
489
+ } else {
490
+ // Remove from list and save it to file `repository.cfg`
491
+ list.splice(index, 1);
492
+ fs.writeFileSync(WORKSPACE.repositories, list.join('\n'));
493
+ // Return the name to indicate "successfully removed"
494
+ servePlainText(res, rname);
495
+ }
496
+ } catch(err) {
497
+ console.log(err);
498
+ servePlainText(res, `ERROR: Failed to remove "${rname}"`);
499
+ }
500
+ }
501
+
502
+ function repoDir(res, rname) {
503
+ // Returns a newline-separated list of names of the modules stored in the
504
+ // specified repository
505
+ const mlist = [];
506
+ if(rname === 'local host') {
507
+ // Return list of base filenames of Linny-R models in `modules` directory
508
+ const flist = fs.readdirSync(WORKSPACE.modules);
509
+ for(let i = 0; i < flist.length; i++) {
510
+ const pp = path.parse(flist[i]);
511
+ // Only add Linny-R model files (.lnr) without this extension
512
+ if(pp.ext === '.lnr') mlist.push(pp.name);
513
+ }
514
+ servePlainText(res, mlist.join('\n'));
515
+ } else {
516
+ // Get list from remote server
517
+ const r = repositoryByName(rname);
518
+ if(r) {
519
+ postRequest(r[1], {action: 'dir', repo: r[0]},
520
+ // The `on_ok` function
521
+ (data, res) => servePlainText(res, data),
522
+ // The `on_error` function
523
+ (error, res) => {
524
+ console.log(error);
525
+ servePlainText(res,
526
+ `ERROR: Failed to access remote repository "${rname}"`);
527
+ },
528
+ res);
529
+ } else {
530
+ servePlainText(res, `ERROR: Repository "${rname}" not registered`);
531
+ }
532
+ }
533
+ }
534
+
535
+ function repoInfo(res, rname, mname) {
536
+ // Returns the documentation (<notes> in XML) of the requested model file
537
+ // if found in the specified repository
538
+ // NOTE: the function `serveNotes` is called when the XML text has been
539
+ // retrieved either from a local file or from a remote repository URL
540
+ const serveNotes = (res, xml) => {
541
+ // Parse XML string
542
+ try {
543
+ const parser = new DOMParser();
544
+ xml = parser.parseFromString(xml, 'text/xml');
545
+ const de = xml.documentElement;
546
+ // Linny-R model must contain a model node
547
+ if(de.nodeName !== 'model') throw 'XML document has no model element';
548
+ let notes = '';
549
+ // The XML will contain many "notes" elements; only consider child
550
+ // nodes of the "model" element
551
+ for(let i = 0; i < de.childNodes.length; i++) {
552
+ const ce = de.childNodes[i];
553
+ // NOTE: node text content is stored as a child node
554
+ if(ce.nodeName === 'notes' && ce.childNodes.length > 0) {
555
+ notes = ce.childNodes[0].nodeValue;
556
+ break;
557
+ }
558
+ }
559
+ servePlainText(res, notes);
560
+ } catch(err) {
561
+ console.log(err);
562
+ console.log('XML', xml);
563
+ servePlainText(res, 'ERROR: Failed to parse XML of Linny-R model');
564
+ }
565
+ };
566
+ // See where to obtain the XML
567
+ if(rname === 'local host') {
568
+ // NOTE: file name includes version number but not the extension
569
+ fs.readFile(path.join(WORKSPACE.modules, mname + '.lnr'), 'utf8',
570
+ (err, data) => {
571
+ if(err) {
572
+ console.log(err);
573
+ servePlainText(res, 'ERROR: Failed to read model file');
574
+ } else {
575
+ serveNotes(res, data);
576
+ }
577
+ });
578
+ } else {
579
+ // Get file from remote server
580
+ r = repositoryByName(rname);
581
+ if(r) {
582
+ postRequest(r[1], {action: 'load', repo: r[0], file: mname},
583
+ // The `on_ok` function
584
+ (data, res) => serveNotes(res, data.toString()),
585
+ // The `on_error` function
586
+ (error, res) => {
587
+ console.log(error);
588
+ servePlainText(res, 'ERROR: Failed to download model file');
589
+ },
590
+ res);
591
+ } else {
592
+ servePlainText(res, `ERROR: Repository "${rname}" not registered`);
593
+ }
594
+ }
595
+ }
596
+
597
+ function repoLoad(res, rname, mname, pipe=null) {
598
+ // Returns the requested model file if found in the specified repository
599
+ // The optional function pipe(res, xml) allows pass ingon the loaded XML
600
+ if(rname === 'local host') {
601
+ // NOTE: file name includes version number but not the extension
602
+ fs.readFile(path.join(WORKSPACE.modules, mname + '.lnr'), 'utf8',
603
+ (err, data) => {
604
+ if(err) {
605
+ console.log(err);
606
+ servePlainText(res, 'ERROR: Failed to read model file');
607
+ } else if(pipe) {
608
+ pipe(res, data);
609
+ } else {
610
+ servePlainText(res, data);
611
+ }
612
+ });
613
+ } else {
614
+ // Get file from remote server
615
+ r = repositoryByName(rname);
616
+ if(r) {
617
+ postRequest(r[1], {action: 'load', repo: r[0], file: mname},
618
+ // The `on_ok` function
619
+ (data, res) => {
620
+ if(pipe) {
621
+ pipe(res, data.toString());
622
+ } else {
623
+ servePlainText(res, data.toString());
624
+ }
625
+ },
626
+ // The `on_error` function
627
+ (error, res) => {
628
+ console.log(error);
629
+ servePlainText(res, 'ERROR: Failed to download model file');
630
+ },
631
+ res);
632
+ } else {
633
+ servePlainText(res, `ERROR: Repository "${rname}" not registered`);
634
+ }
635
+ }
636
+ }
637
+
638
+ function repoAccess(res, rname, rtoken) {
639
+ // Requests write access for a remote repository
640
+ r = repositoryByName(rname);
641
+ if(!r) {
642
+ servePlainText(`ERROR: Repository "${rname}" not registered`);
643
+ return;
644
+ }
645
+ postRequest(r[1], {action: 'access', repo: r[0], token: rtoken},
646
+ // The `on_ok` function
647
+ (data, res) => {
648
+ if(data !== 'Authenticated') {
649
+ servePlainText(res, data);
650
+ } else {
651
+ try {
652
+ // Read the list of repositories from the repository config file
653
+ const
654
+ list = fs.readFileSync(
655
+ WORKSPACE.repositories, 'utf8').split('\n'),
656
+ new_list = [];
657
+ for(let i = 0; i < list.length; i++) {
658
+ const nu = list[i].trim().split('|');
659
+ if(nu[0] === rname) {
660
+ // Add or replace the token
661
+ if(nu.length === 2) {
662
+ nu.push(rtoken);
663
+ } else if(nu.length === 3) {
664
+ nu[2] = rtoken;
665
+ }
666
+ }
667
+ new_list.push(nu.join('|'));
668
+ }
669
+ fs.writeFileSync(new_list.join('\n'));
670
+ servePlainText(res, `Authenticated for <b>${rname}</b>`);
671
+ } catch(err) {
672
+ console.log(err);
673
+ servePlainText(res, `ERROR: Failed to set token for "${rname}"`);
674
+ }
675
+ }
676
+ },
677
+ // The `on_error` function
678
+ (error, res) => {
679
+ console.log(error);
680
+ servePlainText(res, 'ERROR: Failed to connect to' + r[1]);
681
+ },
682
+ res);
683
+ }
684
+
685
+ function repoStore(res, rname, mname, mxml) {
686
+ // Stores the posted model in the specified repository
687
+ // NOTE: file name must not contain spaces or special characters
688
+ mname = asFileName(mname);
689
+ // Validate XML as a Linny-R model
690
+ let valid = false;
691
+ try {
692
+ const
693
+ parser = new DOMParser(),
694
+ doc = parser.parseFromString(mxml, 'text/xml');
695
+ root = doc.documentElement;
696
+ // Linny-R model have a model element as root
697
+ if(root.nodeName !== 'model') throw 'XML document has no model element';
698
+ valid = true;
699
+ } catch(err) {
700
+ console.log(err);
701
+ servePlainText(res, 'ERROR: Not a Linny-R model');
702
+ return;
703
+ }
704
+ if(rname === 'local host') {
705
+ // Always allow storing on local host
706
+ try {
707
+ // NOTE: first find latest version (if any)
708
+ const re = new RegExp('^' + mname + '-(\\d+).lnr');
709
+ // NOTE: Version numbers start at 1
710
+ let version = 0;
711
+ const list = fs.readdirSync(WORKSPACE.modules);
712
+ for(let i = 0; i < list.length; i++) {
713
+ const match = list[i].match(re);
714
+ if(match && match.length > 1) {
715
+ // File name equal to model name plus version number => get the number
716
+ version = Math.max(version, parseInt(match[1]));
717
+ }
718
+ }
719
+ mname += `-${version + 1}.lnr`;
720
+ fs.writeFileSync(path.join(WORKSPACE.modules, mname), mxml);
721
+ servePlainText(res, `Model stored as <tt>${mname}</tt>`);
722
+ } catch(err) {
723
+ console.log(err);
724
+ servePlainText(res, 'ERROR: Failed to write file');
725
+ }
726
+ } else {
727
+ // Otherwise, post file with token
728
+ r = repositoryByName(rname);
729
+ if(r) {
730
+ postRequest(r[1],
731
+ {action: 'store', repo: rname, file: mname, xml: mxml, token: r[2]},
732
+ // The `on_ok` function: serve the data sent by the remote server
733
+ (data, res) => servePlainText(res, data),
734
+ // The `on_error` function
735
+ (error, res) => {
736
+ console.log(error);
737
+ servePlainText(res, 'ERROR: Failed to connect to' + r[1]);
738
+ },
739
+ res);
740
+ } else {
741
+ servePlainText(res, `ERROR: Repository "${rname}" not registered`);
742
+ }
743
+ }
744
+ }
745
+
746
+ function repoDelete(res, name, file) {
747
+ // Deletes the specified module from the specified repository
748
+ // NOTE: this works only on the "local host" repository on this server
749
+ if(name === 'local host') {
750
+ // Delete specified model file
751
+ // NOTE: file name includes version number but not the extension
752
+ try {
753
+ fs.unlinkSync(path.join(WORKSPACE.modules, file + '.lnr'));
754
+ servePlainText(res,
755
+ `Module <tt>${file}</tt> removed from <strong>${name}</strong>`);
756
+ } catch(err) {
757
+ console.log(err);
758
+ servePlainText(resp, 'ERROR: Failed to delete file');
759
+ }
760
+ } else {
761
+ servePlainText(res, 'Cannot delete modules from a remote repository');
762
+ }
763
+ }
764
+
765
+ // Remote dataset functionality
766
+ // ============================
767
+ // This code section implements the retrieval of time series data from the URL
768
+ // or file path (on local host) when such a URL or path is specified in the
769
+ // Dataset dialog
770
+
771
+ function anyOSpath(p) {
772
+ // Helper function that converts Unix path notation (with slashes) to
773
+ // Windows notation if needed
774
+ if(p.indexOf('/') < 0) return p;
775
+ p = p.split('/');
776
+ // On macOS machines, paths start with a slash, so first substring is empty
777
+ if(p[0].length === 0) {
778
+ // In that case, add the leading slash
779
+ return '/' + path.join(...p);
780
+ } else if(p[0].endsWith(':') && path.sep === '\\') {
781
+ // On Windows machines, add a backslash after the disk (if specified)
782
+ path[0] += path.sep;
783
+ }
784
+ // Reassemble path for the OS of this machine
785
+ return path.join(...p);
786
+ }
787
+
788
+ function loadData(res, url) {
789
+ // Passed parameter is the URL or full path
790
+ console.log('Load data from', url);
791
+ if(!url) servePlainText(res, 'ERROR: No URL or path');
792
+ if(url.toLowerCase().startsWith('http')) {
793
+ // URL => validate it, and then try to download its content as text
794
+ try {
795
+ new URL(url); // Will throw an error if URL is not valid
796
+ getTextFromURL(url,
797
+ (data, res) => servePlainText(res, data),
798
+ (error, res) => servePlainText(res,
799
+ `ERROR: Failed to get data from <tt>${url}</tt>`),
800
+ res);
801
+ } catch(err) {
802
+ console.log(err);
803
+ servePlainText(res, `ERROR: Invalid URL <tt>${url}</tt>`);
804
+ }
805
+ } else {
806
+ const fp = anyOSpath(url);
807
+ fs.readFile(fp, 'utf8', (err, data) => {
808
+ if(err) {
809
+ console.log(err);
810
+ servePlainText(res, `ERROR: Could not read file <tt>${fp}</tt>`);
811
+ } else {
812
+ servePlainText(res, data);
813
+ }
814
+ });
815
+ }
816
+ }
817
+
818
+ // Receiver functionality
819
+ // ======================
820
+ // Respond to Linny-R receiver actions:
821
+ // listen - look for a Linny-r model file in the channel directory, and run it
822
+ // abort - write message to file <original model file name>-abort.txt
823
+ // report - write data and statistics on all chart variables as two text files
824
+ // having names <original model file name>-data.txt and -stats.txt,
825
+ // respectively
826
+ // call-back - delete model file (to prevent running it again), and then execute
827
+ // the call-back Python script specified for the channel
828
+
829
+ function receiver(res, sp) {
830
+ //This function processes all receiver actions
831
+ let
832
+ rpath = anyOSpath(sp.get('path') || ''),
833
+ rfile = anyOSpath(sp.get('file') || '');
834
+ // Assume that path is relative tochannel directory unless it starts with
835
+ // a (back)slash or specifiess drive or volume
836
+ if(!(rpath.startsWith(path.sep) || rpath.indexOf(':') >= 0 ||
837
+ rpath.startsWith(MAIN_DIRECTORY))) {
838
+ rpath = path.join(MAIN_DIRECTORY, rpath);
839
+ }
840
+ // Verify that the channel path exists
841
+ try {
842
+ fs.opendirSync(rpath);
843
+ } catch(err) {
844
+ console.log(err);
845
+ servePlainText(res, `ERROR: No channel path (${rpath})`);
846
+ return;
847
+ }
848
+ // Get the action from the search parameters
849
+ const action = sp.get('action');
850
+ console.log('Receiver action:', action, rpath, rfile);
851
+ if(action === 'listen') {
852
+ rcvrListen(res, rpath);
853
+ } else if(action === 'abort') {
854
+ rcvrAbort(res, rpath, rfile, sp.get('log') || 'NO EVENT LOG');
855
+ } else if(action === 'report') {
856
+ let run = sp.get('run');
857
+ // Zero-pad run number to permit sorting run report file names in sequence
858
+ run = (run ? '-' + run.padStart(3, '0') : '');
859
+ let data = sp.get('data') || '',
860
+ stats = sp.get('stats') || '',
861
+ log = sp.get('log') || 'NO EVENT LOG';
862
+ rcvrReport(res, rpath, rfile, run, data, stats, log);
863
+ } else if(action === 'call-back') {
864
+ rcvrCallBack(res, rpath, rfile, sp.get('script') || '');
865
+ } else {
866
+ servePlainText(res, `ERROR: Invalid action: "${action}"`);
867
+ }
868
+ }
869
+
870
+ function rcvrListen(res, rpath) {
871
+ // "Listens" at the channel, i.e., looks for work to do
872
+ let mdl = '',
873
+ cmd = '';
874
+ try {
875
+ // Look for a model file and/or a command file in the channel directory
876
+ const flist = fs.readdirSync(rpath);
877
+ // NOTE: `flist` contains file names relative to `rpath`
878
+ for(let i = 0; i < flist.length; i++) {
879
+ const f = path.parse(flist[i]);
880
+ if(f.ext === '.lnr' && !mdl) mdl = flist[i];
881
+ if(f.ext === '.lnrc' && !cmd) cmd = flist[i];
882
+ }
883
+ } catch(err) {
884
+ console.log(err);
885
+ servePlainText(res, `ERROR: Failed to get file list from <tt>${rpath}</tt>`);
886
+ return;
887
+ }
888
+ // Model files take precedence over command files
889
+ if(mdl) {
890
+ fs.readFile(path.join(rpath, mdl), 'utf8', (err, data) => {
891
+ if(err) {
892
+ console.log(err);
893
+ servePlainText(res, `ERROR: Failed to read model <tt>${mdl}</tt>`);
894
+ } else {
895
+ serveJSON(res, {file: path.parse(mdl).name, model: data});
896
+ }
897
+ });
898
+ return;
899
+ }
900
+ if(cmd) {
901
+ try {
902
+ cmd = fs.readFileSync(path.join(rpath, cmd), 'utf8').trim();
903
+ } catch(err) {
904
+ console.log(err);
905
+ servePlainText(res, `ERROR: Failed to read command file <tt>${cmd}</tt>`);
906
+ }
907
+ // Special command to deactivate the receiver
908
+ if(cmd === 'STOP LISTENING') {
909
+ serveJSON(res, {stop: 1});
910
+ } else {
911
+ // For now, command can only be
912
+ // "[experiment name|]module name[@repository name]"
913
+ let m = '',
914
+ r = '',
915
+ x = '';
916
+ const m_r = cmd.split('@');
917
+ // Repository `r` is local host unless specified
918
+ if(m_r.length === 2) {
919
+ r = m_r[1];
920
+ } else if(m_r.length === 1) {
921
+ r = 'local host';
922
+ } else {
923
+ // Multiple occurrences of @
924
+ servePlainText(res, `ERROR: Invalid command <tt>${cmd}</tt>`);
925
+ return;
926
+ }
927
+ m = m_r[0];
928
+ // Module `m` can be prefixed by an experiment title
929
+ const x_m = m.split('|');
930
+ if(x_m.length === 2) {
931
+ x = x_m[0];
932
+ m = x_m[1];
933
+ }
934
+ // Call repoLoad with its callback function to get the model XML
935
+ repoLoad(res, r.trim(), m.trim(), (res, xml) => serveJSON(res,
936
+ {file: path.parse(cmd).name, model: xml, experiment: x.trim()}));
937
+ }
938
+ } else {
939
+ // Empty fields will be interpreted as "nothing to do"
940
+ serveJSON(res, {file: '', model: '', experiment: ''});
941
+ }
942
+ }
943
+
944
+ function rcvrAbort(res, rpath, rfile, log) {
945
+ const log_path = path.join(rpath, rfile + '-log.txt');
946
+ fs.writeFile(log_path, log, (err) => {
947
+ if(err) {
948
+ console.log(err);
949
+ servePlainText(res,
950
+ `ERROR: Failed to write event log to file <tt>${log_path}</tt>`);
951
+ } else {
952
+ servePlainText(res, 'Remote run aborted');
953
+ }
954
+ });
955
+ }
956
+
957
+ function rcvrReport(res, rpath, rfile, run, data, stats, log) {
958
+ try {
959
+ let fp = path.join(rpath, rfile + run + '-data.txt');
960
+ fs.writeFileSync(fp, data);
961
+ } catch(err) {
962
+ console.log(err);
963
+ servePlainText(res,
964
+ `ERROR: Failed to write data to file <tt>${fp}</tt>`);
965
+ return;
966
+ }
967
+ try {
968
+ fp = path.join(rpath, rfile + run + '-stats.txt');
969
+ fs.writeFileSync(fp, stats);
970
+ } catch(err) {
971
+ console.log(err);
972
+ servePlainText(res,
973
+ `ERROR: Failed to write statistics to file <tt>${fp}</tt>`);
974
+ return;
975
+ }
976
+ try {
977
+ fp = path.join(rpath, rfile + run + '-log.txt');
978
+ fs.writeFileSync(fp, log);
979
+ } catch(err) {
980
+ console.log(err);
981
+ servePlainText(res,
982
+ `ERROR: Failed to write event log to file <tt>${fp}</tt>`);
983
+ }
984
+ servePlainText(res, `Data and statistics reported for <tt>${rfile}</tt>`);
985
+ }
986
+
987
+ function rcvrCallBack(res, rpath, rfile, script) {
988
+ let file_type = '',
989
+ cpath = path.join(rpath, rfile + '.lnr');
990
+ try {
991
+ fs.accessSync(cpath);
992
+ file_type = 'model';
993
+ } catch(err) {
994
+ cpath = path.join(rpath, rfile + '.lnrc');
995
+ try {
996
+ fs.accessSync(cpath);
997
+ file_type = 'command';
998
+ } catch(err) {
999
+ cpath = '';
1000
+ }
1001
+ }
1002
+ if(cpath) {
1003
+ console.log('Deleting', file_type, ' file:', cpath);
1004
+ try {
1005
+ fs.unlinkSync(cpath);
1006
+ } catch(err) {
1007
+ console.log(err);
1008
+ servePlainText(res,
1009
+ `ERROR: Failed to delete ${file_type} file <tt>${rfile}</tt>`);
1010
+ return;
1011
+ }
1012
+ }
1013
+ if(!script) {
1014
+ servePlainText(res, 'No call-back script to execute');
1015
+ return;
1016
+ }
1017
+ try {
1018
+ cmd = fs.readFileSync(path.join(WORKSPACE.callback, script), 'utf8');
1019
+ console.log(`Executing callback command "${cmd}"`);
1020
+ child_process.exec(cmd, (error, stdout, stderr) => {
1021
+ console.log(stdout);
1022
+ if(error) {
1023
+ console.log(error);
1024
+ console.log(stderr);
1025
+ servePlainText(res,
1026
+ `ERROR: Failed to execute script <tt>${script}</tt>`);
1027
+ } else {
1028
+ servePlainText(res, `Call-back script <tt>${script}</tt> executed`);
1029
+ }
1030
+ });
1031
+ } catch(err) {
1032
+ console.log(err);
1033
+ servePlainText(res,
1034
+ `WARNING: Call-back script <tt>${script}</tt> not found`);
1035
+ }
1036
+ }
1037
+
1038
+ // Basic server functionality
1039
+ // ===========================
1040
+ //
1041
+ // To provide some minimum of security, the files that will be served
1042
+ // from the (main)/static directory are restricted to specific MIME
1043
+ // types, files, and sub-directories of (main)/static
1044
+ const STATIC_FILES = {
1045
+ // MIME types of files that can be served
1046
+ extensions: {
1047
+ js: 'application/javascript',
1048
+ xml: 'application/xml',
1049
+ wav: 'audio/x-wav',
1050
+ ttc: 'font/collection',
1051
+ otf: 'font/otf',
1052
+ ttf: 'font/ttf',
1053
+ icns: 'image/icns',
1054
+ png: 'image/png',
1055
+ svg: 'image/svg+xml',
1056
+ ico: 'image/x-icon',
1057
+ css: 'text/css',
1058
+ html: 'text/html',
1059
+ txt: 'text/plain'
1060
+ },
1061
+ // Subdirectories of (main)/static/ directory from which files with
1062
+ // accepted MIME types can be served
1063
+ directories: {
1064
+ '/scripts': ['js'],
1065
+ '/images': ['icns', 'ico', 'png', 'svg'],
1066
+ '/fonts': ['otf', 'ttc', 'ttf'],
1067
+ '/sounds': ['wav'],
1068
+ // NOTE: diagrams will actually be served from (main)/user/diagrams/
1069
+ '/diagrams': ['png', 'svg']
1070
+ },
1071
+ // Files that can be served from the (main)/static/ directory itself
1072
+ files: [
1073
+ '/index.html',
1074
+ '/show-png.html',
1075
+ '/show-diff.html',
1076
+ '/linny-r.css',
1077
+ '/favicon.ico',
1078
+ ]
1079
+ };
1080
+
1081
+ function processRequest(req, res, cmd, data) {
1082
+ // Make correct response to request
1083
+ // NOTE: `data` is a string of form field1=value1&field2=value2& ... etc.
1084
+ // regardless of the request method (GET or POST)
1085
+ if(permittedFile(cmd)) {
1086
+ // Path contains valid MIME file type extension => serve if allowed
1087
+ serveStaticFile(res, cmd);
1088
+ } else if(cmd === '/solver/') {
1089
+ const
1090
+ sp = new URLSearchParams(data),
1091
+ action = sp.get('action');
1092
+ // NOTE: on remote servers, solver actions require authentication
1093
+ if(action === 'logon') {
1094
+ // No authentication -- simply return the passed token, "local host" as
1095
+ // server name, and the identifier of the solver
1096
+ serveJSON(res,
1097
+ {token: 'local host', server: 'local host', solver: SOLVER.id});
1098
+ } else if(action === 'png') {
1099
+ convertSVGtoPNG(req, res, sp);
1100
+ } else if(action === 'solve') {
1101
+ serveJSON(res, SOLVER.solveBlock(sp));
1102
+ } else {
1103
+ // Invalid action => return JSON with error message
1104
+ const msg = `Invalid action: "${action}"`;
1105
+ console.log(msg);
1106
+ serveJSON(res, {error: msg});
1107
+ }
1108
+ } else if(cmd === '/shutdown') {
1109
+ // Shut down this server
1110
+ serveHTML(res, SHUTDOWN_MESSAGE);
1111
+ SERVER.close();
1112
+ } else if(cmd === '/auto-check') {
1113
+ autoCheck(res);
1114
+ } else if(cmd === '/auto-update') {
1115
+ autoUpdate(res);
1116
+ } else if(cmd === '/check-version') {
1117
+ checkVersion(res, (new URLSearchParams(data)).get('v'));
1118
+ } else if(cmd === '/repo/') {
1119
+ repo(res, new URLSearchParams(data));
1120
+ } else if(cmd === '/load-data/') {
1121
+ loadData(res, (new URLSearchParams(data)).get('url'));
1122
+ } else if(cmd === '/receiver/') {
1123
+ receiver(res, new URLSearchParams(data));
1124
+ } else {
1125
+ serveJSON(res, {error: `Unknown Linny-R request: "${cmd}"`});
1126
+ }
1127
+ }
1128
+
1129
+ function servePlainText(res, msg) {
1130
+ // Serve string `msg` as plain text
1131
+ res.setHeader('Content-Type', 'text/plain');
1132
+ res.writeHead(200);
1133
+ res.end(msg);
1134
+ }
1135
+
1136
+ function serveHTML(res, html) {
1137
+ // Serve HTML string `html`
1138
+ res.setHeader('Content-Type', 'text/html');
1139
+ res.writeHead(200);
1140
+ res.end(html);
1141
+ }
1142
+
1143
+ function serveJSON(res, obj) {
1144
+ // Serve object `obj` as JSON string
1145
+ res.setHeader('Content-Type', 'application/json');
1146
+ res.writeHead(200);
1147
+ res.end(JSON.stringify(obj));
1148
+ }
1149
+
1150
+ function permittedFile(path) {
1151
+ // Returns TRUE when file specified by `path` may be served
1152
+ if(path === '/' || path === '') path = '/index.html';
1153
+ if(STATIC_FILES.files.indexOf(path) >= 0) return true;
1154
+ const
1155
+ parts = path.split('/'),
1156
+ file = parts.pop(),
1157
+ dir = STATIC_FILES.directories[parts.join('/')],
1158
+ ext = file.split('.').pop();
1159
+ if(dir && dir.indexOf(ext) >= 0) return true;
1160
+ return false;
1161
+ }
1162
+
1163
+ function serveStaticFile(res, path) {
1164
+ // Serve the specified path (if permitted: only diagrams and static files)
1165
+ if(path === '/' || path === '') path = '/index.html';
1166
+ if(path.startsWith('/diagrams/')) {
1167
+ // Serve diagrams from the (main)/user/diagrams/ sub-directory
1168
+ console.log('Diagram:', path);
1169
+ path = '/user' + path;
1170
+ } else {
1171
+ // Other files from the (main)/static/ subdirectory
1172
+ console.log('Static file:', path);
1173
+ path = '/static' + path;
1174
+ }
1175
+ fs.readFile(MAIN_DIRECTORY + path, (err, data) => {
1176
+ if(err) {
1177
+ console.log(err);
1178
+ res.writeHead(404);
1179
+ res.end(JSON.stringify(err));
1180
+ return;
1181
+ }
1182
+ const ct = STATIC_FILES.extensions[path.split('.').pop()];
1183
+ res.setHeader('Content-Type', ct);
1184
+ res.writeHead(200);
1185
+ res.end(data);
1186
+ });
1187
+ }
1188
+
1189
+ function convertSVGtoPNG(req, res, sp) {
1190
+ // Convert SVG data from browser to PNG image using Inkscape
1191
+ // NOTE: images can be huge, so send only the file name as response;
1192
+ // Linny-R will open a new browser window, load the file, and display it
1193
+ const
1194
+ svg = decodeURI(atob(sp.get('data'))),
1195
+ // Use current time as file name
1196
+ fn = 'diagram-' +
1197
+ (new Date()).toISOString().slice(0, 19).replace(/[\-\:]/g, ''),
1198
+ fp = path.join(WORKSPACE.diagrams, fn);
1199
+ // NOTE: use binary encoding for SVG file
1200
+ console.log('Saving SVG file:', fp);
1201
+ try {
1202
+ fs.writeFileSync(fp + '.svg', svg);
1203
+ } catch(error) {
1204
+ console.log('WARNING: Failed to save SVG --', error);
1205
+ }
1206
+ // Use Inkscape to convert SVG to the requested format
1207
+ if(SETTINGS.inkscape) {
1208
+ console.log('Rendering image');
1209
+ let
1210
+ cmd = SETTINGS.inkscape,
1211
+ svg = fp + '.svg';
1212
+ // Enclose paths in double quotes if they contain spaces
1213
+ if(cmd.indexOf(' ') >= 0) cmd = `"${cmd}"`;
1214
+ if(svg.indexOf(' ') >= 0) svg = `"${svg}"`;
1215
+ child_process.exec(cmd + ' --export-type=png --export-dpi=' +
1216
+ SETTINGS.dpi + ' ' + svg,
1217
+ (error, stdout, stderr) => {
1218
+ let ext = '.svg';
1219
+ console.log(stdout);
1220
+ if(error) {
1221
+ console.log('WARNING: Failed to run Inkscape --', error);
1222
+ console.log(stderr);
1223
+ } else {
1224
+ ext = '.png';
1225
+ // Delete the SVG
1226
+ try {
1227
+ fs.unlinkSync(fp + '.svg');
1228
+ } catch(error) {
1229
+ console.log(`NOTICE: Failed to delete SVG file "${fp}.svg"`);
1230
+ }
1231
+ }
1232
+ // Return the image file name (PNG if successful, otherwise SVG)
1233
+ servePlainText(res, 'diagrams/' + fn + ext);
1234
+ }
1235
+ );
1236
+ } else {
1237
+ servePlainText(res, 'diagrams/' + fn + '.svg');
1238
+ }
1239
+ }
1240
+
1241
+ // Convenience functions to fetch data from external URL
1242
+ // NOTE: the parameters `on_ok` and `on_error` must be functions with two
1243
+ // parameters:
1244
+ // - `result`: either a string with the text obtained from the URL, or an error
1245
+ // - `response`: a response object (if any) passed by the function that is
1246
+ // calling `getTextFromURL` so that it may be completed and then passed
1247
+ // to the browser by the `on_ok` or `on_error` functions
1248
+
1249
+ function getTextFromURL(url, on_ok, on_error, response=null) {
1250
+ // Gets a text string (plain, HTML, or other) from the specified URL,
1251
+ // and then calls `on_ok`, or `on_error` if the request failed
1252
+ https.get(url, (res) => {
1253
+ // Any 2xx status code signals a successful response, but be strict
1254
+ if (res.statusCode !== 200) {
1255
+ // Consume response data to free up memory
1256
+ res.resume();
1257
+ return on_error(new Error('Get text request failed -- Status code: ' +
1258
+ res.statusCode), response);
1259
+ }
1260
+ // Fetch the complete data string
1261
+ res.setEncoding('utf8');
1262
+ let data = '';
1263
+ res.on('data', (chunk) => { data += chunk; });
1264
+ res.on('end', () => on_ok(data, response));
1265
+ }).on('error', (e) => on_error(e, response));
1266
+ }
1267
+
1268
+ function postRequest(url, obj, on_ok, on_error, response=null) {
1269
+ // Submits `obj` as "POST form" to the remote server specified by `url`
1270
+ // NOTE: A trailing slash is crucial here, as otherwise the server will
1271
+ // redirect it as a GET !!!
1272
+ if(!url.endsWith('/')) url += '/';
1273
+ const
1274
+ post_data = formData(obj),
1275
+ options = {
1276
+ method: 'POST',
1277
+ headers: {
1278
+ 'Content-Type': 'application/x-www-form-urlencoded',
1279
+ 'Content-Length': Buffer.byteLength(post_data)
1280
+ }
1281
+ },
1282
+ req = https.request(url, options, (res) => {
1283
+ if (res.statusCode !== 200) {
1284
+ // Consume response data to free up memory
1285
+ res.resume();
1286
+ return on_error(new Error(`POST request (${url}) failed -- ` +
1287
+ `Status code: ${res.statusCode}`), response);
1288
+ }
1289
+ // Fetch the complete data buffer
1290
+ const chunks = [];
1291
+ res.on('data', (chunk) => chunks.push(chunk));
1292
+ res.on('end', () => on_ok(Buffer.concat(chunks), response));
1293
+ });
1294
+ req.on('error', (e) => on_error(e, response));
1295
+ // Add the object as form data to the request body
1296
+ req.write(post_data);
1297
+ req.end();
1298
+ }
1299
+
1300
+ function formData(obj) {
1301
+ // Encodes `obj` as a form that can be POSTed
1302
+ const fields = [];
1303
+ for(let k in obj) if(obj.hasOwnProperty(k)) {
1304
+ fields.push(encodeURIComponent(k) + "=" + encodeURIComponent(obj[k]));
1305
+ }
1306
+ return fields.join('&');
1307
+ }
1308
+
1309
+ //
1310
+ // Functions used during initialization
1311
+ //
1312
+
1313
+ function commandLineSettings() {
1314
+ // Sets default settings, and then checks the command line arguments
1315
+ const settings = {
1316
+ cli_name: (PLATFORM.startsWith('win') ? 'Command Prompt' : 'Terminal'),
1317
+ inkscape: '',
1318
+ dpi: 300,
1319
+ launch: false,
1320
+ port: 5050,
1321
+ preferred_solver: '',
1322
+ solver: '',
1323
+ solver_path: '',
1324
+ user_dir: path.join(MAIN_DIRECTORY, 'user')
1325
+ };
1326
+ for(let i = 2; i < process.argv.length; i++) {
1327
+ const lca = process.argv[i].toLowerCase();
1328
+ if(lca === 'launch') {
1329
+ settings.launch = true;
1330
+ } else {
1331
+ const av = lca.split('=');
1332
+ if(av.length === 1) av.push('');
1333
+ if(av[0] === 'port') {
1334
+ // Accept any number greater than or equal to 1024
1335
+ const n = parseInt(av[1]);
1336
+ if(isNaN(n) || n < 1024) {
1337
+ console.log(`WARNING: Invalid port number ${av[1]}`);
1338
+ } else {
1339
+ settings.port = n;
1340
+ }
1341
+ } else if(av[0] === 'solver') {
1342
+ if(av[1] !== 'gurobi' && av[1] !== 'lp_solve') {
1343
+ console.log(`WARNING: Unknown solver "${av[1]}"`);
1344
+ } else {
1345
+ settings.preferred_solver = av[1];
1346
+ }
1347
+ } else if(av[0] === 'dpi') {
1348
+ // Accept any number greater than or equal to 1024
1349
+ const n = parseInt(av[1]);
1350
+ if(isNaN(n) || n > 1200) {
1351
+ console.log(`WARNING: Invalid resolution ${av[1]} (max. 1200 dpi)`);
1352
+ } else {
1353
+ settings.dpi = n;
1354
+ }
1355
+ } else if(av[0] === 'workspace') {
1356
+ // User directory must be READ/WRITE-accessible
1357
+ try {
1358
+ fs.accessSync(av[1], fs.constants.R_OK | fs.constants.W_O);
1359
+ } catch(err) {
1360
+ console.log(`ERROR: No access to directory "${av[1]}"`);
1361
+ process.exit();
1362
+ }
1363
+ settings.user_dir = av[1];
1364
+ } else {
1365
+ // Terminate script
1366
+ console.log(
1367
+ `ERROR: Invalid command line argument "${process.argv[i]}"`);
1368
+ process.exit();
1369
+ }
1370
+ }
1371
+ }
1372
+ // Check whether MILP solver(s) and Inkscape have been installed
1373
+ const path_list = process.env.PATH.split(path.delimiter);
1374
+ let gurobi_path = '',
1375
+ match,
1376
+ max_v = -1;
1377
+ for(let i = 0; i < path_list.length; i++) {
1378
+ match = path_list[i].match(/gurobi(\d+)/i);
1379
+ if(match && parseInt(match[1]) > max_v) {
1380
+ gurobi_path = path_list[i];
1381
+ max_v = parseInt(match[1]);
1382
+ }
1383
+ match = path_list[i].match(/inkscape/i);
1384
+ if(match) settings.inkscape = path_list[i];
1385
+ }
1386
+ if(!gurobi_path && !PLATFORM.startsWith('win')) {
1387
+ console.log('Looking for Gurobi in /usr/local/bin');
1388
+ try {
1389
+ // On macOS and Unix, Gurobi is in the user's local binaries
1390
+ const gp = '/usr/local/bin';
1391
+ fs.accessSync(gp + '/gurobi_cl');
1392
+ gurobi_path = gp;
1393
+ } catch(err) {
1394
+ // No real error, so no action needed
1395
+ }
1396
+ }
1397
+ if(gurobi_path) {
1398
+ console.log('Path to Gurobi:', gurobi_path);
1399
+ // Check if command line version is executable
1400
+ const sp = path.join(gurobi_path,
1401
+ 'gurobi_cl' + (PLATFORM.startsWith('win') ? '.exe' : ''));
1402
+ try {
1403
+ fs.accessSync(sp, fs.constants.X_OK);
1404
+ if(settings.solver !== 'gurobi')
1405
+ settings.solver = 'gurobi';
1406
+ settings.solver_path = sp;
1407
+ } catch(err) {
1408
+ console.log(err.message);
1409
+ console.log(
1410
+ 'WARNING: Failed to access the Gurobi command line application');
1411
+ }
1412
+ }
1413
+ // Check if lp_solve(.exe) exists in main directory
1414
+ const
1415
+ sp = path.join(MAIN_DIRECTORY,
1416
+ 'lp_solve' + (PLATFORM.startsWith('win') ? '.exe' : '')),
1417
+ need_lps = !settings.solver || settings.preferred_solver === 'lp_solve';
1418
+ try {
1419
+ fs.accessSync(sp, fs.constants.X_OK);
1420
+ console.log('Path to LP_solve:', sp);
1421
+ if(need_lps) {
1422
+ settings.solver = 'lp_solve';
1423
+ settings.solver_path = sp;
1424
+ }
1425
+ } catch(err) {
1426
+ // Only report error if LP_solve is needed
1427
+ if(need_lps) {
1428
+ console.log(err.message);
1429
+ console.log('WARNING: LP_solve application not found in', sp);
1430
+ }
1431
+ }
1432
+ // On macOS, Inkscape is not added to the PATH environment variable
1433
+ if(!settings.inkscape && PLATFORM === 'darwin') {
1434
+ console.log('Looking for Inkscape in Applications...');
1435
+ try {
1436
+ // Look in the default directory
1437
+ const ip = '/Applications/Inkscape.app/Contents/MacOS';
1438
+ fs.accessSync(ip);
1439
+ settings.inkscape = ip;
1440
+ } catch(err) {
1441
+ // No real error, so no action needed
1442
+ }
1443
+ }
1444
+ // Verify that Inkscape is installed
1445
+ if(settings.inkscape) {
1446
+ // NOTE: on Windows, the command line version is a .com file
1447
+ const ip = path.join(settings.inkscape,
1448
+ 'inkscape' + (PLATFORM.startsWith('win') ? '.com' : ''));
1449
+ try {
1450
+ fs.accessSync(ip, fs.constants.X_OK);
1451
+ console.log('Path to Inkscape:', settings.inkscape);
1452
+ settings.inkscape = ip;
1453
+ console.log(
1454
+ `SVG will be rendered with ${settings.dpi} dpi resolution`);
1455
+ } catch(err) {
1456
+ settings.inkscape = '';
1457
+ console.log(err.message);
1458
+ console.log(
1459
+ 'WARNING: Failed to access the Inkscape command line application');
1460
+ }
1461
+ } else {
1462
+ console.log(
1463
+ 'Inkscape not installed, so images will not be rendered as PNG');
1464
+ }
1465
+ return settings;
1466
+ }
1467
+
1468
+ function createWorkspace() {
1469
+ // Verifies that Linny-R has write access to the user workspace, defines
1470
+ // paths to sub-directories, and creates them if necessary
1471
+ try {
1472
+ // See whether the user directory already exists
1473
+ try {
1474
+ fs.accessSync(SETTINGS.user_dir, fs.constants.R_OK | fs.constants.W_O);
1475
+ } catch(err) {
1476
+ // If not, try to create it
1477
+ fs.mkdirSync(SETTINGS.user_dir);
1478
+ console.log('Created user directory:', SETTINGS.user_dir);
1479
+ }
1480
+ } catch(err) {
1481
+ console.log(err.message);
1482
+ console.log('FATAL ERROR: Failed to create user workspace in',
1483
+ SETTINGS.user_dir);
1484
+ process.exit();
1485
+ }
1486
+ // Define the sub-directory paths
1487
+ const ws = {
1488
+ channel: path.join(SETTINGS.user_dir, 'channel'),
1489
+ callback: path.join(SETTINGS.user_dir, 'callback'),
1490
+ diagrams: path.join(SETTINGS.user_dir, 'diagrams'),
1491
+ modules: path.join(SETTINGS.user_dir, 'modules'),
1492
+ solver_output: path.join(SETTINGS.user_dir, 'solver'),
1493
+ };
1494
+ // Create these sub-directories if not aready there
1495
+ try {
1496
+ for(let p in ws) if(ws.hasOwnProperty(p)) {
1497
+ try {
1498
+ fs.accessSync(ws[p]);
1499
+ } catch(e) {
1500
+ fs.mkdirSync(ws[p]);
1501
+ console.log('Created workspace sub-directory:', ws[p]);
1502
+ }
1503
+ }
1504
+ } catch(err) {
1505
+ console.log(err.message);
1506
+ console.log('WARNING: No access to workspace directory');
1507
+ }
1508
+ // The file containing name, URL and access token for remote repositories
1509
+ ws.repositories = path.join(SETTINGS.user_dir, 'repositories.cfg');
1510
+ // Return the updated workspace object
1511
+ return ws;
1512
+ }
1513
+
1514
+ function createLaunchScript() {
1515
+ // Creates platform-specific script with Linny-R start-up command
1516
+ const lines = [
1517
+ '# The first line (without the comment symbol #) should be like this:',
1518
+ '# cd ',
1519
+ '',
1520
+ '# Then this command to launch the Linny-R server should work:',
1521
+ 'node server launch'
1522
+ ];
1523
+ let sp;
1524
+ if(PLATFORM.startsWith('win')) {
1525
+ sp = path.join(MAIN_DIRECTORY, 'linny-r.bat');
1526
+ lines[1] += 'C:\\path\\to\\main\\Linny-R\\directory';
1527
+ } else {
1528
+ sp = path.join(MAIN_DIRECTORY, 'linny-r.command');
1529
+ lines[1] += '/path/to/main/Linny-R/directory';
1530
+ }
1531
+ lines[2] = 'cd ' + MAIN_DIRECTORY;
1532
+ try {
1533
+ try {
1534
+ fs.accessSync(sp);
1535
+ } catch(err) {
1536
+ // Only write the script content if the file it does not yet exist
1537
+ console.log('Creating launch script:', sp);
1538
+ fs.writeFileSync(sp, lines.join(os.EOL), 'utf8');
1539
+ }
1540
+ } catch(err) {
1541
+ console.log('WARNING: Failed to create launch script');
1542
+ }
1543
+ }
1544
+
1545
+ /////////////////////////////////////////////////////////////////////////////
1546
+ // Code ends here //
1547
+ /////////////////////////////////////////////////////////////////////////////