175 lines
5.1 KiB
TypeScript
175 lines
5.1 KiB
TypeScript
import { type ParseSpreadsheetResult } from "./charts.types";
|
|
|
|
/**
|
|
* @private exported for testing
|
|
*/
|
|
export const tryParseNumber = (s: string): number | null => {
|
|
const match =
|
|
/^([-+]?)[$\u20AC\u00A3\u00A5\u20A9]?([-+]?)([\d.,]+)[%]?$/.exec(s);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
return parseFloat(`${(match[1] || match[2]) + match[3]}`.replace(/,/g, ""));
|
|
};
|
|
|
|
const isNumericColumn = (lines: string[][], columnIndex: number) =>
|
|
lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
|
|
|
|
/**
|
|
* @private exported for testing
|
|
*/
|
|
export const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
|
const numCols = cells[0].length;
|
|
|
|
if (numCols > 2) {
|
|
const hasHeader = cells[0].every((cell) => tryParseNumber(cell) === null);
|
|
const rows = hasHeader ? cells.slice(1) : cells;
|
|
|
|
if (rows.length < 1) {
|
|
return { ok: false, reason: "No data rows" };
|
|
}
|
|
|
|
const invalidNumericColumn = rows.some((row) =>
|
|
row.slice(1).some((value) => tryParseNumber(value) === null),
|
|
);
|
|
if (invalidNumericColumn) {
|
|
return { ok: false, reason: "Value is not numeric" };
|
|
}
|
|
|
|
// When there are more value columns than data rows, the data is in
|
|
// "wide" format — transpose so columns become labels (dimensions)
|
|
// and rows become series. This enables e.g. radar charts for wide data.
|
|
const numValueCols = numCols - 1;
|
|
if (numValueCols > rows.length) {
|
|
const labels = hasHeader ? cells[0].slice(1).map((h) => h.trim()) : null;
|
|
const series = rows.map((row) => ({
|
|
title: row[0]?.trim() || null,
|
|
values: row.slice(1).map((v) => tryParseNumber(v)!),
|
|
}));
|
|
const title =
|
|
series.length === 1
|
|
? series[0].title
|
|
: hasHeader
|
|
? cells[0][0].trim() || null
|
|
: null;
|
|
return {
|
|
ok: true,
|
|
data: { title, labels, series },
|
|
};
|
|
}
|
|
|
|
const series = cells[0].slice(1).map((seriesTitle, index) => {
|
|
const valueColumnIndex = index + 1;
|
|
const fallbackTitle = `Series ${valueColumnIndex}`;
|
|
return {
|
|
title: hasHeader ? seriesTitle.trim() || fallbackTitle : fallbackTitle,
|
|
values: rows.map((row) => tryParseNumber(row[valueColumnIndex])!),
|
|
};
|
|
});
|
|
|
|
return {
|
|
ok: true,
|
|
data: {
|
|
title: hasHeader ? cells[0][0].trim() || null : null,
|
|
labels: rows.map((row) => row[0]),
|
|
series,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (numCols === 1) {
|
|
if (!isNumericColumn(cells, 0)) {
|
|
return { ok: false, reason: "Value is not numeric" };
|
|
}
|
|
|
|
const hasHeader = tryParseNumber(cells[0][0]) === null;
|
|
const title = hasHeader ? cells[0][0] : null;
|
|
const values = (hasHeader ? cells.slice(1) : cells).map((line) =>
|
|
tryParseNumber(line[0]),
|
|
);
|
|
|
|
if (values.length < 2) {
|
|
return { ok: false, reason: "Less than two rows" };
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
data: {
|
|
title,
|
|
labels: null,
|
|
series: [{ title, values: values as number[] }],
|
|
},
|
|
};
|
|
}
|
|
|
|
const hasHeader = tryParseNumber(cells[0][1]) === null;
|
|
const rows = hasHeader ? cells.slice(1) : cells;
|
|
|
|
if (rows.length < 2) {
|
|
return { ok: false, reason: "Less than 2 rows" };
|
|
}
|
|
|
|
const invalidNumericColumn = rows.some(
|
|
(row) => tryParseNumber(row[1]) === null,
|
|
);
|
|
if (invalidNumericColumn) {
|
|
return { ok: false, reason: "Value is not numeric" };
|
|
}
|
|
|
|
const title = hasHeader ? cells[0][1] : null;
|
|
|
|
return {
|
|
ok: true,
|
|
data: {
|
|
title,
|
|
labels: rows.map((row) => row[0]),
|
|
series: [{ title, values: rows.map((row) => tryParseNumber(row[1])!) }],
|
|
},
|
|
};
|
|
};
|
|
|
|
export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
|
|
// Copy/paste from excel, spreadsheets, TSV, CSV, semicolon-separated.
|
|
const parseDelimitedLines = (delimiter: "\t" | "," | ";") =>
|
|
text
|
|
.replace(/\r\n?/g, "\n")
|
|
.split("\n")
|
|
.filter((line) => line.trim().length > 0)
|
|
.map((line) => line.split(delimiter).map((cell) => cell.trim()));
|
|
|
|
// Score each delimiter: prefer consistent column counts with the most columns.
|
|
// A delimiter that produces all single-column rows likely isn't the right one.
|
|
const candidates = (["\t", ",", ";"] as const).map((delimiter) => {
|
|
const parsed = parseDelimitedLines(delimiter);
|
|
const numCols = parsed[0]?.length ?? 0;
|
|
const isConsistent =
|
|
parsed.length > 0 && parsed.every((line) => line.length === numCols);
|
|
return { delimiter, parsed, numCols, isConsistent };
|
|
});
|
|
|
|
// Prefer: consistent + most columns. Among ties, tab > comma > semicolon
|
|
// (the array order already encodes this priority).
|
|
const best =
|
|
candidates.find((c) => c.isConsistent && c.numCols > 1) ??
|
|
candidates.find((c) => c.isConsistent) ??
|
|
candidates[0];
|
|
|
|
const lines = best.parsed;
|
|
|
|
if (lines.length === 0) {
|
|
return { ok: false, reason: "No values" };
|
|
}
|
|
|
|
const numColsFirstLine = lines[0].length;
|
|
const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine);
|
|
|
|
if (!isSpreadsheet) {
|
|
return {
|
|
ok: false,
|
|
reason: "All rows don't have same number of columns",
|
|
};
|
|
}
|
|
|
|
return tryParseCells(lines);
|
|
};
|