f270812219
Add token library/placements, keep play-time moves for the session, lock presentation interactions, and fix export/import modal layout plus freeform trap label. Co-authored-by: Cursor <cursoragent@cursor.com>
278 lines
9.2 KiB
TypeScript
278 lines
9.2 KiB
TypeScript
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<void> {
|
|
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<AppToken> {
|
|
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<void> {
|
|
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<void> {
|
|
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<Map<string, string>> {
|
|
await this.ensureLoaded();
|
|
const remap = new Map<string, string>();
|
|
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<void> {
|
|
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');
|
|
}
|
|
}
|