@pixerate/editor 0.272.0 → 0.273.0
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/dist/agent/index.cjs +34 -32
- package/dist/agent/index.js +10 -8
- package/dist/chart/contract.d.cts +95 -0
- package/dist/chart/contract.d.ts +95 -0
- package/dist/chart/controller.d.cts +4 -0
- package/dist/chart/controller.d.ts +4 -0
- package/dist/chart/index.cjs +492 -26
- package/dist/chart/index.d.cts +1 -0
- package/dist/chart/index.d.ts +1 -0
- package/dist/chart/index.js +476 -10
- package/dist/chart/tools.d.cts +3 -0
- package/dist/chart/tools.d.ts +3 -0
- package/dist/chart-maps/index.cjs +4 -64
- package/dist/chart-maps/index.js +3 -63
- package/dist/chunk-2A5IKZMU.js +557 -0
- package/dist/{chunk-NMZQMPFZ.js → chunk-4LZQO5Y7.js} +2 -0
- package/dist/chunk-5UGRMVNE.js +328 -0
- package/dist/{chunk-X4FTNFIA.js → chunk-7MGPW2OV.js} +1 -1
- package/dist/{chunk-DGITFN73.cjs → chunk-BWJBZAUE.cjs} +8 -8
- package/dist/chunk-CHZYVNXZ.js +336 -0
- package/dist/chunk-DRMZ5R3S.cjs +67 -0
- package/dist/chunk-EI5GFTKG.js +67 -0
- package/dist/chunk-JV7DKLMX.js +1073 -0
- package/dist/chunk-MFBUQC67.cjs +328 -0
- package/dist/{chunk-IBXEQOGI.js → chunk-PYHB2EHE.js} +2 -324
- package/dist/chunk-QEUI6OGE.cjs +336 -0
- package/dist/{chunk-6ZSFRRJI.js → chunk-RSF6BT34.js} +1 -1
- package/dist/chunk-T2HUQBRC.cjs +1073 -0
- package/dist/{chunk-GSLOH2IN.cjs → chunk-VF6ODSYX.cjs} +2 -0
- package/dist/{chunk-NQUKTRKN.cjs → chunk-WZLNNGG3.cjs} +45 -45
- package/dist/{chunk-NRBGAEFI.cjs → chunk-Y3RUYOOW.cjs} +8 -330
- package/dist/chunk-YH6SKC7P.cjs +557 -0
- package/dist/commands/index.cjs +7 -323
- package/dist/commands/index.js +5 -321
- package/dist/extensions/index.cjs +4 -4
- package/dist/extensions/index.js +3 -3
- package/dist/index.cjs +9 -9
- package/dist/index.js +3 -3
- package/dist/paint-editor/controller.d.cts +86 -0
- package/dist/paint-editor/controller.d.ts +86 -0
- package/dist/paint-editor/document.d.cts +1 -1
- package/dist/paint-editor/document.d.ts +1 -1
- package/dist/paint-editor/index.cjs +559 -166
- package/dist/paint-editor/index.d.cts +1 -0
- package/dist/paint-editor/index.d.ts +1 -0
- package/dist/paint-editor/index.js +397 -4
- package/dist/paint-editor/presence.d.cts +5 -1
- package/dist/paint-editor/presence.d.ts +5 -1
- package/dist/paint-editor/ui.d.cts +5 -2
- package/dist/paint-editor/ui.d.ts +5 -2
- package/dist/persistence/index.cjs +24 -1042
- package/dist/persistence/index.js +22 -1040
- package/dist/raster/fill.d.cts +85 -0
- package/dist/raster/fill.d.ts +85 -0
- package/dist/raster/index.cjs +9 -1
- package/dist/raster/index.d.cts +1 -0
- package/dist/raster/index.d.ts +1 -0
- package/dist/raster/index.js +8 -0
- package/dist/tools/index.cjs +5 -3
- package/dist/tools/index.js +8 -6
- package/package.json +1 -1
- package/skills/pixerate-paint-editor/SKILL.md +19 -43
- package/skills/pixerate-paint-editor/references/evolution.md +27 -0
- package/skills/pixerate-paint-sketch-to-finish/SKILL.md +16 -5
- package/skills/pixerate-paint-sketch-to-finish/evals/flats-under-ink.json +2 -3
|
@@ -0,0 +1,1073 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EditorDocumentLoadError,
|
|
3
|
+
collectEditorAssetGarbage,
|
|
4
|
+
collectRetiredEditorAssets,
|
|
5
|
+
parseEditorDocument,
|
|
6
|
+
preloadEditorAssets,
|
|
7
|
+
toEditorDocumentFile,
|
|
8
|
+
tryCollectEditorAssetGarbage,
|
|
9
|
+
withEditorAssets
|
|
10
|
+
} from "./chunk-XBQX76CT.js";
|
|
11
|
+
|
|
12
|
+
// src/persistence/recoveryPolicy.ts
|
|
13
|
+
function exponentialBackoff(failures, base = 1e3, max = 3e4) {
|
|
14
|
+
const exp = Math.min(Math.max(failures - 1, 0), 16);
|
|
15
|
+
return Math.min(base * 2 ** exp, max);
|
|
16
|
+
}
|
|
17
|
+
function createRecoveryPolicy(options = {}) {
|
|
18
|
+
const interval = options.interval ?? 5e3;
|
|
19
|
+
const retryDelay = options.retryDelay ?? (() => interval);
|
|
20
|
+
const minWake = interval * 0.01;
|
|
21
|
+
let lastRevision = null;
|
|
22
|
+
let lastTime = null;
|
|
23
|
+
let gap = interval;
|
|
24
|
+
let ownsDirtyMeta = false;
|
|
25
|
+
let cleaning = false;
|
|
26
|
+
let snapshots = 0;
|
|
27
|
+
let snapshotsAtClean = 0;
|
|
28
|
+
let failures = 0;
|
|
29
|
+
const fail = (now) => {
|
|
30
|
+
failures++;
|
|
31
|
+
lastRevision = null;
|
|
32
|
+
lastTime = now;
|
|
33
|
+
gap = retryDelay(failures);
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
tick(now, revision, savedRevision, tickOptions) {
|
|
37
|
+
const force = tickOptions?.force === true;
|
|
38
|
+
const since = force || lastTime === null ? Infinity : now - lastTime;
|
|
39
|
+
if (revision !== savedRevision) {
|
|
40
|
+
if (lastRevision === revision) return { type: "nothing" };
|
|
41
|
+
if (since >= gap) {
|
|
42
|
+
lastRevision = revision;
|
|
43
|
+
lastTime = now;
|
|
44
|
+
ownsDirtyMeta = true;
|
|
45
|
+
snapshots++;
|
|
46
|
+
return { type: "writeSnapshot", revision };
|
|
47
|
+
}
|
|
48
|
+
return { type: "wakeAfter", delay: Math.max(gap - since, minWake) };
|
|
49
|
+
}
|
|
50
|
+
if (ownsDirtyMeta && !cleaning) {
|
|
51
|
+
if (lastRevision !== null || since >= gap) {
|
|
52
|
+
cleaning = true;
|
|
53
|
+
snapshotsAtClean = snapshots;
|
|
54
|
+
return { type: "writeClean" };
|
|
55
|
+
}
|
|
56
|
+
return { type: "wakeAfter", delay: Math.max(gap - since, minWake) };
|
|
57
|
+
}
|
|
58
|
+
return { type: "nothing" };
|
|
59
|
+
},
|
|
60
|
+
written(revision, ok, now) {
|
|
61
|
+
if (ok) {
|
|
62
|
+
if (lastRevision === revision) {
|
|
63
|
+
failures = 0;
|
|
64
|
+
gap = interval;
|
|
65
|
+
}
|
|
66
|
+
} else if (lastRevision === revision) {
|
|
67
|
+
fail(now);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
cleaned(ok, now) {
|
|
71
|
+
cleaning = false;
|
|
72
|
+
if (ok) {
|
|
73
|
+
ownsDirtyMeta = snapshots !== snapshotsAtClean;
|
|
74
|
+
failures = 0;
|
|
75
|
+
gap = interval;
|
|
76
|
+
} else {
|
|
77
|
+
fail(now);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
get ownsSnapshot() {
|
|
81
|
+
return ownsDirtyMeta;
|
|
82
|
+
},
|
|
83
|
+
get failures() {
|
|
84
|
+
return failures;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// src/persistence/storage.ts
|
|
90
|
+
var StorageUnavailableError = class extends Error {
|
|
91
|
+
constructor(kind) {
|
|
92
|
+
super(`${kind} storage is not available in this environment`);
|
|
93
|
+
this.name = "StorageUnavailableError";
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
var InjectedStorageError = class extends Error {
|
|
97
|
+
constructor(operation, key) {
|
|
98
|
+
super(`injected ${operation} failure for "${key}"`);
|
|
99
|
+
this.name = "InjectedStorageError";
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
function checkFailure(failure, operation, key) {
|
|
103
|
+
if (!failure) return;
|
|
104
|
+
const result = failure(operation, key);
|
|
105
|
+
if (result instanceof Error) throw result;
|
|
106
|
+
if (result) throw new InjectedStorageError(operation, key);
|
|
107
|
+
}
|
|
108
|
+
function withStorageFailures(inner, failure) {
|
|
109
|
+
return {
|
|
110
|
+
kind: inner.kind,
|
|
111
|
+
get available() {
|
|
112
|
+
return inner.available;
|
|
113
|
+
},
|
|
114
|
+
async get(key) {
|
|
115
|
+
checkFailure(failure, "get", key);
|
|
116
|
+
return inner.get(key);
|
|
117
|
+
},
|
|
118
|
+
async set(key, value) {
|
|
119
|
+
checkFailure(failure, "set", key);
|
|
120
|
+
return inner.set(key, value);
|
|
121
|
+
},
|
|
122
|
+
async remove(key) {
|
|
123
|
+
checkFailure(failure, "remove", key);
|
|
124
|
+
return inner.remove(key);
|
|
125
|
+
},
|
|
126
|
+
...inner.keys ? {
|
|
127
|
+
async keys(prefix = "") {
|
|
128
|
+
checkFailure(failure, "keys", prefix);
|
|
129
|
+
return inner.keys(prefix);
|
|
130
|
+
}
|
|
131
|
+
} : {}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function guarded(kind, isAvailable, failure, impl) {
|
|
135
|
+
const check = (operation, key) => {
|
|
136
|
+
if (!isAvailable()) throw new StorageUnavailableError(kind);
|
|
137
|
+
checkFailure(failure, operation, key);
|
|
138
|
+
};
|
|
139
|
+
return {
|
|
140
|
+
kind,
|
|
141
|
+
get available() {
|
|
142
|
+
return isAvailable();
|
|
143
|
+
},
|
|
144
|
+
async get(key) {
|
|
145
|
+
check("get", key);
|
|
146
|
+
return impl.get(key);
|
|
147
|
+
},
|
|
148
|
+
async set(key, value) {
|
|
149
|
+
check("set", key);
|
|
150
|
+
return impl.set(key, value);
|
|
151
|
+
},
|
|
152
|
+
async remove(key) {
|
|
153
|
+
check("remove", key);
|
|
154
|
+
return impl.remove(key);
|
|
155
|
+
},
|
|
156
|
+
...impl.keys ? {
|
|
157
|
+
async keys(prefix = "") {
|
|
158
|
+
check("keys", prefix);
|
|
159
|
+
return impl.keys(prefix);
|
|
160
|
+
}
|
|
161
|
+
} : {}
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function createMemoryStorage(options = {}) {
|
|
165
|
+
const entries = new Map(Object.entries(options.initial ?? {}));
|
|
166
|
+
const adapter = guarded("memory", () => true, options.failure, {
|
|
167
|
+
async get(key) {
|
|
168
|
+
return entries.has(key) ? entries.get(key) : null;
|
|
169
|
+
},
|
|
170
|
+
async set(key, value) {
|
|
171
|
+
entries.set(key, value);
|
|
172
|
+
},
|
|
173
|
+
async remove(key) {
|
|
174
|
+
entries.delete(key);
|
|
175
|
+
},
|
|
176
|
+
async keys(prefix) {
|
|
177
|
+
return [...entries.keys()].filter((key) => key.startsWith(prefix));
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
return Object.assign(adapter, { entries });
|
|
181
|
+
}
|
|
182
|
+
function detectLocalStorage() {
|
|
183
|
+
try {
|
|
184
|
+
const storage = globalThis.localStorage;
|
|
185
|
+
return storage && typeof storage.getItem === "function" ? storage : void 0;
|
|
186
|
+
} catch {
|
|
187
|
+
return void 0;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function isLocalStorageAvailable() {
|
|
191
|
+
return detectLocalStorage() !== void 0;
|
|
192
|
+
}
|
|
193
|
+
function createLocalStorageAdapter(prefix = "", options = {}) {
|
|
194
|
+
const storage = options.storage ?? detectLocalStorage();
|
|
195
|
+
return guarded("localStorage", () => storage !== void 0, options.failure, {
|
|
196
|
+
async get(key) {
|
|
197
|
+
return storage.getItem(prefix + key);
|
|
198
|
+
},
|
|
199
|
+
async set(key, value) {
|
|
200
|
+
storage.setItem(prefix + key, value);
|
|
201
|
+
},
|
|
202
|
+
async remove(key) {
|
|
203
|
+
storage.removeItem(prefix + key);
|
|
204
|
+
},
|
|
205
|
+
// Listing needs `length` and `key()` (real Web Storage has both).
|
|
206
|
+
...!storage || typeof storage.key === "function" && typeof storage.length === "number" ? {
|
|
207
|
+
async keys(start) {
|
|
208
|
+
const out = [];
|
|
209
|
+
for (let i = 0; i < storage.length; i++) {
|
|
210
|
+
const key = storage.key(i);
|
|
211
|
+
if (key !== null && key.startsWith(prefix + start)) out.push(key.slice(prefix.length));
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
} : {}
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
function detectIndexedDb() {
|
|
219
|
+
try {
|
|
220
|
+
const factory = globalThis.indexedDB;
|
|
221
|
+
return factory && typeof factory.open === "function" ? factory : void 0;
|
|
222
|
+
} catch {
|
|
223
|
+
return void 0;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function isIndexedDbAvailable() {
|
|
227
|
+
return detectIndexedDb() !== void 0;
|
|
228
|
+
}
|
|
229
|
+
var requestToPromise = (request) => new Promise((resolve, reject) => {
|
|
230
|
+
request.onsuccess = () => resolve(request.result);
|
|
231
|
+
request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed"));
|
|
232
|
+
});
|
|
233
|
+
function createIndexedDbAdapter(name = "pixerate-editor", options = {}) {
|
|
234
|
+
const factory = options.indexedDB ?? detectIndexedDb();
|
|
235
|
+
const storeName = options.store ?? "kv";
|
|
236
|
+
let dbPromise = null;
|
|
237
|
+
const open = () => {
|
|
238
|
+
dbPromise ??= new Promise((resolve, reject) => {
|
|
239
|
+
const request = factory.open(name, 1);
|
|
240
|
+
request.onupgradeneeded = () => {
|
|
241
|
+
const db = request.result;
|
|
242
|
+
if (!db.objectStoreNames.contains(storeName)) db.createObjectStore(storeName);
|
|
243
|
+
};
|
|
244
|
+
request.onsuccess = () => resolve(request.result);
|
|
245
|
+
request.onerror = () => {
|
|
246
|
+
dbPromise = null;
|
|
247
|
+
reject(request.error ?? new Error("IndexedDB open failed"));
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
return dbPromise;
|
|
251
|
+
};
|
|
252
|
+
const write = async (fn) => {
|
|
253
|
+
const db = await open();
|
|
254
|
+
await new Promise((resolve, reject) => {
|
|
255
|
+
const tx = db.transaction(storeName, "readwrite");
|
|
256
|
+
tx.oncomplete = () => resolve();
|
|
257
|
+
tx.onerror = () => reject(tx.error ?? new Error("IndexedDB transaction failed"));
|
|
258
|
+
tx.onabort = () => reject(tx.error ?? new Error("IndexedDB transaction aborted"));
|
|
259
|
+
fn(tx.objectStore(storeName));
|
|
260
|
+
});
|
|
261
|
+
};
|
|
262
|
+
return guarded("indexedDB", () => factory !== void 0, options.failure, {
|
|
263
|
+
async get(key) {
|
|
264
|
+
const db = await open();
|
|
265
|
+
const tx = db.transaction(storeName, "readonly");
|
|
266
|
+
const value = await requestToPromise(tx.objectStore(storeName).get(key));
|
|
267
|
+
return typeof value === "string" ? value : null;
|
|
268
|
+
},
|
|
269
|
+
set: (key, value) => write((store) => store.put(value, key)),
|
|
270
|
+
remove: (key) => write((store) => store.delete(key)),
|
|
271
|
+
async keys(prefix) {
|
|
272
|
+
const db = await open();
|
|
273
|
+
const tx = db.transaction(storeName, "readonly");
|
|
274
|
+
const all = await requestToPromise(tx.objectStore(storeName).getAllKeys());
|
|
275
|
+
return all.filter((key) => typeof key === "string" && key.startsWith(prefix));
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
function detectOpfsRoot() {
|
|
280
|
+
try {
|
|
281
|
+
const storage = globalThis.navigator?.storage;
|
|
282
|
+
if (storage && typeof storage.getDirectory === "function") {
|
|
283
|
+
return () => storage.getDirectory();
|
|
284
|
+
}
|
|
285
|
+
} catch {
|
|
286
|
+
}
|
|
287
|
+
return void 0;
|
|
288
|
+
}
|
|
289
|
+
function isOpfsAvailable() {
|
|
290
|
+
return detectOpfsRoot() !== void 0;
|
|
291
|
+
}
|
|
292
|
+
var isNotFound = (error) => !!error && typeof error === "object" && error.name === "NotFoundError";
|
|
293
|
+
function createOpfsAdapter(dir = "pixerate-editor", options = {}) {
|
|
294
|
+
const getRoot = options.root ?? detectOpfsRoot();
|
|
295
|
+
let dirPromise = null;
|
|
296
|
+
const directory = () => {
|
|
297
|
+
dirPromise ??= getRoot().then((root) => root.getDirectoryHandle(dir, { create: true }));
|
|
298
|
+
dirPromise.catch(() => {
|
|
299
|
+
dirPromise = null;
|
|
300
|
+
});
|
|
301
|
+
return dirPromise;
|
|
302
|
+
};
|
|
303
|
+
const fileName = (key) => encodeURIComponent(key) || "_";
|
|
304
|
+
const readFile = async (d, name) => {
|
|
305
|
+
try {
|
|
306
|
+
const handle = await d.getFileHandle(name);
|
|
307
|
+
return await (await handle.getFile()).text();
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (isNotFound(error)) return null;
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
const writeFile = async (d, name, value) => {
|
|
314
|
+
const handle = await d.getFileHandle(name, { create: true });
|
|
315
|
+
if (typeof handle.createWritable !== "function") {
|
|
316
|
+
throw new StorageUnavailableError("opfs (createWritable)");
|
|
317
|
+
}
|
|
318
|
+
const writable = await handle.createWritable();
|
|
319
|
+
await writable.write(value);
|
|
320
|
+
await writable.close();
|
|
321
|
+
return handle;
|
|
322
|
+
};
|
|
323
|
+
const removeFile = async (d, name) => {
|
|
324
|
+
try {
|
|
325
|
+
await d.removeEntry(name);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
if (!isNotFound(error)) throw error;
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
return guarded("opfs", () => getRoot !== void 0, options.failure, {
|
|
331
|
+
async get(key) {
|
|
332
|
+
const d = await directory();
|
|
333
|
+
const base = fileName(key);
|
|
334
|
+
const pointer = await readFile(d, `${base}.ptr`);
|
|
335
|
+
if (pointer === "a" || pointer === "b") return readFile(d, `${base}.${pointer}`);
|
|
336
|
+
return readFile(d, base);
|
|
337
|
+
},
|
|
338
|
+
async set(key, value) {
|
|
339
|
+
const d = await directory();
|
|
340
|
+
const base = fileName(key);
|
|
341
|
+
const pointer = await readFile(d, `${base}.ptr`);
|
|
342
|
+
if (!options.disableRename && pointer === null) {
|
|
343
|
+
const tmp = await writeFile(d, `${base}.tmp`, value);
|
|
344
|
+
if (typeof tmp.move === "function") {
|
|
345
|
+
await tmp.move(base);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
await removeFile(d, `${base}.tmp`);
|
|
349
|
+
}
|
|
350
|
+
const next = pointer === "a" ? "b" : "a";
|
|
351
|
+
await writeFile(d, `${base}.${next}`, value);
|
|
352
|
+
await writeFile(d, `${base}.ptr`, next);
|
|
353
|
+
},
|
|
354
|
+
async remove(key) {
|
|
355
|
+
const d = await directory();
|
|
356
|
+
const base = fileName(key);
|
|
357
|
+
for (const name of [`${base}.ptr`, base, `${base}.a`, `${base}.b`, `${base}.tmp`]) {
|
|
358
|
+
await removeFile(d, name);
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
async keys(prefix) {
|
|
362
|
+
const d = await directory();
|
|
363
|
+
if (typeof d.keys !== "function") throw new StorageUnavailableError("opfs (directory listing)");
|
|
364
|
+
const names = /* @__PURE__ */ new Set();
|
|
365
|
+
for await (const name of d.keys()) names.add(name);
|
|
366
|
+
const keys = /* @__PURE__ */ new Set();
|
|
367
|
+
for (const name of names) {
|
|
368
|
+
if (name.endsWith(".tmp")) continue;
|
|
369
|
+
if (name.endsWith(".ptr")) keys.add(name.slice(0, -4));
|
|
370
|
+
else if (/\.[ab]$/.test(name) && names.has(`${name.slice(0, -2)}.ptr`)) continue;
|
|
371
|
+
else keys.add(name);
|
|
372
|
+
}
|
|
373
|
+
const out = [];
|
|
374
|
+
for (const name of keys) {
|
|
375
|
+
let key;
|
|
376
|
+
try {
|
|
377
|
+
key = decodeURIComponent(name);
|
|
378
|
+
} catch {
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (key.startsWith(prefix)) out.push(key);
|
|
382
|
+
}
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// src/persistence/autosave.ts
|
|
389
|
+
var recoveryMetaKey = (key) => `${key}:meta`;
|
|
390
|
+
function formatRecoveryMessage(meta, formatTime = (time) => new Date(time).toLocaleString()) {
|
|
391
|
+
return typeof meta.time === "number" && Number.isFinite(meta.time) ? `Restore unsaved changes from ${formatTime(meta.time)}?` : "Restore unsaved changes?";
|
|
392
|
+
}
|
|
393
|
+
function createAutosaveController(options) {
|
|
394
|
+
const {
|
|
395
|
+
storage,
|
|
396
|
+
key,
|
|
397
|
+
getSnapshot,
|
|
398
|
+
revision,
|
|
399
|
+
savedRevision,
|
|
400
|
+
interval = 5e3,
|
|
401
|
+
debounceMs = 1e3,
|
|
402
|
+
retryBaseMs = 1e3,
|
|
403
|
+
retryMaxMs = 3e4
|
|
404
|
+
} = options;
|
|
405
|
+
const serialize = options.serialize ?? ((value) => JSON.stringify(value));
|
|
406
|
+
const deserialize = options.deserialize ?? ((text) => JSON.parse(text));
|
|
407
|
+
const now = options.now ?? (() => Date.now());
|
|
408
|
+
const wallClock = options.wallClock ?? (() => Date.now());
|
|
409
|
+
const setTimer = options.setTimeout ?? ((fn, ms) => globalThis.setTimeout(fn, ms));
|
|
410
|
+
const clearTimer = options.clearTimeout ?? ((handle) => globalThis.clearTimeout(handle));
|
|
411
|
+
const metaKey = recoveryMetaKey(key);
|
|
412
|
+
const policy = createRecoveryPolicy({
|
|
413
|
+
interval,
|
|
414
|
+
retryDelay: (failures) => exponentialBackoff(failures, retryBaseMs, retryMaxMs)
|
|
415
|
+
});
|
|
416
|
+
let hydrated = options.ready === true;
|
|
417
|
+
let destroyed = false;
|
|
418
|
+
let timer = null;
|
|
419
|
+
let timerDue = Infinity;
|
|
420
|
+
let inflight = null;
|
|
421
|
+
let observedRevision = revision();
|
|
422
|
+
let lastChangeAt = null;
|
|
423
|
+
let pendingSince = null;
|
|
424
|
+
let forceNext = false;
|
|
425
|
+
let info = {
|
|
426
|
+
status: "idle",
|
|
427
|
+
error: null,
|
|
428
|
+
failures: 0,
|
|
429
|
+
nextAttemptAt: null,
|
|
430
|
+
lastSavedAt: null,
|
|
431
|
+
ready: hydrated
|
|
432
|
+
};
|
|
433
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
434
|
+
const setInfo = (patch) => {
|
|
435
|
+
const next = { ...info, ...patch, failures: policy.failures, ready: hydrated };
|
|
436
|
+
if (next.status === info.status && next.error === info.error && next.failures === info.failures && next.nextAttemptAt === info.nextAttemptAt && next.lastSavedAt === info.lastSavedAt && next.ready === info.ready) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
info = next;
|
|
440
|
+
options.onStatus?.(info);
|
|
441
|
+
listeners.forEach((listener) => {
|
|
442
|
+
try {
|
|
443
|
+
listener(info);
|
|
444
|
+
} catch (error) {
|
|
445
|
+
console.error("[autosave] Error in status listener:", error);
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
};
|
|
449
|
+
const cancelTimer = () => {
|
|
450
|
+
if (timer !== null) clearTimer(timer);
|
|
451
|
+
timer = null;
|
|
452
|
+
timerDue = Infinity;
|
|
453
|
+
};
|
|
454
|
+
const schedule = (delay) => {
|
|
455
|
+
if (destroyed) return;
|
|
456
|
+
const due = now() + Math.max(0, delay);
|
|
457
|
+
if (timer !== null && timerDue <= due) return;
|
|
458
|
+
cancelTimer();
|
|
459
|
+
timerDue = due;
|
|
460
|
+
timer = setTimer(() => {
|
|
461
|
+
timer = null;
|
|
462
|
+
timerDue = Infinity;
|
|
463
|
+
tick();
|
|
464
|
+
}, Math.max(0, delay));
|
|
465
|
+
};
|
|
466
|
+
const observe = (t) => {
|
|
467
|
+
const rev = revision();
|
|
468
|
+
if (rev !== observedRevision) {
|
|
469
|
+
observedRevision = rev;
|
|
470
|
+
lastChangeAt = t;
|
|
471
|
+
pendingSince ??= t;
|
|
472
|
+
}
|
|
473
|
+
return rev;
|
|
474
|
+
};
|
|
475
|
+
const docName = () => typeof options.name === "function" ? options.name() : options.name;
|
|
476
|
+
const run = (action, rev) => {
|
|
477
|
+
if (action.type === "writeSnapshot") {
|
|
478
|
+
setInfo({ status: "saving", nextAttemptAt: null });
|
|
479
|
+
inflight = (async () => {
|
|
480
|
+
let ok = false;
|
|
481
|
+
let error = null;
|
|
482
|
+
try {
|
|
483
|
+
const text = serialize(getSnapshot());
|
|
484
|
+
await storage.set(key, text);
|
|
485
|
+
const meta = {
|
|
486
|
+
dirty: true,
|
|
487
|
+
revision: action.revision,
|
|
488
|
+
time: wallClock(),
|
|
489
|
+
name: docName()
|
|
490
|
+
};
|
|
491
|
+
await storage.set(metaKey, JSON.stringify(meta));
|
|
492
|
+
ok = true;
|
|
493
|
+
} catch (e) {
|
|
494
|
+
error = e;
|
|
495
|
+
}
|
|
496
|
+
const t = now();
|
|
497
|
+
policy.written(action.revision, ok, t);
|
|
498
|
+
inflight = null;
|
|
499
|
+
if (ok) {
|
|
500
|
+
if (observedRevision === action.revision) pendingSince = null;
|
|
501
|
+
setInfo({ status: "saved", error: null, lastSavedAt: t });
|
|
502
|
+
} else {
|
|
503
|
+
setInfo({ status: "error", error });
|
|
504
|
+
}
|
|
505
|
+
tick();
|
|
506
|
+
})();
|
|
507
|
+
} else if (action.type === "writeClean") {
|
|
508
|
+
setInfo({ status: "saving", nextAttemptAt: null });
|
|
509
|
+
inflight = (async () => {
|
|
510
|
+
let ok = false;
|
|
511
|
+
let error = null;
|
|
512
|
+
try {
|
|
513
|
+
await storage.set(metaKey, JSON.stringify({ dirty: false }));
|
|
514
|
+
ok = true;
|
|
515
|
+
} catch (e) {
|
|
516
|
+
error = e;
|
|
517
|
+
}
|
|
518
|
+
policy.cleaned(ok, now());
|
|
519
|
+
inflight = null;
|
|
520
|
+
if (ok) {
|
|
521
|
+
if (!policy.ownsSnapshot) await storage.remove(key).catch(() => void 0);
|
|
522
|
+
setInfo({ status: "saved", error: null });
|
|
523
|
+
} else {
|
|
524
|
+
setInfo({ status: "error", error });
|
|
525
|
+
}
|
|
526
|
+
tick();
|
|
527
|
+
})();
|
|
528
|
+
} else if (action.type === "wakeAfter") {
|
|
529
|
+
schedule(action.delay);
|
|
530
|
+
setInfo(
|
|
531
|
+
info.status === "error" ? { nextAttemptAt: now() + action.delay } : { status: rev !== savedRevision() ? "dirty" : info.status, nextAttemptAt: now() + action.delay }
|
|
532
|
+
);
|
|
533
|
+
} else if (rev === savedRevision() && info.status === "dirty") {
|
|
534
|
+
setInfo({ status: policy.ownsSnapshot ? "dirty" : "idle", nextAttemptAt: null });
|
|
535
|
+
} else if (rev !== savedRevision() && info.status !== "saved" && info.status !== "error") {
|
|
536
|
+
setInfo({ status: "dirty" });
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
function tick() {
|
|
540
|
+
if (destroyed || inflight) return;
|
|
541
|
+
const t = now();
|
|
542
|
+
const rev = observe(t);
|
|
543
|
+
const saved = savedRevision();
|
|
544
|
+
if (!hydrated) {
|
|
545
|
+
if (rev !== saved) setInfo({ status: "dirty" });
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const force = forceNext;
|
|
549
|
+
forceNext = false;
|
|
550
|
+
if (rev !== saved && !force && info.status !== "error") {
|
|
551
|
+
const changed = lastChangeAt ?? t;
|
|
552
|
+
const due = Math.min(changed + debounceMs, (pendingSince ?? changed) + interval);
|
|
553
|
+
if (t < due) {
|
|
554
|
+
setInfo({ status: "dirty", nextAttemptAt: due });
|
|
555
|
+
schedule(due - t);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
run(policy.tick(t, rev, saved, { force }), rev);
|
|
560
|
+
}
|
|
561
|
+
const whenIdle = async () => {
|
|
562
|
+
while (inflight) await inflight;
|
|
563
|
+
};
|
|
564
|
+
return {
|
|
565
|
+
getStatus: () => info,
|
|
566
|
+
subscribe(listener) {
|
|
567
|
+
listeners.add(listener);
|
|
568
|
+
return () => {
|
|
569
|
+
listeners.delete(listener);
|
|
570
|
+
};
|
|
571
|
+
},
|
|
572
|
+
notify() {
|
|
573
|
+
if (destroyed) return;
|
|
574
|
+
const t = now();
|
|
575
|
+
const before = observedRevision;
|
|
576
|
+
const rev = observe(t);
|
|
577
|
+
const dirty = rev !== savedRevision();
|
|
578
|
+
if (dirty && rev !== before && info.status !== "error" && !inflight) {
|
|
579
|
+
setInfo({ status: "dirty" });
|
|
580
|
+
}
|
|
581
|
+
if (!hydrated) return;
|
|
582
|
+
const changed = lastChangeAt ?? t;
|
|
583
|
+
const due = Math.min(changed + debounceMs, (pendingSince ?? changed) + interval);
|
|
584
|
+
schedule(dirty ? due - t : 0);
|
|
585
|
+
},
|
|
586
|
+
ready() {
|
|
587
|
+
if (hydrated || destroyed) return;
|
|
588
|
+
hydrated = true;
|
|
589
|
+
setInfo({});
|
|
590
|
+
schedule(0);
|
|
591
|
+
},
|
|
592
|
+
get isReady() {
|
|
593
|
+
return hydrated;
|
|
594
|
+
},
|
|
595
|
+
async flushNow() {
|
|
596
|
+
if (destroyed || !hydrated) return;
|
|
597
|
+
await whenIdle();
|
|
598
|
+
cancelTimer();
|
|
599
|
+
forceNext = true;
|
|
600
|
+
tick();
|
|
601
|
+
await whenIdle();
|
|
602
|
+
},
|
|
603
|
+
async markClean() {
|
|
604
|
+
if (destroyed) return;
|
|
605
|
+
await whenIdle();
|
|
606
|
+
cancelTimer();
|
|
607
|
+
tick();
|
|
608
|
+
await whenIdle();
|
|
609
|
+
},
|
|
610
|
+
async checkRecovery() {
|
|
611
|
+
if (policy.ownsSnapshot) return null;
|
|
612
|
+
let meta;
|
|
613
|
+
try {
|
|
614
|
+
const metaText = await storage.get(metaKey);
|
|
615
|
+
if (!metaText) return null;
|
|
616
|
+
meta = JSON.parse(metaText);
|
|
617
|
+
} catch {
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
if (!meta || meta.dirty !== true) return null;
|
|
621
|
+
const text = await storage.get(key).catch(() => null);
|
|
622
|
+
if (text === null) return null;
|
|
623
|
+
try {
|
|
624
|
+
return { snapshot: deserialize(text), text, meta };
|
|
625
|
+
} catch (error) {
|
|
626
|
+
return { snapshot: void 0, text, meta, error };
|
|
627
|
+
}
|
|
628
|
+
},
|
|
629
|
+
async discardRecovery() {
|
|
630
|
+
if (policy.ownsSnapshot) return false;
|
|
631
|
+
await whenIdle();
|
|
632
|
+
await storage.remove(metaKey);
|
|
633
|
+
await storage.remove(key);
|
|
634
|
+
return true;
|
|
635
|
+
},
|
|
636
|
+
tick,
|
|
637
|
+
whenIdle,
|
|
638
|
+
destroy() {
|
|
639
|
+
destroyed = true;
|
|
640
|
+
cancelTimer();
|
|
641
|
+
listeners.clear();
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// src/persistence/revisions.ts
|
|
647
|
+
function createRevisionTracker(options = {}) {
|
|
648
|
+
let revision = 0;
|
|
649
|
+
let saved = 0;
|
|
650
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
651
|
+
const emit = () => listeners.forEach((listener) => {
|
|
652
|
+
try {
|
|
653
|
+
listener();
|
|
654
|
+
} catch (error) {
|
|
655
|
+
console.error("[autosave] Error in revision listener:", error);
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
const syncSaved = () => {
|
|
659
|
+
if (options.dirty && !options.dirty.dirty) saved = revision;
|
|
660
|
+
};
|
|
661
|
+
const unsubscribers = [];
|
|
662
|
+
if (options.history) {
|
|
663
|
+
unsubscribers.push(
|
|
664
|
+
options.history.subscribe(() => {
|
|
665
|
+
revision++;
|
|
666
|
+
emit();
|
|
667
|
+
})
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
if (options.dirty) {
|
|
671
|
+
let first = true;
|
|
672
|
+
unsubscribers.push(
|
|
673
|
+
options.dirty.subscribe((dirty) => {
|
|
674
|
+
if (first) {
|
|
675
|
+
first = false;
|
|
676
|
+
if (dirty && revision === saved) revision++;
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if (dirty && revision === saved) revision++;
|
|
680
|
+
if (!dirty) saved = revision;
|
|
681
|
+
emit();
|
|
682
|
+
})
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
return {
|
|
686
|
+
revision: () => revision,
|
|
687
|
+
savedRevision: () => {
|
|
688
|
+
syncSaved();
|
|
689
|
+
return saved;
|
|
690
|
+
},
|
|
691
|
+
bump() {
|
|
692
|
+
revision++;
|
|
693
|
+
emit();
|
|
694
|
+
},
|
|
695
|
+
markSaved() {
|
|
696
|
+
saved = revision;
|
|
697
|
+
emit();
|
|
698
|
+
},
|
|
699
|
+
subscribe(listener) {
|
|
700
|
+
listeners.add(listener);
|
|
701
|
+
return () => {
|
|
702
|
+
listeners.delete(listener);
|
|
703
|
+
};
|
|
704
|
+
},
|
|
705
|
+
destroy() {
|
|
706
|
+
unsubscribers.forEach((fn) => fn());
|
|
707
|
+
unsubscribers.length = 0;
|
|
708
|
+
listeners.clear();
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// src/persistence/editorAutosave.ts
|
|
714
|
+
function createEditorAutosave(doc, options) {
|
|
715
|
+
const { dirty, key, collectAssetGarbage, ...rest } = options;
|
|
716
|
+
const collect = () => collectRetiredEditorAssets(rest.storage, doc, collectAssetGarbage);
|
|
717
|
+
const revisions = createRevisionTracker({ history: doc, dirty });
|
|
718
|
+
const controller = createAutosaveController({
|
|
719
|
+
...rest,
|
|
720
|
+
// Image and paint documents hold asset references: their bytes are saved and read with them.
|
|
721
|
+
storage: doc.assets ? withEditorAssets(rest.storage, doc.assets) : rest.storage,
|
|
722
|
+
key: key ?? doc.kind,
|
|
723
|
+
getSnapshot: () => toEditorDocumentFile(doc),
|
|
724
|
+
deserialize: (text) => parseEditorDocument(text, { kind: doc.kind }),
|
|
725
|
+
revision: revisions.revision,
|
|
726
|
+
savedRevision: revisions.savedRevision
|
|
727
|
+
});
|
|
728
|
+
const unsubscribe = revisions.subscribe(() => controller.notify());
|
|
729
|
+
return {
|
|
730
|
+
controller,
|
|
731
|
+
revisions,
|
|
732
|
+
checkRecovery: () => controller.checkRecovery(),
|
|
733
|
+
restore(recovery) {
|
|
734
|
+
if (!recovery.snapshot) throw recovery.error ?? new Error("The recovered snapshot could not be read.");
|
|
735
|
+
const report = doc.loadDocument(recovery.snapshot, { history: "reset" });
|
|
736
|
+
controller.ready();
|
|
737
|
+
return report;
|
|
738
|
+
},
|
|
739
|
+
async discard() {
|
|
740
|
+
await controller.discardRecovery();
|
|
741
|
+
controller.ready();
|
|
742
|
+
await collect();
|
|
743
|
+
},
|
|
744
|
+
async markClean() {
|
|
745
|
+
revisions.markSaved();
|
|
746
|
+
await controller.markClean();
|
|
747
|
+
await collect();
|
|
748
|
+
},
|
|
749
|
+
destroy() {
|
|
750
|
+
unsubscribe();
|
|
751
|
+
revisions.destroy();
|
|
752
|
+
controller.destroy();
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// src/persistence/documentStore.ts
|
|
758
|
+
function slugifyDocumentName(name) {
|
|
759
|
+
const slug = name.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
|
|
760
|
+
return slug || "untitled";
|
|
761
|
+
}
|
|
762
|
+
function createEditorDocumentStore(options) {
|
|
763
|
+
const { storage } = options;
|
|
764
|
+
const ns = options.namespace ?? "pixerate.documents";
|
|
765
|
+
const now = options.now ?? Date.now;
|
|
766
|
+
const indexKey = `${ns}:index`;
|
|
767
|
+
const fileKey = (key) => `${ns}:file:${key}`;
|
|
768
|
+
const gc = options.collectAssetGarbage ?? true;
|
|
769
|
+
let index = null;
|
|
770
|
+
let queue = Promise.resolve();
|
|
771
|
+
const run = (task) => {
|
|
772
|
+
const result = queue.then(task, task);
|
|
773
|
+
queue = result.catch(() => void 0);
|
|
774
|
+
return result;
|
|
775
|
+
};
|
|
776
|
+
const loadIndex = async () => {
|
|
777
|
+
if (index) return index;
|
|
778
|
+
const raw = await storage.get(indexKey);
|
|
779
|
+
let items = [];
|
|
780
|
+
if (raw) {
|
|
781
|
+
try {
|
|
782
|
+
const data = JSON.parse(raw);
|
|
783
|
+
if (data && Array.isArray(data.items)) {
|
|
784
|
+
items = data.items.filter((item) => item && typeof item.key === "string" && typeof item.kind === "string");
|
|
785
|
+
}
|
|
786
|
+
} catch {
|
|
787
|
+
items = [];
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
index = items;
|
|
791
|
+
return index;
|
|
792
|
+
};
|
|
793
|
+
const saveIndex = async (items) => {
|
|
794
|
+
await storage.set(indexKey, JSON.stringify({ version: 1, items }));
|
|
795
|
+
index = items;
|
|
796
|
+
};
|
|
797
|
+
const newestFirst = (a, b) => a.savedAt < b.savedAt ? 1 : a.savedAt > b.savedAt ? -1 : 0;
|
|
798
|
+
const readFile = async (key) => {
|
|
799
|
+
const raw = await storage.get(fileKey(key));
|
|
800
|
+
return raw === null ? null : parseEditorDocument(raw);
|
|
801
|
+
};
|
|
802
|
+
return {
|
|
803
|
+
save: (doc, name) => run(async () => {
|
|
804
|
+
const trimmed = name.trim() || "Untitled";
|
|
805
|
+
const entry = {
|
|
806
|
+
key: `${doc.kind}/${slugifyDocumentName(trimmed)}`,
|
|
807
|
+
kind: doc.kind,
|
|
808
|
+
name: trimmed,
|
|
809
|
+
formatVersion: doc.formatVersion,
|
|
810
|
+
savedAt: new Date(now()).toISOString()
|
|
811
|
+
};
|
|
812
|
+
const text = JSON.stringify(toEditorDocumentFile(doc, trimmed));
|
|
813
|
+
await (doc.assets ? withEditorAssets(storage, doc.assets) : storage).set(fileKey(entry.key), text);
|
|
814
|
+
await saveIndex([...(await loadIndex()).filter((item) => item.key !== entry.key), entry]);
|
|
815
|
+
return entry;
|
|
816
|
+
}),
|
|
817
|
+
list: (kind) => run(async () => (await loadIndex()).filter((item) => !kind || item.kind === kind).sort(newestFirst)),
|
|
818
|
+
read: (key) => run(() => readFile(key)),
|
|
819
|
+
open: (doc, key, openOptions = {}) => run(async () => {
|
|
820
|
+
const entry = (await loadIndex()).find((item) => item.key === key);
|
|
821
|
+
const file = entry ? await readFile(key) : null;
|
|
822
|
+
if (!entry || !file) {
|
|
823
|
+
const keys = (await loadIndex()).filter((item) => item.kind === doc.kind).sort(newestFirst).map((item) => item.key);
|
|
824
|
+
throw new EditorDocumentLoadError(
|
|
825
|
+
`There is no saved document "${key}". ${keys.length ? `Saved ${doc.kind} documents: ${keys.join(", ")}.` : `No ${doc.kind} documents are saved yet.`}`
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
if (doc.assets) {
|
|
829
|
+
const missing = await preloadEditorAssets(storage, doc.assets, file);
|
|
830
|
+
if (missing.length) throw new EditorDocumentLoadError(`The saved document "${key}" refers to ${missing.length} asset${missing.length === 1 ? "" : "s"} that storage no longer has, so it cannot be opened.`);
|
|
831
|
+
}
|
|
832
|
+
const report = doc.loadDocument(file, { history: openOptions.history ?? "reset" });
|
|
833
|
+
return { ...entry, report };
|
|
834
|
+
}),
|
|
835
|
+
remove: (key) => run(async () => {
|
|
836
|
+
const items = await loadIndex();
|
|
837
|
+
if (!items.some((item) => item.key === key)) return false;
|
|
838
|
+
await saveIndex(items.filter((item) => item.key !== key));
|
|
839
|
+
await storage.remove(fileKey(key));
|
|
840
|
+
if (gc) await tryCollectEditorAssetGarbage(storage, gc === true ? {} : gc);
|
|
841
|
+
return true;
|
|
842
|
+
}),
|
|
843
|
+
...storage.keys ? { collectAssetGarbage: (gcOptions) => run(() => collectEditorAssetGarbage(storage, gcOptions)) } : {}
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// src/persistence/documentTools.ts
|
|
848
|
+
var READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
849
|
+
var WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
|
|
850
|
+
var REPLACE = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false };
|
|
851
|
+
var OVERWRITE = { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false };
|
|
852
|
+
var object = (properties, required = []) => ({
|
|
853
|
+
type: "object",
|
|
854
|
+
properties,
|
|
855
|
+
...required.length ? { required } : {},
|
|
856
|
+
additionalProperties: false
|
|
857
|
+
});
|
|
858
|
+
var reportOut = (report) => ({
|
|
859
|
+
...report.migratedFrom !== null ? { migratedFrom: report.migratedFrom } : {},
|
|
860
|
+
...report.problems.length ? { problems: report.problems } : {},
|
|
861
|
+
...report.unknownKeys.length ? { unknownKeys: report.unknownKeys } : {}
|
|
862
|
+
});
|
|
863
|
+
function createDocumentToolProvider(doc, options = {}) {
|
|
864
|
+
const { kind } = doc;
|
|
865
|
+
const noun = options.noun ?? kind.replace(/_/g, " ");
|
|
866
|
+
const maxChars = options.maxChars ?? 2e5;
|
|
867
|
+
const store = options.store;
|
|
868
|
+
const writable = () => {
|
|
869
|
+
const reason = doc.readOnly();
|
|
870
|
+
if (reason) throw new Error(`${reason} Read tools still work; write tools are refused.`);
|
|
871
|
+
};
|
|
872
|
+
const historyOut = () => {
|
|
873
|
+
const h = doc.history.getState();
|
|
874
|
+
return {
|
|
875
|
+
revision: doc.revision(),
|
|
876
|
+
canUndo: h.canUndo,
|
|
877
|
+
canRedo: h.canRedo,
|
|
878
|
+
...h.undoName ? { undoName: h.undoName } : {},
|
|
879
|
+
...h.redoName ? { redoName: h.redoName } : {}
|
|
880
|
+
};
|
|
881
|
+
};
|
|
882
|
+
const loaded = (report) => ({
|
|
883
|
+
revision: doc.revision(),
|
|
884
|
+
undoable: doc.recordsLoads,
|
|
885
|
+
...reportOut(report)
|
|
886
|
+
});
|
|
887
|
+
const get = {
|
|
888
|
+
name: `get_${kind}_document`,
|
|
889
|
+
title: `Get the ${noun} document`,
|
|
890
|
+
description: `Returns the whole ${noun} as plain JSON: { kind, formatVersion, revision, document }. Use it to keep a copy, compare versions or pass the document to load_${kind}_document later. For reading or editing parts of the ${noun}, prefer the editor's own read tools: this result can be large.`,
|
|
891
|
+
inputSchema: object({}),
|
|
892
|
+
annotations: { title: `Get the ${noun} document`, ...READ },
|
|
893
|
+
handler: () => {
|
|
894
|
+
const document = doc.getDocument();
|
|
895
|
+
const size = JSON.stringify(document)?.length ?? 0;
|
|
896
|
+
if (size > maxChars) {
|
|
897
|
+
throw new Error(`The ${noun} document is ${size} characters of JSON, over the ${maxChars} limit for this tool. Use the editor's read tools to look at parts of it.`);
|
|
898
|
+
}
|
|
899
|
+
return { kind, formatVersion: doc.formatVersion, revision: doc.revision(), document };
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
const load = {
|
|
903
|
+
name: `load_${kind}_document`,
|
|
904
|
+
title: `Load a ${noun} document`,
|
|
905
|
+
description: `Replaces the whole ${noun} with a document from get_${kind}_document (its "document" value, or the whole result as a saved file). The document is checked first: when it does not fit, nothing changes and the error says why. Returns { revision, undoable, migratedFrom?, problems?, unknownKeys? }; undoable: true means undo_${kind} brings the previous ${noun} back.`,
|
|
906
|
+
inputSchema: object(
|
|
907
|
+
{
|
|
908
|
+
document: {
|
|
909
|
+
type: "object",
|
|
910
|
+
description: `The ${noun} document: the "document" value from get_${kind}_document, or a saved file ({ format, kind, formatVersion, document }).`
|
|
911
|
+
}
|
|
912
|
+
},
|
|
913
|
+
["document"]
|
|
914
|
+
),
|
|
915
|
+
annotations: { title: `Load a ${noun} document`, ...REPLACE },
|
|
916
|
+
handler: ({ document }) => {
|
|
917
|
+
writable();
|
|
918
|
+
return loaded(doc.loadDocument(document, { history: "record" }));
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
const undo = {
|
|
922
|
+
name: `undo_${kind}`,
|
|
923
|
+
title: `Undo a ${noun} edit`,
|
|
924
|
+
description: `Undoes the last ${noun} edit (one tool call or one user gesture). Returns { undone, revision, canUndo, canRedo, undoName?, redoName? }; undone is the name of the step that was undone.`,
|
|
925
|
+
inputSchema: object({}),
|
|
926
|
+
annotations: { title: `Undo a ${noun} edit`, ...WRITE },
|
|
927
|
+
handler: () => {
|
|
928
|
+
writable();
|
|
929
|
+
const name = doc.history.getState().undoName;
|
|
930
|
+
if (!doc.history.getState().canUndo || !doc.history.undo()) throw new Error("Nothing to undo.");
|
|
931
|
+
return { undone: name ?? true, ...historyOut() };
|
|
932
|
+
}
|
|
933
|
+
};
|
|
934
|
+
const redo = {
|
|
935
|
+
name: `redo_${kind}`,
|
|
936
|
+
title: `Redo a ${noun} edit`,
|
|
937
|
+
description: `Redoes the last undone ${noun} edit. Returns { redone, revision, canUndo, canRedo, undoName?, redoName? }; redone is the name of the step that was redone.`,
|
|
938
|
+
inputSchema: object({}),
|
|
939
|
+
annotations: { title: `Redo a ${noun} edit`, ...WRITE },
|
|
940
|
+
handler: () => {
|
|
941
|
+
writable();
|
|
942
|
+
const name = doc.history.getState().redoName;
|
|
943
|
+
if (!doc.history.getState().canRedo || !doc.history.redo()) throw new Error("Nothing to redo.");
|
|
944
|
+
return { redone: name ?? true, ...historyOut() };
|
|
945
|
+
}
|
|
946
|
+
};
|
|
947
|
+
const storeTools = (s) => [
|
|
948
|
+
{
|
|
949
|
+
name: `save_${kind}_document`,
|
|
950
|
+
title: `Save the ${noun}`,
|
|
951
|
+
description: `Saves the current ${noun} under a name in the host's document storage. Saving the same name again replaces that saved copy. Returns { key, name, savedAt, revision }; pass key to open_saved_${kind}_document to load it again.`,
|
|
952
|
+
inputSchema: object({
|
|
953
|
+
name: {
|
|
954
|
+
type: "string",
|
|
955
|
+
minLength: 1,
|
|
956
|
+
maxLength: 200,
|
|
957
|
+
description: `Name to save under (default: the ${noun}'s current name, or "Untitled ${noun}").`
|
|
958
|
+
}
|
|
959
|
+
}),
|
|
960
|
+
annotations: { title: `Save the ${noun}`, ...OVERWRITE },
|
|
961
|
+
handler: async ({ name }) => {
|
|
962
|
+
const entry = await s.save(doc, name ?? options.name?.() ?? `Untitled ${noun}`);
|
|
963
|
+
return { key: entry.key, name: entry.name, savedAt: entry.savedAt, revision: doc.revision() };
|
|
964
|
+
}
|
|
965
|
+
},
|
|
966
|
+
{
|
|
967
|
+
name: `list_saved_${kind}_documents`,
|
|
968
|
+
title: `List saved ${noun} documents`,
|
|
969
|
+
description: `Lists the ${noun} documents in the host's document storage, newest first. Returns { documents: [{ key, name, savedAt, formatVersion }] }.`,
|
|
970
|
+
inputSchema: object({}),
|
|
971
|
+
annotations: { title: `List saved ${noun} documents`, ...READ },
|
|
972
|
+
handler: async () => ({
|
|
973
|
+
documents: (await s.list(kind)).map(({ key, name, savedAt, formatVersion }) => ({ key, name, savedAt, formatVersion }))
|
|
974
|
+
})
|
|
975
|
+
},
|
|
976
|
+
{
|
|
977
|
+
name: `open_saved_${kind}_document`,
|
|
978
|
+
title: `Open a saved ${noun}`,
|
|
979
|
+
description: `Replaces the current ${noun} with a saved one. Unsaved changes stay reachable through undo_${kind} when undoable is true. Returns { key, name, revision, undoable, migratedFrom?, problems?, unknownKeys? }.`,
|
|
980
|
+
inputSchema: object({ key: { type: "string", minLength: 1, description: `A key from list_saved_${kind}_documents or save_${kind}_document.` } }, ["key"]),
|
|
981
|
+
annotations: { title: `Open a saved ${noun}`, ...REPLACE },
|
|
982
|
+
handler: async ({ key }) => {
|
|
983
|
+
writable();
|
|
984
|
+
const entry = await s.open(doc, key, { history: "record" });
|
|
985
|
+
return { key: entry.key, name: entry.name, ...loaded(entry.report) };
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
];
|
|
989
|
+
const LIST_LIMIT = 100;
|
|
990
|
+
const assetTools = (s) => [
|
|
991
|
+
{
|
|
992
|
+
name: `find_unreferenced_${kind}_assets`,
|
|
993
|
+
title: `Find unreferenced ${noun} assets`,
|
|
994
|
+
description: `Finds the stored image data (layer pixels, masks and other assets of every image and paint document in the host's storage) that no saved document or autosave snapshot refers to any more, without deleting anything. The open ${noun}'s own assets always count as used. Returns { assets, referenced, kept, unreferenced, unreferencedChars, refs, more }: unreferenced is how many could be deleted, unreferencedChars the characters of data they hold, refs up to ${LIST_LIMIT} of their references for delete_unreferenced_${kind}_assets, and more how many were left out of refs.`,
|
|
995
|
+
inputSchema: object({}),
|
|
996
|
+
annotations: { title: `Find unreferenced ${noun} assets`, ...READ },
|
|
997
|
+
handler: async () => {
|
|
998
|
+
const report = await s.collectAssetGarbage({ dryRun: true, keep: [doc.getDocument()] });
|
|
999
|
+
return {
|
|
1000
|
+
assets: report.assets,
|
|
1001
|
+
referenced: report.referenced,
|
|
1002
|
+
kept: report.kept,
|
|
1003
|
+
unreferenced: report.unreferenced.length,
|
|
1004
|
+
unreferencedChars: report.unreferencedChars,
|
|
1005
|
+
refs: report.unreferenced.slice(0, LIST_LIMIT),
|
|
1006
|
+
more: Math.max(0, report.unreferenced.length - LIST_LIMIT)
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
},
|
|
1010
|
+
{
|
|
1011
|
+
name: `delete_unreferenced_${kind}_assets`,
|
|
1012
|
+
title: `Delete unreferenced ${noun} assets`,
|
|
1013
|
+
description: `Deletes stored image data that no saved document or autosave snapshot refers to, to free browser storage. Saved documents and the open ${noun} are never affected, and a write in progress is waited for. Call find_unreferenced_${kind}_assets first to see what would go, and do it when the user asks to free space or clean up storage. It cannot be undone, but nothing refers to the deleted data. Returns { removed, freedChars, kept, remaining }: how many assets were deleted, the characters of data freed, how many unreferenced ones were kept because a write was using them, and how many assets are left.`,
|
|
1014
|
+
inputSchema: object({
|
|
1015
|
+
refs: {
|
|
1016
|
+
type: "array",
|
|
1017
|
+
items: { type: "string", pattern: "^pixerate-asset:" },
|
|
1018
|
+
minItems: 1,
|
|
1019
|
+
uniqueItems: true,
|
|
1020
|
+
description: `Delete only these references from find_unreferenced_${kind}_assets (refs). Default: every unreferenced asset. If any is no longer unreferenced, nothing is deleted and the error names it.`
|
|
1021
|
+
}
|
|
1022
|
+
}),
|
|
1023
|
+
annotations: { title: `Delete unreferenced ${noun} assets`, ...OVERWRITE },
|
|
1024
|
+
handler: async ({ refs }) => {
|
|
1025
|
+
writable();
|
|
1026
|
+
const report = await s.collectAssetGarbage({ keep: [doc.getDocument()], ...refs ? { only: refs } : {} });
|
|
1027
|
+
return {
|
|
1028
|
+
removed: report.removed.length,
|
|
1029
|
+
freedChars: report.freedChars,
|
|
1030
|
+
kept: report.kept,
|
|
1031
|
+
remaining: report.assets - report.removed.length
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
];
|
|
1036
|
+
return {
|
|
1037
|
+
id: options.id ?? `${kind}-document`,
|
|
1038
|
+
...options.namespace !== void 0 ? { namespace: options.namespace } : {},
|
|
1039
|
+
title: options.title ?? `${noun.charAt(0).toUpperCase()}${noun.slice(1)} document`,
|
|
1040
|
+
description: `Get, load${options.history === false ? "" : ", undo, redo"}${store ? ", save and open" : ""} the whole ${noun}.`,
|
|
1041
|
+
...options.skill ? { skill: options.skill } : {},
|
|
1042
|
+
tools: [
|
|
1043
|
+
get,
|
|
1044
|
+
load,
|
|
1045
|
+
...options.history === false ? [] : [undo, redo],
|
|
1046
|
+
...store ? storeTools(store) : [],
|
|
1047
|
+
...store?.collectAssetGarbage && doc.assets ? assetTools(store) : []
|
|
1048
|
+
]
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
export {
|
|
1053
|
+
exponentialBackoff,
|
|
1054
|
+
createRecoveryPolicy,
|
|
1055
|
+
StorageUnavailableError,
|
|
1056
|
+
InjectedStorageError,
|
|
1057
|
+
withStorageFailures,
|
|
1058
|
+
createMemoryStorage,
|
|
1059
|
+
isLocalStorageAvailable,
|
|
1060
|
+
createLocalStorageAdapter,
|
|
1061
|
+
isIndexedDbAvailable,
|
|
1062
|
+
createIndexedDbAdapter,
|
|
1063
|
+
isOpfsAvailable,
|
|
1064
|
+
createOpfsAdapter,
|
|
1065
|
+
recoveryMetaKey,
|
|
1066
|
+
formatRecoveryMessage,
|
|
1067
|
+
createAutosaveController,
|
|
1068
|
+
createRevisionTracker,
|
|
1069
|
+
createEditorAutosave,
|
|
1070
|
+
slugifyDocumentName,
|
|
1071
|
+
createEditorDocumentStore,
|
|
1072
|
+
createDocumentToolProvider
|
|
1073
|
+
};
|