| import fs from 'node:fs'; |
| import path from 'node:path'; |
| import _ from 'lodash'; |
| import sanitize from 'sanitize-filename'; |
| import { sync as writeFileAtomicSync } from 'write-file-atomic'; |
| import { extractFileFromZipBuffer, extractFilesFromZipBuffer, normalizeZipEntryPath, ensureDirectory } from './util.js'; |
| import { DEFAULT_AVATAR_PATH } from './constants.js'; |
|
|
| |
| const CHARX_EMBEDDED_URI_PREFIXES = ['embeded://', 'embedded://', '__asset:']; |
| const CHARX_IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'webp', 'gif', 'apng', 'avif', 'bmp', 'jfif']); |
| const CHARX_SPRITE_TYPES = new Set(['emotion', 'expression']); |
| const CHARX_BACKGROUND_TYPES = new Set(['background']); |
|
|
| |
| const ZIP_SIGNATURE = Buffer.from([0x50, 0x4B, 0x03, 0x04]); |
|
|
| |
| |
| |
| |
| |
| function findZipStart(buffer) { |
| const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer); |
| const index = buf.indexOf(ZIP_SIGNATURE); |
| if (index > 0) { |
| return buf.slice(index); |
| } |
| return buf; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| export class CharXParser { |
| #data; |
|
|
| |
| |
| |
| constructor(data) { |
| |
| this.#data = findZipStart(Buffer.isBuffer(data) ? data : Buffer.from(data)); |
| } |
|
|
| |
| |
| |
| |
| async parse() { |
| console.info('Importing from CharX'); |
| const cardBuffer = await extractFileFromZipBuffer(this.#data, 'card.json'); |
|
|
| if (!cardBuffer) { |
| throw new Error('Failed to extract card.json from CharX file'); |
| } |
|
|
| const card = JSON.parse(cardBuffer.toString()); |
|
|
| if (card.spec === undefined) { |
| throw new Error('Invalid CharX card file: missing spec field'); |
| } |
|
|
| const embeddedAssets = this.collectCharXAssets(card); |
| const iconAsset = this.pickCharXIconAsset(embeddedAssets); |
| const auxiliaryAssets = this.mapCharXAssetsForStorage(embeddedAssets); |
|
|
| const archivePaths = new Set(); |
|
|
| if (iconAsset?.zipPath) { |
| archivePaths.add(iconAsset.zipPath); |
| } |
| for (const asset of auxiliaryAssets) { |
| if (asset?.zipPath) { |
| archivePaths.add(asset.zipPath); |
| } |
| } |
|
|
| let extractedBuffers = new Map(); |
| if (archivePaths.size > 0) { |
| extractedBuffers = await extractFilesFromZipBuffer(this.#data, [...archivePaths]); |
| } |
|
|
| |
| let avatar = DEFAULT_AVATAR_PATH; |
| if (iconAsset?.zipPath) { |
| const iconBuffer = extractedBuffers.get(iconAsset.zipPath); |
| if (iconBuffer) { |
| avatar = iconBuffer; |
| } |
| } |
|
|
| return { card, avatar, auxiliaryAssets, extractedBuffers }; |
| } |
|
|
| getEmbeddedZipPathFromUri(uri) { |
| if (typeof uri !== 'string') { |
| return null; |
| } |
|
|
| const trimmed = uri.trim(); |
| if (!trimmed) { |
| return null; |
| } |
|
|
| const lower = trimmed.toLowerCase(); |
| for (const prefix of CHARX_EMBEDDED_URI_PREFIXES) { |
| if (lower.startsWith(prefix)) { |
| const rawPath = trimmed.slice(prefix.length); |
| return normalizeZipEntryPath(rawPath); |
| } |
| } |
|
|
| return null; |
| } |
|
|
| |
| |
| |
| |
| |
| normalizeExtString(ext) { |
| if (typeof ext !== 'string') return ''; |
| return ext.trim().toLowerCase().replace(/^\./, ''); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| stripTrailingImageExtension(name, expectedExt) { |
| if (!name || !expectedExt) return name; |
| const lower = name.toLowerCase(); |
| |
| if (lower.endsWith(`.${expectedExt}`)) { |
| return name.slice(0, -(expectedExt.length + 1)); |
| } |
| |
| for (const ext of CHARX_IMAGE_EXTENSIONS) { |
| if (lower.endsWith(`.${ext}`)) { |
| return name.slice(0, -(ext.length + 1)); |
| } |
| } |
| return name; |
| } |
|
|
| deriveCharXAssetExtension(assetExt, zipPath) { |
| const metaExt = this.normalizeExtString(assetExt); |
| const pathExt = this.normalizeExtString(path.extname(zipPath || '')); |
| return metaExt || pathExt; |
| } |
|
|
| collectCharXAssets(card) { |
| const assets = _.get(card, 'data.assets'); |
| if (!Array.isArray(assets)) { |
| return []; |
| } |
|
|
| return assets.map((asset, index) => { |
| if (!asset) { |
| return null; |
| } |
|
|
| const zipPath = this.getEmbeddedZipPathFromUri(asset.uri); |
| if (!zipPath) { |
| return null; |
| } |
|
|
| const ext = this.deriveCharXAssetExtension(asset.ext, zipPath); |
| const type = typeof asset.type === 'string' ? asset.type.toLowerCase() : ''; |
| const name = typeof asset.name === 'string' ? asset.name : ''; |
|
|
| return { |
| type, |
| name, |
| ext, |
| zipPath, |
| order: index, |
| }; |
| }).filter(Boolean); |
| } |
|
|
| pickCharXIconAsset(assets) { |
| const iconAssets = assets.filter(asset => asset.type === 'icon' && CHARX_IMAGE_EXTENSIONS.has(asset.ext) && asset.zipPath); |
| if (iconAssets.length === 0) { |
| return null; |
| } |
|
|
| const mainIcon = iconAssets.find(asset => asset.name?.toLowerCase() === 'main'); |
| return mainIcon || iconAssets[0]; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| getCharXAssetBaseName(name, fallback, useHyphens = false) { |
| const cleaned = (String(name ?? '').trim() || ''); |
| if (!cleaned) { |
| return fallback.toLowerCase(); |
| } |
|
|
| const separator = useHyphens ? '-' : '_'; |
| |
| const base = cleaned |
| .toLowerCase() |
| .replace(/[^a-z0-9]+/g, separator) |
| .replace(new RegExp(`^${separator}|${separator}$`, 'g'), ''); |
|
|
| if (!base) { |
| return fallback.toLowerCase(); |
| } |
|
|
| const sanitized = sanitize(base); |
| return (sanitized || fallback).toLowerCase(); |
| } |
|
|
| mapCharXAssetsForStorage(assets) { |
| return assets.reduce((acc, asset) => { |
| if (!asset?.zipPath) { |
| return acc; |
| } |
|
|
| const ext = (asset.ext || '').toLowerCase(); |
| if (!CHARX_IMAGE_EXTENSIONS.has(ext)) { |
| return acc; |
| } |
|
|
| if (asset.type === 'icon' || asset.type === 'user_icon') { |
| return acc; |
| } |
|
|
| let storageCategory; |
| if (CHARX_SPRITE_TYPES.has(asset.type)) { |
| storageCategory = 'sprite'; |
| } else if (CHARX_BACKGROUND_TYPES.has(asset.type)) { |
| storageCategory = 'background'; |
| } else { |
| storageCategory = 'misc'; |
| } |
|
|
| |
| |
| const useHyphens = storageCategory === 'sprite'; |
| |
| const nameWithoutExt = this.stripTrailingImageExtension(asset.name, ext); |
| acc.push({ |
| ...asset, |
| ext, |
| storageCategory, |
| baseName: this.getCharXAssetBaseName(nameWithoutExt, `${storageCategory}-${asset.order ?? 0}`, useHyphens), |
| }); |
|
|
| return acc; |
| }, []); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| function deleteExistingByBaseName(dirPath, baseName) { |
| try { |
| const files = fs.readdirSync(dirPath, { withFileTypes: true }).filter(f => f.isFile()).map(f => f.name); |
| for (const file of files) { |
| if (path.parse(file).name === baseName) { |
| fs.unlinkSync(path.join(dirPath, file)); |
| } |
| } |
| } catch { |
| |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function persistCharXAssets(assets, bufferMap, directories, characterFolder) { |
| |
| const summary = { sprites: 0, backgrounds: 0, misc: 0 }; |
| if (!Array.isArray(assets) || assets.length === 0) { |
| return summary; |
| } |
|
|
| let spritesPath = null; |
| let miscPath = null; |
|
|
| const ensureSpritesPath = () => { |
| if (spritesPath) { |
| return spritesPath; |
| } |
| const candidate = path.join(directories.characters, characterFolder); |
| if (!ensureDirectory(candidate)) { |
| return null; |
| } |
| spritesPath = candidate; |
| return spritesPath; |
| }; |
|
|
| const ensureMiscPath = () => { |
| if (miscPath) { |
| return miscPath; |
| } |
| |
| const candidate = path.join(directories.userImages, characterFolder); |
| if (!ensureDirectory(candidate)) { |
| return null; |
| } |
| miscPath = candidate; |
| return miscPath; |
| }; |
|
|
| for (const asset of assets) { |
| if (!asset?.zipPath) { |
| continue; |
| } |
| const buffer = bufferMap.get(asset.zipPath); |
| if (!buffer) { |
| console.warn(`CharX: Asset ${asset.zipPath} missing or unsupported, skipping.`); |
| continue; |
| } |
|
|
| try { |
| if (asset.storageCategory === 'sprite') { |
| const targetDir = ensureSpritesPath(); |
| if (!targetDir) { |
| continue; |
| } |
| |
| deleteExistingByBaseName(targetDir, asset.baseName); |
| const filePath = path.join(targetDir, `${asset.baseName}.${asset.ext || 'png'}`); |
| writeFileAtomicSync(filePath, buffer); |
| summary.sprites += 1; |
| continue; |
| } |
|
|
| if (asset.storageCategory === 'background') { |
| |
| const backgroundDir = path.join(directories.characters, characterFolder, 'backgrounds'); |
| if (!ensureDirectory(backgroundDir)) { |
| continue; |
| } |
| |
| deleteExistingByBaseName(backgroundDir, asset.baseName); |
| const fileName = `${asset.baseName}.${asset.ext || 'png'}`; |
| const filePath = path.join(backgroundDir, fileName); |
| writeFileAtomicSync(filePath, buffer); |
| summary.backgrounds += 1; |
| continue; |
| } |
|
|
| if (asset.storageCategory === 'misc') { |
| const miscDir = ensureMiscPath(); |
| if (!miscDir) { |
| continue; |
| } |
| |
| const filePath = path.join(miscDir, `${asset.baseName}.${asset.ext || 'png'}`); |
| writeFileAtomicSync(filePath, buffer); |
| summary.misc += 1; |
| } |
| } catch (error) { |
| console.warn(`CharX: Failed to save asset "${asset.name}": ${error.message}`); |
| } |
| } |
|
|
| return summary; |
| } |
|
|