From 11252f80883b2e47bae87daf4cdc30cc4541e036 Mon Sep 17 00:00:00 2001
From: Ben Senescu <44480372+bensenescu@users.noreply.github.com>
Date: Mon, 4 May 2026 12:49:42 -0400
Subject: [PATCH] fix: improve spreadsheet exports (#146)
---
src/client/features/domain/utils.ts | 8 ++------
src/client/lib/clipboard.test.ts | 16 +++++++++++++++
src/client/lib/clipboard.ts | 31 ++++++++++++++++++-----------
src/client/lib/csv.test.ts | 20 +++++++++++++++++++
src/client/lib/csv.ts | 17 ++++++++++++++--
5 files changed, 72 insertions(+), 20 deletions(-)
create mode 100644 src/client/lib/csv.test.ts
diff --git a/src/client/features/domain/utils.ts b/src/client/features/domain/utils.ts
index c233f08..139ec7d 100644
--- a/src/client/features/domain/utils.ts
+++ b/src/client/features/domain/utils.ts
@@ -111,7 +111,7 @@ export function keywordsToTable(rows: KeywordRow[]): ExportTable {
row.searchVolume,
row.traffic,
row.cpc,
- row.relativeUrl ?? row.url,
+ row.url ?? row.relativeUrl,
row.keywordDifficulty,
]),
};
@@ -120,11 +120,7 @@ export function keywordsToTable(rows: KeywordRow[]): ExportTable {
export function pagesToTable(rows: PageRow[]): ExportTable {
return {
headers: ["Page", "Organic Traffic", "Keywords"],
- rows: rows.map((row) => [
- row.relativePath ?? row.page,
- row.organicTraffic,
- row.keywords,
- ]),
+ rows: rows.map((row) => [row.page, row.organicTraffic, row.keywords]),
};
}
diff --git a/src/client/lib/clipboard.test.ts b/src/client/lib/clipboard.test.ts
index 2813a9d..2047dad 100644
--- a/src/client/lib/clipboard.test.ts
+++ b/src/client/lib/clipboard.test.ts
@@ -58,6 +58,22 @@ describe("copyTableToClipboard", () => {
expect(written[0].html).toContain("
1234 | ");
});
+ it("rounds decimal numbers to at most two places", async () => {
+ const { written } = mockClipboard();
+ await copyTableToClipboard(["Traffic"], [[1250.321954]]);
+ expect(written[0].plain).toBe("Traffic\n1250.32");
+ expect(written[0].html).toContain("1250.32 | ");
+ });
+
+ it("emits URL cells as HTML links for spreadsheet paste", async () => {
+ const { written } = mockClipboard();
+ await copyTableToClipboard(["URL"], [["https://example.com/tools"]]);
+ expect(written[0].plain).toBe("URL\nhttps://example.com/tools");
+ expect(written[0].html).toContain(
+ 'https://example.com/tools | ',
+ );
+ });
+
it("sanitizes formula-injection cells with a leading apostrophe", async () => {
const { written } = mockClipboard();
await copyTableToClipboard(["Keyword"], [['=HYPERLINK("evil")']]);
diff --git a/src/client/lib/clipboard.ts b/src/client/lib/clipboard.ts
index 657162c..56058bd 100644
--- a/src/client/lib/clipboard.ts
+++ b/src/client/lib/clipboard.ts
@@ -1,4 +1,4 @@
-import { sanitizeCsvValue, type CsvValue } from "./csv";
+import { normalizeExportValue, type CsvValue, type ExportValue } from "./csv";
export const GOOGLE_SHEETS_NEW_URL = "https://sheets.new";
@@ -11,7 +11,7 @@ export async function copyTableToClipboard(
}
const safeRows = rows.map((row) =>
- row.map((value) => sanitizeCsvValue(value ?? "")),
+ row.map((value) => normalizeExportValue(value ?? "")),
);
const tsv = buildTsv(headers, safeRows);
@@ -27,10 +27,7 @@ export async function copyTableToClipboard(
]);
}
-function buildTsv(
- headers: string[],
- rows: (string | number | boolean)[][],
-): string {
+function buildTsv(headers: string[], rows: ExportValue[][]): string {
const lines = [headers.map(tsvCell).join("\t")];
for (const row of rows) {
lines.push(row.map(tsvCell).join("\t"));
@@ -38,15 +35,12 @@ function buildTsv(
return lines.join("\n");
}
-function tsvCell(value: string | number | boolean): string {
+function tsvCell(value: ExportValue): string {
if (typeof value !== "string") return String(value);
return value.replace(/[\t\r\n]+/g, " ");
}
-function buildHtmlTable(
- headers: string[],
- rows: (string | number | boolean)[][],
-): string {
+function buildHtmlTable(headers: string[], rows: ExportValue[][]): string {
const thead = `${headers.map((h) => `| ${escapeHtml(h)} | `).join("")}
`;
const tbody = `${rows
.map(
@@ -57,12 +51,25 @@ function buildHtmlTable(
return ``;
}
-function escapeHtmlCell(value: string | number | boolean): string {
+function escapeHtmlCell(value: ExportValue): string {
if (typeof value === "number" || typeof value === "boolean")
return String(value);
+ if (isLinkableUrl(value)) {
+ const safeValue = escapeHtml(value);
+ return `${safeValue}`;
+ }
return escapeHtml(value);
}
+function isLinkableUrl(value: string): boolean {
+ try {
+ const url = new URL(value);
+ return url.protocol === "http:" || url.protocol === "https:";
+ } catch {
+ return false;
+ }
+}
+
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&")
diff --git a/src/client/lib/csv.test.ts b/src/client/lib/csv.test.ts
new file mode 100644
index 0000000..6b1bf65
--- /dev/null
+++ b/src/client/lib/csv.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { buildCsv } from "./csv";
+
+describe("buildCsv", () => {
+ it("rounds decimal numbers to at most two places", () => {
+ const csv = buildCsv(
+ ["Page", "Traffic", "Keywords"],
+ [["/tools", 1250.321954, 4]],
+ );
+
+ expect(csv).toContain('"1250.32"');
+ expect(csv).toContain('"4"');
+ });
+
+ it("keeps formula-injection protection for string cells", () => {
+ const csv = buildCsv(["Value"], [['=HYPERLINK("evil")']]);
+
+ expect(csv).toContain('"\'=HYPERLINK(""evil"")"');
+ });
+});
diff --git a/src/client/lib/csv.ts b/src/client/lib/csv.ts
index 2a25a1d..7e1320c 100644
--- a/src/client/lib/csv.ts
+++ b/src/client/lib/csv.ts
@@ -2,9 +2,11 @@ import Papa from "papaparse";
export type CsvValue = string | number | boolean | null | undefined;
+export type ExportValue = string | number | boolean;
+
export function buildCsv(headers: string[], rows: CsvValue[][]): string {
const normalizedRows = rows.map((row) =>
- row.map((value) => sanitizeCsvValue(value ?? "")),
+ row.map((value) => normalizeExportValue(value ?? "")),
);
return Papa.unparse(
@@ -19,10 +21,21 @@ export function buildCsv(headers: string[], rows: CsvValue[][]): string {
);
}
+export function normalizeExportValue(value: CsvValue): ExportValue {
+ const normalized =
+ typeof value === "number" ? roundExportNumber(value) : value;
+ return sanitizeCsvValue(normalized ?? "");
+}
+
+function roundExportNumber(value: number): number {
+ if (!Number.isFinite(value)) return value;
+ return Math.round((value + Number.EPSILON) * 100) / 100;
+}
+
// Prevent CSV/TSV injection (formula injection) by prefixing dangerous
// characters with a single quote. See OWASP guidance:
// https://owasp.org/www-community/attacks/CSV_Injection
-export function sanitizeCsvValue(
+function sanitizeCsvValue(
value: string | number | boolean,
): string | number | boolean {
if (typeof value !== "string" || value.length === 0) {