| 1 | export interface CsvMarkdownConversionOptions { |
| 2 | title: string |
| 3 | } |
| 4 | |
| 5 | const normalizeNewlines = (value: string): string => |
| 6 | value.replace(/\r\n/g, '\n').replace(/\r/g, '\n') |
| 7 | |
| 8 | const cleanCell = (value: string): string => |
| 9 | value.replace(/\s+/g, ' ').trim() |
| 10 | |
| 11 | const escapeMarkdownCell = (value: string): string => |
| 12 | cleanCell(value).replace(/\\/g, '\\\\').replace(/\|/g, '\\|') || ' ' |
| 13 | |
| 14 | const escapeHeadingText = (value: string): string => |
| 15 | cleanCell(value).replace(/#+/g, '').trim() || 'Untitled' |
| 16 | |
| 17 | const hasCjkText = (value: string): boolean => /[\u3400-\u9fff]/.test(value) |
| 18 | |
| 19 | const usesChineseLabels = (title: string, headers: string[]): boolean => |
| 20 | hasCjkText(`${title}\n${headers.join('\n')}`) |
| 21 | |
| 22 | const fieldListLabel = (useChinese: boolean): string => |
| 23 | useChinese ? '字段' : 'Fields' |
| 24 | |
| 25 | const rowCountLabel = (useChinese: boolean): string => |
| 26 | useChinese ? '记录数' : 'Rows' |
| 27 | |
| 28 | const ungroupedValue = (useChinese: boolean): string => |
| 29 | useChinese ? '未分组' : 'Ungrouped' |
| 30 | |
| 31 | const dataSectionTitle = (headers: string[], useChinese: boolean): string => { |
| 32 | const shortHeaders = headers.filter((header) => header.length > 0 && header.length <= 18) |
| 33 | if (shortHeaders.length >= 2 && shortHeaders.length <= 4 && shortHeaders.length === headers.length) { |
| 34 | return shortHeaders.join(useChinese ? '、' : ' / ') |
| 35 | } |
| 36 | return useChinese ? '数据明细' : 'Data Details' |
| 37 | } |
| 38 | |
| 39 | const groupedSectionTitle = (groupHeader: string, useChinese: boolean): string => |
| 40 | useChinese ? `按${escapeHeadingText(groupHeader)}拆分` : `By ${escapeHeadingText(groupHeader)}` |
| 41 | |
| 42 | const parseCsvRows = (source: string): string[][] => { |
| 43 | const rows: string[][] = [] |
| 44 | let row: string[] = [] |
| 45 | let cell = '' |
| 46 | let inQuotes = false |
| 47 | const text = normalizeNewlines(source) |
| 48 | |
| 49 | for (let index = 0; index < text.length; index += 1) { |
| 50 | const char = text[index] |
| 51 | const nextChar = text[index + 1] |
| 52 | if (char === '"') { |
| 53 | if (inQuotes && nextChar === '"') { |
| 54 | cell += '"' |
| 55 | index += 1 |
| 56 | } else { |
| 57 | inQuotes = !inQuotes |
| 58 | } |
| 59 | } else if (char === ',' && !inQuotes) { |
| 60 | row.push(cell) |
| 61 | cell = '' |
| 62 | } else if (char === '\n' && !inQuotes) { |
| 63 | row.push(cell) |
| 64 | rows.push(row) |
| 65 | row = [] |
| 66 | cell = '' |
| 67 | } else { |
| 68 | cell += char |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | row.push(cell) |
| 73 | rows.push(row) |
| 74 | |
| 75 | return rows |
| 76 | .map((cells) => cells.map(cleanCell)) |
| 77 | .filter((cells) => cells.some((value) => value.length > 0)) |
| 78 | } |
| 79 | |
| 80 | const normalizeRows = (rows: string[][]): { headers: string[]; dataRows: string[][] } => { |
| 81 | const [rawHeaders = [], ...rawDataRows] = rows |
| 82 | const columnCount = Math.max(rawHeaders.length, ...rawDataRows.map((row) => row.length), 1) |
| 83 | const headers = Array.from({ length: columnCount }, (_, index) => { |
| 84 | const header = cleanCell(rawHeaders[index] || '') |
| 85 | return header || `Column ${index + 1}` |
| 86 | }) |
| 87 | const dataRows = rawDataRows.map((row) => |
| 88 | Array.from({ length: columnCount }, (_, index) => cleanCell(row[index] || '')) |
| 89 | ) |
| 90 | return { headers, dataRows } |
| 91 | } |
| 92 | |
| 93 | const NUMERIC_VALUE_PATTERN = /^[-+]?[$¥€]?\s*\d+(?:,\d{3})*(?:\.\d+)?%?$/ |
| 94 | const DATE_LIKE_VALUE_PATTERN = /^\d{4}[-/年]\d{1,2}(?:[-/月]\d{1,2}日?)?$|^\d{1,2}[-/]\d{1,2}[-/]\d{2,4}$/ |
| 95 | |
| 96 | const mostlyMatches = (values: string[], pattern: RegExp): boolean => { |
| 97 | if (values.length === 0) return false |
| 98 | return values.filter((value) => pattern.test(value)).length / values.length >= 0.75 |
| 99 | } |
| 100 | |
| 101 | const selectGroupColumnIndex = (headers: string[], dataRows: string[][]): number | null => { |
| 102 | if (dataRows.length < 4) return null |
| 103 | let bestCandidate: { index: number; score: number } | null = null |
| 104 | |
| 105 | for (const [index] of headers.entries()) { |
| 106 | const values = dataRows.map((row) => row[index]).filter(Boolean) |
| 107 | const uniqueValues = new Set(values) |
| 108 | if (uniqueValues.size < 2) continue |
| 109 | if (uniqueValues.size > 50) continue |
| 110 | if (uniqueValues.size >= values.length * 0.75) continue |
| 111 | if (mostlyMatches(values, NUMERIC_VALUE_PATTERN)) continue |
| 112 | if (mostlyMatches(values, DATE_LIKE_VALUE_PATTERN)) continue |
| 113 | |
| 114 | const repeatRatio = 1 - uniqueValues.size / values.length |
| 115 | const coverageRatio = values.length / dataRows.length |
| 116 | const earlyColumnBonus = Math.max(0, (headers.length - index) / headers.length) * 0.05 |
| 117 | const score = repeatRatio + coverageRatio * 0.25 + earlyColumnBonus |
| 118 | if (!bestCandidate || score > bestCandidate.score) { |
| 119 | bestCandidate = { index, score } |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | return bestCandidate?.index ?? null |
| 124 | } |
| 125 | |
| 126 | const formatMarkdownTable = (headers: string[], rows: string[][]): string => { |
| 127 | const headerLine = `| ${headers.map(escapeMarkdownCell).join(' | ')} |` |
| 128 | const dividerLine = `| ${headers.map(() => '---').join(' | ')} |` |
| 129 | const rowLines = rows.map((row) => `| ${row.map(escapeMarkdownCell).join(' | ')} |`) |
| 130 | return [headerLine, dividerLine, ...rowLines].join('\n') |
| 131 | } |
| 132 | |
| 133 | export const convertCsvTextToMarkdown = ( |
| 134 | source: string, |
| 135 | options: CsvMarkdownConversionOptions |
| 136 | ): string => { |
| 137 | const rows = parseCsvRows(source) |
| 138 | const { headers, dataRows } = normalizeRows(rows) |
| 139 | const useChinese = usesChineseLabels(options.title, headers) |
| 140 | if (dataRows.length === 0) { |
| 141 | return [ |
| 142 | `# ${escapeHeadingText(options.title)}`, |
| 143 | '', |
| 144 | `## ${dataSectionTitle(headers, useChinese)}`, |
| 145 | '', |
| 146 | formatMarkdownTable(headers, []) |
| 147 | ].join('\n') |
| 148 | } |
| 149 | |
| 150 | const groupColumnIndex = selectGroupColumnIndex(headers, dataRows) |
| 151 | const lines = [ |
| 152 | `# ${escapeHeadingText(options.title)}`, |
| 153 | '', |
| 154 | `- ${fieldListLabel(useChinese)}:${headers.join(useChinese ? '、' : ', ')}`, |
| 155 | `- ${rowCountLabel(useChinese)}:${dataRows.length}` |
| 156 | ] |
| 157 | |
| 158 | if (groupColumnIndex === null) { |
| 159 | lines.push('', `## ${dataSectionTitle(headers, useChinese)}`, '', formatMarkdownTable(headers, dataRows)) |
| 160 | return lines.join('\n') |
| 161 | } |
| 162 | |
| 163 | const groupHeader = headers[groupColumnIndex] |
| 164 | const groupedRows = new Map<string, string[][]>() |
| 165 | dataRows.forEach((row) => { |
| 166 | const groupValue = row[groupColumnIndex] || ungroupedValue(useChinese) |
| 167 | const currentRows = groupedRows.get(groupValue) || [] |
| 168 | currentRows.push(row) |
| 169 | groupedRows.set(groupValue, currentRows) |
| 170 | }) |
| 171 | |
| 172 | lines.push('', `## ${groupedSectionTitle(groupHeader, useChinese)}`) |
| 173 | groupedRows.forEach((groupRows, groupValue) => { |
| 174 | lines.push('', `### ${escapeHeadingText(groupValue)}`, '') |
| 175 | lines.push(formatMarkdownTable(headers, groupRows)) |
| 176 | }) |
| 177 | |
| 178 | return lines.join('\n') |
| 179 | } |
| 180 |