@thecodecodex/tools 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +44 -2
- package/package.json +46 -4
- package/src/base64.ts +36 -0
- package/src/br/cnpj.ts +55 -0
- package/src/br/cpf.ts +61 -0
- package/src/cli/main.ts +93 -0
- package/src/cli/tools.ts +643 -0
- package/src/compensation-comparator.ts +124 -0
- package/src/css-playground.ts +699 -0
- package/src/dev-ref.ts +295 -0
- package/src/diff.ts +301 -0
- package/src/finance/clt.ts +189 -0
- package/src/finance/compound.ts +78 -0
- package/src/finance/pj.ts +146 -0
- package/src/fov.ts +145 -0
- package/src/hash.ts +13 -0
- package/src/image-to-pdf.ts +299 -0
- package/src/invoice-pdf.ts +134 -0
- package/src/invoice.ts +94 -0
- package/src/jwt.ts +44 -0
- package/src/market.ts +49 -0
- package/src/password.ts +51 -0
- package/src/pdf-studio.ts +453 -0
- package/src/random/index.ts +19 -0
- package/src/regex.ts +426 -0
- package/src/skills.json +60 -0
- package/src/skills.ts +52 -0
- package/src/state-holidays.ts +94 -0
- package/src/table-convert/code.ts +99 -0
- package/src/table-convert/index.ts +42 -0
- package/src/table-convert/model.ts +72 -0
- package/src/table-convert/parse.ts +242 -0
- package/src/table-convert/serialize.ts +435 -0
- package/src/table-convert/transform.ts +133 -0
- package/src/table-convert/xlsx.ts +262 -0
- package/src/table-convert/zip.ts +127 -0
- package/src/timestamp.ts +50 -0
- package/src/url-codec.ts +25 -0
- package/src/uuid.ts +53 -0
- package/src/vacation.ts +171 -0
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """)
|
|
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, "'")
|
|
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, "|").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
|
+
}
|