kingkont 0.7.39 → 0.7.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,391 @@
1
+ // Операции с папкой KingKont-проекта (FS-уровень).
2
+ // Используется CLI (bin/kingkont.js). UI работает через File System Access
3
+ // API, но формат файлов идентичный — поэтому эти операции совместимы.
4
+ //
5
+ // Формат проекта (см. skill/SKILL.md):
6
+ // <root>/
7
+ // ├── _characters/<name>/scene.json + clips/ images/ audio/ texts/
8
+ // ├── _locations/<name>/scene.json
9
+ // ├── <scene-name>/scene.json
10
+ // └── _deleted/
11
+
12
+ 'use strict';
13
+
14
+ const fs = require('node:fs');
15
+ const fsp = require('node:fs/promises');
16
+ const path = require('node:path');
17
+ const crypto = require('node:crypto');
18
+
19
+ // =============================================================================
20
+ // Board references — парсинг и резолв путей.
21
+ // =============================================================================
22
+
23
+ /**
24
+ * Распарсить board-reference в нормализованный вид.
25
+ * '_characters/Anna' → { kind: 'character', name: 'Anna' }
26
+ * 'characters/Anna' → { kind: 'character', name: 'Anna' }
27
+ * '_locations/Office' → { kind: 'location', name: 'Office' }
28
+ * 'locations/Office' → { kind: 'location', name: 'Office' }
29
+ * 'Scene1' → { kind: 'scene', name: 'Scene1' }
30
+ */
31
+ function parseBoardRef(s) {
32
+ if (!s || typeof s !== 'string') throw new Error('board ref пустой');
33
+ const parts = s.split(/[\\/]/).filter(Boolean);
34
+ if (parts.length === 1) return { kind: 'scene', name: parts[0] };
35
+ const head = parts[0].replace(/^_/, '').toLowerCase();
36
+ const tail = parts.slice(1).join('/');
37
+ if (head === 'characters' || head === 'character') return { kind: 'character', name: tail };
38
+ if (head === 'locations' || head === 'location') return { kind: 'location', name: tail };
39
+ // Иначе: считаем что это сцена с подпапкой (редко, но возможно).
40
+ return { kind: 'scene', name: parts.join('/') };
41
+ }
42
+
43
+ /** Абсолютный путь к папке доски. */
44
+ function boardDir(root, ref) {
45
+ if (typeof ref === 'string') ref = parseBoardRef(ref);
46
+ switch (ref.kind) {
47
+ case 'character': return path.join(root, '_characters', ref.name);
48
+ case 'location': return path.join(root, '_locations', ref.name);
49
+ case 'scene': return path.join(root, ref.name);
50
+ }
51
+ throw new Error(`unknown board kind: ${ref.kind}`);
52
+ }
53
+
54
+ /** Уникальный «ключ» (как в renderer'е): 'scene/Scene1' / 'character/Anna'. */
55
+ function boardKey(ref) {
56
+ if (typeof ref === 'string') ref = parseBoardRef(ref);
57
+ return `${ref.kind}/${ref.name}`;
58
+ }
59
+
60
+ // =============================================================================
61
+ // Listing.
62
+ // =============================================================================
63
+
64
+ /** Все доски проекта. */
65
+ async function listBoards(root) {
66
+ const out = { scenes: [], characters: [], locations: [] };
67
+ const entries = await fsp.readdir(root, { withFileTypes: true }).catch(() => []);
68
+ for (const e of entries) {
69
+ if (!e.isDirectory()) continue;
70
+ if (e.name === '_characters') {
71
+ const sub = await fsp.readdir(path.join(root, '_characters'), { withFileTypes: true }).catch(() => []);
72
+ for (const c of sub) if (c.isDirectory() && (await hasSceneJson(path.join(root, '_characters', c.name)))) {
73
+ out.characters.push(c.name);
74
+ }
75
+ } else if (e.name === '_locations') {
76
+ const sub = await fsp.readdir(path.join(root, '_locations'), { withFileTypes: true }).catch(() => []);
77
+ for (const l of sub) if (l.isDirectory() && (await hasSceneJson(path.join(root, '_locations', l.name)))) {
78
+ out.locations.push(l.name);
79
+ }
80
+ } else if (e.name === '_deleted' || e.name.startsWith('.') || e.name === 'node_modules') {
81
+ continue;
82
+ } else if (await hasSceneJson(path.join(root, e.name))) {
83
+ out.scenes.push(e.name);
84
+ }
85
+ }
86
+ return out;
87
+ }
88
+
89
+ async function hasSceneJson(dir) {
90
+ try { return (await fsp.stat(path.join(dir, 'scene.json'))).isFile(); } catch { return false; }
91
+ }
92
+
93
+ // =============================================================================
94
+ // Scene IO.
95
+ // =============================================================================
96
+
97
+ /**
98
+ * Прочитать scene.json и распаковать text-ноды (текст подгружается из .md).
99
+ * @returns {Promise<object>} { nodes, connections, view, character, location, timeline, history }
100
+ */
101
+ async function loadScene(root, ref) {
102
+ const dir = boardDir(root, ref);
103
+ const sceneFile = path.join(dir, 'scene.json');
104
+ let raw;
105
+ try { raw = await fsp.readFile(sceneFile, 'utf-8'); }
106
+ catch (e) {
107
+ if (e.code === 'ENOENT') {
108
+ // Доски ещё нет — вернём пустую (для генерации новой).
109
+ return { nodes: [], connections: [], view: null, character: null, location: null, timeline: null, history: null };
110
+ }
111
+ throw e;
112
+ }
113
+ const data = JSON.parse(raw);
114
+ const scene = {
115
+ nodes: Array.isArray(data.nodes) ? data.nodes : [],
116
+ connections: Array.isArray(data.connections) ? data.connections : [],
117
+ view: data.view || null,
118
+ character: data.character || null,
119
+ location: data.location || null,
120
+ timeline: data.timeline || null,
121
+ history: data.history || null,
122
+ };
123
+ // Подгрузить .md для text-нод (как делает renderer в loadBoardMetadata).
124
+ for (const n of scene.nodes) {
125
+ if (n.type === 'text' && n.file) {
126
+ try { n.text = await fsp.readFile(path.join(dir, n.file), 'utf-8'); }
127
+ catch { n.text = ''; }
128
+ }
129
+ }
130
+ return scene;
131
+ }
132
+
133
+ /**
134
+ * Записать scene.json. Текст text-нод вынесет в .md (или обновит).
135
+ * Создаёт папку доски если её нет.
136
+ */
137
+ async function saveScene(root, ref, scene) {
138
+ const dir = boardDir(root, ref);
139
+ await fsp.mkdir(dir, { recursive: true });
140
+ // Сохранить .md для text-нод.
141
+ for (const n of scene.nodes) {
142
+ if (n.type === 'text' && n.file && typeof n.text === 'string') {
143
+ const full = path.join(dir, n.file);
144
+ await fsp.mkdir(path.dirname(full), { recursive: true });
145
+ await fsp.writeFile(full, n.text);
146
+ }
147
+ }
148
+ const nodesForJson = scene.nodes.map(n => {
149
+ const out = { ...n };
150
+ delete out.text;
151
+ return out;
152
+ });
153
+ const payload = {
154
+ nodes: nodesForJson,
155
+ connections: scene.connections || [],
156
+ view: scene.view || null,
157
+ character: scene.character || null,
158
+ location: scene.location || null,
159
+ timeline: scene.timeline || null,
160
+ history: scene.history && (scene.history.past?.length || scene.history.future?.length) ? scene.history : null,
161
+ };
162
+ await fsp.writeFile(path.join(dir, 'scene.json'), JSON.stringify(payload, null, 2));
163
+ }
164
+
165
+ // =============================================================================
166
+ // Node mutations.
167
+ // =============================================================================
168
+
169
+ const NODE_KINDS = new Set(['image', 'video', 'audio', 'text']);
170
+
171
+ /**
172
+ * Добавить ноду в scene. Мутирует scene.nodes. Возвращает новую ноду.
173
+ * @param {object} scene
174
+ * @param {object} partial { type, name?, x?, y?, file?, text?, generated?, ... }
175
+ */
176
+ function addNode(scene, partial) {
177
+ if (!partial || typeof partial !== 'object') throw new Error('node partial обязателен');
178
+ if (!NODE_KINDS.has(partial.type)) throw new Error(`unknown node type: ${partial.type}`);
179
+ // Авто-координаты: если не задано, ставим справа от последней.
180
+ let x = partial.x, y = partial.y;
181
+ if (typeof x !== 'number' || typeof y !== 'number') {
182
+ const last = scene.nodes[scene.nodes.length - 1];
183
+ x = last ? (last.x + 320) : 100;
184
+ y = last ? last.y : 100;
185
+ }
186
+ const node = {
187
+ id: crypto.randomUUID(),
188
+ type: partial.type,
189
+ x, y,
190
+ ...(partial.name ? { name: partial.name } : {}),
191
+ ...(partial.file ? { file: partial.file } : {}),
192
+ ...(partial.width ? { width: partial.width } : {}),
193
+ ...(partial.height ? { height: partial.height } : {}),
194
+ ...(partial.generated ? { generated: partial.generated } : {}),
195
+ ...(partial.status ? { status: partial.status } : {}),
196
+ };
197
+ if (partial.type === 'text' && typeof partial.text === 'string') {
198
+ node.text = partial.text;
199
+ // .md-файл создадим при saveScene если ещё нет.
200
+ if (!node.file) {
201
+ const slug = (partial.name || 'text').replace(/[^\wЀ-ӿ.-]+/g, '_').slice(0, 60) || 'text';
202
+ node.file = `texts/${slug}_${node.id.slice(0, 8)}.md`;
203
+ }
204
+ }
205
+ scene.nodes.push(node);
206
+ return node;
207
+ }
208
+
209
+ /** Найти ноду по id или имени. */
210
+ function findNode(scene, idOrName) {
211
+ return scene.nodes.find(n => n.id === idOrName)
212
+ || scene.nodes.find(n => n.name === idOrName);
213
+ }
214
+
215
+ /** Добавить связь. Дубликаты игнорируются. */
216
+ function addConnection(scene, fromId, toId, opts = {}) {
217
+ if (!scene.connections) scene.connections = [];
218
+ const exists = scene.connections.some(c => c.from === fromId && c.to === toId);
219
+ if (exists) return false;
220
+ const conn = { from: fromId, to: toId };
221
+ if (opts.toPort) conn.toPort = opts.toPort;
222
+ scene.connections.push(conn);
223
+ return true;
224
+ }
225
+
226
+ /** Удалить ноду + связанные коннекты + переместить файл в _deleted/. */
227
+ async function removeNode(root, ref, nodeId) {
228
+ const scene = await loadScene(root, ref);
229
+ const idx = scene.nodes.findIndex(n => n.id === nodeId);
230
+ if (idx < 0) throw new Error(`node ${nodeId} не найдена`);
231
+ const node = scene.nodes[idx];
232
+ scene.nodes.splice(idx, 1);
233
+ scene.connections = (scene.connections || []).filter(c => c.from !== nodeId && c.to !== nodeId);
234
+ // Переместить файл в _deleted/.
235
+ if (node.file) {
236
+ try {
237
+ const src = path.join(boardDir(root, ref), node.file);
238
+ const trash = path.join(root, '_deleted', boardKey(ref).replace('/', '_'));
239
+ await fsp.mkdir(trash, { recursive: true });
240
+ const dst = path.join(trash, `${nodeId.slice(0, 8)}_${path.basename(node.file)}`);
241
+ await fsp.rename(src, dst).catch(() => {});
242
+ } catch {}
243
+ }
244
+ await saveScene(root, ref, scene);
245
+ return node;
246
+ }
247
+
248
+ // =============================================================================
249
+ // Reference resolution для генерации.
250
+ // =============================================================================
251
+
252
+ /**
253
+ * Резолвит @-ссылки из промпта/refs в массив абсолютных путей к файлам и
254
+ * meta-инфу. Используется CLI перед загрузкой в провайдера.
255
+ *
256
+ * Поиск:
257
+ * 1) В текущей доске — по имени ноды.
258
+ * 2) Если имя совпадает с character/location — берём всю доску
259
+ * (для character — `characterSheet`/первую image-ноду).
260
+ *
261
+ * @returns {Promise<Array<{ name, type, file, abs, char?, board? }>>}
262
+ */
263
+ async function resolveRefs(root, ref, refNames) {
264
+ if (!Array.isArray(refNames) || !refNames.length) return [];
265
+ const out = [];
266
+ const scene = await loadScene(root, ref);
267
+ const boards = await listBoards(root);
268
+ const boardDirAbs = boardDir(root, ref);
269
+
270
+ for (const raw of refNames) {
271
+ const name = raw.replace(/^@/, '');
272
+ // 1) Локальная нода в текущей доске
273
+ const local = scene.nodes.find(n => n.name === name);
274
+ if (local && local.file) {
275
+ out.push({
276
+ name, type: local.type, file: local.file,
277
+ abs: path.join(boardDirAbs, local.file),
278
+ });
279
+ continue;
280
+ }
281
+ // 2) Character / location доска
282
+ if (boards.characters.includes(name)) {
283
+ const charScene = await loadScene(root, { kind: 'character', name });
284
+ const sheet = charScene.character?.characterSheet
285
+ || charScene.nodes.find(n => n.type === 'image')?.file;
286
+ if (sheet) {
287
+ out.push({
288
+ name, type: 'image', file: sheet,
289
+ abs: path.join(boardDir(root, { kind: 'character', name }), sheet),
290
+ char: name,
291
+ });
292
+ }
293
+ continue;
294
+ }
295
+ if (boards.locations.includes(name)) {
296
+ const locScene = await loadScene(root, { kind: 'location', name });
297
+ const first = locScene.nodes.find(n => n.type === 'image');
298
+ if (first?.file) {
299
+ out.push({
300
+ name, type: 'image', file: first.file,
301
+ abs: path.join(boardDir(root, { kind: 'location', name }), first.file),
302
+ });
303
+ }
304
+ continue;
305
+ }
306
+ // Не нашли — пропускаем (молча, но логируем).
307
+ console.warn(`[refs] не нашёл @${name} (ни ноды, ни character/location)`);
308
+ }
309
+ return out;
310
+ }
311
+
312
+ // =============================================================================
313
+ // Helpers для CLI генерации (file naming).
314
+ // =============================================================================
315
+
316
+ /**
317
+ * Сгенерировать уникальное имя файла в субпапке доски.
318
+ * `images/img_001.jpg` → `images/img_002.jpg` если 001 занят.
319
+ */
320
+ async function uniqueFilename(root, ref, subdir, baseName) {
321
+ const dir = path.join(boardDir(root, ref), subdir);
322
+ await fsp.mkdir(dir, { recursive: true });
323
+ const ext = path.extname(baseName);
324
+ const stem = path.basename(baseName, ext);
325
+ let candidate = baseName;
326
+ let i = 1;
327
+ while (true) {
328
+ try { await fsp.access(path.join(dir, candidate)); }
329
+ catch { return path.join(subdir, candidate); } // не существует — занимаем
330
+ i++;
331
+ candidate = `${stem}_${String(i).padStart(3, '0')}${ext}`;
332
+ }
333
+ }
334
+
335
+ /** Сохранить буфер в файл доски. Возвращает относительный путь. */
336
+ async function saveBoardFile(root, ref, relPath, buffer) {
337
+ const abs = path.join(boardDir(root, ref), relPath);
338
+ await fsp.mkdir(path.dirname(abs), { recursive: true });
339
+ await fsp.writeFile(abs, buffer);
340
+ return relPath;
341
+ }
342
+
343
+ /** Прочитать файл из доски. */
344
+ async function readBoardFile(root, ref, relPath) {
345
+ return await fsp.readFile(path.join(boardDir(root, ref), relPath));
346
+ }
347
+
348
+ // =============================================================================
349
+ // Дефолтный субдир для kind.
350
+ // =============================================================================
351
+ function defaultSubdir(kind, subKind) {
352
+ if (kind === 'image') return 'images';
353
+ if (kind === 'video') return 'clips';
354
+ if (kind === 'audio') return 'audio';
355
+ if (kind === 'text') return 'texts';
356
+ return 'misc';
357
+ }
358
+
359
+ function defaultExt(kind, subKind, mime) {
360
+ if (mime?.includes('jpeg') || mime?.includes('jpg')) return '.jpg';
361
+ if (mime?.includes('png')) return '.png';
362
+ if (mime?.includes('webp')) return '.webp';
363
+ if (mime?.includes('mpeg') && kind === 'audio') return '.mp3';
364
+ if (mime?.includes('wav')) return '.wav';
365
+ if (mime?.includes('mp4')) return '.mp4';
366
+ if (mime?.includes('webm')) return '.webm';
367
+ if (kind === 'image') return '.jpg';
368
+ if (kind === 'video') return '.mp4';
369
+ if (kind === 'audio') return '.mp3';
370
+ if (kind === 'text') return '.md';
371
+ return '.bin';
372
+ }
373
+
374
+ module.exports = {
375
+ parseBoardRef,
376
+ boardDir,
377
+ boardKey,
378
+ listBoards,
379
+ loadScene,
380
+ saveScene,
381
+ addNode,
382
+ findNode,
383
+ addConnection,
384
+ removeNode,
385
+ resolveRefs,
386
+ uniqueFilename,
387
+ saveBoardFile,
388
+ readBoardFile,
389
+ defaultSubdir,
390
+ defaultExt,
391
+ };