Enhanced CharX Import with Asset Extraction (#4825) * feat: Extract and store CharX embedded assets on import Enhances CharX (.charx) file import to extract and store embedded assets in accessible locations: - Add CharXParser class in new src/charx.js module - Extract sprites (emotion/expression) → characters/{name}/ - Extract backgrounds → backgrounds/{name}_{asset}.{ext} - Extract misc/x-risu-asset → user/images/{name}/ - Skip user_icon assets to prevent persona conflicts - Support SFX (self-extracting) ZIP archives - Add extractFilesFromZipBuffer() for efficient multi-file extraction - Add normalizeZipEntryPath() for safe path handling - Use hyphens in sprite names for ST expression label compatibility - Fix ArrayBuffer slicing for Node.js buffer pool issue Tested with 34 real-world RisuAI exports (200-300MB, 1000+ assets each). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Address review feedback for CharX parser - Add normalizeExtString() to handle dotted/uppercase extensions (e.g., '.PNG' or 'PNG' now correctly becomes 'png') - Revert default avatar to path string instead of eager fs.readFileSync - Simplify getCharXAssetBaseName regex, remove dead useHyphens branch - Add JSDoc for persistCharXAssets documenting sync write behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: Move ensureDirectory to util.js for reuse - Extract ensureDirectory() from charx.js to util.js as a shared helper - Add JSDoc for getUniqueAssetPath explaining sprite naming convention - Prepares utilities for future ZIP-based format imports (similar to BYAF) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: CharX asset overwrite and folder naming - Overwrite existing assets on re-import instead of creating duplicates (matches sprites.js upload behavior with deleteExistingByBaseName) - Use character name for asset folders, not unique filename (ST's sprite system looks up by character name from card data) - Strip trailing extensions from asset names to avoid file.ext.ext - Add per-file error handling so one bad asset doesn't abort import - Remove redundant else branch in storage category assignment - Add comment explaining intentional 'embeded://' typo (RisuAI compat) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Address PR review feedback - Filter out directories in deleteExistingByBaseName (Cohee1207) - Use ISO timestamp for create_date instead of humanizedDateTime 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Store CharX backgrounds in character-specific folder Changed background storage from global `backgrounds/` to character-specific `characters/{charName}/backgrounds/` per maintainer feedback. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Remove unused import * fix: Reduce CharX debug logging noise Remove verbose per-file debug logs that create excessive console noise for large archives. Keep only essential logging matching BYAF style: - Entry point info log - Final summary of imported assets - Warnings for actual failures 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

38f6b9a119a2fdb2384fa178cba63fc629f6b9a5

alex <axAilotl@pm.me>

Signed
3 files changed, +571 -28Ignore whitespace
src/charx.js+399 -0
@@ -0,0 +1,399 @@
1import fs from 'node:fs';
2import path from 'node:path';
3import _ from 'lodash';
4import sanitize from 'sanitize-filename';
5import { sync as writeFileAtomicSync } from 'write-file-atomic';
6import { extractFileFromZipBuffer, extractFilesFromZipBuffer, normalizeZipEntryPath, ensureDirectory } from './util.js';
7import { DEFAULT_AVATAR_PATH } from './constants.js';
8
9// 'embeded://' is intentional - RisuAI exports use this misspelling
10const CHARX_EMBEDDED_URI_PREFIXES = ['embeded://', 'embedded://', '__asset:'];
11const CHARX_IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'webp', 'gif', 'apng', 'avif', 'bmp', 'jfif']);
12const CHARX_SPRITE_TYPES = new Set(['emotion', 'expression']);
13const CHARX_BACKGROUND_TYPES = new Set(['background']);
14
15// ZIP local file header signature: PK\x03\x04
16const ZIP_SIGNATURE = Buffer.from([0x50, 0x4B, 0x03, 0x04]);
17
18/**
19 * Find ZIP data start in buffer (handles SFX/self-extracting archives).
20 * @param {Buffer} buffer
21 * @returns {Buffer} Buffer starting at ZIP signature, or original if not found
22 */
23function findZipStart(buffer) {
24 const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
25 const index = buf.indexOf(ZIP_SIGNATURE);
26 if (index > 0) {
27 return buf.slice(index);
28 }
29 return buf;
30}
31
32/**
33 * @typedef {Object} CharXAsset
34 * @property {string} type - Asset type (emotion, expression, background, etc.)
35 * @property {string} name - Asset name from metadata
36 * @property {string} ext - File extension (lowercase, no dot)
37 * @property {string} zipPath - Normalized path within the ZIP archive
38 * @property {number} order - Original index in assets array
39 * @property {string} [storageCategory] - 'sprite' | 'background' | 'misc' (set by mapCharXAssetsForStorage)
40 * @property {string} [baseName] - Normalized filename base (set by mapCharXAssetsForStorage)
41 */
42
43/**
44 * @typedef {Object} CharXParseResult
45 * @property {Object} card - Parsed card.json (CCv2 or CCv3 spec)
46 * @property {string|Buffer} avatar - Avatar image buffer or DEFAULT_AVATAR_PATH
47 * @property {CharXAsset[]} auxiliaryAssets - Assets mapped for storage
48 * @property {Map<string, Buffer>} extractedBuffers - Map of zipPath to extracted buffer
49 */
50
51export class CharXParser {
52 #data;
53
54 /**
55 * @param {ArrayBuffer|Buffer} data
56 */
57 constructor(data) {
58 // Handle SFX (self-extracting) ZIP archives by finding the actual ZIP start
59 this.#data = findZipStart(Buffer.isBuffer(data) ? data : Buffer.from(data));
60 }
61
62 /**
63 * Parse the CharX archive and extract card data and assets.
64 * @returns {Promise<CharXParseResult>}
65 */
66 async parse() {
67 console.info('Importing from CharX');
68 const cardBuffer = await extractFileFromZipBuffer(this.#data, 'card.json');
69
70 if (!cardBuffer) {
71 throw new Error('Failed to extract card.json from CharX file');
72 }
73
74 const card = JSON.parse(cardBuffer.toString());
75
76 if (card.spec === undefined) {
77 throw new Error('Invalid CharX card file: missing spec field');
78 }
79
80 const embeddedAssets = this.collectCharXAssets(card);
81 const iconAsset = this.pickCharXIconAsset(embeddedAssets);
82 const auxiliaryAssets = this.mapCharXAssetsForStorage(embeddedAssets);
83
84 const archivePaths = new Set();
85
86 if (iconAsset?.zipPath) {
87 archivePaths.add(iconAsset.zipPath);
88 }
89 for (const asset of auxiliaryAssets) {
90 if (asset?.zipPath) {
91 archivePaths.add(asset.zipPath);
92 }
93 }
94
95 let extractedBuffers = new Map();
96 if (archivePaths.size > 0) {
97 extractedBuffers = await extractFilesFromZipBuffer(this.#data, [...archivePaths]);
98 }
99
100 /** @type {string|Buffer} */
101 let avatar = DEFAULT_AVATAR_PATH;
102 if (iconAsset?.zipPath) {
103 const iconBuffer = extractedBuffers.get(iconAsset.zipPath);
104 if (iconBuffer) {
105 avatar = iconBuffer;
106 }
107 }
108
109 return { card, avatar, auxiliaryAssets, extractedBuffers };
110 }
111
112 getEmbeddedZipPathFromUri(uri) {
113 if (typeof uri !== 'string') {
114 return null;
115 }
116
117 const trimmed = uri.trim();
118 if (!trimmed) {
119 return null;
120 }
121
122 const lower = trimmed.toLowerCase();
123 for (const prefix of CHARX_EMBEDDED_URI_PREFIXES) {
124 if (lower.startsWith(prefix)) {
125 const rawPath = trimmed.slice(prefix.length);
126 return normalizeZipEntryPath(rawPath);
127 }
128 }
129
130 return null;
131 }
132
133 /**
134 * Normalize extension string: lowercase, strip leading dot.
135 * @param {string} ext
136 * @returns {string}
137 */
138 normalizeExtString(ext) {
139 if (typeof ext !== 'string') return '';
140 return ext.trim().toLowerCase().replace(/^\./, '');
141 }
142
143 /**
144 * Strip trailing image extension from asset name if present.
145 * Handles cases like "image.png" with ext "png" → "image" (avoids "image.png.png")
146 * @param {string} name - Asset name that may contain extension
147 * @param {string} expectedExt - The expected extension (lowercase, no dot)
148 * @returns {string} Name with trailing extension stripped if it matched
149 */
150 stripTrailingImageExtension(name, expectedExt) {
151 if (!name || !expectedExt) return name;
152 const lower = name.toLowerCase();
153 // Check if name ends with the expected extension
154 if (lower.endsWith(`.${expectedExt}`)) {
155 return name.slice(0, -(expectedExt.length + 1));
156 }
157 // Also check for any known image extension at the end
158 for (const ext of CHARX_IMAGE_EXTENSIONS) {
159 if (lower.endsWith(`.${ext}`)) {
160 return name.slice(0, -(ext.length + 1));
161 }
162 }
163 return name;
164 }
165
166 deriveCharXAssetExtension(assetExt, zipPath) {
167 const metaExt = this.normalizeExtString(assetExt);
168 const pathExt = this.normalizeExtString(path.extname(zipPath || ''));
169 return metaExt || pathExt;
170 }
171
172 collectCharXAssets(card) {
173 const assets = _.get(card, 'data.assets');
174 if (!Array.isArray(assets)) {
175 return [];
176 }
177
178 return assets.map((asset, index) => {
179 if (!asset) {
180 return null;
181 }
182
183 const zipPath = this.getEmbeddedZipPathFromUri(asset.uri);
184 if (!zipPath) {
185 return null;
186 }
187
188 const ext = this.deriveCharXAssetExtension(asset.ext, zipPath);
189 const type = typeof asset.type === 'string' ? asset.type.toLowerCase() : '';
190 const name = typeof asset.name === 'string' ? asset.name : '';
191
192 return {
193 type,
194 name,
195 ext,
196 zipPath,
197 order: index,
198 };
199 }).filter(Boolean);
200 }
201
202 pickCharXIconAsset(assets) {
203 const iconAssets = assets.filter(asset => asset.type === 'icon' && CHARX_IMAGE_EXTENSIONS.has(asset.ext) && asset.zipPath);
204 if (iconAssets.length === 0) {
205 return null;
206 }
207
208 const mainIcon = iconAssets.find(asset => asset.name?.toLowerCase() === 'main');
209 return mainIcon || iconAssets[0];
210 }
211
212 /**
213 * Normalize asset name for filesystem storage.
214 * @param {string} name - Original asset name
215 * @param {string} fallback - Fallback name if normalization fails
216 * @param {boolean} useHyphens - Use hyphens instead of underscores (for sprites)
217 * @returns {string} Normalized filename base (without extension)
218 */
219 getCharXAssetBaseName(name, fallback, useHyphens = false) {
220 const cleaned = (String(name ?? '').trim() || '');
221 if (!cleaned) {
222 return fallback.toLowerCase();
223 }
224
225 const separator = useHyphens ? '-' : '_';
226 // Convert to lowercase, collapse non-alphanumeric runs to separator, trim edges
227 const base = cleaned
228 .toLowerCase()
229 .replace(/[^a-z0-9]+/g, separator)
230 .replace(new RegExp(`^${separator}|${separator}$`, 'g'), '');
231
232 if (!base) {
233 return fallback.toLowerCase();
234 }
235
236 const sanitized = sanitize(base);
237 return (sanitized || fallback).toLowerCase();
238 }
239
240 mapCharXAssetsForStorage(assets) {
241 return assets.reduce((acc, asset) => {
242 if (!asset?.zipPath) {
243 return acc;
244 }
245
246 const ext = (asset.ext || '').toLowerCase();
247 if (!CHARX_IMAGE_EXTENSIONS.has(ext)) {
248 return acc;
249 }
250
251 if (asset.type === 'icon' || asset.type === 'user_icon') {
252 return acc;
253 }
254
255 let storageCategory;
256 if (CHARX_SPRITE_TYPES.has(asset.type)) {
257 storageCategory = 'sprite';
258 } else if (CHARX_BACKGROUND_TYPES.has(asset.type)) {
259 storageCategory = 'background';
260 } else {
261 storageCategory = 'misc';
262 }
263
264 // Use hyphens for sprites so ST's expression label extraction works correctly
265 // (sprites.js extracts label via regex that splits on dash or dot)
266 const useHyphens = storageCategory === 'sprite';
267 // Strip trailing extension from name if present (e.g., "image.png" with ext "png")
268 const nameWithoutExt = this.stripTrailingImageExtension(asset.name, ext);
269 acc.push({
270 ...asset,
271 ext,
272 storageCategory,
273 baseName: this.getCharXAssetBaseName(nameWithoutExt, `${storageCategory}-${asset.order ?? 0}`, useHyphens),
274 });
275
276 return acc;
277 }, []);
278 }
279}
280
281/**
282 * Delete existing file with same base name (any extension) before overwriting.
283 * Matches ST's sprite upload behavior in sprites.js.
284 * @param {string} dirPath - Directory path
285 * @param {string} baseName - Base filename without extension
286 */
287function deleteExistingByBaseName(dirPath, baseName) {
288 try {
289 const files = fs.readdirSync(dirPath, { withFileTypes: true }).filter(f => f.isFile()).map(f => f.name);
290 for (const file of files) {
291 if (path.parse(file).name === baseName) {
292 fs.unlinkSync(path.join(dirPath, file));
293 }
294 }
295 } catch {
296 // Directory doesn't exist yet or other error, that's fine
297 }
298}
299
300/**
301 * Persist extracted CharX assets to appropriate ST directories.
302 * Note: Uses sync writes consistent with ST's existing file handling.
303 * @param {Array} assets - Mapped assets from CharXParser
304 * @param {Map<string, Buffer>} bufferMap - Extracted file buffers
305 * @param {Object} directories - User directories object
306 * @param {string} characterFolder - Character folder name (sanitized)
307 * @returns {{sprites: number, backgrounds: number, misc: number}}
308 */
309export function persistCharXAssets(assets, bufferMap, directories, characterFolder) {
310 /** @type {{sprites: number, backgrounds: number, misc: number}} */
311 const summary = { sprites: 0, backgrounds: 0, misc: 0 };
312 if (!Array.isArray(assets) || assets.length === 0) {
313 return summary;
314 }
315
316 let spritesPath = null;
317 let miscPath = null;
318
319 const ensureSpritesPath = () => {
320 if (spritesPath) {
321 return spritesPath;
322 }
323 const candidate = path.join(directories.characters, characterFolder);
324 if (!ensureDirectory(candidate)) {
325 return null;
326 }
327 spritesPath = candidate;
328 return spritesPath;
329 };
330
331 const ensureMiscPath = () => {
332 if (miscPath) {
333 return miscPath;
334 }
335 // Use the image gallery path: user/images/{characterName}/
336 const candidate = path.join(directories.userImages, characterFolder);
337 if (!ensureDirectory(candidate)) {
338 return null;
339 }
340 miscPath = candidate;
341 return miscPath;
342 };
343
344 for (const asset of assets) {
345 if (!asset?.zipPath) {
346 continue;
347 }
348 const buffer = bufferMap.get(asset.zipPath);
349 if (!buffer) {
350 console.warn(`CharX: Asset ${asset.zipPath} missing or unsupported, skipping.`);
351 continue;
352 }
353
354 try {
355 if (asset.storageCategory === 'sprite') {
356 const targetDir = ensureSpritesPath();
357 if (!targetDir) {
358 continue;
359 }
360 // Delete existing sprite with same base name (any extension) - matches sprites.js behavior
361 deleteExistingByBaseName(targetDir, asset.baseName);
362 const filePath = path.join(targetDir, `${asset.baseName}.${asset.ext || 'png'}`);
363 writeFileAtomicSync(filePath, buffer);
364 summary.sprites += 1;
365 continue;
366 }
367
368 if (asset.storageCategory === 'background') {
369 // Store in character-specific backgrounds folder: characters/{charName}/backgrounds/
370 const backgroundDir = path.join(directories.characters, characterFolder, 'backgrounds');
371 if (!ensureDirectory(backgroundDir)) {
372 continue;
373 }
374 // Delete existing background with same base name
375 deleteExistingByBaseName(backgroundDir, asset.baseName);
376 const fileName = `${asset.baseName}.${asset.ext || 'png'}`;
377 const filePath = path.join(backgroundDir, fileName);
378 writeFileAtomicSync(filePath, buffer);
379 summary.backgrounds += 1;
380 continue;
381 }
382
383 if (asset.storageCategory === 'misc') {
384 const miscDir = ensureMiscPath();
385 if (!miscDir) {
386 continue;
387 }
388 // Overwrite existing misc asset with same name
389 const filePath = path.join(miscDir, `${asset.baseName}.${asset.ext || 'png'}`);
390 writeFileAtomicSync(filePath, buffer);
391 summary.misc += 1;
392 }
393 } catch (error) {
394 console.warn(`CharX: Failed to save asset "${asset.name}": ${error.message}`);
395 }
396 }
397
398 return summary;
399}
src/endpoints/characters.js+24 -26
@@ -14,7 +14,7 @@ import storage from 'node-persist';
1414
15import { AVATAR_WIDTH, AVATAR_HEIGHT, DEFAULT_AVATAR_PATH } from '../constants.js';15import { AVATAR_WIDTH, AVATAR_HEIGHT, DEFAULT_AVATAR_PATH } from '../constants.js';
16import { default as validateAvatarUrlMiddleware, getFileNameValidationFunction } from '../middleware/validateFileName.js';16import { default as validateAvatarUrlMiddleware, getFileNameValidationFunction } from '../middleware/validateFileName.js';
17import { deepMerge, humanizedDateTime, tryParse, extractFileFromZipBuffer, MemoryLimitedMap, getConfigValue, mutateJsonString, clientRelativePath, getUniqueName, sanitizeSafeCharacterReplacements } from '../util.js';17import { deepMerge, humanizedDateTime, tryParse, MemoryLimitedMap, getConfigValue, mutateJsonString, clientRelativePath, getUniqueName, sanitizeSafeCharacterReplacements } from '../util.js';
18import { TavernCardValidator } from '../validator/TavernCardValidator.js';18import { TavernCardValidator } from '../validator/TavernCardValidator.js';
19import { parse, read, write } from '../character-card-parser.js';19import { parse, read, write } from '../character-card-parser.js';
20import { readWorldInfoFile } from './worldinfo.js';20import { readWorldInfoFile } from './worldinfo.js';
@@ -23,6 +23,7 @@ import { importRisuSprites } from './sprites.js';
23import { getUserDirectories } from '../users.js';23import { getUserDirectories } from '../users.js';
24import { getChatInfo } from './chats.js';24import { getChatInfo } from './chats.js';
25import { ByafParser } from '../byaf.js';25import { ByafParser } from '../byaf.js';
26import { CharXParser, persistCharXAssets } from '../charx.js';
26import cacheBuster from '../middleware/cacheBuster.js';27import cacheBuster from '../middleware/cacheBuster.js';
2728
28// With 100 MB limit it would take roughly 3000 characters to reach this limit29// With 100 MB limit it would take roughly 3000 characters to reach this limit
@@ -767,40 +768,37 @@ async function importFromYaml(uploadPath, context, preservedFileName) {
767 * @returns {Promise<string>} Internal name of the character768 * @returns {Promise<string>} Internal name of the character
768 */769 */
769async function importFromCharX(uploadPath, { request }, preservedFileName) {770async function importFromCharX(uploadPath, { request }, preservedFileName) {
770 const data = fs.readFileSync(uploadPath).buffer;771 const fileBuffer = fs.readFileSync(uploadPath);
772 // Create a properly-sized ArrayBuffer (Node's buffer pool can cause oversized .buffer)
773 const data = fileBuffer.buffer.slice(fileBuffer.byteOffset, fileBuffer.byteOffset + fileBuffer.byteLength);
771 fs.unlinkSync(uploadPath);774 fs.unlinkSync(uploadPath);
772 console.info('Importing from CharX');
773 const cardBuffer = await extractFileFromZipBuffer(data, 'card.json');
774775
775 if (!cardBuffer) {776 const parser = new CharXParser(data);
776 throw new Error('Failed to extract card.json from CharX file');777 const { card, avatar, auxiliaryAssets, extractedBuffers } = await parser.parse();
777 }
778778
779 const card = readFromV2(JSON.parse(cardBuffer.toString()));779 // Apply standard character transformations
780 let processedCard = readFromV2(card);
781 unsetPrivateFields(processedCard);
782 processedCard['create_date'] = new Date().toISOString();
783 processedCard.name = sanitize(processedCard.name);
780784
781 if (card.spec === undefined) {785 const fileName = preservedFileName || getPngName(processedCard.name, request.user.directories);
782 throw new Error('Invalid CharX card file: missing spec field');786 // Use the actual character name for asset folders, not the unique filename
783 }787 // ST's sprite system looks up by character name, not PNG filename
788 const characterFolder = processedCard.name;
784789
785 /** @type {string|Buffer} */790 if (auxiliaryAssets.length > 0) {
786 let avatar = DEFAULT_AVATAR_PATH;791 try {
787 const assets = _.get(card, 'data.assets');792 const summary = persistCharXAssets(auxiliaryAssets, extractedBuffers, request.user.directories, characterFolder);
788 if (Array.isArray(assets) && assets.length) {793 if (summary.sprites || summary.backgrounds || summary.misc) {
789 for (const asset of assets.filter(x => x.type === 'icon' && typeof x.uri === 'string')) {794 console.log(`CharX: Imported ${summary.sprites} sprite(s), ${summary.backgrounds} background(s), ${summary.misc} misc asset(s) for ${characterFolder}`);
790 const pathNoProtocol = String(asset.uri.replace(/^(?:\/\/|[^/]+)*\//, ''));
791 const buffer = await extractFileFromZipBuffer(data, pathNoProtocol);
792 if (buffer) {
793 avatar = buffer;
794 break;
795 }795 }
796 } catch (error) {
797 console.warn(`CharX: Failed to persist auxiliary assets for ${characterFolder}`, error);
796 }798 }
797 }799 }
798800
799 unsetPrivateFields(card);801 const result = await writeCharacterData(avatar, JSON.stringify(processedCard), fileName, request);
800 card['create_date'] = new Date().toISOString();
801 card.name = sanitize(card.name);
802 const fileName = preservedFileName || getPngName(card.name, request.user.directories);
803 const result = await writeCharacterData(avatar, JSON.stringify(card), fileName, request);
804 return result ? fileName : '';802 return result ? fileName : '';
805}803}
806804
src/util.js+148 -2
@@ -221,7 +221,6 @@ export async function extractFileFromZipBuffer(archiveBuffer, fileExtension) {
221221
222 zipfile.on('entry', (entry) => {222 zipfile.on('entry', (entry) => {
223 if (entry.fileName.endsWith(fileExtension) && !entry.fileName.startsWith('__MACOSX')) {223 if (entry.fileName.endsWith(fileExtension) && !entry.fileName.startsWith('__MACOSX')) {
224 console.info(`Extracting ${entry.fileName}`);
225 zipfile.openReadStream(entry, (err, readStream) => {224 zipfile.openReadStream(entry, (err, readStream) => {
226 if (err) {225 if (err) {
227 console.warn(`Error opening read stream: ${err.message}`);226 console.warn(`Error opening read stream: ${err.message}`);
@@ -264,6 +263,154 @@ export async function extractFileFromZipBuffer(archiveBuffer, fileExtension) {
264}263}
265264
266/**265/**
266 * Normalizes a ZIP entry path for safe extraction.
267 * @param {string} entryName The entry name from the ZIP archive
268 * @returns {string|null} Normalized path or null if invalid
269 */
270export function normalizeZipEntryPath(entryName) {
271 if (typeof entryName !== 'string') {
272 return null;
273 }
274
275 let normalized = entryName.replace(/\\/g, '/').trim();
276
277 if (!normalized) {
278 return null;
279 }
280
281 normalized = normalized.replace(/^\.\/+/g, '');
282 normalized = path.posix.normalize(normalized);
283
284 if (!normalized || normalized === '.' || normalized.startsWith('..')) {
285 return null;
286 }
287
288 if (normalized.startsWith('/')) {
289 normalized = normalized.slice(1);
290 }
291
292 return normalized;
293}
294
295/**
296 * Extracts multiple files from an ArrayBuffer containing a ZIP archive.
297 * @param {ArrayBufferLike} archiveBuffer Buffer containing a ZIP archive
298 * @param {string[]} fileNames Array of file paths to extract
299 * @returns {Promise<Map<string, Buffer>>} Map of normalized paths to their extracted buffers
300 */
301export async function extractFilesFromZipBuffer(archiveBuffer, fileNames) {
302 const targets = new Map();
303
304 if (Array.isArray(fileNames)) {
305 for (const fileName of fileNames) {
306 const normalized = normalizeZipEntryPath(fileName);
307 if (normalized && !targets.has(normalized)) {
308 targets.set(normalized, true);
309 }
310 }
311 }
312
313 if (targets.size === 0) {
314 return new Map();
315 }
316
317 return await new Promise((resolve) => {
318 const results = new Map();
319
320 try {
321 yauzl.fromBuffer(Buffer.from(archiveBuffer), { lazyEntries: true }, (err, zipfile) => {
322 if (err) {
323 console.warn(`Error opening ZIP file: ${err.message}`);
324 return resolve(results);
325 }
326
327 let finished = false;
328 const finalize = () => {
329 if (finished) {
330 return;
331 }
332 finished = true;
333 resolve(results);
334 };
335
336 zipfile.readEntry();
337
338 zipfile.on('entry', (entry) => {
339 const normalizedEntry = normalizeZipEntryPath(entry.fileName);
340 if (!normalizedEntry || !targets.has(normalizedEntry)) {
341 return zipfile.readEntry();
342 }
343
344 zipfile.openReadStream(entry, (streamErr, readStream) => {
345 if (streamErr) {
346 console.warn(`Error opening read stream: ${streamErr.message}`);
347 return zipfile.readEntry();
348 }
349
350 const chunks = [];
351 readStream.on('data', (chunk) => {
352 chunks.push(chunk);
353 });
354
355 readStream.on('end', () => {
356 results.set(normalizedEntry, Buffer.concat(chunks));
357 targets.delete(normalizedEntry);
358
359 if (targets.size === 0) {
360 finalize();
361 } else {
362 zipfile.readEntry();
363 }
364 });
365
366 readStream.on('error', (streamError) => {
367 console.warn(`Error reading stream: ${streamError.message}`);
368 zipfile.readEntry();
369 });
370 });
371 });
372
373 zipfile.on('error', (zipError) => {
374 console.warn('ZIP processing error', zipError);
375 finalize();
376 });
377
378 zipfile.on('close', () => {
379 finalize();
380 });
381
382 zipfile.on('end', () => {
383 finalize();
384 });
385 });
386 } catch (error) {
387 console.warn('Failed to process ZIP buffer', error);
388 resolve(results);
389 }
390 });
391}
392
393/**
394 * Ensures a directory exists, creating it if necessary.
395 * @param {string} dirPath Path to the directory
396 * @returns {boolean} True if the directory exists or was created, false on error
397 */
398export function ensureDirectory(dirPath) {
399 try {
400 if (!fs.existsSync(dirPath)) {
401 fs.mkdirSync(dirPath, { recursive: true });
402 } else if (!fs.statSync(dirPath).isDirectory()) {
403 console.warn(`ensureDirectory: Path ${dirPath} exists and is not a directory.`);
404 return false;
405 }
406 return true;
407 } catch (error) {
408 console.error(`ensureDirectory: Failed to prepare directory ${dirPath}`, error);
409 return false;
410 }
411}
412
413/**
267 * Extracts all images from a ZIP archive.414 * Extracts all images from a ZIP archive.
268 * @param {string} zipFilePath Path to the ZIP archive415 * @param {string} zipFilePath Path to the ZIP archive
269 * @returns {Promise<[string, Buffer][]>} Array of image buffers416 * @returns {Promise<[string, Buffer][]>} Array of image buffers
@@ -286,7 +433,6 @@ export async function getImageBuffers(zipFilePath) {
286 zipfile.on('entry', (entry) => {433 zipfile.on('entry', (entry) => {
287 const mimeType = mime.lookup(entry.fileName);434 const mimeType = mime.lookup(entry.fileName);
288 if (mimeType && mimeType.startsWith('image/') && !entry.fileName.startsWith('__MACOSX')) {435 if (mimeType && mimeType.startsWith('image/') && !entry.fileName.startsWith('__MACOSX')) {
289 console.info(`Extracting ${entry.fileName}`);
290 zipfile.openReadStream(entry, (err, readStream) => {436 zipfile.openReadStream(entry, (err, readStream) => {
291 if (err) {437 if (err) {
292 reject(err);438 reject(err);