import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; import type { AppToken, TokenId } from '../../shared/types'; import { asTokenId } from '../../shared/types/ids'; import { optimizeImageBufferVisuallyLossless } from '../project/optimizeImageImport.lib.mjs'; type TokensManifest = { tokens: AppToken[]; }; function mimeFromExt(ext: string): string { const e = ext.toLowerCase(); if (e === '.png') return 'image/png'; if (e === '.jpg' || e === '.jpeg') return 'image/jpeg'; if (e === '.webp') return 'image/webp'; if (e === '.gif') return 'image/gif'; return 'application/octet-stream'; } function safeFileBase(name: string): string { const base = name.replace(/[^\w.\-]+/gu, '_').slice(0, 48); return base || 'token'; } function randomTokenId(): TokenId { return asTokenId(`token_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`); } export class TokensStore { private readonly rootDir: string; private readonly filesDir: string; private readonly manifestPath: string; private tokens: AppToken[] = []; private loaded = false; constructor(userData: string) { this.rootDir = path.join(userData, 'tokens'); this.filesDir = path.join(this.rootDir, 'files'); this.manifestPath = path.join(this.rootDir, 'tokens.json'); } async ensureLoaded(): Promise { if (this.loaded) return; await fs.mkdir(this.filesDir, { recursive: true }); try { const raw = await fs.readFile(this.manifestPath, 'utf8'); const parsed = JSON.parse(raw) as TokensManifest; this.tokens = Array.isArray(parsed.tokens) ? parsed.tokens.filter( (t): t is AppToken => Boolean(t) && typeof t.id === 'string' && typeof t.name === 'string' && typeof t.imageRelPath === 'string' && typeof t.sha256 === 'string', ) : []; } catch { this.tokens = []; await this.persist(); } this.loaded = true; } list(): AppToken[] { return [...this.tokens]; } getById(id: TokenId): AppToken | null { return this.tokens.find((t) => t.id === id) ?? null; } findBySha256(sha256: string): AppToken | null { return this.tokens.find((t) => t.sha256 === sha256) ?? null; } getImageReadInfo(id: TokenId): { absPath: string; mime: string } | null { const token = this.getById(id); if (!token) return null; const absPath = path.join(this.rootDir, token.imageRelPath); return { absPath, mime: mimeFromExt(path.extname(token.imageRelPath)) }; } getImageUrl(id: TokenId): string | null { if (!this.getImageReadInfo(id)) return null; return `dnd://token?id=${encodeURIComponent(id)}`; } absPathForRel(relPath: string): string { return path.join(this.rootDir, relPath); } async upsert(input: { id?: TokenId | null; name: string; filePath?: string | null; }): Promise { await this.ensureLoaded(); const name = input.name.trim(); if (!name) throw new Error('Token name is required'); const existing = input.id ? this.getById(input.id) : null; if (input.id && !existing) throw new Error('Token not found'); if (!existing && !input.filePath) throw new Error('Token image is required'); let imageRelPath = existing?.imageRelPath ?? ''; let sha256 = existing?.sha256 ?? ''; if (input.filePath) { let buf = await fs.readFile(input.filePath); try { const opt = await optimizeImageBufferVisuallyLossless(buf); if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer); } catch { /* keep original */ } sha256 = crypto.createHash('sha256').update(buf).digest('hex'); const id = existing?.id ?? randomTokenId(); const ext = path.extname(input.filePath) || '.png'; const fileName = `${id}_${safeFileBase(name)}${ext.toLowerCase()}`; imageRelPath = path.join('files', fileName).replace(/\\/gu, '/'); const abs = path.join(this.rootDir, imageRelPath); await fs.mkdir(path.dirname(abs), { recursive: true }); await fs.writeFile(abs, buf); if (existing && existing.imageRelPath !== imageRelPath) { try { await fs.unlink(path.join(this.rootDir, existing.imageRelPath)); } catch { /* ignore */ } } const token: AppToken = { id, name, imageRelPath, sha256, }; if (existing) { this.tokens = this.tokens.map((t) => (t.id === id ? token : t)); } else { this.tokens = [...this.tokens, token]; } await this.persist(); return token; } const token: AppToken = { id: existing!.id, name, imageRelPath, sha256, }; this.tokens = this.tokens.map((t) => (t.id === token.id ? token : t)); await this.persist(); return token; } /** Импорт токена из внешнего файла (storyline zip) с заданным id или дедупом по sha256. */ async importFromFile(input: { preferredId: TokenId; name: string; absFilePath: string; sha256?: string; }): Promise<{ token: AppToken; remappedFrom: TokenId }> { await this.ensureLoaded(); let buf = await fs.readFile(input.absFilePath); const sha256 = input.sha256 ?? crypto.createHash('sha256').update(buf).digest('hex'); const existingByHash = this.findBySha256(sha256); if (existingByHash) { return { token: existingByHash, remappedFrom: input.preferredId }; } const existingById = this.getById(input.preferredId); const id = existingById ? randomTokenId() : input.preferredId; try { const opt = await optimizeImageBufferVisuallyLossless(buf); if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer); } catch { /* keep */ } const ext = path.extname(input.absFilePath) || '.png'; const fileName = `${id}_${safeFileBase(input.name)}${ext.toLowerCase()}`; const imageRelPath = path.join('files', fileName).replace(/\\/gu, '/'); const abs = path.join(this.rootDir, imageRelPath); await fs.mkdir(path.dirname(abs), { recursive: true }); await fs.writeFile(abs, buf); const token: AppToken = { id, name: input.name.trim() || 'Token', imageRelPath, sha256: crypto.createHash('sha256').update(buf).digest('hex'), }; this.tokens = [...this.tokens, token]; await this.persist(); return { token, remappedFrom: input.preferredId }; } async delete(id: TokenId): Promise { await this.ensureLoaded(); const existing = this.getById(id); if (!existing) return; this.tokens = this.tokens.filter((t) => t.id !== id); await this.persist(); try { await fs.unlink(path.join(this.rootDir, existing.imageRelPath)); } catch { /* ignore */ } } /** Упаковать выбранные токены в каталог экспорта (`app-tokens/`). */ async packForExport(tokenIds: string[], exportRoot: string): Promise { await this.ensureLoaded(); const idSet = new Set(tokenIds); const selected = this.tokens.filter((t) => idSet.has(t.id)); if (selected.length === 0) return; const outDir = path.join(exportRoot, 'app-tokens'); const filesOut = path.join(outDir, 'files'); await fs.mkdir(filesOut, { recursive: true }); const packed: AppToken[] = []; for (const t of selected) { const src = path.join(this.rootDir, t.imageRelPath); const base = path.basename(t.imageRelPath); const destRel = path.join('files', base).replace(/\\/gu, '/'); await fs.copyFile(src, path.join(outDir, destRel)); packed.push({ ...t, imageRelPath: destRel }); } await fs.writeFile( path.join(outDir, 'tokens.json'), `${JSON.stringify({ tokens: packed }, null, 2)}\n`, 'utf8', ); } /** * Импорт из `sourceCache/app-tokens/`. * @returns map oldTokenId → newTokenId */ async importFromExportDir(sourceCache: string): Promise> { await this.ensureLoaded(); const remap = new Map(); const manifestPath = path.join(sourceCache, 'app-tokens', 'tokens.json'); let raw: string; try { raw = await fs.readFile(manifestPath, 'utf8'); } catch { return remap; } let parsed: TokensManifest; try { parsed = JSON.parse(raw) as TokensManifest; } catch { return remap; } const list = Array.isArray(parsed.tokens) ? parsed.tokens : []; for (const t of list) { if (!t?.id || !t.imageRelPath) continue; const abs = path.join(sourceCache, 'app-tokens', t.imageRelPath); const { token, remappedFrom } = await this.importFromFile({ preferredId: asTokenId(t.id), name: typeof t.name === 'string' ? t.name : 'Token', absFilePath: abs, sha256: typeof t.sha256 === 'string' ? t.sha256 : undefined, }); remap.set(remappedFrom, token.id); } return remap; } private async persist(): Promise { await fs.mkdir(this.rootDir, { recursive: true }); const payload: TokensManifest = { tokens: this.tokens }; await fs.writeFile(this.manifestPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); } }