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/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]); + } + } +} 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 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/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/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 DuplicateAssetDialog from '~/components/assets/DuplicateAssetDialog.vue' +import Icon from '~/components/Icon.vue' +import { Button } from '~/components/ui/button' +import { ScrollArea } from '~/components/ui/scroll-area' +import { Spinner } from '~/components/ui/spinner' +import { useAssetUploadBatch } from '~/composables/useAssetUploadBatch' + +const { t } = useI18n() + +const { + items, + isRunning, + isPanelDismissed, + duplicatePrompt, + batchTotals, + cancel, + retryFailed, + dismissPanel, + resolveDuplicatePrompt, +} = useAssetUploadBatch() + +const visible = computed(() => items.value.length > 0 && !isPanelDismissed.value) + +const failures = computed(() => items.value.filter((item) => item.status === 'error')) + +const failureLabel = (item: (typeof items.value)[number]) => { + if (item.cancelled) { + return String(t('labels.assets.batch.cancelled')) + } + + return item.errorMessage || String(t('composables.assets.uploadError')) +} + +const hasRetryable = computed(() => failures.value.some((item) => !item.permanentError)) + + + diff --git a/resources/js/components/assets/UploadDialog.vue b/resources/js/components/assets/UploadDialog.vue index a4754b66..f4a482dc 100644 --- a/resources/js/components/assets/UploadDialog.vue +++ b/resources/js/components/assets/UploadDialog.vue @@ -1,4 +1,8 @@ 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/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/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.", 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/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/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')); + } +} 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']) + }) +}) 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') + }) +}) 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']) + }) +})