Uploads carry the name the file came with (#83)

An upload sends its file name next to the raw bytes, and the server keeps
it in a sidecar next to the content-addressed file. Both pickers caption
every tile and row with it, and an asset made there is named after the
file instead of "Texture" / "Font". Last upload of the same content wins.

The API's own set_error_handler ignores error_reporting(), so @-silenced
warnings still throw: a missing sidecar took the whole listing down with a
500. Existence is checked instead of suppressed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DeveloperandClaude Opus 5 committed 2026-09-13 20:32:45 +02:00
1 parent 86b5363a01
commit 99c5d2ad78
21 files changed
+347 -61

No files matched your search

+17 -2
View File
@@ -81,9 +81,24 @@ export interface TextureImage {
height: number;
}
/** Apart from `buildAsset` so callers get a `TextureAsset` rather than the `Asset` union. */
/**
* Apart from `buildAsset` so callers get a `TextureAsset` rather than the `Asset` union.
*
* The three fields are copied out one by one rather than spread: callers pass the whole upload
* result, which carries `bytes` and – since original names are kept – a `name` of its own. A
* spread would put the *file's* name where the asset's belongs and silently overrule the caller.
*/
export function buildTextureAsset(name: string, image: TextureImage, id = newId()): TextureAsset {
return { id, name, props: defaultsFor('texture'), type: 'texture', ...image, frames: [] };
return {
id,
name,
props: defaultsFor('texture'),
type: 'texture',
file: image.file,
width: image.width,
height: image.height,
frames: [],
};
}
/**
+5 -1
View File
@@ -1,12 +1,15 @@
export interface UploadedFont {
/** Content-addressed name in `data/fonts/`: `{sha256}.{ttf|otf|woff|woff2}` (see `isFontFile`). */
file: string;
/** The name the file was uploaded under, '' when the server knows none. Never a path. */
name: string;
bytes: number;
}
/** A typeface the server holds, whether any project references it or not. */
export interface StoredFont {
file: string;
name: string;
bytes: number;
updatedAt: string;
}
@@ -16,7 +19,8 @@ export interface StoredFont {
* an image a font is never re-encoded on the way, so what the server stores is what was picked.
*/
export interface FontStore {
upload(bytes: Blob | ArrayBuffer, type: string): Promise<UploadedFont>;
/** `name` is kept beside the file as its caption; the stored file is always the hash. */
upload(bytes: Blob | ArrayBuffer, type: string, name?: string): Promise<UploadedFont>;
list(): Promise<StoredFont[]>;
delete(file: string): Promise<void>;
}
+4 -3
View File
@@ -4,8 +4,8 @@ import { apiRequest } from './http';
export class HttpFontStore implements FontStore {
constructor(private readonly endpoint: string) {}
async upload(bytes: Blob | ArrayBuffer, type: string): Promise<UploadedFont> {
return this.request<UploadedFont>('POST', 'fonts', { bytes, type });
async upload(bytes: Blob | ArrayBuffer, type: string, name = ''): Promise<UploadedFont> {
return this.request<UploadedFont>('POST', 'fonts', { bytes, type, name });
}
async list(): Promise<StoredFont[]> {
@@ -19,11 +19,12 @@ export class HttpFontStore implements FontStore {
private async request<T>(
method: string,
route: string,
payload?: { bytes: Blob | ArrayBuffer; type: string },
payload?: { bytes: Blob | ArrayBuffer; type: string; name: string },
): Promise<T> {
return apiRequest<T>(this.endpoint, method, route, {
headers: payload ? { 'Content-Type': payload.type } : {},
body: payload?.bytes,
query: payload ? { name: payload.name } : undefined,
});
}
}
+4 -3
View File
@@ -4,8 +4,8 @@ import type { StoredTexture, TextureStore, UploadedTexture } from './TextureStor
export class HttpTextureStore implements TextureStore {
constructor(private readonly endpoint: string) {}
async upload(bytes: Blob | ArrayBuffer, type: string): Promise<UploadedTexture> {
return this.request<UploadedTexture>('POST', 'textures', { bytes, type });
async upload(bytes: Blob | ArrayBuffer, type: string, name = ''): Promise<UploadedTexture> {
return this.request<UploadedTexture>('POST', 'textures', { bytes, type, name });
}
async list(): Promise<StoredTexture[]> {
@@ -19,11 +19,12 @@ export class HttpTextureStore implements TextureStore {
private async request<T>(
method: string,
route: string,
payload?: { bytes: Blob | ArrayBuffer; type: string },
payload?: { bytes: Blob | ArrayBuffer; type: string; name: string },
): Promise<T> {
return apiRequest<T>(this.endpoint, method, route, {
headers: payload ? { 'Content-Type': payload.type } : {},
body: payload?.bytes,
query: payload ? { name: payload.name } : undefined,
});
}
}
+9 -2
View File
@@ -1,6 +1,11 @@
export interface UploadedTexture {
/** Content-addressed name in `data/textures/`: `{sha256}.{png|jpg|webp|gif}` (see `isTextureFile`). */
file: string;
/**
* The name the file was uploaded under, '' when the server knows none. A picture may be
* re-encoded on the way, so this can be a `.png` name on a file stored as `.webp`.
*/
name: string;
width: number;
height: number;
bytes: number;
@@ -9,6 +14,7 @@ export interface UploadedTexture {
/** A file the server holds, whether any project references it or not. */
export interface StoredTexture {
file: string;
name: string;
/** 0 × 0 when the server could not read the header; such a file cannot become an asset. */
width: number;
height: number;
@@ -23,9 +29,10 @@ export interface StoredTexture {
export interface TextureStore {
/**
* `type` only fills the Content-Type header – the server trusts the bytes, not the label – and
* uploading the same picture twice yields the same file, the hash of its content.
* uploading the same picture twice yields the same file, the hash of its content. `name` is the
* caption kept beside it; uploading the same bytes under a new name renames what pickers show.
*/
upload(bytes: Blob | ArrayBuffer, type: string): Promise<UploadedTexture>;
upload(bytes: Blob | ArrayBuffer, type: string, name?: string): Promise<UploadedTexture>;
list(): Promise<StoredTexture[]>;
delete(file: string): Promise<void>;
}
+10 -3
View File
@@ -20,7 +20,9 @@ export function statusMessage(status: number, statusText: string): string {
/**
* One call against the PHP API. Routing goes through the `r` query parameter because the site's
* nginx config does not pass PATH_INFO to PHP.
* nginx config does not pass PATH_INFO to PHP; `query` adds further parameters beside it, which is
* how an upload sends its file name while the body stays the raw bytes. An empty value is dropped
* rather than sent as `&name=`.
*
* `mapError` lets a store turn a particular status into its own error type before the generic
* one is thrown.
@@ -29,10 +31,15 @@ export async function apiRequest<T>(
endpoint: string,
method: string,
route: string,
init: Pick<RequestInit, 'headers' | 'body'> = {},
init: Pick<RequestInit, 'headers' | 'body'> & { query?: Record<string, string> } = {},
mapError?: (status: number, data: unknown) => Error | undefined,
): Promise<T> {
const response = await fetch(`${endpoint}?r=${encodeURIComponent(route)}`, { method, ...init });
const { query, ...request } = init;
let url = `${endpoint}?r=${encodeURIComponent(route)}`;
for (const [key, value] of Object.entries(query ?? {})) {
if (value !== '') url += `&${key}=${encodeURIComponent(value)}`;
}
const response = await fetch(url, { method, ...request });
if (response.status === 204) return undefined as T;
const text = await response.text();
+11 -3
View File
@@ -90,6 +90,11 @@ class FontList extends Widget {
return this.items.filter((item) => item.assetName !== null).map((item) => item.file);
}
/** The captions as drawn, read off the rows themselves rather than recomputed (test hook). */
get captions(): string[] {
return this.rows.map((row) => row.caption.text);
}
select(file: string | null): void {
if (file === this.selectedFile) return;
this.selectedFile = file;
@@ -134,9 +139,11 @@ class FontList extends Widget {
node.sample.text = truncate(SAMPLE_TEXT, node.sample.style, this.w - ROW_PAD * 2);
node.sample.position.set(ROW_PAD, ROW_PAD - 2);
// The asset name wins where there is one – it is what the project calls this face. Otherwise
// the name it was uploaded under, which is all a bare hash leaves to go on.
node.caption.style = textStyle(item.assetName ? 'accent' : 'muted');
node.caption.text = truncate(
item.assetName ?? formatBytes(item.bytes),
item.assetName ?? (item.name || formatBytes(item.bytes)),
node.caption.style,
this.w - ROW_PAD * 2,
);
@@ -210,10 +217,11 @@ export class FontPicker extends Popup {
}
/** What the dialog offers, which of it the project already wears, and what is picked (test hook). */
debugInfo(): { files: string[]; used: string[]; selected: string | null } {
debugInfo(): { files: string[]; used: string[]; captions: string[]; selected: string | null } {
return {
files: this.list.fileList,
used: this.list.usedFiles,
captions: this.list.captions,
selected: this.list.selected?.file ?? null,
};
}
@@ -266,7 +274,7 @@ export class FontPicker extends Popup {
}
function describe(item: PickerItem): string {
const size = formatBytes(item.bytes);
const size = item.name ? `${item.name} – ${formatBytes(item.bytes)}` : formatBytes(item.bytes);
return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size;
}
+5 -9
View File
@@ -3,7 +3,7 @@ import { nextAssetName } from '../core/commands/naming';
import type { FontAsset } from '../core/model/Asset';
import type { FontStore, UploadedFont } from '../core/store/FontStore';
import type { EditorState } from './state';
import { type ImportOutcome, importNotice } from './textureImport';
import { type ImportOutcome, assetNameForFile, importNotice } from './textureImport';
/** What the file dialog offers and a drop accepts; the server rejects everything else anyway. */
export const FONT_ACCEPT = '.ttf,.otf,.woff,.woff2';
@@ -39,8 +39,8 @@ async function importFont(file: File, deps: FontImportDeps): Promise<ImportOutco
try {
if (!isFontFileName(file.name)) throw new Error('Not a font file – use TTF, OTF, WOFF or WOFF2.');
// The bytes go up untouched: unlike a picture, a typeface cannot be re-encoded smaller here.
const uploaded = await deps.fonts.upload(file, file.type || 'application/octet-stream');
return adopt(uploaded, assetNameFor(file), file.name, deps);
const uploaded = await deps.fonts.upload(file, file.type || 'application/octet-stream', file.name);
return adopt(uploaded, assetNameForFile(file.name, 'Font'), file.name, deps);
} catch (error) {
return {
name: file.name,
@@ -69,7 +69,8 @@ function adopt(font: UploadedFont, nameBase: string, label: string, deps: FontIm
* picker, so this is "+ → Font" and the asset is selected the way a created one would be.
*/
export function importStoredFont(font: UploadedFont, deps: FontImportDeps): ImportOutcome {
const outcome = adopt(font, 'Font', font.file, deps);
// Named after the file it was uploaded as, like a dropped one; "Font" when the server knows none.
const outcome = adopt(font, assetNameForFile(font.name, 'Font'), font.name || font.file, deps);
if (outcome.assetId) deps.state.select({ kind: 'asset', assetId: outcome.assetId });
deps.state.setNotice(
outcome.error ??
@@ -78,11 +79,6 @@ export function importStoredFont(font: UploadedFont, deps: FontImportDeps): Impo
return outcome;
}
/** The file's own name without its extension: "Roboto-Bold.ttf" reads better than "Font 3". */
function assetNameFor(file: File): string {
return file.name.replace(/\.[^.]+$/, '').trim() || 'Font';
}
/** Opens the browser's file dialog; resolves empty when the dialog was cancelled. */
export function pickFontFiles(): Promise<File[]> {
return new Promise((resolve) => {
+13 -4
View File
@@ -81,6 +81,11 @@ class ImageGrid extends Widget {
return this.items.filter((item) => item.assetName !== null).map((item) => item.file);
}
/** The captions as drawn, read off the tiles themselves rather than recomputed (test hook). */
get captions(): string[] {
return this.tiles.map((tile) => tile.caption.text);
}
select(file: string | null): void {
if (file === this.selectedFile) return;
this.selectedFile = file;
@@ -139,8 +144,10 @@ class ImageGrid extends Widget {
node.sprite.height = fitted.h;
}
// The asset name is the useful caption where there is one; a bare hash tells nobody anything.
const text = item.width > 0 ? (item.assetName ?? `${item.width} × ${item.height}`) : 'unreadable';
// The asset name is the useful caption where there is one, then the name the file was
// uploaded under; a bare hash tells nobody anything, and the size is the last resort.
const text =
item.width > 0 ? (item.assetName ?? (item.name || `${item.width} × ${item.height}`)) : 'unreadable';
node.caption.style = textStyle(item.assetName ? 'accent' : 'muted');
node.caption.text = truncate(text, node.caption.style, side);
node.caption.position.set(0, side + 1);
@@ -210,10 +217,11 @@ export class ImagePicker extends Popup {
}
/** What the dialog offers, which of it the project already wears, and what is picked (test hook). */
debugInfo(): { files: string[]; used: string[]; selected: string | null } {
debugInfo(): { files: string[]; used: string[]; captions: string[]; selected: string | null } {
return {
files: this.grid.fileList,
used: this.grid.usedFiles,
captions: this.grid.captions,
selected: this.grid.selected?.file ?? null,
};
}
@@ -270,7 +278,8 @@ export class ImagePicker extends Popup {
function describe(item: PickerItem): string {
if (item.width <= 0) return 'The server cannot read this file as an image.';
const size = `${item.width} × ${item.height} px, ${formatBytes(item.bytes)}`;
const measurements = `${item.width} × ${item.height} px, ${formatBytes(item.bytes)}`;
const size = item.name ? `${item.name} – ${measurements}` : measurements;
return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size;
}
+11 -5
View File
@@ -119,8 +119,8 @@ export async function importImages(
async function importImage(file: File, deps: TextureImportDeps): Promise<ImportOutcome> {
try {
const image = await prepareImage(file);
const uploaded = await deps.textures.upload(image.blob, image.type);
return adopt(uploaded, assetNameFor(file), file.name, deps);
const uploaded = await deps.textures.upload(image.blob, image.type, file.name);
return adopt(uploaded, assetNameForFile(file.name, 'Texture'), file.name, deps);
} catch (error) {
return { name: file.name, assetId: null, error: error instanceof Error ? error.message : String(error) };
}
@@ -155,7 +155,9 @@ function adopt(
* unreferenced files, so a picture one project dropped is still there for the next to pick up.
*/
export function importStoredImage(image: UploadedTexture, deps: TextureImportDeps): ImportOutcome {
const outcome = adopt(image, 'Texture', image.file, deps);
// The name it was uploaded under is what the asset is called, the same as for a dropped file;
// a file from before names were kept has none, and falls back to "Texture" as it always did.
const outcome = adopt(image, assetNameForFile(image.name, 'Texture'), image.name || image.file, deps);
// Chosen in the picker, so this is "+ → Texture": the tab opens like for any created asset. A
// pasted or dropped picture (`importImages`) only selects – it usually goes into the scene.
if (outcome.assetId) deps.state.openAsset(outcome.assetId);
@@ -198,8 +200,12 @@ export function importNotice(outcomes: readonly ImportOutcome[], noun = 'image')
return `Imported ${added + reused} of ${outcomes.length}.${known} ${problem}`;
}
function assetNameFor(file: File): string {
return file.name.replace(/\.[^.]+$/, '').trim() || 'Texture';
/**
* A file name without its extension, as an asset is called: "hero-idle.png" reads better than
* "Texture 3". Shared with the picker, which 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. */