Skip to content

Commit df8c430

Browse files
authored
Merge pull request #26: add XML export
2 parents b4062d5 + 34afdd3 commit df8c430

6 files changed

Lines changed: 212 additions & 14 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ For files larger than 10 MB you get a quick menu to open the full file, preview
9090
- The status bar shows the selection size plus live `Count / Sum / Avg / Min / Max`
9191
- **Export as JSON** - Convert the current filtered and sorted view to a JSON array of objects via the native VS Code save dialog. Column headers become the keys and numbers and booleans come out typed, while values that would lose information (IDs with leading zeros, very large numbers) stay strings. Columns you have hidden in the column chooser are left out, the same as copy.
9292
- **Export as JSON Lines** - The same view as JSON Lines (NDJSON), one object per line, handy for streaming tools and data pipelines
93+
- **Export as XML** - The same view as an XML document, one `<row>` element per row and one child element per column. Column headers become element names, with anything that is not legal in an XML name (spaces, punctuation, a leading digit) replaced so the output always parses. Cell text is written exactly as it reads, with `&`, `<` and `>` escaped.
9394
- **Export as Markdown table** - The same view as a GitHub-flavored Markdown table, ready to paste into a README, issue or pull request
9495

9596
### Delimiter

src/csvEditorProvider.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ export class CsvEditorProvider implements vscode.CustomEditorProvider<CsvDocumen
255255

256256
// F4: Export handler — the webview sends the converted text plus a
257257
// suggested filename; the extension picks dialog filters from its
258-
// extension (.json / .jsonl / .md).
258+
// extension (.json / .jsonl / .xml / .md).
259259
} else if (msg.type === 'export') {
260260
const filename = msg.filename ?? 'export.json';
261261
const defaultUri = vscode.Uri.file(
@@ -264,6 +264,7 @@ export class CsvEditorProvider implements vscode.CustomEditorProvider<CsvDocumen
264264
const ext = path.extname(filename).toLowerCase();
265265
const filters: Record<string, string[]> =
266266
ext === '.jsonl' ? { 'JSON Lines': ['jsonl', 'ndjson'] } :
267+
ext === '.xml' ? { 'XML': ['xml'] } :
267268
ext === '.md' ? { 'Markdown': ['md'] } :
268269
{ 'JSON': ['json'] };
269270
filters['All files'] = ['*'];

src/webview.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ export function getWebviewContent(
124124
<button id="btn-go-to-row" title="Go to row (${mod}G)${isChunked ? ' — disabled in Paged View' : ''}"${isChunked ? ' disabled' : ''}><i class="codicon codicon-list-ordered"></i></button>
125125
<button id="btn-duplicates" title="Find duplicate rows${isChunked ? ' — disabled in Paged View' : ''}"${isChunked ? ' disabled' : ''}><i class="codicon codicon-files"></i></button>
126126
<div class="separator"></div>
127-
<button id="btn-export" title="Export as JSON, JSON Lines or Markdown" class="text-btn"${isPreview ? ' style="display:none;"' : ''}><i class="codicon codicon-export"></i> Export</button>
127+
<button id="btn-export" title="Export as JSON, JSON Lines, XML or Markdown" class="text-btn"${isPreview ? ' style="display:none;"' : ''}><i class="codicon codicon-export"></i> Export</button>
128128
<div class="separator"></div>
129129
<span id="delim-badge" class="delim-badge" title="Click to change delimiter">Delim: ,</span>
130130
<span class="info" id="info"></span>
@@ -142,6 +142,7 @@ export function getWebviewContent(
142142
<div id="export-dropdown" class="delim-dropdown hidden">
143143
<div class="export-option" data-format="json">JSON</div>
144144
<div class="export-option" data-format="jsonl">JSON Lines</div>
145+
<div class="export-option" data-format="xml">XML</div>
145146
<div class="export-option" data-format="md">Markdown table</div>
146147
</div>
147148

src/webview/features/export.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
11
import { state } from '../state';
22
import type { ColType } from '../types';
3-
import { toJson, toJsonLines, toMarkdownTable } from '../utils/export-formats';
3+
import { toJson, toJsonLines, toMarkdownTable, toXml } from '../utils/export-formats';
44
import { closeAllPopups } from './popups';
55

66
// ── Export menu ──────────────────────────────────────────────────────────────
7-
// The toolbar Export button opens a small dropdown (JSON / JSON Lines /
8-
// Markdown table). Every format exports the CURRENT VIEW: active filters and
9-
// sort order are applied, columns appear in their current (possibly reordered)
7+
// The toolbar Export button opens a small dropdown (JSON / JSON Lines / XML /
8+
// Markdown table). Every format exports the CURRENT VIEW: active filters
9+
// and sort order are applied, columns appear in their current (possibly reordered)
1010
// order with their current (possibly renamed) headers. Hidden columns are
1111
// EXCLUDED, matching the Excel-style copy behavior — export reflects exactly the
1212
// visible view. A frozen reference row is exported first, matching where the
1313
// user sees it.
1414
// Saving the file itself already writes CSV, so there is no CSV entry here.
1515

16-
type ExportFormat = 'json' | 'jsonl' | 'md';
16+
type ExportFormat = 'json' | 'jsonl' | 'xml' | 'md';
1717

1818
const FORMAT_EXT: Record<ExportFormat, string> = {
1919
json: '.json',
2020
jsonl: '.jsonl',
21+
xml: '.xml',
2122
md: '.md',
2223
};
2324

@@ -56,6 +57,7 @@ function runExport(format: ExportFormat): void {
5657
let text: string;
5758
if (format === 'json') text = toJson(headers, rows, types);
5859
else if (format === 'jsonl') text = toJsonLines(headers, rows, types);
60+
else if (format === 'xml') text = toXml(headers, rows, types);
5961
else text = toMarkdownTable(headers, rows, types);
6062

6163
const base = FILENAME ? FILENAME.replace(/\.[^.]+$/, '') : 'export';

src/webview/utils/export-formats.ts

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import type { ColType } from '../types';
22

33
// ── Export format converters ─────────────────────────────────────────────────
44
// Pure string-matrix → text converters behind the toolbar Export menu (JSON,
5-
// JSON Lines, Markdown table). Kept free of DOM/grid access so they are unit-
6-
// testable in plain Node (see test/export-formats.test.cjs); features/export.ts
7-
// gathers the current grid view (filter/sort applied) and feeds it in here.
5+
// JSON Lines, Markdown table, XML). Kept free of DOM/grid access so they are
6+
// unit-testable in plain Node (see test/export-formats.test.cjs);
7+
// features/export.ts gathers the current grid view (filter/sort applied) and
8+
// feeds it in here.
89

910
// JSON object keys come from the header row, which the user can rename or leave
1011
// blank — keys must still be non-empty and unique or rows would silently lose
@@ -102,3 +103,93 @@ export function toMarkdownTable(headers: string[], rows: string[][], types: ColT
102103
}
103104
return out.join('\n') + '\n';
104105
}
106+
107+
// ── XML ──────────────────────────────────────────────────────────────────────
108+
109+
// XML element names are far stricter than JSON keys: a header like "Total (€)"
110+
// or "1st year" is not a legal name, so every character outside the XML Name
111+
// production is replaced with '_' and a name that cannot start a Name gets an
112+
// '_' prefix. Letters and digits are matched by Unicode class, so non-ASCII
113+
// headers ("Họ tên", "Größe") stay readable instead of collapsing into '______'.
114+
const XML_NAME_START = /[:_\p{L}]/u;
115+
const XML_NAME_CHAR = /[-.:_\p{L}\p{N}\p{M}]/u;
116+
117+
function sanitizeXmlName(raw: string, index: number): string {
118+
const trimmed = (raw ?? '').trim();
119+
let name = '';
120+
for (const ch of trimmed) name += XML_NAME_CHAR.test(ch) ? ch : '_';
121+
if (name === '') return `column_${index + 1}`;
122+
// A name may not start with a digit, '-' or '.', and any name beginning
123+
// with "xml" is reserved by the spec — an underscore fixes both without
124+
// throwing away the header text.
125+
if (!XML_NAME_START.test(name[0]) || /^xml/i.test(name)) name = '_' + name;
126+
return name;
127+
}
128+
129+
// Sanitizing can collapse two distinct headers onto one name ("a b" and "a-b"
130+
// both become "a_b"), so uniqueness is applied AFTER sanitizing — otherwise two
131+
// columns would share an element name and a reader could not tell them apart.
132+
export function xmlTagNames(headers: string[]): string[] {
133+
const used = new Set<string>();
134+
return headers.map((h, i) => {
135+
const base = sanitizeXmlName(h, i);
136+
let name = base;
137+
for (let n = 2; used.has(name); n++) name = `${base}_${n}`;
138+
used.add(name);
139+
return name;
140+
});
141+
}
142+
143+
// The XML 1.0 Char production: tab/LF/CR plus the printable ranges, with the
144+
// surrogate block and the two non-characters at the end of the BMP excluded.
145+
function isXmlChar(cp: number): boolean {
146+
return cp === 0x09 || cp === 0x0a || cp === 0x0d
147+
|| (cp >= 0x20 && cp <= 0xd7ff)
148+
|| (cp >= 0xe000 && cp <= 0xfffd)
149+
|| (cp >= 0x10000 && cp <= 0x10ffff);
150+
}
151+
152+
// Characters outside that production (stray C0 control bytes, lone surrogates)
153+
// cannot appear in a document at all — not even as a numeric character
154+
// reference — so they are dropped rather than escaped.
155+
function stripInvalidXmlChars(value: string): string {
156+
let out = '';
157+
// Iterating by code point keeps astral characters (emoji, rare CJK) intact;
158+
// a lone surrogate arrives on its own and fails isXmlChar, so it is dropped.
159+
for (const ch of value) if (isXmlChar(ch.codePointAt(0)!)) out += ch;
160+
return out;
161+
}
162+
163+
// CR is escaped because a parser would otherwise normalise it to LF and
164+
// silently change the value; LF and tab are left as-is so multi-line cells stay
165+
// readable.
166+
export function escapeXmlText(value: string): string {
167+
return stripInvalidXmlChars(value)
168+
.replace(/&/g, '&amp;')
169+
.replace(/</g, '&lt;')
170+
.replace(/>/g, '&gt;')
171+
.replace(/\r/g, '&#13;');
172+
}
173+
174+
// XML — one <row> element per row, one child element per column, 2-space
175+
// indented like the JSON output. Cell text is written verbatim (XML has no
176+
// number type, so coercing "2.50" into 2.5 would only lose formatting); the
177+
// column type is used solely to decide emptiness: an empty cell in a typed
178+
// column is the counterpart of JSON's null and becomes a self-closing <age/>,
179+
// while an empty cell in a string column stays an empty <name></name>.
180+
export function toXml(headers: string[], rows: string[][], types: ColType[]): string {
181+
const tags = xmlTagNames(headers);
182+
const out = ['<?xml version="1.0" encoding="UTF-8"?>', '<rows>'];
183+
for (const row of rows) {
184+
out.push(' <row>');
185+
for (let c = 0; c < tags.length; c++) {
186+
const raw = row[c] ?? '';
187+
const type = types[c] ?? 'string';
188+
if (raw.trim() === '' && type !== 'string') out.push(` <${tags[c]}/>`);
189+
else out.push(` <${tags[c]}>${escapeXmlText(raw)}</${tags[c]}>`);
190+
}
191+
out.push(' </row>');
192+
}
193+
out.push('</rows>');
194+
return out.join('\n') + '\n';
195+
}

test/export-formats.test.cjs

Lines changed: 106 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
// Tests for the export format converters (webview/utils/export-formats.ts):
2-
// JSON / JSON Lines / Markdown table generation from the grid's string matrix.
3-
// The risky parts are key derivation (blank and duplicate headers must not
4-
// silently drop fields) and value coercion (numbers/booleans should be typed
2+
// JSON / JSON Lines / Markdown table / XML generation from the grid's string
3+
// matrix. The risky parts are key derivation (blank and duplicate headers must
4+
// not silently drop fields), value coercion (numbers/booleans should be typed
55
// in JSON, but NEVER lossily — "007" IDs, huge integers and stray text inside
6-
// a numeric column must survive as strings).
6+
// a numeric column must survive as strings) and XML well-formedness (arbitrary
7+
// header text has to become a legal element name, cell text has to be escaped).
78
//
89
// Run after `npm run compile` (or `tsc -p ./`): node test/export-formats.test.cjs
910

@@ -14,6 +15,9 @@ const {
1415
toJson,
1516
toJsonLines,
1617
toMarkdownTable,
18+
xmlTagNames,
19+
escapeXmlText,
20+
toXml,
1721
} = require('../out/webview/utils/export-formats.js');
1822

1923
let failures = 0;
@@ -149,5 +153,103 @@ test('toMarkdownTable pads short rows so the table stays rectangular', () => {
149153
assert.strictEqual(out.split('\n')[2], '| x | |');
150154
});
151155

156+
// ── xmlTagNames ──────────────────────────────────────────────────────────────
157+
158+
test('clean headers become element names unchanged', () => {
159+
assert.deepStrictEqual(xmlTagNames(['name', 'city_2']), ['name', 'city_2']);
160+
});
161+
162+
test('characters illegal in an XML name become underscores', () => {
163+
assert.deepStrictEqual(xmlTagNames(['Total (€)', 'first name']), ['Total____', 'first_name']);
164+
});
165+
166+
test('names that cannot start an XML name get an underscore prefix', () => {
167+
assert.deepStrictEqual(xmlTagNames(['1st year', '-lead', '.dot']), ['_1st_year', '_-lead', '_.dot']);
168+
});
169+
170+
test('the reserved "xml" prefix is escaped', () => {
171+
assert.deepStrictEqual(xmlTagNames(['xmlns', 'XmlId']), ['_xmlns', '_XmlId']);
172+
});
173+
174+
test('blank headers become positional column_N element names', () => {
175+
assert.deepStrictEqual(xmlTagNames(['a', '', ' ']), ['a', 'column_2', 'column_3']);
176+
});
177+
178+
test('non-ASCII letters survive as element names', () => {
179+
assert.deepStrictEqual(xmlTagNames(['Größe', 'Họ tên']), ['Größe', 'Họ_tên']);
180+
});
181+
182+
test('headers that sanitize onto the same name are still made unique', () => {
183+
assert.deepStrictEqual(xmlTagNames(['a b', 'a-b', 'a b']), ['a_b', 'a-b', 'a_b_2']);
184+
});
185+
186+
// ── escapeXmlText ────────────────────────────────────────────────────────────
187+
188+
test('markup characters are escaped', () => {
189+
assert.strictEqual(escapeXmlText('a & b <tag> "q" \'s\''), 'a &amp; b &lt;tag&gt; "q" \'s\'');
190+
});
191+
192+
test('an already-escaped entity is not double-decoded, only re-escaped', () => {
193+
assert.strictEqual(escapeXmlText('&amp;'), '&amp;amp;');
194+
});
195+
196+
test('CR is escaped so parsers do not normalise it away, LF and tab stay literal', () => {
197+
assert.strictEqual(escapeXmlText('a\r\nb\tc'), 'a&#13;\nb\tc');
198+
});
199+
200+
test('control characters illegal in XML 1.0 are dropped', () => {
201+
const raw = 'a' + String.fromCharCode(0) + 'b' + String.fromCharCode(0x1f) + 'c';
202+
assert.strictEqual(escapeXmlText(raw), 'abc');
203+
});
204+
205+
test('astral characters (emoji) survive intact', () => {
206+
assert.strictEqual(escapeXmlText('ok 👍'), 'ok 👍');
207+
});
208+
209+
test('a lone surrogate is dropped instead of producing invalid XML', () => {
210+
assert.strictEqual(escapeXmlText('a' + String.fromCharCode(0xd800) + 'b'), 'ab');
211+
});
212+
213+
// ── toXml ────────────────────────────────────────────────────────────────────
214+
215+
test('toXml wraps rows in a declaration and a <rows> root', () => {
216+
const out = toXml(['name', 'age'], [['Alice', '30']], ['string', 'integer']);
217+
assert.strictEqual(out,
218+
'<?xml version="1.0" encoding="UTF-8"?>\n' +
219+
'<rows>\n' +
220+
' <row>\n' +
221+
' <name>Alice</name>\n' +
222+
' <age>30</age>\n' +
223+
' </row>\n' +
224+
'</rows>\n');
225+
});
226+
227+
test('toXml on an empty row set still emits a well-formed root', () => {
228+
const out = toXml(['a'], [], ['string']);
229+
assert.strictEqual(out, '<?xml version="1.0" encoding="UTF-8"?>\n<rows>\n</rows>\n');
230+
});
231+
232+
test('toXml writes numbers verbatim — no coercion, so "2.50" and "007" keep their text', () => {
233+
const out = toXml(['f', 'id'], [['2.50', '007']], ['float', 'integer']);
234+
assert.ok(out.indexOf('<f>2.50</f>') >= 0, 'trailing zero kept');
235+
assert.ok(out.indexOf('<id>007</id>') >= 0, 'leading zero kept');
236+
});
237+
238+
test('toXml: an empty cell is self-closing in a typed column, empty text in a string column', () => {
239+
const out = toXml(['age', 'note'], [['', '']], ['integer', 'string']);
240+
assert.ok(out.indexOf(' <age/>\n') >= 0, 'typed empty cell is self-closing');
241+
assert.ok(out.indexOf(' <note></note>\n') >= 0, 'string empty cell stays an empty element');
242+
});
243+
244+
test('toXml escapes cell content instead of emitting raw markup', () => {
245+
const out = toXml(['a'], [['<b>&</b>']], ['string']);
246+
assert.ok(out.indexOf('<a>&lt;b&gt;&amp;&lt;/b&gt;</a>') >= 0);
247+
});
248+
249+
test('toXml pads short rows so every row has the same elements', () => {
250+
const out = toXml(['a', 'b'], [['x']], ['string', 'string']);
251+
assert.ok(out.indexOf('<b></b>') >= 0);
252+
});
253+
152254
if (failures) { console.error('\n' + failures + ' test(s) failed'); process.exit(1); }
153255
console.log('\nAll tests passed');

0 commit comments

Comments
 (0)