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 `${thead}${tbody}
`; } -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) {