kingkont 0.20.8 → 0.20.9

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.
package/renderer/chat.js CHANGED
@@ -1342,6 +1342,10 @@
1342
1342
 
1343
1343
  // Главная entry-point: попытаться WS, если не получится — polling.
1344
1344
  function startTransport(key) {
1345
+ // /ws — Electron-only (lib/wsHub.js). В web чат вообще disabled
1346
+ // (web-shim стабит /api/chat/* → 503), но даже без этого WS на
1347
+ // kingkont.ru/ws всегда падает. Не дергаем — просто no-op.
1348
+ if (window.__KINGKONT_WEB__) return;
1345
1349
  if (typeof WebSocket !== 'undefined') ensureWS(key);
1346
1350
  else startPolling(key);
1347
1351
  }
@@ -34,10 +34,127 @@
34
34
  // Структура: Map<projectId, Map<path, {kind:'file'|'dir', data?: Uint8Array, mtimeMs?: number}>>.
35
35
  // Path хранится в normalised форме без leading slash (например 'characters/Anna/scene.json').
36
36
  const _mem = new Map();
37
+ // projectId → Promise<void> для in-flight restore. Используем Promise (а не
38
+ // Set), чтобы конкурирующие openCloudProject(сразу два — например один на
39
+ // navigate, второй на hashchange) ОЖИДАЛИ один и тот же restore, иначе
40
+ // второй стартует readTextFile когда _mem ещё пуст, видит metaPresent=false
41
+ // и триггерит лишний download.
42
+ const _idbRestored = new Map();
37
43
  function _memProject(id) {
38
44
  if (!_mem.has(id)) _mem.set(id, new Map());
39
45
  return _mem.get(id);
40
46
  }
47
+
48
+ // === IndexedDB persistence (web fallback only). ============================
49
+ // Зачем: in-memory _mem живёт только пока вкладка не закрылась/не вытеснена
50
+ // Chrome'ом. Юзер открыл проект, оставил на минуту → memory может быть
51
+ // освобождена → openCloudProject снова качает все файлы. С IDB кэш
52
+ // переживает eviction и reload вкладки.
53
+ // Schema: DB 'kk-cloud-fs', store 'files', keyPath 'key' = 'projectId|relPath'.
54
+ // Records: { key, projectId, relPath, kind: 'file'|'dir', data?: Uint8Array, mtimeMs? }
55
+ const IDB_NAME = 'kk-cloud-fs';
56
+ const IDB_STORE = 'files';
57
+ let _idbPromise = null;
58
+ function _idb() {
59
+ if (!_idbPromise) {
60
+ _idbPromise = new Promise((resolve, reject) => {
61
+ if (typeof indexedDB === 'undefined') { resolve(null); return; }
62
+ const req = indexedDB.open(IDB_NAME, 1);
63
+ req.onupgradeneeded = () => {
64
+ const db = req.result;
65
+ if (!db.objectStoreNames.contains(IDB_STORE)) {
66
+ const store = db.createObjectStore(IDB_STORE, { keyPath: 'key' });
67
+ store.createIndex('projectId', 'projectId', { unique: false });
68
+ }
69
+ };
70
+ req.onsuccess = () => resolve(req.result);
71
+ req.onerror = () => { console.warn('[cloudFs] IDB open failed:', req.error); resolve(null); };
72
+ }).catch(() => null);
73
+ }
74
+ return _idbPromise;
75
+ }
76
+ function _idbKey(projectId, relPath) { return projectId + '|' + relPath; }
77
+ async function _idbPut(projectId, relPath, kind, data, mtimeMs) {
78
+ const db = await _idb(); if (!db) return;
79
+ return new Promise(resolve => {
80
+ try {
81
+ const tx = db.transaction(IDB_STORE, 'readwrite');
82
+ tx.objectStore(IDB_STORE).put({
83
+ key: _idbKey(projectId, relPath), projectId, relPath, kind,
84
+ data: data || null, mtimeMs: mtimeMs || 0,
85
+ });
86
+ tx.oncomplete = () => resolve();
87
+ tx.onerror = () => { console.warn('[cloudFs] IDB put failed:', tx.error); resolve(); };
88
+ } catch (e) { console.warn('[cloudFs] IDB put exc:', e); resolve(); }
89
+ });
90
+ }
91
+ async function _idbDelete(projectId, relPath) {
92
+ const db = await _idb(); if (!db) return;
93
+ return new Promise(resolve => {
94
+ try {
95
+ const tx = db.transaction(IDB_STORE, 'readwrite');
96
+ tx.objectStore(IDB_STORE).delete(_idbKey(projectId, relPath));
97
+ tx.oncomplete = () => resolve();
98
+ tx.onerror = () => resolve();
99
+ } catch { resolve(); }
100
+ });
101
+ }
102
+ async function _idbDeleteByPrefix(projectId, prefix) {
103
+ const db = await _idb(); if (!db) return;
104
+ return new Promise(resolve => {
105
+ try {
106
+ const tx = db.transaction(IDB_STORE, 'readwrite');
107
+ const idx = tx.objectStore(IDB_STORE).index('projectId');
108
+ const req = idx.openCursor(IDBKeyRange.only(projectId));
109
+ req.onsuccess = () => {
110
+ const cur = req.result;
111
+ if (!cur) return;
112
+ const r = cur.value;
113
+ if (r.relPath === prefix || r.relPath.startsWith(prefix + '/')) cur.delete();
114
+ cur.continue();
115
+ };
116
+ tx.oncomplete = () => resolve();
117
+ tx.onerror = () => resolve();
118
+ } catch { resolve(); }
119
+ });
120
+ }
121
+ // Загрузить ВСЕ файлы проекта из IDB в _mem за один transaction. Lazy:
122
+ // делаем максимум один раз на (projectId, page-load). Конкурирующие вызовы
123
+ // ждут одну и ту же Promise (важно — иначе второй вызов читает _mem пустым
124
+ // пока первый ещё в IDB-tx и триггерит лишний download).
125
+ function _idbRestoreProject(projectId) {
126
+ const existing = _idbRestored.get(projectId);
127
+ if (existing) return existing;
128
+ const promise = (async () => {
129
+ const db = await _idb(); if (!db) return;
130
+ return new Promise(resolve => {
131
+ try {
132
+ const tx = db.transaction(IDB_STORE, 'readonly');
133
+ const idx = tx.objectStore(IDB_STORE).index('projectId');
134
+ const req = idx.openCursor(IDBKeyRange.only(projectId));
135
+ const map = _memProject(projectId);
136
+ let count = 0;
137
+ req.onsuccess = () => {
138
+ const cur = req.result;
139
+ if (!cur) return;
140
+ const r = cur.value;
141
+ if (!map.has(r.relPath)) {
142
+ map.set(r.relPath, { kind: r.kind, data: r.data || undefined, mtimeMs: r.mtimeMs || 0 });
143
+ count++;
144
+ }
145
+ cur.continue();
146
+ };
147
+ tx.oncomplete = () => {
148
+ if (count) console.log('[cloudFs] restored', count, 'entries from IDB for', projectId);
149
+ resolve();
150
+ };
151
+ tx.onerror = () => resolve();
152
+ } catch { resolve(); }
153
+ });
154
+ })();
155
+ _idbRestored.set(projectId, promise);
156
+ return promise;
157
+ }
41
158
  // Нормализация и помощник для родительских папок: 'a/b/c' → ['a', 'a/b', 'a/b/c'].
42
159
  function _normalize(p) { return String(p || '').replace(/^\/+|\/+$/g, ''); }
43
160
  function _ensureDirs(map, p) {
@@ -129,20 +246,45 @@
129
246
  async write(rel, data) {
130
247
  const p = _normalize(rel);
131
248
  const { dir } = _split(p);
132
- if (dir) _ensureDirs(map, dir);
249
+ if (dir) {
250
+ _ensureDirs(map, dir);
251
+ // Persist parent dirs (fire-and-forget) — нужно для list() при restore.
252
+ const parts = dir.split('/');
253
+ let acc = '';
254
+ for (const part of parts) {
255
+ acc = acc ? acc + '/' + part : part;
256
+ _idbPut(projectId, acc, 'dir', null, 0).catch(() => {});
257
+ }
258
+ }
133
259
  const buf = typeof data === 'string'
134
260
  ? new TextEncoder().encode(data)
135
261
  : (data instanceof Uint8Array ? data : new Uint8Array(data));
136
- map.set(p, { kind: 'file', data: buf, mtimeMs: Date.now() });
262
+ const mtimeMs = Date.now();
263
+ map.set(p, { kind: 'file', data: buf, mtimeMs });
264
+ // Persist в IDB (fire-and-forget — UI не ждёт).
265
+ _idbPut(projectId, p, 'file', buf, mtimeMs).catch(() => {});
266
+ return true;
267
+ },
268
+ async mkdir(rel) {
269
+ _ensureDirs(map, rel);
270
+ const p = _normalize(rel);
271
+ if (p) {
272
+ const parts = p.split('/');
273
+ let acc = '';
274
+ for (const part of parts) {
275
+ acc = acc ? acc + '/' + part : part;
276
+ _idbPut(projectId, acc, 'dir', null, 0).catch(() => {});
277
+ }
278
+ }
137
279
  return true;
138
280
  },
139
- async mkdir(rel) { _ensureDirs(map, rel); return true; },
140
281
  async remove(rel) {
141
282
  const p = _normalize(rel);
142
283
  // Удаляем запись и все потомки.
143
284
  for (const k of Array.from(map.keys())) {
144
285
  if (k === p || k.startsWith(p + '/')) map.delete(k);
145
286
  }
287
+ _idbDeleteByPrefix(projectId, p).catch(() => {});
146
288
  return true;
147
289
  },
148
290
  };
@@ -282,10 +424,39 @@
282
424
  // Получить projectId / backend из любого вложенного cloud-handle.
283
425
  function getCloudProjectId(handle) { return handle?.__cloudProjectId || null; }
284
426
 
427
+ // Полностью очистить проект из IDB и из памяти (для cache invalidation,
428
+ // когда обнаружили что серверный манифест новее).
429
+ async function _clearProject(projectId) {
430
+ _mem.delete(projectId);
431
+ _idbRestored.delete(projectId); // Map.delete — same API.
432
+ const db = await _idb(); if (!db) return;
433
+ return new Promise(resolve => {
434
+ try {
435
+ const tx = db.transaction(IDB_STORE, 'readwrite');
436
+ const idx = tx.objectStore(IDB_STORE).index('projectId');
437
+ const req = idx.openCursor(IDBKeyRange.only(projectId));
438
+ req.onsuccess = () => {
439
+ const cur = req.result;
440
+ if (!cur) return;
441
+ cur.delete();
442
+ cur.continue();
443
+ };
444
+ tx.oncomplete = () => resolve();
445
+ tx.onerror = () => resolve();
446
+ } catch { resolve(); }
447
+ });
448
+ }
449
+
285
450
  window.cloudFsShim = {
286
451
  makeCloudHandle,
287
452
  isCloudHandle,
288
453
  getCloudProjectId,
454
+ // Web-only: восстановить проект из IDB перед openCloudProject. Зачем:
455
+ // Chrome eviction убивает _mem; без этого manifest+файлы каждый раз
456
+ // качаются заново. Lazy — делает максимум один раз на (projectId, page-load).
457
+ restoreProject: _idbRestoreProject,
458
+ // Web-only: полная инвалидация (если серверный mtime новее локального).
459
+ clearProject: _clearProject,
289
460
  // exposed для save-to-server: пройтись по всем файлам in-memory map'а.
290
461
  _memProject,
291
462
  // Web-only helper: прямой write в backend этого проекта. Используется
@@ -77,6 +77,35 @@
77
77
  saveBtn.disabled = false;
78
78
  }
79
79
  }
80
+ // 🤝 Расшарить — sidebar button. Показываем когда открыт мой
81
+ // cloud-проект (state.cloudMine === true). JS-injected, не в HTML.
82
+ // openShareModal живёт в board.js (window.openShareModal). В Electron
83
+ // share-API'ы (/api/proj/setPublic|share|unshare|shares) проксируются
84
+ // через server.js → kingkont.ru/proj_*; в web — через web-shim.
85
+ {
86
+ let shareBtn = $('shareProjectBtn');
87
+ const wantShow = logged && state.cloudProjectId && state.cloudMine !== false;
88
+ if (wantShow && !shareBtn && saveBtn?.parentNode) {
89
+ shareBtn = document.createElement('button');
90
+ shareBtn.id = 'shareProjectBtn';
91
+ shareBtn.className = 'kk-edit-only';
92
+ shareBtn.textContent = '🤝 Расшарить';
93
+ shareBtn.title = 'Расшарить проект другому юзеру или сделать публичным';
94
+ shareBtn.style.cssText = 'margin-top:6px; font-size:12px;';
95
+ shareBtn.addEventListener('click', () => {
96
+ if (typeof window.openShareModal === 'function') {
97
+ window.openShareModal({
98
+ id: state.cloudProjectId,
99
+ name: state.filmHandle?.name || '',
100
+ isPublic: !!state.cloudIsPublic,
101
+ mine: state.cloudMine !== false,
102
+ });
103
+ }
104
+ });
105
+ saveBtn.parentNode.insertBefore(shareBtn, saveBtn.nextSibling);
106
+ }
107
+ if (shareBtn) shareBtn.style.display = wantShow ? '' : 'none';
108
+ }
80
109
  }).catch(() => {});
81
110
  }
82
111
 
@@ -196,8 +225,15 @@
196
225
  });
197
226
  writeCache(cached);
198
227
  notifyListChange(cached);
228
+ // Свежесозданный проект — мы сами owner.
229
+ state.cloudPermission = 'owner';
230
+ state.cloudMine = true;
231
+ state.cloudCanModify = true;
232
+ state.cloudIsPublic = false;
199
233
  await openFilm(handle);
200
234
  setCloudButtonsVisibility();
235
+ if (typeof window.renderTemplateOverlay === 'function') window.renderTemplateOverlay();
236
+ if (window.__KINGKONT_WEB__) location.hash = '#project=' + created.id;
201
237
  } catch (e) {
202
238
  alert('Не удалось создать облачный проект: ' + (e?.message || e));
203
239
  }
@@ -260,10 +296,14 @@
260
296
  // 1. Проверяем синхронизирован ли уже локально.
261
297
  // Electron: userData/cloud-projects/<id>/ через window.cloudFs (диск).
262
298
  // Web: in-memory map в cloudFsShim — живёт пока страница не перезагружена.
263
- // Без web-ветки браузер качал файлы при каждом open, хотя в памяти
264
- // они уже лежали с прошлого визита в этой же сессии.
299
+ // Кроме _mem поднимаем сохранённые ранее файлы из IndexedDB
300
+ // Chrome аггрессивно вытесняет JS-память, и без IDB через минуту
301
+ // после открытия мы снова качали весь проект с сервера.
265
302
  if (!opts.forceRefresh) {
266
303
  try {
304
+ if (!window.cloudFs && window.cloudFsShim?.restoreProject) {
305
+ await window.cloudFsShim.restoreProject(projectId);
306
+ }
267
307
  let metaText = null;
268
308
  if (window.cloudFs) {
269
309
  metaText = await window.cloudFs.readText(projectId, '.kingkont-meta.json');
@@ -276,17 +316,38 @@
276
316
  const handle = await window.cloudFsShim.makeCloudHandle(projectId, suggestedName || projectId);
277
317
  state.cloudProjectId = projectId;
278
318
  state.cloudDirty = false;
319
+ // Сбрасываем permissions-state в "ещё не знаем" (null), иначе
320
+ // прошлые значения (или initial false из state.js) триггерили
321
+ // view-only-mode у renderTemplateOverlay'я ДО ответа сервера.
322
+ // Юзер видел readonly-полку, потом она менялась на edit-mode.
323
+ // Сравнение `state.cloudCanModify === false` теперь false для
324
+ // null — eager toggle оставляет edit-mode (по URL #project=).
325
+ state.cloudCanModify = null;
326
+ state.cloudPermission = null;
327
+ state.cloudMine = null;
328
+ state.cloudIsPublic = null;
329
+ // Restore CDN-карту из meta — иначе после reload в web view-only
330
+ // картинки не находились (FS-fallback видит только scene.json,
331
+ // сами файлы лежат на CDN, локально не качались).
332
+ if (meta.cdnByBoard) state.cloudCdnByBoard = meta.cdnByBoard;
279
333
  touchCloudOpened(projectId);
280
334
  await openFilm(handle);
281
335
  setCloudButtonsVisibility();
282
- if (window.__KINGKONT_WEB__) location.hash = '#project=' + projectId;
283
- // Background узнаём permission (cache не хранил его). Если
284
- // оказалось что не наш рисуем «Создать копию» поверх.
336
+ // Eager mode-toggle на основе URL — НЕ ждём сервер. Иначе при
337
+ // hash navigation (#project= #template= без reload) edit-mode
338
+ // оставался активным ~500ms пока fetch /api/projects не отдаст
339
+ // canModify. Юзер видит лишние edit-кнопки на view-only-link.
340
+ if (typeof window.renderTemplateOverlay === 'function') window.renderTemplateOverlay();
341
+ if (window.__KINGKONT_WEB__ && !/^#template=/.test(location.hash)) location.hash = '#project=' + projectId;
342
+ // Background — узнаём актуальный canModify. Если расширились
343
+ // shared-edit права или наоборот отозвали — обновится здесь.
285
344
  fetch('/api/projects/' + encodeURIComponent(projectId)).then(r => r.ok ? r.json() : null).then(p => {
286
345
  if (!p) return;
287
346
  state.cloudPermission = p.permission || (p.mine ? 'owner' : null);
288
347
  state.cloudMine = !!p.mine;
289
348
  state.cloudCanModify = !!p.canModify;
349
+ state.cloudIsPublic = !!p.isPublic;
350
+ setCloudButtonsVisibility();
290
351
  if (typeof window.renderTemplateOverlay === 'function') window.renderTemplateOverlay();
291
352
  }).catch(() => {});
292
353
  return;
@@ -294,8 +355,18 @@
294
355
  }
295
356
  } catch {}
296
357
  }
297
- // 2. Не синхронизирован → скачиваем с сервера.
298
- PROGRESS.show('Загрузка проекта…');
358
+ // 2. Не синхронизирован → новая стратегия (web view-only OR медленный
359
+ // первый load расшаренной ссылки):
360
+ // a) GET /api/projects/<id> — манифест
361
+ // b) Пишем только scene.json'ы (маленькие) — нужны для openFilm
362
+ // c) openFilm() СРАЗУ — доска видна
363
+ // d) Pre-populate state.currentBoard.urls с CDN-proxy URL'ами →
364
+ // картинки грузятся напрямую из chatium-storage параллельно через
365
+ // браузер. Никакого «скачали → сохранили локально → URL.createObjectURL».
366
+ // e) Если canModify=true (юзер может сохранять) — в фоне качаем файлы
367
+ // и сохраняем локально (для dedup при save). Если canModify=false —
368
+ // ничего не качаем, всё работает через CDN.
369
+ PROGRESS.show('Загрузка манифеста…');
299
370
  try {
300
371
  const r = await fetch('/api/projects/' + encodeURIComponent(projectId));
301
372
  if (!r.ok) {
@@ -304,122 +375,163 @@
304
375
  }
305
376
  const proj = await r.json();
306
377
  const handle = await window.cloudFsShim.makeCloudHandle(projectId, proj.name || suggestedName || 'Project');
307
- // Скачиваем все board'ы манифеста (если они уже не лежат локально с тем же hash).
308
378
  const boards = Array.isArray(proj.manifest?.boards) ? proj.manifest.boards : [];
309
- // Сначала собираем общий список файлов для прогресса (CDN-файлы + DB-тексты).
310
- const totalFiles = boards.reduce(
311
- (s, b) => s + Object.keys(b?.files || {}).length + Object.keys(b?.texts || {}).length, 0);
312
- let done = 0;
313
- // fileHashes / textHashes индексируем тем же ключом что использует save:
314
- // `${kind}/${name}/${relPath}`. Иначе при следующем save dedup не найдёт
315
- // ничего и перезальёт всё заново.
316
- const fileHashes = {};
317
- const textHashes = {};
379
+ const canModify = !!proj.canModify;
380
+
381
+ // a) Пишем scene.json'ы синхронно (для каждого board нужен, чтобы
382
+ // openFilm / switchBoard смогли отрендерить ноды).
318
383
  for (const board of boards) {
319
384
  const boardDir = boardKindToDir(board.kind, board.name);
320
- // 1) scene.json
321
385
  if (board.scene) {
322
386
  await writeCloudFile(projectId, joinPath(boardDir, 'scene.json'),
323
387
  new TextEncoder().encode(JSON.stringify(board.scene, null, 2)));
324
388
  }
325
- // 2) media-файлы — из CDN.
389
+ }
390
+ // CDN-карта — будет и в state, и в meta (чтобы пережила reload).
391
+ // Конвертируем /get/<hash> → /thumbnail/<hash>/1024x для лучшего
392
+ // кеширования на CDN (юзер: «/get плохо кешируется, /thumbnail
393
+ // кешируется»). 1024px достаточно и для card-thumbnail и для
394
+ // полноэкранного просмотра. Маленькие thumbs (320x) — отдельной
395
+ // картой не требуются, scene-card сам ресайзит через CSS.
396
+ const cdnByBoard = {};
397
+ for (const board of boards) {
398
+ const map = {};
326
399
  for (const [relPath, cdnUrl] of Object.entries(board.files || {})) {
327
- done++;
328
- PROGRESS.update(done, totalFiles, `Загрузка ${relPath} (${done}/${totalFiles})`);
329
- const fr = await fetch('/api/proxy?url=' + encodeURIComponent(cdnUrl));
330
- if (!fr.ok) throw new Error(`Не удалось скачать ${relPath}: ${fr.status}`);
331
- const buf = new Uint8Array(await fr.arrayBuffer());
332
- const localPath = joinPath(boardDir, relPath);
333
- await writeCloudFile(projectId, localPath, buf);
334
- // Получаем mtimeMs от FS — без него dedup на save не сработает
335
- // (cached.mtime === file.lastModified должно совпадать).
336
- let mtime = Date.now();
337
- if (window.cloudFs?.stat) {
338
- try {
339
- const st = await window.cloudFs.stat(projectId, localPath);
340
- if (st?.mtimeMs) mtime = st.mtimeMs;
341
- } catch {}
342
- }
343
- fileHashes[`${board.kind}/${board.name}/${relPath}`] = {
344
- url: cdnUrl, size: buf.byteLength, mtime,
345
- };
346
- }
347
- // 3) text-файлы (.md) — из таблицы project_texts по textId.
348
- for (const [relPath, textId] of Object.entries(board.texts || {})) {
349
- done++;
350
- PROGRESS.update(done, totalFiles, `Загрузка ${relPath} (${done}/${totalFiles})`);
351
- const tr = await fetch('/api/project-texts/' + encodeURIComponent(textId));
352
- if (!tr.ok) throw new Error(`Не удалось получить текст ${relPath}: ${tr.status}`);
353
- const td = await tr.json();
354
- const buf = new TextEncoder().encode(td.content || '');
355
- const localPath = joinPath(boardDir, relPath);
356
- await writeCloudFile(projectId, localPath, buf);
357
- let mtime = Date.now();
358
- if (window.cloudFs?.stat) {
359
- try {
360
- const st = await window.cloudFs.stat(projectId, localPath);
361
- if (st?.mtimeMs) mtime = st.mtimeMs;
362
- } catch {}
363
- }
364
- textHashes[`${board.kind}/${board.name}/${relPath}`] = {
365
- textId, size: buf.byteLength, mtime,
366
- };
400
+ const thumbUrl = cdnUrl.includes('fs.chatium.ru/get/')
401
+ ? cdnUrl.replace('/get/', '/thumbnail/') + '/1024x'
402
+ : cdnUrl;
403
+ map[relPath] = '/api/proxy?url=' + encodeURIComponent(thumbUrl);
367
404
  }
405
+ cdnByBoard[board.name] = map;
368
406
  }
369
- // 3) cover (опционально). Запоминаем size+mtime coverHash для
370
- // dedup'а при следующем save'е (иначе первый же save перезальёт
371
- // обложку, хотя она не менялась).
372
- let openedCoverHash = null;
373
- if (proj.coverUrl) {
374
- try {
375
- const fr = await fetch('/api/proxy?url=' + encodeURIComponent(proj.coverUrl));
376
- if (fr.ok) {
377
- const buf = new Uint8Array(await fr.arrayBuffer());
378
- const ct = fr.headers.get('content-type') || 'image/jpeg';
379
- const ext = ct.includes('png') ? 'png' : (ct.includes('webp') ? 'webp' : 'jpg');
380
- const coverPath = '.cover.' + ext;
381
- await writeCloudFile(projectId, coverPath, buf);
382
- let coverMtime = Date.now();
383
- if (window.cloudFs?.stat) {
384
- try {
385
- const st = await window.cloudFs.stat(projectId, coverPath);
386
- if (st?.mtimeMs) coverMtime = st.mtimeMs;
387
- } catch {}
388
- }
389
- openedCoverHash = { size: buf.byteLength, mtime: coverMtime, url: proj.coverUrl };
390
- }
391
- } catch {}
392
- }
393
- // 4) meta — cloudProjectId + fileHashes (собран в loop'е выше) + coverHash.
394
- // Ключи в формате `${kind}/${name}/${relPath}` совпадают с тем что
395
- // использует saveCloudProject — dedup сработает на следующем save'е.
407
+ // Skeleton meta + cdnByBoard (для fast cache-hit на reload).
396
408
  await writeCloudFile(projectId, '.kingkont-meta.json',
397
409
  new TextEncoder().encode(JSON.stringify({
398
410
  cloudProjectId: projectId,
399
- fileHashes,
400
- textHashes,
411
+ fileHashes: {},
412
+ textHashes: {},
401
413
  coverUrl: proj.coverUrl || null,
402
- coverHash: openedCoverHash,
414
+ coverHash: null,
403
415
  syncedAt: Date.now(),
416
+ incomplete: !canModify, // флажок для будущего save (если расширим)
417
+ cdnByBoard, // {boardName: {relPath: '/api/proxy?url=...'}}
404
418
  }, null, 2)));
405
- // 5) Открываем.
419
+
420
+ // b) Стейт + open
406
421
  state.cloudProjectId = projectId;
407
422
  state.cloudDirty = false;
408
423
  state.cloudPermission = proj.permission || (proj.mine ? 'owner' : null);
409
424
  state.cloudMine = !!proj.mine;
410
- state.cloudCanModify = !!proj.canModify;
425
+ state.cloudCanModify = canModify;
426
+ state.cloudIsPublic = !!proj.isPublic;
427
+ // CDN-карта используется при switchBoard — наполняет урлы новой доски
428
+ // (см. _hookBoardSwitchForCdn ниже).
429
+ state.cloudCdnByBoard = cdnByBoard;
411
430
  touchCloudOpened(projectId);
412
431
  await openFilm(handle);
432
+ // Pre-populate URLs ТЕКУЩЕЙ доски — чтобы при renderCanvas картинки
433
+ // сразу же сослались на CDN, без round-trip через FS.
434
+ _applyCdnUrlsToCurrentBoard();
413
435
  setCloudButtonsVisibility();
414
436
  if (typeof window.renderTemplateOverlay === 'function') window.renderTemplateOverlay();
415
- if (window.__KINGKONT_WEB__) location.hash = '#project=' + projectId;
416
- PROGRESS.hide();
437
+ if (window.__KINGKONT_WEB__ && !/^#template=/.test(location.hash)) location.hash = '#project=' + projectId;
438
+ PROGRESS.hide(); // доска уже видна юзеру
439
+
440
+ // c) Background download — только если canModify (нужны локальные
441
+ // файлы для save+dedup). Параллельно, без блокировки UI.
442
+ if (canModify) {
443
+ _backgroundDownload(projectId, boards, proj.coverUrl).catch(e =>
444
+ console.warn('[cloudProjects] background download failed:', e?.message || e));
445
+ }
417
446
  } catch (e) {
418
447
  PROGRESS.hide();
419
448
  alert('Не удалось открыть облачный проект: ' + (e?.message || e));
420
449
  }
421
450
  }
422
451
 
452
+ // Подложить CDN-URL'ы под state.currentBoard.urls — рендеру медиа-нод
453
+ // в settings.js достаточно непустого `urls[node.file]`, чтобы не лезть
454
+ // в FS (см. settings.js:405).
455
+ function _applyCdnUrlsToCurrentBoard() {
456
+ const board = state.currentBoard;
457
+ if (!board) return;
458
+ const map = state.cloudCdnByBoard?.[board.name];
459
+ if (!map) return;
460
+ board.urls = board.urls || {};
461
+ for (const [relPath, url] of Object.entries(map)) {
462
+ if (!board.urls[relPath]) board.urls[relPath] = url;
463
+ }
464
+ }
465
+ // openFilm и selectBoard могут вызываться без нашего ведома (юзер кликает
466
+ // на другую сцену в сайдбаре). Перехватываем смену через event — после
467
+ // каждой смены доски подкладываем URL'ы. board.js шлёт project-changed
468
+ // и board-changed; ловим оба.
469
+ window.addEventListener('board-changed', _applyCdnUrlsToCurrentBoard);
470
+ window.addEventListener('project-changed', _applyCdnUrlsToCurrentBoard);
471
+
472
+ // Background-загрузка всех файлов в локальный FS (для edit-mode).
473
+ // Параллельно по 4 за раз — не валим chatium.
474
+ async function _backgroundDownload(projectId, boards, coverUrl) {
475
+ const tasks = [];
476
+ for (const board of boards) {
477
+ const boardDir = boardKindToDir(board.kind, board.name);
478
+ for (const [relPath, cdnUrl] of Object.entries(board.files || {})) {
479
+ tasks.push({ kind: 'file', boardKind: board.kind, boardName: board.name,
480
+ boardDir, relPath, cdnUrl });
481
+ }
482
+ for (const [relPath, textId] of Object.entries(board.texts || {})) {
483
+ tasks.push({ kind: 'text', boardKind: board.kind, boardName: board.name,
484
+ boardDir, relPath, textId });
485
+ }
486
+ }
487
+ if (coverUrl) tasks.push({ kind: 'cover', cdnUrl: coverUrl });
488
+ const fileHashes = {};
489
+ const textHashes = {};
490
+ let coverHash = null;
491
+ const CONCURRENCY = 4;
492
+ let i = 0;
493
+ async function worker() {
494
+ while (i < tasks.length) {
495
+ const t = tasks[i++];
496
+ try {
497
+ if (t.kind === 'file') {
498
+ const fr = await fetch('/api/proxy?url=' + encodeURIComponent(t.cdnUrl));
499
+ if (!fr.ok) continue;
500
+ const buf = new Uint8Array(await fr.arrayBuffer());
501
+ await writeCloudFile(projectId, joinPath(t.boardDir, t.relPath), buf);
502
+ fileHashes[`${t.boardKind}/${t.boardName}/${t.relPath}`] =
503
+ { url: t.cdnUrl, size: buf.byteLength, mtime: Date.now() };
504
+ } else if (t.kind === 'text') {
505
+ const tr = await fetch('/api/project-texts/' + encodeURIComponent(t.textId));
506
+ if (!tr.ok) continue;
507
+ const td = await tr.json();
508
+ const buf = new TextEncoder().encode(td.content || '');
509
+ await writeCloudFile(projectId, joinPath(t.boardDir, t.relPath), buf);
510
+ textHashes[`${t.boardKind}/${t.boardName}/${t.relPath}`] =
511
+ { textId: t.textId, size: buf.byteLength, mtime: Date.now() };
512
+ } else if (t.kind === 'cover') {
513
+ const fr = await fetch('/api/proxy?url=' + encodeURIComponent(t.cdnUrl));
514
+ if (!fr.ok) continue;
515
+ const buf = new Uint8Array(await fr.arrayBuffer());
516
+ const ct = fr.headers.get('content-type') || 'image/jpeg';
517
+ const ext = ct.includes('png') ? 'png' : (ct.includes('webp') ? 'webp' : 'jpg');
518
+ await writeCloudFile(projectId, '.cover.' + ext, buf);
519
+ coverHash = { size: buf.byteLength, mtime: Date.now(), url: t.cdnUrl };
520
+ }
521
+ } catch {}
522
+ }
523
+ }
524
+ await Promise.all(Array.from({ length: CONCURRENCY }, worker));
525
+ // Обновляем meta — теперь с реальными хэшами для dedup'а при save.
526
+ try {
527
+ await writeCloudFile(projectId, '.kingkont-meta.json',
528
+ new TextEncoder().encode(JSON.stringify({
529
+ cloudProjectId: projectId, fileHashes, textHashes,
530
+ coverUrl: coverUrl || null, coverHash, syncedAt: Date.now(),
531
+ }, null, 2)));
532
+ } catch {}
533
+ }
534
+
423
535
  function boardKindToDir(kind, name) {
424
536
  if (kind === 'character') return '_characters/' + name;
425
537
  if (kind === 'location') return '_locations/' + name;