kingkont 0.20.7 → 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/index.html +37 -15
- package/lib/providers.js +8 -3
- package/main.js +3 -4
- package/package.json +3 -3
- package/renderer/board.js +3668 -2784
- package/renderer/chat.js +4 -0
- package/renderer/cloudFs.js +174 -3
- package/renderer/cloudProjects.js +215 -90
- package/renderer/drawings.js +259 -0
- package/renderer/generate.js +41 -5
- package/renderer/notifyPanel.js +4 -1
- package/renderer/settings.js +1 -1
- package/renderer/state.js +18 -10
- package/renderer/styles.css +85 -1
- package/server.js +35 -0
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
|
}
|
package/renderer/cloudFs.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
//
|
|
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,57 @@
|
|
|
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
|
-
|
|
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 права или наоборот отозвали — обновится здесь.
|
|
344
|
+
fetch('/api/projects/' + encodeURIComponent(projectId)).then(r => r.ok ? r.json() : null).then(p => {
|
|
345
|
+
if (!p) return;
|
|
346
|
+
state.cloudPermission = p.permission || (p.mine ? 'owner' : null);
|
|
347
|
+
state.cloudMine = !!p.mine;
|
|
348
|
+
state.cloudCanModify = !!p.canModify;
|
|
349
|
+
state.cloudIsPublic = !!p.isPublic;
|
|
350
|
+
setCloudButtonsVisibility();
|
|
351
|
+
if (typeof window.renderTemplateOverlay === 'function') window.renderTemplateOverlay();
|
|
352
|
+
}).catch(() => {});
|
|
283
353
|
return;
|
|
284
354
|
}
|
|
285
355
|
}
|
|
286
356
|
} catch {}
|
|
287
357
|
}
|
|
288
|
-
// 2. Не синхронизирован →
|
|
289
|
-
|
|
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('Загрузка манифеста…');
|
|
290
370
|
try {
|
|
291
371
|
const r = await fetch('/api/projects/' + encodeURIComponent(projectId));
|
|
292
372
|
if (!r.ok) {
|
|
@@ -295,118 +375,163 @@
|
|
|
295
375
|
}
|
|
296
376
|
const proj = await r.json();
|
|
297
377
|
const handle = await window.cloudFsShim.makeCloudHandle(projectId, proj.name || suggestedName || 'Project');
|
|
298
|
-
// Скачиваем все board'ы манифеста (если они уже не лежат локально с тем же hash).
|
|
299
378
|
const boards = Array.isArray(proj.manifest?.boards) ? proj.manifest.boards : [];
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
// fileHashes / textHashes индексируем тем же ключом что использует save:
|
|
305
|
-
// `${kind}/${name}/${relPath}`. Иначе при следующем save dedup не найдёт
|
|
306
|
-
// ничего и перезальёт всё заново.
|
|
307
|
-
const fileHashes = {};
|
|
308
|
-
const textHashes = {};
|
|
379
|
+
const canModify = !!proj.canModify;
|
|
380
|
+
|
|
381
|
+
// a) Пишем scene.json'ы синхронно (для каждого board нужен, чтобы
|
|
382
|
+
// openFilm / switchBoard смогли отрендерить ноды).
|
|
309
383
|
for (const board of boards) {
|
|
310
384
|
const boardDir = boardKindToDir(board.kind, board.name);
|
|
311
|
-
// 1) scene.json
|
|
312
385
|
if (board.scene) {
|
|
313
386
|
await writeCloudFile(projectId, joinPath(boardDir, 'scene.json'),
|
|
314
387
|
new TextEncoder().encode(JSON.stringify(board.scene, null, 2)));
|
|
315
388
|
}
|
|
316
|
-
|
|
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 = {};
|
|
317
399
|
for (const [relPath, cdnUrl] of Object.entries(board.files || {})) {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const buf = new Uint8Array(await fr.arrayBuffer());
|
|
323
|
-
const localPath = joinPath(boardDir, relPath);
|
|
324
|
-
await writeCloudFile(projectId, localPath, buf);
|
|
325
|
-
// Получаем mtimeMs от FS — без него dedup на save не сработает
|
|
326
|
-
// (cached.mtime === file.lastModified должно совпадать).
|
|
327
|
-
let mtime = Date.now();
|
|
328
|
-
if (window.cloudFs?.stat) {
|
|
329
|
-
try {
|
|
330
|
-
const st = await window.cloudFs.stat(projectId, localPath);
|
|
331
|
-
if (st?.mtimeMs) mtime = st.mtimeMs;
|
|
332
|
-
} catch {}
|
|
333
|
-
}
|
|
334
|
-
fileHashes[`${board.kind}/${board.name}/${relPath}`] = {
|
|
335
|
-
url: cdnUrl, size: buf.byteLength, mtime,
|
|
336
|
-
};
|
|
337
|
-
}
|
|
338
|
-
// 3) text-файлы (.md) — из таблицы project_texts по textId.
|
|
339
|
-
for (const [relPath, textId] of Object.entries(board.texts || {})) {
|
|
340
|
-
done++;
|
|
341
|
-
PROGRESS.update(done, totalFiles, `Загрузка ${relPath} (${done}/${totalFiles})`);
|
|
342
|
-
const tr = await fetch('/api/project-texts/' + encodeURIComponent(textId));
|
|
343
|
-
if (!tr.ok) throw new Error(`Не удалось получить текст ${relPath}: ${tr.status}`);
|
|
344
|
-
const td = await tr.json();
|
|
345
|
-
const buf = new TextEncoder().encode(td.content || '');
|
|
346
|
-
const localPath = joinPath(boardDir, relPath);
|
|
347
|
-
await writeCloudFile(projectId, localPath, buf);
|
|
348
|
-
let mtime = Date.now();
|
|
349
|
-
if (window.cloudFs?.stat) {
|
|
350
|
-
try {
|
|
351
|
-
const st = await window.cloudFs.stat(projectId, localPath);
|
|
352
|
-
if (st?.mtimeMs) mtime = st.mtimeMs;
|
|
353
|
-
} catch {}
|
|
354
|
-
}
|
|
355
|
-
textHashes[`${board.kind}/${board.name}/${relPath}`] = {
|
|
356
|
-
textId, size: buf.byteLength, mtime,
|
|
357
|
-
};
|
|
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);
|
|
358
404
|
}
|
|
405
|
+
cdnByBoard[board.name] = map;
|
|
359
406
|
}
|
|
360
|
-
//
|
|
361
|
-
// dedup'а при следующем save'е (иначе первый же save перезальёт
|
|
362
|
-
// обложку, хотя она не менялась).
|
|
363
|
-
let openedCoverHash = null;
|
|
364
|
-
if (proj.coverUrl) {
|
|
365
|
-
try {
|
|
366
|
-
const fr = await fetch('/api/proxy?url=' + encodeURIComponent(proj.coverUrl));
|
|
367
|
-
if (fr.ok) {
|
|
368
|
-
const buf = new Uint8Array(await fr.arrayBuffer());
|
|
369
|
-
const ct = fr.headers.get('content-type') || 'image/jpeg';
|
|
370
|
-
const ext = ct.includes('png') ? 'png' : (ct.includes('webp') ? 'webp' : 'jpg');
|
|
371
|
-
const coverPath = '.cover.' + ext;
|
|
372
|
-
await writeCloudFile(projectId, coverPath, buf);
|
|
373
|
-
let coverMtime = Date.now();
|
|
374
|
-
if (window.cloudFs?.stat) {
|
|
375
|
-
try {
|
|
376
|
-
const st = await window.cloudFs.stat(projectId, coverPath);
|
|
377
|
-
if (st?.mtimeMs) coverMtime = st.mtimeMs;
|
|
378
|
-
} catch {}
|
|
379
|
-
}
|
|
380
|
-
openedCoverHash = { size: buf.byteLength, mtime: coverMtime, url: proj.coverUrl };
|
|
381
|
-
}
|
|
382
|
-
} catch {}
|
|
383
|
-
}
|
|
384
|
-
// 4) meta — cloudProjectId + fileHashes (собран в loop'е выше) + coverHash.
|
|
385
|
-
// Ключи в формате `${kind}/${name}/${relPath}` совпадают с тем что
|
|
386
|
-
// использует saveCloudProject — dedup сработает на следующем save'е.
|
|
407
|
+
// Skeleton meta + cdnByBoard (для fast cache-hit на reload).
|
|
387
408
|
await writeCloudFile(projectId, '.kingkont-meta.json',
|
|
388
409
|
new TextEncoder().encode(JSON.stringify({
|
|
389
410
|
cloudProjectId: projectId,
|
|
390
|
-
fileHashes,
|
|
391
|
-
textHashes,
|
|
411
|
+
fileHashes: {},
|
|
412
|
+
textHashes: {},
|
|
392
413
|
coverUrl: proj.coverUrl || null,
|
|
393
|
-
coverHash:
|
|
414
|
+
coverHash: null,
|
|
394
415
|
syncedAt: Date.now(),
|
|
416
|
+
incomplete: !canModify, // флажок для будущего save (если расширим)
|
|
417
|
+
cdnByBoard, // {boardName: {relPath: '/api/proxy?url=...'}}
|
|
395
418
|
}, null, 2)));
|
|
396
|
-
|
|
419
|
+
|
|
420
|
+
// b) Стейт + open
|
|
397
421
|
state.cloudProjectId = projectId;
|
|
398
422
|
state.cloudDirty = false;
|
|
423
|
+
state.cloudPermission = proj.permission || (proj.mine ? 'owner' : null);
|
|
424
|
+
state.cloudMine = !!proj.mine;
|
|
425
|
+
state.cloudCanModify = canModify;
|
|
426
|
+
state.cloudIsPublic = !!proj.isPublic;
|
|
427
|
+
// CDN-карта используется при switchBoard — наполняет урлы новой доски
|
|
428
|
+
// (см. _hookBoardSwitchForCdn ниже).
|
|
429
|
+
state.cloudCdnByBoard = cdnByBoard;
|
|
399
430
|
touchCloudOpened(projectId);
|
|
400
431
|
await openFilm(handle);
|
|
432
|
+
// Pre-populate URLs ТЕКУЩЕЙ доски — чтобы при renderCanvas картинки
|
|
433
|
+
// сразу же сослались на CDN, без round-trip через FS.
|
|
434
|
+
_applyCdnUrlsToCurrentBoard();
|
|
401
435
|
setCloudButtonsVisibility();
|
|
402
|
-
if (window.
|
|
403
|
-
|
|
436
|
+
if (typeof window.renderTemplateOverlay === 'function') window.renderTemplateOverlay();
|
|
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
|
+
}
|
|
404
446
|
} catch (e) {
|
|
405
447
|
PROGRESS.hide();
|
|
406
448
|
alert('Не удалось открыть облачный проект: ' + (e?.message || e));
|
|
407
449
|
}
|
|
408
450
|
}
|
|
409
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
|
+
|
|
410
535
|
function boardKindToDir(kind, name) {
|
|
411
536
|
if (kind === 'character') return '_characters/' + name;
|
|
412
537
|
if (kind === 'location') return '_locations/' + name;
|