import { validateSpritesheetImage } from "/js/plugins/sourceProvider.js";
const DB_NAME = "impro-plugin-custom-images";
const STORE_NAME = "images";
const MAX_IMAGES_PER_PLUGIN = 20;
const MAX_TOTAL_BYTES_PER_PLUGIN = 20 * 1024 * 1024;
const NAME_RE = /^[a-zA-Z0-9_-]{1,64}$/;
function makeKey(pluginId, name) {
return `${pluginId}::${name}`;
}
function isPositiveInt(value) {
return Number.isInteger(value) && value > 0;
}
// Exported so callers (e.g. pluginService.js's registerCustomImage host
// method) can shape the same public summary from a record without a second
// store round-trip after register()/loadDescriptorsForMount().
export function toPublicDescriptor(record) {
return {
name: record.name,
frameWidth: record.frameWidth,
frameHeight: record.frameHeight,
frameCount: record.frameCount,
size: record.size,
};
}
// Real backing store: one IndexedDB object store holding every plugin's
// custom images, keyed by a composite "pluginId::name" string (mirrors
// drafts.js's KVIndexedDB pattern). Per-plugin counts here are small (see
// MAX_IMAGES_PER_PLUGIN), so a full-store scan filtered client-side is
// simpler than key-range plumbing and plenty fast. Deliberately local-only
// (not synced via loadData/saveData's AT-proto preferences record, and not
// wired through this project's node-based unit test environment, which has
// no indexedDB) — see PluginCustomImages below for the injectable seam that
// keeps this swappable in tests.
class IndexedDbCustomImageStore {
constructor(dbName = DB_NAME, storeName = STORE_NAME) {
this._dbName = dbName;
this._storeName = storeName;
this._dbPromise = null;
}
async _open() {
if (this._dbPromise) return this._dbPromise;
this._dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(this._dbName, 1);
request.onupgradeneeded = () => {
request.result.createObjectStore(this._storeName);
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
return this._dbPromise;
}
async _request(mode, callback) {
const db = await this._open();
return new Promise((resolve, reject) => {
const transaction = db.transaction(this._storeName, mode);
const request = callback(transaction.objectStore(this._storeName));
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async list(pluginId) {
const all = await this._request("readonly", (store) => store.getAll());
return (all ?? []).filter((record) => record.pluginId === pluginId);
}
async put(pluginId, name, record) {
await this._request("readwrite", (store) =>
store.put(record, makeKey(pluginId, name)),
);
}
async remove(pluginId, name) {
await this._request("readwrite", (store) =>
store.delete(makeKey(pluginId, name)),
);
}
}
// User-uploaded spritesheet images, stored locally per plugin. Complements
// manifest.images (bundled into a plugin's own repo at publish time) with a
// runtime path: a plugin renders a real (see
// pluginRendering.js/pluginFileStaging.js), the host reads the picked File
// directly, and this class validates it with the exact same rules as
// bundled images (validateSpritesheetImage, from sourceProvider.js) before
// persisting it — so PluginAssetsLoader can mount a custom image the same
// way it mounts a bundled one, and needs no changes at all.
export class PluginCustomImages {
constructor({
store = new IndexedDbCustomImageStore(),
validateImage = validateSpritesheetImage,
} = {}) {
this._store = store;
this._validateImage = validateImage;
}
async list(pluginId) {
const records = await this._store.list(pluginId);
return records
.map(toPublicDescriptor)
.sort((a, b) => a.name.localeCompare(b.name));
}
// A name may collide with a bundled manifest.images entry — that's
// allowed on purpose, so a user can replace a built-in animation with
// their own upload (see pluginAssetsLoader.js's mountCustomImage, which
// mounts into the same per-plugin map bundled images use, last write
// wins). pluginService.js's deleteCustomImage handler is what restores
// the bundled original when such an override is later deleted.
async register(
pluginId,
{ name, frameWidth, frameHeight, frameCount },
bytes,
) {
if (typeof name !== "string" || !NAME_RE.test(name)) {
throw new Error(
'Image name must be 1-64 characters of letters, numbers, "_", or "-"',
);
}
if (
!isPositiveInt(frameWidth) ||
!isPositiveInt(frameHeight) ||
!isPositiveInt(frameCount)
) {
throw new Error(
"frameWidth, frameHeight, and frameCount must be positive integers",
);
}
const existing = await this.list(pluginId);
const replacing = existing.find((entry) => entry.name === name);
const otherCount = existing.length - (replacing ? 1 : 0);
if (otherCount >= MAX_IMAGES_PER_PLUGIN) {
throw new Error(
`This plugin already has the maximum of ${MAX_IMAGES_PER_PLUGIN} custom images`,
);
}
const otherBytes = existing.reduce(
(sum, entry) => sum + (entry.name === name ? 0 : entry.size),
0,
);
if (otherBytes + bytes.byteLength > MAX_TOTAL_BYTES_PER_PLUGIN) {
throw new Error(
`This plugin's custom images would exceed the ${MAX_TOTAL_BYTES_PER_PLUGIN}-byte limit`,
);
}
const blob = await this._validateImage(
bytes,
{ frameWidth, frameHeight, frameCount },
name,
);
const record = {
pluginId,
name,
blob,
frameWidth,
frameHeight,
frameCount,
size: blob.size,
createdAt: new Date().toISOString(),
};
await this._store.put(pluginId, name, record);
// Returns the full record (including the blob) so the caller can mount
// it into PluginAssetsLoader without a second store round-trip; strip
// the blob (toPublicDescriptor) before this ever reaches the plugin.
return record;
}
async delete(pluginId, name) {
await this._store.remove(pluginId, name);
}
// Descriptors shaped for PluginAssetsLoader.mountCustomImage — includes
// the blob, unlike list()'s public-facing summaries.
async loadDescriptorsForMount(pluginId) {
const records = await this._store.list(pluginId);
return records.map((record) => ({
name: record.name,
blob: record.blob,
frameWidth: record.frameWidth,
frameHeight: record.frameHeight,
frameCount: record.frameCount,
}));
}
async purgeForPlugin(pluginId) {
const records = await this._store.list(pluginId);
await Promise.all(
records.map((record) => this._store.remove(pluginId, record.name)),
);
}
}