/*globals qq, File, XMLHttpRequest, FormData, Blob*/ qq.UploadHandlerXhr = function(o, uploadCompleteCallback, logCallback) { "use strict"; var options = o, uploadComplete = uploadCompleteCallback, log = logCallback, fileState = [], cookieItemDelimiter = "|", chunkFiles = options.chunking.enabled && qq.supportedFeatures.chunking, resumeEnabled = options.resume.enabled && chunkFiles && qq.supportedFeatures.resume, resumeId = getResumeId(), multipart = options.forceMultipart || options.paramsInBody, api; function addChunkingSpecificParams(id, params, chunkData) { var size = api.getSize(id), name = api.getName(id); params[options.chunking.paramNames.partIndex] = chunkData.part; params[options.chunking.paramNames.partByteOffset] = chunkData.start; params[options.chunking.paramNames.chunkSize] = chunkData.size; params[options.chunking.paramNames.totalParts] = chunkData.count; params[options.totalFileSizeParamName] = size; /** * When a Blob is sent in a multipart request, the filename value in the content-disposition header is either "blob" * or an empty string. So, we will need to include the actual file name as a param in this case. */ if (multipart) { params[options.chunking.paramNames.filename] = name; } } function addResumeSpecificParams(params) { params[options.resume.paramNames.resuming] = true; } function getChunk(fileOrBlob, startByte, endByte) { if (fileOrBlob.slice) { return fileOrBlob.slice(startByte, endByte); } else if (fileOrBlob.mozSlice) { return fileOrBlob.mozSlice(startByte, endByte); } else if (fileOrBlob.webkitSlice) { return fileOrBlob.webkitSlice(startByte, endByte); } } function getChunkData(id, chunkIndex) { var chunkSize = options.chunking.partSize, fileSize = api.getSize(id), fileOrBlob = fileState[id].file || fileState[id].blobData.blob, startBytes = chunkSize * chunkIndex, endBytes = startBytes+chunkSize >= fileSize ? fileSize : startBytes+chunkSize, totalChunks = getTotalChunks(id); return { part: chunkIndex, start: startBytes, end: endBytes, count: totalChunks, blob: getChunk(fileOrBlob, startBytes, endBytes), size: endBytes - startBytes }; } function getTotalChunks(id) { var fileSize = api.getSize(id), chunkSize = options.chunking.partSize; return Math.ceil(fileSize / chunkSize); } function createXhr(id) { var xhr = new XMLHttpRequest(); fileState[id].xhr = xhr; return xhr; } function setParamsAndGetEntityToSend(params, xhr, fileOrBlob, id) { var formData = new FormData(), method = options.demoMode ? "GET" : "POST", endpoint = options.endpointStore.getEndpoint(id), url = endpoint, name = api.getName(id), size = api.getSize(id), blobData = fileState[id].blobData; params[options.uuidParamName] = fileState[id].uuid; if (multipart) { params[options.totalFileSizeParamName] = size; if (blobData) { /** * When a Blob is sent in a multipart request, the filename value in the content-disposition header is either "blob" * or an empty string. So, we will need to include the actual file name as a param in this case. */ params[options.blobs.paramNames.name] = blobData.name; } } //build query string if (!options.paramsInBody) { if (!multipart) { params[options.inputName] = name; } url = qq.obj2url(params, endpoint); } xhr.open(method, url, true); if (options.cors.expected && options.cors.sendCredentials) { xhr.withCredentials = true; } if (multipart) { if (options.paramsInBody) { qq.obj2FormData(params, formData); } formData.append(options.inputName, fileOrBlob); return formData; } return fileOrBlob; } function setHeaders(id, xhr) { var extraHeaders = options.customHeaders, fileOrBlob = fileState[id].file || fileState[id].blobData.blob; xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest"); xhr.setRequestHeader("Cache-Control", "no-cache"); if (!multipart) { xhr.setRequestHeader("Content-Type", "application/octet-stream"); //NOTE: return mime type in xhr works on chrome 16.0.9 firefox 11.0a2 xhr.setRequestHeader("X-Mime-Type", fileOrBlob.type); } qq.each(extraHeaders, function(name, val) { xhr.setRequestHeader(name, val); }); } function handleCompletedItem(id, response, xhr) { var name = api.getName(id), size = api.getSize(id); fileState[id].attemptingResume = false; options.onProgress(id, name, size, size); options.onComplete(id, name, response, xhr); delete fileState[id].xhr; uploadComplete(id); } function uploadNextChunk(id) { var chunkIdx = fileState[id].remainingChunkIdxs[0], chunkData = getChunkData(id, chunkIdx), xhr = createXhr(id), size = api.getSize(id), name = api.getName(id), toSend, params; if (fileState[id].loaded === undefined) { fileState[id].loaded = 0; } if (resumeEnabled && fileState[id].file) { persistChunkData(id, chunkData); } xhr.onreadystatechange = getReadyStateChangeHandler(id, xhr); xhr.upload.onprogress = function(e) { if (e.lengthComputable) { var totalLoaded = e.loaded + fileState[id].loaded, estTotalRequestsSize = calcAllRequestsSizeForChunkedUpload(id, chunkIdx, e.total); options.onProgress(id, name, totalLoaded, estTotalRequestsSize); } }; options.onUploadChunk(id, name, getChunkDataForCallback(chunkData)); params = options.paramsStore.getParams(id); addChunkingSpecificParams(id, params, chunkData); if (fileState[id].attemptingResume) { addResumeSpecificParams(params); } toSend = setParamsAndGetEntityToSend(params, xhr, chunkData.blob, id); setHeaders(id, xhr); log('Sending chunked upload request for item ' + id + ": bytes " + (chunkData.start+1) + "-" + chunkData.end + " of " + size); xhr.send(toSend); } function calcAllRequestsSizeForChunkedUpload(id, chunkIdx, requestSize) { var chunkData = getChunkData(id, chunkIdx), blobSize = chunkData.size, overhead = requestSize - blobSize, size = api.getSize(id), chunkCount = chunkData.count, initialRequestOverhead = fileState[id].initialRequestOverhead, overheadDiff = overhead - initialRequestOverhead; fileState[id].lastRequestOverhead = overhead; if (chunkIdx === 0) { fileState[id].lastChunkIdxProgress = 0; fileState[id].initialRequestOverhead = overhead; fileState[id].estTotalRequestsSize = size + (chunkCount * overhead); } else if (fileState[id].lastChunkIdxProgress !== chunkIdx) { fileState[id].lastChunkIdxProgress = chunkIdx; fileState[id].estTotalRequestsSize += overheadDiff; } return fileState[id].estTotalRequestsSize; } function getLastRequestOverhead(id) { if (multipart) { return fileState[id].lastRequestOverhead; } else { return 0; } } function handleSuccessfullyCompletedChunk(id, response, xhr) { var chunkIdx = fileState[id].remainingChunkIdxs.shift(), chunkData = getChunkData(id, chunkIdx); fileState[id].attemptingResume = false; fileState[id].loaded += chunkData.size + getLastRequestOverhead(id); if (fileState[id].remainingChunkIdxs.length > 0) { uploadNextChunk(id); } else { if (resumeEnabled) { deletePersistedChunkData(id); } handleCompletedItem(id, response, xhr); } } function isErrorResponse(xhr, response) { return xhr.status !== 200 || !response.success || response.reset; } function parseResponse(xhr) { var response; try { response = qq.parseJson(xhr.responseText); } catch(error) { log('Error when attempting to parse xhr response text (' + error + ')', 'error'); response = {}; } return response; } function handleResetResponse(id) { log('Server has ordered chunking effort to be restarted on next attempt for item ID ' + id, 'error'); if (resumeEnabled) { deletePersistedChunkData(id); fileState[id].attemptingResume = false; } fileState[id].remainingChunkIdxs = []; delete fileState[id].loaded; delete fileState[id].estTotalRequestsSize; delete fileState[id].initialRequestOverhead; } function handleResetResponseOnResumeAttempt(id) { fileState[id].attemptingResume = false; log("Server has declared that it cannot handle resume for item ID " + id + " - starting from the first chunk", 'error'); handleResetResponse(id); api.upload(id, true); } function handleNonResetErrorResponse(id, response, xhr) { var name = api.getName(id); if (options.onAutoRetry(id, name, response, xhr)) { return; } else { handleCompletedItem(id, response, xhr); } } function onComplete(id, xhr) { var response; // the request was aborted/cancelled if (!fileState[id]) { return; } log("xhr - server response received for " + id); log("responseText = " + xhr.responseText); response = parseResponse(xhr); if (isErrorResponse(xhr, response)) { if (response.reset) { handleResetResponse(id); } if (fileState[id].attemptingResume && response.reset) { handleResetResponseOnResumeAttempt(id); } else { handleNonResetErrorResponse(id, response, xhr); } } else if (chunkFiles) { handleSuccessfullyCompletedChunk(id, response, xhr); } else { handleCompletedItem(id, response, xhr); } } function getChunkDataForCallback(chunkData) { return { partIndex: chunkData.part, startByte: chunkData.start + 1, endByte: chunkData.end, totalParts: chunkData.count }; } function getReadyStateChangeHandler(id, xhr) { return function() { if (xhr.readyState === 4) { onComplete(id, xhr); } }; } function persistChunkData(id, chunkData) { var fileUuid = api.getUuid(id), lastByteSent = fileState[id].loaded, initialRequestOverhead = fileState[id].initialRequestOverhead, estTotalRequestsSize = fileState[id].estTotalRequestsSize, cookieName = getChunkDataCookieName(id), cookieValue = fileUuid + cookieItemDelimiter + chunkData.part + cookieItemDelimiter + lastByteSent + cookieItemDelimiter + initialRequestOverhead + cookieItemDelimiter + estTotalRequestsSize, cookieExpDays = options.resume.cookiesExpireIn; qq.setCookie(cookieName, cookieValue, cookieExpDays); } function deletePersistedChunkData(id) { if (fileState[id].file) { var cookieName = getChunkDataCookieName(id); qq.deleteCookie(cookieName); } } function getPersistedChunkData(id) { var chunkCookieValue = qq.getCookie(getChunkDataCookieName(id)), filename = api.getName(id), sections, uuid, partIndex, lastByteSent, initialRequestOverhead, estTotalRequestsSize; if (chunkCookieValue) { sections = chunkCookieValue.split(cookieItemDelimiter); if (sections.length === 5) { uuid = sections[0]; partIndex = parseInt(sections[1], 10); lastByteSent = parseInt(sections[2], 10); initialRequestOverhead = parseInt(sections[3], 10); estTotalRequestsSize = parseInt(sections[4], 10); return { uuid: uuid, part: partIndex, lastByteSent: lastByteSent, initialRequestOverhead: initialRequestOverhead, estTotalRequestsSize: estTotalRequestsSize }; } else { log('Ignoring previously stored resume/chunk cookie for ' + filename + " - old cookie format", "warn"); } } } function getChunkDataCookieName(id) { var filename = api.getName(id), fileSize = api.getSize(id), maxChunkSize = options.chunking.partSize, cookieName; cookieName = "qqfilechunk" + cookieItemDelimiter + encodeURIComponent(filename) + cookieItemDelimiter + fileSize + cookieItemDelimiter + maxChunkSize; if (resumeId !== undefined) { cookieName += cookieItemDelimiter + resumeId; } return cookieName; } function getResumeId() { if (options.resume.id !== null && options.resume.id !== undefined && !qq.isFunction(options.resume.id) && !qq.isObject(options.resume.id)) { return options.resume.id; } } function handleFileChunkingUpload(id, retry) { var name = api.getName(id), firstChunkIndex = 0, persistedChunkInfoForResume, firstChunkDataForResume, currentChunkIndex; if (!fileState[id].remainingChunkIdxs || fileState[id].remainingChunkIdxs.length === 0) { fileState[id].remainingChunkIdxs = []; if (resumeEnabled && !retry && fileState[id].file) { persistedChunkInfoForResume = getPersistedChunkData(id); if (persistedChunkInfoForResume) { firstChunkDataForResume = getChunkData(id, persistedChunkInfoForResume.part); if (options.onResume(id, name, getChunkDataForCallback(firstChunkDataForResume)) !== false) { firstChunkIndex = persistedChunkInfoForResume.part; fileState[id].uuid = persistedChunkInfoForResume.uuid; fileState[id].loaded = persistedChunkInfoForResume.lastByteSent; fileState[id].estTotalRequestsSize = persistedChunkInfoForResume.estTotalRequestsSize; fileState[id].initialRequestOverhead = persistedChunkInfoForResume.initialRequestOverhead; fileState[id].attemptingResume = true; log('Resuming ' + name + " at partition index " + firstChunkIndex); } } } for (currentChunkIndex = getTotalChunks(id)-1; currentChunkIndex >= firstChunkIndex; currentChunkIndex-=1) { fileState[id].remainingChunkIdxs.unshift(currentChunkIndex); } } uploadNextChunk(id); } function handleStandardFileUpload(id) { var fileOrBlob = fileState[id].file || fileState[id].blobData.blob, name = api.getName(id), xhr, params, toSend; fileState[id].loaded = 0; xhr = createXhr(id); xhr.upload.onprogress = function(e){ if (e.lengthComputable){ fileState[id].loaded = e.loaded; options.onProgress(id, name, e.loaded, e.total); } }; xhr.onreadystatechange = getReadyStateChangeHandler(id, xhr); params = options.paramsStore.getParams(id); toSend = setParamsAndGetEntityToSend(params, xhr, fileOrBlob, id); setHeaders(id, xhr); log('Sending upload request for ' + id); xhr.send(toSend); } api = { /** * Adds File or Blob to the queue * Returns id to use with upload, cancel **/ add: function(fileOrBlobData){ var id; if (fileOrBlobData instanceof File) { id = fileState.push({file: fileOrBlobData}) - 1; } else if (qq.isBlob(fileOrBlobData.blob)) { id = fileState.push({blobData: fileOrBlobData}) - 1; } else { throw new Error('Passed obj in not a File or BlobData (in qq.UploadHandlerXhr)'); } fileState[id].uuid = qq.getUniqueId(); return id; }, getName: function(id){ if (api.isValid(id)) { var file = fileState[id].file, blobData = fileState[id].blobData; if (file) { // fix missing name in Safari 4 //NOTE: fixed missing name firefox 11.0a2 file.fileName is actually undefined return (file.fileName !== null && file.fileName !== undefined) ? file.fileName : file.name; } else { return blobData.name; } } else { log(id + " is not a valid item ID.", "error"); } }, getSize: function(id){ /*jshint eqnull: true*/ var fileOrBlob = fileState[id].file || fileState[id].blobData.blob; if (qq.isFileOrInput(fileOrBlob)) { return fileOrBlob.fileSize != null ? fileOrBlob.fileSize : fileOrBlob.size; } else { return fileOrBlob.size; } }, getFile: function(id) { if (fileState[id]) { return fileState[id].file || fileState[id].blobData.blob; } }, /** * Returns uploaded bytes for file identified by id */ getLoaded: function(id){ return fileState[id].loaded || 0; }, isValid: function(id) { return fileState[id] !== undefined; }, reset: function() { fileState = []; }, getUuid: function(id) { return fileState[id].uuid; }, /** * Sends the file identified by id to the server */ upload: function(id, retry){ var name = this.getName(id); options.onUpload(id, name); if (chunkFiles) { handleFileChunkingUpload(id, retry); } else { handleStandardFileUpload(id); } }, cancel: function(id){ var xhr = fileState[id].xhr; options.onCancel(id, this.getName(id)); if (xhr) { xhr.onreadystatechange = null; xhr.abort(); } if (resumeEnabled) { deletePersistedChunkData(id); } delete fileState[id]; }, getResumableFilesData: function() { var matchingCookieNames = [], resumableFilesData = []; if (chunkFiles && resumeEnabled) { if (resumeId === undefined) { matchingCookieNames = qq.getCookieNames(new RegExp("^qqfilechunk\\" + cookieItemDelimiter + ".+\\" + cookieItemDelimiter + "\\d+\\" + cookieItemDelimiter + options.chunking.partSize + "=")); } else { matchingCookieNames = qq.getCookieNames(new RegExp("^qqfilechunk\\" + cookieItemDelimiter + ".+\\" + cookieItemDelimiter + "\\d+\\" + cookieItemDelimiter + options.chunking.partSize + "\\" + cookieItemDelimiter + resumeId + "=")); } qq.each(matchingCookieNames, function(idx, cookieName) { var cookiesNameParts = cookieName.split(cookieItemDelimiter); var cookieValueParts = qq.getCookie(cookieName).split(cookieItemDelimiter); resumableFilesData.push({ name: decodeURIComponent(cookiesNameParts[1]), size: cookiesNameParts[2], uuid: cookieValueParts[0], partIdx: cookieValueParts[1] }); }); return resumableFilesData; } return []; } }; return api; };