@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,262 @@
1
+ // Excel (.xlsx) sem dependências: um .xlsx é um ZIP de arquivos XML (SpreadsheetML).
2
+ // Escrita: uma aba, cabeçalho em negrito e congelado, filtro, larguras de coluna e células tipadas.
3
+ // Leitura: todas as abas, textos compartilhados, fórmulas (valor calculado), datas e células mescladas.
4
+ import { uniqueKeys, type Table } from "./model"
5
+ import { decodeEntities } from "./parse"
6
+ import { columnKinds, displayWidth, typedRows, type SerializeOptions } from "./serialize"
7
+ import { unzip, zip, ZipError } from "./zip"
8
+
9
+ const MAIN = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
10
+ const REL = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
11
+ const XML_HEAD = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
12
+
13
+ // ---------- escrita ----------
14
+
15
+ export function columnName(index: number): string {
16
+ let name = ""
17
+ for (let n = index + 1; n > 0; n = Math.floor((n - 1) / 26)) name = String.fromCharCode(65 + ((n - 1) % 26)) + name
18
+ return name
19
+ }
20
+
21
+ // caracteres de controle são proibidos em XML; o Excel limita a célula a 32.767 caracteres
22
+ const xmlText = (text: string) =>
23
+ text
24
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f￾￿]/g, "")
25
+ .slice(0, 32767)
26
+ .replace(/&/g, "&amp;")
27
+ .replace(/</g, "&lt;")
28
+ .replace(/>/g, "&gt;")
29
+ .replace(/"/g, "&quot;")
30
+
31
+ export function sheetName(name: string | undefined): string {
32
+ const clean = (name ?? "").replace(/[[\]:*?/\\]/g, " ").trim().slice(0, 31)
33
+ return clean || "Sheet1"
34
+ }
35
+
36
+ // Datas ISO (2026-10-21 ou 2026-10-21 13:45[:00]) viram datas de verdade no Excel
37
+ const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/
38
+
39
+ export function excelSerial(iso: string): number | undefined {
40
+ const m = ISO_DATE.exec(iso)
41
+ if (!m) return undefined
42
+ const [year, month, day, hour, minute, second] = m.slice(1).map((v) => Number(v ?? 0)) as number[]
43
+ const ms = Date.UTC(year as number, (month as number) - 1, day, hour, minute, second)
44
+ const check = new Date(ms)
45
+ if (check.getUTCMonth() !== (month as number) - 1 || check.getUTCDate() !== day) return undefined
46
+ return ms / 86_400_000 + 25569
47
+ }
48
+
49
+ // estilos (índices de cellXfs): 0 normal, 1 cabeçalho em negrito, 2 data, 3 data e hora
50
+ const DATE_STYLE = ' s="2"'
51
+ const DATETIME_STYLE = ' s="3"'
52
+
53
+ export function toXlsx(t: Table, o: SerializeOptions & { sheet?: string } = {}): Uint8Array {
54
+ const header = o.header ?? true
55
+ const kinds = columnKinds(t, o)
56
+ const rows = typedRows(t, o, kinds)
57
+ // coluna de datas: todos os valores preenchidos são datas ISO válidas
58
+ const dateColumns = t.columns.map((_, i) => {
59
+ if (!(o.types ?? true) || kinds[i] !== "text") return undefined
60
+ const values = t.rows.map((r) => r[i] ?? "").filter((c) => c !== "")
61
+ if (!values.length || !values.every((c) => excelSerial(c) !== undefined)) return undefined
62
+ return values.some((c) => c.length > 10) ? "datetime" : "date"
63
+ })
64
+ const cell = (ref: string, value: string | number | boolean | null, style = "", column = -1) => {
65
+ if (value === null || value === "") return ""
66
+ const date = dateColumns[column]
67
+ if (date && typeof value === "string") return `<c r="${ref}"${date === "date" ? DATE_STYLE : DATETIME_STYLE}><v>${excelSerial(value)}</v></c>`
68
+ if (typeof value === "number") return `<c r="${ref}"${style}><v>${value}</v></c>`
69
+ if (typeof value === "boolean") return `<c r="${ref}"${style} t="b"><v>${value ? 1 : 0}</v></c>`
70
+ return `<c r="${ref}"${style} t="inlineStr"><is><t xml:space="preserve">${xmlText(value)}</t></is></c>`
71
+ }
72
+ const lines: string[] = []
73
+ const offset = header ? 1 : 0
74
+ if (header) lines.push(`<row r="1">${uniqueKeys(t.columns).map((name, c) => cell(`${columnName(c)}1`, name, ' s="1"')).join("")}</row>`)
75
+ rows.forEach((r, i) => lines.push(`<row r="${i + 1 + offset}">${r.map((v, c) => cell(`${columnName(c)}${i + 1 + offset}`, v ?? null, "", c)).join("")}</row>`))
76
+ const widths = t.columns.map((name, c) => Math.min(60, Math.max(8, displayWidth(name) + 2, ...t.rows.slice(0, 500).map((r) => displayWidth(r[c] ?? "") + 2))))
77
+ const last = `${columnName(Math.max(0, t.columns.length - 1))}${Math.max(1, rows.length + offset)}`
78
+ const sheet =
79
+ XML_HEAD +
80
+ `<worksheet xmlns="${MAIN}" xmlns:r="${REL}">` +
81
+ (header ? '<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>' : "") +
82
+ (widths.length ? `<cols>${widths.map((w, c) => `<col min="${c + 1}" max="${c + 1}" width="${w}" customWidth="1"/>`).join("")}</cols>` : "") +
83
+ `<sheetData>${lines.join("")}</sheetData>` +
84
+ (header && t.columns.length ? `<autoFilter ref="A1:${last}"/>` : "") +
85
+ "</worksheet>"
86
+ const styles =
87
+ XML_HEAD +
88
+ `<styleSheet xmlns="${MAIN}">` +
89
+ '<numFmts count="1"><numFmt numFmtId="164" formatCode="yyyy\\-mm\\-dd\\ hh:mm:ss"/></numFmts>' +
90
+ '<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>' +
91
+ '<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>' +
92
+ '<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>' +
93
+ '<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' +
94
+ '<cellXfs count="4"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/>' +
95
+ '<xf numFmtId="14" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/><xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/></cellXfs>' +
96
+ '<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>' +
97
+ "</styleSheet>"
98
+ const files: Record<string, string> = {
99
+ "[Content_Types].xml":
100
+ XML_HEAD +
101
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
102
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
103
+ '<Default Extension="xml" ContentType="application/xml"/>' +
104
+ '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
105
+ '<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' +
106
+ '<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' +
107
+ "</Types>",
108
+ "_rels/.rels": XML_HEAD + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="${REL}/officeDocument" Target="xl/workbook.xml"/></Relationships>`,
109
+ "xl/workbook.xml": XML_HEAD + `<workbook xmlns="${MAIN}" xmlns:r="${REL}"><sheets><sheet name="${xmlText(sheetName(o.sheet))}" sheetId="1" r:id="rId1"/></sheets>` + (header && t.columns.length ? `<definedNames><definedName name="_xlnm._FilterDatabase" localSheetId="0" hidden="1">'${sheetName(o.sheet).replace(/'/g, "''")}'!$A$1:$${columnName(t.columns.length - 1)}$${Math.max(1, rows.length + 1)}</definedName></definedNames>` : "") + "</workbook>",
110
+ "xl/_rels/workbook.xml.rels": XML_HEAD + `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="${REL}/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="${REL}/styles" Target="styles.xml"/></Relationships>`,
111
+ "xl/worksheets/sheet1.xml": sheet,
112
+ "xl/styles.xml": styles,
113
+ }
114
+ const encoder = new TextEncoder()
115
+ return zip(Object.entries(files).map(([name, text]) => ({ name, data: encoder.encode(text) })))
116
+ }
117
+
118
+ // ---------- leitura ----------
119
+
120
+ export interface Sheet {
121
+ name: string
122
+ rows: string[][]
123
+ }
124
+
125
+ export class XlsxError extends Error {}
126
+
127
+ export const isZip = (bytes: Uint8Array) => bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04
128
+
129
+ const attr = (attrs: string, name: string) => {
130
+ const match = new RegExp(`(?:^|\\s)(?:\\w+:)?${name}\\s*=\\s*("([^"]*)"|'([^']*)')`).exec(attrs)
131
+ return match ? decodeEntities(match[2] ?? match[3] ?? "") : undefined
132
+ }
133
+ // OOXML escapa caracteres de controle como _x000D_
134
+ const ooxmlText = (text: string) => decodeEntities(text).replace(/_x([\da-fA-F]{4})_/g, (_, hex: string) => String.fromCharCode(parseInt(hex, 16)))
135
+ const runsText = (xml: string) => [...xml.replace(/<rPh\b[\s\S]*?<\/rPh>/g, "").matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>|<t\b[^>]*\/>/g)].map((m) => ooxmlText(m[1] ?? "")).join("")
136
+
137
+ // Formatos numéricos de data/hora embutidos no Excel
138
+ const DATE_FORMATS = new Set([14, 15, 16, 17, 18, 19, 20, 21, 22, 27, 30, 36, 45, 46, 47, 50, 57])
139
+
140
+ function isDateFormat(code: string): boolean {
141
+ const bare = code
142
+ .replace(/"[^"]*"/g, "")
143
+ .replace(/\\./g, "")
144
+ .replace(/\[(?!h\]|m\]|s\])[^\]]*\]/gi, "")
145
+ return /[dmyhs]/i.test(bare) && !/^general$/i.test(code.trim())
146
+ }
147
+
148
+ const pad2 = (n: number) => String(n).padStart(2, "0")
149
+
150
+ // Número de série do Excel → data ISO (sistema 1900, ou 1904 de planilhas antigas do Mac)
151
+ export function excelDate(serial: number, date1904 = false): string {
152
+ const days = serial + (date1904 ? 1462 : 0)
153
+ const ms = Math.round((days - 25569) * 86_400_000)
154
+ const d = new Date(ms)
155
+ const date = `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`
156
+ const time = `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`
157
+ if (serial < 1 && !date1904) return time
158
+ return Number.isInteger(Math.round(serial * 86400) / 86400) ? date : `${date} ${time}`
159
+ }
160
+
161
+ function cellIndex(ref: string | undefined): [number, number] | undefined {
162
+ const match = /^\$?([A-Z]+)\$?(\d+)$/i.exec(ref ?? "")
163
+ if (!match) return undefined
164
+ const letters = (match[1] ?? "").toUpperCase()
165
+ let column = 0
166
+ for (const ch of letters) column = column * 26 + (ch.charCodeAt(0) - 64)
167
+ return [Number(match[2]) - 1, column - 1]
168
+ }
169
+
170
+ function resolvePath(base: string, target: string): string {
171
+ if (target.startsWith("/")) return target.slice(1)
172
+ const parts = base.split("/").slice(0, -1)
173
+ for (const part of target.split("/")) {
174
+ if (part === "..") parts.pop()
175
+ else if (part !== ".") parts.push(part)
176
+ }
177
+ return parts.join("/")
178
+ }
179
+
180
+ export async function readXlsx(bytes: Uint8Array): Promise<Sheet[]> {
181
+ let files: ReturnType<typeof unzip>
182
+ try {
183
+ files = unzip(bytes)
184
+ } catch (error) {
185
+ throw new XlsxError(error instanceof ZipError ? error.message : String(error))
186
+ }
187
+ const decoder = new TextDecoder()
188
+ const text = async (path: string) => {
189
+ const get = files.get(path)
190
+ return get ? decoder.decode(await get()) : undefined
191
+ }
192
+ const rels = async (path: string) => {
193
+ const dir = path.split("/").slice(0, -1).join("/")
194
+ const xml = (await text(`${dir ? `${dir}/` : ""}_rels/${path.split("/").pop()}.rels`)) ?? ""
195
+ return new Map([...xml.matchAll(/<Relationship\b([^>]*)\/?>/g)].map((m) => [attr(m[1] ?? "", "Id") ?? "", { target: attr(m[1] ?? "", "Target") ?? "", type: attr(m[1] ?? "", "Type") ?? "" }]))
196
+ }
197
+
198
+ const root = [...(await rels("")).values()].find((r) => r.type.endsWith("/officeDocument"))
199
+ const workbookPath = root ? resolvePath("", root.target) : "xl/workbook.xml"
200
+ const workbook = await text(workbookPath)
201
+ if (!workbook) throw new XlsxError(files.has("word/document.xml") ? "this is a Word file, not a spreadsheet" : "workbook.xml not found (is this an .xlsx?)")
202
+ const date1904 = /<workbookPr\b[^>]*date1904\s*=\s*["'](1|true)["']/i.test(workbook)
203
+ const workbookRels = await rels(workbookPath)
204
+
205
+ const sharedTarget = [...workbookRels.values()].find((r) => r.type.endsWith("/sharedStrings"))
206
+ const sharedXml = (await text(sharedTarget ? resolvePath(workbookPath, sharedTarget.target) : "xl/sharedStrings.xml")) ?? ""
207
+ const shared = [...sharedXml.matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>|<si\b[^>]*\/>/g)].map((m) => runsText(m[1] ?? ""))
208
+
209
+ const stylesTarget = [...workbookRels.values()].find((r) => r.type.endsWith("/styles"))
210
+ const stylesXml = (await text(stylesTarget ? resolvePath(workbookPath, stylesTarget.target) : "xl/styles.xml")) ?? ""
211
+ const customFormats = new Map([...stylesXml.matchAll(/<numFmt\b([^>]*)\/?>/g)].map((m) => [Number(attr(m[1] ?? "", "numFmtId")), attr(m[1] ?? "", "formatCode") ?? ""]))
212
+ const cellXfs = /<cellXfs\b[^>]*>([\s\S]*?)<\/cellXfs>/.exec(stylesXml)?.[1] ?? ""
213
+ const dateStyles = [...cellXfs.matchAll(/<xf\b([^>]*?)(?:\/>|>)/g)].map((m) => {
214
+ const id = Number(attr(m[1] ?? "", "numFmtId") ?? 0)
215
+ return DATE_FORMATS.has(id) || (customFormats.has(id) && isDateFormat(customFormats.get(id) ?? ""))
216
+ })
217
+
218
+ const sheets: Sheet[] = []
219
+ for (const m of workbook.matchAll(/<sheet\b([^>]*)\/?>/g)) {
220
+ const attrs = m[1] ?? ""
221
+ const rel = workbookRels.get(attr(attrs, "id") ?? "")
222
+ if (!rel || !rel.type.endsWith("/worksheet")) continue
223
+ const xml = (await text(resolvePath(workbookPath, rel.target))) ?? ""
224
+ const grid: string[][] = []
225
+ let nextRow = 0
226
+ for (const rowMatch of xml.matchAll(/<row\b([^>]*?)(?:\/>|>([\s\S]*?)<\/row>)/g)) {
227
+ const r = Number(attr(rowMatch[1] ?? "", "r") ?? nextRow + 1) - 1
228
+ nextRow = r + 1
229
+ const row: string[] = []
230
+ let nextColumn = 0
231
+ for (const c of (rowMatch[2] ?? "").matchAll(/<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)) {
232
+ const cAttrs = c[1] ?? ""
233
+ const body = c[2] ?? ""
234
+ const column = cellIndex(attr(cAttrs, "r"))?.[1] ?? nextColumn
235
+ nextColumn = column + 1
236
+ const type = attr(cAttrs, "t") ?? "n"
237
+ const raw = /<v\b[^>]*>([\s\S]*?)<\/v>/.exec(body)?.[1]
238
+ let value = ""
239
+ if (type === "s") value = shared[Number(raw)] ?? ""
240
+ else if (type === "inlineStr") value = runsText(/<is\b[^>]*>([\s\S]*?)<\/is>/.exec(body)?.[1] ?? "")
241
+ else if (type === "b") value = raw === "1" ? "true" : raw === "0" ? "false" : ""
242
+ else if (raw !== undefined && type === "n") {
243
+ const n = Number(raw)
244
+ value = !Number.isFinite(n) ? raw : dateStyles[Number(attr(cAttrs, "s") ?? 0)] ? excelDate(n, date1904) : String(n)
245
+ } else if (raw !== undefined) value = ooxmlText(raw)
246
+ row[column] = value
247
+ }
248
+ grid[r] = Array.from(row, (v) => v ?? "")
249
+ }
250
+ // células mescladas: o valor do canto superior esquerdo se repete no intervalo
251
+ for (const merge of xml.matchAll(/<mergeCell\b[^>]*ref\s*=\s*["']([^"']+)["']/g)) {
252
+ const [from, to] = (merge[1] ?? "").split(":").map(cellIndex)
253
+ if (!from || !to) continue
254
+ const value = grid[from[0]]?.[from[1]] ?? ""
255
+ for (let r = from[0]; r <= Math.min(to[0], from[0] + 10_000); r++) for (let c = from[1]; c <= Math.min(to[1], from[1] + 1000); c++) (grid[r] ??= [])[c] = value
256
+ }
257
+ const rows = Array.from(grid, (r) => Array.from(r ?? [], (v) => v ?? "")).filter((r) => r.some((v) => v !== ""))
258
+ sheets.push({ name: attr(attrs, "name") ?? `Sheet${sheets.length + 1}`, rows })
259
+ }
260
+ if (sheets.length === 0) throw new XlsxError("no worksheets found")
261
+ return sheets
262
+ }
@@ -0,0 +1,127 @@
1
+ // ZIP mínimo para o .xlsx: grava sem compressão (STORE) e lê STORE ou DEFLATE.
2
+ // A descompressão usa o DecompressionStream nativo (navegadores atuais e Node 18+), sem dependências.
3
+
4
+ const CRC_TABLE = (() => {
5
+ const table = new Uint32Array(256)
6
+ for (let n = 0; n < 256; n++) {
7
+ let c = n
8
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
9
+ table[n] = c >>> 0
10
+ }
11
+ return table
12
+ })()
13
+
14
+ export function crc32(data: Uint8Array): number {
15
+ let crc = 0xffffffff
16
+ for (const byte of data) crc = (CRC_TABLE[(crc ^ byte) & 0xff] as number) ^ (crc >>> 8)
17
+ return (crc ^ 0xffffffff) >>> 0
18
+ }
19
+
20
+ export interface ZipEntry {
21
+ name: string
22
+ data: Uint8Array
23
+ }
24
+
25
+ // Data fixa (1980-01-01) para o mesmo conteúdo gerar sempre os mesmos bytes
26
+ const DOS_TIME = 0
27
+ const DOS_DATE = (0 << 9) | (1 << 5) | 1
28
+
29
+ export function zip(entries: ZipEntry[]): Uint8Array {
30
+ const encoder = new TextEncoder()
31
+ const locals: Uint8Array[] = []
32
+ const centrals: Uint8Array[] = []
33
+ let offset = 0
34
+ for (const entry of entries) {
35
+ const name = encoder.encode(entry.name)
36
+ const crc = crc32(entry.data)
37
+ const local = new Uint8Array(30 + name.length)
38
+ const lv = new DataView(local.buffer)
39
+ lv.setUint32(0, 0x04034b50, true)
40
+ lv.setUint16(4, 20, true)
41
+ lv.setUint16(6, 0x0800, true) // nomes em UTF-8
42
+ lv.setUint16(8, 0, true) // STORE
43
+ lv.setUint16(10, DOS_TIME, true)
44
+ lv.setUint16(12, DOS_DATE, true)
45
+ lv.setUint32(14, crc, true)
46
+ lv.setUint32(18, entry.data.length, true)
47
+ lv.setUint32(22, entry.data.length, true)
48
+ lv.setUint16(26, name.length, true)
49
+ local.set(name, 30)
50
+ const central = new Uint8Array(46 + name.length)
51
+ const cv = new DataView(central.buffer)
52
+ cv.setUint32(0, 0x02014b50, true)
53
+ cv.setUint16(4, 20, true)
54
+ cv.setUint16(6, 20, true)
55
+ cv.setUint16(8, 0x0800, true)
56
+ cv.setUint16(10, 0, true)
57
+ cv.setUint16(12, DOS_TIME, true)
58
+ cv.setUint16(14, DOS_DATE, true)
59
+ cv.setUint32(16, crc, true)
60
+ cv.setUint32(20, entry.data.length, true)
61
+ cv.setUint32(24, entry.data.length, true)
62
+ cv.setUint16(28, name.length, true)
63
+ cv.setUint32(42, offset, true)
64
+ central.set(name, 46)
65
+ locals.push(local, entry.data)
66
+ centrals.push(central)
67
+ offset += local.length + entry.data.length
68
+ }
69
+ const centralSize = centrals.reduce((sum, c) => sum + c.length, 0)
70
+ const end = new Uint8Array(22)
71
+ const ev = new DataView(end.buffer)
72
+ ev.setUint32(0, 0x06054b50, true)
73
+ ev.setUint16(8, entries.length, true)
74
+ ev.setUint16(10, entries.length, true)
75
+ ev.setUint32(12, centralSize, true)
76
+ ev.setUint32(16, offset, true)
77
+ const out = new Uint8Array(offset + centralSize + end.length)
78
+ let position = 0
79
+ for (const part of [...locals, ...centrals, end]) {
80
+ out.set(part, position)
81
+ position += part.length
82
+ }
83
+ return out
84
+ }
85
+
86
+ export class ZipError extends Error {}
87
+
88
+ async function inflateRaw(data: Uint8Array): Promise<Uint8Array> {
89
+ const stream = new Blob([data as BlobPart]).stream().pipeThrough(new DecompressionStream("deflate-raw"))
90
+ return new Uint8Array(await new Response(stream).arrayBuffer())
91
+ }
92
+
93
+ // Índice do arquivo: nome → função que devolve o conteúdo (só descompacta o que for pedido)
94
+ export function unzip(bytes: Uint8Array): Map<string, () => Promise<Uint8Array>> {
95
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
96
+ let end = -1
97
+ for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 22 - 0xffff); i--) {
98
+ if (view.getUint32(i, true) === 0x06054b50) {
99
+ end = i
100
+ break
101
+ }
102
+ }
103
+ if (end < 0) throw new ZipError("not a ZIP file")
104
+ const count = view.getUint16(end + 10, true)
105
+ let position = view.getUint32(end + 16, true)
106
+ const decoder = new TextDecoder()
107
+ const files = new Map<string, () => Promise<Uint8Array>>()
108
+ for (let n = 0; n < count; n++) {
109
+ if (view.getUint32(position, true) !== 0x02014b50) throw new ZipError("corrupted central directory")
110
+ const method = view.getUint16(position + 10, true)
111
+ const compressed = view.getUint32(position + 20, true)
112
+ const nameLength = view.getUint16(position + 28, true)
113
+ const extraLength = view.getUint16(position + 30, true)
114
+ const commentLength = view.getUint16(position + 32, true)
115
+ const local = view.getUint32(position + 42, true)
116
+ const name = decoder.decode(bytes.subarray(position + 46, position + 46 + nameLength))
117
+ const start = local + 30 + view.getUint16(local + 26, true) + view.getUint16(local + 28, true)
118
+ const data = bytes.subarray(start, start + compressed)
119
+ files.set(name, async () => {
120
+ if (method === 0) return data
121
+ if (method === 8) return inflateRaw(data)
122
+ throw new ZipError(`unsupported compression method ${method}`)
123
+ })
124
+ position += 46 + nameLength + extraLength + commentLength
125
+ }
126
+ return files
127
+ }
@@ -0,0 +1,50 @@
1
+ // Detecta se o número está em segundos, milissegundos, micro ou nanossegundos pela magnitude
2
+ export type Unit = "s" | "ms" | "us" | "ns"
3
+
4
+ export function detectUnit(value: number): Unit {
5
+ const abs = Math.abs(value)
6
+ if (abs < 1e11) return "s"
7
+ if (abs < 1e14) return "ms"
8
+ if (abs < 1e17) return "us"
9
+ return "ns"
10
+ }
11
+
12
+ const TO_MS: Record<Unit, number> = { s: 1000, ms: 1, us: 1e-3, ns: 1e-6 }
13
+
14
+ export function parseTimestamp(raw: string): { date: Date, unit: Unit } | undefined {
15
+ const trimmed = raw.trim()
16
+ if (!/^-?\d+(\.\d+)?$/.test(trimmed)) return undefined
17
+ const value = Number(trimmed)
18
+ const unit = detectUnit(value)
19
+ const date = new Date(value * TO_MS[unit])
20
+ return Number.isNaN(date.getTime()) ? undefined : { date, unit }
21
+ }
22
+
23
+ export function toUnix(date: Date): { seconds: number, milliseconds: number } {
24
+ return { seconds: Math.floor(date.getTime() / 1000), milliseconds: date.getTime() }
25
+ }
26
+
27
+ // "há 3 dias" / "in 2 hours" via Intl, escolhendo a maior unidade inteira
28
+ const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
29
+ ["year", 31_536_000_000],
30
+ ["month", 2_592_000_000],
31
+ ["day", 86_400_000],
32
+ ["hour", 3_600_000],
33
+ ["minute", 60_000],
34
+ ["second", 1000],
35
+ ]
36
+
37
+ export function relative(date: Date, locale: string, now: Date = new Date()): string {
38
+ const diff = date.getTime() - now.getTime()
39
+ const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" })
40
+ for (const [unit, ms] of UNITS) {
41
+ if (Math.abs(diff) >= ms || unit === "second") return formatter.format(Math.round(diff / ms), unit)
42
+ }
43
+ return ""
44
+ }
45
+
46
+ // Valor para <input type="datetime-local"> no fuso local
47
+ export function toLocalInput(date: Date): string {
48
+ const offset = date.getTimezoneOffset() * 60_000
49
+ return new Date(date.getTime() - offset).toISOString().slice(0, 19)
50
+ }
@@ -0,0 +1,25 @@
1
+ // Conversão de parâmetros de URL compartilhada pelo site (useUrlState) e pelo script de terminal (src/cli)
2
+ // Converte um valor de estado de/para texto na URL; `parse` devolve undefined para valores inválidos
3
+ export interface UrlCodec<T> {
4
+ parse: (raw: string) => T | undefined
5
+ format: (value: T) => string
6
+ }
7
+
8
+ export const codec = {
9
+ string: { parse: (raw) => raw, format: (value) => value } satisfies UrlCodec<string>,
10
+ number: { parse: (raw) => (raw !== "" && Number.isFinite(Number(raw)) ? Number(raw) : undefined), format: String } satisfies UrlCodec<number>,
11
+ boolean: { parse: (raw) => (raw === "1" ? true : raw === "0" ? false : undefined), format: (value) => (value ? "1" : "0") } satisfies UrlCodec<boolean>,
12
+ oneOf<T extends string | number>(options: readonly T[]): UrlCodec<T> {
13
+ return { parse: (raw) => options.find((option) => String(option) === raw), format: String }
14
+ },
15
+ // listas: valores separados por vírgula
16
+ list<T>(item: UrlCodec<T>): UrlCodec<T[]> {
17
+ return {
18
+ parse: (raw) => {
19
+ const items = raw.split(",").map((part) => item.parse(part))
20
+ return items.every((value) => value !== undefined) ? (items as T[]) : undefined
21
+ },
22
+ format: (values) => values.map(item.format).join(","),
23
+ }
24
+ },
25
+ }
package/src/uuid.ts ADDED
@@ -0,0 +1,53 @@
1
+ // UUID v4 (aleatório) e v7 (ordenável por tempo, RFC 9562)
2
+ export type UuidVersion = 4 | 7
3
+
4
+ function hex(bytes: Uint8Array): string {
5
+ return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("")
6
+ }
7
+
8
+ function format(raw: string): string {
9
+ return `${raw.slice(0, 8)}-${raw.slice(8, 12)}-${raw.slice(12, 16)}-${raw.slice(16, 20)}-${raw.slice(20)}`
10
+ }
11
+
12
+ export function uuidV4(bytes: Uint8Array = crypto.getRandomValues(new Uint8Array(16))): string {
13
+ const b = Uint8Array.from(bytes)
14
+ b[6] = ((b[6] ?? 0) & 0x0f) | 0x40
15
+ b[8] = ((b[8] ?? 0) & 0x3f) | 0x80
16
+ return format(hex(b))
17
+ }
18
+
19
+ export function uuidV7(timestamp: number = Date.now(), bytes: Uint8Array = crypto.getRandomValues(new Uint8Array(10))): string {
20
+ const b = new Uint8Array(16)
21
+ // 48 bits de milissegundos desde a época Unix
22
+ let ms = timestamp
23
+ for (let i = 5; i >= 0; i--) {
24
+ b[i] = ms % 256
25
+ ms = Math.floor(ms / 256)
26
+ }
27
+ b.set(bytes.slice(0, 10), 6)
28
+ b[6] = ((b[6] ?? 0) & 0x0f) | 0x70
29
+ b[8] = ((b[8] ?? 0) & 0x3f) | 0x80
30
+ return format(hex(b))
31
+ }
32
+
33
+ export const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
34
+
35
+ export interface UuidInfo {
36
+ valid: boolean
37
+ version?: number
38
+ timestamp?: Date
39
+ }
40
+
41
+ export function inspectUuid(value: string): UuidInfo {
42
+ const uuid = value.trim()
43
+ if (!UUID_PATTERN.test(uuid)) return { valid: false }
44
+ const version = parseInt(uuid[14] ?? "0", 16)
45
+ const info: UuidInfo = { valid: true, version }
46
+ if (version === 7) info.timestamp = new Date(parseInt(uuid.replace(/-/g, "").slice(0, 12), 16))
47
+ return info
48
+ }
49
+
50
+ export function formatUuid(uuid: string, { uppercase = false, hyphens = true } = {}): string {
51
+ const value = hyphens ? uuid : uuid.replace(/-/g, "")
52
+ return uppercase ? value.toUpperCase() : value
53
+ }