@qe-mcp/ena 0.0.0-stage → 0.1.18
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/README.md +648 -2
- package/fonts/Inter-Bold.ttf +0 -0
- package/fonts/Inter-Regular.ttf +0 -0
- package/fonts/OFL.txt +93 -0
- package/fonts/README.md +4 -0
- package/hooks/ena-privacy-gate.mjs +31 -0
- package/lines.js +349 -0
- package/manifest.json +120 -0
- package/package.json +41 -4
- package/server.js +3605 -0
- package/skill/ena-analysis/SKILL.md +348 -0
- package/webena.js +651 -0
- package/xlsx.js +181 -0
package/xlsx.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xlsx.js — read the first worksheet of an .xlsx file as CSV text, as the ENA
|
|
3
|
+
* Web Tool does for an uploaded .xlsx (rENA-webtool js/XlsxReader.js, ported
|
|
4
|
+
* to Node: zlib instead of JSZip, a small XML reader instead of the browser's
|
|
5
|
+
* DOMParser).
|
|
6
|
+
*
|
|
7
|
+
* Parity with the web tool, and so with R's openxlsx::read.xlsx(sheet = 1):
|
|
8
|
+
* - first sheet in tab order only; its first row is the header row
|
|
9
|
+
* - values as stored: dates stay Excel serial numbers (no date detection)
|
|
10
|
+
* - booleans as TRUE / FALSE; shared and inline strings resolved
|
|
11
|
+
* - rows are taken in file order and padded to the widest row
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { inflateRawSync } from 'zlib';
|
|
15
|
+
|
|
16
|
+
// ── ZIP: the entries of an .xlsx (a ZIP of XML parts) ────────────────────────
|
|
17
|
+
function unzip(buf) {
|
|
18
|
+
// End of central directory: scan back over a possible archive comment.
|
|
19
|
+
let eocd = -1;
|
|
20
|
+
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i--)
|
|
21
|
+
if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
|
22
|
+
if (eocd < 0) throw new Error('not an .xlsx file (no ZIP directory found)');
|
|
23
|
+
const count = buf.readUInt16LE(eocd + 10);
|
|
24
|
+
let p = buf.readUInt32LE(eocd + 16);
|
|
25
|
+
const files = new Map();
|
|
26
|
+
for (let n = 0; n < count; n++) {
|
|
27
|
+
if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('corrupt .xlsx (bad ZIP directory)');
|
|
28
|
+
const method = buf.readUInt16LE(p + 10);
|
|
29
|
+
const size = buf.readUInt32LE(p + 20);
|
|
30
|
+
const nameLen = buf.readUInt16LE(p + 28), extraLen = buf.readUInt16LE(p + 30), commentLen = buf.readUInt16LE(p + 32);
|
|
31
|
+
const local = buf.readUInt32LE(p + 42);
|
|
32
|
+
const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
|
|
33
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
34
|
+
files.set(name, () => {
|
|
35
|
+
const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
|
|
36
|
+
const data = buf.subarray(start, start + size);
|
|
37
|
+
if (method === 0) return data;
|
|
38
|
+
if (method === 8) return inflateRawSync(data);
|
|
39
|
+
throw new Error(`unsupported compression in .xlsx entry ${name}`);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return { file: (name) => files.get(name)?.() ?? null };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── XML: the little of it that SpreadsheetML needs ───────────────────────────
|
|
46
|
+
// Elements with getElementsByTagName (descendants, local name), getAttribute
|
|
47
|
+
// (prefixed or not) and textContent — the subset the readers below use. No
|
|
48
|
+
// namespaces, DTDs or entities beyond the five predefined and numeric ones.
|
|
49
|
+
const XML_ENTITIES = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" };
|
|
50
|
+
const unescapeXml = (s) => s.indexOf('&') < 0 ? s : s.replace(/&(#x[0-9a-fA-F]+|#\d+|\w+);/g, (m, e) =>
|
|
51
|
+
e[0] === '#' ? String.fromCodePoint(e[1] === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10))
|
|
52
|
+
: (XML_ENTITIES[e] ?? m));
|
|
53
|
+
|
|
54
|
+
class XmlElement {
|
|
55
|
+
constructor(name, attrs) { this.name = name; this.attrs = attrs; this.children = []; }
|
|
56
|
+
get localName() { const i = this.name.indexOf(':'); return i < 0 ? this.name : this.name.slice(i + 1); }
|
|
57
|
+
getAttribute(name) {
|
|
58
|
+
if (name in this.attrs) return this.attrs[name];
|
|
59
|
+
const i = name.indexOf(':'); // "r:id" also matches an unprefixed "id"
|
|
60
|
+
return i >= 0 && name.slice(i + 1) in this.attrs ? this.attrs[name.slice(i + 1)] : null;
|
|
61
|
+
}
|
|
62
|
+
getAttributeNS(ns, local) {
|
|
63
|
+
for (const k in this.attrs) if (k === local || k.endsWith(':' + local)) return this.attrs[k];
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
get textContent() {
|
|
67
|
+
let out = '';
|
|
68
|
+
for (const c of this.children) out += typeof c === 'string' ? c : c.textContent;
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
getElementsByTagName(local) {
|
|
72
|
+
const out = [];
|
|
73
|
+
const walk = (el) => { for (const c of el.children) if (typeof c !== 'string') { if (c.localName === local) out.push(c); walk(c); } };
|
|
74
|
+
walk(this);
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const ATTR_RE = /([^\s=\/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
80
|
+
function parseXml(buf) {
|
|
81
|
+
const xml = buf.toString('utf8');
|
|
82
|
+
const root = new XmlElement('#document', {});
|
|
83
|
+
const stack = [root];
|
|
84
|
+
const TAG_RE = /<(\/)?([^\s\/>!?]+)([^>]*?)(\/)?>|<!\[CDATA\[([\s\S]*?)\]\]>|<!--[\s\S]*?-->|<[!?][^>]*>/g;
|
|
85
|
+
let last = 0, m;
|
|
86
|
+
while ((m = TAG_RE.exec(xml))) {
|
|
87
|
+
const text = xml.slice(last, m.index);
|
|
88
|
+
if (text) stack[stack.length - 1].children.push(unescapeXml(text));
|
|
89
|
+
last = TAG_RE.lastIndex;
|
|
90
|
+
if (m[5] !== undefined) { stack[stack.length - 1].children.push(m[5]); continue; } // CDATA
|
|
91
|
+
if (m[2] === undefined) continue; // comment, PI, doctype
|
|
92
|
+
if (m[1]) { if (stack.length > 1) stack.pop(); continue; } // closing tag
|
|
93
|
+
const attrs = {};
|
|
94
|
+
for (const a of m[3].matchAll(ATTR_RE)) attrs[a[1]] = unescapeXml(a[2] ?? a[3] ?? '');
|
|
95
|
+
const el = new XmlElement(m[2], attrs);
|
|
96
|
+
stack[stack.length - 1].children.push(el);
|
|
97
|
+
if (!m[4]) stack.push(el);
|
|
98
|
+
}
|
|
99
|
+
return root;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// "A1" / "BC12" → 0-based column. Column letters are base-26 (bijective).
|
|
103
|
+
function refCol(ref) {
|
|
104
|
+
let col = 0;
|
|
105
|
+
for (let i = 0; i < ref.length; i++) {
|
|
106
|
+
const ch = ref.charCodeAt(i);
|
|
107
|
+
if (ch < 65 || ch > 90) break;
|
|
108
|
+
col = col * 26 + (ch - 64);
|
|
109
|
+
}
|
|
110
|
+
return col - 1;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Text of a shared-string <si> or inline <is> (rich-text runs concatenated).
|
|
114
|
+
function textOf(elem) {
|
|
115
|
+
if (!elem) return '';
|
|
116
|
+
let out = '';
|
|
117
|
+
for (const t of elem.getElementsByTagName('t')) out += t.textContent;
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function firstSheetPath(zip) {
|
|
122
|
+
const wb = zip.file('xl/workbook.xml');
|
|
123
|
+
if (!wb) throw new Error('not an .xlsx workbook (xl/workbook.xml missing)');
|
|
124
|
+
const sheets = parseXml(wb).getElementsByTagName('sheet');
|
|
125
|
+
if (!sheets.length) throw new Error('the workbook has no sheets');
|
|
126
|
+
const rid = sheets[0].getAttribute('r:id')
|
|
127
|
+
|| sheets[0].getAttributeNS('http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'id');
|
|
128
|
+
const rels = zip.file('xl/_rels/workbook.xml.rels');
|
|
129
|
+
if (rels) {
|
|
130
|
+
for (const rel of parseXml(rels).getElementsByTagName('Relationship')) {
|
|
131
|
+
if (rel.getAttribute('Id') !== rid) continue;
|
|
132
|
+
const target = rel.getAttribute('Target');
|
|
133
|
+
if (target.charAt(0) === '/') return target.slice(1);
|
|
134
|
+
return target.indexOf('xl/') === 0 ? target : 'xl/' + target;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return 'xl/worksheets/sheet1.xml';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function csvField(v) {
|
|
141
|
+
if (v == null) return '';
|
|
142
|
+
const s = String(v);
|
|
143
|
+
return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function cellValue(cell, shared) {
|
|
147
|
+
const t = cell.getAttribute('t'); // s | inlineStr | str | b | e | n | (none)
|
|
148
|
+
if (t === 'inlineStr') return textOf(cell.getElementsByTagName('is')[0]);
|
|
149
|
+
const v = cell.getElementsByTagName('v')[0];
|
|
150
|
+
const raw = v ? v.textContent : '';
|
|
151
|
+
if (t === 's') return shared[parseInt(raw, 10)] || '';
|
|
152
|
+
if (t === 'b') return raw === '1' ? 'TRUE' : 'FALSE';
|
|
153
|
+
return raw;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** CSV text of the first worksheet of the .xlsx in buf. */
|
|
157
|
+
export function xlsxToCSV(buf) {
|
|
158
|
+
const zip = unzip(buf);
|
|
159
|
+
const sst = zip.file('xl/sharedStrings.xml');
|
|
160
|
+
const shared = sst ? Array.from(parseXml(sst).getElementsByTagName('si'), textOf) : [];
|
|
161
|
+
const path = firstSheetPath(zip);
|
|
162
|
+
const sheet = zip.file(path);
|
|
163
|
+
if (!sheet) throw new Error(`worksheet not found: ${path}`);
|
|
164
|
+
|
|
165
|
+
const grid = [];
|
|
166
|
+
let maxCol = -1;
|
|
167
|
+
for (const rowEl of parseXml(sheet).getElementsByTagName('row')) {
|
|
168
|
+
const row = [];
|
|
169
|
+
Array.from(rowEl.getElementsByTagName('c')).forEach((cell, c) => {
|
|
170
|
+
const ref = cell.getAttribute('r');
|
|
171
|
+
const col = ref ? refCol(ref) : c;
|
|
172
|
+
row[col] = cellValue(cell, shared);
|
|
173
|
+
if (col > maxCol) maxCol = col;
|
|
174
|
+
});
|
|
175
|
+
grid.push(row);
|
|
176
|
+
}
|
|
177
|
+
if (maxCol < 0) return '';
|
|
178
|
+
return grid.map(row => Array.from({ length: maxCol + 1 }, (_, c) => csvField(row[c])).join(',')).join('\r\n');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export const isXlsx = (path) => /\.xlsx$/i.test(String(path ?? ''));
|