mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-10-31 03:01:58 +01:00
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:
5 files changed
+31
-22
No files matched your search
@@ -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");
|
||||
};
|
||||
})();
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user