Blame Raw
· · · 399 lines (14.0 KB)
0 contributors
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}