/** * Pictures, typefaces and sounds all arrive the same way: a file goes up to the server, comes back * as a content-addressed name, and an asset is made for it – or the asset already naming those * bytes is used. What differs between them is an `ImportKind`; the pipeline is this file. */ import type { Command } from '../core/commands/Command'; import { nextAssetName } from '../core/commands/naming'; import type { AssetType } from '../core/model/AssetTypes'; import type { FileStore, UploadedFile } from '../core/store/FileStore'; import type { EditorState } from './state'; /** One file's outcome; exactly one of `assetId` and `error` is set. */ export interface ImportOutcome { name: string; assetId: string | null; error: string | null; /** The project already had this exact file, so its existing asset was used instead. */ reused?: boolean; } export interface ImportDeps { state: EditorState; } export interface ImportKind { type: AssetType; /** What the notices call one: "image", "font", "sound". */ noun: string; /** What an asset is called when the file name gives nothing to go on. */ fallbackName: string; store(deps: D): FileStore; /** The bytes to send, and their Content-Type; throws with the reason when the file cannot go up. */ prepare(file: File): Promise<{ blob: Blob; type: string }>; create(name: string, uploaded: U): Command & { asset: { id: string } }; } /** * Imports the files sequentially, so each asset is named against the project as the previous one * left it. A file that fails is reported in the notice; the remaining ones are still imported. */ export async function importFiles( kind: ImportKind, files: readonly File[], deps: D, ): Promise { if (files.length === 0) return []; const outcomes: ImportOutcome[] = []; for (const file of files) outcomes.push(await importFile(kind, file, deps)); // The asset the batch created last, so the panel shows what just arrived. const created = outcomes.filter((outcome) => outcome.assetId !== null); const last = created[created.length - 1]; if (last?.assetId) deps.state.select({ kind: 'asset', assetId: last.assetId }); deps.state.setNotice(importNotice(outcomes, kind.noun)); return outcomes; } async function importFile( kind: ImportKind, file: File, deps: D, ): Promise { try { const { blob, type } = await kind.prepare(file); const uploaded = await kind.store(deps).upload(blob, type, file.name); return adopt(kind, uploaded, assetNameForFile(file.name, kind.fallbackName), file.name, deps); } catch (error) { return { name: file.name, assetId: null, error: error instanceof Error ? error.message : String(error) }; } } /** * Makes an asset for a file the server holds. `label` names the source in messages, `nameBase` is * what the asset is called. The server stores by content hash, so a re-imported file comes back * as the one the project already references; two assets on one file would disagree about it. */ function adopt( kind: ImportKind, uploaded: U, nameBase: string, label: string, deps: D, ): ImportOutcome { const existing = deps.state.project.assets.find( (a) => a.type === kind.type && 'file' in a && a.file === uploaded.file, ); if (existing) return { name: label, assetId: existing.id, error: null, reused: true }; const command = kind.create(nextAssetName(deps.state.project, nameBase), uploaded); // A rejected command has already put its reason into the notice; that is the message here. if (!deps.state.execute(command)) return { name: label, assetId: null, error: deps.state.notice }; return { name: label, assetId: command.asset.id, error: null, reused: false }; } /** * Adopts a file the server already holds – no upload, the bytes are there. Nothing collects * unreferenced files, so one project's dropped picture is still there for the next to pick up. * Chosen in the picker, so this is "+ → Texture": the tab opens like for any created asset (a kind * without a tab is just selected). A dropped file only selects – it usually goes into the scene. */ export function importStored( kind: ImportKind, uploaded: U, deps: D, ): ImportOutcome { // Named after the file it was uploaded as, like a dropped one; a file from before names were // kept has none and falls back to the kind's own word. const nameBase = assetNameForFile(uploaded.name, kind.fallbackName); const outcome = adopt(kind, uploaded, nameBase, uploaded.name || uploaded.file, deps); if (outcome.assetId) deps.state.openAsset(outcome.assetId); deps.state.setNotice( outcome.error ?? (outcome.reused ? `That ${kind.noun} is already in the project.` : `Added the ${kind.noun} to the project.`), ); return outcome; } /** One line for the whole batch; reuse is worded apart, since "Imported 1 image" would be a lie. */ export function importNotice(outcomes: readonly ImportOutcome[], noun = 'image'): string { const added = outcomes.filter((outcome) => outcome.assetId !== null && !outcome.reused).length; const reused = outcomes.filter((outcome) => outcome.reused).length; const failed = outcomes.filter((outcome) => outcome.error !== null); const first = failed[0]; const known = reused === 0 ? '' : ` ${reused === 1 ? 'One was' : `${reused} were`} already in the project.`; if (!first) { if (added === 0 && reused > 0) return reused === 1 ? `That ${noun} is already in the project.` : `Those ${reused} ${noun}s are already in the project.`; return `Imported ${added} ${added === 1 ? noun : `${noun}s`}.${known}`; } const more = failed.length > 1 ? ` (and ${failed.length - 1} more)` : ''; const problem = `Could not import "${first.name}": ${first.error ?? ''}${more}`; if (added + reused === 0) return problem; return `Imported ${added + reused} of ${outcomes.length}.${known} ${problem}`; } /** * A file name without its extension, as an asset is called: "hero-idle.png" reads better than * "Texture 3". The picker gets the same name back from the server. */ export function assetNameForFile(fileName: string, fallback: string): string { return fileName.replace(/\.[^.]+$/, '').trim() || fallback; } /** Opens the browser's file dialog; resolves empty when the dialog was cancelled. */ export function pickFiles(accept: string): Promise { return new Promise((resolve) => { const input = document.createElement('input'); input.type = 'file'; input.accept = accept; input.multiple = true; input.style.display = 'none'; document.body.appendChild(input); const finish = (files: File[]) => { input.remove(); resolve(files); }; input.addEventListener('change', () => finish([...(input.files ?? [])])); // Without `cancel` a dismissed dialog leaves both the element and this promise behind. input.addEventListener('cancel', () => finish([])); input.click(); }); } /** The *Upload…* button: the file dialog, then the batch import of whatever it returns. */ export function pickAndImport( kind: ImportKind, accept: string, deps: D, ): void { void pickFiles(accept) .then((files) => importFiles(kind, files, deps)) .catch((error: unknown) => deps.state.setNotice(String(error), 'error')); }