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);