import { adminApi } from '@strapi/admin/strapi-admin'; import { openUploadProgress, setUploadFailed, retryCancelledFiles, setFileError, setFileComplete, setFileUploading, setFileProgress } from '../store/uploadProgress.mjs'; import { createRafBatcher } from '../utils/createRafBatcher.mjs'; import { getFilenameFromUrl } from '../utils/files.mjs'; import { uploadFileViaXHR, UploadAbortedError } from './uploadFileViaXHR.mjs'; /** * Stores the original upload entries for a batch (keyed by uploadId) to enable retry. */ const uploadRegistry = new Map(); const registerUploadEntries = (uploadId, entries)=>{ uploadRegistry.set(uploadId, entries); }; const getUploadEntries = (uploadId)=>{ return uploadRegistry.get(uploadId); }; /** * Manages abort controllers for in-flight uploads. * * Design decision: Uses a Map to track uploads by their unique uploadId. * Redux state cannot store function references (abort controllers), RTK Query's * signal is only accessible within the queryFn, and the upload is triggered in * AssetsPage but cancelled from UploadProgressDialog. */ const abortControllers = new Map(); const registerAbortController = (uploadId, controller)=>{ abortControllers.set(uploadId, controller); }; const unregisterAbortController = (uploadId)=>{ abortControllers.delete(uploadId); }; /** * Aborts an upload by its uploadId. * Called from the UploadProgressDialog when the user clicks cancel or close. */ const abortUpload = (uploadId)=>{ const controller = abortControllers.get(uploadId); if (controller) { controller.abort(); unregisterAbortController(uploadId); } }; /** * Uploads the given entry indices one at a time through the single-file endpoint. * * For each file: dispatches "uploading", wires `XHR.upload.onprogress` through a * per-frame batcher to `setFileProgress`, awaits the XHR, then dispatches * "complete" or "error". Each file is wrapped in its own try/catch so one failure * does not stop the batch. An abort stops the loop without starting further files. */ const runSequentialUpload = async ({ entries, indices, token, uploadId, abortController, dispatch })=>{ const url = `${window.strapi.backendURL}/upload/unstable/upload-file`; const uploaded = []; for (const index of indices){ if (abortController.signal.aborted) { break; } const entry = entries[index]; if (!entry) { continue; } const fileName = entry.fileInfo?.name ?? entry.file.name; dispatch(setFileUploading({ name: fileName, index, size: entry.file.size })); const formData = new FormData(); formData.append('files', entry.file); formData.append('fileInfo', JSON.stringify(entry.fileInfo)); // Coalesce high-frequency progress events into one dispatch per frame. const batcher = createRafBatcher((bytes)=>{ dispatch(setFileProgress({ index, bytes })); }); try { const file = await uploadFileViaXHR(url, token, formData, abortController.signal, (bytes)=>batcher.schedule(bytes)); batcher.cancel(); uploaded.push(file); dispatch(setFileComplete({ index, file })); } catch (err) { batcher.cancel(); if (err instanceof UploadAbortedError) { break; } const message = err instanceof Error ? err.message : 'Upload failed'; dispatch(setFileError({ index, name: fileName, message })); } } unregisterAbortController(uploadId); return { data: uploaded }; }; /* ------------------------------------------------------------------------------------------------- * URL upload flow (SSE) — kept as-is for this iteration * -----------------------------------------------------------------------------------------------*/ /** * Parses a raw SSE text chunk into event/data pairs. * * SSE format: * event: \n * data: \n * \n */ const parseSSEEvents = (chunk)=>{ const events = []; const blocks = chunk.split('\n\n').filter(Boolean); for (const block of blocks){ let event = ''; let data = ''; for (const line of block.split('\n')){ if (line.startsWith('event: ')) { event = line.slice(7); } else if (line.startsWith('data: ')) { data = line.slice(6); } } if (event && data) { events.push({ event, data }); } } return events; }; /** * Makes a streaming upload-from-URLs request to the server. * Sends URLs as JSON body instead of FormData. */ const fetchUrlUploadStream = async ({ token, urls, folderId, signal })=>{ const backendURL = window.strapi.backendURL; const headers = { 'Content-Type': 'application/json' }; if (token) { headers.Authorization = `Bearer ${token}`; } return fetch(`${backendURL}/upload/unstable/stream-from-urls`, { method: 'POST', headers, body: JSON.stringify({ urls, folderId }), signal }); }; /** * Processes an SSE stream from the URL upload endpoint. * Dispatches Redux actions for each file event and returns the final result. */ const processSSEStream = async ({ response, dispatch })=>{ const reader = response.body.getReader(); const decoder = new TextDecoder(); let streamResult = null; let buffer = ''; while(true){ const { done, value } = await reader.read(); if (done) { break; } buffer += decoder.decode(value, { stream: true }); // Process complete SSE events from the buffer const lastDoubleNewline = buffer.lastIndexOf('\n\n'); if (lastDoubleNewline === -1) { continue; } const completePart = buffer.slice(0, lastDoubleNewline + 2); buffer = buffer.slice(lastDoubleNewline + 2); const events = parseSSEEvents(completePart); for (const { event, data } of events){ const parsed = JSON.parse(data); const index = parsed.index; switch(event){ case 'file:fetching': { // URL is being fetched server-side - mark as uploading (processing) dispatch(setFileUploading({ name: parsed.url, index, size: 0 })); break; } case 'file:uploading': { const payload = parsed; dispatch(setFileUploading({ name: payload.name, index, size: payload.size })); break; } case 'file:complete': { const payload = parsed; dispatch(setFileComplete({ index, file: payload.file })); break; } case 'file:error': { const payload = parsed; dispatch(setFileError({ index, name: payload.name, message: payload.message })); break; } case 'stream:complete': { const payload = parsed; streamResult = { data: payload.data, errors: payload.errors }; break; } default: console.error(`[SSE Upload] unknown event: ${event}`, parsed); } } } return streamResult; }; const uploadApi = adminApi.enhanceEndpoints({ addTagTypes: [ 'Asset', 'Folder' ] }).injectEndpoints({ endpoints: (builder)=>({ /** * Upload files sequentially, one request per file, to `/upload/unstable/upload-file`. * Real per-file byte progress comes from `XHR.upload.onprogress`. */ uploadFiles: builder.mutation({ queryFn: async ({ formData, totalFiles }, { dispatch, getState })=>{ const token = getState().admin_app?.token; // Extract the original files and their per-file fileInfo from the combined FormData. const files = formData.getAll('files'); const fileInfoJson = formData.get('fileInfo'); const fileInfoArray = JSON.parse(fileInfoJson); const entries = files.map((file, index)=>({ file, fileInfo: fileInfoArray[index] ?? { name: file.name, caption: null, alternativeText: null, folder: null } })); const fileNames = entries.map((entry)=>entry.fileInfo.name ?? entry.file.name); const fileSizes = entries.map((entry)=>entry.file.size); // Open the progress dialog dispatch(openUploadProgress({ totalFiles, fileNames, fileSizes })); // Get the uploadId from state after dispatching const uploadId = getState().uploadProgress.uploadId; // Store original entries for retry functionality registerUploadEntries(uploadId, entries); // One AbortController per batch const abortController = new AbortController(); registerAbortController(uploadId, abortController); return runSequentialUpload({ entries, indices: entries.map((_, index)=>index), token, uploadId, abortController, dispatch }); }, invalidatesTags: [ { type: 'Asset', id: 'LIST' } ] }), /** * Upload a single, already-prepared file WITHOUT opening the global * upload-progress dialog. For flows that create one asset programmatically * (e.g. crop → "Save as copy"), where the bulk-upload progress UI is * inappropriate. Same endpoint as `uploadFiles`, minus the progress/retry * machinery. */ uploadFileSilently: builder.mutation({ queryFn: async ({ file, fileInfo }, { getState })=>{ const token = getState().admin_app?.token; const url = `${window.strapi.backendURL}/upload/unstable/upload-file`; const formData = new FormData(); formData.append('files', file); formData.append('fileInfo', JSON.stringify(fileInfo)); try { const uploaded = await uploadFileViaXHR(url, token, formData, new AbortController().signal, ()=>{}); return { data: uploaded }; } catch (err) { const message = err instanceof Error ? err.message : 'Upload failed'; return { error: { name: 'UnknownError', message } }; } }, invalidatesTags: [ { type: 'Asset', id: 'LIST' } ] }), /** * Retry uploading cancelled files. * Maps cancelled rows back to their original entries and re-runs only those * through the same sequential loop with a fresh AbortController. */ retryCancelledFiles: builder.mutation({ queryFn: async (_, { dispatch, getState })=>{ const { uploadId, files: stateFiles } = getState().uploadProgress; const token = getState().admin_app?.token; const cancelledIndices = stateFiles.filter((f)=>f.status === 'cancelled').map((f)=>f.index); if (cancelledIndices.length === 0) { return { error: { name: 'UnknownError', message: 'No cancelled files to retry' } }; } const entries = getUploadEntries(uploadId); if (!entries) { return { error: { name: 'UnknownError', message: 'Original files not found' } }; } // Reset cancelled files back to pending dispatch(retryCancelledFiles()); // Fresh AbortController for the retry run const abortController = new AbortController(); registerAbortController(uploadId, abortController); return runSequentialUpload({ entries, indices: cancelledIndices, token, uploadId, abortController, dispatch }); }, invalidatesTags: [ { type: 'Asset', id: 'LIST' } ] }), /** * Upload files from URLs. * Sends URLs to the server which fetches and uploads them (SSE flow, unchanged). */ uploadFromUrls: builder.mutation({ queryFn: async ({ urls, folderId }, { dispatch, getState })=>{ const token = getState().admin_app?.token; // Extract filenames from URLs for the progress dialog const fileNames = urls.map((url)=>getFilenameFromUrl(url)); // Open progress dialog with all URLs as pending files dispatch(openUploadProgress({ totalFiles: urls.length, fileNames })); // Get the uploadId from state after dispatching const uploadId = getState().uploadProgress.uploadId; // Create abort controller for this upload const abortController = new AbortController(); registerAbortController(uploadId, abortController); try { // Send URLs to server for fetching and uploading const response = await fetchUrlUploadStream({ token, urls, folderId, signal: abortController.signal }); if (!response.ok || !response.body) { unregisterAbortController(uploadId); let errorMessage = 'Upload request failed'; try { const errorData = await response.json(); if (errorData.error?.message) { errorMessage = errorData.error.message; } else if (errorData.message) { errorMessage = errorData.message; } } catch { errorMessage = `Upload failed with status ${response.status}`; } dispatch(setUploadFailed({ message: errorMessage })); return { error: { name: 'UnknownError', message: errorMessage, status: response.status } }; } // Process SSE stream from server const streamResult = await processSSEStream({ response, dispatch }); unregisterAbortController(uploadId); if (streamResult && streamResult.data.length > 0) { return { data: streamResult }; } return { data: { data: [], errors: [] } }; } catch (err) { unregisterAbortController(uploadId); if (err instanceof DOMException && err.name === 'AbortError') { return { error: { name: 'UnknownError', message: 'Upload cancelled' } }; } const errorMessage = err instanceof Error ? err.message : 'Network error occurred'; dispatch(setUploadFailed({ message: errorMessage })); return { error: { name: 'UnknownError', message: errorMessage } }; } }, invalidatesTags: [ { type: 'Asset', id: 'LIST' } ] }) }) }); const { useUploadFilesMutation, useUploadFileSilentlyMutation, useRetryCancelledFilesMutation, useUploadFromUrlsMutation } = uploadApi; export { abortUpload, uploadApi, useRetryCancelledFilesMutation, useUploadFileSilentlyMutation, useUploadFilesMutation, useUploadFromUrlsMutation }; //# sourceMappingURL=api.mjs.map