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 +123 -0
- package/README.md +3 -3
- package/index.js +19 -30
- package/package.json +20 -6
- package/public/mfd-tile.html +3 -0
- package/public/screenshots/course-chart.png +0 -0
- package/public/screenshots/mfd-countdown.png +0 -0
- package/public/screenshots/mfd-leaderboard.png +0 -0
- package/public/screenshots/results.png +0 -0
- package/public/screenshots/start-timer.png +0 -0
- package/test/xlsx.test.js +109 -0
- package/xlsx.js +308 -0
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
|
-
|
|
269
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
2474
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|
package/public/mfd-tile.html
CHANGED
|
@@ -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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
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 & <more>"/);
|
|
89
|
+
const sheet = files['xl/worksheets/sheet1.xml'];
|
|
90
|
+
assert.match(sheet, /Title "q" & <b>/);
|
|
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,"","x"\)<\/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, '&')
|
|
87
|
+
.replace(/</g, '<')
|
|
88
|
+
.replace(/>/g, '>')
|
|
89
|
+
.replace(/"/g, '"');
|
|
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 };
|