aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/csv.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/node-page.js7
-rw-r--r--packages/meshbay-hub/tests/test_csv_cell.py32
3 files changed, 48 insertions, 5 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/csv.js b/packages/meshbay-hub/src/meshbay_hub/static/csv.js
new file mode 100644
index 0000000..310bdca
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/csv.js
@@ -0,0 +1,14 @@
+/**
+ * One CSV cell, quoted when it has to be, and never a formula.
+ *
+ * A spreadsheet runs a cell that starts with `=`, `+`, `-` or `@` (or a tab or a
+ * carriage return in front of one) as a formula. The audit export carries text
+ * a member chose — a refused blob's kind, a file name — so such a cell is given
+ * a leading apostrophe, which spreadsheets read as "this is text", and which is
+ * what other exports do.
+ */
+export function csvCell(value) {
+ let s = value == null ? '' : String(value);
+ if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;
+ return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
index f940934..41e9567 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
@@ -1,3 +1,4 @@
+import { csvCell } from './csv.js';
import {
html, useState, useEffect, useCallback, useRef,
} from './vendor/htm-preact.js';
@@ -585,17 +586,13 @@ export function NodePage({ groups, token, username }) {
const cols = ['timestamp', 'event', 'user', 'user_id', 'ip',
'group', 'group_id', 'detail'];
- const esc = (v) => {
- const s = v == null ? '' : String(v);
- return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
- };
const lines = [cols.join(',')];
for (const e of rows) {
lines.push([
new Date(e.timestamp * 1000).toISOString(),
e.event, e.username || '', e.user_id || '', e.ip || '',
e.group_name || '', e.group_id || '', e.detail || '',
- ].map(esc).join(','));
+ ].map(csvCell).join(','));
}
const csv = lines.join('\r\n') + '\r\n';
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
diff --git a/packages/meshbay-hub/tests/test_csv_cell.py b/packages/meshbay-hub/tests/test_csv_cell.py
new file mode 100644
index 0000000..ca38805
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_csv_cell.py
@@ -0,0 +1,32 @@
+"""
+The audit export never hands a spreadsheet a formula.
+
+It carries text a member chose — a refused blob's kind, a file name. A cell that
+starts with `=`, `+`, `-` or `@` runs as a formula when the operator opens the
+file, so it is given a leading apostrophe, which spreadsheets read as text.
+"""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+CSV = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" / "csv.js"
+
+pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node unavailable")
+
+CASES = ['=HYPERLINK("http://x","y")', "+1+1", "-2+3", "@SUM(A1)", "\t=1", "plain",
+ 'with "quotes"', "a,b", "", None, 12]
+
+
+def test_a_cell_is_text_and_quoted_where_it_must_be(tmp_path):
+ script = tmp_path / "case.mjs"
+ script.write_text(
+ f"import {{ csvCell }} from '{CSV.as_uri()}';\n"
+ f"console.log(JSON.stringify({json.dumps(CASES)}.map(csvCell)));\n")
+ out = json.loads(subprocess.run(["node", str(script)], capture_output=True,
+ text=True, check=True).stdout)
+ assert out == ['"\'=HYPERLINK(""http://x"",""y"")"', "'+1+1", "'-2+3", "'@SUM(A1)",
+ "'\t=1", "plain", '"with ""quotes"""', '"a,b"', "", "", "12"]