@thecodecodex/tools 0.0.0-stage → 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.
@@ -0,0 +1,435 @@
1
+ import { inferValue, uniqueKeys, type Decimal, type Scalar, type Table } from "./model"
2
+ import { toGo, toPandas, toPhp, toPython, toTypeScript } from "./code"
3
+ import { DELIMITERS, type Delimiter } from "./parse"
4
+
5
+ // Formatos agrupados como aparecem na tela
6
+ export const OUTPUT_GROUPS = {
7
+ data: ["csv", "xlsx", "json", "jsonl", "sql", "yaml", "toml", "ini", "xml"],
8
+ docs: ["markdown", "html", "ascii", "latex", "jira", "mediawiki", "asciidoc", "rst"],
9
+ code: ["python", "pandas", "typescript", "go", "php"],
10
+ } as const
11
+ export type OutputGroup = keyof typeof OUTPUT_GROUPS
12
+ export const OUTPUT_FORMATS = [...OUTPUT_GROUPS.data, ...OUTPUT_GROUPS.docs, ...OUTPUT_GROUPS.code] as const
13
+ export type OutputFormat = (typeof OUTPUT_FORMATS)[number]
14
+ export type TextFormat = Exclude<OutputFormat, "xlsx">
15
+
16
+ export const JSON_SHAPES = ["objects", "arrays", "columns", "keyed"] as const
17
+ export type JsonShape = (typeof JSON_SHAPES)[number]
18
+ export const INDENTS = ["2", "4", "tab", "min"] as const
19
+ export type Indent = (typeof INDENTS)[number]
20
+ export const SQL_DIALECTS = ["mysql", "postgres", "sqlite", "mssql"] as const
21
+ export type SqlDialect = (typeof SQL_DIALECTS)[number]
22
+ export const ALIGNS = ["none", "left", "center", "right"] as const
23
+ export type Align = (typeof ALIGNS)[number]
24
+ export const ASCII_STYLES = ["unicode", "ascii"] as const
25
+ export type AsciiStyle = (typeof ASCII_STYLES)[number]
26
+
27
+ export interface SerializeOptions {
28
+ // a primeira linha da saída é o cabeçalho (CSV, ASCII, LaTeX, HTML); formatos com chaves sempre usam os nomes
29
+ header?: boolean
30
+ // números, true/false e null viram tipos de verdade (JSON, YAML, SQL)
31
+ types?: boolean
32
+ emptyAsNull?: boolean
33
+ decimal?: Decimal
34
+ outputDelimiter?: Delimiter
35
+ jsonShape?: JsonShape
36
+ indent?: Indent
37
+ tableName?: string
38
+ dialect?: SqlDialect
39
+ multiRow?: boolean
40
+ createTable?: boolean
41
+ align?: Align
42
+ pretty?: boolean
43
+ // nome da lista/elemento raiz (XML, TOML) ou do tipo (Go, TypeScript)
44
+ xmlRoot?: string
45
+ xmlRow?: string
46
+ asciiStyle?: AsciiStyle
47
+ // nome do tipo gerado (Go, TypeScript)
48
+ typeName?: string
49
+ }
50
+
51
+ export const EXTENSIONS: Record<OutputFormat, { ext: string, mime: string }> = {
52
+ csv: { ext: "csv", mime: "text/csv" },
53
+ json: { ext: "json", mime: "application/json" },
54
+ jsonl: { ext: "jsonl", mime: "application/jsonl" },
55
+ markdown: { ext: "md", mime: "text/markdown" },
56
+ html: { ext: "html", mime: "text/html" },
57
+ sql: { ext: "sql", mime: "application/sql" },
58
+ yaml: { ext: "yaml", mime: "application/yaml" },
59
+ xml: { ext: "xml", mime: "application/xml" },
60
+ ascii: { ext: "txt", mime: "text/plain" },
61
+ latex: { ext: "tex", mime: "application/x-tex" },
62
+ xlsx: { ext: "xlsx", mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" },
63
+ toml: { ext: "toml", mime: "application/toml" },
64
+ ini: { ext: "ini", mime: "text/plain" },
65
+ jira: { ext: "txt", mime: "text/plain" },
66
+ mediawiki: { ext: "wiki", mime: "text/plain" },
67
+ asciidoc: { ext: "adoc", mime: "text/asciidoc" },
68
+ rst: { ext: "rst", mime: "text/x-rst" },
69
+ python: { ext: "py", mime: "text/x-python" },
70
+ pandas: { ext: "py", mime: "text/x-python" },
71
+ typescript: { ext: "ts", mime: "text/typescript" },
72
+ go: { ext: "go", mime: "text/x-go" },
73
+ php: { ext: "php", mime: "application/x-php" },
74
+ }
75
+
76
+ // Largura visível (emojis e acentos combinados contam 1; CJK e emoji ocupam 2 colunas no terminal)
77
+ export function displayWidth(text: string): number {
78
+ let width = 0
79
+ for (const ch of text.replace(/\p{M}/gu, "")) {
80
+ const code = ch.codePointAt(0) ?? 0
81
+ width += (code >= 0x1100 && code <= 0x115f) || (code >= 0x2e80 && code <= 0xa4cf) || (code >= 0xac00 && code <= 0xd7a3) || (code >= 0xf900 && code <= 0xfaff) || (code >= 0xff00 && code <= 0xff60) || (code >= 0x1f300 && code <= 0x1faff) ? 2 : 1
82
+ }
83
+ return width
84
+ }
85
+
86
+ const pad = (text: string, width: number, align: Align = "left") => {
87
+ const gap = Math.max(0, width - displayWidth(text))
88
+ if (align === "right") return " ".repeat(gap) + text
89
+ if (align === "center") return " ".repeat(Math.floor(gap / 2)) + text + " ".repeat(Math.ceil(gap / 2))
90
+ return text + " ".repeat(gap)
91
+ }
92
+
93
+ export type ColumnKind = "number" | "integer" | "boolean" | "text"
94
+
95
+ // Tipos decididos por coluna, não por célula: se um valor da coluna não é número (ex.: "8500,50" com
96
+ // decimal "dot"), a coluna inteira fica como texto, para o JSON/SQL não misturar 7200 com "8500,50".
97
+ // Vazios e "null" não decidem o tipo.
98
+ export function columnKinds(t: Table, o: SerializeOptions = {}): ColumnKind[] {
99
+ return t.columns.map((_, i) => {
100
+ if (!(o.types ?? true)) return "text"
101
+ const values = t.rows.map((r) => r[i] ?? "").filter((c) => c !== "" && c.toLowerCase() !== "null").map((c) => inferValue(c, false, o.decimal))
102
+ if (values.length && values.every((v) => typeof v === "number")) return values.every((v) => Number.isInteger(v)) ? "integer" : "number"
103
+ if (values.length && values.every((v) => typeof v === "boolean")) return "boolean"
104
+ return "text"
105
+ })
106
+ }
107
+
108
+ export function typedRows(t: Table, o: SerializeOptions = {}, kinds = columnKinds(t, o)): Scalar[][] {
109
+ return t.rows.map((r) =>
110
+ t.columns.map((_, i) => {
111
+ const cell = r[i] ?? ""
112
+ if (cell === "") return o.emptyAsNull ? null : kinds[i] === "text" ? "" : null
113
+ if (kinds[i] === "text") return cell
114
+ return cell.toLowerCase() === "null" ? null : inferValue(cell, false, o.decimal)
115
+ }),
116
+ )
117
+ }
118
+
119
+ const indentOf = (indent: Indent = "2") => (indent === "tab" ? "\t" : indent === "min" ? undefined : Number(indent))
120
+ const records = (t: Table, o: SerializeOptions) => {
121
+ const keys = uniqueKeys(t.columns)
122
+ return typedRows(t, o).map((r) => Object.fromEntries(keys.map((k, i) => [k, r[i] ?? null])))
123
+ }
124
+
125
+ // ---------- texto delimitado ----------
126
+
127
+ export function toCsv(t: Table, o: SerializeOptions = {}): string {
128
+ const delimiter = DELIMITERS[o.outputDelimiter ?? "comma"]
129
+ const quote = (cell: string) => (cell.includes(delimiter) || /["\r\n]/.test(cell) || /^\s|\s$/.test(cell) ? `"${cell.replace(/"/g, '""')}"` : cell)
130
+ const lines = (o.header ?? true ? [t.columns, ...t.rows] : t.rows).map((r) => r.map(quote).join(delimiter))
131
+ return lines.join("\n")
132
+ }
133
+
134
+ // ---------- JSON ----------
135
+
136
+ export function toJson(t: Table, o: SerializeOptions = {}): string {
137
+ const space = indentOf(o.indent)
138
+ const keys = uniqueKeys(t.columns)
139
+ let data: unknown
140
+ switch (o.jsonShape ?? "objects") {
141
+ case "objects":
142
+ data = records(t, o)
143
+ break
144
+ case "arrays":
145
+ data = [t.columns, ...typedRows(t, o)]
146
+ break
147
+ case "columns": {
148
+ const rows = typedRows(t, o)
149
+ data = Object.fromEntries(keys.map((k, i) => [k, rows.map((r) => r[i] ?? null)]))
150
+ break
151
+ }
152
+ case "keyed": {
153
+ // primeira coluna vira a chave do objeto
154
+ const rows = typedRows(t, o)
155
+ data = Object.fromEntries(t.rows.map((r, n) => [r[0] ?? "", Object.fromEntries(keys.slice(1).map((k, i) => [k, rows[n]?.[i + 1] ?? null]))]))
156
+ break
157
+ }
158
+ }
159
+ return JSON.stringify(data, null, space)
160
+ }
161
+
162
+ export const toJsonLines = (t: Table, o: SerializeOptions = {}) => records(t, o).map((r) => JSON.stringify(r)).join("\n")
163
+
164
+ // ---------- Markdown ----------
165
+
166
+ export function toMarkdown(t: Table, o: SerializeOptions = {}): string {
167
+ const escape = (cell: string) => cell.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>")
168
+ const head = t.columns.map(escape)
169
+ const body = t.rows.map((r) => r.map(escape))
170
+ const align = o.align ?? "none"
171
+ const pretty = o.pretty ?? true
172
+ const widths = head.map((h, i) => Math.max(3, displayWidth(h), ...body.map((r) => displayWidth(r[i] ?? ""))))
173
+ const rule = (w: number) => {
174
+ const dashes = pretty ? w : 3
175
+ if (align === "left") return `:${"-".repeat(dashes - 1)}`
176
+ if (align === "right") return `${"-".repeat(dashes - 1)}:`
177
+ if (align === "center") return `:${"-".repeat(Math.max(1, dashes - 2))}:`
178
+ return "-".repeat(dashes)
179
+ }
180
+ const line = (cells: string[]) => `| ${cells.map((c, i) => (pretty ? pad(c, widths[i] ?? 0, align === "none" ? "left" : align) : c)).join(" | ")} |`
181
+ return [line(head), `| ${widths.map(rule).join(" | ")} |`, ...body.map(line)].join("\n")
182
+ }
183
+
184
+ // ---------- HTML ----------
185
+
186
+ export const escapeHtml = (text: string) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")
187
+
188
+ export function toHtml(t: Table, o: SerializeOptions = {}): string {
189
+ const pretty = o.pretty ?? true
190
+ const [nl, i1, i2, i3] = pretty ? ["\n", " ", " ", " "] : ["", "", "", ""]
191
+ const cell = (tag: string, text: string) => `${i3}<${tag}>${escapeHtml(text).replace(/\r?\n/g, "<br>")}</${tag}>`
192
+ const row = (tag: string, cells: string[]) => `${i2}<tr>${nl}${cells.map((c) => cell(tag, c)).join(nl)}${nl}${i2}</tr>`
193
+ const parts = ["<table>"]
194
+ if (o.header ?? true) parts.push(`${i1}<thead>`, row("th", t.columns), `${i1}</thead>`)
195
+ parts.push(`${i1}<tbody>`, ...t.rows.map((r) => row("td", r)), `${i1}</tbody>`, "</table>")
196
+ return parts.join(nl)
197
+ }
198
+
199
+ // ---------- SQL ----------
200
+
201
+ const sqlIdent = (name: string, dialect: SqlDialect) => {
202
+ if (dialect === "mysql") return `\`${name.replace(/`/g, "``")}\``
203
+ if (dialect === "mssql") return `[${name.replace(/]/g, "]]")}]`
204
+ return `"${name.replace(/"/g, '""')}"`
205
+ }
206
+
207
+ const sqlLiteral = (v: Scalar, dialect: SqlDialect) => {
208
+ if (v === null) return "NULL"
209
+ if (typeof v === "number") return String(v)
210
+ if (typeof v === "boolean") return dialect === "mssql" ? (v ? "1" : "0") : v ? "TRUE" : "FALSE"
211
+ return `'${v.replace(/'/g, "''")}'`
212
+ }
213
+
214
+ // Tipo de coluna inferido dos valores: inteiro, decimal, booleano ou texto (VARCHAR com folga)
215
+ export function sqlColumnType(cells: string[], dialect: SqlDialect, decimal: Decimal = "dot", infer = true): string {
216
+ const values = infer ? cells.filter((c) => c !== "" && c.toLowerCase() !== "null").map((c) => inferValue(c, false, decimal)) : []
217
+ const longest = Math.max(1, ...cells.map((c) => c.length))
218
+ const text = dialect === "sqlite" ? "TEXT" : dialect === "mssql" ? `NVARCHAR(${longest > 4000 ? "MAX" : Math.min(4000, Math.ceil(longest * 1.5))})` : longest > 255 ? "TEXT" : `VARCHAR(${Math.min(255, Math.max(16, Math.ceil(longest * 1.5)))})`
219
+ if (values.length === 0) return text
220
+ if (values.every((v) => typeof v === "number" && Number.isInteger(v))) return dialect === "sqlite" ? "INTEGER" : values.some((v) => Math.abs(v as number) > 2_147_483_647) ? "BIGINT" : "INT"
221
+ if (values.every((v) => typeof v === "number")) return dialect === "sqlite" ? "REAL" : "DECIMAL(18,6)"
222
+ if (values.every((v) => typeof v === "boolean")) return dialect === "mssql" ? "BIT" : dialect === "sqlite" ? "INTEGER" : "BOOLEAN"
223
+ return text
224
+ }
225
+
226
+ export function toSql(t: Table, o: SerializeOptions = {}): string {
227
+ const dialect = o.dialect ?? "mysql"
228
+ const table = sqlIdent(o.tableName?.trim() || "my_table", dialect)
229
+ const keys = uniqueKeys(t.columns)
230
+ const columns = keys.map((k) => sqlIdent(k, dialect)).join(", ")
231
+ const typed = typedRows(t, o)
232
+ const tuple = (r: Scalar[]) => `(${keys.map((_, i) => sqlLiteral(r[i] ?? null, dialect)).join(", ")})`
233
+ const parts: string[] = []
234
+ if (o.createTable) {
235
+ const defs = keys.map((k, i) => ` ${sqlIdent(k, dialect)} ${sqlColumnType(t.rows.map((r) => r[i] ?? ""), dialect, o.decimal, o.types ?? true)}`)
236
+ parts.push(`CREATE TABLE ${table} (\n${defs.join(",\n")}\n);`)
237
+ }
238
+ if (t.rows.length) {
239
+ // MS SQL aceita no máximo 1000 linhas por VALUES
240
+ const batch = o.multiRow ?? true ? (dialect === "mssql" ? 1000 : 500) : 1
241
+ for (let i = 0; i < typed.length; i += batch) {
242
+ const chunk = typed.slice(i, i + batch)
243
+ parts.push(chunk.length === 1 ? `INSERT INTO ${table} (${columns}) VALUES ${tuple(chunk[0] as Scalar[])};` : `INSERT INTO ${table} (${columns}) VALUES\n${chunk.map((r) => ` ${tuple(r)}`).join(",\n")};`)
244
+ }
245
+ }
246
+ return parts.join("\n\n")
247
+ }
248
+
249
+ // ---------- YAML ----------
250
+
251
+ const YAML_PLAIN_UNSAFE = /^(?:[-?:,[\]{}#&*!|>'"%@`]|\s)|[:\s]$|: | #|[\r\n\t]/
252
+ const YAML_RESERVED = /^(?:true|false|yes|no|on|off|y|n|null|~|[-+]?(?:\.inf|\.nan)|[-+]?(?:\d[\d_]*)?(?:\.\d*)?(?:e[-+]?\d+)?|0x[\da-f]+|0o[0-7]+)$/i
253
+
254
+ export function yamlScalar(v: Scalar): string {
255
+ if (v === null) return "null"
256
+ if (typeof v !== "string") return String(v)
257
+ if (v === "" || YAML_PLAIN_UNSAFE.test(v) || YAML_RESERVED.test(v)) return JSON.stringify(v)
258
+ return v
259
+ }
260
+
261
+ export function toYaml(t: Table, o: SerializeOptions = {}): string {
262
+ if (t.rows.length === 0) return "[]"
263
+ const keys = uniqueKeys(t.columns).map(yamlScalar)
264
+ return typedRows(t, o).map((r) => keys.map((k, i) => `${i === 0 ? "- " : " "}${k}: ${yamlScalar(r[i] ?? null)}`).join("\n")).join("\n")
265
+ }
266
+
267
+ // ---------- XML ----------
268
+
269
+ export function xmlName(name: string): string {
270
+ const cleaned = name.trim().replace(/[^\p{L}\p{N}_.-]+/gu, "_")
271
+ return !cleaned || /^[^\p{L}_]/u.test(cleaned) || /^xml/i.test(cleaned) ? `_${cleaned}` : cleaned
272
+ }
273
+
274
+ const escapeXml = (text: string) => escapeHtml(text).replace(/'/g, "&apos;")
275
+
276
+ export function toXml(t: Table, o: SerializeOptions = {}): string {
277
+ const root = xmlName(o.xmlRoot || "rows")
278
+ const rowName = xmlName(o.xmlRow || "row")
279
+ const keys = uniqueKeys(t.columns).map(xmlName)
280
+ const rows = t.rows.map((r) => ` <${rowName}>\n${keys.map((k, i) => (r[i] ? ` <${k}>${escapeXml(r[i] ?? "")}</${k}>` : ` <${k}/>`)).join("\n")}\n </${rowName}>`)
281
+ return [`<?xml version="1.0" encoding="UTF-8"?>`, rows.length ? `<${root}>\n${rows.join("\n")}\n</${root}>` : `<${root}/>`].join("\n")
282
+ }
283
+
284
+ // ---------- ASCII ----------
285
+
286
+ export function toAscii(t: Table, o: SerializeOptions = {}): string {
287
+ const box = (o.asciiStyle ?? "unicode") === "unicode" ? { h: "─", v: "│", tl: "┌", tm: "┬", tr: "┐", ml: "├", mm: "┼", mr: "┤", bl: "└", bm: "┴", br: "┘" } : { h: "-", v: "|", tl: "+", tm: "+", tr: "+", ml: "+", mm: "+", mr: "+", bl: "+", bm: "+", br: "+" }
288
+ const flat = (c: string) => c.replace(/\r?\n/g, " ")
289
+ const grid = (o.header ?? true ? [t.columns, ...t.rows] : t.rows).map((r) => r.map(flat))
290
+ const width = t.columns.length
291
+ if (width === 0) return ""
292
+ const widths = Array.from({ length: width }, (_, i) => Math.max(1, ...grid.map((r) => displayWidth(r[i] ?? ""))))
293
+ const right = widths.map((_, i) => t.rows.length > 0 && t.rows.every((r) => /^-?[\d.,]+%?$/.test((r[i] ?? "").trim()) || !r[i]))
294
+ const rule = (l: string, m: string, r: string) => l + widths.map((w) => box.h.repeat(w + 2)).join(m) + r
295
+ const line = (cells: string[], head: boolean) => box.v + widths.map((w, i) => ` ${pad(cells[i] ?? "", w, right[i] && !head ? "right" : "left")} `).join(box.v) + box.v
296
+ const out = [rule(box.tl, box.tm, box.tr)]
297
+ grid.forEach((r, i) => {
298
+ out.push(line(r, i === 0 && (o.header ?? true)))
299
+ if (i === 0 && (o.header ?? true) && grid.length > 1) out.push(rule(box.ml, box.mm, box.mr))
300
+ })
301
+ out.push(rule(box.bl, box.bm, box.br))
302
+ return out.join("\n")
303
+ }
304
+
305
+ // ---------- LaTeX ----------
306
+
307
+ const LATEX: Record<string, string> = { "\\": "\\textbackslash{}", "&": "\\&", "%": "\\%", $: "\\$", "#": "\\#", _: "\\_", "{": "\\{", "}": "\\}", "~": "\\textasciitilde{}", "^": "\\textasciicircum{}" }
308
+ export const escapeLatex = (text: string) => text.replace(/[\\&%$#_{}~^]/g, (ch) => LATEX[ch] ?? ch).replace(/\r?\n/g, " \\newline ")
309
+
310
+ export function toLatex(t: Table, o: SerializeOptions = {}): string {
311
+ const align = { none: "l", left: "l", center: "c", right: "r" }[o.align ?? "none"]
312
+ const row = (cells: string[]) => ` ${cells.map(escapeLatex).join(" & ")} \\\\`
313
+ const lines = [`\\begin{tabular}{|${t.columns.map(() => align).join("|")}|}`, " \\hline"]
314
+ if (o.header ?? true) lines.push(row(t.columns), " \\hline")
315
+ lines.push(...t.rows.map(row), " \\hline", "\\end{tabular}")
316
+ return lines.join("\n")
317
+ }
318
+
319
+ // ---------- TOML ----------
320
+
321
+ const tomlKey = (key: string) => (/^[A-Za-z0-9_-]+$/.test(key) ? key : JSON.stringify(key))
322
+
323
+ // Lista de tabelas ([[rows]]). TOML não tem null: a chave é omitida.
324
+ export function toToml(t: Table, o: SerializeOptions = {}): string {
325
+ const name = tomlKey(o.xmlRoot || "rows")
326
+ const keys = uniqueKeys(t.columns).map(tomlKey)
327
+ return typedRows(t, o)
328
+ .map((r) => [`[[${name}]]`, ...keys.flatMap((k, i) => (r[i] === null || r[i] === undefined ? [] : [`${k} = ${typeof r[i] === "string" ? JSON.stringify(r[i]) : String(r[i])}`]))].join("\n"))
329
+ .join("\n\n")
330
+ }
331
+
332
+ // ---------- INI ----------
333
+
334
+ // Uma seção por linha, com o nome tirado da primeira coluna
335
+ export function toIni(t: Table): string {
336
+ const keys = uniqueKeys(t.columns)
337
+ const quote = (v: string) => (/[;#="]|^\s|\s$|\n/.test(v) ? `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r?\n/g, "\\n")}"` : v)
338
+ return t.rows
339
+ .map((r, n) => {
340
+ const section = (r[0] ?? "").replace(/[[\]\r\n]/g, "").trim() || `row ${n + 1}`
341
+ return [`[${section}]`, ...keys.slice(1).map((k, i) => `${k.replace(/[=\s]+/g, "_")} = ${quote(r[i + 1] ?? "")}`)].join("\n")
342
+ })
343
+ .join("\n\n")
344
+ }
345
+
346
+ // ---------- wikis e documentação ----------
347
+
348
+ export function toJira(t: Table, o: SerializeOptions = {}): string {
349
+ const cell = (c: string) => c.replace(/\|/g, "\\|").replace(/\r?\n/g, " \\\\ ") || " "
350
+ const lines = t.rows.map((r) => `|${r.map(cell).join("|")}|`)
351
+ if (o.header ?? true) lines.unshift(`||${t.columns.map(cell).join("||")}||`)
352
+ return lines.join("\n")
353
+ }
354
+
355
+ export function toMediaWiki(t: Table, o: SerializeOptions = {}): string {
356
+ const cell = (c: string) => c.replace(/\|/g, "&#124;").replace(/\r?\n/g, "<br>")
357
+ const lines = ['{| class="wikitable"']
358
+ if (o.header ?? true) lines.push("|-", `! ${t.columns.map(cell).join(" !! ")}`)
359
+ for (const r of t.rows) lines.push("|-", `| ${r.map(cell).join(" || ")}`)
360
+ lines.push("|}")
361
+ return lines.join("\n")
362
+ }
363
+
364
+ export function toAsciiDoc(t: Table, o: SerializeOptions = {}): string {
365
+ const cell = (c: string) => c.replace(/\|/g, "\\|").replace(/\r?\n/g, " +\n")
366
+ const row = (r: string[]) => r.map((c) => `| ${cell(c)}`).join(" ")
367
+ const header = o.header ?? true
368
+ const lines = [`[cols="${t.columns.map(() => "1").join(",")}"${header ? ', options="header"' : ""}]`, "|==="]
369
+ if (header) lines.push(row(t.columns), "")
370
+ lines.push(...t.rows.map(row), "|===")
371
+ return lines.join("\n")
372
+ }
373
+
374
+ // reStructuredText: grid table, com "=" separando o cabeçalho
375
+ export function toRst(t: Table, o: SerializeOptions = {}): string {
376
+ if (t.columns.length === 0) return ""
377
+ const header = o.header ?? true
378
+ const flat = (c: string) => c.replace(/\r?\n/g, " ")
379
+ const grid = (header ? [t.columns, ...t.rows] : t.rows).map((r) => r.map(flat))
380
+ const widths = t.columns.map((_, i) => Math.max(1, ...grid.map((r) => displayWidth(r[i] ?? ""))))
381
+ const rule = (ch: string) => `+${widths.map((w) => ch.repeat(w + 2)).join("+")}+`
382
+ const out = [rule("-")]
383
+ grid.forEach((r, i) => {
384
+ out.push(`|${widths.map((w, c) => ` ${pad(r[c] ?? "", w)} `).join("|")}|`)
385
+ out.push(rule(i === 0 && header ? "=" : "-"))
386
+ })
387
+ return out.join("\n")
388
+ }
389
+
390
+ export function serialize(t: Table, format: TextFormat, o: SerializeOptions = {}): string {
391
+ switch (format) {
392
+ case "csv":
393
+ return toCsv(t, o)
394
+ case "json":
395
+ return toJson(t, o)
396
+ case "jsonl":
397
+ return toJsonLines(t, o)
398
+ case "markdown":
399
+ return toMarkdown(t, o)
400
+ case "html":
401
+ return toHtml(t, o)
402
+ case "sql":
403
+ return toSql(t, o)
404
+ case "yaml":
405
+ return toYaml(t, o)
406
+ case "xml":
407
+ return toXml(t, o)
408
+ case "ascii":
409
+ return toAscii(t, o)
410
+ case "latex":
411
+ return toLatex(t, o)
412
+ case "toml":
413
+ return toToml(t, o)
414
+ case "ini":
415
+ return toIni(t)
416
+ case "jira":
417
+ return toJira(t, o)
418
+ case "mediawiki":
419
+ return toMediaWiki(t, o)
420
+ case "asciidoc":
421
+ return toAsciiDoc(t, o)
422
+ case "rst":
423
+ return toRst(t, o)
424
+ case "python":
425
+ return toPython(t, o)
426
+ case "pandas":
427
+ return toPandas(t, o)
428
+ case "typescript":
429
+ return toTypeScript(t, o)
430
+ case "go":
431
+ return toGo(t, o)
432
+ case "php":
433
+ return toPhp(t, o)
434
+ }
435
+ }
@@ -0,0 +1,133 @@
1
+ import { parseNumber, type Decimal, type Table } from "./model"
2
+
3
+ export const CASES = ["none", "upper", "lower", "title"] as const
4
+ export type CaseMode = (typeof CASES)[number]
5
+
6
+ // Transformações não destrutivas: a entrada colada não muda, então desfazer é só desligar a opção
7
+ // (e o link compartilhado reproduz o mesmo resultado).
8
+ export interface TransformOptions {
9
+ trim?: boolean
10
+ dropEmpty?: boolean
11
+ find?: string
12
+ replace?: string
13
+ regex?: boolean
14
+ ignoreCase?: boolean
15
+ caseMode?: CaseMode
16
+ // índice da coluna (na tabela lida) para ordenar; -1 = sem ordenação
17
+ sortBy?: number
18
+ descending?: boolean
19
+ // colunas exibidas, na ordem, por índice na tabela lida; vazio = todas
20
+ columns?: number[]
21
+ dedupe?: boolean
22
+ transpose?: boolean
23
+ // como reconhecer números ao ordenar
24
+ decimal?: Decimal
25
+ }
26
+
27
+ export interface Transformed {
28
+ table: Table
29
+ // para cada coluna do resultado, o índice na tabela lida (undefined depois de transpor)
30
+ source: (number | undefined)[]
31
+ // colunas que podem aparecer (as vazias removidas por dropEmpty ficam de fora), para restaurar ocultas
32
+ available: number[]
33
+ removedDuplicates: number
34
+ removedEmpty: number
35
+ }
36
+
37
+ export class FindPatternError extends Error {}
38
+
39
+ const titleCase = (text: string) => text.toLocaleLowerCase().replace(/(^|[\s\-_/(])(\p{L})/gu, (_, sep: string, ch: string) => sep + ch.toLocaleUpperCase())
40
+
41
+ function cellMapper(options: TransformOptions): ((cell: string) => string) | undefined {
42
+ const steps: ((cell: string) => string)[] = []
43
+ if (options.trim) steps.push((c) => c.trim().replace(/[ \t]+/g, " "))
44
+ if (options.find) {
45
+ const flags = `g${options.ignoreCase ? "i" : ""}u`
46
+ let pattern: RegExp
47
+ try {
48
+ pattern = new RegExp(options.regex ? options.find : options.find.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), flags)
49
+ } catch (error) {
50
+ throw new FindPatternError(error instanceof Error ? error.message : String(error))
51
+ }
52
+ const replacement = options.replace ?? ""
53
+ // sem regex, "$1" na substituição é texto literal
54
+ steps.push(options.regex ? (c) => c.replace(pattern, replacement) : (c) => c.replace(pattern, () => replacement))
55
+ }
56
+ if (options.caseMode === "upper") steps.push((c) => c.toLocaleUpperCase())
57
+ if (options.caseMode === "lower") steps.push((c) => c.toLocaleLowerCase())
58
+ if (options.caseMode === "title") steps.push(titleCase)
59
+ if (steps.length === 0) return undefined
60
+ return (cell) => steps.reduce((value, step) => step(value), cell)
61
+ }
62
+
63
+ const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" })
64
+
65
+ export function compareCells(a: string, b: string, decimal: Decimal = "dot"): number {
66
+ // números comparam como números; o resto em ordem "natural" (item2 antes de item10)
67
+ const x = parseNumber(a, decimal)
68
+ const y = parseNumber(b, decimal)
69
+ if (x !== undefined && y !== undefined) return x - y
70
+ return collator.compare(a, b)
71
+ }
72
+
73
+ export function transposeTable(table: Table): Table {
74
+ const grid = [table.columns, ...table.rows]
75
+ const [first = [], ...rest] = grid[0]?.map((_, c) => grid.map((row) => row[c] ?? "")) ?? []
76
+ return { columns: first, rows: rest }
77
+ }
78
+
79
+ // Ordem: limpeza → localizar/substituir → caixa → ordenar → escolher colunas → remover duplicadas → transpor
80
+ export function transformTable(input: Table, options: TransformOptions = {}): Transformed {
81
+ const map = cellMapper(options)
82
+ const columns = map ? input.columns.map(map) : [...input.columns]
83
+ let rows = map ? input.rows.map((r) => r.map(map)) : input.rows.map((r) => [...r])
84
+
85
+ let removedEmpty = 0
86
+ if (options.dropEmpty) {
87
+ const before = rows.length
88
+ rows = rows.filter((r) => r.some((c) => c.trim() !== ""))
89
+ removedEmpty = before - rows.length
90
+ const keep = columns.map((name, i) => name.trim() !== "" && !/^column\d+$/.test(name) ? true : rows.some((r) => (r[i] ?? "").trim() !== ""))
91
+ if (keep.includes(false) && keep.includes(true)) {
92
+ // colunas vazias saem; a numeração de origem é preservada pelo índice
93
+ const indices = keep.flatMap((k, i) => (k ? [i] : []))
94
+ removedEmpty += keep.length - indices.length
95
+ return finish({ columns, rows }, options, indices, removedEmpty)
96
+ }
97
+ }
98
+ return finish({ columns, rows }, options, columns.map((_, i) => i), removedEmpty)
99
+ }
100
+
101
+ function finish(table: Table, options: TransformOptions, available: number[], removedEmpty: number): Transformed {
102
+ let rows = table.rows
103
+ const sortBy = options.sortBy ?? -1
104
+ if (sortBy >= 0 && sortBy < table.columns.length) {
105
+ const direction = options.descending ? -1 : 1
106
+ rows = [...rows].sort((a, b) => {
107
+ const x = a[sortBy] ?? ""
108
+ const y = b[sortBy] ?? ""
109
+ if (x === "" || y === "") return x === y ? 0 : x === "" ? 1 : -1
110
+ return compareCells(x, y, options.decimal) * direction
111
+ })
112
+ }
113
+
114
+ const chosen = (options.columns ?? []).filter((i) => available.includes(i))
115
+ const source = chosen.length ? chosen : available
116
+ let result: Table = { columns: source.map((i) => table.columns[i] ?? ""), rows: rows.map((r) => source.map((i) => r[i] ?? "")) }
117
+
118
+ let removedDuplicates = 0
119
+ if (options.dedupe) {
120
+ const seen = new Set<string>()
121
+ const unique = result.rows.filter((r) => {
122
+ const key = JSON.stringify(r)
123
+ if (seen.has(key)) return false
124
+ seen.add(key)
125
+ return true
126
+ })
127
+ removedDuplicates = result.rows.length - unique.length
128
+ result = { ...result, rows: unique }
129
+ }
130
+
131
+ if (options.transpose) return { table: transposeTable(result), source: [], available, removedDuplicates, removedEmpty }
132
+ return { table: result, source, available, removedDuplicates, removedEmpty }
133
+ }