signalk-race-control 0.12.8 → 0.12.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,123 @@
1
+ # Changelog
2
+
3
+ All notable changes to `signalk-race-control`, newest first.
4
+
5
+ ## 0.12.10 (2026-10-14)
6
+ - Drop the `exceljs` dependency: the Excel export now uses a small built-in `.xlsx` writer, so the plugin has no runtime dependencies (and no `npm audit` findings for installers). `overrides` only apply to the project that declares them, so the earlier `uuid` override never protected installed copies
7
+
8
+ ## 0.12.9 (2026-10-13)
9
+ - Patch `brace-expansion` (via `exceljs`) to fix the npm audit finding
10
+ - Fix the MFD tile showing "No active race" over a live race (its stylesheet ignored the `hidden` attribute)
11
+ - Add this changelog and App Store screenshots
12
+
13
+ ## 0.12.8 (2026-10-12)
14
+ - Split the Excel export by class
15
+
16
+ ## 0.12.7 (2026-10-11)
17
+ - Allow editing class start times before the race starts
18
+
19
+ ## 0.12.6 (2026-09-20)
20
+ - Add read-only MFD tile webapp for signalk-mfd-plugin
21
+
22
+ ## 0.12.5 (2026-09-20)
23
+ - Add optional buddy-list registration when adding a boat
24
+
25
+ ## 0.12.4 (2026-09-20)
26
+ - Fix webapp icon resolution via signalk.appIcon
27
+
28
+ ## 0.12.3 (2026-09-20)
29
+ - Add a favicon: a racing mark with a clock hand
30
+
31
+ ## 0.12.2 (2026-09-19)
32
+ - Repopulate the schedule input/button from the actual saved schedule
33
+
34
+ ## 0.12.1 (2026-09-18)
35
+ - Fix App Store audit warnings: repository field, uuid override
36
+ - Add a plugin-level test suite
37
+
38
+ ## 0.12.0 (2026-09-16)
39
+ - Auto-activate the course as a SignalK route on start-line crossing
40
+ - Add 10-minute laylines for self on the course chart
41
+ - Add a lift/header wind-shift instrument for self
42
+
43
+ ## 0.11.1 (2026-09-16)
44
+ - Fix OpenSeaMap autosuggest/snap never finding real data
45
+
46
+ ## 0.11.0 (2026-09-16)
47
+ - Let the chart fill the page width, without trapping page scroll
48
+ - Add a recenter button to the chart
49
+ - Reuse a chosen OpenSeaMap mark instantly in other course-point rows
50
+ - Scope the recenter button to just the race's own course
51
+ - Fall back to self position when recentering with no course set
52
+ - Schedule Start/Call-off only need a date on multi-day races
53
+ - Revert Schedule Start to always need a date
54
+ - Add classes for staggered/pursuit starts
55
+ - Fix buoy name suggestion losing to a generic reverse-geocode guess
56
+ - Fix map-pick feedback stalling on a slow Overpass lookup
57
+ - Cache OpenSeaMap nav marks per chart area
58
+ - Reorder course sections and turn course points into tables
59
+ - Collapse course positions once a course is saved
60
+
61
+ ## 0.10.1 (2026-09-15)
62
+ - Fix scheduled start/call-off firing immediately when set far enough ahead
63
+
64
+ ## 0.10.0 (2026-09-15)
65
+ - Fix multiple registered charts stacking instead of switching
66
+ - Show a boat's inherited start time instead of blank
67
+ - Autosuggest course point names from the chart area, not just waypoints
68
+ - Source the course-point name autosuggest from OpenSeaMap, not general OSM
69
+ - Try multiple Overpass mirrors, and ask to snap a map click to a nearby mark
70
+ - Default the chart to Eniro's plain map, not whichever loaded first
71
+
72
+ ## 0.9.0 (2026-09-13)
73
+ - Add click-to-place course points on the map, fix unreadable mark dropdown
74
+ - Merge course-point buttons into one, suggest names from OpenStreetMap
75
+
76
+ ## 0.8.0 (2026-09-13)
77
+ - Fix Add Boat not responding to Enter in the offline export
78
+ - Mark speed pickups on the course chart
79
+ - Draw speed-pickup rings in each boat's own trace color
80
+ - Add a real background chart to the course/replay map
81
+
82
+ ## 0.7.0 (2026-09-08)
83
+ - Auto-stop a complete race, and add a DNS status
84
+ - Make Excel export use live formulas, reorder time columns
85
+ - Sort DNS boats to the end and allow marking DNS before race start
86
+ - Sort DNS boats last in the offline export template too
87
+
88
+ ## 0.6.0 (2026-09-07)
89
+ - Add KTK's KLR handicap register alongside VET-tall
90
+
91
+ ## 0.5.1 (2026-09-04)
92
+ - Stop the boats table from resizing/reshuffling confusingly
93
+
94
+ ## 0.5.0 (2026-09-03)
95
+ - Add a start timer view with distance/ETA/burn time to the line
96
+
97
+ ## 0.4.3 (2026-09-03)
98
+ - Let course points autocomplete against existing SignalK waypoints
99
+ - Make mark-rounding radius configurable and add manual mark rounding
100
+ - Record positions for manual mark roundings and improve chart replay
101
+ - Add author to package.json
102
+
103
+ ## 0.4.2 (2026-09-02)
104
+ - Remember sail number across races, like MMSI
105
+
106
+ ## 0.4.1 (2026-08-30)
107
+ - Make the offline export installable as a standalone iOS app
108
+
109
+ ## 0.4.0 (2026-08-30)
110
+ - Fetch VET-tall handicaps live in the offline export
111
+
112
+ ## 0.3.0 (2026-08-20)
113
+ - Add Manage2Sail fleet import with handicap-system conversion
114
+
115
+ ## 0.2.0 (2026-08-20)
116
+ - Add signalk-race-control plugin: race timing, handicaps, course/chart, VET import
117
+ - Add MIT LICENSE file and drop empty repository field
118
+ - Add offline export: standalone backup race timer
119
+ - Add multi-day races and individual per-boat start times
120
+ - Fix ghost row left in the boat table after removing a boat
121
+ - Fall back to the leader when no boat is marked self
122
+ - Clarify the multi-day toggle isn't needed for ordinary overnight races
123
+ - Fix scheduled call-off loss, stale offline exports, and add race-start correction
package/README.md CHANGED
@@ -265,11 +265,11 @@ and a live projected finishing order — during and after the race.
265
265
 
266
266
  ## Install
267
267
 
268
- Install this directory's dependencies once (needed for the Excel export), then copy
269
- (or symlink) it into your SignalK server's `node_modules`, e.g.:
268
+ The easiest way is the SignalK **App Store** (or `npm install signalk-race-control`).
269
+ The plugin has no runtime dependencies, so a checkout also works as-is — copy (or
270
+ symlink) it into your SignalK server's `node_modules`, e.g.:
270
271
 
271
272
  ```bash
272
- cd /path/to/race-control && npm install
273
273
  cd ~/.signalk/node_modules
274
274
  ln -s /path/to/race-control signalk-race-control
275
275
  ```
package/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const crypto = require('crypto');
4
- const ExcelJS = require('exceljs');
4
+ const xlsx = require('./xlsx');
5
5
 
6
6
  const DEFAULT_HANDICAP_SOURCE_PAGE = 'https://ssca.no/aktiviteter/vet-tall';
7
7
  const HANDICAP_CACHE_TTL_MS = 60 * 60 * 1000;
@@ -2397,9 +2397,7 @@ module.exports = function (app) {
2397
2397
  const ranked = rankedBoatList(race);
2398
2398
  const headers = ['Rank', 'Boat', 'Sail Number', 'MMSI', 'TCF', 'Start Time', 'Finish Time', 'Elapsed', 'Corrected', 'Status'];
2399
2399
 
2400
- const workbook = new ExcelJS.Workbook();
2401
- workbook.creator = 'Race Control';
2402
- workbook.created = new Date();
2400
+ const workbook = xlsx.createWorkbook();
2403
2401
 
2404
2402
  const statusLine = race.stopTime
2405
2403
  ? `Called off ${formatLocalDateTime(race.stopTime, tz)}`
@@ -2437,24 +2435,19 @@ module.exports = function (app) {
2437
2435
  return name;
2438
2436
  };
2439
2437
 
2438
+ const DAY_MS = 86400000;
2440
2439
  const addResultsSheet = (sheetName, title, entries) => {
2441
2440
  const sheet = workbook.addWorksheet(uniqueSheetName(sheetName));
2442
2441
 
2443
- const titleRow = sheet.addRow([title]);
2444
- sheet.mergeCells(titleRow.number, 1, titleRow.number, headers.length);
2445
- titleRow.getCell(1).font = { bold: true, size: 14 };
2442
+ const titleRow = sheet.addRow([{ value: title, font: { bold: true, size: 14 } }]);
2443
+ sheet.mergeCells(titleRow, 1, titleRow, headers.length);
2446
2444
 
2447
- const infoRow = sheet.addRow([statusLine]);
2448
- sheet.mergeCells(infoRow.number, 1, infoRow.number, headers.length);
2449
- infoRow.getCell(1).font = { italic: true, color: { argb: 'FF64748B' } };
2445
+ const infoRow = sheet.addRow([{ value: statusLine, font: { italic: true, color: 'FF64748B' } }]);
2446
+ sheet.mergeCells(infoRow, 1, infoRow, headers.length);
2450
2447
 
2451
2448
  sheet.addRow([]);
2452
2449
 
2453
- const headerRow = sheet.addRow(headers);
2454
- headerRow.font = { bold: true };
2455
- headerRow.eachCell((cell) => {
2456
- cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } };
2457
- });
2450
+ const headerRow = sheet.addRow(headers, { font: { bold: true }, fill: 'FFE2E8F0' });
2458
2451
 
2459
2452
  entries.forEach((r, i) => {
2460
2453
  const status = r.boat.dns
@@ -2470,29 +2463,26 @@ module.exports = function (app) {
2470
2463
  const start = effectiveStartTime(race, r.boat);
2471
2464
  const startDate = start != null ? new Date(start - tz * 60000) : null;
2472
2465
  const finishDate = r.boat.finishTime != null ? new Date(r.boat.finishTime - tz * 60000) : null;
2473
- const rowNum = headerRow.number + 1 + i;
2474
- const row = sheet.addRow([
2466
+ const rowNum = headerRow + 1 + i;
2467
+ // Cached results alongside the formulas, for viewers that don't
2468
+ // recalculate on open.
2469
+ const elapsedDays = start != null && r.boat.finishTime != null ? (r.boat.finishTime - start) / DAY_MS : null;
2470
+ sheet.addRow([
2475
2471
  rankLabel,
2476
2472
  r.boat.name,
2477
2473
  r.boat.sailNumber || '',
2478
2474
  r.boat.mmsi || '',
2479
2475
  r.boat.tcf,
2480
- startDate,
2481
- finishDate,
2482
- { formula: `IF(OR(F${rowNum}="",G${rowNum}=""),"",G${rowNum}-F${rowNum})` },
2483
- { formula: `IF(H${rowNum}="","",H${rowNum}*E${rowNum})` },
2476
+ { value: startDate, numFmt: dateNumFmt },
2477
+ { value: finishDate, numFmt: dateNumFmt },
2478
+ { formula: `IF(OR(F${rowNum}="",G${rowNum}=""),"",G${rowNum}-F${rowNum})`, value: elapsedDays == null ? '' : elapsedDays, numFmt: durationNumFmt },
2479
+ { formula: `IF(H${rowNum}="","",H${rowNum}*E${rowNum})`, value: elapsedDays == null ? "" : elapsedDays * (r.boat.tcf != null ? r.boat.tcf : 1), numFmt: durationNumFmt },
2484
2480
  status
2485
2481
  ]);
2486
- row.getCell(6).numFmt = dateNumFmt;
2487
- row.getCell(7).numFmt = dateNumFmt;
2488
- row.getCell(8).numFmt = durationNumFmt;
2489
- row.getCell(9).numFmt = durationNumFmt;
2490
2482
  });
2491
2483
 
2492
2484
  const widths = [7, 24, 12, 12, 8, race.multiDay ? 17 : 12, race.multiDay ? 17 : 12, 12, 12, 12];
2493
- widths.forEach((w, i) => {
2494
- sheet.getColumn(i + 1).width = w;
2495
- });
2485
+ widths.forEach((w, i) => sheet.setColumnWidth(i + 1, w));
2496
2486
  };
2497
2487
 
2498
2488
  const classes = race.classes || [];
@@ -2510,8 +2500,7 @@ module.exports = function (app) {
2510
2500
  res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
2511
2501
  res.setHeader('Content-Disposition', `attachment; filename="${safeName}-results.xlsx"`);
2512
2502
  try {
2513
- await workbook.xlsx.write(res);
2514
- res.end();
2503
+ res.end(workbook.toBuffer());
2515
2504
  } catch (e) {
2516
2505
  app.error('race-control: failed to generate xlsx export: ' + e.message);
2517
2506
  if (!res.headersSent) res.status(500).json({ error: 'Could not generate export' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-race-control",
3
- "version": "0.12.8",
3
+ "version": "0.12.10",
4
4
  "description": "SignalK plugin and webapp for tracking elapsed and handicap-corrected (Time-on-Time) race time, with a per-boat editable TCF and boat names pulled from AIS when available.",
5
5
  "main": "index.js",
6
6
  "author": "Joachim Bakke <github@heiamoss.com>",
@@ -9,7 +9,15 @@
9
9
  "url": "https://github.com/joabakk/signalk-race-control.git"
10
10
  },
11
11
  "signalk": {
12
- "appIcon": "favicon.svg"
12
+ "appIcon": "favicon.svg",
13
+ "displayName": "Race Control",
14
+ "screenshots": [
15
+ "screenshots/results.png",
16
+ "screenshots/course-chart.png",
17
+ "screenshots/start-timer.png",
18
+ "screenshots/mfd-leaderboard.png",
19
+ "screenshots/mfd-countdown.png"
20
+ ]
13
21
  },
14
22
  "keywords": [
15
23
  "signalk-node-server-plugin",
@@ -23,10 +31,16 @@
23
31
  "scripts": {
24
32
  "test": "node --test"
25
33
  },
26
- "dependencies": {
27
- "exceljs": "^4.4.0"
28
- },
29
34
  "overrides": {
30
- "uuid": "^11.1.1"
35
+ "uuid": "^11.1.1",
36
+ "glob": {
37
+ "brace-expansion": "^1.1.21"
38
+ },
39
+ "readdir-glob": {
40
+ "brace-expansion": "^2.1.7"
41
+ }
42
+ },
43
+ "devDependencies": {
44
+ "exceljs": "^4.4.0"
31
45
  }
32
46
  }
@@ -17,6 +17,9 @@
17
17
  --bad: #f87171;
18
18
  }
19
19
  * { box-sizing: border-box; }
20
+ /* Elements that set their own `display` would otherwise override the
21
+ `hidden` attribute's UA-stylesheet `display: none`. */
22
+ [hidden] { display: none !important; }
20
23
  html, body {
21
24
  margin: 0;
22
25
  height: 100%;
Binary file
@@ -0,0 +1,109 @@
1
+ const { test, describe } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const zlib = require('node:zlib');
4
+
5
+ const { createWorkbook, crc32, zip, columnLetters, excelSerial } = require('../xlsx.js');
6
+
7
+ // Reads the entries back out of a ZIP via its central directory.
8
+ function unzip(buf) {
9
+ const eocd = buf.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
10
+ const count = buf.readUInt16LE(eocd + 10);
11
+ let p = buf.readUInt32LE(eocd + 16);
12
+ const files = {};
13
+ for (let i = 0; i < count; i++) {
14
+ const method = buf.readUInt16LE(p + 10);
15
+ const crc = buf.readUInt32LE(p + 16);
16
+ const csize = buf.readUInt32LE(p + 20);
17
+ const nameLen = buf.readUInt16LE(p + 28);
18
+ const extraLen = buf.readUInt16LE(p + 30);
19
+ const commentLen = buf.readUInt16LE(p + 32);
20
+ const localOffset = buf.readUInt32LE(p + 42);
21
+ const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
22
+ const lNameLen = buf.readUInt16LE(localOffset + 26);
23
+ const lExtraLen = buf.readUInt16LE(localOffset + 28);
24
+ const dataStart = localOffset + 30 + lNameLen + lExtraLen;
25
+ const raw = method === 8 ? zlib.inflateRawSync(buf.subarray(dataStart, dataStart + csize)) : buf.subarray(dataStart, dataStart + csize);
26
+ assert.equal(crc32(raw), crc, `crc of ${name}`);
27
+ files[name] = raw.toString('utf8');
28
+ p += 46 + nameLen + extraLen + commentLen;
29
+ }
30
+ return files;
31
+ }
32
+
33
+ describe('xlsx helpers', () => {
34
+ test('crc32 matches the standard check value', () => {
35
+ assert.equal(crc32(Buffer.from('123456789')), 0xcbf43926);
36
+ });
37
+
38
+ test('columnLetters', () => {
39
+ assert.equal(columnLetters(1), 'A');
40
+ assert.equal(columnLetters(10), 'J');
41
+ assert.equal(columnLetters(26), 'Z');
42
+ assert.equal(columnLetters(27), 'AA');
43
+ assert.equal(columnLetters(52), 'AZ');
44
+ assert.equal(columnLetters(703), 'AAA');
45
+ });
46
+
47
+ test('excelSerial: 1900-01-01 is 2 and the Unix epoch is 25569', () => {
48
+ assert.equal(excelSerial(new Date(Date.UTC(1970, 0, 1))), 25569);
49
+ assert.equal(excelSerial(new Date(Date.UTC(1900, 0, 1))), 2);
50
+ assert.equal(excelSerial(new Date(Date.UTC(1970, 0, 1, 12))), 25569.5);
51
+ });
52
+
53
+ test('zip round-trips entries with valid CRCs', () => {
54
+ const files = unzip(zip([{ name: 'a.txt', data: 'hello' }, { name: 'dir/b.txt', data: 'x'.repeat(5000) }]));
55
+ assert.equal(files['a.txt'], 'hello');
56
+ assert.equal(files['dir/b.txt'], 'x'.repeat(5000));
57
+ });
58
+ });
59
+
60
+ describe('createWorkbook', () => {
61
+ const build = () => {
62
+ const wb = createWorkbook();
63
+ const sh = wb.addWorksheet('Results & <more>');
64
+ const title = sh.addRow([{ value: 'Title "q" & <b>', font: { bold: true, size: 14 } }]);
65
+ sh.mergeCells(title, 1, title, 3);
66
+ sh.addRow([]);
67
+ sh.addRow(['Boat', 'TCF', 'Start', 'Elapsed'], { font: { bold: true }, fill: 'FFE2E8F0' });
68
+ sh.addRow(['Alpha', 0.97, { value: new Date(Date.UTC(2026, 9, 13, 18, 5, 0)), numFmt: 'hh:mm:ss' }, { formula: 'B4*2', value: 1.94, numFmt: '[h]:mm:ss' }]);
69
+ sh.addRow(['Beta', null, null, { formula: 'IF(1=1,"","x")', value: '', numFmt: '[h]:mm:ss' }]);
70
+ sh.setColumnWidth(1, 24);
71
+ return unzip(wb.toBuffer());
72
+ };
73
+
74
+ test('emits the expected package parts', () => {
75
+ const files = build();
76
+ assert.deepEqual(Object.keys(files).sort(), [
77
+ '[Content_Types].xml',
78
+ '_rels/.rels',
79
+ 'xl/_rels/workbook.xml.rels',
80
+ 'xl/styles.xml',
81
+ 'xl/workbook.xml',
82
+ 'xl/worksheets/sheet1.xml'
83
+ ]);
84
+ });
85
+
86
+ test('escapes sheet names and strings, and writes merges, widths, values and formulas', () => {
87
+ const files = build();
88
+ assert.match(files['xl/workbook.xml'], /name="Results &amp; &lt;more&gt;"/);
89
+ const sheet = files['xl/worksheets/sheet1.xml'];
90
+ assert.match(sheet, /Title &quot;q&quot; &amp; &lt;b&gt;/);
91
+ assert.match(sheet, /<mergeCell ref="A1:C1"\/>/);
92
+ assert.match(sheet, /<col min="1" max="1" width="24" customWidth="1"\/>/);
93
+ assert.match(sheet, /<c r="B4"[^>]*><v>0\.97<\/v><\/c>/);
94
+ assert.match(sheet, /<c r="D4"[^>]*><f>B4\*2<\/f><v>1\.94<\/v><\/c>/);
95
+ assert.match(sheet, /<c r="D5"[^>]*t="str"><f>IF\(1=1,&quot;&quot;,&quot;x&quot;\)<\/f><v><\/v><\/c>/);
96
+ assert.match(sheet, new RegExp(`<c r="C4"[^>]*><v>${excelSerial(new Date(Date.UTC(2026, 9, 13, 18, 5, 0)))}</v></c>`));
97
+ // Null cells are simply absent.
98
+ assert.doesNotMatch(sheet, /r="B5"/);
99
+ });
100
+
101
+ test('registers fonts, fills and number formats in styles.xml', () => {
102
+ const styles = build()['xl/styles.xml'];
103
+ assert.match(styles, /<numFmt numFmtId="164" formatCode="hh:mm:ss"\/>/);
104
+ assert.match(styles, /<numFmt numFmtId="165" formatCode="\[h\]:mm:ss"\/>/);
105
+ assert.match(styles, /<b\/>/);
106
+ assert.match(styles, /<sz val="14"\/>/);
107
+ assert.match(styles, /<fgColor rgb="FFE2E8F0"\/>/);
108
+ });
109
+ });
package/xlsx.js ADDED
@@ -0,0 +1,308 @@
1
+ 'use strict';
2
+
3
+ // A deliberately small, dependency-free .xlsx (Office Open XML) writer —
4
+ // just what the results export needs: several worksheets, string / number /
5
+ // date / formula cells, bold/italic/size/colour fonts, solid fills, custom
6
+ // number formats, merged cells and column widths. It replaces a general
7
+ // spreadsheet library whose (unmaintained) dependency tree kept tripping
8
+ // npm audit for every installer of this plugin.
9
+
10
+ const zlib = require('zlib');
11
+
12
+ const CRC_TABLE = (() => {
13
+ const table = new Uint32Array(256);
14
+ for (let n = 0; n < 256; n++) {
15
+ let c = n;
16
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
17
+ table[n] = c >>> 0;
18
+ }
19
+ return table;
20
+ })();
21
+
22
+ function crc32(buf) {
23
+ let c = 0xffffffff;
24
+ for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
25
+ return (c ^ 0xffffffff) >>> 0;
26
+ }
27
+
28
+ // Minimal ZIP writer: deflated entries, UTF-8 names, no extras.
29
+ function zip(entries, now = new Date()) {
30
+ const dosTime = (now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1);
31
+ const dosDate = ((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate();
32
+ const locals = [];
33
+ const centrals = [];
34
+ let offset = 0;
35
+ entries.forEach(({ name, data }) => {
36
+ const nameBuf = Buffer.from(name, 'utf8');
37
+ const raw = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
38
+ const packed = zlib.deflateRawSync(raw);
39
+ const crc = crc32(raw);
40
+
41
+ const local = Buffer.alloc(30);
42
+ local.writeUInt32LE(0x04034b50, 0);
43
+ local.writeUInt16LE(20, 4);
44
+ local.writeUInt16LE(0x0800, 6);
45
+ local.writeUInt16LE(8, 8);
46
+ local.writeUInt16LE(dosTime, 10);
47
+ local.writeUInt16LE(dosDate, 12);
48
+ local.writeUInt32LE(crc, 14);
49
+ local.writeUInt32LE(packed.length, 18);
50
+ local.writeUInt32LE(raw.length, 22);
51
+ local.writeUInt16LE(nameBuf.length, 26);
52
+ local.writeUInt16LE(0, 28);
53
+ locals.push(local, nameBuf, packed);
54
+
55
+ const central = Buffer.alloc(46);
56
+ central.writeUInt32LE(0x02014b50, 0);
57
+ central.writeUInt16LE(20, 4);
58
+ central.writeUInt16LE(20, 6);
59
+ central.writeUInt16LE(0x0800, 8);
60
+ central.writeUInt16LE(8, 10);
61
+ central.writeUInt16LE(dosTime, 12);
62
+ central.writeUInt16LE(dosDate, 14);
63
+ central.writeUInt32LE(crc, 16);
64
+ central.writeUInt32LE(packed.length, 20);
65
+ central.writeUInt32LE(raw.length, 24);
66
+ central.writeUInt16LE(nameBuf.length, 28);
67
+ central.writeUInt32LE(offset, 42);
68
+ centrals.push(central, nameBuf);
69
+
70
+ offset += local.length + nameBuf.length + packed.length;
71
+ });
72
+ const centralBuf = Buffer.concat(centrals);
73
+ const end = Buffer.alloc(22);
74
+ end.writeUInt32LE(0x06054b50, 0);
75
+ end.writeUInt16LE(entries.length, 8);
76
+ end.writeUInt16LE(entries.length, 10);
77
+ end.writeUInt32LE(centralBuf.length, 12);
78
+ end.writeUInt32LE(offset, 16);
79
+ return Buffer.concat([...locals, centralBuf, end]);
80
+ }
81
+
82
+ // Control characters other than tab/newline/CR are illegal in XML 1.0.
83
+ function xmlEscape(s) {
84
+ return String(s)
85
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '')
86
+ .replace(/&/g, '&amp;')
87
+ .replace(/</g, '&lt;')
88
+ .replace(/>/g, '&gt;')
89
+ .replace(/"/g, '&quot;');
90
+ }
91
+
92
+ function columnLetters(col) {
93
+ let s = '';
94
+ for (let n = col; n > 0; n = Math.floor((n - 1) / 26)) s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
95
+ return s;
96
+ }
97
+
98
+ const cellRef = (row, col) => `${columnLetters(col)}${row}`;
99
+
100
+ // Excel stores dates as days since 1899-12-30 (the 1900 leap-year bug folded
101
+ // in). The caller passes a Date whose UTC fields hold the wall-clock to show.
102
+ const excelSerial = (date) => date.getTime() / 86400000 + 25569;
103
+
104
+ // Registers fonts / fills / number formats / cell formats as they're used,
105
+ // and emits xl/styles.xml. Index 0 of each is the default.
106
+ function createStyles() {
107
+ const fonts = ['<font><sz val="11"/><name val="Calibri"/></font>'];
108
+ const fills = ['<fill><patternFill patternType="none"/></fill>', '<fill><patternFill patternType="gray125"/></fill>'];
109
+ const numFmts = [];
110
+ const xfs = ['<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'];
111
+ const cache = new Map();
112
+
113
+ const indexOf = (list, xml) => {
114
+ const i = list.indexOf(xml);
115
+ if (i >= 0) return i;
116
+ list.push(xml);
117
+ return list.length - 1;
118
+ };
119
+
120
+ function xfFor(style) {
121
+ if (!style) return 0;
122
+ const key = JSON.stringify(style);
123
+ if (cache.has(key)) return cache.get(key);
124
+ const f = style.font;
125
+ let fontId = 0;
126
+ if (f) {
127
+ fontId = indexOf(
128
+ fonts,
129
+ '<font>' +
130
+ (f.bold ? '<b/>' : '') +
131
+ (f.italic ? '<i/>' : '') +
132
+ `<sz val="${f.size || 11}"/>` +
133
+ (f.color ? `<color rgb="${xmlEscape(f.color)}"/>` : '') +
134
+ '<name val="Calibri"/></font>'
135
+ );
136
+ }
137
+ const fillId = style.fill
138
+ ? indexOf(fills, `<fill><patternFill patternType="solid"><fgColor rgb="${xmlEscape(style.fill)}"/><bgColor indexed="64"/></patternFill></fill>`)
139
+ : 0;
140
+ let numFmtId = 0;
141
+ if (style.numFmt) {
142
+ const idx = indexOf(numFmts, style.numFmt);
143
+ numFmtId = 164 + idx;
144
+ }
145
+ const xf = `<xf numFmtId="${numFmtId}" fontId="${fontId}" fillId="${fillId}" borderId="0" xfId="0"` +
146
+ (numFmtId ? ' applyNumberFormat="1"' : '') +
147
+ (fontId ? ' applyFont="1"' : '') +
148
+ (fillId ? ' applyFill="1"' : '') +
149
+ '/>';
150
+ const id = indexOf(xfs, xf);
151
+ cache.set(key, id);
152
+ return id;
153
+ }
154
+
155
+ function toXml() {
156
+ return (
157
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
158
+ '<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
159
+ (numFmts.length
160
+ ? `<numFmts count="${numFmts.length}">` +
161
+ numFmts.map((code, i) => `<numFmt numFmtId="${164 + i}" formatCode="${xmlEscape(code)}"/>`).join('') +
162
+ '</numFmts>'
163
+ : '') +
164
+ `<fonts count="${fonts.length}">${fonts.join('')}</fonts>` +
165
+ `<fills count="${fills.length}">${fills.join('')}</fills>` +
166
+ '<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>' +
167
+ '<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' +
168
+ `<cellXfs count="${xfs.length}">${xfs.join('')}</cellXfs>` +
169
+ '<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>' +
170
+ '</styleSheet>'
171
+ );
172
+ }
173
+
174
+ return { xfFor, toXml };
175
+ }
176
+
177
+ // A cell is a plain value (string, number, Date, null) or an object:
178
+ // { value, formula, numFmt, font: {bold, italic, size, color}, fill }
179
+ // `formula` (without the leading "=") may come with a cached `value` so
180
+ // viewers that don't recalculate still show a result.
181
+ function createWorkbook() {
182
+ const styles = createStyles();
183
+ const sheets = [];
184
+
185
+ function addWorksheet(name) {
186
+ const rows = []; // rows[r-1] = [cell specs by column]
187
+ const merges = [];
188
+ const widths = [];
189
+ const sheet = {
190
+ name,
191
+ // Appends a row and returns its 1-based number. `rowStyle` is merged
192
+ // under each cell's own style (e.g. a bold, filled header row).
193
+ addRow(cells, rowStyle) {
194
+ rows.push(cells.map((c) => normalise(c, rowStyle)));
195
+ return rows.length;
196
+ },
197
+ mergeCells(row1, col1, row2, col2) {
198
+ merges.push(`${cellRef(row1, col1)}:${cellRef(row2, col2)}`);
199
+ },
200
+ setColumnWidth(col, width) {
201
+ widths[col - 1] = width;
202
+ },
203
+ toXml() {
204
+ const sheetData = rows
205
+ .map((cells, ri) => {
206
+ const r = ri + 1;
207
+ const cs = cells.map((spec, ci) => cellXml(spec, r, ci + 1)).join('');
208
+ return cs ? `<row r="${r}">${cs}</row>` : '';
209
+ })
210
+ .join('');
211
+ const cols = widths
212
+ .map((w, i) => (w ? `<col min="${i + 1}" max="${i + 1}" width="${w}" customWidth="1"/>` : ''))
213
+ .join('');
214
+ return (
215
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
216
+ '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
217
+ (cols ? `<cols>${cols}</cols>` : '') +
218
+ `<sheetData>${sheetData}</sheetData>` +
219
+ (merges.length ? `<mergeCells count="${merges.length}">${merges.map((m) => `<mergeCell ref="${m}"/>`).join('')}</mergeCells>` : '') +
220
+ '</worksheet>'
221
+ );
222
+ }
223
+ };
224
+ sheets.push(sheet);
225
+ return sheet;
226
+ }
227
+
228
+ function normalise(c, rowStyle) {
229
+ const spec = c !== null && typeof c === 'object' && !(c instanceof Date) ? c : { value: c };
230
+ const style = {
231
+ font: spec.font || (rowStyle && rowStyle.font),
232
+ fill: spec.fill || (rowStyle && rowStyle.fill),
233
+ numFmt: spec.numFmt || (rowStyle && rowStyle.numFmt)
234
+ };
235
+ return { value: spec.value, formula: spec.formula, style: style.font || style.fill || style.numFmt ? style : null };
236
+ }
237
+
238
+ function cellXml(spec, row, col) {
239
+ const s = styles.xfFor(spec.style);
240
+ const attrs = `r="${cellRef(row, col)}"${s ? ` s="${s}"` : ''}`;
241
+ const v = spec.value;
242
+ if (spec.formula) {
243
+ const f = `<f>${xmlEscape(spec.formula)}</f>`;
244
+ if (typeof v === 'number') return `<c ${attrs}>${f}<v>${v}</v></c>`;
245
+ return `<c ${attrs} t="str">${f}<v>${v == null ? '' : xmlEscape(v)}</v></c>`;
246
+ }
247
+ if (v === null || v === undefined || v === '') return s ? `<c ${attrs}/>` : '';
248
+ if (v instanceof Date) return `<c ${attrs}><v>${excelSerial(v)}</v></c>`;
249
+ if (typeof v === 'number') return isFinite(v) ? `<c ${attrs}><v>${v}</v></c>` : '';
250
+ return `<c ${attrs} t="inlineStr"><is><t xml:space="preserve">${xmlEscape(v)}</t></is></c>`;
251
+ }
252
+
253
+ function toBuffer() {
254
+ const NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
255
+ const sheetFiles = sheets.map((sh, i) => ({ name: `xl/worksheets/sheet${i + 1}.xml`, data: sh.toXml() }));
256
+ const entries = [
257
+ {
258
+ name: '[Content_Types].xml',
259
+ data:
260
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
261
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
262
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
263
+ '<Default Extension="xml" ContentType="application/xml"/>' +
264
+ '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
265
+ sheets
266
+ .map((_, i) => `<Override PartName="/xl/worksheets/sheet${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`)
267
+ .join('') +
268
+ '<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' +
269
+ '</Types>'
270
+ },
271
+ {
272
+ name: '_rels/.rels',
273
+ data:
274
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
275
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
276
+ `<Relationship Id="rId1" Type="${NS}/officeDocument" Target="xl/workbook.xml"/>` +
277
+ '</Relationships>'
278
+ },
279
+ {
280
+ name: 'xl/workbook.xml',
281
+ data:
282
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
283
+ `<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="${NS}">` +
284
+ '<sheets>' +
285
+ sheets.map((sh, i) => `<sheet name="${xmlEscape(sh.name)}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`).join('') +
286
+ '</sheets><calcPr fullCalcOnLoad="1"/></workbook>'
287
+ },
288
+ {
289
+ name: 'xl/_rels/workbook.xml.rels',
290
+ data:
291
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
292
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
293
+ sheets.map((_, i) => `<Relationship Id="rId${i + 1}" Type="${NS}/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`).join('') +
294
+ `<Relationship Id="rId${sheets.length + 1}" Type="${NS}/styles" Target="styles.xml"/>` +
295
+ '</Relationships>'
296
+ },
297
+ ...sheetFiles,
298
+ // Styles are emitted last: building the sheets above is what
299
+ // registers the fonts/fills/formats they use.
300
+ { name: 'xl/styles.xml', data: styles.toXml() }
301
+ ];
302
+ return zip(entries);
303
+ }
304
+
305
+ return { addWorksheet, toBuffer };
306
+ }
307
+
308
+ module.exports = { createWorkbook, crc32, zip, columnLetters, excelSerial };