Skip to content

Commit cc62496

Browse files
feat: Add zip extraction to server
1 parent 6556e03 commit cc62496

6 files changed

Lines changed: 224 additions & 18 deletions

File tree

dist/index.js

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package-lock.json

Lines changed: 75 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,14 @@
5151
"author": "André Dietrich",
5252
"license": "ISC",
5353
"dependencies": {
54+
"@fastify/multipart": "^8.1.0",
55+
"@fastify/static": "^7.0.1",
5456
"@liascript/simple-scorm-packager": "^0.3.0",
5557
"@types/fs-extra": "^11.0.4",
58+
"@types/unzipper": "^0.10.11",
5659
"archiver": "^7.0.1",
5760
"epub-gen": "^0.1.0",
61+
"fastify": "^4.26.0",
5862
"fs-extra": "^11.3.3",
5963
"jsonld": "^9.0.0",
6064
"minimist": "^1.2.5",
@@ -64,11 +68,9 @@
6468
"simply-beautiful": "^1.0.1",
6569
"temp": "^0.9.4",
6670
"typescript": "^5.9.3",
71+
"unzipper": "^0.12.3",
6772
"xhr2": "^0.2.1",
68-
"yaml": "^2.8.2",
69-
"fastify": "^4.26.0",
70-
"@fastify/multipart": "^8.1.0",
71-
"@fastify/static": "^7.0.1"
73+
"yaml": "^2.8.2"
7274
},
7375
"devDependencies": {
7476
"@parcel/transformer-elm": "^2.16.3",

src/server/queue/jobQueue.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -169,13 +169,19 @@ export class JobQueue extends EventEmitter {
169169
job.source.files &&
170170
job.source.files.length > 0
171171
) {
172-
// Use the first file (README.md or similar)
173-
const readmeFile = job.source.files.find(
174-
(f) =>
175-
f.filename === 'README.md' ||
176-
f.filename.toLowerCase().endsWith('.md'),
177-
)
178-
inputFile = readmeFile ? readmeFile.path : job.source.files[0].path
172+
// Check if we have a main file from ZIP extraction
173+
if ((job.source as any).mainFile) {
174+
inputFile = (job.source as any).mainFile
175+
console.log(`Using main markdown from ZIP: ${inputFile}`)
176+
} else {
177+
// Use the first markdown file or fallback to first file
178+
const readmeFile = job.source.files.find(
179+
(f) =>
180+
f.filename === 'README.md' ||
181+
f.filename.toLowerCase().endsWith('.md'),
182+
)
183+
inputFile = readmeFile ? readmeFile.path : job.source.files[0].path
184+
}
179185
} else if (job.source.type === 'git' && job.source.gitUrl) {
180186
// For git repos, we'd need to clone first - not implemented yet
181187
throw new Error('Git repository export not yet implemented')

src/server/routes/export.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { tmpdir } from 'os'
77
import { fileURLToPath } from 'url'
88
import { dirname } from 'path'
99
import * as YAML from 'yaml'
10+
import { extractZip, findMainMarkdown, isZipFile } from '../utils/zipExtractor'
1011

1112
export const exportRouter: FastifyPluginAsync = async (fastify) => {
1213
// GET /api/presets - Get available presets configuration
@@ -51,14 +52,13 @@ export const exportRouter: FastifyPluginAsync = async (fastify) => {
5152
if (request.isMultipart()) {
5253
const parts = request.parts()
5354
const files: any[] = []
55+
const uploadId = randomUUID()
56+
const uploadDir = join(tmpdir(), 'liaex-uploads', uploadId)
57+
await mkdir(uploadDir, { recursive: true })
5458

5559
for await (const part of parts) {
5660
if (part.type === 'file') {
5761
// Save file temporarily
58-
const uploadId = randomUUID()
59-
const uploadDir = join(tmpdir(), 'liaex-uploads', uploadId)
60-
await mkdir(uploadDir, { recursive: true })
61-
6262
const filepath = join(uploadDir, part.filename)
6363
const buffer = await part.toBuffer()
6464
await writeFile(filepath, buffer)
@@ -93,6 +93,36 @@ export const exportRouter: FastifyPluginAsync = async (fastify) => {
9393
if (files.length > 0) {
9494
jobData.source.type = 'upload'
9595
jobData.source.files = files
96+
jobData.source.uploadDir = uploadDir
97+
98+
// Check if any file is a ZIP - extract it
99+
const zipFile = files.find((f) => isZipFile(f.filename))
100+
if (zipFile) {
101+
fastify.log.info(`Extracting ZIP file: ${zipFile.filename}`)
102+
103+
// Create extraction directory
104+
const extractDir = join(uploadDir, 'extracted')
105+
await mkdir(extractDir, { recursive: true })
106+
107+
// Extract ZIP
108+
await extractZip(zipFile.path, extractDir)
109+
fastify.log.info(`ZIP extracted to: ${extractDir}`)
110+
111+
// Find main markdown file
112+
const mainMarkdown = await findMainMarkdown(extractDir)
113+
if (!mainMarkdown) {
114+
return reply.code(400).send({
115+
error:
116+
'No markdown file found in ZIP archive. Please include a README.md or any .md file.',
117+
})
118+
}
119+
120+
fastify.log.info(`Found main markdown: ${mainMarkdown}`)
121+
122+
// Update job data with extracted markdown
123+
jobData.source.mainFile = mainMarkdown
124+
jobData.source.extractedFrom = zipFile.filename
125+
}
96126
} else if (jobData.source.gitUrl) {
97127
jobData.source.type = 'git'
98128
} else {

src/server/utils/zipExtractor.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { createReadStream } from 'fs'
2+
import { readdir, stat } from 'fs/promises'
3+
import { join, extname } from 'path'
4+
import unzipper from 'unzipper'
5+
6+
/**
7+
* Extracts a ZIP file to a target directory
8+
* @param zipPath Path to the ZIP file
9+
* @param extractPath Path where to extract the ZIP contents
10+
*/
11+
export async function extractZip(
12+
zipPath: string,
13+
extractPath: string,
14+
): Promise<void> {
15+
return new Promise((resolve, reject) => {
16+
createReadStream(zipPath)
17+
.pipe(unzipper.Extract({ path: extractPath }))
18+
.on('close', resolve)
19+
.on('error', reject)
20+
})
21+
}
22+
23+
/**
24+
* Recursively finds all markdown files in a directory
25+
* @param dir Directory to search
26+
* @param files Array to collect found files (used internally for recursion)
27+
* @returns Array of markdown file paths
28+
*/
29+
async function findMarkdownFiles(
30+
dir: string,
31+
files: string[] = [],
32+
): Promise<string[]> {
33+
const entries = await readdir(dir)
34+
35+
for (const entry of entries) {
36+
const fullPath = join(dir, entry)
37+
const stats = await stat(fullPath)
38+
39+
if (stats.isDirectory()) {
40+
// Skip common non-content directories
41+
if (
42+
!entry.startsWith('.') &&
43+
entry !== 'node_modules' &&
44+
entry !== 'dist' &&
45+
entry !== 'build'
46+
) {
47+
await findMarkdownFiles(fullPath, files)
48+
}
49+
} else if (stats.isFile() && extname(entry).toLowerCase() === '.md') {
50+
files.push(fullPath)
51+
}
52+
}
53+
54+
return files
55+
}
56+
57+
/**
58+
* Finds the main markdown file in a directory
59+
* Priority:
60+
* 1. README.md (case insensitive)
61+
* 2. First markdown file found
62+
* @param dir Directory to search
63+
* @returns Path to the main markdown file or null if none found
64+
*/
65+
export async function findMainMarkdown(dir: string): Promise<string | null> {
66+
const markdownFiles = await findMarkdownFiles(dir)
67+
68+
if (markdownFiles.length === 0) {
69+
return null
70+
}
71+
72+
// Look for README.md (case insensitive)
73+
const readmeFile = markdownFiles.find((file) => {
74+
const filename = file.split('/').pop()?.toLowerCase()
75+
return filename === 'readme.md'
76+
})
77+
78+
if (readmeFile) {
79+
return readmeFile
80+
}
81+
82+
// Return first markdown file found
83+
return markdownFiles[0]
84+
}
85+
86+
/**
87+
* Checks if a file is a ZIP file based on its extension
88+
* @param filename Filename to check
89+
* @returns true if the file is a ZIP file
90+
*/
91+
export function isZipFile(filename: string): boolean {
92+
return extname(filename).toLowerCase() === '.zip'
93+
}

0 commit comments

Comments
 (0)