/** * Upload management composable * Handles file uploads, queue processing, and progress tracking */ import * as FailedUploads from '../db/failed-uploads.js'; import * as IncognitoStorage from '../db/incognito-storage.js'; // Parse error message and return friendly error info function getFriendlyError(errorMessage, t) { const _t = t || ((key) => key); if (!errorMessage) return { title: _t('errors.processingError'), message: _t('errors.processingErrorMessage') }; const lowerText = errorMessage.toLowerCase(); const patterns = [ { patterns: ['maximum content size limit', 'file too large', '413', 'payload too large', 'exceeded'], title: _t('errors.fileTooLargeTitle'), guidance: _t('errors.enableChunkingGuidance') }, { patterns: ['timed out', 'timeout', 'deadline exceeded'], title: _t('errors.processingTimeout'), guidance: _t('errors.splitAudioGuidance') }, { patterns: ['401', 'unauthorized', 'invalid api key', 'authentication failed', 'incorrect api key'], title: _t('errors.authenticationError'), guidance: _t('errors.checkApiKeyGuidance') }, { patterns: ['rate limit', 'too many requests', '429', 'quota exceeded'], title: _t('errors.rateLimitExceeded'), guidance: _t('errors.waitAndRetryGuidance') }, { patterns: ['connection refused', 'connection reset', 'could not connect', 'network unreachable'], title: _t('errors.connectionError'), guidance: _t('errors.checkNetworkGuidance') }, { patterns: ['503', '502', '500', 'service unavailable', 'server error', 'internal server error'], title: _t('errors.serviceUnavailable'), guidance: _t('errors.tryAgainLaterGuidance') }, { patterns: ['invalid file format', 'unsupported format', 'could not decode', 'corrupt'], title: _t('errors.invalidAudioFormat'), guidance: _t('errors.convertFormatGuidance') }, { patterns: ['audio extraction failed', 'ffmpeg failed', 'no audio stream'], title: _t('errors.audioExtractionFailed'), guidance: _t('errors.convertStandardGuidance') }, ]; for (const pattern of patterns) { for (const p of pattern.patterns) { if (lowerText.includes(p)) return { title: pattern.title, guidance: pattern.guidance }; } } return { title: _t('errors.processingError'), guidance: _t('errors.processingErrorFallbackGuidance') }; } export function useUpload(state, utils) { const { uploadQueue, currentlyProcessingFile, processingProgress, processingMessage, isProcessingActive, pollInterval, progressPopupMinimized, progressPopupClosed, maxFileSizeMB, chunkingEnabled, chunkingMode, chunkingLimit, maxConcurrentUploads, recordings, selectedRecording, totalRecordings, globalError, selectedTagIds, uploadLanguage, uploadMinSpeakers, uploadMaxSpeakers, uploadHotwords, uploadInitialPrompt, useAsrEndpoint, connectorSupportsDiarization, asrLanguage, asrMinSpeakers, asrMaxSpeakers, dragover, availableTags, uploadTagSearchFilter, // Folder state availableFolders, selectedFolderId, // Incognito mode state incognitoMode, incognitoRecording, incognitoProcessing, // View state currentView, // Upload disclaimer state uploadDisclaimer, showUploadDisclaimerModal } = state; const { computed, nextTick, ref } = Vue; const { setGlobalError, showToast, formatFileSize, onChatComplete, t } = utils; // Compute selected tags from IDs const selectedTags = computed(() => { return selectedTagIds.value.map(id => availableTags.value.find(t => t.id === id) ).filter(Boolean); }); // --- Tag Drag-and-Drop State --- const draggedTagIndex = ref(null); const dragOverTagIndex = ref(null); // Reorder selectedTagIds array const reorderSelectedTags = (fromIndex, toIndex) => { const tagIds = [...selectedTagIds.value]; const [removed] = tagIds.splice(fromIndex, 1); tagIds.splice(toIndex, 0, removed); selectedTagIds.value = tagIds; applyTagDefaults(); // Re-apply defaults since first tag may have changed }; // === MOUSE DRAG HANDLERS === const handleTagDragStart = (index, event) => { draggedTagIndex.value = index; event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', index.toString()); }; const handleTagDragOver = (index, event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; dragOverTagIndex.value = index; }; const handleTagDrop = (targetIndex, event) => { event.preventDefault(); if (draggedTagIndex.value !== null && draggedTagIndex.value !== targetIndex) { reorderSelectedTags(draggedTagIndex.value, targetIndex); } draggedTagIndex.value = null; dragOverTagIndex.value = null; }; const handleTagDragEnd = () => { draggedTagIndex.value = null; dragOverTagIndex.value = null; }; // === TOUCH HANDLERS (Mobile) === let touchStartIndex = null; const handleTagTouchStart = (index, event) => { touchStartIndex = index; draggedTagIndex.value = index; }; const handleTagTouchMove = (event) => { if (touchStartIndex === null) return; event.preventDefault(); const touch = event.touches[0]; const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY); const tagElement = elementBelow?.closest('[data-tag-index]'); if (tagElement) { const targetIndex = parseInt(tagElement.dataset.tagIndex); dragOverTagIndex.value = targetIndex; } }; const handleTagTouchEnd = () => { if (touchStartIndex !== null && dragOverTagIndex.value !== null && touchStartIndex !== dragOverTagIndex.value) { reorderSelectedTags(touchStartIndex, dragOverTagIndex.value); } touchStartIndex = null; draggedTagIndex.value = null; dragOverTagIndex.value = null; }; // Handle drag events const handleDragOver = (e) => { e.preventDefault(); dragover.value = true; }; const handleDragLeave = (e) => { if (e.relatedTarget && e.currentTarget.contains(e.relatedTarget)) { return; } dragover.value = false; }; const handleDrop = (e) => { e.preventDefault(); dragover.value = false; addFilesToQueue(e.dataTransfer.files); }; const handleFileSelect = (e) => { addFilesToQueue(e.target.files); e.target.value = null; }; // Add files to the upload queue const addFilesToQueue = (files) => { let filesAdded = 0; for (const file of files) { const fileObject = file.file ? file.file : file; const notes = file.notes || null; const tags = file.tags || selectedTags.value || []; const asrOptions = file.asrOptions || { language: asrLanguage.value, min_speakers: asrMinSpeakers.value, max_speakers: asrMaxSpeakers.value }; // Check if it's an audio file or video container with audio const isAudioFile = fileObject && ( fileObject.type.startsWith('audio/') || fileObject.type === 'video/mp4' || fileObject.type === 'video/quicktime' || fileObject.type === 'video/x-msvideo' || fileObject.type === 'video/webm' || fileObject.name.toLowerCase().endsWith('.amr') || fileObject.name.toLowerCase().endsWith('.3gp') || fileObject.name.toLowerCase().endsWith('.3gpp') || fileObject.name.toLowerCase().endsWith('.mp4') || fileObject.name.toLowerCase().endsWith('.mov') || fileObject.name.toLowerCase().endsWith('.avi') || fileObject.name.toLowerCase().endsWith('.mkv') || fileObject.name.toLowerCase().endsWith('.webm') || fileObject.name.toLowerCase().endsWith('.weba') ); if (isAudioFile) { // Only check general file size limit if (fileObject.size > maxFileSizeMB.value * 1024 * 1024) { setGlobalError(t('upload.fileExceedsMaxSize', { name: fileObject.name, size: maxFileSizeMB.value })); continue; } const clientId = `client-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; uploadQueue.value.push({ file: fileObject, notes: notes, tags: tags, asrOptions: asrOptions, status: 'queued', recordingId: null, clientId: clientId, error: null, willAutoSummarize: false // Server will tell us via SUMMARIZING status }); filesAdded++; } else if (fileObject) { setGlobalError(t('upload.invalidFileType', { name: fileObject.name })); } } if (filesAdded > 0) { console.log(`Added ${filesAdded} file(s) to the queue.`); } }; // Remove a file from the queue before processing starts const removeFromQueue = (clientId) => { const index = uploadQueue.value.findIndex(item => item.clientId === clientId); if (index !== -1 && (uploadQueue.value[index].status === 'queued' || uploadQueue.value[index].status === 'ready')) { uploadQueue.value.splice(index, 1); console.log(`Removed file from queue: ${clientId}`); } }; // Cancel a waiting file from the upload progress queue const cancelWaitingFile = (clientId) => { const index = uploadQueue.value.findIndex(item => item.clientId === clientId); if (index !== -1 && uploadQueue.value[index].status === 'ready') { uploadQueue.value.splice(index, 1); console.log(`Cancelled waiting file: ${clientId}`); showToast(t('upload.fileRemovedFromQueue'), 'fa-trash'); } }; // Clear completed uploads from queue const clearCompletedUploads = () => { uploadQueue.value = uploadQueue.value.filter(item => !['completed', 'failed'].includes(item.status)); }; // Start processing all queued files const startUpload = () => { const pendingFiles = uploadQueue.value.filter(item => item.status === 'queued'); if (pendingFiles.length === 0) { return; } // Show upload disclaimer if configured if (uploadDisclaimer.value && uploadDisclaimer.value.trim() !== '') { showUploadDisclaimerModal.value = true; return; } // Update all queued files with current tags and ASR options // AND change their status to 'ready' so they move to upload progress immediately for (const item of uploadQueue.value) { if (item.status === 'queued') { if (!item.preserveOptions) { // For file uploads: use current UI selection (user may have changed tags after dropping) item.tags = [...selectedTags.value]; item.asrOptions = { language: asrLanguage.value, min_speakers: asrMinSpeakers.value, max_speakers: asrMaxSpeakers.value, hotwords: uploadHotwords.value, initial_prompt: uploadInitialPrompt.value, }; item.folder_id = selectedFolderId.value; } // Change status to 'ready' to remove from upload view but keep in queue item.status = 'ready'; } } progressPopupMinimized.value = false; progressPopupClosed.value = false; startProcessingQueue(); }; // --- Parallel Upload System --- // Concurrency limiter: configurable via MAX_CONCURRENT_UPLOADS env var (default 3) let activeUploadCount = 0; const pendingUploadQueue = []; // Functions waiting for a slot const acquireUploadSlot = () => { return new Promise(resolve => { if (activeUploadCount < (maxConcurrentUploads?.value || 3)) { activeUploadCount++; resolve(); } else { pendingUploadQueue.push(resolve); } }); }; const releaseUploadSlot = () => { activeUploadCount--; if (pendingUploadQueue.length > 0) { activeUploadCount++; const next = pendingUploadQueue.shift(); next(); } // When all uploads are done, clear processing active flag const stillUploading = uploadQueue.value.some(item => ['uploading', 'ready'].includes(item.status) ); if (!stillUploading) { isProcessingActive.value = false; } }; const resetCurrentFileProcessingState = () => { if (pollInterval.value) clearInterval(pollInterval.value); pollInterval.value = null; currentlyProcessingFile.value = null; processingProgress.value = 0; processingMessage.value = ''; }; /** * Upload a single file to the server. * Acquires a concurrency slot, uploads, then releases. * Status updates are per-item (no global processingProgress). */ const uploadSingleFile = async (fileItem) => { await acquireUploadSlot(); fileItem.status = 'uploading'; fileItem.progress = 5; try { const formData = new FormData(); formData.append('file', fileItem.file); // Send file's lastModified timestamp for meeting_date if (fileItem.file.lastModified) { const lastModified = fileItem.file.lastModified; formData.append('file_last_modified', lastModified.toString()); } if (fileItem.notes) { formData.append('notes', fileItem.notes); } // Add tags if selected const tagsToUse = fileItem.tags || selectedTags.value || []; tagsToUse.forEach((tag, index) => { const tagId = tag.id || tag; formData.append(`tag_ids[${index}]`, tagId); }); // Add folder if selected const folderToUse = fileItem.folder_id || selectedFolderId.value; if (folderToUse) { formData.append('folder_id', folderToUse); } // Add ASR options const asrOpts = fileItem.asrOptions || {}; const language = asrOpts.language || uploadLanguage.value; if (language) { formData.append('language', language); } if (connectorSupportsDiarization.value) { const minSpeakers = asrOpts.min_speakers || uploadMinSpeakers.value; const maxSpeakers = asrOpts.max_speakers || uploadMaxSpeakers.value; if (minSpeakers && minSpeakers !== '') { formData.append('min_speakers', minSpeakers.toString()); } if (maxSpeakers && maxSpeakers !== '') { formData.append('max_speakers', maxSpeakers.toString()); } } // Add hotwords and initial prompt const hotwords = asrOpts.hotwords || uploadHotwords.value; const initialPrompt = asrOpts.initial_prompt || uploadInitialPrompt.value; if (hotwords && hotwords.trim()) { formData.append('hotwords', hotwords.trim()); } if (initialPrompt && initialPrompt.trim()) { formData.append('initial_prompt', initialPrompt.trim()); } // Refresh CSRF token before upload (prevents stale token after sleep/idle) let csrfToken; if (window.csrfManager) { try { csrfToken = await window.csrfManager.refreshToken(); } catch (e) { csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); } } else { csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); } // Use XMLHttpRequest for per-file upload progress const data = await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.upload.onprogress = (e) => { if (e.lengthComputable) { // Map upload progress to 5-90% range fileItem.progress = Math.round(5 + (e.loaded / e.total) * 85); } }; xhr.onload = () => { const contentType = xhr.getResponseHeader('content-type') || ''; if (!contentType.includes('application/json')) { const titleMatch = xhr.responseText.match(/