@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,94 @@
1
+ // Feriados estaduais com base legal. Cada entrada cita a lei e a fonte consultada; só entram datas com folga
2
+ // (pontos facultativos e datas comemorativas ficam de fora). Datas móveis usam `rule`.
3
+
4
+ export const UFS = ["AC", "AL", "AM", "AP", "BA", "CE", "DF", "ES", "GO", "MA", "MG", "MS", "MT", "PA", "PB", "PE", "PI", "PR", "RJ", "RN", "RO", "RR", "RS", "SC", "SE", "SP", "TO"] as const
5
+ export type Uf = (typeof UFS)[number]
6
+
7
+ export interface StateHoliday {
8
+ uf: Uf
9
+ name: string
10
+ // "MM-DD" para datas fixas
11
+ date?: string
12
+ // datas móveis: devolve "YYYY-MM-DD" para o ano
13
+ rule?: (year: number) => string
14
+ // transferências pontuais publicadas por decreto/portaria para um ano específico
15
+ overrides?: Record<number, string>
16
+ law: string
17
+ source: string
18
+ note?: string
19
+ }
20
+
21
+ // Estados sem feriado estadual com folga além dos nacionais (verificado): MG, MT, PR.
22
+ // Ainda sem documento oficial verificado (não incluídos): AP, RN, RO e o 05/10 de TO.
23
+ const sundayOnOrAfter = (iso: string) => {
24
+ const date = new Date(`${iso}T00:00:00Z`)
25
+ date.setUTCDate(date.getUTCDate() + ((7 - date.getUTCDay()) % 7))
26
+ return date.toISOString().slice(0, 10)
27
+ }
28
+ const plusDays = (iso: string, days: number) => {
29
+ const date = new Date(`${iso}T00:00:00Z`)
30
+ date.setUTCDate(date.getUTCDate() + days)
31
+ return date.toISOString().slice(0, 10)
32
+ }
33
+
34
+ const TJAC = "https://webdev.tjac.jus.br/wp-content/uploads/2025/12/Portaria_PRESI_TJAC_6569_2025.pdf"
35
+ const JFAL = "https://www.jfal.jus.br/noticias/6685/Portaria%20n.%20199-2025-feriados-2026.pdf"
36
+
37
+ export const STATE_HOLIDAYS: StateHoliday[] = [
38
+ { uf: "AC", date: "01-20", overrides: { 2026: "2026-01-22" }, name: "Dia do Católico", law: "Lei 3.137/2016", source: TJAC, note: "A Lei 2.126/2009 transfere alguns feriados do AC; em 2026 foi para 22/01." },
39
+ { uf: "AC", date: "01-23", name: "Dia do Evangélico", law: "Lei 1.538/2004", source: TJAC },
40
+ { uf: "AC", date: "03-08", name: "Dia Internacional da Mulher", law: "Lei 1.411/2001", source: TJAC },
41
+ { uf: "AC", date: "06-15", name: "Aniversário do Estado do Acre", law: "Lei 14/1964", source: TJAC },
42
+ { uf: "AC", date: "09-05", name: "Dia da Amazônia", law: "Lei 243/1968", source: TJAC },
43
+ { uf: "AL", date: "06-24", name: "São João", law: "Lei 5.508/1993", source: JFAL },
44
+ { uf: "AL", date: "06-29", name: "São Pedro", law: "Lei 5.509/1993", source: JFAL },
45
+ { uf: "AL", date: "09-16", name: "Emancipação Política de Alagoas", law: "Decreto 106.093/2025", source: JFAL },
46
+ { uf: "AM", date: "09-05", name: "Elevação do Amazonas à categoria de Província", law: "Calendário oficial TJAM 2026", source: "https://www.tjam.jus.br/images/2026/CalendarioJudicial2026_alterado.pdf" },
47
+ { uf: "BA", date: "07-02", name: "Independência da Bahia", law: "Constituição da Bahia, art. 6º, §3º", source: "https://www.tjba.jus.br/portal/wp-content/uploads/2025/12/Decreto-1050-25_-expediente-forense-2026.pdf" },
48
+ { uf: "CE", date: "03-25", name: "Data Magna do Ceará", law: "EC estadual 73/2011", source: "https://portal.tjce.jus.br/uploads/2026/04/PORT-FERIADOS-E-PONTO-FACULTATIVO-2026-1776690769.pdf" },
49
+ { uf: "DF", date: "11-30", name: "Dia do Evangélico", law: "Lei distrital 963/1995", source: "https://www.lex.com.br/feriado-do-dia-do-evangelico-instituido-por-lei-distrital-nao-se-aplica-a-orgaos-publicos-federais-com-sede-no-df/", note: "Não vale para órgãos federais; há divergência no TST para o setor privado." },
50
+ { uf: "ES", rule: (year) => plusDays(easterSunday(year), 8), name: "Nossa Senhora da Penha (Data Magna)", law: "Lei 11.010/2019", source: "https://www.folhavitoria.com.br/geral/dia-de-nossa-senhora-da-penha-se-torna-feriado-em-todo-o-espirito-santo/", note: "Segunda-feira, 8 dias após a Páscoa." },
51
+ { uf: "GO", date: "07-26", name: "Fundação da Cidade de Goiás (Data Magna)", law: "Lei 20.756/2020, art. 269", source: "https://www.tjgo.jus.br/files/2026/01%20-%20Janeiro/Calendario%20do%20TJGO_2026_publicado-2.pdf", note: "Já foi transferido por decreto em anos anteriores." },
52
+ { uf: "MA", date: "07-28", name: "Adesão do Maranhão à Independência", law: "Lei 2.457/1964", source: "https://novogerenciador.tjma.jus.br/storage/portalweb/medida_provisoria_313_08_05_2020_26052020_1240.pdf" },
53
+ { uf: "MS", date: "10-11", name: "Criação do Estado de Mato Grosso do Sul", law: "Lei 10/1979", source: "https://web.trf3.jus.br/atos-normativos/atos-normativos-dir/Conselho%20da%20Justi%C3%A7a/Portarias/2020/Portaria0431.htm" },
54
+ { uf: "PA", date: "08-15", name: "Adesão do Pará à Independência", law: "Portaria MPF PR/PA 2/2026", source: "https://biblioteca.mpf.mp.br/repositorio/bitstreams/4b2d53d1-112c-4958-a8f6-3b318d7bcef7/download" },
55
+ { uf: "PB", date: "08-05", name: "Data Magna da Paraíba", law: "Lei 10.601/2015", source: "https://www.tjpb.jus.br/sites/default/files/legislacao/ATO_CONJUNTO_No_04_16102025.pdf" },
56
+ { uf: "PE", date: "03-06", name: "Data Magna de Pernambuco (Revolução de 1817)", law: "Lei 16.241/2017, art. 49", source: "https://portal.tjpe.jus.br/documents/d/portal/feriados-2026-pdf" },
57
+ { uf: "PI", date: "10-19", name: "Dia do Piauí", law: "Lei 176/1937", source: "https://www.legisweb.com.br/legislacao/?id=454668" },
58
+ { uf: "RJ", rule: (year) => plusDays(easterSunday(year), -47), name: "Carnaval (terça-feira)", law: "Lei 5.243/2008", source: "https://www.conjur.com.br/2008-set-02/confederacao_questiona_lei_amplia_feriado_rio", note: "Questionado em ADI no STF; cumprido na prática." },
59
+ { uf: "RJ", date: "04-23", name: "Dia de São Jorge", law: "Lei 5.198/2008", source: "https://www.coad.com.br/home/noticias-detalhe/12754/dia-de-sao-jorge-passa-a-ser-feriado-estadual", note: "Questionado em ADI no STF; cumprido na prática." },
60
+ { uf: "RR", date: "10-05", name: "Aniversário do Estado de Roraima", law: "Portaria TJRR/PR 1558/2025", source: "https://diario.tjrr.jus.br/dpj/dpj-20251222.pdf" },
61
+ { uf: "RS", date: "09-20", name: "Revolução Farroupilha (Data Magna)", law: "Constituição do RS, art. 6º, parágrafo único", source: "https://www.mprs.mp.br/legislacao/provimentos/12970/" },
62
+ { uf: "SC", rule: (year) => sundayOnOrAfter(`${year}-08-11`), name: "Data Magna de Santa Catarina", law: "Lei 18.531/2022", source: "https://www.nsctotal.com.br/noticias/data-magna-santa-catarina", note: "Celebrada no domingo seguinte ao 11/08 (ou no próprio dia, se for domingo)." },
63
+ { uf: "SE", date: "07-08", name: "Independência de Sergipe", law: "Constituição de Sergipe, art. 269", source: "https://www.gov.br/hubrasil/pt-br/hospitais-universitarios/regiao-nordeste/hu-ufs/acesso-a-informacao/boletim-de-servico/2026/boletim-servico-no-464-de-15-de-janeiro-de-2026.pdf" },
64
+ { uf: "SP", date: "07-09", name: "Revolução Constitucionalista de 1932", law: "Lei 9.497/1997", source: "https://www.al.sp.gov.br/repositorio/legislacao/lei/1997/lei-9497-05.03.1997.html" },
65
+ { uf: "TO", date: "09-08", name: "Nossa Senhora da Natividade", law: "Lei 627/1993", source: "https://trf1.jus.br/sjto/conteudo/files/FERIADOS%20SUBSE__O%20JUDICI_RIA%20DE%20GURUPI.pdf" },
66
+ ]
67
+
68
+ export function stateHolidaysFor(uf: Uf, year: number): { date: string, name: string, law: string, source: string }[] {
69
+ return STATE_HOLIDAYS.filter((h) => h.uf === uf).map((h) => ({
70
+ date: h.overrides?.[year] ?? (h.rule ? h.rule(year) : `${year}-${h.date}`),
71
+ name: h.name,
72
+ law: h.law,
73
+ source: h.source,
74
+ }))
75
+ }
76
+
77
+ // Páscoa (Meeus/Jones/Butcher) — duplicada de vacation.ts para evitar import circular
78
+ function easterSunday(year: number): string {
79
+ const a = year % 19
80
+ const b = Math.floor(year / 100)
81
+ const c = year % 100
82
+ const d = Math.floor(b / 4)
83
+ const e = b % 4
84
+ const f = Math.floor((b + 8) / 25)
85
+ const g = Math.floor((b - f + 1) / 3)
86
+ const h = (19 * a + b - d - g + 15) % 30
87
+ const i = Math.floor(c / 4)
88
+ const k = c % 4
89
+ const l = (32 + 2 * e + 2 * i - h - k) % 7
90
+ const m = Math.floor((a + 11 * h + 22 * l) / 451)
91
+ const month = Math.floor((h + l - 7 * m + 114) / 31)
92
+ const day = ((h + l - 7 * m + 114) % 31) + 1
93
+ return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`
94
+ }
@@ -0,0 +1,99 @@
1
+ // Saída como código: a tabela vira um literal pronto para colar (Python, pandas, TypeScript, Go, PHP).
2
+ // Os tipos vêm de columnKinds, os mesmos do JSON e do SQL.
3
+ import { uniqueKeys, type Scalar, type Table } from "./model"
4
+ import { columnKinds, typedRows, type ColumnKind, type SerializeOptions } from "./serialize"
5
+
6
+ const typed = (t: Table, o: SerializeOptions) => {
7
+ const kinds = columnKinds(t, o)
8
+ const rows = typedRows(t, o, kinds)
9
+ const nullable = kinds.map((_, i) => rows.some((r) => r[i] === null))
10
+ return { kinds, rows, nullable }
11
+ }
12
+
13
+ // ---------- Python ----------
14
+
15
+ const python = (v: Scalar) => (v === null ? "None" : v === true ? "True" : v === false ? "False" : typeof v === "number" ? String(v) : JSON.stringify(v))
16
+
17
+ export function toPython(t: Table, o: SerializeOptions = {}): string {
18
+ const keys = uniqueKeys(t.columns).map((k) => JSON.stringify(k))
19
+ const { rows } = typed(t, o)
20
+ if (rows.length === 0) return "rows = []"
21
+ return `rows = [\n${rows.map((r) => ` {${keys.map((k, i) => `${k}: ${python(r[i] ?? null)}`).join(", ")}},`).join("\n")}\n]`
22
+ }
23
+
24
+ export function toPandas(t: Table, o: SerializeOptions = {}): string {
25
+ const keys = uniqueKeys(t.columns).map((k) => JSON.stringify(k))
26
+ const { rows } = typed(t, o)
27
+ const columns = keys.map((k, i) => ` ${k}: [${rows.map((r) => python(r[i] ?? null)).join(", ")}],`)
28
+ return `import pandas as pd\n\ndf = pd.DataFrame({\n${columns.join("\n")}\n})`
29
+ }
30
+
31
+ // ---------- TypeScript ----------
32
+
33
+ const tsKey = (key: string) => (/^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key))
34
+ const TS_TYPE: Record<ColumnKind, string> = { number: "number", integer: "number", boolean: "boolean", text: "string" }
35
+ const literal = (v: Scalar) => (v === null ? "null" : typeof v === "string" ? JSON.stringify(v) : String(v))
36
+
37
+ export function identifier(name: string, fallback = "Row"): string {
38
+ const words = name
39
+ .normalize("NFD")
40
+ .replace(/\p{M}/gu, "")
41
+ .split(/[^A-Za-z0-9]+/)
42
+ .filter(Boolean)
43
+ const joined = words.map((w) => w[0]?.toUpperCase() + w.slice(1)).join("")
44
+ return !joined ? fallback : /^\d/.test(joined) ? `${fallback}${joined}` : joined
45
+ }
46
+
47
+ export function toTypeScript(t: Table, o: SerializeOptions = {}): string {
48
+ const type = identifier(o.typeName ?? "", "Row")
49
+ const keys = uniqueKeys(t.columns).map(tsKey)
50
+ const { kinds, rows, nullable } = typed(t, o)
51
+ const fields = keys.map((k, i) => ` ${k}: ${TS_TYPE[kinds[i] ?? "text"]}${nullable[i] ? " | null" : ""}`)
52
+ const values = rows.map((r) => ` { ${keys.map((k, i) => `${k}: ${literal(r[i] ?? null)}`).join(", ")} },`)
53
+ return `export interface ${type} {\n${fields.join("\n")}\n}\n\nexport const rows: ${type}[] = [\n${values.join("\n")}\n]`
54
+ }
55
+
56
+ // ---------- Go ----------
57
+
58
+ // Siglas em maiúsculas, como o gofmt/golint esperam (UserID, not UserId)
59
+ const INITIALISMS = new Set(["Id", "Url", "Uri", "Api", "Http", "Https", "Json", "Sql", "Uuid", "Ip", "Html", "Xml", "Cpf", "Cnpj"])
60
+ const GO_TYPE: Record<ColumnKind, string> = { number: "float64", integer: "int64", boolean: "bool", text: "string" }
61
+
62
+ function goField(name: string, used: Set<string>): string {
63
+ const base = identifier(name, "Field").replace(/[A-Z][a-z]+|[a-z]+/g, (w) => (INITIALISMS.has(w[0]?.toUpperCase() + w.slice(1).toLowerCase()) ? w.toUpperCase() : w))
64
+ let field = base
65
+ for (let n = 2; used.has(field); n++) field = `${base}${n}`
66
+ used.add(field)
67
+ return field
68
+ }
69
+
70
+ export function toGo(t: Table, o: SerializeOptions = {}): string {
71
+ const type = identifier(o.typeName ?? "", "Row")
72
+ const keys = uniqueKeys(t.columns)
73
+ const used = new Set<string>()
74
+ const fields = keys.map((k) => goField(k, used))
75
+ const { kinds, rows, nullable } = typed(t, o)
76
+ // colunas com null viram ponteiro (nil = ausente); valores não nulos usam o helper ptr.
77
+ // No literal, campos nulos ou "" ficam de fora (já são o valor zero do Go).
78
+ const types = kinds.map((k, i) => `${nullable[i] ? "*" : ""}${GO_TYPE[k]}`)
79
+ const nameWidth = Math.max(...fields.map((f) => f.length), 0)
80
+ const typeWidth = Math.max(...types.map((f) => f.length), 0)
81
+ const struct = fields.map((f, i) => `\t${f.padEnd(nameWidth)} ${(types[i] ?? "").padEnd(typeWidth)} \`json:"${(keys[i] ?? "").replace(/[`"\\]/g, "_")}"\``)
82
+ const value = (v: Scalar, i: number) => {
83
+ const plain = typeof v === "string" ? JSON.stringify(v) : String(v)
84
+ return nullable[i] ? `ptr(${kinds[i] === "number" && typeof v === "number" && Number.isInteger(v) ? `float64(${plain})` : kinds[i] === "integer" ? `int64(${plain})` : plain})` : plain
85
+ }
86
+ const values = rows.map((r) => `\t{${fields.flatMap((f, i) => (r[i] === null || r[i] === undefined || r[i] === "" ? [] : [`${f}: ${value(r[i] as Scalar, i)}`])).join(", ")}},`)
87
+ const helper = nullable.some(Boolean) ? "\n\nfunc ptr[T any](v T) *T { return &v }" : ""
88
+ return `type ${type} struct {\n${struct.join("\n")}\n}\n\nvar rows = []${type}{\n${values.join("\n")}\n}${helper}`
89
+ }
90
+
91
+ // ---------- PHP ----------
92
+
93
+ const php = (v: Scalar) => (v === null ? "null" : typeof v === "boolean" ? String(v) : typeof v === "number" ? String(v) : `'${v.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`)
94
+
95
+ export function toPhp(t: Table, o: SerializeOptions = {}): string {
96
+ const keys = uniqueKeys(t.columns).map(php)
97
+ const { rows } = typed(t, o)
98
+ return `<?php\n\n$rows = [\n${rows.map((r) => ` [${keys.map((k, i) => `${k} => ${php(r[i] ?? null)}`).join(", ")}],`).join("\n")}\n];`
99
+ }
@@ -0,0 +1,42 @@
1
+ // Conversor de tabelas: entrada (CSV/TSV, Excel, JSON, Markdown, HTML) → modelo único → transformações → saída.
2
+ // Funções puras, usadas igualmente pela página e pelo comando de terminal.
3
+ import { parseTable, type ParseOptions, type Parsed } from "./parse"
4
+ import { serialize, type OutputFormat, type SerializeOptions } from "./serialize"
5
+ import { transformTable, type TransformOptions, type Transformed } from "./transform"
6
+ import { toXlsx } from "./xlsx"
7
+
8
+ export * from "./model"
9
+ export * from "./parse"
10
+ export * from "./serialize"
11
+ export * from "./transform"
12
+ export * from "./code"
13
+ export * from "./xlsx"
14
+
15
+ export interface ConvertOptions extends ParseOptions, TransformOptions, SerializeOptions {
16
+ to?: OutputFormat
17
+ sheet?: string
18
+ }
19
+
20
+ export interface Conversion {
21
+ parsed: Parsed
22
+ result: Transformed
23
+ // texto da saída; para xlsx fica vazio e o arquivo vem em `file`
24
+ output: string
25
+ file?: Uint8Array
26
+ }
27
+
28
+ export function convertTable(text: string, options: ConvertOptions = {}): Conversion {
29
+ const parsed = parseTable(text, options)
30
+ const result = transformTable(parsed.table, options)
31
+ const to = options.to ?? "json"
32
+ if (!result.table.columns.length) return { parsed, result, output: "" }
33
+ if (to === "xlsx") return { parsed, result, output: "", file: toXlsx(result.table, options) }
34
+ return { parsed, result, output: serialize(result.table, to, options) }
35
+ }
36
+
37
+ export const SAMPLE = `id,name,email,city,salary,active
38
+ 1,Ana Souza,ana@example.com,São Paulo,8500.50,true
39
+ 2,Bruno Lima,bruno@example.com,Porto Alegre,7200,false
40
+ 3,Carla Dias,carla@example.com,Recife,9100,true
41
+ 4,"Diego, Jr.",diego@example.com,Curitiba,,true`
42
+
@@ -0,0 +1,72 @@
1
+ // Modelo intermediário do conversor: todo formato de entrada vira isto, e todo formato de saída parte disto.
2
+ // Células são sempre texto; a inferência de tipos (número, booleano, nulo) acontece só na saída.
3
+ export interface Table {
4
+ columns: string[]
5
+ rows: string[][]
6
+ }
7
+
8
+ export class TableParseError extends Error {
9
+ constructor(readonly format: string, message: string) {
10
+ super(message)
11
+ this.name = "TableParseError"
12
+ }
13
+ }
14
+
15
+ export const EMPTY_TABLE: Table = { columns: [], rows: [] }
16
+
17
+ // Iguala o número de células de todas as linhas (completa com vazio) e dá nome às colunas sem título
18
+ export function normalize(grid: string[][], header: boolean): Table {
19
+ const width = grid.reduce((max, row) => Math.max(max, row.length), 0)
20
+ const pad = (row: string[]) => Array.from({ length: width }, (_, i) => row[i] ?? "")
21
+ const padded = grid.map(pad)
22
+ if (!header) return { columns: generatedColumns(width), rows: padded }
23
+ const [first = [], ...rows] = padded
24
+ return { columns: first.map((name, i) => name.trim() || `column${i + 1}`), rows }
25
+ }
26
+
27
+ export const generatedColumns = (width: number) => Array.from({ length: width }, (_, i) => `column${i + 1}`)
28
+
29
+ // Chaves únicas para formatos com nomes (JSON, YAML, XML, SQL): repetidas ganham sufixo _2, _3…
30
+ export function uniqueKeys(columns: string[]): string[] {
31
+ const seen = new Map<string, number>()
32
+ return columns.map((name) => {
33
+ const count = (seen.get(name) ?? 0) + 1
34
+ seen.set(name, count)
35
+ return count === 1 ? name : `${name}_${count}`
36
+ })
37
+ }
38
+
39
+ export type Scalar = string | number | boolean | null
40
+
41
+ export const DECIMALS = ["dot", "comma"] as const
42
+ // "dot": 1234.5 (padrão de programação); "comma": 1.234,5 (Excel e planilhas em português)
43
+ export type Decimal = (typeof DECIMALS)[number]
44
+
45
+ // Inferência conservadora: zeros à esquerda (CEP, CPF, "007") continuam texto
46
+ const NUMBER = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?$/
47
+ // vírgula decimal, com ou sem ponto de milhar bem formado (1.234.567,89)
48
+ const NUMBER_COMMA = /^-?(0|[1-9]\d{0,2}(\.\d{3})+|[1-9]\d*)(,\d+)?$/
49
+
50
+ // Converte o texto de uma célula em número, ou undefined se não for número no formato escolhido
51
+ export function parseNumber(cell: string, decimal: Decimal = "dot"): number | undefined {
52
+ const text = cell.trim()
53
+ if (decimal === "comma") {
54
+ if (!NUMBER_COMMA.test(text)) return undefined
55
+ const n = Number(text.replace(/\./g, "").replace(",", "."))
56
+ return Number.isFinite(n) ? n : undefined
57
+ }
58
+ return NUMBER.test(text) && Number.isFinite(Number(text)) ? Number(text) : undefined
59
+ }
60
+
61
+ export function inferValue(cell: string, emptyAsNull = false, decimal: Decimal = "dot"): Scalar {
62
+ if (cell === "") return emptyAsNull ? null : ""
63
+ const n = parseNumber(cell, decimal)
64
+ if (n !== undefined) return n
65
+ const lower = cell.toLowerCase()
66
+ if (lower === "true") return true
67
+ if (lower === "false") return false
68
+ if (lower === "null") return null
69
+ return cell
70
+ }
71
+
72
+ export const isNumeric = (cell: string, decimal: Decimal = "dot") => parseNumber(cell, decimal) !== undefined
@@ -0,0 +1,242 @@
1
+ import { normalize, TableParseError, type Table } from "./model"
2
+
3
+ export const INPUT_FORMATS = ["auto", "csv", "json", "markdown", "html"] as const
4
+ export type InputFormat = (typeof INPUT_FORMATS)[number]
5
+ export type ConcreteInput = Exclude<InputFormat, "auto">
6
+
7
+ export const DELIMITERS = { comma: ",", semicolon: ";", tab: "\t", pipe: "|" } as const
8
+ export type Delimiter = keyof typeof DELIMITERS
9
+ export const DELIMITER_NAMES = Object.keys(DELIMITERS) as Delimiter[]
10
+
11
+ export interface ParseOptions {
12
+ from?: InputFormat
13
+ delimiter?: Delimiter | "auto"
14
+ header?: boolean
15
+ }
16
+
17
+ export interface Parsed {
18
+ table: Table
19
+ format: ConcreteInput
20
+ delimiter?: Delimiter
21
+ }
22
+
23
+ // ---------- detecção ----------
24
+
25
+ export function detectFormat(text: string): ConcreteInput {
26
+ const start = text.trimStart()
27
+ if (start.startsWith("[") || start.startsWith("{")) return "json"
28
+ if (/^<(!doctype|html|table|thead|tbody|tr|div|body)\b/i.test(start) || /<table[\s>]/i.test(start)) return "html"
29
+ const lines = start.split(/\r?\n/).filter((l) => l.trim())
30
+ if (lines.length >= 2 && lines[0]?.trim().startsWith("|") && isMarkdownSeparator(lines[1] ?? "")) return "markdown"
31
+ return "csv"
32
+ }
33
+
34
+ // Escolhe o separador que dá o mesmo número (> 1) de colunas no maior número de linhas.
35
+ // Em empate vence a ordem: tab (colado do Excel/Sheets), ponto-e-vírgula (Excel em pt-BR), vírgula, barra.
36
+ export function detectDelimiter(text: string): Delimiter {
37
+ const sample = text.split(/\r?\n/).slice(0, 30).join("\n")
38
+ let best: { name: Delimiter, score: number } = { name: "comma", score: 0 }
39
+ for (const name of ["tab", "semicolon", "comma", "pipe"] as const) {
40
+ const rows = parseDelimited(sample, DELIMITERS[name]).filter((r) => r.length > 1 || r[0] !== "")
41
+ if (rows.length === 0) continue
42
+ const counts = new Map<number, number>()
43
+ for (const r of rows) counts.set(r.length, (counts.get(r.length) ?? 0) + 1)
44
+ const [width, hits] = [...counts.entries()].sort((a, b) => b[1] - a[1] || b[0] - a[0])[0] ?? [1, 0]
45
+ if (width < 2) continue
46
+ const score = (hits / rows.length) * 1000 + width
47
+ if (score > best.score) best = { name, score }
48
+ }
49
+ return best.name
50
+ }
51
+
52
+ // ---------- CSV / TSV (RFC 4180) ----------
53
+
54
+ export function parseDelimited(text: string, delimiter: string): string[][] {
55
+ const rows: string[][] = []
56
+ let row: string[] = []
57
+ let cell = ""
58
+ let quoted = false
59
+ let i = 0
60
+ const input = text.replace(/^/, "")
61
+ while (i < input.length) {
62
+ const ch = input[i] as string
63
+ if (quoted) {
64
+ if (ch === '"') {
65
+ if (input[i + 1] === '"') {
66
+ cell += '"'
67
+ i++
68
+ } else quoted = false
69
+ } else cell += ch
70
+ } else if (ch === '"' && cell === "") quoted = true
71
+ else if (ch === delimiter) {
72
+ row.push(cell)
73
+ cell = ""
74
+ } else if (ch === "\n" || ch === "\r") {
75
+ if (ch === "\r" && input[i + 1] === "\n") i++
76
+ row.push(cell)
77
+ rows.push(row)
78
+ row = []
79
+ cell = ""
80
+ } else cell += ch
81
+ i++
82
+ }
83
+ if (cell !== "" || row.length > 0) {
84
+ row.push(cell)
85
+ rows.push(row)
86
+ }
87
+ // linhas totalmente vazias (ex.: final do arquivo) não contam
88
+ return rows.filter((r) => r.some((c) => c !== ""))
89
+ }
90
+
91
+ // ---------- Markdown ----------
92
+
93
+ const isMarkdownSeparator = (line: string) => /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/.test(line) && line.includes("-")
94
+
95
+ function splitMarkdownRow(line: string): string[] {
96
+ let body = line.trim()
97
+ if (body.startsWith("|")) body = body.slice(1)
98
+ if (body.endsWith("|") && !body.endsWith("\\|")) body = body.slice(0, -1)
99
+ const cells: string[] = []
100
+ let cell = ""
101
+ for (let i = 0; i < body.length; i++) {
102
+ const ch = body[i]
103
+ if (ch === "\\" && body[i + 1] === "|") {
104
+ cell += "|"
105
+ i++
106
+ } else if (ch === "|") {
107
+ cells.push(cell)
108
+ cell = ""
109
+ } else cell += ch
110
+ }
111
+ cells.push(cell)
112
+ return cells.map((c) => c.trim().replace(/<br\s*\/?>/gi, "\n"))
113
+ }
114
+
115
+ export function parseMarkdown(text: string): string[][] {
116
+ return text
117
+ .split(/\r?\n/)
118
+ .filter((line) => line.includes("|") && !isMarkdownSeparator(line))
119
+ .map(splitMarkdownRow)
120
+ }
121
+
122
+ // ---------- HTML ----------
123
+
124
+ const ENTITIES: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", copy: "©", reg: "®", hellip: "…", mdash: "—", ndash: "–" }
125
+
126
+ export function decodeEntities(text: string): string {
127
+ return text.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (match, code: string) => {
128
+ if (code[0] === "#") {
129
+ const n = code[1] === "x" || code[1] === "X" ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10)
130
+ return Number.isFinite(n) ? String.fromCodePoint(n) : match
131
+ }
132
+ return ENTITIES[code.toLowerCase()] ?? match
133
+ })
134
+ }
135
+
136
+ // Parser leve de propósito (sem DOM): funciona igual no navegador e no terminal. Lê a primeira <table>;
137
+ // colspan repete a célula vazia para manter as colunas alinhadas.
138
+ export function parseHtml(text: string): string[][] {
139
+ const clean = text.replace(/<!--[\s\S]*?-->/g, "").replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, "")
140
+ const table = /<table\b[\s\S]*?<\/table>/i.exec(clean)?.[0] ?? clean
141
+ const rows: string[][] = []
142
+ for (const tr of table.match(/<tr\b[\s\S]*?(?=<tr\b|<\/tbody|<\/thead|<\/tfoot|<\/table|$)/gi) ?? []) {
143
+ const cells: string[] = []
144
+ for (const match of tr.matchAll(/<(td|th)\b([^>]*)>([\s\S]*?)(?=<td\b|<th\b|<\/tr|$)/gi)) {
145
+ const value = decodeEntities(
146
+ (match[3] ?? "")
147
+ .replace(/<\/(td|th)>\s*$/i, "")
148
+ .replace(/<br\s*\/?>/gi, "\u0000")
149
+ .replace(/<[^>]+>/g, "")
150
+ .replace(/\s+/g, " "),
151
+ )
152
+ .split("\u0000")
153
+ .map((line) => line.trim())
154
+ .join("\n")
155
+ .trim()
156
+ cells.push(value)
157
+ const span = Number(/colspan\s*=\s*["']?(\d+)/i.exec(match[2] ?? "")?.[1] ?? 1)
158
+ for (let s = 1; s < Math.min(span, 1000); s++) cells.push("")
159
+ }
160
+ if (cells.length) rows.push(cells)
161
+ }
162
+ return rows
163
+ }
164
+
165
+ // ---------- JSON ----------
166
+
167
+ const cellText = (value: unknown): string => (value === null || value === undefined ? "" : typeof value === "object" ? JSON.stringify(value) : String(value))
168
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
169
+
170
+ function parseJsonValue(text: string): unknown {
171
+ try {
172
+ return JSON.parse(text)
173
+ } catch (error) {
174
+ // JSON Lines: um objeto por linha
175
+ const lines = text.split(/\r?\n/).filter((l) => l.trim())
176
+ if (lines.length > 1) {
177
+ try {
178
+ return lines.map((l) => JSON.parse(l))
179
+ } catch {
180
+ // mantém o erro original, mais útil
181
+ }
182
+ }
183
+ throw new TableParseError("json", error instanceof Error ? error.message : String(error))
184
+ }
185
+ }
186
+
187
+ // Aceita: array de objetos, array de arrays, objeto de colunas ({ nome: [...] }), objeto de objetos
188
+ // ({ id: {...} } → coluna "key") e um objeto que embrulha um desses ({ "data": [...] }).
189
+ export function parseJson(text: string, header = true): Table {
190
+ let value = parseJsonValue(text)
191
+ if (isRecord(value)) {
192
+ const entries = Object.entries(value)
193
+ const arrays = entries.filter(([, v]) => Array.isArray(v))
194
+ const scalarList = (v: unknown) => Array.isArray(v) && v.every((x) => !isRecord(x) && !Array.isArray(x))
195
+ if (entries.length > 1 && entries.every(([, v]) => scalarList(v))) {
196
+ const columns = entries.map(([k]) => k)
197
+ const lists = entries.map(([, v]) => v as unknown[])
198
+ const height = Math.max(...lists.map((l) => l.length))
199
+ return { columns, rows: Array.from({ length: height }, (_, i) => lists.map((l) => cellText(l[i]))) }
200
+ }
201
+ if (arrays.length === 1) value = arrays[0]?.[1]
202
+ else if (entries.length > 0 && entries.every(([, v]) => isRecord(v))) value = entries.map(([key, v]) => ({ key, ...(v as Record<string, unknown>) }))
203
+ else value = [value]
204
+ }
205
+ if (!Array.isArray(value)) throw new TableParseError("json", "expected an array or object")
206
+ if (value.length === 0) return { columns: [], rows: [] }
207
+ if (value.every(Array.isArray)) return normalize((value as unknown[][]).map((row) => row.map(cellText)), header)
208
+ const columns: string[] = []
209
+ const known = new Set<string>()
210
+ for (const item of value) {
211
+ for (const key of Object.keys(isRecord(item) ? item : { value: item })) {
212
+ if (!known.has(key)) {
213
+ known.add(key)
214
+ columns.push(key)
215
+ }
216
+ }
217
+ }
218
+ return { columns, rows: value.map((item) => columns.map((key) => cellText(isRecord(item) ? item[key] : key === "value" ? item : undefined))) }
219
+ }
220
+
221
+ // ---------- entrada única ----------
222
+
223
+ export function parseTable(text: string, options: ParseOptions = {}): Parsed {
224
+ const header = options.header ?? true
225
+ if (!text.trim()) return { table: { columns: [], rows: [] }, format: options.from && options.from !== "auto" ? options.from : "csv" }
226
+ const format = !options.from || options.from === "auto" ? detectFormat(text) : options.from
227
+ switch (format) {
228
+ case "json":
229
+ return { table: parseJson(text, header), format }
230
+ case "markdown":
231
+ return { table: normalize(parseMarkdown(text), header), format }
232
+ case "html": {
233
+ const grid = parseHtml(text)
234
+ if (grid.length === 0) throw new TableParseError("html", "no <tr> rows found")
235
+ return { table: normalize(grid, header), format }
236
+ }
237
+ case "csv": {
238
+ const delimiter = !options.delimiter || options.delimiter === "auto" ? detectDelimiter(text) : options.delimiter
239
+ return { table: normalize(parseDelimited(text, DELIMITERS[delimiter]), header), format, delimiter }
240
+ }
241
+ }
242
+ }