Build every CSV export through one helper that quotes cells and defuses formulas (#179)

The four "Export CSV" buttons each built CSV by hand, from values that come from the mesh:

- /nodelist used a data: URL through encodeURI(), which leaves "#" alone, so the file
  ended at the first "#" in any cell. Real node names contain one ("Big Rock Ridge #1").
- The node page's chart export (neighbors, telemetry) did not quote cells at all.
- /stats quoted cells but did not double quotes inside them, so a channel such as
  Mesh "West", Bay split into extra columns.
- None of them guarded against spreadsheet formulas. A node name, channel, firmware string
  or message that starts with = + - or @ opens as a live formula in Excel or LibreOffice.

Add static/csv.js (loaded from base.html, like html-escape.js) with csvCell(), which
quotes every cell, doubles inner quotes and prefixes formula-like text with ', leaving
plain numbers alone, and csvFromRows(). All four exports use it; /nodelist now downloads a
Blob like the others.

Co-authored-by: Nathan <201262769+nullrouten0@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-27 17:18:11 -07:00
1 parent 1e9b1a3ce3
commit 5f3d37bf06
5 files changed
+31 -22

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
/* Shared CSV helpers for the "Export CSV" buttons. Node names, channels, firmware strings and
message text come from the mesh, so every cell is quoted with inner quotes doubled, and text
that a spreadsheet would run as a formula (a leading = + - @, tab or carriage return) gets a
leading ' so it opens as plain text. Plain numbers, including negative ones, are unchanged. */
(function () {
const NUMBER = /^[-+]?(\d+(\.\d*)?|\.\d+)([eE][-+]?\d+)?$/;
const FORMULA = /^\s*[=+\-@]|^[\t\r]/;
window.csvCell = function csvCell(value) {
if (value === null || value === undefined) return '""';
let text = String(value);
if (FORMULA.test(text) && !NUMBER.test(text.trim())) text = "'" + text;
return '"' + text.replace(/"/g, '""') + '"';
};
window.csvFromRows = function csvFromRows(rows) {
return rows.map(row => row.map(csvCell).join(",")).join("\n");
};
})();
+1
View File
@@ -11,6 +11,7 @@
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script src="/static/html-escape.js"></script>
<script src="/static/csv.js"></script>
{% block head %}{% endblock %}
+5 -7
View File
@@ -1649,7 +1649,7 @@ function exportCSV(type){
}
}
const csv = rows.map(r => r.join(",")).join("\n");
const csv = csvFromRows(rows);
const blob = new Blob([csv], { type:"text/csv" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
@@ -1785,10 +1785,8 @@ async function loadNodeStats(nodeId) {
const portName = PORT_LABEL_MAP[pkt.portnum] || `Port ${pkt.portnum}`;
const displayPacketId = pkt.packet_id || (pkt.portnum === 73 ? "Not a Packet" : "");
// Escape quotes + line breaks for CSV safety
const payload = (pkt.payload || "")
.replace(/"/g, '""')
.replace(/\r?\n/g, " ");
// Keep each packet on one line; csvFromRows() does the quoting.
const payload = (pkt.payload || "").replace(/\r?\n/g, " ");
rows.push([
time,
@@ -1797,11 +1795,11 @@ async function loadNodeStats(nodeId) {
pkt.to_node_id,
pkt.portnum,
portName,
`"${payload}"`
payload
]);
}
const csv = rows.map(r => r.join(",")).join("\n");
const csv = csvFromRows(rows);
const blob = new Blob([csv], { type: "text/csv" });
const link = document.createElement("a");
+7 -14
View File
@@ -732,23 +732,16 @@ document.addEventListener("DOMContentLoaded", async function() {
}
function exportToCSV() {
const rows = [];
const headerList = Array.from(headers).map(h =>
`"${h.innerText.replace(/▲|▼/g,'')}"`
);
rows.push(headerList.join(","));
const trs = tbody.querySelectorAll("tr");
trs.forEach(tr => {
const cells = Array.from(tr.children).map(td =>
`"${td.innerText.replace(/"/g,'""')}"`
);
rows.push(cells.join(","));
const rows = [Array.from(headers).map(h => h.innerText.replace(/▲|▼/g,''))];
tbody.querySelectorAll("tr").forEach(tr => {
rows.push(Array.from(tr.children).map(td => td.innerText));
});
const csv = "data:text/csv;charset=utf-8,\uFEFF" + rows.join("\n");
// A Blob, not a data: URL: encodeURI() leaves "#" alone, which cut the file short at
// the first "#" in any cell (node names such as "Big Rock Ridge #1").
const blob = new Blob(["\uFEFF" + csvFromRows(rows)], { type: "text/csv;charset=utf-8" });
const a = document.createElement("a");
a.href = encodeURI(csv);
a.href = URL.createObjectURL(blob);
a.download = "nodelist.csv";
a.click();
}
+1 -1
View File
@@ -726,7 +726,7 @@ document.getElementById("closeModal").addEventListener("click",()=>{
});
function downloadCSV(filename,rows){
const csvContent=rows.map(r=>r.map(v=>`"${v}"`).join(",")).join("\n");
const csvContent=csvFromRows(rows);
const blob=new Blob([csvContent],{type:"text/csv;charset=utf-8;"});
const link=document.createElement("a");
link.href=URL.createObjectURL(blob);