|
|
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; |
|
|
} |
|
|
|