From 81a559b8801a5c7a774a6d435586c45c28e759a3 Mon Sep 17 00:00:00 2001 From: Michael Wallner Date: Thu, 27 Aug 2026 16:23:41 +0200 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=94=92=20Bound=20string=20length=20ev?= =?UTF-8?q?en=20when=20the=20value=20is=20blank?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Laravel skips every non-implicit rule when a string trims to nothing, so `string|min:1|max:4000` lets a 250 KB run of spaces through untouched. Adding `present` does not help, and a closure rule is not implicit either. BoundedString is marked implicit, so it runs on blank and absent values and does the length check itself. Co-Authored-By: Claude Opus 5 (1M context) --- app/Rules/BoundedString.php | 51 +++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 app/Rules/BoundedString.php diff --git a/app/Rules/BoundedString.php b/app/Rules/BoundedString.php new file mode 100644 index 00000000..c51635ee --- /dev/null +++ b/app/Rules/BoundedString.php @@ -0,0 +1,51 @@ +translate(); + + return; + } + + $length = mb_strlen($value); + + if ($length < $this->min) { + $fail('validation.min.string')->translate(['min' => $this->min]); + + return; + } + + if ($length > $this->max) { + $fail('validation.max.string')->translate(['max' => $this->max]); + } + } +} From 5b1ae5cbe9631355be830b6f7a8031571b55d21b Mon Sep 17 00:00:00 2001 From: Michael Wallner Date: Thu, 27 Aug 2026 16:23:50 +0200 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9C=A8=20Materialize=20a=20whole=20folde?= =?UTF-8?q?r=20path=20in=20one=20request?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensure-paths takes the folder paths of a dropped tree and returns a path to id map, so the browser resolves every folder in a single round trip instead of one create per folder. Existing folders are merged case-insensitively and in NFC, which makes a re-drop idempotent rather than piling up duplicates. Soft-deleted folders are ignored: a drop must not undelete. Segment names are truncated to the column length and fall back to a placeholder when purification empties them, and the response reports what it renamed. The work is bounded by total segments rather than path count, since one path could otherwise carry 2000 of them. A per-space lock serializes callers, measured to leave room under max_execution_time. Co-Authored-By: Claude Opus 5 (1M context) --- app/Actions/Asset/EnsureAssetFolderPaths.php | 209 +++++++++++ .../Mgmt/EnsureAssetFolderPathsController.php | 37 ++ .../Asset/EnsureAssetFolderPathsRequest.php | 104 ++++++ routes/private_mgmt.php | 3 + .../Mgmt/AssetFolderEnsurePathsTest.php | 348 ++++++++++++++++++ 5 files changed, 701 insertions(+) create mode 100644 app/Actions/Asset/EnsureAssetFolderPaths.php create mode 100644 app/Http/Controllers/Mgmt/EnsureAssetFolderPathsController.php create mode 100644 app/Http/Requests/Asset/EnsureAssetFolderPathsRequest.php create mode 100644 tests/Feature/Mgmt/AssetFolderEnsurePathsTest.php diff --git a/app/Actions/Asset/EnsureAssetFolderPaths.php b/app/Actions/Asset/EnsureAssetFolderPaths.php new file mode 100644 index 00000000..40f21c4a --- /dev/null +++ b/app/Actions/Asset/EnsureAssetFolderPaths.php @@ -0,0 +1,209 @@ +, + * folders: Collection, + * renamed: list, + * } + */ +class EnsureAssetFolderPaths +{ + /** Mirrors `asset_folders.name` being varchar(100). */ + private const NAME_MAX_LENGTH = 100; + + /** + * Long enough to outlast the work it guards, short enough to expire well + * before anything else gives up on the request. + * + * `EnsureAssetFolderPathsRequest::MAX_SEGMENTS` caps a payload at 2000 + * folder levels, measured at ~4.8 s of work when none of them exist yet. + * Even several times slower than the measurement, that finishes inside this + * TTL, so the lock cannot lapse with the transaction still open and let a + * second drop create the duplicate folder it exists to prevent. It is also + * short of the 59 s + * `max_execution_time`: if a request is ever killed mid-transaction and the + * release in `block()` never runs, the space is blocked for at most this + * long rather than for a stretch nobody can wait out. + */ + private const LOCK_TTL_SECONDS = 30; + + /** + * How long a queued caller waits. A legitimate large drop can hold the lock + * for most of the TTL, so giving up after a few seconds would 503 callers + * that only needed to wait their turn. Waiting plus the caller's own work + * still fits inside `max_execution_time`. + */ + private const LOCK_WAIT_SECONDS = 20; + + /** + * @param list $paths + * @return EnsureResult + */ + public function execute(Space $space, ?string $parentId, array $paths): array + { + $lock = Cache::lock("asset-folder-paths:{$space->id}", self::LOCK_TTL_SECONDS); + + try { + return $lock->block(self::LOCK_WAIT_SECONDS, fn (): array => $this->resolve($parentId, $paths)); + } catch (LockTimeoutException) { + // Another drop is mirroring a tree into this space right now. Running + // anyway is what creates duplicate folders, so ask for a retry. + abort( + 503, + 'Another folder upload is still mirroring folders into this space. ' + . 'Nothing was lost — wait for it to finish and upload again.', + ['Retry-After' => (string) self::LOCK_TTL_SECONDS], + ); + } + } + + /** + * @param list $paths + * @return EnsureResult + */ + private function resolve(?string $parentId, array $paths): array + { + return new AssetFolder()->getConnection()->transaction(function () use ($parentId, $paths): array { + $resolved = []; + $touched = collect(); + $renamed = []; + + /** @var array $sanitized */ + $sanitized = []; + + /** @var array> $childrenByParent */ + $childrenByParent = []; + + foreach ($paths as $path) { + // Only genuinely empty segments (leading, trailing or doubled + // slashes) drop out. A folder literally named " " exists on + // disk, so it becomes a real folder under the placeholder name + // instead of collapsing into its parent. + $segments = array_values(array_filter( + explode('/', $path), + static fn (string $segment): bool => $segment !== '', + )); + + $currentParentId = $parentId; + + foreach ($segments as $segment) { + $segment = $this->normalize($segment); + $name = $this->sanitizeSegment($segment, $sanitized); + + if ($name !== $segment) { + $renamed[$segment] = $name; + } + + $cacheKey = $currentParentId ?? ''; + $children = $childrenByParent[$cacheKey] ??= AssetFolder::query() + ->where('parent_id', $currentParentId) + ->get() + ->keyBy(fn (AssetFolder $folder): string => $this->foldKey($folder->name ?? '')); + + $folder = $children->get($this->foldKey($name)); + + if (!$folder) { + $folder = new AssetFolder([ + 'name' => $name, + 'parent_id' => $currentParentId, + ]); + $folder->save(); + + $childrenByParent[$cacheKey]->put($this->foldKey($name), $folder); + } + + $touched->put($folder->id, $folder); + $currentParentId = $folder->id; + } + + $resolved[$path] = $currentParentId; + } + + return [ + 'paths' => $resolved, + 'folders' => $touched->values(), + 'renamed' => collect($renamed) + ->map(static fn (string $to, string $from): array => ['from' => $from, 'to' => $to]) + ->values() + ->all(), + ]; + }); + } + + /** + * Runs a segment through the model's own name purification, then trims, + * truncates to the column length and falls back to a placeholder when + * purification leaves nothing. + * + * Reads the raw attribute rather than `$probe->name`: `name` purifies on + * both get and set, so the accessor would run HTMLPurifier a second time + * over text the mutator has already cleaned. The stored value is what the + * column would hold, which is exactly what this needs. + * + * Segments repeat heavily across a real tree (every path under `Brand/` + * carries `Brand`), so results are memoized for the length of one call. + * + * @param array $memo + */ + private function sanitizeSegment(string $segment, array &$memo): string + { + if (isset($memo[$segment])) { + return $memo[$segment]; + } + + $probe = new AssetFolder; + $probe->name = $segment; + + $name = trim(mb_substr(trim((string) ($probe->getAttributes()['name'] ?? '')), 0, self::NAME_MAX_LENGTH)); + + return $memo[$segment] = $name === '' ? 'folder' : $name; + } + + /** + * The comparison key for merging siblings: one Unicode form, one case. + * macOS hands the browser decomposed names, so an NFD "Café" from a drop + * has to find the NFC "Café" a UI create left behind. + */ + private function foldKey(string $name): string + { + return mb_strtolower($this->normalize($name)); + } + + /** + * NFC-normalizes when ext-intl is available. The extension is not a declared + * requirement, so without it names are compared as they arrive and a drop + * from macOS can still produce a second, visually identical folder. + */ + private function normalize(string $value): string + { + if (!class_exists(Normalizer::class)) { + return $value; + } + + return Normalizer::normalize($value, Normalizer::FORM_C) ?: $value; + } +} diff --git a/app/Http/Controllers/Mgmt/EnsureAssetFolderPathsController.php b/app/Http/Controllers/Mgmt/EnsureAssetFolderPathsController.php new file mode 100644 index 00000000..f3227b9d --- /dev/null +++ b/app/Http/Controllers/Mgmt/EnsureAssetFolderPathsController.php @@ -0,0 +1,37 @@ +authorizeSpace($space, 'asset_folders.manage'); + + $result = $action->execute( + $space, + $request->validated('parent_id'), + $request->validated('paths'), + ); + + return response()->json([ + 'paths' => $result['paths'], + 'folders' => AssetFolderResource::collection($result['folders']), + 'renamed' => $result['renamed'], + ]); + } +} diff --git a/app/Http/Requests/Asset/EnsureAssetFolderPathsRequest.php b/app/Http/Requests/Asset/EnsureAssetFolderPathsRequest.php new file mode 100644 index 00000000..f0f404d3 --- /dev/null +++ b/app/Http/Requests/Asset/EnsureAssetFolderPathsRequest.php @@ -0,0 +1,104 @@ + [ + 'nullable', + 'string', + Rule::exists(new AssetFolder()->getConnectionName() . '.asset_folders', 'id') + ->whereNull('deleted_at'), + ], + 'paths' => 'required|array|min:1|max:' . self::MAX_PATHS, + // A folder named " " is a folder the user really dropped, so a + // blank path must not be rejected. `string|min|max` cannot express + // that: Laravel skips non-implicit rules on a blank string, which + // would drop the length bound with it. See BoundedString. + 'paths.*' => [new BoundedString(1, self::MAX_PATH_LENGTH)], + ]; + } + + /** + * @return list + */ + public function after(): array + { + return [ + function (Validator $validator): void { + $paths = $this->input('paths'); + + if (!is_array($paths)) { + return; + } + + $segments = 0; + + foreach ($paths as $path) { + if (is_string($path)) { + $segments += count(array_filter( + explode('/', $path), + static fn (string $segment): bool => $segment !== '', + )); + } + } + + if ($segments > self::MAX_SEGMENTS) { + $validator->errors()->add( + 'paths', + 'A single upload can mirror at most ' . self::MAX_SEGMENTS + . ' folder levels, this drop has ' . $segments . '. Split it into smaller parts.', + ); + } + }, + ]; + } + + public function messages(): array + { + return [ + 'paths.max' => 'A single upload can mirror at most ' . self::MAX_PATHS . ' folders. Split the drop into smaller parts.', + ]; + } + + public function authorize(): bool + { + return true; + } +} diff --git a/routes/private_mgmt.php b/routes/private_mgmt.php index ebe3b822..5deb0eb4 100644 --- a/routes/private_mgmt.php +++ b/routes/private_mgmt.php @@ -61,6 +61,7 @@ use App\Http\Controllers\Mgmt\DataEntryDataImportController; use App\Http\Controllers\Mgmt\DataEntryTranslationStreamController; use App\Http\Controllers\Mgmt\DataSourceController; +use App\Http\Controllers\Mgmt\EnsureAssetFolderPathsController; use App\Http\Controllers\Mgmt\FieldPluginController; use App\Http\Controllers\Mgmt\IconController; use App\Http\Controllers\Mgmt\IconDataImportController; @@ -322,6 +323,8 @@ Route::get('stats', SpaceStatsController::class); Route::apiResource('asset-folders', AssetFolderController::class); + Route::post('asset-folders/ensure-paths', EnsureAssetFolderPathsController::class) + ->name('asset-folders.ensure-paths'); Route::apiResource('asset-tags', AssetTagController::class)->parameters([ 'asset-tags' => 'tag', ]); diff --git a/tests/Feature/Mgmt/AssetFolderEnsurePathsTest.php b/tests/Feature/Mgmt/AssetFolderEnsurePathsTest.php new file mode 100644 index 00000000..ee1b4dda --- /dev/null +++ b/tests/Feature/Mgmt/AssetFolderEnsurePathsTest.php @@ -0,0 +1,348 @@ +user = User::factory()->create(); + $this->space = Space::factory()->create(); + + $this->assignSpaceRole($this->space, $this->user, 'owner'); + + Storage::factory()->create([ + 'space_id' => $this->space->id, + 'is_default' => true, + 'driver' => 'local', + 'state' => 'live', + ]); + + Sanctum::actingAs($this->user); + + $this->setUpSpaceTesting($this->space); + } + + private function ensurePaths(array $payload) + { + return $this->postJson( + "/mgmt/v1/spaces/{$this->space->id}/asset-folders/ensure-paths", + $payload, + ); + } + + #[Test] + public function it_creates_a_nested_path() + { + $response = $this->ensurePaths([ + 'parent_id' => null, + 'paths' => ['Brand/Logos/Dark'], + ]); + + $response->assertOk(); + + $brand = AssetFolder::query()->where('name', 'Brand')->whereNull('parent_id')->firstOrFail(); + $logos = AssetFolder::query()->where('name', 'Logos')->where('parent_id', $brand->id)->firstOrFail(); + $dark = AssetFolder::query()->where('name', 'Dark')->where('parent_id', $logos->id)->firstOrFail(); + + $response->assertJsonPath('paths.Brand/Logos/Dark', $dark->id); + $response->assertJsonCount(3, 'folders'); + $response->assertJsonPath('renamed', []); + } + + #[Test] + public function it_resolves_paths_under_a_given_parent() + { + $parent = AssetFolder::factory()->create(['name' => 'Existing']); + + $response = $this->ensurePaths([ + 'parent_id' => $parent->id, + 'paths' => ['Photos'], + ]); + + $response->assertOk(); + + $photos = AssetFolder::query()->where('name', 'Photos')->firstOrFail(); + $this->assertSame($parent->id, $photos->parent_id); + $response->assertJsonPath('paths.Photos', $photos->id); + } + + #[Test] + public function it_merges_into_an_existing_folder() + { + $existing = AssetFolder::factory()->create(['name' => 'Brand']); + + $response = $this->ensurePaths([ + 'parent_id' => null, + 'paths' => ['Brand/Logos'], + ]); + + $response->assertOk(); + $response->assertJsonPath('paths.Brand/Logos', AssetFolder::query()->where('name', 'Logos')->firstOrFail()->id); + + $this->assertSame(1, AssetFolder::query()->where('name', 'Brand')->count()); + $this->assertSame($existing->id, AssetFolder::query()->where('name', 'Logos')->firstOrFail()->parent_id); + } + + #[Test] + public function it_merges_case_insensitively() + { + $existing = AssetFolder::factory()->create(['name' => 'brand']); + + $response = $this->ensurePaths([ + 'parent_id' => null, + 'paths' => ['BRAND/Logos'], + ]); + + $response->assertOk(); + + $this->assertSame(2, AssetFolder::query()->count()); + $this->assertSame( + $existing->id, + AssetFolder::query()->where('name', 'Logos')->firstOrFail()->parent_id, + ); + } + + #[Test] + public function it_ignores_soft_deleted_folders_instead_of_restoring_them() + { + $deleted = AssetFolder::factory()->create(['name' => 'Brand']); + $deleted->delete(); + + $response = $this->ensurePaths([ + 'parent_id' => null, + 'paths' => ['Brand'], + ]); + + $response->assertOk(); + + $fresh = AssetFolder::query()->where('name', 'Brand')->firstOrFail(); + $this->assertNotSame($deleted->id, $fresh->id); + $this->assertSoftDeleted('asset_folders', ['id' => $deleted->id]); + } + + #[Test] + public function it_rejects_users_without_the_folder_manage_ability() + { + $viewer = User::factory()->create(); + $this->assignSpaceRole($this->space, $viewer, 'viewer'); + Sanctum::actingAs($viewer); + + $response = $this->ensurePaths([ + 'parent_id' => null, + 'paths' => ['Brand'], + ]); + + $response->assertForbidden(); + $this->assertSame(0, AssetFolder::query()->count()); + } + + #[Test] + public function it_truncates_names_past_the_column_length_and_reports_the_change() + { + $long = str_repeat('a', 150); + + $response = $this->ensurePaths([ + 'parent_id' => null, + 'paths' => [$long], + ]); + + $response->assertOk(); + + $folder = AssetFolder::query()->firstOrFail(); + $this->assertSame(str_repeat('a', 100), $folder->name); + $response->assertJsonPath('renamed.0.from', $long); + $response->assertJsonPath('renamed.0.to', str_repeat('a', 100)); + } + + #[Test] + public function it_falls_back_to_a_placeholder_when_purification_empties_a_name() + { + $response = $this->ensurePaths([ + 'parent_id' => null, + 'paths' => ['

/Logos'], + ]); + + $response->assertOk(); + + $placeholder = AssetFolder::query()->where('name', 'folder')->firstOrFail(); + $this->assertNull($placeholder->parent_id); + $this->assertSame( + $placeholder->id, + AssetFolder::query()->where('name', 'Logos')->firstOrFail()->parent_id, + ); + $response->assertJsonPath('renamed.0.to', 'folder'); + } + + #[Test] + public function it_creates_a_placeholder_folder_for_a_whitespace_only_segment() + { + $response = $this->ensurePaths([ + 'parent_id' => null, + 'paths' => ['Brand/ '], + ]); + + $response->assertOk(); + + $brand = AssetFolder::query()->where('name', 'Brand')->firstOrFail(); + $placeholder = AssetFolder::query()->where('name', 'folder')->firstOrFail(); + + // The folder exists on disk, so it becomes a real folder rather than + // collapsing into its parent and stranding the files it holds. + $this->assertSame($brand->id, $placeholder->parent_id); + $this->assertSame($placeholder->id, $response->json('paths')['Brand/ ']); + $response->assertJsonPath('renamed.0.from', ' '); + $response->assertJsonPath('renamed.0.to', 'folder'); + } + + #[Test] + public function it_merges_two_casings_of_the_same_new_path_within_one_payload() + { + $response = $this->ensurePaths([ + 'parent_id' => null, + 'paths' => ['Brand/Logos', 'BRAND/Icons', 'brand'], + ]); + + $response->assertOk(); + + $this->assertSame(1, AssetFolder::query()->whereNull('parent_id')->count()); + $brand = AssetFolder::query()->whereNull('parent_id')->firstOrFail(); + + $paths = $response->json('paths'); + $this->assertSame($brand->id, $paths['brand']); + $this->assertSame( + $brand->id, + AssetFolder::query()->where('name', 'Logos')->firstOrFail()->parent_id, + ); + $this->assertSame( + $brand->id, + AssetFolder::query()->where('name', 'Icons')->firstOrFail()->parent_id, + ); + } + + #[Test] + public function it_merges_a_decomposed_name_into_its_composed_twin() + { + if (!class_exists(\Normalizer::class)) { + $this->markTestSkipped('ext-intl is not installed, names are compared as they arrive.'); + } + + $existing = AssetFolder::factory()->create(['name' => "Caf\u{e9}"]); + + $response = $this->ensurePaths([ + 'parent_id' => null, + 'paths' => ["Cafe\u{301}/Menus"], + ]); + + $response->assertOk(); + + $this->assertSame(2, AssetFolder::query()->count()); + $this->assertSame( + $existing->id, + AssetFolder::query()->where('name', 'Menus')->firstOrFail()->parent_id, + ); + } + + #[Test] + public function it_bounds_the_length_of_a_whitespace_only_path() + { + // Laravel skips non-implicit rules on a blank string, so `min`/`max` + // would never see this one. A quarter megabyte of spaces per path is a + // cheap way to buy 2000 purifier passes inside the lock. + $response = $this->ensurePaths([ + 'parent_id' => null, + 'paths' => [str_repeat(' ', 9000)], + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('paths.0'); + $this->assertSame(0, AssetFolder::query()->count()); + } + + #[Test] + public function it_rejects_an_empty_path() + { + $response = $this->ensurePaths(['parent_id' => null, 'paths' => ['']]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('paths.0'); + } + + #[Test] + public function it_rejects_a_path_that_is_not_a_string() + { + $response = $this->ensurePaths(['parent_id' => null, 'paths' => [['Brand']]]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('paths.0'); + } + + #[Test] + public function it_rejects_more_folder_levels_than_a_single_upload_may_mirror() + { + // Few paths, but each one a deep chain: the array size does not bound + // the folders this would create, the segment count does. + $paths = array_map( + static fn (int $index): string => implode('/', array_fill(0, 300, "s{$index}")), + range(1, 10), + ); + + $response = $this->ensurePaths(['parent_id' => null, 'paths' => $paths]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('paths'); + $this->assertSame(0, AssetFolder::query()->count()); + } + + #[Test] + public function it_rejects_more_paths_than_a_single_upload_may_mirror() + { + $paths = array_map(static fn (int $index): string => "Folder {$index}", range(1, 2001)); + + $response = $this->ensurePaths(['parent_id' => null, 'paths' => $paths]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('paths'); + $this->assertSame(0, AssetFolder::query()->count()); + } + + #[Test] + public function it_is_idempotent_for_the_same_payload() + { + $payload = [ + 'parent_id' => null, + 'paths' => ['Brand/Logos', 'Brand/Photos', 'Brand'], + ]; + + $first = $this->ensurePaths($payload); + $second = $this->ensurePaths($payload); + + $first->assertOk(); + $second->assertOk(); + + $this->assertSame(3, AssetFolder::query()->count()); + $this->assertSame($first->json('paths'), $second->json('paths')); + } +} From f61625547d75b50aed31abc526dbfe06cb7c800c Mon Sep 17 00:00:00 2001 From: Michael Wallner Date: Thu, 27 Aug 2026 16:23:56 +0200 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9C=A8=20Read=20a=20dropped=20folder=20t?= =?UTF-8?q?ree=20out=20of=20the=20browser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dataTransfer.files flattens a folder drop and loses the structure. This walks webkitGetAsEntry instead, snapshotting the entries synchronously because the DataTransfer dies when the handler returns. readEntries answers with at most 100 entries per call, so it is called until it comes back empty. Stopping after one call silently truncates any folder with more than 100 children. One unreadable file no longer costs the whole drop: failures are counted and reported instead of rejecting the traversal. Paths leave in NFC so a Finder "Café" merges with one created in the UI. Co-Authored-By: Claude Opus 5 (1M context) --- resources/js/lib/dropped-tree.ts | 246 +++++++++++++++++++++ tests/js/lib/dropped-tree.test.ts | 341 ++++++++++++++++++++++++++++++ 2 files changed, 587 insertions(+) create mode 100644 resources/js/lib/dropped-tree.ts create mode 100644 tests/js/lib/dropped-tree.test.ts diff --git a/resources/js/lib/dropped-tree.ts b/resources/js/lib/dropped-tree.ts new file mode 100644 index 00000000..5f41a32e --- /dev/null +++ b/resources/js/lib/dropped-tree.ts @@ -0,0 +1,246 @@ +/** + * Reads a folder tree out of a drag-and-drop payload or a directory-picker + * FileList, so both paths produce the same shape: files with their relative + * folder path plus the list of directories, including empty ones. + * + * A DataTransfer is only readable while the drop handler runs, so the entries + * must be snapshotted synchronously with {@link snapshotDropEntries}; the + * async traversal in {@link readDroppedTree} works on that snapshot. + * + * Pure DOM and Promise logic, no Vue, so Vitest can drive it directly. + */ + +export interface DroppedFile { + file: File + /** Folder path relative to the drop target, POSIX separators, no filename. */ + path: string +} + +export interface DroppedTree { + files: DroppedFile[] + /** Every directory encountered, in traversal order, including empty ones. */ + directories: string[] + /** Entries filtered out: system junk, dotfiles and zero-byte files. */ + skipped: number + /** Files the browser refused to hand over (offline volume, moved file). */ + unreadableFiles: number + /** + * Directories the browser refused to list. Counted apart from files because + * the cost is not comparable: an unreadable directory can hide any number of + * files, and it still gets created as an empty folder. + */ + unreadableDirectories: number +} + +export interface DropSnapshot { + entries: FileSystemEntry[] + /** Items the browser exposed without an entry (no folder support). */ + files: File[] +} + +/** Mirrors `asset_folders.name` being varchar(100) on the server. */ +export const FOLDER_NAME_MAX_LENGTH = 100 + +const JUNK_NAMES = new Set(['thumbs.db', '__macosx']) + +const isJunkName = (name: string): boolean => + name.startsWith('.') || JUNK_NAMES.has(name.toLowerCase()) + +/** + * macOS hands Chrome decomposed names. The server compares NFC, so every path + * that leaves this module is NFC too and a dropped "Café" merges into the + * "Café" that already exists. + */ +const toNfc = (value: string): string => value.normalize('NFC') + +/** + * The server truncates with `mb_substr`, which counts characters, so the + * prediction has to count code points rather than UTF-16 units. `slice` would + * halve a name of astral characters and could sever a surrogate pair. + */ +const truncateToCodePoints = (value: string, max: number): string => + Array.from(value).slice(0, max).join('') + +/** + * Client-side prediction of what the server will store for a path segment: + * NFC-normalized, trimmed, truncated to the column length, placeholder when + * nothing is left. The server additionally strips HTML; this only covers what + * can be predicted without a purifier. + */ +export const normalizeFolderSegment = (segment: string): string => { + const trimmed = truncateToCodePoints(toNfc(segment).trim(), FOLDER_NAME_MAX_LENGTH).trim() + + return trimmed === '' ? 'folder' : trimmed +} + +/** + * Captures the FileSystemEntry objects while the DataTransfer is still valid. + * Must be called synchronously inside the drop handler. + */ +export const snapshotDropEntries = (dataTransfer: DataTransfer): DropSnapshot => { + const entries: FileSystemEntry[] = [] + const files: File[] = [] + + for (const item of Array.from(dataTransfer.items)) { + if (item.kind !== 'file') { + continue + } + + const entry = typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null + + if (entry) { + entries.push(entry) + continue + } + + const file = item.getAsFile() + + if (file) { + files.push(file) + } + } + + return { entries, files } +} + +const readAllEntries = async ( + directory: FileSystemDirectoryEntry +): Promise<{ entries: FileSystemEntry[]; failed: boolean }> => { + const reader = directory.createReader() + const entries: FileSystemEntry[] = [] + + // readEntries yields at most 100 entries per call; the rest only arrives by + // calling it again on the same reader until it answers with an empty array. + // Stopping after one call silently truncates large folders. + for (;;) { + let batch: FileSystemEntry[] + + try { + batch = await new Promise((resolve, reject) => { + reader.readEntries(resolve, reject) + }) + } catch { + // Keep whatever the directory already yielded rather than losing the drop. + return { entries, failed: true } + } + + if (batch.length === 0) { + break + } + + entries.push(...batch) + } + + return { entries, failed: false } +} + +const entryFile = (entry: FileSystemFileEntry): Promise => + new Promise((resolve, reject) => entry.file(resolve, reject)) + +/** + * Traverses a snapshot into the flat tree result. A single unreadable entry + * never fails the whole traversal: it is counted so the pre-flight can tell the + * user what did not make it, files and directories apart. + */ +export const readDroppedTree = async (snapshot: DropSnapshot): Promise => { + const files: DroppedFile[] = [] + const directories: string[] = [] + let skipped = 0 + let unreadableFiles = 0 + let unreadableDirectories = 0 + + const collect = (file: File, path: string) => { + if (isJunkName(file.name) || file.size === 0) { + skipped++ + return + } + + files.push({ file, path }) + } + + const visit = async (entry: FileSystemEntry, parentPath: string): Promise => { + if (isJunkName(entry.name)) { + skipped++ + return + } + + const name = toNfc(entry.name) + + if (entry.isDirectory) { + const path = parentPath ? `${parentPath}/${name}` : name + directories.push(path) + + const { entries, failed } = await readAllEntries(entry as FileSystemDirectoryEntry) + + if (failed) { + unreadableDirectories++ + } + + for (const child of entries) { + await visit(child, path) + } + + return + } + + if (entry.isFile) { + try { + collect(await entryFile(entry as FileSystemFileEntry), parentPath) + } catch { + unreadableFiles++ + } + } + } + + for (const entry of snapshot.entries) { + await visit(entry, '') + } + + for (const file of snapshot.files) { + collect(file, '') + } + + return { files, directories, skipped, unreadableFiles, unreadableDirectories } +} + +/** + * Builds the same result from an `` + * FileList (or a plain file input, where `webkitRelativePath` is empty and + * everything lands at the root). + */ +export const readTreeFromFileList = (list: ArrayLike): DroppedTree => { + const files: DroppedFile[] = [] + const directorySet = new Set() + let skipped = 0 + + for (const file of Array.from(list)) { + const segments = (file.webkitRelativePath || '').split('/').filter(Boolean) + const directorySegments = segments.slice(0, -1).map(toNfc) + + if (directorySegments.some(isJunkName)) { + skipped++ + continue + } + + // Ancestors are real directories even when their only files are junk; + // the drop path collects such directories as empty ones too. + directorySegments.forEach((_, index) => { + directorySet.add(directorySegments.slice(0, index + 1).join('/')) + }) + + if (isJunkName(file.name) || file.size === 0) { + skipped++ + continue + } + + files.push({ file, path: directorySegments.join('/') }) + } + + return { + files, + directories: [...directorySet], + skipped, + unreadableFiles: 0, + unreadableDirectories: 0, + } +} diff --git a/tests/js/lib/dropped-tree.test.ts b/tests/js/lib/dropped-tree.test.ts new file mode 100644 index 00000000..4e94069b --- /dev/null +++ b/tests/js/lib/dropped-tree.test.ts @@ -0,0 +1,341 @@ +import { describe, expect, it } from 'vitest' + +import { + normalizeFolderSegment, + readDroppedTree, + readTreeFromFileList, + snapshotDropEntries, + type DropSnapshot, +} from '~/lib/dropped-tree' + +const makeFile = (name: string, size = 8): File => + new File([new Uint8Array(size)], name, { type: 'application/octet-stream' }) + +const fileEntry = (file: File): FileSystemEntry => + ({ + isFile: true, + isDirectory: false, + name: file.name, + file: (resolve: (file: File) => void) => resolve(file), + }) as unknown as FileSystemEntry + +/** A file the browser hands back but refuses to read, e.g. an offline volume. */ +const unreadableFileEntry = (name: string): FileSystemEntry => + ({ + isFile: true, + isDirectory: false, + name, + file: (_resolve: (file: File) => void, reject: (error: Error) => void) => + reject(new Error('NotReadableError')), + }) as unknown as FileSystemEntry + +/** A directory whose reader yields one batch and then fails. */ +const failingDirEntry = (name: string, children: FileSystemEntry[]): FileSystemEntry => + ({ + isFile: false, + isDirectory: true, + name, + createReader: () => { + let served = false + + return { + readEntries: ( + resolve: (entries: FileSystemEntry[]) => void, + reject: (error: Error) => void + ) => { + if (served) { + reject(new Error('NotReadableError')) + return + } + + served = true + resolve(children) + }, + } + }, + }) as unknown as FileSystemEntry + +/** A directory whose reader fails before yielding anything at all. */ +const unreadableDirEntry = (name: string): FileSystemEntry => + ({ + isFile: false, + isDirectory: true, + name, + createReader: () => ({ + readEntries: (_resolve: (entries: FileSystemEntry[]) => void, reject: (error: Error) => void) => + reject(new Error('NotReadableError')), + }), + }) as unknown as FileSystemEntry + +const dropItem = (item: Partial): DataTransferItem => + ({ kind: 'file', getAsFile: () => null, ...item }) as unknown as DataTransferItem + +const dataTransfer = (items: DataTransferItem[]): DataTransfer => + ({ items }) as unknown as DataTransfer + +/** + * A faithful FileSystemDirectoryReader double: each readEntries call hands out + * at most 100 entries and an empty array once drained, exactly like Chromium. + */ +const dirEntry = (name: string, children: FileSystemEntry[]): FileSystemEntry => + ({ + isFile: false, + isDirectory: true, + name, + createReader: () => { + let offset = 0 + + return { + readEntries: (resolve: (entries: FileSystemEntry[]) => void) => { + const batch = children.slice(offset, offset + 100) + offset += batch.length + resolve(batch) + }, + } + }, + }) as unknown as FileSystemEntry + +const snapshot = (entries: FileSystemEntry[], files: File[] = []): DropSnapshot => ({ + entries, + files, +}) + +const withRelativePath = (file: File, relativePath: string): File => { + Object.defineProperty(file, 'webkitRelativePath', { value: relativePath }) + + return file +} + +describe('readDroppedTree', () => { + it('drains directories past the 100-entry readEntries cap', async () => { + const children = Array.from({ length: 250 }, (_, index) => + fileEntry(makeFile(`file-${index}.png`)) + ) + + const tree = await readDroppedTree(snapshot([dirEntry('Shoot', children)])) + + expect(tree.files).toHaveLength(250) + expect(tree.skipped).toBe(0) + expect(new Set(tree.files.map((entry) => entry.file.name)).size).toBe(250) + }) + + it('derives POSIX folder paths without the filename', async () => { + const tree = await readDroppedTree( + snapshot([ + dirEntry('Brand', [ + fileEntry(makeFile('logo.svg')), + dirEntry('Logos', [fileEntry(makeFile('dark.svg'))]), + ]), + fileEntry(makeFile('loose.txt')), + ]) + ) + + expect(tree.files).toEqual([ + expect.objectContaining({ path: 'Brand' }), + expect.objectContaining({ path: 'Brand/Logos' }), + expect.objectContaining({ path: '' }), + ]) + expect(tree.directories).toEqual(['Brand', 'Brand/Logos']) + }) + + it('collects empty directories', async () => { + const tree = await readDroppedTree( + snapshot([dirEntry('Brand', [dirEntry('Empty', []), dirEntry('AlsoEmpty', [])])]) + ) + + expect(tree.files).toHaveLength(0) + expect(tree.directories).toEqual(['Brand', 'Brand/Empty', 'Brand/AlsoEmpty']) + }) + + it('filters junk: dotfiles, Thumbs.db, __MACOSX and zero-byte files', async () => { + const tree = await readDroppedTree( + snapshot([ + dirEntry('Shoot', [ + fileEntry(makeFile('.DS_Store')), + fileEntry(makeFile('Thumbs.db')), + fileEntry(makeFile('.hidden')), + fileEntry(makeFile('empty.txt', 0)), + fileEntry(makeFile('keep.jpg')), + dirEntry('__MACOSX', [fileEntry(makeFile('._keep.jpg'))]), + dirEntry('.git', [fileEntry(makeFile('HEAD'))]), + ]), + ]) + ) + + expect(tree.files.map((entry) => entry.file.name)).toEqual(['keep.jpg']) + expect(tree.directories).toEqual(['Shoot']) + expect(tree.skipped).toBe(6) + }) + + it('applies no file-type filter', async () => { + const tree = await readDroppedTree( + snapshot([ + fileEntry(makeFile('mock.psd')), + fileEntry(makeFile('design.sketch')), + fileEntry(makeFile('archive.zip')), + ]) + ) + + expect(tree.files).toHaveLength(3) + }) + + it('keeps fallback files from items without an entry at the root', async () => { + const tree = await readDroppedTree(snapshot([], [makeFile('plain.pdf'), makeFile('.DS_Store')])) + + expect(tree.files.map((entry) => entry.file.name)).toEqual(['plain.pdf']) + expect(tree.skipped).toBe(1) + }) + + it('keeps the rest of the drop when a single file cannot be read', async () => { + const tree = await readDroppedTree( + snapshot([ + dirEntry('Shoot', [ + fileEntry(makeFile('first.jpg')), + unreadableFileEntry('gone.jpg'), + fileEntry(makeFile('last.jpg')), + ]), + ]) + ) + + expect(tree.files.map((entry) => entry.file.name)).toEqual(['first.jpg', 'last.jpg']) + expect(tree.unreadableFiles).toBe(1) + expect(tree.unreadableDirectories).toBe(0) + expect(tree.skipped).toBe(0) + }) + + it('keeps what a directory already yielded when its reader fails', async () => { + const tree = await readDroppedTree( + snapshot([failingDirEntry('Shoot', [fileEntry(makeFile('kept.jpg'))])]) + ) + + expect(tree.directories).toEqual(['Shoot']) + expect(tree.files.map((entry) => entry.file.name)).toEqual(['kept.jpg']) + expect(tree.unreadableDirectories).toBe(1) + expect(tree.unreadableFiles).toBe(0) + }) + + it('reports a directory that yields nothing as an unreadable directory, not a file', async () => { + const tree = await readDroppedTree( + snapshot([ + dirEntry('Shoot', [unreadableDirEntry('Offline'), fileEntry(makeFile('kept.jpg'))]), + unreadableFileEntry('gone.jpg'), + ]) + ) + + // The folder is still mirrored: an empty folder beats losing it silently. + expect(tree.directories).toEqual(['Shoot', 'Shoot/Offline']) + expect(tree.files.map((entry) => entry.file.name)).toEqual(['kept.jpg']) + expect(tree.unreadableDirectories).toBe(1) + expect(tree.unreadableFiles).toBe(1) + }) + + it('normalizes folder names to NFC so they match what the server compares', async () => { + const tree = await readDroppedTree( + snapshot([dirEntry('Cafe\u0301', [fileEntry(makeFile('menu.pdf'))])]) + ) + + expect(tree.directories).toEqual(['Caf\u00e9']) + expect(tree.files[0].path).toBe('Caf\u00e9') + }) +}) + +describe('snapshotDropEntries', () => { + it('skips items that are not files', () => { + const entry = fileEntry(makeFile('a.png')) + const snapshot = snapshotDropEntries( + dataTransfer([ + dropItem({ kind: 'string', webkitGetAsEntry: () => entry }), + dropItem({ kind: 'file', webkitGetAsEntry: () => entry }), + ]) + ) + + expect(snapshot.entries).toEqual([entry]) + expect(snapshot.files).toEqual([]) + }) + + it('falls back to getAsFile when the item exposes no entry', () => { + const file = makeFile('plain.pdf') + const snapshot = snapshotDropEntries( + dataTransfer([ + dropItem({ webkitGetAsEntry: () => null, getAsFile: () => file }), + dropItem({ webkitGetAsEntry: undefined, getAsFile: () => file }), + dropItem({ webkitGetAsEntry: () => null, getAsFile: () => null }), + ]) + ) + + expect(snapshot.entries).toEqual([]) + expect(snapshot.files).toEqual([file, file]) + }) + + it('reads the entries synchronously, before the DataTransfer is invalidated', async () => { + const entry = dirEntry('Brand', [fileEntry(makeFile('logo.svg'))]) + let valid = true + + const snapshot = snapshotDropEntries( + dataTransfer([ + dropItem({ + webkitGetAsEntry: () => { + if (!valid) { + throw new Error('the DataTransfer is gone once the handler returns') + } + + return entry + }, + }), + ]) + ) + + valid = false + + expect(snapshot.entries).toEqual([entry]) + expect((await readDroppedTree(snapshot)).files).toHaveLength(1) + }) +}) + +describe('readTreeFromFileList', () => { + it('builds the same result from webkitRelativePath', () => { + const tree = readTreeFromFileList([ + withRelativePath(makeFile('logo.svg'), 'Brand/logo.svg'), + withRelativePath(makeFile('dark.svg'), 'Brand/Logos/dark.svg'), + withRelativePath(makeFile('.DS_Store'), 'Brand/Logos/.DS_Store'), + withRelativePath(makeFile('._x.jpg'), '__MACOSX/Brand/._x.jpg'), + withRelativePath(makeFile('empty.png', 0), 'Brand/empty.png'), + ]) + + expect(tree.files).toEqual([ + expect.objectContaining({ path: 'Brand' }), + expect.objectContaining({ path: 'Brand/Logos' }), + ]) + expect(tree.directories).toEqual(['Brand', 'Brand/Logos']) + expect(tree.skipped).toBe(3) + }) + + it('treats a plain file input as root files', () => { + const tree = readTreeFromFileList([makeFile('a.png'), makeFile('b.png')]) + + expect(tree.files.map((entry) => entry.path)).toEqual(['', '']) + expect(tree.directories).toEqual([]) + }) +}) + +describe('normalizeFolderSegment', () => { + it('trims and truncates to the folder column length', () => { + expect(normalizeFolderSegment(' Brand ')).toBe('Brand') + expect(normalizeFolderSegment('x'.repeat(150))).toBe('x'.repeat(100)) + }) + + it('truncates by code point, matching the server mb_substr', () => { + const truncated = normalizeFolderSegment('\u{1f600}'.repeat(150)) + + expect(Array.from(truncated)).toHaveLength(100) + expect(truncated).toBe('\u{1f600}'.repeat(100)) + }) + + it('normalizes to NFC', () => { + expect(normalizeFolderSegment('Cafe\u0301')).toBe('Caf\u00e9') + }) + + it('falls back to a placeholder when nothing is left', () => { + expect(normalizeFolderSegment(' ')).toBe('folder') + }) +}) From 334fe7a842ec68a3cf0644540f7fff65a435779b Mon Sep 17 00:00:00 2001 From: Michael Wallner Date: Thu, 27 Aug 2026 16:32:43 +0200 Subject: [PATCH 4/7] =?UTF-8?q?=E2=9C=A8=20Upload=20a=20batch=20of=20asset?= =?UTF-8?q?s=20in=20the=20background?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 400 file drop held the upload dialog open for twenty minutes and locked the asset area behind it. The batch now lives at module scope, so it survives the dialog closing and in-app navigation, and a docked panel carries the progress. Three lanes, since each video upload runs ffmpeg inside the request. Each enqueue keeps its own uploader and settle callback, so files always upload to the space they were staged for even when a second drop joins a running batch. Lanes carry a generation token so one still unwinding from a reset batch cannot settle the next one. Cancel stops the queue and keeps everything already uploaded. Failures carry into the next batch rather than vanishing, and the panel is the only thing that clears them, so a retry button is never left backed by nothing. Logging out resets the batch: the next account must not see the previous one's filenames, let alone retry them. Batch uploads take a silent uploadAsset, which skips the per-file list invalidation and the toast and throws so the batch can record the server message. The single-file path is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- resources/js/app.vue | 18 +- .../js/components/assets/UploadBatchPanel.vue | 164 ++++++ .../js/composables/useAssetUploadBatch.ts | 473 ++++++++++++++++++ resources/js/composables/useAssets.ts | 15 +- .../composables/useAssetUploadBatch.test.ts | 97 ++++ 5 files changed, 764 insertions(+), 3 deletions(-) create mode 100644 resources/js/components/assets/UploadBatchPanel.vue create mode 100644 resources/js/composables/useAssetUploadBatch.ts create mode 100644 tests/js/composables/useAssetUploadBatch.test.ts diff --git a/resources/js/app.vue b/resources/js/app.vue index 468284ec..cf2e21ae 100644 --- a/resources/js/app.vue +++ b/resources/js/app.vue @@ -5,8 +5,10 @@ import { RouterView } from 'vue-router' import { Toaster } from 'vue-sonner' import { AlertDialogProvider } from '@/composables/useAlertDialog' +import UploadBatchPanel from '~/components/assets/UploadBatchPanel.vue' import Command from '~/components/Command.vue' import KeyboardShortcutsDialog from '~/components/KeyboardShortcutsDialog.vue' +import { useAssetUploadBatch } from '~/composables/useAssetUploadBatch' import { useUrlNotifications } from '~/composables/useUrlNotifications' import DefaultLayout from '~/layouts/default.vue' import ShareLayout from '~/layouts/share.vue' @@ -20,7 +22,20 @@ const commandOpen = ref(false) provide('commandOpen', commandOpen) const route = useRoute() -const { isAuthenticated } = useAuth() +const { isAuthenticated, user } = useAuth() +const { reset: resetUploadBatch } = useAssetUploadBatch() + +// The batch is module-scope state that outlives every route, so the session +// ending has to take it with it. Otherwise the next account on this browser +// sees the previous one's filenames and can retry their uploads. +watch( + () => user.value?.id ?? null, + (id, previousId) => { + if (previousId && id !== previousId) { + resetUploadBatch() + } + } +) const layoutMap: Record = { default: DefaultLayout, @@ -47,6 +62,7 @@ const currentLayout = computed(() => { + diff --git a/resources/js/components/assets/UploadBatchPanel.vue b/resources/js/components/assets/UploadBatchPanel.vue new file mode 100644 index 00000000..431ac0e1 --- /dev/null +++ b/resources/js/components/assets/UploadBatchPanel.vue @@ -0,0 +1,164 @@ + + + diff --git a/resources/js/composables/useAssetUploadBatch.ts b/resources/js/composables/useAssetUploadBatch.ts new file mode 100644 index 00000000..e7c1063c --- /dev/null +++ b/resources/js/composables/useAssetUploadBatch.ts @@ -0,0 +1,473 @@ +/** + * App-scope upload batch: the state lives at module scope so a running batch + * survives the upload dialog closing, in-app navigation and a space switch. + * Components read and control it through `useAssetUploadBatch()`; the docked + * `UploadBatchPanel` renders it whenever items exist. + * + * The upload function itself is handed in at enqueue time (a `silent` + * `uploadAsset` bound to its space), so this file needs no sibling composable + * imports and stays out of the auto-import trap. Each enqueue forms a group + * that keeps its own uploader and settle callback, which is what lets a batch + * started in space A finish there while space B queues behind it. + */ + +export type BatchItemStatus = 'pending' | 'uploading' | 'complete' | 'error' + +export interface BatchUploadItem extends UploadFile { + progress: number + status: BatchItemStatus + errorMessage?: string + /** Folder path relative to the drop target, '' when dropped at the root. */ + folderPath: string + /** Not retryable, e.g. over the server's size limit; never uploaded. */ + permanentError?: boolean + /** Failed because the batch was cancelled, not because the upload did. */ + cancelled?: boolean + /** Assigned by `enqueue`; binds the item to the uploader it arrived with. */ + groupId?: string +} + +/** + * A batch item while the upload dialog still stages it. The dialog and its tree + * rows share this shape; the batch itself never reads `enqueued`. + */ +export interface StagedUploadFile extends BatchUploadItem { + /** Already handed to the batch; enqueuing it again would upload it twice. */ + enqueued?: boolean +} + +type BatchUploadOutcome = + | { status: 'success'; asset: AssetResource } + | { status: 'duplicate'; duplicate: AssetUploadDuplicate } + | null + +export type BatchUploadFn = ( + payload: UploadAssetPayload, + onProgress: (progress: number) => void, + options: { force?: boolean; signal?: AbortSignal } +) => Promise + +export interface BatchEnqueueDeps { + upload: BatchUploadFn + /** Runs once when this group's items finish; per-upload invalidation is off. */ + onSettled: () => void +} + +interface BatchGroup extends BatchEnqueueDeps { + settled: boolean +} + +type DuplicateDecision = 'copies' | 'use-existing' + +const CONCURRENT_UPLOADS = 3 + +const items = ref([]) +const isRunning = ref(false) +const isCancelled = ref(false) +const isPanelDismissed = ref(false) +const duplicatePrompt = ref<{ filename: string; duplicate: AssetUploadDuplicate } | null>(null) + +let duplicateDecision: DuplicateDecision | null = null +let duplicateWaiters: Array<(decision: DuplicateDecision) => void> = [] +let activeLanes = 0 +let abortController: AbortController | null = null +let groupSequence = 0 +/** + * Bumped by `reset()`. A lane captures it when it opens and checks it before + * touching `activeLanes` again, so a lane still unwinding from a wiped batch + * cannot decrement the counter of the batch that replaced it. + */ +let generation = 0 +const groups = new Map() + +const guardUnload = (event: BeforeUnloadEvent) => { + event.preventDefault() +} + +const batchTotals = computed(() => { + let complete = 0 + let failed = 0 + let progressSum = 0 + + for (const item of items.value) { + if (item.status === 'complete') complete++ + if (item.status === 'error') failed++ + progressSum += item.status === 'complete' ? 100 : item.progress + } + + const total = items.value.length + + return { + total, + complete, + failed, + settled: complete + failed, + percent: total ? Math.round(progressSum / total) : 0, + } +}) + +const startRunning = () => { + if (isRunning.value) { + return + } + + isRunning.value = true + window.addEventListener('beforeunload', guardUnload) +} + +const failItem = (item: BatchUploadItem, message?: string, cancelled = false) => { + item.status = 'error' + item.cancelled = cancelled || undefined + item.errorMessage = cancelled ? undefined : message +} + +/** + * Calls `onSettled` for every group that has no live item left. Groups settle + * independently, so the space that queued first refreshes its asset list + * without waiting for whatever was queued after it. + */ +const flushSettledGroups = () => { + const live = new Set() + + for (const item of items.value) { + if (item.groupId && (item.status === 'pending' || item.status === 'uploading')) { + live.add(item.groupId) + } + } + + for (const [id, group] of groups) { + if (!group.settled && !live.has(id)) { + group.settled = true + group.onSettled() + } + } +} + +const maybeSettle = () => { + if (!isRunning.value || activeLanes > 0) { + return + } + + if (items.value.some((item) => item.status === 'pending')) { + if (!isCancelled.value) { + return + } + + // A cancelled batch fails what it never started. Settling over pending work + // would leave those items stuck at `pending` with nothing left to run them. + for (const item of items.value) { + if (item.status === 'pending') { + failItem(item, undefined, true) + } + } + } + + isRunning.value = false + window.removeEventListener('beforeunload', guardUnload) + flushSettledGroups() +} + +const payloadOf = (item: BatchUploadItem): UploadAssetPayload => ({ + file: item.file, + folder_id: item.folder_id, + tags: item.tags, + metadata: item.metadata, + data: item.data, +}) + +const promptForDuplicate = ( + item: BatchUploadItem, + duplicate: AssetUploadDuplicate +): Promise => { + return new Promise((resolve) => { + duplicateWaiters.push(resolve) + + // The first lane to hit a duplicate opens the prompt; later lanes wait for + // the same answer. One prompt per batch, the decision applies to the rest. + if (!duplicatePrompt.value) { + duplicatePrompt.value = { filename: item.file.name, duplicate } + } + }) +} + +const resolveDuplicatePrompt = (decision: DuplicateDecision) => { + duplicateDecision = decision + duplicatePrompt.value = null + + const waiters = duplicateWaiters + duplicateWaiters = [] + waiters.forEach((resolve) => resolve(decision)) +} + +const performUpload = async (item: BatchUploadItem, group: BatchGroup) => { + const signal = abortController?.signal + const onProgress = (progress: number) => { + item.progress = progress + } + + try { + const outcome = await group.upload(payloadOf(item), onProgress, { + force: duplicateDecision === 'copies', + signal, + }) + + if (outcome?.status === 'success') { + item.status = 'complete' + return + } + + if (outcome?.status === 'duplicate') { + const decision = duplicateDecision ?? (await promptForDuplicate(item, outcome.duplicate)) + + if (decision === 'use-existing') { + // The existing asset already satisfies the intent of this upload. + item.status = 'complete' + return + } + + const forced = await group.upload(payloadOf(item), onProgress, { force: true, signal }) + + if (forced?.status === 'success') { + item.status = 'complete' + return + } + } + + failItem(item) + } catch (error) { + failItem(item, error instanceof Error ? error.message : undefined, signal?.aborted === true) + } +} + +const pump = () => { + while (!isCancelled.value && activeLanes < CONCURRENT_UPLOADS) { + const next = items.value.find((item) => item.status === 'pending') + + if (!next) { + break + } + + const group = next.groupId ? groups.get(next.groupId) : undefined + + if (!group) { + // Nothing can upload this item any more; failing it beats leaving it + // pending forever and blocking the batch from settling. + failItem(next) + continue + } + + const laneGeneration = generation + + activeLanes++ + next.status = 'uploading' + next.progress = 0 + + void performUpload(next, group).finally(() => { + // A lane can outlive its batch: `ensureCsrfCookie` is not abortable, so a + // lane parked in it survives `reset()` for as long as that fetch takes. + // Its bookkeeping went with the batch, so it must stay out of the new one. + if (laneGeneration !== generation) { + return + } + + activeLanes-- + flushSettledGroups() + pump() + }) + } + + maybeSettle() +} + +/** + * Clears the settled batch to make room for the next one. Failures are carried + * over: they are the only items the user still has to act on, and dropping them + * would take the panel's failure list and its Retry button with them. They keep + * their group, so a retry still has the uploader that item arrived with. + */ +const resetForNewBatch = () => { + const carried = items.value.filter((item) => item.status === 'error') + const carriedGroups = new Set(carried.map((item) => item.groupId)) + + items.value = carried + isCancelled.value = false + duplicateDecision = null + duplicatePrompt.value = null + duplicateWaiters = [] + + // Deleting the entry the iterator is on is safe on a Map. + for (const id of groups.keys()) { + if (!carriedGroups.has(id)) { + groups.delete(id) + } + } +} + +/** + * Reuses the current controller while it is still live: a retry must not hand + * running lanes a signal that `cancel()` can no longer abort. + */ +const ensureAbortController = () => { + if (!abortController || abortController.signal.aborted) { + abortController = new AbortController() + } +} + +const requeue = (toRetry: BatchUploadItem[]) => { + // Retry is a settled-batch action. Running it mid-flight would un-cancel a + // draining batch and race the lanes still unwinding. + if (isRunning.value || !toRetry.length) { + return + } + + isCancelled.value = false + ensureAbortController() + + for (const item of toRetry) { + item.status = 'pending' + item.progress = 0 + item.errorMessage = undefined + item.cancelled = undefined + + const group = item.groupId ? groups.get(item.groupId) : undefined + + if (group) { + group.settled = false + } + } + + isPanelDismissed.value = false + startRunning() + pump() +} + +/** + * Drops everything: in-flight requests, queued items and the unload guard. The + * session that started the batch is gone, so its filenames and its uploader + * must not survive into the next one. + */ +const resetAssetUploadBatch = () => { + abortController?.abort() + abortController = null + generation++ + activeLanes = 0 + isRunning.value = false + // Emptied before resetForNewBatch(), which would otherwise carry the previous + // session's failures over. Nothing of that session may survive. + items.value = [] + isPanelDismissed.value = false + window.removeEventListener('beforeunload', guardUnload) + + const waiters = duplicateWaiters + duplicateWaiters = [] + waiters.forEach((resolve) => resolve('copies')) + + resetForNewBatch() +} + +export function useAssetUploadBatch() { + /** + * Adds items to the batch. While a batch runs, new items join its queue and + * totals; a settled batch is replaced, except for its unresolved failures, + * which are carried over. Items pre-marked as permanent errors (oversize + * files) surface in the failure list without ever uploading. + * + * Items already in the batch are ignored: re-adding one would upload it twice + * and double-count it. Use `retryItem`/`retryFailed` to run a failure again. + */ + const enqueue = (candidates: BatchUploadItem[], deps: BatchEnqueueDeps) => { + const known = new Set(items.value.map((item) => item.id)) + const newItems = candidates.filter((item) => !known.has(item.id)) + + if (!newItems.length) { + return + } + + if (!isRunning.value) { + resetForNewBatch() + } + + ensureAbortController() + + // New work must not inherit a cancel that is still draining, or it would + // never be pumped. + isCancelled.value = false + + const groupId = String(++groupSequence) + groups.set(groupId, { ...deps, settled: false }) + + for (const item of newItems) { + item.groupId = groupId + } + + isPanelDismissed.value = false + + items.value.push(...newItems) + startRunning() + pump() + } + + /** + * Stops the queue and aborts in-flight requests. Everything already + * uploaded, including folders already created, stays. + */ + const cancel = () => { + if (!isRunning.value) { + return + } + + isCancelled.value = true + + for (const item of items.value) { + if (item.status === 'pending') { + failItem(item, undefined, true) + } + } + + // Lanes waiting on the duplicate prompt resume and fail right away on the + // aborted signal. The answer is not recorded as the batch decision. + duplicatePrompt.value = null + const waiters = duplicateWaiters + duplicateWaiters = [] + waiters.forEach((resolve) => resolve('copies')) + + abortController?.abort() + maybeSettle() + } + + const retryFailed = () => { + requeue(items.value.filter((item) => item.status === 'error' && !item.permanentError)) + } + + const retryItem = (id: string) => { + requeue( + items.value.filter( + (item) => item.id === id && item.status === 'error' && !item.permanentError + ) + ) + } + + const dismissPanel = () => { + if (isRunning.value) { + return + } + + isPanelDismissed.value = true + } + + return { + items, + isRunning, + isCancelled, + isPanelDismissed, + duplicatePrompt, + batchTotals, + enqueue, + cancel, + retryFailed, + retryItem, + dismissPanel, + resolveDuplicatePrompt, + reset: resetAssetUploadBatch, + } +} diff --git a/resources/js/composables/useAssets.ts b/resources/js/composables/useAssets.ts index 2af3deac..86cafee8 100644 --- a/resources/js/composables/useAssets.ts +++ b/resources/js/composables/useAssets.ts @@ -116,11 +116,15 @@ export function useAssets(spaceId: MaybeRef) { * existing asset in the space, the request is not silently accepted - * the caller gets a `{ status: 'duplicate' }` outcome back and can decide * to re-call with `{ force: true }` to upload anyway. + * + * `silent` is for batch uploads: no toast, no per-upload list invalidation + * (the batch invalidates once when it settles), and failures are thrown so + * the batch can record the server message per file. */ const uploadAsset = async ( payload: UploadAssetPayload, onProgress?: (progress: number) => void, - options: { force?: boolean } = {} + options: { force?: boolean; silent?: boolean; signal?: AbortSignal } = {} ): Promise => { try { await apiClient.ensureCsrfCookie() @@ -148,6 +152,7 @@ export function useAssets(spaceId: MaybeRef) { formData, { onProgress, + signal: options.signal, fallbackMessage: (status, statusText) => `Upload failed with status ${status}: ${statusText}`, } @@ -157,7 +162,9 @@ export function useAssets(spaceId: MaybeRef) { return null } - debouncedInvalidateQueries() + if (!options.silent) { + debouncedInvalidateQueries() + } return { status: 'success', asset: response.data } } catch (err) { @@ -169,6 +176,10 @@ export function useAssets(spaceId: MaybeRef) { return duplicate } + if (options.silent) { + throw err + } + console.error(err) const message = err instanceof Error ? err.message : (t('composables.assets.uploadError') as string) diff --git a/tests/js/composables/useAssetUploadBatch.test.ts b/tests/js/composables/useAssetUploadBatch.test.ts new file mode 100644 index 00000000..44b06a75 --- /dev/null +++ b/tests/js/composables/useAssetUploadBatch.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' + +import { + useAssetUploadBatch, + type BatchUploadFn, + type BatchUploadItem, +} from '~/composables/useAssetUploadBatch' + +const item = (id: string): BatchUploadItem => ({ + id, + file: new File([new Uint8Array(4)], `${id}.png`, { type: 'image/png' }), + data: {}, + metadata: {}, + tags: [], + type: 'image', + progress: 0, + status: 'pending', + folderPath: '', +}) + +/** An upload the test resolves or rejects by hand. */ +const deferred = () => { + let settle: (value: never) => void = () => {} + let reject: (error: Error) => void = () => {} + + const promise = new Promise((res, rej) => { + settle = res + reject = rej + }) + + return { promise, settle, reject } +} + +const failing: BatchUploadFn = () => Promise.reject(new Error('network blip')) + +const succeeding: BatchUploadFn = () => + Promise.resolve({ status: 'success', asset: {} as AssetResource }) + +/** Lets every queued microtask run so the batch reaches its settled state. */ +const drain = async () => { + for (let round = 0; round < 10; round++) { + await nextTick() + } +} + +describe('useAssetUploadBatch', () => { + beforeEach(() => { + useAssetUploadBatch().reset() + }) + + it('carries a settled batch failure into the next batch so retry keeps working', async () => { + const batch = useAssetUploadBatch() + + batch.enqueue([item('a')], { upload: failing, onSettled: vi.fn() }) + await drain() + + expect(batch.isRunning.value).toBe(false) + expect(batch.items.value.map((entry) => entry.status)).toEqual(['error']) + + batch.enqueue([item('b')], { upload: succeeding, onSettled: vi.fn() }) + await drain() + + // The failure survived the batch that replaced it, so the panel still + // shows it and the Retry button still has something behind it. + expect(batch.items.value.map((entry) => entry.id)).toEqual(['a', 'b']) + expect(batch.items.value.map((entry) => entry.status)).toEqual(['error', 'complete']) + + batch.retryItem('a') + + // Not a no-op: the carried item kept its group, so it has an uploader again. + expect(batch.items.value.find((entry) => entry.id === 'a')?.status).toBe('uploading') + }) + + it('ignores a lane still unwinding from a batch that reset() wiped', async () => { + const batch = useAssetUploadBatch() + const stale = deferred() + + batch.enqueue([item('stale')], { upload: () => stale.promise, onSettled: vi.fn() }) + await drain() + + expect(batch.isRunning.value).toBe(true) + + batch.reset() + + const live = deferred() + batch.enqueue([item('live')], { upload: () => live.promise, onSettled: vi.fn() }) + await drain() + + stale.reject(new Error('aborted')) + await drain() + + // The stale lane must not settle the batch that replaced it. + expect(batch.isRunning.value).toBe(true) + expect(batch.items.value.map((entry) => entry.status)).toEqual(['uploading']) + }) +}) From 70d3a655b0f039fc78c6a68b88c1d4ca0575811e Mon Sep 17 00:00:00 2001 From: Michael Wallner Date: Thu, 27 Aug 2026 16:32:50 +0200 Subject: [PATCH 5/7] =?UTF-8?q?=E2=9C=A8=20Add=20a=20tree=20view=20for=20s?= =?UTF-8?q?taged=20uploads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildUploadTree turns the flat file and directory lists into nested nodes, filling in any intermediate folder that neither list mentions and counting files recursively. Empty folders become nodes of their own, because the drop promised to mirror what was there. UploadTreeItem renders a folder and recurses; UploadFileRow carries the status, progress and per-file actions for both the tree and the flat list. Indentation and the expand affordance follow AssetFolderTree. Co-Authored-By: Claude Opus 5 (1M context) --- .../js/components/assets/UploadFileRow.vue | 154 ++++++++++++++++++ .../js/components/assets/UploadTreeItem.vue | 102 ++++++++++++ resources/js/lib/upload-tree.ts | 108 ++++++++++++ tests/js/lib/upload-tree.test.ts | 154 ++++++++++++++++++ 4 files changed, 518 insertions(+) create mode 100644 resources/js/components/assets/UploadFileRow.vue create mode 100644 resources/js/components/assets/UploadTreeItem.vue create mode 100644 resources/js/lib/upload-tree.ts create mode 100644 tests/js/lib/upload-tree.test.ts diff --git a/resources/js/components/assets/UploadFileRow.vue b/resources/js/components/assets/UploadFileRow.vue new file mode 100644 index 00000000..f4b69623 --- /dev/null +++ b/resources/js/components/assets/UploadFileRow.vue @@ -0,0 +1,154 @@ + + + diff --git a/resources/js/components/assets/UploadTreeItem.vue b/resources/js/components/assets/UploadTreeItem.vue new file mode 100644 index 00000000..27340774 --- /dev/null +++ b/resources/js/components/assets/UploadTreeItem.vue @@ -0,0 +1,102 @@ + + + diff --git a/resources/js/lib/upload-tree.ts b/resources/js/lib/upload-tree.ts new file mode 100644 index 00000000..fc356457 --- /dev/null +++ b/resources/js/lib/upload-tree.ts @@ -0,0 +1,108 @@ +/** + * Turns the flat result of a folder drop into the nested structure the upload + * dialog renders: folder nodes holding their child folders and their files, + * at whatever depth was dropped. + * + * Pure data, no Vue and no DOM, so Vitest can drive it directly. + */ + +/** The part of a staged upload item this transform needs. */ +export interface UploadTreeItem { + /** Folder path relative to the drop target, POSIX separators, '' at the root. */ + folderPath: string + file: { name: string } +} + +export interface UploadTreeNode { + /** Folder path from the drop target. '' for the root node. */ + path: string + /** Last segment of the path. '' for the root node. */ + name: string + /** Child folders, sorted by name. */ + folders: UploadTreeNode[] + /** Files directly in this folder, sorted by filename. */ + files: TFile[] + /** Files in this folder and in every folder below it. */ + fileCount: number +} + +const segmentsOf = (path: string): string[] => path.split('/').filter(Boolean) + +const makeNode = ( + path: string, + name: string +): UploadTreeNode => ({ path, name, folders: [], files: [], fileCount: 0 }) + +const byName = (a: { name: string }, b: { name: string }): number => a.name.localeCompare(b.name) + +/** + * Builds the tree from the staged files and the dropped directory list. + * + * Directories are passed separately because empty ones have no file to hint at + * them, and the drop promises to mirror what was dropped. Intermediate folders + * missing from either list are created on the way down, so a directory list of + * `['a/b/c']` alone still yields the full chain. + * + * Returns the root node, whose own `path` and `name` are empty; its files are + * the ones dropped at the target itself. + */ +export const buildUploadTree = ( + files: readonly TFile[], + directories: readonly string[] = [] +): UploadTreeNode => { + const root = makeNode('', '') + const index = new Map>([['', root]]) + + const folderAt = (path: string): UploadTreeNode => { + const known = index.get(path) + + if (known) { + return known + } + + let current = root + let walked = '' + + for (const segment of segmentsOf(path)) { + walked = walked ? `${walked}/${segment}` : segment + + let next = index.get(walked) + + if (!next) { + next = makeNode(walked, segment) + index.set(walked, next) + current.folders.push(next) + } + + current = next + } + + return current + } + + for (const directory of directories) { + folderAt(directory) + } + + for (const file of files) { + folderAt(file.folderPath).files.push(file) + } + + // Sorting and counting in one pass down the tree: a folder's count is its own + // files plus whatever its children reported. + const finish = (node: UploadTreeNode): number => { + node.folders.sort(byName) + node.files.sort((a, b) => byName(a.file, b.file)) + node.fileCount = node.files.length + + for (const child of node.folders) { + node.fileCount += finish(child) + } + + return node.fileCount + } + + finish(root) + + return root +} diff --git a/tests/js/lib/upload-tree.test.ts b/tests/js/lib/upload-tree.test.ts new file mode 100644 index 00000000..36abd911 --- /dev/null +++ b/tests/js/lib/upload-tree.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest' + +import { buildUploadTree, type UploadTreeItem, type UploadTreeNode } from '~/lib/upload-tree' + +const item = (folderPath: string, name: string): UploadTreeItem => ({ + folderPath, + file: { name }, +}) + +const findFolder = ( + node: UploadTreeNode, + path: string +): UploadTreeNode | undefined => { + for (const child of node.folders) { + if (child.path === path) { + return child + } + + const nested = findFolder(child, path) + + if (nested) { + return nested + } + } + + return undefined +} + +const folder = ( + node: UploadTreeNode, + path: string +): UploadTreeNode => { + const found = findFolder(node, path) + + if (!found) { + throw new Error(`no folder at "${path}"`) + } + + return found +} + +const names = (node: UploadTreeNode) => node.folders.map((child) => child.name) +const filenames = (node: UploadTreeNode) => node.files.map((file) => file.file.name) + +describe('buildUploadTree', () => { + it('returns an empty root for an empty drop', () => { + const root = buildUploadTree([], []) + + expect(root.path).toBe('') + expect(root.name).toBe('') + expect(root.folders).toEqual([]) + expect(root.files).toEqual([]) + expect(root.fileCount).toBe(0) + }) + + it('keeps files dropped at the root on the root node', () => { + const root = buildUploadTree([item('', 'a.png'), item('', 'b.png')], []) + + expect(filenames(root)).toEqual(['a.png', 'b.png']) + expect(root.folders).toEqual([]) + expect(root.fileCount).toBe(2) + }) + + it('nests to arbitrary depth', () => { + const root = buildUploadTree( + [item('a/b/c/d', 'deep.png')], + ['a', 'a/b', 'a/b/c', 'a/b/c/d'] + ) + + expect(names(root)).toEqual(['a']) + + const deepest = folder(root, 'a/b/c/d') + + expect(deepest.name).toBe('d') + expect(deepest.folders).toEqual([]) + expect(filenames(deepest)).toEqual(['deep.png']) + }) + + it('creates the intermediate folders a directory path implies', () => { + const root = buildUploadTree([], ['a/b/c']) + + expect(names(root)).toEqual(['a']) + expect(names(folder(root, 'a'))).toEqual(['b']) + expect(names(folder(root, 'a/b'))).toEqual(['c']) + expect(folder(root, 'a/b/c').fileCount).toBe(0) + }) + + it('shows empty folders as nodes', () => { + const root = buildUploadTree([item('photos', 'a.png')], ['photos', 'photos/raw', 'empty']) + + expect(names(root)).toEqual(['empty', 'photos']) + expect(folder(root, 'empty').files).toEqual([]) + expect(folder(root, 'empty').fileCount).toBe(0) + expect(folder(root, 'photos/raw').fileCount).toBe(0) + }) + + it('counts files in nested folders towards every ancestor', () => { + const root = buildUploadTree( + [ + item('', 'root.png'), + item('a', 'one.png'), + item('a/b', 'two.png'), + item('a/b/c', 'three.png'), + item('other', 'four.png'), + ], + [] + ) + + expect(root.fileCount).toBe(5) + expect(folder(root, 'a').fileCount).toBe(3) + expect(folder(root, 'a/b').fileCount).toBe(2) + expect(folder(root, 'a/b/c').fileCount).toBe(1) + expect(folder(root, 'other').fileCount).toBe(1) + }) + + it('orders folders and files by name regardless of input order', () => { + const root = buildUploadTree( + [item('zulu', 'z.png'), item('', 'b.png'), item('', 'a.png'), item('alpha', 'x.png')], + ['zulu', 'alpha', 'Mike'] + ) + + expect(names(root)).toEqual(['alpha', 'Mike', 'zulu']) + expect(filenames(root)).toEqual(['a.png', 'b.png']) + }) + + it('builds the same tree whatever order the input arrives in', () => { + const files = [item('a/b', 'two.png'), item('a', 'one.png'), item('', 'root.png')] + const directories = ['a', 'a/b', 'a/c'] + + const forwards = buildUploadTree(files, directories) + const backwards = buildUploadTree([...files].reverse(), [...directories].reverse()) + + expect(backwards).toEqual(forwards) + }) + + it('files a file whose folder was never listed as a directory', () => { + const root = buildUploadTree([item('missing/from/list', 'a.png')], []) + + expect(folder(root, 'missing/from/list').fileCount).toBe(1) + expect(root.fileCount).toBe(1) + }) + + it('ignores leading and trailing separators', () => { + const root = buildUploadTree([item('/a/b/', 'a.png')], ['/a/']) + + expect(names(root)).toEqual(['a']) + expect(names(folder(root, 'a'))).toEqual(['b']) + + const inner = folder(root, 'a/b') + + expect(inner.folders).toEqual([]) + expect(filenames(inner)).toEqual(['a.png']) + }) +}) From c2332c15738329ca97d21e83b2ad33a7fa267a8c Mon Sep 17 00:00:00 2001 From: Michael Wallner Date: Thu, 27 Aug 2026 16:32:58 +0200 Subject: [PATCH 6/7] =?UTF-8?q?=E2=9C=A8=20Drag=20a=20folder=20tree=20into?= =?UTF-8?q?=20the=20asset=20library?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropping folders from Finder or Explorer now mirrors the structure into asset folders and uploads the files into it, instead of flattening everything into the folder that happens to be open. A drop carrying folders renders as a tree so the structure is visible before anything uploads. Thumbnails stay below fifty files: four hundred decoded previews is a memory problem, not a nicety. The pre-flight states what was found before committing to it, including skipped system files, entries the browser could not read, and folder names the server will rename. Files past the size limit are marked before the batch starts rather than travelling and failing. Missing required fields are stated once with a count, not per file. Folders are resolved in one ensure-paths call whose answers are reused, so a second Upload click cannot re-post them. A drop containing folders is refused outright without asset_folders.manage: flattening four hundred files into one pile is worse than saying no. Co-Authored-By: Claude Opus 5 (1M context) --- resources/js/api/resources/asset-folders.ts | 20 + resources/js/components/assets/AssetGrid.vue | 58 +- .../js/components/assets/AssetListView.vue | 1 + .../js/components/assets/UploadDialog.vue | 704 ++++++++++++------ resources/js/i18n/de.json | 24 + resources/js/i18n/en.json | 24 + 6 files changed, 614 insertions(+), 217 deletions(-) diff --git a/resources/js/api/resources/asset-folders.ts b/resources/js/api/resources/asset-folders.ts index 92740a0a..831856e5 100644 --- a/resources/js/api/resources/asset-folders.ts +++ b/resources/js/api/resources/asset-folders.ts @@ -8,6 +8,20 @@ export interface AssetFoldersQueryParams extends BaseQueryParams { } } +export interface EnsureAssetFolderPathsPayload { + parent_id: string | null + paths: string[] +} + +export interface EnsureAssetFolderPathsResult { + /** Requested path string to the id of the folder it resolved to. */ + paths: Record + /** Every folder created or matched, so the caller can refresh its cache. */ + folders: AssetFolderResource[] + /** Segment names the server had to change (truncated, purified, placeholder). */ + renamed: Array<{ from: string; to: string }> +} + export class AssetFolders extends BaseResource< AssetFolderResource, UpsertAssetFolderPayload, @@ -20,4 +34,10 @@ export class AssetFolders extends BaseResource< super(client) this.basePath = `/mgmt/v1/spaces/${spaceId}/asset-folders` } + + public async ensurePaths( + payload: EnsureAssetFolderPathsPayload + ): Promise { + return this.client.post(this.getPath('ensure-paths'), payload) + } } diff --git a/resources/js/components/assets/AssetGrid.vue b/resources/js/components/assets/AssetGrid.vue index 5dd7c25b..9306050e 100644 --- a/resources/js/components/assets/AssetGrid.vue +++ b/resources/js/components/assets/AssetGrid.vue @@ -33,6 +33,12 @@ import SortSelect from '~/components/ui/SortSelect.vue' import TablePaginationFooter from '~/components/ui/TablePaginationFooter.vue' import type { AssetSelectionEntry } from '~/composables/useAssetSelection' import { getAssetManagerDragItems, type AssetManagerDragItem } from '~/lib/assets/assetDragAndDrop' +import { + readDroppedTree, + snapshotDropEntries, + type DropSnapshot, + type DroppedTree, +} from '~/lib/dropped-tree' import { downloadAssetFiles } from '~/lib/assets/downloadAssets' import { isEditableTarget } from '~/lib/shortcuts' import type { AssetShareSource } from '~/types/asset-distribution' @@ -123,7 +129,7 @@ const isManualCollectionView = computed( ) const showUploadDialog = ref(false) -const droppedFiles = ref([]) +const droppedTree = ref(null) const folderDialogOpen = ref(false) const dialogParentFolderId = ref(null) const editingFolder = ref(null) @@ -1474,15 +1480,54 @@ const handleDocumentDragLeave = (event: DragEvent) => { } } +const ingestDroppedTree = async (snapshot: DropSnapshot) => { + let tree: DroppedTree + + try { + tree = await readDroppedTree(snapshot) + } catch (error) { + toast.error( + String( + t('messages.assets.dropReadFailed', { + error: error instanceof Error ? error.message : 'Unknown error', + }) + ) + ) + return + } + + if (tree.directories.length && !canManageFolders.value) { + toast.error(String(t('messages.assets.folderDropDenied'))) + return + } + + if (!tree.files.length && !tree.directories.length) { + return + } + + droppedTree.value = tree + showUploadDialog.value = true +} + const handleDocumentDrop = (event: DragEvent) => { - if (!event.dataTransfer?.files?.length) { + if (!event.dataTransfer?.types.includes('Files')) { return } event.preventDefault() document.body.classList.remove('drag-over') - droppedFiles.value = Array.from(event.dataTransfer.files) - showUploadDialog.value = true + + // The DataTransfer is gone once the handler returns, so the entries are + // captured synchronously and traversed afterwards. + void ingestDroppedTree(snapshotDropEntries(event.dataTransfer)).catch((error: unknown) => { + toast.error( + String( + t('messages.assets.dropReadFailed', { + error: error instanceof Error ? error.message : 'Unknown error', + }) + ) + ) + }) } watch([folderId, tagId, collectionId], () => { @@ -1969,11 +2014,12 @@ onUnmounted(() => { v-model:open="showUploadDialog" :folder-id="activeFolderId || undefined" :space-id="spaceId" - :initial-files="droppedFiles" + :initial-tree="droppedTree" + :allow-folder-upload="canManageFolders" @update:open=" (open) => { if (!open) { - droppedFiles = [] + droppedTree = null } } " diff --git a/resources/js/components/assets/AssetListView.vue b/resources/js/components/assets/AssetListView.vue index 2f851ad8..c6ff98e4 100644 --- a/resources/js/components/assets/AssetListView.vue +++ b/resources/js/components/assets/AssetListView.vue @@ -1101,6 +1101,7 @@ onUnmounted(() => { v-model:open="showUploadDialog" :folder-id="folderId || undefined" :space-id="spaceId" + :allow-folder-upload="canManageFolders" /> +import { useQueryClient } from '@tanstack/vue-query' +import { toast } from 'vue-sonner' + +import { api } from '~/api' import AssetComplianceIndicator from '~/components/assets/AssetComplianceIndicator.vue' import Icon from '~/components/Icon.vue' import { Alert, AlertDescription } from '~/components/ui/alert' @@ -6,37 +10,97 @@ import { Button } from '~/components/ui/button' import { Dialog, DialogContent, DialogFooter, DialogHeaderCombined } from '~/components/ui/dialog' import { ScrollArea } from '~/components/ui/scroll-area' import { Spinner } from '~/components/ui/spinner' +import { useAssetUploadBatch, type StagedUploadFile } from '~/composables/useAssetUploadBatch' +import { queryKeys } from '~/composables/useQueryClient' +import { + normalizeFolderSegment, + readTreeFromFileList, + readDroppedTree, + snapshotDropEntries, + type DroppedFile, + type DroppedTree, +} from '~/lib/dropped-tree' +import { buildUploadTree } from '~/lib/upload-tree' -import DuplicateAssetDialog from './DuplicateAssetDialog.vue' import UploadDetailsDialog from './UploadDetailsDialog.vue' +import UploadFileRow from './UploadFileRow.vue' +import UploadTreeItem from './UploadTreeItem.vue' const { t } = useI18n() const { formatFileSize } = useFormat() const { getFileType, getFileIcon } = useFileUtils() const ulid = useUlid() const { alert } = useAlertDialog() +const queryClient = useQueryClient() const props = defineProps<{ spaceId: string folderId?: string open: boolean - initialFiles?: File[] // New prop for dropped files + initialTree?: DroppedTree | null + /** Whether dropped folders may be mirrored (requires asset_folders.manage). */ + allowFolderUpload?: boolean onUploadComplete?: () => void }>() const emit = defineEmits(['update:open']) -interface UploadFileWithProgress extends UploadFile { - progress: number - status: 'pending' | 'uploading' | 'error' | 'complete' - errorMessage?: string -} +/** Matches the server's `filesystems.max_upload_size` default of 500 MB. */ +const MAX_UPLOAD_FILE_BYTES = 500 * 1024 * 1024 +/** At this many files the grid with previews gives way to the compact list. */ +const COMPACT_THRESHOLD = 50 +/** Above this many files plus folders the upload asks for a confirmation. */ +const LARGE_BATCH_THRESHOLD = 500 const { uploadAsset } = useAssets(props.spaceId) const { ensureAssetFieldData, getMissingRequiredFields } = useAssetRequirements(props.spaceId) -const files = ref([]) +const { + enqueue, + retryItem, + items: batchItems, + isRunning: isBatchRunning, +} = useAssetUploadBatch() + +const files = ref([]) +const treeDirectories = ref([]) +/** Folder path to folder id for every path `ensure-paths` has already answered. */ +const ensuredPaths = ref(new Map()) +const skippedCount = ref(0) +const unreadableFileCount = ref(0) +const unreadableFolderCount = ref(0) +const renamedNames = ref>([]) + +/** + * Folder paths the user collapsed. Tracking what was closed rather than what is + * open keeps folders from a second drop expanded without seeding anything, and + * it lives only as long as the dialog does. + */ +const collapsedPaths = ref(new Set()) + +/** + * Only the batch can retry an item, so the button has to follow the batch + * rather than the staged copy this dialog holds. The two diverge: a new batch + * replaces the previous one, and a file this dialog still shows as failed may + * no longer be in it. + */ +const retryableIds = computed( + () => + new Set( + batchItems.value + .filter((item) => item.status === 'error' && !item.permanentError) + .map((item) => item.id) + ) +) + +const isCompact = computed(() => files.value.length >= COMPACT_THRESHOLD) + +/** Anything to show at all: an empty-folder drop stages folders and no file. */ +const hasStaged = computed(() => files.value.length > 0 || treeDirectories.value.length > 0) -const revokeFilePreviews = (items: UploadFileWithProgress[]) => { +/** A drop that carried folders is shown as a tree, whatever the file count. */ +const hasFolders = computed(() => treeDirectories.value.length > 0) + +const revokeFilePreviews = (items: StagedUploadFile[]) => { for (const file of items) { if (file.preview) { URL.revokeObjectURL(file.preview) @@ -48,68 +112,163 @@ const revokeFilePreviews = (items: UploadFileWithProgress[]) => { const clearFiles = () => { revokeFilePreviews(files.value) files.value = [] + treeDirectories.value = [] + collapsedPaths.value = new Set() + ensuredPaths.value = new Map() + skippedCount.value = 0 + unreadableFileCount.value = 0 + unreadableFolderCount.value = 0 + renamedNames.value = [] } const detailsOpen = ref(false) -const selectedFile = ref(null) +const selectedFile = ref(null) const fileInputRef = ref(null) -const isUploading = ref(false) - -// Process initial files if provided -watch( - () => props.initialFiles, - (newFiles) => { - if (newFiles && newFiles.length > 0) { - handleFilesAdded(newFiles) - } - }, - { immediate: true } -) +const isStarting = ref(false) -// Track if any upload is still in progress const hasUploadInProgress = computed(() => { return files.value.some((file) => file.status === 'uploading') }) +const isUploading = computed(() => isStarting.value || hasUploadInProgress.value) + +/** Exactly what the next Upload click would hand to the batch. */ +const stagedForUpload = computed(() => + files.value.filter((file) => !file.enqueued && (file.status === 'pending' || file.permanentError)) +) + +/** Paths `ensure-paths` has not answered yet; re-sending the rest is pure contention. */ +const pendingDirectories = computed(() => + treeDirectories.value.filter((path) => !ensuredPaths.value.has(path)) +) + +/** + * Whether an Upload click would do anything. `isUploading` is not enough: items + * queued behind another group's lanes are neither starting nor uploading, which + * would re-enable the button for a click that only re-runs the confirmations + * and re-posts `ensure-paths`. + */ +const hasWorkToUpload = computed( + () => stagedForUpload.value.length > 0 || pendingDirectories.value.length > 0 +) + const filesWithMissingRequirements = computed(() => { - return files.value.filter( - (file) => getMissingRequiredFields(file, props.folderId ?? file.folder_id ?? null).length > 0 + return stagedForUpload.value.filter( + (file) => + file.status === 'pending' && + getMissingRequiredFields(file, props.folderId ?? file.folder_id ?? null).length > 0 ) }) -// Common function to process files whether from input or dropped -const handleFilesAdded = (newFilesArray: File[]) => { - const newFiles = newFilesArray.map((file) => { - const fileType = getFileType(file.type) - const fileId = ulid() +const totalSize = computed(() => files.value.reduce((sum, file) => sum + file.file.size, 0)) + +const mergeRenamed = (renamed: Array<{ from: string; to: string }>) => { + const merged = new Map(renamedNames.value.map((entry) => [entry.from, entry.to])) - let preview: string | undefined - if (fileType === 'image') { - preview = URL.createObjectURL(file) + for (const entry of renamed) { + merged.set(entry.from, entry.to) + } + + renamedNames.value = [...merged].map(([from, to]) => ({ from, to })) +} + +const predictRenames = (directories: string[]) => { + const renamed: Array<{ from: string; to: string }> = [] + + for (const directory of directories) { + for (const segment of directory.split('/')) { + const normalized = normalizeFolderSegment(segment) + + if (normalized !== segment) { + renamed.push({ from: segment, to: normalized }) + } } + } + + mergeRenamed(renamed) +} + +const addFiles = (dropped: DroppedFile[]) => { + const compact = files.value.length + dropped.length >= COMPACT_THRESHOLD + + const next = dropped.map(({ file, path }): StagedUploadFile => { + const fileType = getFileType(file.type) + const oversize = file.size > MAX_UPLOAD_FILE_BYTES return { - id: fileId, + id: ulid(), file, - preview, + // Compact mode exists to avoid decoding hundreds of thumbnails, so no + // object URLs are created once the list is headed there. + preview: + !compact && fileType === 'image' ? URL.createObjectURL(file) : undefined, data: {}, metadata: {}, folder_id: props.folderId, + folderPath: path, tags: [], type: fileType, progress: 0, - status: 'pending' as const, + status: oversize ? 'error' : 'pending', + errorMessage: oversize + ? String( + t('labels.assets.fileTooLarge', { + size: formatFileSize(MAX_UPLOAD_FILE_BYTES), + }) + ) + : undefined, + permanentError: oversize || undefined, } }) - newFiles.forEach((file) => ensureAssetFieldData(file)) - files.value = [...files.value, ...newFiles] + next.forEach((file) => ensureAssetFieldData(file)) + files.value = [...files.value, ...next] +} + +const ingestTree = (tree: DroppedTree) => { + treeDirectories.value = [...new Set([...treeDirectories.value, ...tree.directories])] + skippedCount.value += tree.skipped + unreadableFileCount.value += tree.unreadableFiles + unreadableFolderCount.value += tree.unreadableDirectories + predictRenames(tree.directories) + addFiles(tree.files) +} + +watch( + () => props.initialTree, + (tree) => { + if (tree && (tree.files.length || tree.directories.length)) { + ingestTree(tree) + } + }, + { immediate: true } +) + +watch(isCompact, (compact) => { + if (compact) { + revokeFilePreviews(files.value) + } +}) + +/** + * The dropped structure, folders and files nested as they were dropped. Nodes + * hold the very same staged objects, so a row follows its file's status. + */ +const uploadTree = computed(() => buildUploadTree(files.value, treeDirectories.value)) + +const toggleFolder = (path: string) => { + if (collapsedPaths.value.has(path)) { + collapsedPaths.value.delete(path) + } else { + collapsedPaths.value.add(path) + } } const handleFileChange = (e: Event) => { const target = e.target as HTMLInputElement + if (target.files && target.files.length > 0) { - handleFilesAdded(Array.from(target.files)) + ingestTree(readTreeFromFileList(target.files)) } // Reset the input so the same file can be selected again @@ -126,7 +285,7 @@ const removeFile = (id: string) => { files.value = files.value.filter((file) => file.id !== id) } -const openFileDetails = (file: UploadFileWithProgress) => { +const openFileDetails = (file: StagedUploadFile) => { selectedFile.value = { ...file, data: structuredClone(file.data || {}), @@ -136,154 +295,172 @@ const openFileDetails = (file: UploadFileWithProgress) => { detailsOpen.value = true } +// Mutates the staged object in place: the batch holds a reference to the very +// same object once enqueued, so replacing it would split the two views. const handleFileDetailsSave = (file: UploadFile) => { const currentFile = selectedFile.value if (!currentFile) { return } - files.value = files.value.map((existingFile) => { - if (existingFile.id !== currentFile.id) { - return existingFile - } + const existing = files.value.find((stagedFile) => stagedFile.id === currentFile.id) + if (!existing) { + return + } - return { - ...existingFile, - ...file, - progress: existingFile.progress, - status: existingFile.status, - errorMessage: existingFile.errorMessage, - } - }) + existing.file = file.file + existing.preview = file.preview + existing.type = file.type + existing.data = file.data + existing.metadata = file.metadata + existing.tags = file.tags + existing.folder_id = file.folder_id - selectedFile.value = - files.value.find((existingFile) => existingFile.id === currentFile.id) || null + selectedFile.value = existing } -const updateFileProgress = (id: string, progress: number) => { - const fileIndex = files.value.findIndex((file) => file.id === id) - if (fileIndex !== -1) { - files.value[fileIndex].progress = progress +const statusLabel = (file: StagedUploadFile) => { + if (file.errorMessage) { + return file.errorMessage } + + return file.cancelled + ? String(t('labels.assets.batch.cancelled')) + : String(t('labels.assets.unknown')) } -const updateFileStatus = ( - id: string, - status: 'pending' | 'uploading' | 'error' | 'complete', - errorMessage?: string -) => { - const fileIndex = files.value.findIndex((file) => file.id === id) - if (fileIndex !== -1) { - files.value[fileIndex].status = status - if (errorMessage) { - files.value[fileIndex].errorMessage = errorMessage - } +const handleUpload = async () => { + if (isStarting.value || !hasWorkToUpload.value) { + return } -} -type DuplicateDecision = 'use-existing' | 'upload-anyway' | 'cancel' + const toEnqueue = stagedForUpload.value -const duplicatePrompt = ref<{ - filename: string - duplicate: AssetUploadDuplicate - resolve: (decision: DuplicateDecision) => void -} | null>(null) + if (filesWithMissingRequirements.value.length) { + const count = filesWithMissingRequirements.value.length + const confirmed = await alert.confirm( + `${t('labels.assets.uploadRequirementsSummary', { count })} ${t('messages.assets.uploadRequirementsConfirm')}`, + { + title: String(t('labels.assets.uploadRequirementsTitle')), + confirmLabel: String(t('actions.continue')), + } + ) -const promptDuplicate = (filename: string, duplicate: AssetUploadDuplicate) => { - return new Promise((resolve) => { - duplicatePrompt.value = { filename, duplicate, resolve } - }) -} + if (!confirmed) { + return + } + } -const resolveDuplicatePrompt = (decision: DuplicateDecision) => { - duplicatePrompt.value?.resolve(decision) - duplicatePrompt.value = null -} + if (toEnqueue.length + pendingDirectories.value.length > LARGE_BATCH_THRESHOLD) { + const confirmed = await alert.confirm( + String( + pendingDirectories.value.length + ? t('messages.assets.largeBatchWithFoldersConfirm', { + files: toEnqueue.length, + folders: pendingDirectories.value.length, + }) + : t('messages.assets.largeBatchConfirm', { count: toEnqueue.length }) + ), + { + title: String(t('labels.assets.uploadAssets')), + confirmLabel: String(t('actions.continue')), + } + ) -const performUpload = async (file: UploadFileWithProgress) => { - updateFileStatus(file.id, 'uploading') + if (!confirmed) { + return + } + } + + isStarting.value = true try { - // Upload with progress tracking - const result = await uploadAsset(file, (progress) => { - updateFileProgress(file.id, progress) - }) + // Only the paths the server has not answered yet: a second click must not + // take the per-space lock again just to be told the same folder ids. + if (pendingDirectories.value.length) { + const result = await api.forSpace(props.spaceId).assetFolders.ensurePaths({ + parent_id: props.folderId ?? null, + paths: [...pendingDirectories.value], + }) + + mergeRenamed(result.renamed) + + for (const [path, folderId] of Object.entries(result.paths)) { + ensuredPaths.value.set(path, folderId) + } - if (result?.status === 'success') { - updateFileStatus(file.id, 'complete') - return + queryClient.invalidateQueries({ + queryKey: queryKeys.assetFolders(props.spaceId).lists(), + }) } - if (result?.status === 'duplicate') { - const decision = await promptDuplicate(file.file.name, result.duplicate) + for (const file of toEnqueue) { + if (!file.folderPath || file.status !== 'pending') { + continue + } - if (decision === 'upload-anyway') { - const forced = await uploadAsset( - file, - (progress) => updateFileProgress(file.id, progress), - { force: true } - ) + const folderId = ensuredPaths.value.get(file.folderPath) - if (forced?.status === 'success') { - updateFileStatus(file.id, 'complete') - } else { - updateFileStatus(file.id, 'error', String(t('composables.assets.uploadError'))) - } - } else if (decision === 'use-existing') { - // The existing asset already satisfies the intent of this upload. - updateFileStatus(file.id, 'complete') + if (folderId) { + file.folder_id = folderId } else { - updateFileStatus(file.id, 'pending') - updateFileProgress(file.id, 0) + file.status = 'error' + file.errorMessage = String(t('labels.assets.batch.folderUnavailable')) + file.permanentError = true } - return } - - updateFileStatus(file.id, 'error', String(t('composables.assets.uploadError'))) } catch (error) { - updateFileStatus( - file.id, - 'error', - error instanceof Error ? error.message : String(t('composables.assets.uploadError')) + toast.error( + String( + t('messages.assets.folderCreateFailed', { + error: error instanceof Error ? error.message : 'Unknown error', + }) + ) ) + isStarting.value = false + return } -} - -const handleUpload = async () => { - if (filesWithMissingRequirements.value.length) { - const confirmed = await alert.confirm(String(t('messages.assets.uploadRequirementsConfirm')), { - title: String(t('labels.assets.uploadRequirementsTitle')), - confirmLabel: String(t('actions.continue')), - }) - - if (!confirmed) { - return - } - } - - isUploading.value = true - // Upload files sequentially to avoid overwhelming the server - for (const file of files.value) { - // Skip already completed uploads - if (file.status === 'complete') continue + toEnqueue.forEach((file) => { + file.enqueued = true + }) - await performUpload(file) + if (toEnqueue.length) { + enqueue(toEnqueue, { + upload: (payload, onProgress, options) => + uploadAsset(payload, onProgress, { ...options, silent: true }), + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.assets(props.spaceId).lists(), + }) + }, + }) + } else if (treeDirectories.value.length && !files.value.length) { + // A drop of empty folders only: the tree is mirrored, nothing to upload. + toast.success(String(t('messages.assets.foldersCreated'))) + props.onUploadComplete?.() + emit('update:open', false) + clearFiles() } - isUploading.value = false + isStarting.value = false +} - // Check if all files are completed successfully - const allCompleted = files.value.every((file) => file.status === 'complete') - if (allCompleted) { - // Reset state and close dialog - if (props.onUploadComplete) { - props.onUploadComplete() +watch( + () => + files.value.length > 0 && + !isStarting.value && + files.value.every((file) => file.status === 'complete'), + (allComplete) => { + if (!allComplete) { + return } + + props.onUploadComplete?.() emit('update:open', false) clearFiles() } -} +) const handleBrowseClick = () => { if (fileInputRef.value) { @@ -291,12 +468,8 @@ const handleBrowseClick = () => { } } +// Closing while a batch runs is fine: the docked panel keeps showing it. const onOpenChange = (open: boolean) => { - // Prevent closing if upload is in progress - if (!open && hasUploadInProgress.value) { - return - } - if (!open) { selectedFile.value = null clearFiles() @@ -321,7 +494,7 @@ const handleReplaceFile = () => { const fileType = getFileType(newFile.type) let preview: string | undefined - if (fileType === 'image') { + if (fileType === 'image' && !isCompact.value) { preview = URL.createObjectURL(newFile) } @@ -329,31 +502,27 @@ const handleReplaceFile = () => { URL.revokeObjectURL(selectedFile.value.preview) } - files.value = files.value.map((f) => - f.id === selectedFile.value?.id - ? { - ...f, - file: newFile, - preview, - type: fileType, - progress: 0, - status: 'pending', - errorMessage: undefined, - } - : f - ) + const existing = files.value.find((file) => file.id === selectedFile.value?.id) + + if (existing) { + existing.file = newFile + existing.preview = preview + existing.type = fileType + existing.progress = 0 + existing.status = 'pending' + existing.errorMessage = undefined + existing.permanentError = undefined + } selectedFile.value = { ...selectedFile.value, file: newFile, preview, type: fileType, - } as UploadFileWithProgress - - // Add progress tracking properties - ;(selectedFile.value as UploadFileWithProgress).progress = 0 - ;(selectedFile.value as UploadFileWithProgress).status = 'pending' - ;(selectedFile.value as UploadFileWithProgress).errorMessage = undefined + progress: 0, + status: 'pending', + errorMessage: undefined, + } } } specificFileInput.click() @@ -362,19 +531,33 @@ const handleReplaceFile = () => { const handleDrop = (e: DragEvent) => { e.preventDefault() e.stopPropagation() - if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { - const inputElement = fileInputRef.value - if (inputElement) { - const dataTransfer = new DataTransfer() - for (let i = 0; i < e.dataTransfer.files.length; i++) { - dataTransfer.items.add(e.dataTransfer.files[i]) - } - inputElement.files = dataTransfer.files - const changeEvent = new Event('change', { bubbles: true }) - inputElement.dispatchEvent(changeEvent) - } + if (!e.dataTransfer) { + return } + + const snapshot = snapshotDropEntries(e.dataTransfer) + + readDroppedTree(snapshot) + .then((tree) => { + if (tree.directories.length && !props.allowFolderUpload) { + toast.error(String(t('messages.assets.folderDropDenied'))) + return + } + + if (tree.files.length || tree.directories.length) { + ingestTree(tree) + } + }) + .catch((error: unknown) => { + toast.error( + String( + t('messages.assets.dropReadFailed', { + error: error instanceof Error ? error.message : 'Unknown error', + }) + ) + ) + }) } const handleDragOver = (e: DragEvent) => { @@ -382,7 +565,7 @@ const handleDragOver = (e: DragEvent) => { e.stopPropagation() } -const getProgressColor = (status: 'pending' | 'uploading' | 'error' | 'complete') => { +const getProgressColor = (status: StagedUploadFile['status']) => { switch (status) { case 'uploading': return 'bg-accent' @@ -395,11 +578,6 @@ const getProgressColor = (status: 'pending' | 'uploading' | 'error' | 'complete' } } -// Retry uploading a failed file -const retryUpload = async (file: UploadFileWithProgress) => { - updateFileProgress(file.id, 0) - await performUpload(file) -} diff --git a/resources/js/i18n/de.json b/resources/js/i18n/de.json index e7d3bae1..7149f4f7 100644 --- a/resources/js/i18n/de.json +++ b/resources/js/i18n/de.json @@ -696,6 +696,22 @@ "lastModified": "Zuletzt geändert", "unknown": "Unbekannt", "uploading": "Wird hochgeladen...", + "fileTooLarge": "Größer als das Upload-Limit von {size}", + "batch": { + "title": "Assets werden hochgeladen", + "doneTitle": "Upload abgeschlossen", + "progress": "{complete} von {total} hochgeladen", + "failedCount": "{count} fehlgeschlagen", + "currentFolder": "Aktueller Ordner", + "cancelled": "Abgebrochen", + "summaryFiles": "{count} Datei | {count} Dateien", + "summaryFolders": "{count} Ordner | {count} Ordner", + "summarySkipped": "{count} Systemdatei übersprungen | {count} Systemdateien übersprungen", + "summaryUnreadableFiles": "{count} Datei konnte nicht gelesen werden | {count} Dateien konnten nicht gelesen werden", + "summaryUnreadableFolders": "{count} Ordner konnte nicht gelesen werden, sein Inhalt fehlt | {count} Ordner konnten nicht gelesen werden, ihre Inhalte fehlen", + "renamedFolders": "Einige Ordnernamen wurden angepasst: {names}", + "folderUnavailable": "Der Zielordner konnte nicht erstellt werden" + }, "replaceMedia": "Medien ersetzen", "uploadPoster": "Poster hochladen", "uploadPosterHint": "Wird anstelle der generierten Frames angezeigt", @@ -4303,6 +4319,8 @@ "download": "Herunterladen", "copyUrl": "URL kopieren", "applyTags": "Tags anwenden", + "retryFailed": "Fehlgeschlagene wiederholen", + "cancelUpload": "Upload abbrechen", "addSelected": "{count} ausgewählte hinzufügen", "addToCollection": "Zu Sammlung hinzufügen…", "removeFromCollection": "Aus Sammlung entfernen", @@ -4992,6 +5010,12 @@ "bulkDeleteFromCollectionConfirmation": "{count} Asset endgültig aus der gesamten Bibliothek löschen? Es wird aus allen Sammlungen und Ordnern entfernt, in denen es vorkommt – nicht nur aus dieser Sammlung – und kann nicht wiederhergestellt werden. Um es nur aus dieser Sammlung zu nehmen, nutze stattdessen „Aus Sammlung entfernen“. | {count} Assets endgültig aus der gesamten Bibliothek löschen? Sie werden aus allen Sammlungen und Ordnern entfernt, in denen sie vorkommen – nicht nur aus dieser Sammlung – und können nicht wiederhergestellt werden. Um sie nur aus dieser Sammlung zu nehmen, nutze stattdessen „Aus Sammlung entfernen“.", "forceDeleteConfirmation": "\"{name}\" ist noch in {count} Inhalten verknüpft. Beim erzwungenen Löschen wird die Asset-Datei entfernt, bestehende Inhaltsreferenzen bleiben jedoch erhalten.", "uploadRequirementsConfirm": "Einige Dateien haben fehlende Pflicht-Metadaten. Du kannst sie trotzdem hochladen, aber sie bleiben markiert, bis die fehlenden Werte ergänzt wurden.", + "folderDropDenied": "Zum Ablegen von Ordnern fehlt die Berechtigung, Asset-Ordner zu verwalten.", + "largeBatchConfirm": "Dadurch werden {count} Dateien hochgeladen. Fortfahren?", + "largeBatchWithFoldersConfirm": "Dadurch werden {files} Dateien hochgeladen und {folders} Ordner angelegt. Fortfahren?", + "dropReadFailed": "Der Drop konnte nicht gelesen werden: {error}", + "folderCreateFailed": "Das Erstellen der Ordner ist fehlgeschlagen: {error}", + "foldersCreated": "Ordner erstellt", "confirmDelete": "Dieses Asset löschen? Diese Aktion kann nicht rückgängig gemacht werden.", "confirmDeleteFromCollection": "Dieses Asset aus der Sammlung entfernen?", "bulkDeleteConfirmation": "{count} Asset löschen? Dies kann nicht rückgängig gemacht werden. | {count} Assets löschen? Dies kann nicht rückgängig gemacht werden.", diff --git a/resources/js/i18n/en.json b/resources/js/i18n/en.json index 493e866f..7d40af60 100644 --- a/resources/js/i18n/en.json +++ b/resources/js/i18n/en.json @@ -687,6 +687,22 @@ "lastModified": "Last Modified", "unknown": "Unknown", "uploading": "Uploading...", + "fileTooLarge": "Larger than the {size} upload limit", + "batch": { + "title": "Uploading assets", + "doneTitle": "Upload finished", + "progress": "{complete} of {total} uploaded", + "failedCount": "{count} failed", + "currentFolder": "Current folder", + "cancelled": "Cancelled", + "summaryFiles": "{count} file | {count} files", + "summaryFolders": "{count} folder | {count} folders", + "summarySkipped": "{count} system file skipped | {count} system files skipped", + "summaryUnreadableFiles": "{count} file could not be read | {count} files could not be read", + "summaryUnreadableFolders": "{count} folder could not be listed, its contents were left out | {count} folders could not be listed, their contents were left out", + "renamedFolders": "Some folder names were adjusted: {names}", + "folderUnavailable": "The target folder could not be created" + }, "replaceMedia": "Replace Media", "uploadPoster": "Upload Poster", "uploadPosterHint": "Shown instead of the generated frames", @@ -4294,6 +4310,8 @@ "download": "Download", "copyUrl": "Copy URL", "applyTags": "Apply tags", + "retryFailed": "Retry failed", + "cancelUpload": "Cancel upload", "addSelected": "Add {count} selected", "addToCollection": "Add to collection…", "removeFromCollection": "Remove from collection", @@ -4976,6 +4994,12 @@ "bulkDeleteFromCollectionConfirmation": "Permanently delete {count} asset from your entire library? This removes it from every collection and folder it appears in — not just this collection — and cannot be undone. To only take it out of this collection, use “Remove from collection” instead. | Permanently delete {count} assets from your entire library? This removes them from every collection and folder they appear in — not just this collection — and cannot be undone. To only take them out of this collection, use “Remove from collection” instead.", "forceDeleteConfirmation": "\"{name}\" is still linked in {count} contents. Force delete removes the asset file, but existing content references will remain.", "uploadRequirementsConfirm": "Some files are missing required metadata. You can still upload them, but they will stay flagged until the missing values are filled in.", + "folderDropDenied": "Dropping folders needs permission to manage asset folders.", + "largeBatchConfirm": "This will upload {count} files. Continue?", + "largeBatchWithFoldersConfirm": "This will upload {files} files and create {folders} folders. Continue?", + "dropReadFailed": "The drop could not be read: {error}", + "folderCreateFailed": "Creating the folders failed: {error}", + "foldersCreated": "Folders created", "confirmDelete": "Delete this asset? This cannot be undone.", "confirmDeleteFromCollection": "Remove this asset from the collection?", "bulkDeleteConfirmation": "Delete {count} asset? This cannot be undone. | Delete {count} assets? This cannot be undone.", From 96c18b34cef45f638a28c526e5efc83974d43e4b Mon Sep 17 00:00:00 2001 From: Michael Wallner Date: Thu, 27 Aug 2026 16:33:02 +0200 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=94=A7=20Regenerate=20the=20auto-impo?= =?UTF-8?q?rt=20declarations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- auto-imports.d.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/auto-imports.d.ts b/auto-imports.d.ts index 8464728e..89b7cc19 100644 --- a/auto-imports.d.ts +++ b/auto-imports.d.ts @@ -114,6 +114,7 @@ declare global { const useAssetSelection: typeof import('./resources/js/composables/useAssetSelection').useAssetSelection const useAssetShares: typeof import('./resources/js/composables/useAssetShares').useAssetShares const useAssetTags: typeof import('./resources/js/composables/useAssetTags').useAssetTags + const useAssetUploadBatch: typeof import('./resources/js/composables/useAssetUploadBatch').useAssetUploadBatch const useAssetVersions: typeof import('./resources/js/composables/useAssetVersions').useAssetVersions const useAssets: typeof import('./resources/js/composables/useAssets').useAssets const useAttrs: typeof import('vue').useAttrs @@ -261,6 +262,9 @@ declare global { export type { AssetSelectionEntry, AssetSelectionModifiers } from './resources/js/composables/useAssetSelection' import('./resources/js/composables/useAssetSelection') // @ts-ignore + export type { BatchItemStatus, BatchUploadItem, StagedUploadFile, BatchUploadFn, BatchEnqueueDeps } from './resources/js/composables/useAssetUploadBatch' + import('./resources/js/composables/useAssetUploadBatch') + // @ts-ignore export type { UploadAssetOutcome } from './resources/js/composables/useAssets' import('./resources/js/composables/useAssets') // @ts-ignore @@ -433,6 +437,7 @@ declare module 'vue' { readonly useAssetSelection: UnwrapRef readonly useAssetShares: UnwrapRef readonly useAssetTags: UnwrapRef + readonly useAssetUploadBatch: UnwrapRef readonly useAssetVersions: UnwrapRef readonly useAssets: UnwrapRef readonly useAttrs: UnwrapRef