@smartcat/sanity-plugin 1.0.0 → 1.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/README.md +294 -0
- package/dist/_chunks-cjs/constants.cjs +13 -0
- package/dist/_chunks-cjs/constants.cjs.map +1 -0
- package/dist/_chunks-cjs/index.cjs +174 -0
- package/dist/_chunks-cjs/index.cjs.map +1 -0
- package/dist/_chunks-cjs/index2.cjs +338 -0
- package/dist/_chunks-cjs/index2.cjs.map +1 -0
- package/dist/_chunks-cjs/workflow.cjs +16 -0
- package/dist/_chunks-cjs/workflow.cjs.map +1 -0
- package/dist/_chunks-es/constants.js +14 -0
- package/dist/_chunks-es/constants.js.map +1 -0
- package/dist/_chunks-es/index.js +175 -0
- package/dist/_chunks-es/index.js.map +1 -0
- package/dist/_chunks-es/index2.js +340 -0
- package/dist/_chunks-es/index2.js.map +1 -0
- package/dist/_chunks-es/workflow.js +17 -0
- package/dist/_chunks-es/workflow.js.map +1 -0
- package/dist/export.cjs +160 -0
- package/dist/export.cjs.map +1 -0
- package/dist/export.d.cts +162 -0
- package/dist/export.d.ts +162 -0
- package/dist/export.js +162 -0
- package/dist/export.js.map +1 -0
- package/dist/import.cjs +169 -0
- package/dist/import.cjs.map +1 -0
- package/dist/import.d.cts +155 -0
- package/dist/import.d.ts +155 -0
- package/dist/import.js +169 -0
- package/dist/import.js.map +1 -0
- package/dist/index.cjs +2172 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +122 -0
- package/dist/index.d.ts +122 -0
- package/dist/index.js +2179 -0
- package/dist/index.js.map +1 -0
- package/dist/locjson.cjs +12 -0
- package/dist/locjson.cjs.map +1 -0
- package/dist/locjson.d.cts +291 -0
- package/dist/locjson.d.ts +291 -0
- package/dist/locjson.js +12 -0
- package/dist/locjson.js.map +1 -0
- package/dist/progress.cjs +16 -0
- package/dist/progress.cjs.map +1 -0
- package/dist/progress.d.cts +277 -0
- package/dist/progress.d.ts +277 -0
- package/dist/progress.js +16 -0
- package/dist/progress.js.map +1 -0
- package/dist/smartcat.cjs +287 -0
- package/dist/smartcat.cjs.map +1 -0
- package/dist/smartcat.d.cts +234 -0
- package/dist/smartcat.d.ts +234 -0
- package/dist/smartcat.js +287 -0
- package/dist/smartcat.js.map +1 -0
- package/dist/templates.cjs +12 -0
- package/dist/templates.cjs.map +1 -0
- package/dist/templates.d.cts +52 -0
- package/dist/templates.d.ts +52 -0
- package/dist/templates.js +12 -0
- package/dist/templates.js.map +1 -0
- package/package.json +101 -15
- package/src/actions/AddToProjectAction.tsx +274 -0
- package/src/export/export.test.ts +537 -0
- package/src/export/index.ts +393 -0
- package/src/form/TranslationStatusInput.tsx +212 -0
- package/src/import/import.test.ts +346 -0
- package/src/import/index.ts +418 -0
- package/src/index.ts +63 -0
- package/src/lib/constants.ts +23 -0
- package/src/lib/documentTitle.test.ts +43 -0
- package/src/lib/documentTitle.ts +30 -0
- package/src/lib/languageMap.test.ts +56 -0
- package/src/lib/languageMap.ts +71 -0
- package/src/lib/linkedDocuments.test.ts +56 -0
- package/src/lib/linkedDocuments.ts +100 -0
- package/src/lib/locjson/deserialize.ts +60 -0
- package/src/lib/locjson/fields.ts +355 -0
- package/src/lib/locjson/filename.ts +34 -0
- package/src/lib/locjson/index.ts +10 -0
- package/src/lib/locjson/locjson.test.ts +615 -0
- package/src/lib/locjson/paths.test.ts +41 -0
- package/src/lib/locjson/paths.ts +73 -0
- package/src/lib/locjson/portableText.ts +157 -0
- package/src/lib/locjson/serialize.ts +124 -0
- package/src/lib/locjson/types.ts +106 -0
- package/src/lib/log.ts +33 -0
- package/src/lib/projectItems.ts +31 -0
- package/src/lib/resolveConfig.test.ts +62 -0
- package/src/lib/resolveConfig.ts +54 -0
- package/src/lib/workflow.test.ts +37 -0
- package/src/lib/workflow.ts +48 -0
- package/src/lib/zip.fixtures.ts +75 -0
- package/src/lib/zip.test.ts +110 -0
- package/src/lib/zip.ts +164 -0
- package/src/progress/core.ts +409 -0
- package/src/progress/index.ts +7 -0
- package/src/progress/progress.test.ts +290 -0
- package/src/schema/settings.ts +55 -0
- package/src/schema/translatableOptions.ts +25 -0
- package/src/schema/translationProject.ts +268 -0
- package/src/smartcat/client.test.ts +222 -0
- package/src/smartcat/client.ts +351 -0
- package/src/smartcat/index.ts +10 -0
- package/src/smartcat/types.ts +99 -0
- package/src/templates/core.ts +55 -0
- package/src/templates/index.ts +5 -0
- package/src/templates/templates.test.ts +50 -0
- package/src/tool/Dashboard.tsx +46 -0
- package/src/tool/ItemProgress.tsx +176 -0
- package/src/tool/LogPanel.tsx +207 -0
- package/src/tool/ProjectView.tsx +1004 -0
- package/src/tool/ProjectsView.tsx +208 -0
- package/src/tool/StatusBadge.tsx +33 -0
- package/src/tool/StudioInit.tsx +42 -0
- package/src/tool/WorkflowSelect.tsx +41 -0
- package/src/tool/data.ts +86 -0
- package/src/tool/index.ts +19 -0
- package/src/tool/processing.test.ts +698 -0
- package/src/tool/processing.ts +839 -0
- package/src/tool/useTemplates.ts +84 -0
- package/src/tool/useWorkflowSelection.ts +65 -0
- package/src/types.ts +77 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow selection for new projects. A project stores a single `workflow`
|
|
3
|
+
* string: either `template:<smartcatTemplateId>` or one of the standard presets.
|
|
4
|
+
* The browser sets it at creation; the export Function decodes it into the
|
|
5
|
+
* Smartcat `createProject` params (a template id, or explicit workflow stages).
|
|
6
|
+
*
|
|
7
|
+
* Dependency-free so it bundles into the thin export Function.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const TEMPLATE_PREFIX = 'template:'
|
|
11
|
+
export const STANDARD_AI_HUMAN = 'standard:ai-human'
|
|
12
|
+
export const STANDARD_AI = 'standard:ai'
|
|
13
|
+
|
|
14
|
+
/** Backwards-compatible default: matches the previous hard-coded behavior. */
|
|
15
|
+
export const DEFAULT_WORKFLOW = STANDARD_AI_HUMAN
|
|
16
|
+
|
|
17
|
+
/** Labels for the two standard presets (rendered under the "Standard workflows" group). */
|
|
18
|
+
export const STANDARD_WORKFLOW_OPTIONS: {value: string; label: string}[] = [
|
|
19
|
+
{value: STANDARD_AI_HUMAN, label: 'AI Translation + Human Review'},
|
|
20
|
+
{value: STANDARD_AI, label: 'AI Translation'},
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
export interface WorkflowCreateParams {
|
|
24
|
+
templateId?: string
|
|
25
|
+
workflowStages?: string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether a `workflow` value is currently selectable: the standard presets always
|
|
30
|
+
* are; a `template:<id>` is valid only while that id still exists in the cached
|
|
31
|
+
* template list. Empty / unknown values are invalid (no selection).
|
|
32
|
+
*/
|
|
33
|
+
export function isWorkflowValid(workflow: string, templateIds: string[]): boolean {
|
|
34
|
+
if (workflow === STANDARD_AI_HUMAN || workflow === STANDARD_AI) return true
|
|
35
|
+
if (workflow.startsWith(TEMPLATE_PREFIX)) {
|
|
36
|
+
return templateIds.includes(workflow.slice(TEMPLATE_PREFIX.length))
|
|
37
|
+
}
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Decodes a stored `workflow` value into Smartcat create-project params. */
|
|
42
|
+
export function resolveWorkflowCreateParams(workflow?: string): WorkflowCreateParams {
|
|
43
|
+
if (workflow && workflow.startsWith(TEMPLATE_PREFIX)) {
|
|
44
|
+
return {templateId: workflow.slice(TEMPLATE_PREFIX.length)}
|
|
45
|
+
}
|
|
46
|
+
if (workflow === STANDARD_AI) return {workflowStages: ['translation']}
|
|
47
|
+
return {workflowStages: ['translation', 'postediting']}
|
|
48
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test-only ZIP builder: assembles a minimal, structurally valid archive
|
|
3
|
+
* (local headers + central directory + EOCD) for exercising the reader.
|
|
4
|
+
* CRCs are zeroed — the reader doesn't verify them, and neither do we.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface ZipFixtureEntry {
|
|
8
|
+
name: string
|
|
9
|
+
content: string
|
|
10
|
+
/** 0 = stored (default), 8 = deflate. */
|
|
11
|
+
method?: 0 | 8
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function buildZip(entries: ZipFixtureEntry[]): Promise<Uint8Array> {
|
|
15
|
+
const encoder = new TextEncoder()
|
|
16
|
+
const chunks: Uint8Array[] = []
|
|
17
|
+
const central: Uint8Array[] = []
|
|
18
|
+
let offset = 0
|
|
19
|
+
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
const method = entry.method ?? 0
|
|
22
|
+
const nameBytes = encoder.encode(entry.name)
|
|
23
|
+
const raw = encoder.encode(entry.content)
|
|
24
|
+
const data = method === 8 ? await deflateRaw(raw) : raw
|
|
25
|
+
|
|
26
|
+
const local = new Uint8Array(30 + nameBytes.length)
|
|
27
|
+
const lv = new DataView(local.buffer)
|
|
28
|
+
lv.setUint32(0, 0x04034b50, true)
|
|
29
|
+
lv.setUint16(4, 20, true) // version needed
|
|
30
|
+
lv.setUint16(8, method, true)
|
|
31
|
+
lv.setUint32(18, data.length, true) // compressed size
|
|
32
|
+
lv.setUint32(22, raw.length, true) // uncompressed size
|
|
33
|
+
lv.setUint16(26, nameBytes.length, true)
|
|
34
|
+
local.set(nameBytes, 30)
|
|
35
|
+
chunks.push(local, data)
|
|
36
|
+
|
|
37
|
+
const dir = new Uint8Array(46 + nameBytes.length)
|
|
38
|
+
const dv = new DataView(dir.buffer)
|
|
39
|
+
dv.setUint32(0, 0x02014b50, true)
|
|
40
|
+
dv.setUint16(4, 20, true) // version made by
|
|
41
|
+
dv.setUint16(6, 20, true) // version needed
|
|
42
|
+
dv.setUint16(10, method, true)
|
|
43
|
+
dv.setUint32(20, data.length, true)
|
|
44
|
+
dv.setUint32(24, raw.length, true)
|
|
45
|
+
dv.setUint16(28, nameBytes.length, true)
|
|
46
|
+
dv.setUint32(42, offset, true) // local header offset
|
|
47
|
+
dir.set(nameBytes, 46)
|
|
48
|
+
central.push(dir)
|
|
49
|
+
|
|
50
|
+
offset += local.length + data.length
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const centralSize = central.reduce((sum, c) => sum + c.length, 0)
|
|
54
|
+
const eocd = new Uint8Array(22)
|
|
55
|
+
const ev = new DataView(eocd.buffer)
|
|
56
|
+
ev.setUint32(0, 0x06054b50, true)
|
|
57
|
+
ev.setUint16(8, entries.length, true) // entries on this disk
|
|
58
|
+
ev.setUint16(10, entries.length, true) // entries total
|
|
59
|
+
ev.setUint32(12, centralSize, true)
|
|
60
|
+
ev.setUint32(16, offset, true) // central directory offset
|
|
61
|
+
|
|
62
|
+
const total = offset + centralSize + eocd.length
|
|
63
|
+
const out = new Uint8Array(total)
|
|
64
|
+
let pos = 0
|
|
65
|
+
for (const chunk of [...chunks, ...central, eocd]) {
|
|
66
|
+
out.set(chunk, pos)
|
|
67
|
+
pos += chunk.length
|
|
68
|
+
}
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function deflateRaw(data: Uint8Array): Promise<Uint8Array> {
|
|
73
|
+
const stream = new Blob([data.slice()]).stream().pipeThrough(new CompressionStream('deflate-raw'))
|
|
74
|
+
return new Uint8Array(await new Response(stream).arrayBuffer())
|
|
75
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest'
|
|
2
|
+
import {isZip, unzip, unzipText} from './zip'
|
|
3
|
+
import {buildZip} from './zip.fixtures'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A REAL Smartcat-format export archive: generated with the exact library and
|
|
7
|
+
* settings production uses — Smartcat.Fork.SharpZipLib 1.5.1, `ZipOutputStream
|
|
8
|
+
* { UseZip64 = On }`, streamed entries with `IsUnicodeText = true` (see
|
|
9
|
+
* ExportDocumentsProcessor.cs:1525 in the smartcat repo). Forced ZIP64 means
|
|
10
|
+
* every central-directory entry carries 0xFFFFFFFF size placeholders with the
|
|
11
|
+
* real values in ZIP64 extra fields — the format that a non-ZIP64 reader fails
|
|
12
|
+
* on (which silently degraded every batch to per-document fallback in v1).
|
|
13
|
+
*/
|
|
14
|
+
const SMARTCAT_ZIP64_ARCHIVE_B64 =
|
|
15
|
+
'UEsDBC0AAAgIAAeK6FzGlJGm//////////8iABQAcGFnZS9BYm91dC1hYmMxMjM0NShmci1GUikubG9janNvbgEAEAB8AAAAAAAA' +
|
|
16
|
+
'AGwAAAAAAAAAFYsxDsMgEAT/sjUukjiNS1fJGyIKgk8RNuEsOKREiL/7vNVqZ7Zhz7xTlkAFU8NvKC4F+Z99YV+/lOS5YIJ7+8v1' +
|
|
17
|
+
'Nt4H0qB3g6qefl4NG6kPCRIJBoVr9qQAD4qRYQ3E5Q/JOc2cVuWw3fYDUEsDBC0AAAgIAAeK6Fxyl2Ox//////////8iABQAcGFn' +
|
|
18
|
+
'ZS9Iw7ZtZS1kZWY2Nzg5MChmci1GUikubG9janNvbgEAEAB7AAAAAAAAAHEAAAAAAAAAFYsxDsIwEAT/srUjUZGQH1BQp0AuotwF' +
|
|
19
|
+
'WQQ7Op8lkOVv8QE+xmWr1exsxS5pZ9HAGWPFu8tzDPo5OqWlvDjqlTCCeD33w+XUrRa05lDMs8+94snmQ4NuDIeciixsA6YkG8E7' +
|
|
20
|
+
'6CwP1gPdft9IDN98+wNQSwMELQAACAgAB4roXGMJtzf//////////ysAFAByZXNwb25zaXZlSW1hZ2UvQWx0LTk5ODg3NzY2KGZy' +
|
|
21
|
+
'LUZSKS5sb2Nqc29uAQAQAEUAAAAAAAAAQQAAAAAAAACrViooyi9ILSrJTC1WsqpWqtAtTszLLKkEsVPyk0tzU/NKPFOUrJQsLS0s' +
|
|
22
|
+
'zM3NzHQTgUCptlZHqRSoDqgnOrYWAFBLAQIzAC0AAAgIAAeK6FzGlJGm//////////8iABQAAAAAAAAAAAAAAAAAAABwYWdlL0Fi' +
|
|
23
|
+
'b3V0LWFiYzEyMzQ1KGZyLUZSKS5sb2Nqc29uAQAQAHwAAAAAAAAAbAAAAAAAAABQSwECMwAtAAAICAAHiuhccpdjsf//////////' +
|
|
24
|
+
'IgAUAAAAAAAAAAAAAADAAAAAcGFnZS9Iw7ZtZS1kZWY2Nzg5MChmci1GUikubG9janNvbgEAEAB7AAAAAAAAAHEAAAAAAAAAUEsB' +
|
|
25
|
+
'AjMALQAACAgAB4roXGMJtzf//////////ysAFAAAAAAAAAAAAAAAhQEAAHJlc3BvbnNpdmVJbWFnZS9BbHQtOTk4ODc3NjYoZnIt' +
|
|
26
|
+
'RlIpLmxvY2pzb24BABAARQAAAAAAAABBAAAAAAAAAFBLBQYAAAAAAwADADUBAAAjAgAAAAA='
|
|
27
|
+
|
|
28
|
+
function fromBase64(b64: string): Uint8Array {
|
|
29
|
+
const bin = atob(b64)
|
|
30
|
+
const out = new Uint8Array(bin.length)
|
|
31
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
|
|
32
|
+
return out
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('isZip', () => {
|
|
36
|
+
it('recognizes the PK magic', async () => {
|
|
37
|
+
const zip = await buildZip([{name: 'a.txt', content: 'hello'}])
|
|
38
|
+
expect(isZip(zip)).toBe(true)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('rejects plain text and short payloads', () => {
|
|
42
|
+
expect(isZip(new TextEncoder().encode('{"units":[]}'))).toBe(false)
|
|
43
|
+
expect(isZip(new Uint8Array([0x50, 0x4b]))).toBe(false)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('unzip', () => {
|
|
48
|
+
it('parses a real Smartcat production-format archive (SharpZipLib, forced ZIP64)', async () => {
|
|
49
|
+
const bytes = fromBase64(SMARTCAT_ZIP64_ARCHIVE_B64)
|
|
50
|
+
expect(isZip(bytes)).toBe(true)
|
|
51
|
+
|
|
52
|
+
const entries = await unzipText(bytes)
|
|
53
|
+
expect(entries.map((e) => e.name)).toEqual([
|
|
54
|
+
'page/About-abc12345(fr-FR).locjson',
|
|
55
|
+
'page/Höme-def67890(fr-FR).locjson',
|
|
56
|
+
'responsiveImage/Alt-99887766(fr-FR).locjson',
|
|
57
|
+
])
|
|
58
|
+
// Contents parse as LocJSON and carry the x-sanity correlation metadata.
|
|
59
|
+
const first = JSON.parse(entries[0].content)
|
|
60
|
+
expect(first.properties['x-sanity'].documentId).toBe('abc12345-eeee')
|
|
61
|
+
expect(first.units[0].target).toEqual(['Bonjour'])
|
|
62
|
+
const second = JSON.parse(entries[1].content)
|
|
63
|
+
expect(second.properties['x-sanity'].documentId).toBe('def67890-ffff')
|
|
64
|
+
expect(second.units[0].target).toEqual(['Mönde'])
|
|
65
|
+
const third = JSON.parse(entries[2].content)
|
|
66
|
+
expect(third.properties['x-sanity'].documentId).toBe('99887766-aaaa')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('extracts stored entries', async () => {
|
|
70
|
+
const zip = await buildZip([
|
|
71
|
+
{name: 'page/About-abc12345(fr).locjson', content: '{"a":1}'},
|
|
72
|
+
{name: 'page/Home-def67890(fr).locjson', content: '{"b":2}'},
|
|
73
|
+
])
|
|
74
|
+
const entries = await unzipText(zip)
|
|
75
|
+
expect(entries).toEqual([
|
|
76
|
+
{name: 'page/About-abc12345(fr).locjson', content: '{"a":1}'},
|
|
77
|
+
{name: 'page/Home-def67890(fr).locjson', content: '{"b":2}'},
|
|
78
|
+
])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('inflates deflate-compressed entries', async () => {
|
|
82
|
+
const long = 'translated content '.repeat(200)
|
|
83
|
+
const zip = await buildZip([{name: 'doc.locjson', content: long, method: 8}])
|
|
84
|
+
const entries = await unzipText(zip)
|
|
85
|
+
expect(entries).toHaveLength(1)
|
|
86
|
+
expect(entries[0].content).toBe(long)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('skips directory entries', async () => {
|
|
90
|
+
const zip = await buildZip([
|
|
91
|
+
{name: 'page/', content: ''},
|
|
92
|
+
{name: 'page/About.locjson', content: '{}'},
|
|
93
|
+
])
|
|
94
|
+
const entries = await unzip(zip)
|
|
95
|
+
expect(entries.map((e) => e.name)).toEqual(['page/About.locjson'])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('preserves non-ASCII entry names and contents', async () => {
|
|
99
|
+
const zip = await buildZip([{name: 'sida/Åh nej-abc12345(sv).locjson', content: '{"t":"översättning"}', method: 8}])
|
|
100
|
+
const entries = await unzipText(zip)
|
|
101
|
+
expect(entries[0].name).toBe('sida/Åh nej-abc12345(sv).locjson')
|
|
102
|
+
expect(entries[0].content).toBe('{"t":"översättning"}')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('throws on payloads without an end-of-central-directory record', async () => {
|
|
106
|
+
await expect(unzip(new TextEncoder().encode('PK\x03\x04 not really a zip'))).rejects.toThrow(
|
|
107
|
+
/end-of-central-directory/,
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
})
|
package/src/lib/zip.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal ZIP reader for Smartcat batch-export archives.
|
|
3
|
+
*
|
|
4
|
+
* Dependency-free by design (like the rest of the Function-side code): built on
|
|
5
|
+
* `DataView` + the global `DecompressionStream` (Node 18+ / browsers). Supports
|
|
6
|
+
* the two compression methods Smartcat archives use — stored (0) and deflate (8).
|
|
7
|
+
*
|
|
8
|
+
* ZIP64 is REQUIRED, not an edge case: Smartcat writes export archives with
|
|
9
|
+
* SharpZipLib's `UseZip64 = On` (forced), so central-directory size/offset
|
|
10
|
+
* fields hold `0xFFFFFFFF` placeholders with the real 64-bit values in each
|
|
11
|
+
* entry's ZIP64 extra field — even for tiny archives. Large archives
|
|
12
|
+
* additionally use the ZIP64 end-of-central-directory record.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface ZipEntry {
|
|
16
|
+
/** Entry path inside the archive (folders separated by `/`). */
|
|
17
|
+
name: string
|
|
18
|
+
data: Uint8Array
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const LOCAL_HEADER_SIG = 0x04034b50
|
|
22
|
+
const CENTRAL_HEADER_SIG = 0x02014b50
|
|
23
|
+
const EOCD_SIG = 0x06054b50
|
|
24
|
+
const ZIP64_EOCD_SIG = 0x06064b50
|
|
25
|
+
const ZIP64_EOCD_LOCATOR_SIG = 0x07064b50
|
|
26
|
+
/** EOCD record is 22 bytes + an optional comment of up to 65535 bytes. */
|
|
27
|
+
const EOCD_MIN_SIZE = 22
|
|
28
|
+
/** ZIP64 EOCD locator is a fixed 20 bytes, sitting right before the classic EOCD. */
|
|
29
|
+
const ZIP64_LOCATOR_SIZE = 20
|
|
30
|
+
/** Placeholder meaning "the real value is in the ZIP64 extra field / record". */
|
|
31
|
+
const U32_MAX = 0xffffffff
|
|
32
|
+
const U16_MAX = 0xffff
|
|
33
|
+
/** Extra-field header id of the ZIP64 extended information block. */
|
|
34
|
+
const ZIP64_EXTRA_ID = 0x0001
|
|
35
|
+
|
|
36
|
+
/** Whether the payload looks like a ZIP archive (`PK\x03\x04` magic). */
|
|
37
|
+
export function isZip(bytes: Uint8Array): boolean {
|
|
38
|
+
return (
|
|
39
|
+
bytes.length >= 4 &&
|
|
40
|
+
bytes[0] === 0x50 &&
|
|
41
|
+
bytes[1] === 0x4b &&
|
|
42
|
+
bytes[2] === 0x03 &&
|
|
43
|
+
bytes[3] === 0x04
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Extracts all file entries (directories are skipped). */
|
|
48
|
+
export async function unzip(bytes: Uint8Array): Promise<ZipEntry[]> {
|
|
49
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
50
|
+
const eocd = findEndOfCentralDirectory(view)
|
|
51
|
+
const entries: ZipEntry[] = []
|
|
52
|
+
|
|
53
|
+
let offset = eocd.centralDirectoryOffset
|
|
54
|
+
for (let i = 0; i < eocd.entryCount; i++) {
|
|
55
|
+
if (view.getUint32(offset, true) !== CENTRAL_HEADER_SIG) {
|
|
56
|
+
throw new Error(`Invalid ZIP: bad central directory entry at ${offset}`)
|
|
57
|
+
}
|
|
58
|
+
const method = view.getUint16(offset + 10, true)
|
|
59
|
+
let compressedSize: number = view.getUint32(offset + 20, true)
|
|
60
|
+
let uncompressedSize: number = view.getUint32(offset + 24, true)
|
|
61
|
+
const nameLength = view.getUint16(offset + 28, true)
|
|
62
|
+
const extraLength = view.getUint16(offset + 30, true)
|
|
63
|
+
const commentLength = view.getUint16(offset + 32, true)
|
|
64
|
+
let localHeaderOffset: number = view.getUint32(offset + 42, true)
|
|
65
|
+
const name = new TextDecoder().decode(bytes.subarray(offset + 46, offset + 46 + nameLength))
|
|
66
|
+
|
|
67
|
+
// ZIP64: any field at its 32-bit max is a placeholder; the real value lives
|
|
68
|
+
// in the entry's ZIP64 extra block, in a fixed order, holding ONLY the
|
|
69
|
+
// fields that overflowed (Smartcat's forced-ZIP64 writer always defers the
|
|
70
|
+
// sizes this way, even for small files).
|
|
71
|
+
if (compressedSize === U32_MAX || uncompressedSize === U32_MAX || localHeaderOffset === U32_MAX) {
|
|
72
|
+
const extraStart = offset + 46 + nameLength
|
|
73
|
+
const extraEnd = extraStart + extraLength
|
|
74
|
+
let p = extraStart
|
|
75
|
+
while (p + 4 <= extraEnd) {
|
|
76
|
+
const id = view.getUint16(p, true)
|
|
77
|
+
const size = view.getUint16(p + 2, true)
|
|
78
|
+
if (id === ZIP64_EXTRA_ID) {
|
|
79
|
+
let q = p + 4
|
|
80
|
+
if (uncompressedSize === U32_MAX) {
|
|
81
|
+
uncompressedSize = Number(view.getBigUint64(q, true))
|
|
82
|
+
q += 8
|
|
83
|
+
}
|
|
84
|
+
if (compressedSize === U32_MAX) {
|
|
85
|
+
compressedSize = Number(view.getBigUint64(q, true))
|
|
86
|
+
q += 8
|
|
87
|
+
}
|
|
88
|
+
if (localHeaderOffset === U32_MAX) {
|
|
89
|
+
localHeaderOffset = Number(view.getBigUint64(q, true))
|
|
90
|
+
}
|
|
91
|
+
break
|
|
92
|
+
}
|
|
93
|
+
p += 4 + size
|
|
94
|
+
}
|
|
95
|
+
if (compressedSize === U32_MAX || localHeaderOffset === U32_MAX) {
|
|
96
|
+
throw new Error(`Invalid ZIP: "${name}" needs ZIP64 sizes but has no ZIP64 extra field`)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
offset += 46 + nameLength + extraLength + commentLength
|
|
100
|
+
|
|
101
|
+
if (name.endsWith('/')) continue // directory entry
|
|
102
|
+
|
|
103
|
+
// The local header repeats name/extra with its own lengths; the data
|
|
104
|
+
// follows it. Sizes come from the central directory (authoritative even
|
|
105
|
+
// when the local header deferred them to a data descriptor).
|
|
106
|
+
if (view.getUint32(localHeaderOffset, true) !== LOCAL_HEADER_SIG) {
|
|
107
|
+
throw new Error(`Invalid ZIP: bad local header for "${name}"`)
|
|
108
|
+
}
|
|
109
|
+
const localNameLength = view.getUint16(localHeaderOffset + 26, true)
|
|
110
|
+
const localExtraLength = view.getUint16(localHeaderOffset + 28, true)
|
|
111
|
+
const dataStart = localHeaderOffset + 30 + localNameLength + localExtraLength
|
|
112
|
+
const compressed = bytes.subarray(dataStart, dataStart + compressedSize)
|
|
113
|
+
|
|
114
|
+
entries.push({name, data: await decompress(method, compressed, name)})
|
|
115
|
+
}
|
|
116
|
+
return entries
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Convenience: unzip and decode every file entry as UTF-8 text. */
|
|
120
|
+
export async function unzipText(bytes: Uint8Array): Promise<{name: string; content: string}[]> {
|
|
121
|
+
const entries = await unzip(bytes)
|
|
122
|
+
const decoder = new TextDecoder()
|
|
123
|
+
return entries.map((e) => ({name: e.name, content: decoder.decode(e.data)}))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function findEndOfCentralDirectory(view: DataView): {
|
|
127
|
+
entryCount: number
|
|
128
|
+
centralDirectoryOffset: number
|
|
129
|
+
} {
|
|
130
|
+
const lowest = Math.max(0, view.byteLength - EOCD_MIN_SIZE - 0xffff)
|
|
131
|
+
for (let i = view.byteLength - EOCD_MIN_SIZE; i >= lowest; i--) {
|
|
132
|
+
if (view.getUint32(i, true) !== EOCD_SIG) continue
|
|
133
|
+
|
|
134
|
+
let entryCount: number = view.getUint16(i + 10, true)
|
|
135
|
+
let centralDirectoryOffset: number = view.getUint32(i + 16, true)
|
|
136
|
+
|
|
137
|
+
// ZIP64: placeholder values defer to the ZIP64 EOCD record, found via the
|
|
138
|
+
// fixed-size locator that sits immediately before the classic EOCD.
|
|
139
|
+
if (entryCount === U16_MAX || centralDirectoryOffset === U32_MAX) {
|
|
140
|
+
const locator = i - ZIP64_LOCATOR_SIZE
|
|
141
|
+
if (locator < 0 || view.getUint32(locator, true) !== ZIP64_EOCD_LOCATOR_SIG) {
|
|
142
|
+
throw new Error('Invalid ZIP: ZIP64 values without a ZIP64 end-of-central-directory locator')
|
|
143
|
+
}
|
|
144
|
+
const zip64Eocd = Number(view.getBigUint64(locator + 8, true))
|
|
145
|
+
if (view.getUint32(zip64Eocd, true) !== ZIP64_EOCD_SIG) {
|
|
146
|
+
throw new Error('Invalid ZIP: bad ZIP64 end-of-central-directory record')
|
|
147
|
+
}
|
|
148
|
+
entryCount = Number(view.getBigUint64(zip64Eocd + 32, true))
|
|
149
|
+
centralDirectoryOffset = Number(view.getBigUint64(zip64Eocd + 48, true))
|
|
150
|
+
}
|
|
151
|
+
return {entryCount, centralDirectoryOffset}
|
|
152
|
+
}
|
|
153
|
+
throw new Error('Invalid ZIP: end-of-central-directory record not found')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function decompress(method: number, data: Uint8Array, name: string): Promise<Uint8Array> {
|
|
157
|
+
if (method === 0) return data // stored
|
|
158
|
+
if (method === 8) {
|
|
159
|
+
// Copy into a fresh ArrayBuffer so the Blob sees exactly this entry's bytes.
|
|
160
|
+
const stream = new Blob([data.slice()]).stream().pipeThrough(new DecompressionStream('deflate-raw'))
|
|
161
|
+
return new Uint8Array(await new Response(stream).arrayBuffer())
|
|
162
|
+
}
|
|
163
|
+
throw new Error(`Unsupported ZIP compression method ${method} for "${name}"`)
|
|
164
|
+
}
|