diff --git a/meshview/static/csv.js b/meshview/static/csv.js
new file mode 100644
index 0000000..bdfd19f
--- /dev/null
+++ b/meshview/static/csv.js
@@ -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");
+ };
+})();
diff --git a/meshview/templates/base.html b/meshview/templates/base.html
index 4d470a4..4e48421 100644
--- a/meshview/templates/base.html
+++ b/meshview/templates/base.html
@@ -11,6 +11,7 @@
+
{% block head %}{% endblock %}
diff --git a/meshview/templates/node.html b/meshview/templates/node.html
index 895a738..8914b02 100644
--- a/meshview/templates/node.html
+++ b/meshview/templates/node.html
@@ -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");
diff --git a/meshview/templates/nodelist.html b/meshview/templates/nodelist.html
index 61bab28..a3e9294 100644
--- a/meshview/templates/nodelist.html
+++ b/meshview/templates/nodelist.html
@@ -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();
}
diff --git a/meshview/templates/stats.html b/meshview/templates/stats.html
index deab1f9..510e31a 100644
--- a/meshview/templates/stats.html
+++ b/meshview/templates/stats.html
@@ -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);