feat(tokens): app-local non-player tokens with session moves and UI polish

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>
This commit is contained in:
Ivan Fontosh
2026-07-27 11:04:12 +08:00
parent bdeb64e356
commit f270812219
44 changed files with 2617 additions and 341 deletions
+1
View File
@@ -387,6 +387,7 @@ export async function buildProjectFromFoundryDocuments(
previewRotationDeg: 0,
darkenScene: false,
traps: [],
tokens: [],
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
media: { videos: [], audios: audioRefs },
settings: {
+117 -9
View File
@@ -29,6 +29,8 @@ import { NpcsOverlayStore } from './npcs/npcsOverlayStore';
import { ZipProjectStore } from './project/zipStore';
import { SceneViewStore } from './sceneView/sceneViewStore';
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
import { SceneTokensSessionStore } from './tokens/sceneTokensSessionStore';
import { TokensStore } from './tokens/tokensStore';
import { installAutoUpdater } from './update/installAutoUpdater';
import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo';
import { VideoPlaybackStore } from './video/videoPlaybackStore';
@@ -165,6 +167,8 @@ const sceneViewStore = new SceneViewStore();
const videoStore = new VideoPlaybackStore();
const materialsOverlayStore = new MaterialsOverlayStore();
const npcsOverlayStore = new NpcsOverlayStore();
const sceneTokensSessionStore = new SceneTokensSessionStore();
let tokensStore: TokensStore | null = null;
function emitEffectsState(): void {
const state = effectsStore.getState();
@@ -219,6 +223,20 @@ function emitSceneViewState(): void {
}
}
function emitTokensState(): void {
const tokens = tokensStore?.list() ?? [];
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.tokens.stateChanged, { tokens });
}
}
function emitSceneTokensSessionState(): void {
const state = sceneTokensSessionStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.sceneTokensSession.stateChanged, { state });
}
}
function syncSceneDarknessForProject(project: Project): void {
const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null;
const scene = project.currentSceneId ? project.scenes[project.currentSceneId] : undefined;
@@ -366,11 +384,13 @@ async function main() {
});
const licenseService = new LicenseService(app.getPath('userData'));
tokensStore = new TokensStore(app.getPath('userData'));
await tokensStore.ensureLoaded();
setLicenseAssert(() => {
licenseService.assertForIpc();
});
installAppMenuForSession();
registerDndAssetProtocol(projectStore);
registerDndAssetProtocol(projectStore, tokensStore);
registerHandler(ipcChannels.app.quit, () => {
markAppQuitting();
app.quit();
@@ -388,6 +408,7 @@ async function main() {
registerHandler(ipcChannels.windows.openMultiWindow, () => {
sceneDarknessStore.resetSession();
sceneTrapsStore.resetSession();
sceneTokensSessionStore.reset();
effectsStore.dispatch({ kind: 'tool.set', tool: effectsDefaultTool() });
openMultiWindow();
const project = projectStore.getOpenProject();
@@ -397,6 +418,7 @@ async function main() {
}
emitSceneDarknessState();
emitSceneTrapsState();
emitSceneTokensSessionState();
emitEffectsState();
return { ok: true };
});
@@ -491,7 +513,9 @@ async function main() {
registerHandler(ipcChannels.project.open, async ({ projectId }) => {
const project = await projectStore.openProjectById(projectId);
sceneViewStore.reset();
sceneTokensSessionStore.reset();
emitSceneViewState();
emitSceneTokensSessionState();
emitSessionState();
return { project };
});
@@ -503,12 +527,14 @@ async function main() {
sceneDarknessStore.resetSession();
sceneTrapsStore.resetSession();
sceneViewStore.reset();
sceneTokensSessionStore.reset();
emitEffectsState();
emitMaterialsOverlayState();
emitNpcsOverlayState();
emitSceneDarknessState();
emitSceneTrapsState();
emitSceneViewState();
emitSceneTokensSessionState();
emitSessionState();
return { ok: true };
});
@@ -525,6 +551,7 @@ async function main() {
materialsOverlayStore.clear();
npcsOverlayStore.clear();
sceneViewStore.reset();
// Token moves persist for the whole play session (reset only on project open/close).
const project = projectStore.getOpenProject();
if (project) {
syncSceneDarknessForProject(project);
@@ -536,6 +563,7 @@ async function main() {
emitSceneDarknessState();
emitSceneTrapsState();
emitSceneViewState();
emitSceneTokensSessionState();
emitSessionState();
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
});
@@ -552,6 +580,7 @@ async function main() {
materialsOverlayStore.clear();
npcsOverlayStore.clear();
sceneViewStore.reset();
// Token moves persist for the whole play session (reset only on project open/close).
const project = projectStore.getOpenProject();
if (project) {
syncSceneDarknessForProject(project);
@@ -563,6 +592,7 @@ async function main() {
emitSceneDarknessState();
emitSceneTrapsState();
emitSceneViewState();
emitSceneTokensSessionState();
emitSessionState();
const p = projectStore.getOpenProject();
return {
@@ -1018,6 +1048,11 @@ async function main() {
});
},
npcResolutions,
async (cacheDir) => {
const remap = await tokensStore!.importFromExportDir(cacheDir);
emitTokensState();
return remap;
},
);
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState();
@@ -1041,6 +1076,13 @@ async function main() {
});
},
npcResolutions,
async (cacheDir) => {
// Импорт из другого локального проекта: app-tokens в zip нет — только из внешнего архива.
// Если в sourceCache нет app-tokens, remap пустой.
const remap = await tokensStore!.importFromExportDir(cacheDir);
emitTokensState();
return remap;
},
);
emitZipProgress({ kind: 'import', stage: 'done', percent: 100, detail: 'Готово' });
emitSessionState();
@@ -1121,14 +1163,23 @@ async function main() {
const dest = normalizeSaveProjectZipPath(filePath);
try {
emitZipProgress({ kind: 'export', stage: 'zip', percent: 0, detail: 'Экспорт…' });
await projectStore.exportStorylinesZipToPath(projectId, storylineSelections, dest, labels, (p) => {
emitZipProgress({
kind: 'export',
stage: p.stage,
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
});
await projectStore.exportStorylinesZipToPath(
projectId,
storylineSelections,
dest,
labels,
(p) => {
emitZipProgress({
kind: 'export',
stage: p.stage,
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
},
async (tokenIds, exportRoot) => {
await tokensStore!.packForExport(tokenIds, exportRoot);
},
);
emitZipProgress({ kind: 'export', stage: 'done', percent: 100, detail: 'Готово' });
return { canceled: false as const };
} catch (err) {
@@ -1182,6 +1233,63 @@ async function main() {
return { ok: true };
});
registerHandler(ipcChannels.tokens.list, async () => {
await tokensStore!.ensureLoaded();
return { tokens: tokensStore!.list() };
});
registerHandler(ipcChannels.tokens.upsert, async ({ id, name, filePath }) => {
const token = await tokensStore!.upsert({ id, name, filePath });
emitTokensState();
return { token };
});
registerHandler(ipcChannels.tokens.delete, async ({ id }) => {
await tokensStore!.delete(id);
// Убрать placements с текущей сцены.
const project = projectStore.getOpenProject();
const sceneId = project?.currentSceneId;
if (sceneId && project) {
const scene = project.scenes[sceneId];
if (scene?.tokens?.some((t) => t.tokenId === id)) {
await projectStore.updateScene(sceneId, {
tokens: scene.tokens.filter((t) => t.tokenId !== id),
});
emitSessionState();
}
}
emitTokensState();
return { ok: true };
});
registerHandler(ipcChannels.tokens.pickImage, async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: openDialogFilterLabel('images', app.getLocale()),
extensions: ['png', 'jpg', 'jpeg', 'webp'],
},
],
});
if (canceled || filePaths.length === 0) return { canceled: true as const };
const filePath = filePaths[0]!;
const buf = await fs.readFile(filePath);
const ext = path.extname(filePath).toLowerCase();
const mime =
ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
return { canceled: false as const, filePath, previewDataUrl };
});
registerHandler(ipcChannels.tokens.imageUrl, ({ id }) => {
return { url: tokensStore!.getImageUrl(id) };
});
registerHandler(ipcChannels.sceneTokensSession.getState, () => {
return { state: sceneTokensSessionStore.getState() };
});
registerHandler(ipcChannels.sceneTokensSession.dispatch, ({ event }) => {
sceneTokensSessionStore.dispatch(event);
emitSceneTokensSessionState();
return { ok: true };
});
registerHandler(ipcChannels.video.getState, () => {
return { state: videoStore.getState() };
});
+34 -2
View File
@@ -15,11 +15,13 @@ import {
} from '../../shared/graph/sceneListOrder';
import {
buildPartialExportProject,
collectTokenIdsFromProject,
computeGraphImportOffsetX,
listExportableStorylines,
listImportableStorylines,
mergeStorylinesIntoProject,
newExportBundleProjectId,
remapProjectSceneTokenIds,
type NpcImportResolution,
type SceneImportResolution,
type StorylineImportMergeReport,
@@ -51,6 +53,7 @@ import type {
} from '../../shared/types';
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
import { normalizeMaterialLegend } from '../../shared/types/materialLegend';
import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens';
import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid';
import { normalizeSceneTrap } from '../../shared/types/sceneTraps';
import type { AssetId, GraphNodeId, MaterialId, NpcGroupId, NpcId, NpcRelationId } from '../../shared/types/ids';
@@ -617,12 +620,14 @@ export class ZipProjectStore {
previewRotationDeg: 0,
darkenScene: false,
traps: [],
tokens: [],
grid: { ...DEFAULT_SCENE_GRID },
} satisfies Scene);
const next: Scene = {
...base,
traps: base.traps ?? [],
tokens: base.tokens ?? [],
grid: base.grid ?? { ...DEFAULT_SCENE_GRID },
...(patch.title !== undefined ? { title: patch.title } : null),
...(patch.description !== undefined ? { description: patch.description } : null),
@@ -643,6 +648,13 @@ export class ZipProjectStore {
.filter((t): t is SceneTrap => Boolean(t)),
}
: null),
...(patch.tokens !== undefined
? {
tokens: patch.tokens
.map((t) => normalizeSceneToken(t))
.filter((t): t is SceneToken => Boolean(t)),
}
: null),
...(patch.grid !== undefined ? { grid: normalizeSceneGrid(patch.grid) } : null),
...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null),
...(patch.media ? { media: { ...base.media, ...patch.media } } : null),
@@ -1992,6 +2004,7 @@ export class ZipProjectStore {
destinationPath: string,
labels: StorylineLabels,
onProgress?: (p: { stage: 'zip' | 'done'; percent: number; detail?: string }) => void,
packAppTokens?: (tokenIds: string[], exportRoot: string) => Promise<void>,
): Promise<void> {
if (selections.length === 0) throw new Error('Не выбрана ни одна сюжетная линия');
await this.ensureRoots();
@@ -2016,10 +2029,14 @@ export class ZipProjectStore {
await fs.mkdir(path.dirname(destAbs), { recursive: true });
await fs.copyFile(srcAbs, destAbs);
if (onProgress) {
const pct = Math.round(((i + 1) / Math.max(1, assetIds.length)) * 80);
const pct = Math.round(((i + 1) / Math.max(1, assetIds.length)) * 75);
onProgress({ stage: 'zip', percent: pct, detail: 'Сборка архива…' });
}
}
if (packAppTokens) {
const tokenIds = collectTokenIdsFromProject(partial);
await packAppTokens(tokenIds, exportCache);
}
const now = new Date().toISOString();
const toWrite: Project = {
...partial,
@@ -2116,12 +2133,18 @@ export class ZipProjectStore {
sceneResolutions: SceneImportResolution[],
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
npcResolutions?: NpcImportResolution[],
importAppTokens?: (sourceCacheDir: string) => Promise<Map<string, string>>,
): Promise<{ project: Project; report: StorylineImportMergeReport }> {
if (!this.openProject) throw new Error('Нет открытого проекта');
let sourceForMerge = source;
if (importAppTokens) {
const remap = await importAppTokens(sourceCache);
sourceForMerge = remapProjectSceneTokenIds(source, remap);
}
const offsetX = computeGraphImportOffsetX(this.openProject.project);
const { project: merged, report, assetCopies } = mergeStorylinesIntoProject(
this.openProject.project,
source,
sourceForMerge,
selections,
sceneResolutions,
{
@@ -2162,6 +2185,7 @@ export class ZipProjectStore {
sceneResolutions: SceneImportResolution[],
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
npcResolutions?: NpcImportResolution[],
importAppTokens?: (sourceCacheDir: string) => Promise<Map<string, string>>,
): Promise<{ project: Project; report: StorylineImportMergeReport }> {
this.assertStorylineMergeAllowed(selections);
const snap = await this.loadProjectSnapshot(sourceProjectId);
@@ -2173,6 +2197,7 @@ export class ZipProjectStore {
sceneResolutions,
onProgress,
npcResolutions,
importAppTokens,
);
} finally {
if (snap.ownsCache) {
@@ -2187,6 +2212,7 @@ export class ZipProjectStore {
sceneResolutions: SceneImportResolution[],
onProgress?: (p: { stage: 'copy' | 'done'; percent: number; detail?: string }) => void,
npcResolutions?: NpcImportResolution[],
importAppTokens?: (sourceCacheDir: string) => Promise<Map<string, string>>,
): Promise<{ project: Project; report: StorylineImportMergeReport }> {
this.assertStorylineMergeAllowed(selections);
const source = await this.readExternalProjectForImport(sourcePath);
@@ -2201,6 +2227,7 @@ export class ZipProjectStore {
sceneResolutions,
onProgress,
npcResolutions,
importAppTokens,
);
} finally {
await fs.rm(sourceCache, { recursive: true, force: true }).catch(() => undefined);
@@ -2329,6 +2356,10 @@ function normalizeScene(s: Scene): Scene {
const traps = (Array.isArray(rawTraps) ? rawTraps : [])
.map((t) => normalizeSceneTrap(t))
.filter((t): t is SceneTrap => Boolean(t));
const rawTokens = (s as unknown as { tokens?: unknown[] }).tokens;
const tokens = (Array.isArray(rawTokens) ? rawTokens : [])
.map((t) => normalizeSceneToken(t))
.filter((t): t is SceneToken => Boolean(t));
const grid = normalizeSceneGrid((s as unknown as { grid?: unknown }).grid);
const rawAudios = Array.isArray(raw.audios) ? raw.audios : [];
@@ -2359,6 +2390,7 @@ function normalizeScene(s: Scene): Scene {
previewRotationDeg,
darkenScene,
traps,
tokens,
grid,
layout: layoutIn ?? { x: 0, y: 0 },
media: {
+79 -67
View File
@@ -2,38 +2,39 @@ import fs from 'node:fs/promises';
import { session } from 'electron';
import { asAssetId } from '../../shared/types/ids';
import { asAssetId, asTokenId } from '../../shared/types/ids';
import type { ZipProjectStore } from '../project/zipStore';
import type { TokensStore } from '../tokens/tokensStore';
/**
* Обслуживает `dnd://asset?...` — без этого `<img src="file://...">` в рендерере часто ломается.
*/
export function registerDndAssetProtocol(projectStore: ZipProjectStore): void {
session.defaultSession.protocol.handle('dnd', async (request) => {
const url = new URL(request.url);
if (url.hostname !== 'asset') {
return new Response(null, { status: 404 });
}
const id = url.searchParams.get('id');
if (!id) {
return new Response(null, { status: 404 });
}
const info = projectStore.getAssetReadInfo(asAssetId(id));
if (!info) {
return new Response(null, { status: 404 });
}
try {
const stat = await fs.stat(info.absPath);
const total = stat.size;
const range = request.headers.get('range') ?? request.headers.get('Range');
type ReadInfo = { absPath: string; mime: string };
if (range) {
const m = /^bytes=(\d+)-(\d+)?$/iu.exec(range.trim());
if (m) {
const start = Number(m[1]);
const endRaw = m[2] ? Number(m[2]) : total - 1;
const end = Math.min(endRaw, total - 1);
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= total || end < start) {
async function serveFile(info: ReadInfo, request: Request): Promise<Response> {
try {
const stat = await fs.stat(info.absPath);
const total = stat.size;
const range = request.headers.get('range') ?? request.headers.get('Range');
if (range) {
const m = /^bytes=(\d+)-(\d+)?$/iu.exec(range.trim());
if (m) {
const start = Number(m[1]);
const endRaw = m[2] ? Number(m[2]) : total - 1;
const end = Math.min(endRaw, total - 1);
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= total || end < start) {
return new Response(null, {
status: 416,
headers: {
'Content-Range': `bytes */${String(total)}`,
'Cache-Control': 'no-store',
},
});
}
const len = end - start + 1;
const fh = await fs.open(info.absPath, 'r');
try {
const buf = Buffer.alloc(len);
const { bytesRead } = await fh.read(buf, 0, len, start);
if (bytesRead <= 0) {
return new Response(null, {
status: 416,
headers: {
@@ -42,48 +43,59 @@ export function registerDndAssetProtocol(projectStore: ZipProjectStore): void {
},
});
}
const len = end - start + 1;
const fh = await fs.open(info.absPath, 'r');
try {
const buf = Buffer.alloc(len);
const { bytesRead } = await fh.read(buf, 0, len, start);
if (bytesRead <= 0) {
return new Response(null, {
status: 416,
headers: {
'Content-Range': `bytes */${String(total)}`,
'Cache-Control': 'no-store',
},
});
}
const body = bytesRead === len ? buf : Buffer.from(buf.subarray(0, bytesRead));
const actualEnd = start + bytesRead - 1;
return new Response(body, {
status: 206,
headers: {
'Content-Type': info.mime,
'Accept-Ranges': 'bytes',
'Content-Range': `bytes ${String(start)}-${String(actualEnd)}/${String(total)}`,
'Content-Length': String(body.length),
'Cache-Control': 'no-store',
},
});
} finally {
await fh.close();
}
const body = bytesRead === len ? buf : Buffer.from(buf.subarray(0, bytesRead));
const actualEnd = start + bytesRead - 1;
return new Response(body, {
status: 206,
headers: {
'Content-Type': info.mime,
'Accept-Ranges': 'bytes',
'Content-Range': `bytes ${String(start)}-${String(actualEnd)}/${String(total)}`,
'Content-Length': String(body.length),
'Cache-Control': 'no-store',
},
});
} finally {
await fh.close();
}
}
}
const buf = await fs.readFile(info.absPath);
return new Response(buf, {
headers: {
'Content-Type': info.mime,
'Accept-Ranges': 'bytes',
'Cache-Control': 'no-store',
},
});
} catch {
const buf = await fs.readFile(info.absPath);
return new Response(buf, {
headers: {
'Content-Type': info.mime,
'Accept-Ranges': 'bytes',
'Cache-Control': 'no-store',
},
});
} catch {
return new Response(null, { status: 404 });
}
}
/**
* Обслуживает `dnd://asset?...` и `dnd://token?...`.
*/
export function registerDndAssetProtocol(
projectStore: ZipProjectStore,
tokensStore: TokensStore,
): void {
session.defaultSession.protocol.handle('dnd', async (request) => {
const url = new URL(request.url);
const id = url.searchParams.get('id');
if (!id) {
return new Response(null, { status: 404 });
}
let info: ReadInfo | null = null;
if (url.hostname === 'asset') {
info = projectStore.getAssetReadInfo(asAssetId(id));
} else if (url.hostname === 'token') {
info = tokensStore.getImageReadInfo(asTokenId(id));
}
if (!info) {
return new Response(null, { status: 404 });
}
return serveFile(info, request);
});
}
@@ -0,0 +1,54 @@
import type { SceneTokensSessionEvent, SceneTokensSessionState } from '../../shared/types';
function emptyState(): SceneTokensSessionState {
return {
revision: 1,
byPlacementId: {},
};
}
export class SceneTokensSessionStore {
private state: SceneTokensSessionState = emptyState();
getState(): SceneTokensSessionState {
return this.state;
}
reset(): SceneTokensSessionState {
if (Object.keys(this.state.byPlacementId).length === 0) return this.state;
this.state = {
revision: this.state.revision + 1,
byPlacementId: {},
};
return this.state;
}
dispatch(event: SceneTokensSessionEvent): SceneTokensSessionState {
switch (event.kind) {
case 'clear':
return this.reset();
case 'move': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
const nx = Math.max(0, Math.min(1, event.nx));
const ny = Math.max(0, Math.min(1, event.ny));
if (!Number.isFinite(nx) || !Number.isFinite(ny)) return this.state;
const prev = this.state.byPlacementId[placementId];
if (prev && prev.nx === nx && prev.ny === ny) return this.state;
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: { nx, ny },
},
};
return this.state;
}
default: {
const _exhaustive: never = event;
void _exhaustive;
return this.state;
}
}
}
}
+277
View File
@@ -0,0 +1,277 @@
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');
}
}