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:
1 parent
86b5363a01
commit
99c5d2ad78
21 files changed
+347
-61
No files matched your search
+12
-8
@@ -13,19 +13,23 @@ declare(strict_types=1);
|
|||||||
* PUT ?r=projects/{id} {revision, project} → {revision,updatedAt} | 409 {error,revision}
|
* PUT ?r=projects/{id} {revision, project} → {revision,updatedAt} | 409 {error,revision}
|
||||||
* DELETE ?r=projects/{id} → 204
|
* DELETE ?r=projects/{id} → 204
|
||||||
*
|
*
|
||||||
* GET ?r=textures → [{file,width,height,bytes,updatedAt}]
|
* GET ?r=textures → [{file,name,width,height,bytes,updatedAt}]
|
||||||
* POST ?r=textures raw image bytes → {file,width,height,bytes}
|
* POST ?r=textures&name=x.png raw image bytes → {file,name,width,height,bytes}
|
||||||
* DELETE ?r=textures/{file} → 204
|
* DELETE ?r=textures/{file} → 204
|
||||||
*
|
*
|
||||||
* GET ?r=fonts → [{file,bytes,updatedAt}]
|
* GET ?r=fonts → [{file,name,bytes,updatedAt}]
|
||||||
* POST ?r=fonts raw font bytes → {file,bytes}
|
* POST ?r=fonts&name=x.ttf raw font bytes → {file,name,bytes}
|
||||||
* DELETE ?r=fonts/{file} → 204
|
* DELETE ?r=fonts/{file} → 204
|
||||||
|
*
|
||||||
|
* `name` is the file's original name, kept beside it as `{file}.json` (see OriginalName) and
|
||||||
|
* empty when none is known. It is a caption, never a path: the stored file is always `{hash}.{ext}`.
|
||||||
*
|
*
|
||||||
* Stored images and fonts are served straight from `data/textures/` and `data/fonts/` as
|
* Stored images and fonts are served straight from `data/textures/` and `data/fonts/` as
|
||||||
* static files; the API only writes them.
|
* static files; the API only writes them.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require __DIR__ . '/lib/FontStore.php';
|
require __DIR__ . '/lib/FontStore.php';
|
||||||
|
require __DIR__ . '/lib/OriginalName.php';
|
||||||
require __DIR__ . '/lib/Response.php';
|
require __DIR__ . '/lib/Response.php';
|
||||||
require __DIR__ . '/lib/Store.php';
|
require __DIR__ . '/lib/Store.php';
|
||||||
require __DIR__ . '/lib/TextureStore.php';
|
require __DIR__ . '/lib/TextureStore.php';
|
||||||
@@ -116,7 +120,7 @@ function routeTextures(array $parts, string $method): never
|
|||||||
Response::error('Image must be at most ' . (MAX_TEXTURE_BYTES / 1024 / 1024) . ' MB', 413);
|
Response::error('Image must be at most ' . (MAX_TEXTURE_BYTES / 1024 / 1024) . ' MB', 413);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
Response::json($store->store($bytes), 201);
|
Response::json($store->store($bytes, (string) ($_GET['name'] ?? '')), 201);
|
||||||
} catch (InvalidImageException $e) {
|
} catch (InvalidImageException $e) {
|
||||||
Response::error($e->getMessage(), 422);
|
Response::error($e->getMessage(), 422);
|
||||||
}
|
}
|
||||||
@@ -149,7 +153,7 @@ function routeFonts(array $parts, string $method): never
|
|||||||
Response::error('Font must be at most ' . (MAX_FONT_BYTES / 1024 / 1024) . ' MB', 413);
|
Response::error('Font must be at most ' . (MAX_FONT_BYTES / 1024 / 1024) . ' MB', 413);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
Response::json($store->store($bytes), 201);
|
Response::json($store->store($bytes, (string) ($_GET['name'] ?? '')), 201);
|
||||||
} catch (InvalidFontException $e) {
|
} catch (InvalidFontException $e) {
|
||||||
Response::error($e->getMessage(), 422);
|
Response::error($e->getMessage(), 422);
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-5
@@ -46,10 +46,13 @@ final class FontStore
|
|||||||
* libmagic versions (`font/sfnt`, `application/vnd.ms-opentype`, plain `application/octet-stream`),
|
* libmagic versions (`font/sfnt`, `application/vnd.ms-opentype`, plain `application/octet-stream`),
|
||||||
* so a second opinion from it would reject valid files rather than catch bad ones.
|
* so a second opinion from it would reject valid files rather than catch bad ones.
|
||||||
*
|
*
|
||||||
* @return array{file:string,bytes:int}
|
* `$originalName` is kept beside the file as a caption (see OriginalName): the hash says
|
||||||
|
* nothing, and the name inside the font is deliberately never read (see `gm-font-` families).
|
||||||
|
*
|
||||||
|
* @return array{file:string,name:string,bytes:int}
|
||||||
* @throws InvalidFontException when the bytes are not a supported font
|
* @throws InvalidFontException when the bytes are not a supported font
|
||||||
*/
|
*/
|
||||||
public function store(string $bytes): array
|
public function store(string $bytes, string $originalName = ''): array
|
||||||
{
|
{
|
||||||
$extension = self::SIGNATURES[substr($bytes, 0, 4)] ?? '';
|
$extension = self::SIGNATURES[substr($bytes, 0, 4)] ?? '';
|
||||||
if ($extension === '') {
|
if ($extension === '') {
|
||||||
@@ -65,26 +68,29 @@ final class FontStore
|
|||||||
if (!is_file($path)) {
|
if (!is_file($path)) {
|
||||||
$this->writeFile($path, $bytes);
|
$this->writeFile($path, $bytes);
|
||||||
}
|
}
|
||||||
|
OriginalName::write($this->dir, $file, $originalName);
|
||||||
|
|
||||||
return ['file' => $file, 'bytes' => strlen($bytes)];
|
return ['file' => $file, 'name' => OriginalName::read($this->dir, $file), 'bytes' => strlen($bytes)];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every stored file, newest first – what the font picker offers. Unlike an image there is
|
* Every stored file, newest first – what the font picker offers. Unlike an image there is
|
||||||
* nothing to read out of the header here: the picker shows each face by rendering with it.
|
* nothing to read out of the header here: the picker shows each face by rendering with it.
|
||||||
*
|
*
|
||||||
* @return list<array{file:string,bytes:int,updatedAt:string}>
|
* @return list<array{file:string,name:string,bytes:int,updatedAt:string}>
|
||||||
*/
|
*/
|
||||||
public function list(): array
|
public function list(): array
|
||||||
{
|
{
|
||||||
$items = [];
|
$items = [];
|
||||||
foreach (glob($this->dir . '/*.*') ?: [] as $path) {
|
foreach (glob($this->dir . '/*.*') ?: [] as $path) {
|
||||||
$file = basename($path);
|
$file = basename($path);
|
||||||
|
// Sidecars share the directory and are skipped: `{hash}.ttf.json` is not a valid name.
|
||||||
if (!self::isValidName($file)) {
|
if (!self::isValidName($file)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'file' => $file,
|
'file' => $file,
|
||||||
|
'name' => OriginalName::read($this->dir, $file),
|
||||||
'bytes' => (int) filesize($path),
|
'bytes' => (int) filesize($path),
|
||||||
'updatedAt' => gmdate('c', (int) filemtime($path)),
|
'updatedAt' => gmdate('c', (int) filemtime($path)),
|
||||||
];
|
];
|
||||||
@@ -99,7 +105,11 @@ final class FontStore
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$path = $this->dir . '/' . $file;
|
$path = $this->dir . '/' . $file;
|
||||||
return is_file($path) && unlink($path);
|
if (!is_file($path) || !unlink($path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
OriginalName::delete($this->dir, $file);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Temp file plus rename, so a reader never sees a half-written font (see TextureStore). */
|
/** Temp file plus rename, so a reader never sees a half-written font (see TextureStore). */
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name a file had when it was uploaded, kept beside it as `{file}.json`.
|
||||||
|
*
|
||||||
|
* A stored file is named after the hash of its bytes, which says nothing to a human: the image
|
||||||
|
* picker could only caption a file it already knew as an asset, and a picked one became "Texture 2".
|
||||||
|
* The sidecar gives every file a caption and every asset made from it a sensible name.
|
||||||
|
*
|
||||||
|
* It is deliberately not part of the file itself and never authoritative: a missing, unreadable or
|
||||||
|
* empty sidecar simply means "no name known", which is what every file uploaded before this
|
||||||
|
* existed looks like.
|
||||||
|
*
|
||||||
|
* **The last upload wins** (user's decision 2026-09-13): re-uploading the same bytes under a new
|
||||||
|
* name renames what the picker shows. An upload that carries no name at all (a pasted image has
|
||||||
|
* none) leaves an existing sidecar alone rather than erasing it.
|
||||||
|
*/
|
||||||
|
final class OriginalName
|
||||||
|
{
|
||||||
|
/** Long enough for any real file name; the cap is against a crafted one, not against users. */
|
||||||
|
private const MAX_LENGTH = 120;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name as it is safe to store: no path, no control characters, valid UTF-8, bounded.
|
||||||
|
* Returns '' for anything that is left with nothing – the caller then stores no sidecar.
|
||||||
|
*/
|
||||||
|
public static function sanitize(string $raw): string
|
||||||
|
{
|
||||||
|
// Checked first: the /u flag below makes preg_replace return null on invalid bytes, which
|
||||||
|
// would turn "bad encoding" into "empty" by accident rather than on purpose.
|
||||||
|
if (!mb_check_encoding($raw, 'UTF-8')) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
// A Windows client sends backslashes, which basename() does not treat as separators here.
|
||||||
|
$name = basename(str_replace('\\', '/', $raw));
|
||||||
|
$name = trim((string) preg_replace('/[\x00-\x1F\x7F]/u', '', $name));
|
||||||
|
if ($name === '' || $name === '.' || $name === '..') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return mb_strlen($name) > self::MAX_LENGTH ? mb_substr($name, 0, self::MAX_LENGTH) : $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stored name, or '' when nothing is known about this file – which is the normal case for
|
||||||
|
* everything uploaded before sidecars existed.
|
||||||
|
*
|
||||||
|
* The absence is checked rather than suppressed with `@`: index.php installs an error handler
|
||||||
|
* that turns every warning into an exception, and it does not consult `error_reporting()`, so
|
||||||
|
* `@` does not silence it. A missing sidecar would otherwise fail the whole listing.
|
||||||
|
*/
|
||||||
|
public static function read(string $dir, string $file): string
|
||||||
|
{
|
||||||
|
$path = $dir . '/' . $file . '.json';
|
||||||
|
if (!is_file($path)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$raw = file_get_contents($path);
|
||||||
|
if ($raw === false) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
return is_array($data) && is_string($data['name'] ?? null) ? self::sanitize($data['name']) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes the sidecar; an empty name writes nothing and removes nothing. */
|
||||||
|
public static function write(string $dir, string $file, string $name): void
|
||||||
|
{
|
||||||
|
$clean = self::sanitize($name);
|
||||||
|
if ($clean === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$path = $dir . '/' . $file . '.json';
|
||||||
|
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
|
||||||
|
$json = json_encode(['name' => $clean], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($json === false || file_put_contents($tmp, $json, LOCK_EX) === false || !rename($tmp, $path)) {
|
||||||
|
if (is_file($tmp)) {
|
||||||
|
unlink($tmp);
|
||||||
|
}
|
||||||
|
return; // A nameless file is a small loss; failing the upload over it would be worse.
|
||||||
|
}
|
||||||
|
chmod($path, 0664);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removing a file that has no sidecar is the normal case, not a failure. */
|
||||||
|
public static function delete(string $dir, string $file): void
|
||||||
|
{
|
||||||
|
$path = $dir . '/' . $file . '.json';
|
||||||
|
if (is_file($path)) {
|
||||||
|
unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,10 +57,14 @@ final class TextureStore
|
|||||||
* The extension is derived from the sniffed content, never from anything the client
|
* The extension is derived from the sniffed content, never from anything the client
|
||||||
* says, so no request can choose the name or the type of the file it creates.
|
* says, so no request can choose the name or the type of the file it creates.
|
||||||
*
|
*
|
||||||
* @return array{file:string,width:int,height:int,bytes:int}
|
* `$originalName` is kept beside the file as a caption (see OriginalName) – the picture may be
|
||||||
|
* re-encoded on the way, so it can name a `.png` that is stored as `.webp`. That is the point:
|
||||||
|
* it records where the file came from, not what it now is.
|
||||||
|
*
|
||||||
|
* @return array{file:string,name:string,width:int,height:int,bytes:int}
|
||||||
* @throws InvalidImageException when the bytes are not a supported image
|
* @throws InvalidImageException when the bytes are not a supported image
|
||||||
*/
|
*/
|
||||||
public function store(string $bytes): array
|
public function store(string $bytes, string $originalName = ''): array
|
||||||
{
|
{
|
||||||
$size = @getimagesizefromstring($bytes);
|
$size = @getimagesizefromstring($bytes);
|
||||||
if ($size === false) {
|
if ($size === false) {
|
||||||
@@ -88,8 +92,15 @@ final class TextureStore
|
|||||||
if (!is_file($path)) {
|
if (!is_file($path)) {
|
||||||
$this->writeFile($path, $bytes);
|
$this->writeFile($path, $bytes);
|
||||||
}
|
}
|
||||||
|
OriginalName::write($this->dir, $file, $originalName);
|
||||||
|
|
||||||
return ['file' => $file, 'width' => $width, 'height' => $height, 'bytes' => strlen($bytes)];
|
return [
|
||||||
|
'file' => $file,
|
||||||
|
'name' => OriginalName::read($this->dir, $file),
|
||||||
|
'width' => $width,
|
||||||
|
'height' => $height,
|
||||||
|
'bytes' => strlen($bytes),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,19 +110,22 @@ final class TextureStore
|
|||||||
* records the size, so a picker that only knew the file name could not create one without
|
* records the size, so a picker that only knew the file name could not create one without
|
||||||
* decoding the picture first. An unreadable file reports 0 × 0 and the picker skips it.
|
* decoding the picture first. An unreadable file reports 0 × 0 and the picker skips it.
|
||||||
*
|
*
|
||||||
* @return list<array{file:string,width:int,height:int,bytes:int,updatedAt:string}>
|
* @return list<array{file:string,name:string,width:int,height:int,bytes:int,updatedAt:string}>
|
||||||
*/
|
*/
|
||||||
public function list(): array
|
public function list(): array
|
||||||
{
|
{
|
||||||
$items = [];
|
$items = [];
|
||||||
foreach (glob($this->dir . '/*.*') ?: [] as $path) {
|
foreach (glob($this->dir . '/*.*') ?: [] as $path) {
|
||||||
$file = basename($path);
|
$file = basename($path);
|
||||||
|
// Sidecars live in the same directory and are skipped here: `{hash}.png.json` is not
|
||||||
|
// a valid stored name.
|
||||||
if (!self::isValidName($file)) {
|
if (!self::isValidName($file)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$size = @getimagesize($path);
|
$size = @getimagesize($path);
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'file' => $file,
|
'file' => $file,
|
||||||
|
'name' => OriginalName::read($this->dir, $file),
|
||||||
'width' => (int) ($size[0] ?? 0),
|
'width' => (int) ($size[0] ?? 0),
|
||||||
'height' => (int) ($size[1] ?? 0),
|
'height' => (int) ($size[1] ?? 0),
|
||||||
'bytes' => (int) filesize($path),
|
'bytes' => (int) filesize($path),
|
||||||
@@ -128,7 +142,11 @@ final class TextureStore
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
$path = $this->dir . '/' . $file;
|
$path = $this->dir . '/' . $file;
|
||||||
return is_file($path) && unlink($path);
|
if (!is_file($path) || !unlink($path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
OriginalName::delete($this->dir, $file);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -81,9 +81,24 @@ export interface TextureImage {
|
|||||||
height: number;
|
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 {
|
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: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
export interface UploadedFont {
|
export interface UploadedFont {
|
||||||
/** Content-addressed name in `data/fonts/`: `{sha256}.{ttf|otf|woff|woff2}` (see `isFontFile`). */
|
/** Content-addressed name in `data/fonts/`: `{sha256}.{ttf|otf|woff|woff2}` (see `isFontFile`). */
|
||||||
file: string;
|
file: string;
|
||||||
|
/** The name the file was uploaded under, '' when the server knows none. Never a path. */
|
||||||
|
name: string;
|
||||||
bytes: number;
|
bytes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A typeface the server holds, whether any project references it or not. */
|
/** A typeface the server holds, whether any project references it or not. */
|
||||||
export interface StoredFont {
|
export interface StoredFont {
|
||||||
file: string;
|
file: string;
|
||||||
|
name: string;
|
||||||
bytes: number;
|
bytes: number;
|
||||||
updatedAt: string;
|
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.
|
* an image a font is never re-encoded on the way, so what the server stores is what was picked.
|
||||||
*/
|
*/
|
||||||
export interface FontStore {
|
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[]>;
|
list(): Promise<StoredFont[]>;
|
||||||
delete(file: string): Promise<void>;
|
delete(file: string): Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -4,8 +4,8 @@ import { apiRequest } from './http';
|
|||||||
export class HttpFontStore implements FontStore {
|
export class HttpFontStore implements FontStore {
|
||||||
constructor(private readonly endpoint: string) {}
|
constructor(private readonly endpoint: string) {}
|
||||||
|
|
||||||
async upload(bytes: Blob | ArrayBuffer, type: string): Promise<UploadedFont> {
|
async upload(bytes: Blob | ArrayBuffer, type: string, name = ''): Promise<UploadedFont> {
|
||||||
return this.request<UploadedFont>('POST', 'fonts', { bytes, type });
|
return this.request<UploadedFont>('POST', 'fonts', { bytes, type, name });
|
||||||
}
|
}
|
||||||
|
|
||||||
async list(): Promise<StoredFont[]> {
|
async list(): Promise<StoredFont[]> {
|
||||||
@@ -19,11 +19,12 @@ export class HttpFontStore implements FontStore {
|
|||||||
private async request<T>(
|
private async request<T>(
|
||||||
method: string,
|
method: string,
|
||||||
route: string,
|
route: string,
|
||||||
payload?: { bytes: Blob | ArrayBuffer; type: string },
|
payload?: { bytes: Blob | ArrayBuffer; type: string; name: string },
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
return apiRequest<T>(this.endpoint, method, route, {
|
return apiRequest<T>(this.endpoint, method, route, {
|
||||||
headers: payload ? { 'Content-Type': payload.type } : {},
|
headers: payload ? { 'Content-Type': payload.type } : {},
|
||||||
body: payload?.bytes,
|
body: payload?.bytes,
|
||||||
|
query: payload ? { name: payload.name } : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,8 +4,8 @@ import type { StoredTexture, TextureStore, UploadedTexture } from './TextureStor
|
|||||||
export class HttpTextureStore implements TextureStore {
|
export class HttpTextureStore implements TextureStore {
|
||||||
constructor(private readonly endpoint: string) {}
|
constructor(private readonly endpoint: string) {}
|
||||||
|
|
||||||
async upload(bytes: Blob | ArrayBuffer, type: string): Promise<UploadedTexture> {
|
async upload(bytes: Blob | ArrayBuffer, type: string, name = ''): Promise<UploadedTexture> {
|
||||||
return this.request<UploadedTexture>('POST', 'textures', { bytes, type });
|
return this.request<UploadedTexture>('POST', 'textures', { bytes, type, name });
|
||||||
}
|
}
|
||||||
|
|
||||||
async list(): Promise<StoredTexture[]> {
|
async list(): Promise<StoredTexture[]> {
|
||||||
@@ -19,11 +19,12 @@ export class HttpTextureStore implements TextureStore {
|
|||||||
private async request<T>(
|
private async request<T>(
|
||||||
method: string,
|
method: string,
|
||||||
route: string,
|
route: string,
|
||||||
payload?: { bytes: Blob | ArrayBuffer; type: string },
|
payload?: { bytes: Blob | ArrayBuffer; type: string; name: string },
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
return apiRequest<T>(this.endpoint, method, route, {
|
return apiRequest<T>(this.endpoint, method, route, {
|
||||||
headers: payload ? { 'Content-Type': payload.type } : {},
|
headers: payload ? { 'Content-Type': payload.type } : {},
|
||||||
body: payload?.bytes,
|
body: payload?.bytes,
|
||||||
|
query: payload ? { name: payload.name } : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
export interface UploadedTexture {
|
export interface UploadedTexture {
|
||||||
/** Content-addressed name in `data/textures/`: `{sha256}.{png|jpg|webp|gif}` (see `isTextureFile`). */
|
/** Content-addressed name in `data/textures/`: `{sha256}.{png|jpg|webp|gif}` (see `isTextureFile`). */
|
||||||
file: string;
|
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;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
bytes: number;
|
bytes: number;
|
||||||
@@ -9,6 +14,7 @@ export interface UploadedTexture {
|
|||||||
/** A file the server holds, whether any project references it or not. */
|
/** A file the server holds, whether any project references it or not. */
|
||||||
export interface StoredTexture {
|
export interface StoredTexture {
|
||||||
file: string;
|
file: string;
|
||||||
|
name: string;
|
||||||
/** 0 × 0 when the server could not read the header; such a file cannot become an asset. */
|
/** 0 × 0 when the server could not read the header; such a file cannot become an asset. */
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
@@ -23,9 +29,10 @@ export interface StoredTexture {
|
|||||||
export interface TextureStore {
|
export interface TextureStore {
|
||||||
/**
|
/**
|
||||||
* `type` only fills the Content-Type header – the server trusts the bytes, not the label – and
|
* `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[]>;
|
list(): Promise<StoredTexture[]>;
|
||||||
delete(file: string): Promise<void>;
|
delete(file: string): Promise<void>;
|
||||||
}
|
}
|
||||||
+10
-3
@@ -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
|
* 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
|
* `mapError` lets a store turn a particular status into its own error type before the generic
|
||||||
* one is thrown.
|
* one is thrown.
|
||||||
@@ -29,10 +31,15 @@ export async function apiRequest<T>(
|
|||||||
endpoint: string,
|
endpoint: string,
|
||||||
method: string,
|
method: string,
|
||||||
route: string,
|
route: string,
|
||||||
init: Pick<RequestInit, 'headers' | 'body'> = {},
|
init: Pick<RequestInit, 'headers' | 'body'> & { query?: Record<string, string> } = {},
|
||||||
mapError?: (status: number, data: unknown) => Error | undefined,
|
mapError?: (status: number, data: unknown) => Error | undefined,
|
||||||
): Promise<T> {
|
): 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;
|
if (response.status === 204) return undefined as T;
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ class FontList extends Widget {
|
|||||||
return this.items.filter((item) => item.assetName !== null).map((item) => item.file);
|
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 {
|
select(file: string | null): void {
|
||||||
if (file === this.selectedFile) return;
|
if (file === this.selectedFile) return;
|
||||||
this.selectedFile = file;
|
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.text = truncate(SAMPLE_TEXT, node.sample.style, this.w - ROW_PAD * 2);
|
||||||
node.sample.position.set(ROW_PAD, 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.style = textStyle(item.assetName ? 'accent' : 'muted');
|
||||||
node.caption.text = truncate(
|
node.caption.text = truncate(
|
||||||
item.assetName ?? formatBytes(item.bytes),
|
item.assetName ?? (item.name || formatBytes(item.bytes)),
|
||||||
node.caption.style,
|
node.caption.style,
|
||||||
this.w - ROW_PAD * 2,
|
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). */
|
/** 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 {
|
return {
|
||||||
files: this.list.fileList,
|
files: this.list.fileList,
|
||||||
used: this.list.usedFiles,
|
used: this.list.usedFiles,
|
||||||
|
captions: this.list.captions,
|
||||||
selected: this.list.selected?.file ?? null,
|
selected: this.list.selected?.file ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -266,7 +274,7 @@ export class FontPicker extends Popup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function describe(item: PickerItem): string {
|
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;
|
return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { nextAssetName } from '../core/commands/naming';
|
|||||||
import type { FontAsset } from '../core/model/Asset';
|
import type { FontAsset } from '../core/model/Asset';
|
||||||
import type { FontStore, UploadedFont } from '../core/store/FontStore';
|
import type { FontStore, UploadedFont } from '../core/store/FontStore';
|
||||||
import type { EditorState } from './state';
|
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. */
|
/** What the file dialog offers and a drop accepts; the server rejects everything else anyway. */
|
||||||
export const FONT_ACCEPT = '.ttf,.otf,.woff,.woff2';
|
export const FONT_ACCEPT = '.ttf,.otf,.woff,.woff2';
|
||||||
@@ -39,8 +39,8 @@ async function importFont(file: File, deps: FontImportDeps): Promise<ImportOutco
|
|||||||
try {
|
try {
|
||||||
if (!isFontFileName(file.name)) throw new Error('Not a font file – use TTF, OTF, WOFF or WOFF2.');
|
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.
|
// 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');
|
const uploaded = await deps.fonts.upload(file, file.type || 'application/octet-stream', file.name);
|
||||||
return adopt(uploaded, assetNameFor(file), file.name, deps);
|
return adopt(uploaded, assetNameForFile(file.name, 'Font'), file.name, deps);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
name: file.name,
|
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.
|
* 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 {
|
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 });
|
if (outcome.assetId) deps.state.select({ kind: 'asset', assetId: outcome.assetId });
|
||||||
deps.state.setNotice(
|
deps.state.setNotice(
|
||||||
outcome.error ??
|
outcome.error ??
|
||||||
@@ -78,11 +79,6 @@ export function importStoredFont(font: UploadedFont, deps: FontImportDeps): Impo
|
|||||||
return outcome;
|
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. */
|
/** Opens the browser's file dialog; resolves empty when the dialog was cancelled. */
|
||||||
export function pickFontFiles(): Promise<File[]> {
|
export function pickFontFiles(): Promise<File[]> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
|||||||
@@ -81,6 +81,11 @@ class ImageGrid extends Widget {
|
|||||||
return this.items.filter((item) => item.assetName !== null).map((item) => item.file);
|
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 {
|
select(file: string | null): void {
|
||||||
if (file === this.selectedFile) return;
|
if (file === this.selectedFile) return;
|
||||||
this.selectedFile = file;
|
this.selectedFile = file;
|
||||||
@@ -139,8 +144,10 @@ class ImageGrid extends Widget {
|
|||||||
node.sprite.height = fitted.h;
|
node.sprite.height = fitted.h;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The asset name is the useful caption where there is one; a bare hash tells nobody anything.
|
// The asset name is the useful caption where there is one, then the name the file was
|
||||||
const text = item.width > 0 ? (item.assetName ?? `${item.width} × ${item.height}`) : 'unreadable';
|
// 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.style = textStyle(item.assetName ? 'accent' : 'muted');
|
||||||
node.caption.text = truncate(text, node.caption.style, side);
|
node.caption.text = truncate(text, node.caption.style, side);
|
||||||
node.caption.position.set(0, side + 1);
|
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). */
|
/** 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 {
|
return {
|
||||||
files: this.grid.fileList,
|
files: this.grid.fileList,
|
||||||
used: this.grid.usedFiles,
|
used: this.grid.usedFiles,
|
||||||
|
captions: this.grid.captions,
|
||||||
selected: this.grid.selected?.file ?? null,
|
selected: this.grid.selected?.file ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -270,7 +278,8 @@ export class ImagePicker extends Popup {
|
|||||||
|
|
||||||
function describe(item: PickerItem): string {
|
function describe(item: PickerItem): string {
|
||||||
if (item.width <= 0) return 'The server cannot read this file as an image.';
|
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;
|
return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,8 +119,8 @@ export async function importImages(
|
|||||||
async function importImage(file: File, deps: TextureImportDeps): Promise<ImportOutcome> {
|
async function importImage(file: File, deps: TextureImportDeps): Promise<ImportOutcome> {
|
||||||
try {
|
try {
|
||||||
const image = await prepareImage(file);
|
const image = await prepareImage(file);
|
||||||
const uploaded = await deps.textures.upload(image.blob, image.type);
|
const uploaded = await deps.textures.upload(image.blob, image.type, file.name);
|
||||||
return adopt(uploaded, assetNameFor(file), file.name, deps);
|
return adopt(uploaded, assetNameForFile(file.name, 'Texture'), file.name, deps);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { name: file.name, assetId: null, error: error instanceof Error ? error.message : String(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.
|
* 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 {
|
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
|
// 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.
|
// pasted or dropped picture (`importImages`) only selects – it usually goes into the scene.
|
||||||
if (outcome.assetId) deps.state.openAsset(outcome.assetId);
|
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}`;
|
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. */
|
/** Opens the browser's file dialog; resolves empty when the dialog was cancelled. */
|
||||||
|
|||||||
@@ -61,6 +61,20 @@ describe('HttpTextureStore', () => {
|
|||||||
expect(init?.headers).toEqual({ 'Content-Type': 'image/png' });
|
expect(init?.headers).toEqual({ 'Content-Type': 'image/png' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sends the file name beside the route, and omits it when there is none', async () => {
|
||||||
|
const answer = JSON.stringify({ file: FILE, name: 'hero idle.png', width: 2, height: 3, bytes: 4 });
|
||||||
|
const bytes = new Blob([new Uint8Array(4)]);
|
||||||
|
|
||||||
|
let mock = stubFetch(answer, { status: 201 });
|
||||||
|
await new HttpTextureStore('/api').upload(bytes, 'image/png', 'hero idle.png');
|
||||||
|
// The body stays the raw image, so the name travels in the query – encoded, not as a path.
|
||||||
|
expect(mock.mock.calls[0]![0]).toBe('/api?r=textures&name=hero%20idle.png');
|
||||||
|
|
||||||
|
mock = stubFetch(answer, { status: 201 });
|
||||||
|
await new HttpTextureStore('/api').upload(bytes, 'image/png');
|
||||||
|
expect(mock.mock.calls[0]![0]).toBe('/api?r=textures');
|
||||||
|
});
|
||||||
|
|
||||||
it('reports the nginx size limit as a size problem', async () => {
|
it('reports the nginx size limit as a size problem', async () => {
|
||||||
stubFetch(NGINX_413, { status: 413, statusText: 'Request Entity Too Large' });
|
stubFetch(NGINX_413, { status: 413, statusText: 'Request Entity Too Large' });
|
||||||
const bytes = new Blob([new Uint8Array(8)]);
|
const bytes = new Blob([new Uint8Array(8)]);
|
||||||
|
|||||||
@@ -24,6 +24,19 @@ describe('CreateTextureCommand', () => {
|
|||||||
expect(findAsset(project, command.asset.id)?.name).toBe('Hero');
|
expect(findAsset(project, command.asset.id)?.name).toBe('Hero');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the caller’s name even though the upload result carries one of its own', () => {
|
||||||
|
const { project, stack } = makeProject();
|
||||||
|
// What `TextureStore.upload` actually answers: the file's original name and its byte count
|
||||||
|
// ride along. Copied wholesale they would land in the asset and rename it behind the caller.
|
||||||
|
const upload = { file: FILE, name: 'hero-idle.png', width: 64, height: 32, bytes: 900 };
|
||||||
|
const command = new CreateTextureCommand('Hero', upload);
|
||||||
|
stack.execute(command);
|
||||||
|
|
||||||
|
const asset = findAsset(project, command.asset.id);
|
||||||
|
expect(asset?.name).toBe('Hero');
|
||||||
|
expect(asset).not.toHaveProperty('bytes');
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects a file name the server cannot have produced – in execute, not in the constructor', () => {
|
it('rejects a file name the server cannot have produced – in execute, not in the constructor', () => {
|
||||||
const { project, stack } = makeProject();
|
const { project, stack } = makeProject();
|
||||||
const command = new CreateTextureCommand('Hero', { file: '../secrets.png', width: 1, height: 1 });
|
const command = new CreateTextureCommand('Hero', { file: '../secrets.png', width: 1, height: 1 });
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { EditorState } from '../../src/editor/state';
|
|||||||
import { importNotice } from '../../src/editor/textureImport';
|
import { importNotice } from '../../src/editor/textureImport';
|
||||||
|
|
||||||
const FILE = `${'a'.repeat(64)}.ttf`;
|
const FILE = `${'a'.repeat(64)}.ttf`;
|
||||||
const font = { file: FILE, bytes: 380_660 };
|
const font = { file: FILE, name: '', bytes: 380_660 };
|
||||||
|
|
||||||
function setup(): EditorState {
|
function setup(): EditorState {
|
||||||
const state = new EditorState();
|
const state = new EditorState();
|
||||||
@@ -44,6 +44,16 @@ describe('importStoredFont', () => {
|
|||||||
expect(state.project.assets.filter((a) => a.type === 'font')).toEqual([]);
|
expect(state.project.assets.filter((a) => a.type === 'font')).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('names the asset after the file it was uploaded as, not "Font"', () => {
|
||||||
|
const state = setup();
|
||||||
|
importStoredFont({ ...font, name: 'DejaVuSerif.ttf' }, { state, fonts });
|
||||||
|
|
||||||
|
expect(state.project.assets.find((a): a is FontAsset => a.type === 'font')).toMatchObject({
|
||||||
|
file: FILE,
|
||||||
|
name: 'DejaVuSerif',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('reuses the asset that already names the file instead of making a second one', () => {
|
it('reuses the asset that already names the file instead of making a second one', () => {
|
||||||
const state = setup();
|
const state = setup();
|
||||||
const first = importStoredFont(font, { state, fonts });
|
const first = importStoredFont(font, { state, fonts });
|
||||||
@@ -66,7 +76,8 @@ describe('importFonts', () => {
|
|||||||
const [outcome] = await importFonts([file], { state, fonts });
|
const [outcome] = await importFonts([file], { state, fonts });
|
||||||
|
|
||||||
// The blob goes up as it came in: a typeface cannot be re-encoded smaller on the way.
|
// The blob goes up as it came in: a typeface cannot be re-encoded smaller on the way.
|
||||||
expect(upload).toHaveBeenCalledWith(file, 'font/ttf');
|
// The name travels with the bytes: it is all the server can caption the hash with.
|
||||||
|
expect(upload).toHaveBeenCalledWith(file, 'font/ttf', 'DejaVuSerif.ttf');
|
||||||
expect(outcome).toMatchObject({ error: null, reused: false });
|
expect(outcome).toMatchObject({ error: null, reused: false });
|
||||||
expect(state.project.assets.find((a): a is FontAsset => a.type === 'font')).toMatchObject({
|
expect(state.project.assets.find((a): a is FontAsset => a.type === 'font')).toMatchObject({
|
||||||
file: FILE,
|
file: FILE,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createEmptyProject } from '../../src/core/model/Project';
|
|||||||
import type { TextureStore } from '../../src/core/store/TextureStore';
|
import type { TextureStore } from '../../src/core/store/TextureStore';
|
||||||
import { EditorState } from '../../src/editor/state';
|
import { EditorState } from '../../src/editor/state';
|
||||||
import {
|
import {
|
||||||
|
assetNameForFile,
|
||||||
encodeWithinBudget,
|
encodeWithinBudget,
|
||||||
type ImportOutcome,
|
type ImportOutcome,
|
||||||
importNotice,
|
importNotice,
|
||||||
@@ -27,6 +28,18 @@ function reused(name: string): ImportOutcome {
|
|||||||
return { name, assetId: `id-${name}`, error: null, reused: true };
|
return { name, assetId: `id-${name}`, error: null, reused: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('assetNameForFile', () => {
|
||||||
|
it('drops the extension and falls back when nothing readable is left', () => {
|
||||||
|
expect(assetNameForFile('hero-idle.png', 'Texture')).toBe('hero-idle');
|
||||||
|
// Only the last extension goes: a name with dots in it keeps them.
|
||||||
|
expect(assetNameForFile('walk.cycle.left.png', 'Texture')).toBe('walk.cycle.left');
|
||||||
|
expect(assetNameForFile('README', 'Texture')).toBe('README');
|
||||||
|
expect(assetNameForFile('.png', 'Texture')).toBe('Texture');
|
||||||
|
// What a file the server knows no name for looks like.
|
||||||
|
expect(assetNameForFile('', 'Font')).toBe('Font');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('planImageFit', () => {
|
describe('planImageFit', () => {
|
||||||
it('leaves an image alone that fits in both senses', () => {
|
it('leaves an image alone that fits in both senses', () => {
|
||||||
expect(planImageFit(32, 32, 900)).toEqual({ kind: 'keep' });
|
expect(planImageFit(32, 32, 900)).toEqual({ kind: 'keep' });
|
||||||
@@ -114,7 +127,7 @@ describe('importNotice', () => {
|
|||||||
|
|
||||||
describe('importStoredImage', () => {
|
describe('importStoredImage', () => {
|
||||||
const FILE = `${'a'.repeat(64)}.png`;
|
const FILE = `${'a'.repeat(64)}.png`;
|
||||||
const image = { file: FILE, width: 64, height: 48, bytes: 900 };
|
const image = { file: FILE, name: '', width: 64, height: 48, bytes: 900 };
|
||||||
/** The picker takes the image from the list; nothing here ever uploads. */
|
/** The picker takes the image from the list; nothing here ever uploads. */
|
||||||
const textures = { upload: vi.fn(), list: vi.fn(), delete: vi.fn() } as unknown as TextureStore;
|
const textures = { upload: vi.fn(), list: vi.fn(), delete: vi.fn() } as unknown as TextureStore;
|
||||||
|
|
||||||
@@ -138,6 +151,16 @@ describe('importStoredImage', () => {
|
|||||||
expect(state.project.assets.filter((a) => a.type === 'texture')).toEqual([]);
|
expect(state.project.assets.filter((a) => a.type === 'texture')).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('names the asset after the file the picture was uploaded as, not "Texture"', () => {
|
||||||
|
const state = setup();
|
||||||
|
importStoredImage({ ...image, name: 'hero-idle.png' }, { state, textures });
|
||||||
|
|
||||||
|
expect(state.project.assets.find((a): a is TextureAsset => a.type === 'texture')).toMatchObject({
|
||||||
|
file: FILE,
|
||||||
|
name: 'hero-idle',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('reuses the asset that already names the file instead of making a second one', () => {
|
it('reuses the asset that already names the file instead of making a second one', () => {
|
||||||
const state = setup();
|
const state = setup();
|
||||||
const first = importStoredImage(image, { state, textures });
|
const first = importStoredImage(image, { state, textures });
|
||||||
|
|||||||
@@ -315,6 +315,14 @@ export async function textures(t) {
|
|||||||
(picker?.used ?? []).includes(texture.file) && !(picker?.used ?? []).includes(spare),
|
(picker?.used ?? []).includes(texture.file) && !(picker?.used ?? []).includes(spare),
|
||||||
JSON.stringify(picker?.used),
|
JSON.stringify(picker?.used),
|
||||||
);
|
);
|
||||||
|
// The caption: what the project calls a picture it wears, otherwise the name the file was
|
||||||
|
// uploaded under (#83). Before that a file nobody wore read as its pixel size and nothing else.
|
||||||
|
const captionOf = (file) => (picker?.captions ?? [])[(picker?.files ?? []).indexOf(file)];
|
||||||
|
check(
|
||||||
|
'an unused file is captioned by its original name, a used one by its asset name',
|
||||||
|
captionOf(spare) === 'spare.png' && captionOf(texture.file) === texture.name,
|
||||||
|
JSON.stringify({ spare: captionOf(spare), used: captionOf(texture.file), asset: texture.name }),
|
||||||
|
);
|
||||||
await shot('34-image-picker');
|
await shot('34-image-picker');
|
||||||
|
|
||||||
const tile = await json(`__gm.imagePickerTile(${JSON.stringify(spare)})`);
|
const tile = await json(`__gm.imagePickerTile(${JSON.stringify(spare)})`);
|
||||||
@@ -337,6 +345,29 @@ export async function textures(t) {
|
|||||||
adopted.length === 2 && adopted[1]?.file === spare && adopted[1]?.w === 24 && adopted[1]?.h === 24,
|
adopted.length === 2 && adopted[1]?.file === spare && adopted[1]?.w === 24 && adopted[1]?.h === 24,
|
||||||
JSON.stringify(adopted),
|
JSON.stringify(adopted),
|
||||||
);
|
);
|
||||||
|
check(
|
||||||
|
'and it is named after the file it was uploaded as, not "Texture"',
|
||||||
|
adopted[1]?.name === 'spare',
|
||||||
|
JSON.stringify(adopted.map((a) => a.name)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 12g. The name beside the file, straight against the API: uploading the same bytes again under
|
||||||
|
// a new name renames it (the user's rule), a path is stripped to its last part, and an
|
||||||
|
// upload carrying no name at all leaves the known one alone rather than erasing it.
|
||||||
|
const renamed = await evaluate(`(async () => {
|
||||||
|
const bytes = await fetch('/game-maker/data/textures/${spare}').then((r) => r.blob());
|
||||||
|
const post = (query) => fetch('/game-maker/api/index.php?r=textures' + query,
|
||||||
|
{ method: 'POST', headers: { 'Content-Type': 'image/png' }, body: bytes }).then((r) => r.json());
|
||||||
|
const evil = await post('&name=' + encodeURIComponent('../../evil/renamed.png'));
|
||||||
|
const nameless = await post('');
|
||||||
|
await post('&name=spare.png');
|
||||||
|
return { evil: evil.name, file: evil.file, nameless: nameless.name };
|
||||||
|
})()`);
|
||||||
|
check(
|
||||||
|
'a second upload renames the file, a path is stripped, and a nameless upload keeps the name',
|
||||||
|
renamed.evil === 'renamed.png' && renamed.file === spare && renamed.nameless === 'renamed.png',
|
||||||
|
JSON.stringify(renamed),
|
||||||
|
);
|
||||||
check(
|
check(
|
||||||
'and the notice says it was added rather than imported',
|
'and the notice says it was added rather than imported',
|
||||||
(await json('__gm.state.notice')) === 'Added the image to the project.',
|
(await json('__gm.state.notice')) === 'Added the image to the project.',
|
||||||
|
|||||||
+15
-2
@@ -56,6 +56,12 @@ export async function font(t) {
|
|||||||
JSON.stringify({ serif: serif?.file, mono: mono?.file, count: stored.length }),
|
JSON.stringify({ serif: serif?.file, mono: mono?.file, count: stored.length }),
|
||||||
);
|
);
|
||||||
if (!serif || !mono) return;
|
if (!serif || !mono) return;
|
||||||
|
// The hash says nothing, so the name the file was uploaded under is kept beside it (#83).
|
||||||
|
check(
|
||||||
|
'the listing carries the name each file was uploaded under',
|
||||||
|
serif.name === 'DejaVuSerif.ttf' && mono.name === 'DejaVuSansMono.ttf',
|
||||||
|
JSON.stringify({ serif: serif.name, mono: mono.name }),
|
||||||
|
);
|
||||||
|
|
||||||
// 30b. "+ → Font" offers them; a font is picked rather than created empty, like a texture.
|
// 30b. "+ → Font" offers them; a font is picked rather than created empty, like a texture.
|
||||||
await evaluate('__gm.console.clear()');
|
await evaluate('__gm.console.clear()');
|
||||||
@@ -69,6 +75,13 @@ export async function font(t) {
|
|||||||
Boolean(picker) && picker.files.includes(serif.file) && picker.used.length === 0,
|
Boolean(picker) && picker.files.includes(serif.file) && picker.used.length === 0,
|
||||||
JSON.stringify(picker),
|
JSON.stringify(picker),
|
||||||
);
|
);
|
||||||
|
// A row the project does not wear yet is captioned by the file's own name (#83); without it
|
||||||
|
// there was nothing to read but its byte count.
|
||||||
|
check(
|
||||||
|
'a row nobody wears yet is captioned with the name the file was uploaded under',
|
||||||
|
picker.captions[picker.files.indexOf(serif.file)] === 'DejaVuSerif.ttf',
|
||||||
|
JSON.stringify(picker.captions),
|
||||||
|
);
|
||||||
await shot('30-font-picker');
|
await shot('30-font-picker');
|
||||||
// A row is a container holding its background, not a Graphics with children: Pixi v8 warns
|
// A row is a container holding its background, not a Graphics with children: Pixi v8 warns
|
||||||
// about the latter, and the warning is the only sign that the rows are built the wrong way.
|
// about the latter, and the warning is the only sign that the rows are built the wrong way.
|
||||||
@@ -85,8 +98,8 @@ export async function font(t) {
|
|||||||
await sleep(400);
|
await sleep(400);
|
||||||
const fonts = await assetsOfType('font');
|
const fonts = await assetsOfType('font');
|
||||||
check(
|
check(
|
||||||
'picking a row makes a font asset of that file, and nothing else',
|
'picking a row makes a font asset of that file, named after the file rather than "Font"',
|
||||||
fonts.length === 1 && fonts[0].file === serif.file && fonts[0].name === 'Font',
|
fonts.length === 1 && fonts[0].file === serif.file && fonts[0].name === 'DejaVuSerif',
|
||||||
JSON.stringify(fonts),
|
JSON.stringify(fonts),
|
||||||
);
|
);
|
||||||
if (fonts.length !== 1) return;
|
if (fonts.length !== 1) return;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* All ids exist on the server (created via POST ?r=projects); PUT does not create projects.
|
* All ids exist on the server (created via POST ?r=projects); PUT does not create projects.
|
||||||
*/
|
*/
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { extname } from 'node:path';
|
import { basename, extname } from 'node:path';
|
||||||
|
|
||||||
export const PROJECTS = {
|
export const PROJECTS = {
|
||||||
playground: '649188c15414',
|
playground: '649188c15414',
|
||||||
@@ -91,7 +91,9 @@ export async function uploadFont(baseUrl, filePath) {
|
|||||||
console.error(`unsupported font type: ${filePath}`);
|
console.error(`unsupported font type: ${filePath}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const res = await fetch(`${baseUrl}api/index.php?r=fonts`, {
|
// The name rides along like a browser upload's: the picker captions the hash with it.
|
||||||
|
const name = encodeURIComponent(basename(filePath));
|
||||||
|
const res = await fetch(`${baseUrl}api/index.php?r=fonts&name=${name}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': type },
|
headers: { 'Content-Type': type },
|
||||||
body: bytes,
|
body: bytes,
|
||||||
@@ -102,7 +104,7 @@ export async function uploadFont(baseUrl, filePath) {
|
|||||||
console.error('font upload failed', res.status, result);
|
console.error('font upload failed', res.status, result);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
return { file: result.file };
|
return { file: result.file, name: result.name };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Overwrites a project, keeping the name it already has on the server. */
|
/** Overwrites a project, keeping the name it already has on the server. */
|
||||||
|
|||||||
Reference in new issue
Block a user