@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.
- 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,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
|
+
}
|