@tooee/renderers 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/dist/CodeView.d.ts +14 -0
- package/dist/CodeView.d.ts.map +1 -0
- package/dist/CodeView.js +69 -0
- package/dist/CodeView.js.map +1 -0
- package/dist/CommandPalette.d.ts +15 -0
- package/dist/CommandPalette.d.ts.map +1 -0
- package/dist/CommandPalette.js +73 -0
- package/dist/CommandPalette.js.map +1 -0
- package/dist/ImageView.d.ts +15 -0
- package/dist/ImageView.d.ts.map +1 -0
- package/dist/ImageView.js +115 -0
- package/dist/ImageView.js.map +1 -0
- package/dist/MarkdownView.d.ts +15 -0
- package/dist/MarkdownView.d.ts.map +1 -0
- package/dist/MarkdownView.js +219 -0
- package/dist/MarkdownView.js.map +1 -0
- package/dist/Table.d.ts +35 -0
- package/dist/Table.d.ts.map +1 -0
- package/dist/Table.js +172 -0
- package/dist/Table.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/parsers.d.ts +22 -0
- package/dist/parsers.d.ts.map +1 -0
- package/dist/parsers.js +143 -0
- package/dist/parsers.js.map +1 -0
- package/dist/table-types.d.ts +7 -0
- package/dist/table-types.d.ts.map +1 -0
- package/dist/table-types.js +2 -0
- package/dist/table-types.js.map +1 -0
- package/package.json +45 -0
- package/src/CodeView.tsx +117 -0
- package/src/CommandPalette.tsx +137 -0
- package/src/ImageView.tsx +183 -0
- package/src/MarkdownView.tsx +379 -0
- package/src/Table.tsx +263 -0
- package/src/index.ts +19 -0
- package/src/parsers.ts +146 -0
- package/src/table-types.ts +7 -0
package/src/Table.tsx
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { useTerminalDimensions } from "@opentui/react"
|
|
2
|
+
import { useTheme } from "@tooee/themes"
|
|
3
|
+
import type { ColumnDef, TableRow } from "./table-types.js"
|
|
4
|
+
|
|
5
|
+
export interface TableProps {
|
|
6
|
+
columns: ColumnDef[]
|
|
7
|
+
rows: TableRow[]
|
|
8
|
+
/** Maximum width for the table. If not provided, uses terminal width. */
|
|
9
|
+
maxWidth?: number
|
|
10
|
+
/** Minimum width for any column (default: 4) */
|
|
11
|
+
minColumnWidth?: number
|
|
12
|
+
/** Maximum width for any column (default: 50) */
|
|
13
|
+
maxColumnWidth?: number
|
|
14
|
+
/** Number of rows to sample for width calculation (default: 100) */
|
|
15
|
+
sampleSize?: number
|
|
16
|
+
cursor?: number
|
|
17
|
+
selectionStart?: number
|
|
18
|
+
selectionEnd?: number
|
|
19
|
+
matchingRows?: Set<number>
|
|
20
|
+
currentMatchRow?: number
|
|
21
|
+
toggledRows?: Set<number>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const PADDING = 1
|
|
25
|
+
const THRESHOLD = 20
|
|
26
|
+
const ELLIPSIS = "…"
|
|
27
|
+
const DEFAULT_MIN_COL_WIDTH = 4
|
|
28
|
+
const DEFAULT_MAX_COL_WIDTH = 50
|
|
29
|
+
const DEFAULT_SAMPLE_SIZE = 100
|
|
30
|
+
|
|
31
|
+
function isNumeric(value: string): boolean {
|
|
32
|
+
return /^\s*-?[\d,]+\.?\d*\s*$/.test(value)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ColumnWidthOptions {
|
|
36
|
+
minColumnWidth: number
|
|
37
|
+
maxColumnWidth: number
|
|
38
|
+
sampleSize: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sampleRows(rows: string[][], sampleSize: number): string[][] {
|
|
42
|
+
if (rows.length <= sampleSize) return rows
|
|
43
|
+
// Sample evenly distributed rows for representative widths
|
|
44
|
+
const step = rows.length / sampleSize
|
|
45
|
+
const sampled: string[][] = []
|
|
46
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
47
|
+
sampled.push(rows[Math.floor(i * step)])
|
|
48
|
+
}
|
|
49
|
+
return sampled
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function computeColumnWidths(
|
|
53
|
+
headers: string[],
|
|
54
|
+
rows: string[][],
|
|
55
|
+
maxWidth: number,
|
|
56
|
+
options: ColumnWidthOptions,
|
|
57
|
+
): number[] {
|
|
58
|
+
const { minColumnWidth, maxColumnWidth, sampleSize } = options
|
|
59
|
+
const colCount = headers.length
|
|
60
|
+
|
|
61
|
+
// Sample rows for performance on large tables
|
|
62
|
+
const sampledRows = sampleRows(rows, sampleSize)
|
|
63
|
+
|
|
64
|
+
// Calculate natural width for each column (header + content + padding)
|
|
65
|
+
const naturalWidths = headers.map((header, col) => {
|
|
66
|
+
const headerLen = header.length
|
|
67
|
+
const maxRowLen = sampledRows.reduce((max, row) => Math.max(max, (row[col] ?? "").length), 0)
|
|
68
|
+
const contentWidth = Math.max(headerLen, maxRowLen)
|
|
69
|
+
// Apply min/max constraints before adding padding
|
|
70
|
+
const constrainedWidth = Math.min(maxColumnWidth, Math.max(minColumnWidth, contentWidth))
|
|
71
|
+
return constrainedWidth + PADDING * 2
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// 1 for each column border + 1 for trailing border
|
|
75
|
+
const borderOverhead = colCount + 1
|
|
76
|
+
const totalNatural = naturalWidths.reduce((a, b) => a + b, 0) + borderOverhead
|
|
77
|
+
|
|
78
|
+
// If everything fits, use natural widths
|
|
79
|
+
if (totalNatural <= maxWidth) {
|
|
80
|
+
return naturalWidths
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const available = maxWidth - borderOverhead
|
|
84
|
+
const minColWidthWithPadding = minColumnWidth + PADDING * 2
|
|
85
|
+
|
|
86
|
+
// Extreme case: not even minimum widths fit
|
|
87
|
+
if (available <= colCount * minColWidthWithPadding) {
|
|
88
|
+
return naturalWidths.map(() =>
|
|
89
|
+
Math.max(minColWidthWithPadding, Math.floor(available / colCount)),
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Give compact columns their natural width, distribute rest proportionally
|
|
94
|
+
const compact: boolean[] = naturalWidths.map((w) => w <= THRESHOLD)
|
|
95
|
+
const compactTotal = naturalWidths.reduce((sum, w, i) => sum + (compact[i] ? w : 0), 0)
|
|
96
|
+
const remaining = available - compactTotal
|
|
97
|
+
const longTotal = naturalWidths.reduce((sum, w, i) => sum + (compact[i] ? 0 : w), 0)
|
|
98
|
+
|
|
99
|
+
if (longTotal === 0 || remaining <= 0) {
|
|
100
|
+
// All compact or no space left — distribute evenly
|
|
101
|
+
const total = naturalWidths.reduce((a, b) => a + b, 0)
|
|
102
|
+
return naturalWidths.map((w) =>
|
|
103
|
+
Math.max(minColWidthWithPadding, Math.floor((w / total) * available)),
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return naturalWidths.map((w, i) => {
|
|
108
|
+
if (compact[i]) return w
|
|
109
|
+
return Math.max(minColWidthWithPadding, Math.floor((w / longTotal) * remaining))
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function truncate(text: string, width: number): string {
|
|
114
|
+
const contentWidth = width - PADDING * 2
|
|
115
|
+
if (contentWidth <= 0) return ""
|
|
116
|
+
if (text.length <= contentWidth) return text
|
|
117
|
+
if (contentWidth <= 1) return ELLIPSIS.slice(0, contentWidth)
|
|
118
|
+
return text.slice(0, contentWidth - 1) + ELLIPSIS
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function padCell(text: string, width: number, rightAlign: boolean): string {
|
|
122
|
+
const contentWidth = width - PADDING * 2
|
|
123
|
+
const truncated = truncate(text, width)
|
|
124
|
+
const padLeft = " ".repeat(PADDING)
|
|
125
|
+
const padRight = " ".repeat(PADDING)
|
|
126
|
+
if (rightAlign) {
|
|
127
|
+
const space = contentWidth - truncated.length
|
|
128
|
+
return padLeft + " ".repeat(Math.max(0, space)) + truncated + padRight
|
|
129
|
+
}
|
|
130
|
+
const space = contentWidth - truncated.length
|
|
131
|
+
return padLeft + truncated + " ".repeat(Math.max(0, space)) + padRight
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function buildBorderLine(
|
|
135
|
+
widths: number[],
|
|
136
|
+
left: string,
|
|
137
|
+
mid: string,
|
|
138
|
+
right: string,
|
|
139
|
+
fill: string,
|
|
140
|
+
): string {
|
|
141
|
+
return left + widths.map((w) => fill.repeat(w)).join(mid) + right
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function buildDataLine(cells: string[], widths: number[], alignments: boolean[]): string {
|
|
145
|
+
const parts = cells.map((cell, i) => padCell(cell, widths[i], alignments[i]))
|
|
146
|
+
return "│" + parts.join("│") + "│"
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function formatCellValue(value: unknown): string {
|
|
150
|
+
if (value == null) return ""
|
|
151
|
+
if (typeof value === "string") return value
|
|
152
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
|
153
|
+
if (value instanceof Date) return value.toISOString()
|
|
154
|
+
try {
|
|
155
|
+
return JSON.stringify(value)
|
|
156
|
+
} catch {
|
|
157
|
+
return String(value)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function Table({
|
|
162
|
+
columns,
|
|
163
|
+
rows,
|
|
164
|
+
maxWidth,
|
|
165
|
+
minColumnWidth = DEFAULT_MIN_COL_WIDTH,
|
|
166
|
+
maxColumnWidth = DEFAULT_MAX_COL_WIDTH,
|
|
167
|
+
sampleSize = DEFAULT_SAMPLE_SIZE,
|
|
168
|
+
cursor,
|
|
169
|
+
selectionStart,
|
|
170
|
+
selectionEnd,
|
|
171
|
+
matchingRows,
|
|
172
|
+
currentMatchRow,
|
|
173
|
+
toggledRows,
|
|
174
|
+
}: TableProps) {
|
|
175
|
+
const { theme } = useTheme()
|
|
176
|
+
const { width: terminalWidth } = useTerminalDimensions()
|
|
177
|
+
|
|
178
|
+
// Use terminal width minus margins (1 on each side) if maxWidth not provided
|
|
179
|
+
const effectiveMaxWidth = maxWidth ?? terminalWidth - 2
|
|
180
|
+
|
|
181
|
+
const headers = columns.map((column) => column.header ?? column.key)
|
|
182
|
+
const normalizedRows = rows.map((row) =>
|
|
183
|
+
columns.map((column) => formatCellValue(row[column.key])),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
const colWidths = computeColumnWidths(headers, normalizedRows, effectiveMaxWidth, {
|
|
187
|
+
minColumnWidth,
|
|
188
|
+
maxColumnWidth,
|
|
189
|
+
sampleSize,
|
|
190
|
+
})
|
|
191
|
+
const alignments = columns.map((column, colIdx) => {
|
|
192
|
+
if (column.align === "right") return true
|
|
193
|
+
if (column.align === "left") return false
|
|
194
|
+
const sampleValues = normalizedRows.slice(0, 10).map((row) => row[colIdx] ?? "")
|
|
195
|
+
const numericCount = sampleValues.filter(isNumeric).length
|
|
196
|
+
return numericCount > sampleValues.length / 2
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
const topBorder = buildBorderLine(colWidths, "┌", "┬", "┐", "─")
|
|
200
|
+
const headerSep = buildBorderLine(colWidths, "├", "┼", "┤", "─")
|
|
201
|
+
const bottomBorder = buildBorderLine(colWidths, "└", "┴", "┘", "─")
|
|
202
|
+
|
|
203
|
+
const headerLine = buildDataLine(headers, colWidths, alignments)
|
|
204
|
+
const dataLines = normalizedRows.map((row) => buildDataLine(row, colWidths, alignments))
|
|
205
|
+
|
|
206
|
+
const getRowStyle = (rowIndex: number): { fg?: string; bg?: string } => {
|
|
207
|
+
const isCursor = cursor === rowIndex
|
|
208
|
+
const isSelected =
|
|
209
|
+
selectionStart != null &&
|
|
210
|
+
selectionEnd != null &&
|
|
211
|
+
rowIndex >= selectionStart &&
|
|
212
|
+
rowIndex <= selectionEnd
|
|
213
|
+
const isMatch = matchingRows?.has(rowIndex)
|
|
214
|
+
const isCurrentMatch = currentMatchRow === rowIndex
|
|
215
|
+
const isToggled = toggledRows?.has(rowIndex)
|
|
216
|
+
|
|
217
|
+
let bg: string | undefined
|
|
218
|
+
let fg: string | undefined = theme.text
|
|
219
|
+
|
|
220
|
+
// Determine background: selection < cursor (cursor overwrites)
|
|
221
|
+
if (isSelected) {
|
|
222
|
+
bg = theme.selection
|
|
223
|
+
}
|
|
224
|
+
if (isToggled && !isCursor && !isSelected) {
|
|
225
|
+
bg = theme.backgroundPanel
|
|
226
|
+
}
|
|
227
|
+
if (isCursor) {
|
|
228
|
+
bg = theme.cursorLine
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Highlight match indicator on matching rows
|
|
232
|
+
if (isMatch && !isCursor) {
|
|
233
|
+
fg = isCurrentMatch ? theme.primary : theme.warning
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return { fg, bg }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<box style={{ flexDirection: "column", marginLeft: 1, marginRight: 1, marginBottom: 1 }}>
|
|
241
|
+
<text content={topBorder} fg={theme.border} />
|
|
242
|
+
<text content={headerLine} fg={theme.primary} />
|
|
243
|
+
<text content={headerSep} fg={theme.border} />
|
|
244
|
+
{dataLines.map((line, i) => {
|
|
245
|
+
const style = getRowStyle(i)
|
|
246
|
+
return <text key={i} content={line} fg={style.fg} bg={style.bg} />
|
|
247
|
+
})}
|
|
248
|
+
<text content={bottomBorder} fg={theme.border} />
|
|
249
|
+
</box>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Exported for testing
|
|
254
|
+
export {
|
|
255
|
+
computeColumnWidths,
|
|
256
|
+
truncate,
|
|
257
|
+
padCell,
|
|
258
|
+
isNumeric,
|
|
259
|
+
buildBorderLine,
|
|
260
|
+
buildDataLine,
|
|
261
|
+
sampleRows,
|
|
262
|
+
}
|
|
263
|
+
export type { ColumnWidthOptions }
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { MarkdownView } from "./MarkdownView.jsx"
|
|
2
|
+
export { CodeView } from "./CodeView.jsx"
|
|
3
|
+
export { ImageView } from "./ImageView.jsx"
|
|
4
|
+
export {
|
|
5
|
+
Table,
|
|
6
|
+
computeColumnWidths,
|
|
7
|
+
truncate,
|
|
8
|
+
padCell,
|
|
9
|
+
isNumeric,
|
|
10
|
+
buildBorderLine,
|
|
11
|
+
buildDataLine,
|
|
12
|
+
sampleRows,
|
|
13
|
+
} from "./Table.jsx"
|
|
14
|
+
export type { TableProps, ColumnWidthOptions } from "./Table.jsx"
|
|
15
|
+
export type { ColumnDef, TableRow } from "./table-types.js"
|
|
16
|
+
export { CommandPalette } from "./CommandPalette.jsx"
|
|
17
|
+
export type { CommandPaletteEntry } from "./CommandPalette.jsx"
|
|
18
|
+
export { parseCSV, parseTSV, parseJSON, parseAuto, detectFormat } from "./parsers.js"
|
|
19
|
+
export type { Format, ParsedTable } from "./parsers.js"
|
package/src/parsers.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { ColumnDef, TableRow } from "./table-types.js"
|
|
2
|
+
|
|
3
|
+
export interface ParsedTable {
|
|
4
|
+
columns: ColumnDef[]
|
|
5
|
+
rows: TableRow[]
|
|
6
|
+
format: Format
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function parseCSV(input: string): { columns: ColumnDef[]; rows: TableRow[] } {
|
|
10
|
+
const lines = splitLines(input)
|
|
11
|
+
if (lines.length === 0) return { columns: [], rows: [] }
|
|
12
|
+
const columns = createColumnDefs(parseCSVLine(lines[0]))
|
|
13
|
+
const rows = buildRows(columns, lines.slice(1).map(parseCSVLine))
|
|
14
|
+
return { columns, rows }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseCSVLine(line: string): string[] {
|
|
18
|
+
const fields: string[] = []
|
|
19
|
+
let i = 0
|
|
20
|
+
while (i < line.length) {
|
|
21
|
+
if (line[i] === '"') {
|
|
22
|
+
// Quoted field
|
|
23
|
+
i++
|
|
24
|
+
let field = ""
|
|
25
|
+
while (i < line.length) {
|
|
26
|
+
if (line[i] === '"') {
|
|
27
|
+
if (i + 1 < line.length && line[i + 1] === '"') {
|
|
28
|
+
field += '"'
|
|
29
|
+
i += 2
|
|
30
|
+
} else {
|
|
31
|
+
i++ // closing quote
|
|
32
|
+
break
|
|
33
|
+
}
|
|
34
|
+
} else {
|
|
35
|
+
field += line[i]
|
|
36
|
+
i++
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
fields.push(field)
|
|
40
|
+
if (i < line.length && line[i] === ",") i++ // skip comma
|
|
41
|
+
} else {
|
|
42
|
+
const nextComma = line.indexOf(",", i)
|
|
43
|
+
if (nextComma === -1) {
|
|
44
|
+
fields.push(line.slice(i))
|
|
45
|
+
break
|
|
46
|
+
} else {
|
|
47
|
+
fields.push(line.slice(i, nextComma))
|
|
48
|
+
i = nextComma + 1
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return fields
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function parseTSV(input: string): { columns: ColumnDef[]; rows: TableRow[] } {
|
|
56
|
+
const lines = splitLines(input)
|
|
57
|
+
if (lines.length === 0) return { columns: [], rows: [] }
|
|
58
|
+
const columns = createColumnDefs(lines[0].split("\t"))
|
|
59
|
+
const rows = buildRows(columns, lines.slice(1).map((line) => line.split("\t")))
|
|
60
|
+
return { columns, rows }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function parseJSON(input: string): { columns: ColumnDef[]; rows: TableRow[] } {
|
|
64
|
+
const data = JSON.parse(input)
|
|
65
|
+
if (!Array.isArray(data) || data.length === 0) return { columns: [], rows: [] }
|
|
66
|
+
const keys = Array.from(
|
|
67
|
+
new Set(data.flatMap((item: Record<string, unknown>) => Object.keys(item))),
|
|
68
|
+
)
|
|
69
|
+
const columns: ColumnDef[] = keys.map((key) => ({ key, header: key }))
|
|
70
|
+
const rows = data.map((item: Record<string, unknown>) => {
|
|
71
|
+
const row: TableRow = {}
|
|
72
|
+
for (const column of columns) {
|
|
73
|
+
row[column.key] = item[column.key] ?? ""
|
|
74
|
+
}
|
|
75
|
+
return row
|
|
76
|
+
})
|
|
77
|
+
return { columns, rows }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type Format = "csv" | "tsv" | "json" | "unknown"
|
|
81
|
+
|
|
82
|
+
export function detectFormat(input: string): Format {
|
|
83
|
+
const trimmed = input.trimStart()
|
|
84
|
+
if (trimmed.startsWith("[")) {
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(trimmed)
|
|
87
|
+
if (Array.isArray(parsed)) return "json"
|
|
88
|
+
} catch {}
|
|
89
|
+
}
|
|
90
|
+
const firstLine = input.split("\n")[0] ?? ""
|
|
91
|
+
if (firstLine.includes("\t")) return "tsv"
|
|
92
|
+
if (firstLine.includes(",")) return "csv"
|
|
93
|
+
return "unknown"
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function parseAuto(input: string): ParsedTable {
|
|
97
|
+
const format = detectFormat(input)
|
|
98
|
+
let columns: ColumnDef[]
|
|
99
|
+
let rows: TableRow[]
|
|
100
|
+
switch (format) {
|
|
101
|
+
case "csv":
|
|
102
|
+
;({ columns, rows } = parseCSV(input))
|
|
103
|
+
break
|
|
104
|
+
case "tsv":
|
|
105
|
+
;({ columns, rows } = parseTSV(input))
|
|
106
|
+
break
|
|
107
|
+
case "json":
|
|
108
|
+
;({ columns, rows } = parseJSON(input))
|
|
109
|
+
break
|
|
110
|
+
default:
|
|
111
|
+
// Fall back to CSV
|
|
112
|
+
;({ columns, rows } = parseCSV(input))
|
|
113
|
+
break
|
|
114
|
+
}
|
|
115
|
+
return { columns, rows, format }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function splitLines(input: string): string[] {
|
|
119
|
+
return input.split("\n").filter((line) => line.trim().length > 0)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function createColumnDefs(rawHeaders: string[]): ColumnDef[] {
|
|
123
|
+
const seen = new Map<string, number>()
|
|
124
|
+
return rawHeaders.map((header, index) => {
|
|
125
|
+
const trimmed = header.trim()
|
|
126
|
+
const fallback = `column_${index + 1}`
|
|
127
|
+
const base = trimmed === "" ? fallback : trimmed
|
|
128
|
+
const count = seen.get(base) ?? 0
|
|
129
|
+
seen.set(base, count + 1)
|
|
130
|
+
const key = count === 0 ? base : `${base}_${count + 1}`
|
|
131
|
+
return {
|
|
132
|
+
key,
|
|
133
|
+
header: trimmed || undefined,
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function buildRows(columns: ColumnDef[], rawRows: string[][]): TableRow[] {
|
|
139
|
+
return rawRows.map((row) => {
|
|
140
|
+
const record: TableRow = {}
|
|
141
|
+
columns.forEach((column, index) => {
|
|
142
|
+
record[column.key] = row[index] ?? ""
|
|
143
|
+
})
|
|
144
|
+
return record
|
|
145
|
+
})
|
|
146
|
+
}
|