-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathwiki_encyclopedia.js
More file actions
2551 lines (2350 loc) · 109 KB
/
Copy pathwiki_encyclopedia.js
File metadata and controls
2551 lines (2350 loc) · 109 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// =============================================================================
// CONVOLUTION WIKIPEDIA ENCYCLOPEDIA v1.0
// A Synchronet BBS JavaScript Door
// Compton's MultiMedia Encyclopedia-style interface for Wikipedia
//
// Installation:
// 1. Copy this file to your Synchronet exec/ directory
// 2. In SCFG > External Programs > Online Programs (Doors), add a new door:
// Name : Wiki Encyclopedia
// Command: ?wiki_encyclopedia.js
// Access : (whatever level you want)
// 3. That's it! No external dependencies needed.
//
// Controls (in-door):
// S or / - Search for an article
// N - Next page of article text
// P - Previous page of article text
// R - Related topics / See Also
// H - Help screen
// Q - Quit back to BBS
// =============================================================================
"use strict";
// Load Synchronet constant definitions (K_UPPER, K_NOECHO, K_LINE, etc.)
load("sbbsdefs.js");
// ---------------------------------------------------------------------------
// ANSI color helpers using Synchronet Ctrl-A codes
// Ctrl-A + letter = color attribute
// ---------------------------------------------------------------------------
var A = "\x01"; // Ctrl-A prefix for Synchronet color codes
// Foreground colors
var FG_BLACK = A+"0";
var FG_RED = A+"1"; // (dark red in some terminals)
var FG_GREEN = A+"2";
var FG_YELLOW = A+"3";
var FG_BLUE = A+"4";
var FG_MAGENTA = A+"5";
var FG_CYAN = A+"6";
var FG_WHITE = A+"7";
// Bright foreground
var FG_BBLACK = A+"K";
var FG_BRED = A+"R";
var FG_BGREEN = A+"G";
var FG_BYELLOW = A+"Y";
var FG_BBLUE = A+"B";
var FG_BMAGENTA = A+"M";
var FG_BCYAN = A+"C";
var FG_BWHITE = A+"W";
// COL_ aliases used in display code
var COL_BWHITE = A+"W"; // bright white (same as FG_BWHITE)
// Background colors
var BG_BLACK = A+"0"+A+"-"; // workaround: set fg then flip
// We'll just use raw ANSI sequences for backgrounds since they're more reliable
var RESET = A+"N"; // Normal/reset
// Raw ANSI escape sequences (more reliable for complex bg colors)
var ESC = "\x1b[";
function ansi(code) { return ESC + code + "m"; }
function cls() { return ESC + "2J" + ESC + "1;1H"; }
function gotoxy(x, y) { return ESC + y + ";" + x + "H"; }
function savecursor() { return ESC + "s"; }
function restorecursor() { return ESC + "u"; }
// Color combos used throughout
var COL_TITLE_BG = ansi("1;37;44"); // bright white on blue (title bar)
var COL_HEADER_BG = ansi("0;30;46"); // black on cyan (section headers)
var COL_BODY_BG = ansi("0;37;40"); // gray on black (body text)
var COL_BODY_HI = ansi("1;37;40"); // bright white on black (emphasis)
var COL_STATUS_BG = ansi("0;30;47"); // black on white (status bar)
var COL_HILITE = ansi("1;33;40"); // bright yellow on black (highlights)
var COL_LINK = ansi("1;36;40"); // bright cyan on black (cross-refs)
var COL_BORDER = ansi("0;36;40"); // cyan on black (borders)
var COL_KEY = ansi("1;32;40"); // bright green (key hints)
var COL_INPUT = ansi("1;37;44"); // bright white on blue (text input field)
var COL_RESET = ansi("0");
// ---------------------------------------------------------------------------
// Terminal size detection — reads Synchronet's tracked size (native properties)
// only. No ESC[6n probe: reading its reply would desync the welcome getstr.
// ---------------------------------------------------------------------------
function detectTerminalSize() {
// Synchronet already tracks the negotiated terminal size, so we just READ it
// (console.screen_columns / screen_rows). That is a pure property read with no
// terminal I/O, so -- unlike an ESC[6n cursor-position probe -- it can never
// drain buffered input and desync the welcome search's getstr (the "only one
// letter" bug). Retry briefly in case the properties aren't populated the very
// instant the door starts.
var cols = 0, rows = 0, i;
for (i = 0; i < 6; i++) {
try { if (console.screen_columns > 0) cols = console.screen_columns; } catch (e) {}
try { if (console.screen_rows > 0) rows = console.screen_rows; } catch (e) {}
if (!cols) { try { cols = console.columns || 0; } catch (e) {} }
if (!rows) { try { rows = console.rows || 0; } catch (e) {} }
if (cols > 0 && rows > 0) return { cols: cols, rows: rows };
mswait(50);
}
// Last resort: a sane default. We deliberately do NOT probe with ESC[6n here --
// reading its reply would desync the first getstr, which is the whole bug.
return { cols: cols || 80, rows: rows || 24 };
}
// ---------------------------------------------------------------------------
// Detect whether the connected terminal can display sixel graphics. Prefers
// Synchronet's own auto-detected capability flag; if that isn't available,
// asks the terminal with a Primary Device Attributes request (ESC[c) and looks
// for the sixel parameter ("4") in the reply. Only falls back to "yes" when
// nothing answers either way, so existing sixel terminals never lose images.
// ---------------------------------------------------------------------------
function detectSixel() {
// This Synchronet build doesn't expose a SIXEL capability flag (confirmed via
// logging: SIXELdef=false), and a manual ESC[c probe is consumed by Synchronet
// before we can read it. So key off the terminal type Synchronet recorded at
// login: SyncTERM (the sixel client used here) reports "syncterm"; SSH clients
// like macOS Terminal report "xterm-256color" and have no sixel. Unknown
// terminals default to NO graphics, so they get text instead of raw-sixel junk.
var result = false, how = "term-name";
var term = "";
try { term = ("" + console.terminal).toLowerCase(); } catch (e) {}
// Known sixel-capable terminal identifiers (add more here as needed).
if (term.indexOf("syncterm") >= 0) result = true;
// Future-proofing: if a build ever does expose a real SIXEL flag, trust a yes.
try {
if (!result && typeof console.term_supports === "function" && typeof SIXEL !== "undefined") {
if (console.term_supports(SIXEL)) { result = true; how = "term_supports(SIXEL)"; }
}
} catch (e2) {}
try { log(LOG_INFO, "wiki_encyclopedia: sixel -> " + result + " (" + how + ", term=\"" + term + "\")"); } catch (e3) {}
return result;
}
// Ask the terminal for its character-cell size in pixels (ESC[16t -> reply
// ESC[6;<height>;<width>t). This lets image sizing adapt to the font (e.g. an
// 8x8 square font vs 8x16). Uses a NON-BLOCKING inkey read with a short total
// timeout, so if the terminal/Synchronet doesn't answer it simply gives up
// (no input is consumed by blocking). Returns {w,h} or null. Run lazily, after
// the welcome search's getstr, so it can never disturb that input.
function detectCellSize() {
var resp = "", waited = 0, ch, w = 0, h = 0;
try {
console.putmsg("\x1b[16t");
while (waited < 400) {
ch = console.inkey(K_NOECHO, 50);
if (!ch) { waited += 50; continue; }
resp += ch;
if (ch === "t") break;
if (resp.length > 40) break;
}
var m = /\[6;(\d+);(\d+)t/.exec(resp);
if (m) { h = parseInt(m[1], 10); w = parseInt(m[2], 10); }
} catch (e) {}
try { log(LOG_INFO, "wiki_encyclopedia: cellsize -> w=" + w + " h=" + h
+ " (reply=\"" + resp.replace(/\x1b/g, "<ESC>") + "\")"); } catch (e2) {}
if (w > 0 && h > 0 && w < 64 && h < 64) return { w: w, h: h };
return null;
}
// Screen dimensions — set dynamically at startup
var TERM = { rows: 24, cols: 80, has256: true, hasSixel: true };
var _pendingImageUrl = ""; // set by loadArticle before calling formatArticle
var _articleImgCols = 0; // image columns (set before redrawArticle)
var _articleImgRows = 0; // image rows (set before redrawArticle)
// Application name shown in the standard header as "Convolution BBS - <APP_NAME>".
// Change this single line when reusing this header in another Convolution BBS door.
var APP_NAME = "Wikipedia";
var WEATHER_STR = ""; // header weather ("<desc> <temp>F"), fetched once per session from the user's zip
var SIXEL_CHECKED = false; // detectSixel() runs once, lazily, on first article load (never before the first getstr)
var CELL_PX_W = 8; // character-cell pixel size; detected lazily, defaults to 8x8 (the common SyncTERM text cell)
var CELL_PX_H = 8;
var CELL_CHECKED = false;
var COLS = 80;
var ROWS = 24;
var CONTENT_TOP = 4;
var CONTENT_ROWS = 17;
var CONTENT_BOT = 21; // recalculated by initTerminal
// Character cell pixel dimensions (detected at startup)
var CELL_W = 9; // measured: ~9px/col (135px = 15 cols)
var CELL_H = 11; // 12px/row: 220px=18.3rows, fits in 20 reserved with 2 gap
// Initialise terminal dimensions. SyncTERM always supports 256-color and
// sixel, so both are enabled by default.
// CELL_W and CELL_H are measured empirically for SyncTERM 132x59:
// 198px wide sixel → 22 cols, so CELL_W = 9px/col
// 60px tall sixel → ~13-14 rows, so CELL_H = 4px/row (sixel pixel density)
function initTerminal() {
var size = detectTerminalSize();
COLS = (size.cols >= 80) ? size.cols : 80;
ROWS = (size.rows >= 24) ? size.rows : 24;
CONTENT_TOP = 4;
CONTENT_ROWS = ROWS - CONTENT_TOP - 3;
CONTENT_BOT = ROWS - 3;
// hasSixel is determined LATER (lazily, on the first article load) so that
// no terminal-query I/O ever precedes the welcome search prompt's getstr.
TERM = { rows: ROWS, cols: COLS, has256: true, hasSixel: true };
// Infer the character-cell PIXEL size from the grid we just detected. We can't
// query it (this terminal never answers ESC[16t), but the grid tells us the
// font by SyncTERM convention: the classic 80x25 default uses an 8x16 VGA
// cell, while every larger mode (80x50, 132x60, 160x90, ...) uses an 8x8 cell.
// Width is 8 in all of them. This drives image sizing in wiki_render.py so a
// photo lands at ~25% of screen height with no black gap, at either size.
// - too-SHORT screens (<=30 rows) => 8x16 (the base case)
// - everything bigger => 8x8
CELL_PX_W = 8;
CELL_PX_H = (ROWS <= 30) ? 16 : 8;
try { log(LOG_INFO, "wiki_encyclopedia: cell inferred " + CELL_PX_W + "x" + CELL_PX_H
+ " from " + COLS + "x" + ROWS); } catch (eC) {}
}
// ---------------------------------------------------------------------------
// Draw a horizontal line
// ---------------------------------------------------------------------------
function hline(char, width, color) {
var s = "";
if (color) s += color;
for (var i = 0; i < width; i++) s += char;
s += COL_RESET;
return s;
}
// ---------------------------------------------------------------------------
// Center text in a field of given width
// ---------------------------------------------------------------------------
function center(str, width) {
// strip ANSI for length calculation
var plain = str.replace(/\x1b\[[0-9;]*m/g, "");
var pad = Math.floor((width - plain.length) / 2);
if (pad < 0) pad = 0;
var result = "";
for (var i = 0; i < pad; i++) result += " ";
result += str;
var remain = width - pad - plain.length;
for (var i = 0; i < remain; i++) result += " ";
return result;
}
// ---------------------------------------------------------------------------
// Pad/truncate a string to exactly width chars (no ANSI inside plain parts)
// ---------------------------------------------------------------------------
function padRight(str, width) {
if (str.length > width) return str.substring(0, width);
while (str.length < width) str += " ";
return str;
}
// ---------------------------------------------------------------------------
// Draw the main title / masthead bar (rows 1-3)
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Header clock: current date/time in the user's local zone.
//
// Synchronet stores timezones in SMB format (low 12 bits = minutes, 0x8000 =
// west of UTC). We start from the server's own local time -- which is correct
// on a self-hosted BBS and already accounts for daylight saving via the OS --
// then shift by the (DST-invariant) difference between the calling user's zone
// and the system zone so that remote callers see their own local time.
// ---------------------------------------------------------------------------
function zoneBaseMin(zone) {
if (zone === undefined || zone === null) return null;
var z = (typeof zone === "string") ? parseInt(zone, 10) : zone;
if (typeof z !== "number" || isNaN(z)) return null;
var mag = z & 0x0FFF;
return (z & 0x8000) ? -mag : mag;
}
function clockString() {
var serverOff = -(new Date().getTimezoneOffset()); // server's actual UTC offset (minutes, DST-aware)
var off = serverOff;
try {
if (typeof user !== "undefined" && user && typeof system !== "undefined") {
var uz = zoneBaseMin(user.zone);
var sz = zoneBaseMin(system.timezone);
if (uz !== null && sz !== null) off = serverOff + (uz - sz); // adjust to the caller's zone
}
} catch (e) {}
var d = new Date(new Date().getTime() + off * 60000); // shift instant, then read via UTC getters
var mon = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
var H = d.getUTCHours(), Mi = d.getUTCMinutes();
var ap = (H < 12) ? "AM" : "PM";
var h12 = H % 12; if (h12 === 0) h12 = 12;
var dd = (d.getUTCDate() < 10 ? "0" : "") + d.getUTCDate();
var mm = (Mi < 10 ? "0" : "") + Mi;
return dd + " " + mon[d.getUTCMonth()] + " " + d.getUTCFullYear() + " " + h12 + ":" + mm + " " + ap;
}
function drawHeaderClock() {
if (COLS < 56) return; // too narrow to fit the clock beside the name
var info = (WEATHER_STR ? WEATHER_STR + " " : "") + clockString();
var s = " " + info + " "; // weather (if any) sits just left of the date/time
var col = COLS - s.length; // right-aligned on row 1
if (col < 30) col = 30; // keep clear of "Convolution BBS - <app>"
console.putmsg(gotoxy(col, 1) + COL_TITLE_BG + s + COL_RESET + gotoxy(1, 1));
}
// ---------------------------------------------------------------------------
// Standard two-line header, shared by every screen and reusable across all
// Convolution BBS doors:
// Row 1 : "Convolution BBS" (left) + date / time (right)
// Row 2 : an application-specific title (here, the article title)
// Row 3 : a separator rule above the content area
// Content/body for every screen begins on row 4 (CONTENT_TOP).
// ---------------------------------------------------------------------------
function drawStdHeader(appTitle) {
var suite = " Convolution BBS" + (APP_NAME ? " - " + APP_NAME : "");
console.putmsg(gotoxy(1, 1) + COL_TITLE_BG + padRight(suite, COLS) + COL_RESET);
drawHeaderClock();
var t = translit(appTitle || "");
if (t.length > COLS - 1) t = t.substring(0, COLS - 1);
console.putmsg(gotoxy(1, 2) + COL_HEADER_BG + " " + padRight(t, COLS - 1) + COL_RESET);
console.putmsg(gotoxy(1, 3) + COL_BORDER + repeat("\xC4", COLS) + COL_RESET);
console.putmsg(gotoxy(1, 1));
}
// ---------------------------------------------------------------------------
// Draw the bottom status/nav bar
// ---------------------------------------------------------------------------
function drawStatusBar(pageNum, totalPages, msg) {
var innerW = COLS - 2;
var rowSep = ROWS - 2; // separator line
var rowKeys = ROWS - 1; // key hints
var rowMsg = ROWS; // message bar
// Separator rule
console.putmsg(gotoxy(1, rowSep) + COL_BORDER + repeat("\xC4", COLS) + COL_RESET);
// Key hints + page counter
var pageStr = (pageNum > 0) ? "Page " + pageNum + " of " + totalPages : "";
var keys = "\x18\x19 Scroll TAB=Next link ENTER=Follow [S]earch [Q]uit";
var gap = repeat(" ", Math.max(1, innerW - pageStr.length - keys.length));
console.putmsg(
gotoxy(1, rowKeys) +
COL_STATUS_BG + " " + pageStr + gap + keys + " " + COL_RESET
);
// Message bar (write cols 1..COLS-1 only; filling the bottom-right cell
// makes the terminal auto-scroll, which looks like extra lines appearing).
var msgText = msg || "Ready. Press [S] to search or [Q] to quit.";
console.putmsg(gotoxy(1, rowMsg) + COL_BODY_BG + padRight(" " + msgText, COLS - 1) + COL_RESET);
}
// ---------------------------------------------------------------------------
// Utility: repeat a character n times
// ---------------------------------------------------------------------------
function repeat(ch, n) {
var s = "";
for (var i = 0; i < n; i++) s += ch;
return s;
}
// ---------------------------------------------------------------------------
// Word-wrap a string to maxWidth, returning array of lines
// ---------------------------------------------------------------------------
function wordWrap(text, maxWidth) {
var words = text.split(" ");
var lines = [];
var line = "";
for (var i = 0; i < words.length; i++) {
var w = words[i];
if (line.length === 0) {
line = w;
} else if (line.length + 1 + w.length <= maxWidth) {
line += " " + w;
} else {
lines.push(line);
line = w;
}
}
if (line.length > 0) lines.push(line);
return lines;
}
// ---------------------------------------------------------------------------
// Strip HTML tags from Wikipedia text (basic)
// ---------------------------------------------------------------------------
function stripHtml(html) {
// Replace common entities
html = html.replace(/&/g, "&");
html = html.replace(/</g, "<");
html = html.replace(/>/g, ">");
html = html.replace(/"/g, '"');
html = html.replace(/'/g, "'");
html = html.replace(/ /g, " ");
// Strip tags
html = html.replace(/<[^>]+>/g, "");
// Collapse whitespace
html = html.replace(/\s+/g, " ").trim();
return html;
}
// ---------------------------------------------------------------------------
// Clean extract text — preserve section headers (==Header==) and newlines.
// Also cleans LaTeX math markup (\displaystyle etc.) that Wikipedia embeds.
// ---------------------------------------------------------------------------
// Convert common LaTeX/math sequences to readable ASCII equivalents
function cleanLatex(expr) {
// Strip outer \displaystyle, \textstyle, \scriptstyle wrappers
expr = expr.replace(/\\displaystyle\s*/g, "");
expr = expr.replace(/\\textstyle\s*/g, "");
expr = expr.replace(/\\scriptstyle\s*/g, "");
expr = expr.replace(/\\mathbf\s*/g, "");
expr = expr.replace(/\\mathrm\s*/g, "");
expr = expr.replace(/\\mathit\s*/g, "");
expr = expr.replace(/\\text\s*/g, "");
expr = expr.replace(/\\mbox\s*/g, "");
// Common math symbols → ASCII/Unicode approximations
expr = expr.replace(/\\times/g, "x");
expr = expr.replace(/\\cdot/g, ".");
expr = expr.replace(/\\div/g, "/");
expr = expr.replace(/\\pm/g, "+/-");
expr = expr.replace(/\\mp/g, "-/+");
expr = expr.replace(/\\leq/g, "<=");
expr = expr.replace(/\\geq/g, ">=");
expr = expr.replace(/\\neq/g, "!=");
expr = expr.replace(/\\approx/g, "~=");
expr = expr.replace(/\\infty/g, "inf");
expr = expr.replace(/\\pi/g, "pi");
expr = expr.replace(/\\alpha/g, "alpha");
expr = expr.replace(/\\beta/g, "beta");
expr = expr.replace(/\\gamma/g, "gamma");
expr = expr.replace(/\\delta/g, "delta");
expr = expr.replace(/\\lambda/g, "lambda");
expr = expr.replace(/\\mu/g, "mu");
expr = expr.replace(/\\sigma/g, "sigma");
expr = expr.replace(/\\omega/g, "omega");
expr = expr.replace(/\\theta/g, "theta");
expr = expr.replace(/\\phi/g, "phi");
expr = expr.replace(/\\rho/g, "rho");
expr = expr.replace(/\\eta/g, "eta");
expr = expr.replace(/\\epsilon/g, "epsilon");
expr = expr.replace(/\\sum/g, "SUM");
expr = expr.replace(/\\prod/g, "PROD");
expr = expr.replace(/\\int/g, "INT");
expr = expr.replace(/\\sqrt/g, "sqrt");
expr = expr.replace(/\\frac/g, "/");
expr = expr.replace(/\\left/g, "");
expr = expr.replace(/\\right/g, "");
expr = expr.replace(/\\{/g, "{");
expr = expr.replace(/\\}/g, "}");
// Superscripts: ^{...} or ^x → ^...
expr = expr.replace(/\^\{([^}]+)\}/g, "^$1");
// Subscripts: _{...} or _x → _...
expr = expr.replace(/_\{([^}]+)\}/g, "_$1");
// Strip remaining bare backslash-commands (\foo) we didn't handle
expr = expr.replace(/\\[a-zA-Z]+/g, "");
// Strip curly braces used as LaTeX grouping
expr = expr.replace(/[{}]/g, "");
// Collapse multiple spaces
expr = expr.replace(/\s+/g, " ").trim();
return expr;
}
// Strip or convert LaTeX math blocks from Wikipedia extract text.
// Patterns seen: {\displaystyle X}, {\textstyle X}, bare \command
function cleanMath(text) {
// Match outermost { ... } blocks containing a backslash command
// Use a simple loop since JS regex can't do recursive matching
var result = "";
var i = 0;
while (i < text.length) {
if (text.charAt(i) === "{" && text.charAt(i+1) === "\\") {
// Find matching closing brace, tracking nesting
var depth = 1;
var j = i + 1;
while (j < text.length && depth > 0) {
if (text.charAt(j) === "{") depth++;
if (text.charAt(j) === "}") depth--;
j++;
}
// Extract and convert the LaTeX block
var block = text.substring(i+1, j-1); // contents without outer braces
var cleaned = cleanLatex(block);
if (cleaned) result += cleaned;
i = j;
} else {
result += text.charAt(i);
i++;
}
}
return result;
}
function cleanExtract(text) {
if (!text) return "";
// HTML entities
text = text.replace(/&/g, "&");
text = text.replace(/</g, "<");
text = text.replace(/>/g, ">");
text = text.replace(/"/g, '"');
text = text.replace(/'/g, "'");
text = text.replace(/ /g, " ");
text = text.replace(/<[^>]+>/g, "");
// Normalise line endings to \n
text = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
// -----------------------------------------------------------------------
// Wikipedia extract contains MathML blocks. Each block is a run of lines
// containing a single char or symbol, followed by a line like:
// {\\displaystyle a_{1}x_{1}+\\cdots +a_{n}x_{n}=b,}
// We scan line by line. When we hit a displaystyle line we convert it
// to readable text and discard the preceding MathML lines.
// Normal prose lines are kept and joined into paragraphs.
// -----------------------------------------------------------------------
var lines = text.split("\n");
var outLines = [];
var mathBuf = false; // true = currently inside a MathML block
for (var i = 0; i < lines.length; i++) {
var raw = lines[i];
var trimmed = raw.trim();
// Empty / whitespace-only line
if (!trimmed) {
if (!mathBuf) outLines.push("");
continue;
}
// Section header ==Foo== — always keep
if (/^=+[^=]+=+$/.test(trimmed)) {
mathBuf = false;
outLines.push(trimmed);
continue;
}
// Displaystyle/textstyle line — the readable equation
// In the JS runtime string, backslash is a single \
// The line looks like: {\\displaystyle ...} (in source)
// At runtime it is: {\displaystyle ...}
// trimmed[0]=={ trimmed contains \displaystyle or \textstyle
if (trimmed.charAt(0) === "{" &&
(trimmed.indexOf("\\displaystyle") >= 0 ||
trimmed.indexOf("\\textstyle") >= 0)) {
// Convert to readable ASCII
var eq = cleanLatex(trimmed);
if (eq) {
outLines.push(eq);
outLines.push("");
}
mathBuf = false;
continue;
}
// Indented MathML line — short, indented, single char or symbol
var isIndented = (raw.length > 0 && raw.charAt(0) === " ");
var isMLChar = (trimmed.length <= 3) ||
/^[+\-=.,;:()\[\]\/\\^_|*]$/.test(trimmed) ||
/^[a-zA-Z]$/.test(trimmed) ||
/^\d+$/.test(trimmed);
if (isIndented && isMLChar) {
mathBuf = true;
continue; // drop this MathML fragment
}
// Normal prose line
mathBuf = false;
outLines.push(trimmed);
}
// Join consecutive non-empty, non-header lines into paragraphs
var result = [];
var buf = "";
var lastBlank = false;
function flush() {
var s = buf.replace(/\s+/g, " ").trim();
if (s) { result.push(s); buf = ""; }
}
for (var j = 0; j < outLines.length; j++) {
var line = outLines[j];
if (!line) {
flush();
if (!lastBlank) result.push("");
lastBlank = true;
} else if (/^=+[^=]+=+$/.test(line)) {
flush();
result.push(line);
lastBlank = false;
} else {
buf = buf ? buf + " " + line : line;
lastBlank = false;
}
}
flush();
return result.join("\n").trim();
}
// ---------------------------------------------------------------------------
// URL-encode a string (safe for all Synchronet SpiderMonkey builds)
// ---------------------------------------------------------------------------
function urlEncode(str) {
str = String(str);
var safe = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~*'()";
var out = "";
for (var i = 0; i < str.length; i++) {
var ch = str.charAt(i);
if (safe.indexOf(ch) >= 0) {
out += ch;
} else if (ch === " ") {
out += "+";
} else {
var code = str.charCodeAt(i);
out += "%" + (code < 16 ? "0" : "") + code.toString(16).toUpperCase();
}
}
return out;
}
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// HTTP GET using curl via system.exec() — works reliably in door context
// on Windows (curl is built into Windows 10+ and is in Synchronet's util/).
// Output is written to a temp file, read back, then deleted.
// ---------------------------------------------------------------------------
function httpGet(url) {
// Build a unique temp file path in Synchronet's temp directory
var tmpFile = system.temp_dir + "wiki_" + time() + "_" + random(99999) + ".tmp";
// curl flags:
// -s silent (no progress meter)
// -S show errors even in silent mode
// -L follow redirects
// --max-time 15 give up after 15 seconds
// -A user agent string
// -o output file
var cmd = "curl -s -S -L --max-time 15"
+ " -A \"SynchronetBBSWikiDoor/1.0\""
+ " -o \"" + tmpFile + "\""
+ " \"" + url + "\"";
var rc = system.exec(cmd);
// Read the temp file
var f = new File(tmpFile);
if (!f.open("r")) {
return null;
}
var body = f.read();
f.close();
// Clean up
file_remove(tmpFile);
if (!body || body.length === 0) {
return null;
}
return body;
}
// Fetch a short current-conditions string for the calling user's profile zip,
// e.g. "Partly cloudy 72F". Uses wttr.in's JSON output (no API key, no degree
// symbol, always Fahrenheit via temp_F). Returns "" on any problem so the
// header simply omits weather rather than breaking. Called once at startup.
function fetchWeather() {
try {
if (typeof user === "undefined" || !user) return "";
var zip = ("" + (user.zipcode || "")).replace(/[^0-9A-Za-z]/g, "");
if (!zip) return "";
var tmpFile = system.temp_dir + "wx_" + time() + "_" + random(99999) + ".tmp";
var url = "http://wttr.in/" + zip + "?format=j1";
system.exec("curl -s -S -L --max-time 12 -A \"curl/7.88.1\" -o \"" + tmpFile + "\" \"" + url + "\"");
var body = "";
var f = new File(tmpFile);
if (f.open("r")) { body = f.read(); f.close(); }
file_remove(tmpFile);
if (!body) return "";
var data = JSON.parse(body);
var cc = data && data.current_condition && data.current_condition[0];
if (!cc) return "";
var desc = (cc.weatherDesc && cc.weatherDesc[0] && cc.weatherDesc[0].value) ? ("" + cc.weatherDesc[0].value) : "";
desc = desc.replace(/\s+/g, " ").trim().split(" ").slice(0, 2).join(" "); // keep it short
var tF = (cc.temp_F !== undefined && cc.temp_F !== null) ? ("" + cc.temp_F) : "";
var out = (desc ? desc + " " : "") + (tF ? tF + "F" : "");
out = translit(out).trim();
if (out.length > 22) out = out.substring(0, 22);
return out;
} catch (e) { return ""; }
}
// ---------------------------------------------------------------------------
// Search Wikipedia using the classic MediaWiki action API (more reliable
// than the REST v1 search endpoint across Synchronet versions)
// Returns array of {title, description} or []
// ---------------------------------------------------------------------------
function wikiSearch(query) {
// Use action=opensearch — simple, reliable, no auth needed
// Returns: [query, [titles], [descriptions], [urls]]
var q = urlEncode(query);
var url = "http://en.wikipedia.org/w/api.php?action=opensearch&search="
+ q + "&limit=10&namespace=0&format=json";
log(LOG_DEBUG, "wiki_encyclopedia: search URL: " + url);
var raw = httpGet(url);
log(LOG_DEBUG, "wiki_encyclopedia: raw length: " + (raw ? raw.length : "NULL"));
if (raw) log(LOG_DEBUG, "wiki_encyclopedia: raw[0..200]: " + raw.substring(0, 200));
if (!raw) return [];
try {
var data = JSON.parse(raw);
log(LOG_DEBUG, "wiki_encyclopedia: parsed data type: " + typeof data + " isArray: " + (data instanceof Array));
// opensearch format: [searchTerm, [titles], [descriptions], [urls]]
if (!data || !data[1] || data[1].length === 0) return [];
var titles = data[1];
var descs = data[2] || [];
var results = [];
for (var i = 0; i < titles.length; i++) {
results.push({
title: titles[i] || "",
description: descs[i] || ""
});
}
return results;
} catch(e) {
return [];
}
}
// ---------------------------------------------------------------------------
// Fetch a Wikipedia article — full text plus internal links
// Uses two separate API calls so neither is truncated by the other
// Returns { title, description, extract, links[] } or null
// ---------------------------------------------------------------------------
function wikiGetArticle(title) {
var t = urlEncode(title);
// Call 1: full plain-text extract (no exintro — get the whole article)
var url1 = "http://en.wikipedia.org/w/api.php?action=query"
+ "&titles=" + t
+ "&prop=extracts|description"
+ "&explaintext=1"
+ "&redirects=1"
+ "&format=json";
var raw1 = httpGet(url1);
if (!raw1) return null;
var pageTitle = title;
var pageDesc = "";
var pageText = "";
try {
var data1 = JSON.parse(raw1);
var pages1 = data1.query && data1.query.pages;
if (!pages1) return null;
var pg1 = null;
for (var id in pages1) { pg1 = pages1[id]; break; }
if (!pg1 || pg1.missing !== undefined) return null;
pageTitle = pg1.title || title;
pageDesc = pg1.description || "";
pageText = cleanExtract(pg1.extract || "");
} catch(e) {
return null;
}
// Call 2: internal links (separate call so extract isn't truncated)
var url2 = "http://en.wikipedia.org/w/api.php?action=query"
+ "&titles=" + t
+ "&prop=links"
+ "&pllimit=500"
+ "&plnamespace=0"
+ "&redirects=1"
+ "&format=json";
var links = [];
var raw2 = httpGet(url2);
if (raw2) {
try {
var data2 = JSON.parse(raw2);
var pages2 = data2.query && data2.query.pages;
if (pages2) {
var pg2 = null;
for (var id2 in pages2) { pg2 = pages2[id2]; break; }
if (pg2 && pg2.links) {
for (var li = 0; li < pg2.links.length; li++) {
links.push(pg2.links[li].title);
}
}
}
} catch(e2) {
}
}
// Call 3: get thumbnail image URL from summary API
var imageUrl = "";
var url3 = "http://en.wikipedia.org/api/rest_v1/page/summary/" + t.replace(/\+/g, "_");
var raw3 = httpGet(url3);
if (raw3) {
try {
var data3 = JSON.parse(raw3);
if (data3.thumbnail && data3.thumbnail.source) {
// Extract filename from the CDN URL and use Special:FilePath
// which routes through en.wikipedia.org (works) instead of
// upload.wikimedia.org (blocked by Wikimedia CDN allowlist)
var src = data3.thumbnail.source;
var fnMatch = src.match(/\/([^/]+\.(?:jpg|jpeg|png|gif|svg|webp|tiff?|bmp))/i)
|| src.match(/\/([^/\.?]+(?:\.[^/\.?]+)?)(?:\?|$)/i);
if (fnMatch) {
var fname = fnMatch[1].replace(/^\d+px-/, "");
imageUrl = "https://en.wikipedia.org/wiki/Special:FilePath/"
+ fname + "?width=480";
} else {
imageUrl = src;
}
}
} catch(e3) {
}
}
return {
title: pageTitle,
description: pageDesc,
extract: pageText,
links: links,
imageUrl: imageUrl
};
}
// ---------------------------------------------------------------------------
// Fetch related articles via action API "morelike" search
// Falls back to a plain search on the title if morelike returns nothing
// ---------------------------------------------------------------------------
function wikiRelated(title) {
var t = urlEncode(title);
var url = "http://en.wikipedia.org/w/api.php?action=query"
+ "&list=search"
+ "&srsearch=morelike:" + t
+ "&srlimit=10"
+ "&srnamespace=0"
+ "&format=json";
var raw = httpGet(url);
if (!raw) return [];
try {
var data = JSON.parse(raw);
var items = data.query && data.query.search;
if (!items || items.length === 0) return [];
var results = [];
for (var i = 0; i < items.length; i++) {
var s = items[i];
results.push({
title: s.title || "",
description: stripHtml(s.snippet || "")
});
}
return results;
} catch(e) {
return [];
}
}
// ---------------------------------------------------------------------------
// A line in the article is either:
// a plain string — rendered as-is
// a link object — { link: true, title: "Article Title" }
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Download and render a Wikipedia thumbnail as sixel graphics.
// imgCols = how many terminal columns wide to render the image
// imgRows = how many terminal rows tall to render the image
// Returns the sixel escape sequence string, or "" if unavailable.
// ---------------------------------------------------------------------------
function fetchSixel(imageUrl, imgCols, imgRows) {
if (!imageUrl || !TERM.hasSixel) return "";
var ts = time() + "_" + random(99999);
var tmpImg = system.temp_dir + "wiki_img_" + ts + ".img";
var tmpSix = system.temp_dir + "wiki_six_" + ts + ".txt";
var tmpErr = system.temp_dir + "wiki_err_" + ts + ".txt";
// Download via Special:FilePath (upload.wikimedia.org CDN is blocked)
var dlCmd = "curl -s -L -k --max-time 20"
+ " -H \"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\""
+ " -o \"" + tmpImg + "\""
+ " \"" + imageUrl + "\"";
var rc = system.exec(dlCmd);
// Check file size using Synchronet file_size() which doesn't open the file
var fsize = file_size(tmpImg);
if (fsize < 500) {
file_remove(tmpImg);
return "";
}
// Run wiki_img.py with explicit OUTPUT file argument (avoids shell redirection issues)
// New wiki_img.py signature: wiki_img.py <input> <output> <cols> <rows> [char_w] [char_h]
var pyCmd = "python \"" + system.exec_dir + "wiki_img.py\""
+ " \"" + tmpImg + "\""
+ " \"" + tmpSix + "\""
+ " " + imgCols
+ " " + imgRows
+ " " + CELL_W + " " + CELL_H;
rc = system.exec(pyCmd);
// Read sixel output — parse DIMS header then sixel data
var sixelData = "";
var actCols = imgCols;
var actRows = imgRows;
var f = new File(tmpSix);
if (f.open("rb")) {
var raw = f.read();
f.close();
if (raw && raw.substring(0, 5) === "DIMS:") {
var nl = raw.indexOf("\n");
var hdr = raw.substring(5, nl);
var parts = hdr.split(":");
if (parts.length === 2) {
actCols = parseInt(parts[0]) || imgCols;
actRows = parseInt(parts[1]) || imgRows;
}
sixelData = raw.substring(nl + 1);
} else {
sixelData = raw;
}
}
file_remove(tmpImg);
file_remove(tmpSix);
if (!sixelData || sixelData.substring(0, 5) === "ERROR") {
return null;
}
// Return actual dimensions with sixel data so caller can lay out correctly
return { data: sixelData, cols: actCols, rows: actRows };
}
// Build a "loading image" placeholder for content lines array
// Returns lines to display while image loads, then replaces with sixel
function makeImagePlaceholder(imgCols, imgRows, title) {
var lines = [];
var border = repeat("Ä", imgCols);
lines.push(COL_BORDER + " " + border + COL_RESET);
var mid = Math.floor(imgRows / 2);
for (var i = 0; i < imgRows; i++) {
if (i === mid) {
var label = " [IMAGE: " + title.substring(0, imgCols - 12) + "] ";
var pad = repeat(" ", Math.max(0, imgCols - label.length));
lines.push(COL_HEADER_BG + " " + label + pad + COL_RESET);
} else {
lines.push(COL_BODY_BG + " " + repeat(" ", imgCols) + COL_RESET);
}
}
lines.push(COL_BORDER + " " + border + COL_RESET);
lines.push("");
return lines;
}
// Render a sixel image at the current cursor position
// The sixel sequence is output directly — it advances the cursor down imgRows
function renderSixelImage(sixel, startRow, leftCol) {
if (!sixel) return;
console.putmsg(gotoxy(leftCol, startRow));
print(sixel); // raw output — do not use putmsg() for sixel data
console.putmsg(gotoxy(1, startRow));
}
// ---------------------------------------------------------------------------
// Render a sixel image inline within the content area.
// Draws the image flush with the content area, text flows below.
// imgLines = number of terminal rows the image should occupy
// ---------------------------------------------------------------------------
function renderInlineImage(sixel, screenRow, imgRows) {
if (!sixel) return;
// Position cursor then write sixel raw — must use print() not console.putmsg()
// because putmsg() interprets Ctrl-A codes and mangles the sixel escape sequences
console.putmsg(gotoxy(2, screenRow));
print(sixel); // raw output, no Ctrl-A processing
// Redraw borders
for (var r = screenRow; r < screenRow + imgRows && r <= CONTENT_BOT; r++) {
console.putmsg(
gotoxy(1, r) + COL_BORDER + "\xBA" + COL_RESET +
gotoxy(COLS, r) + COL_BORDER + "\xBA" + COL_RESET
);
}
}
// ---------------------------------------------------------------------------
// 256-color ANSI block-art image renderer (fallback when sixel unavailable)
// Converts the image to 256-color ANSI using half-block characters (▄ \xDC)
// Each character represents 2 vertical pixels using fg/bg color.
// Uses ESC[38;5;Nm (256-color fg) and ESC[48;5;Nm (256-color bg).