/** * 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(/([^<]+)<\/title>/i); const h1Match = xhr.responseText.match(/<h1>([^<]+)<\/h1>/i); reject(new Error(titleMatch?.[1] || h1Match?.[1] || `Server error (${xhr.status}): Response was not JSON`)); return; } let parsed; try { parsed = JSON.parse(xhr.responseText); } catch { reject(new Error(`Invalid JSON response (${xhr.status})`)); return; } if (xhr.status === 202 && parsed.id) { resolve(parsed); } else if (!String(xhr.status).startsWith('2')) { let errorMsg = parsed.error || `Upload failed with status ${xhr.status}`; if (xhr.status === 413) errorMsg = parsed.error || `File too large. Max: ${parsed.max_size_mb?.toFixed(0) || maxFileSizeMB.value} MB.`; reject(new Error(errorMsg)); } else { reject(new Error('Unexpected success response from server after upload.')); } }; xhr.onerror = () => reject(new Error('Network error during upload')); xhr.ontimeout = () => reject(new Error('Upload timed out')); // Store abort controller on item for cancellation fileItem._xhr = xhr; xhr.open('POST', '/upload'); if (csrfToken) { xhr.setRequestHeader('X-CSRFToken', csrfToken); } xhr.send(formData); }); // Upload succeeded - recording is now on the server console.log(`File ${fileItem.file.name} uploaded. Recording ID: ${data.id}. Server will process via job queue.`); fileItem.status = 'pending'; fileItem.recordingId = data.id; fileItem.progress = 100; // Add to recordings list recordings.value.unshift(data); totalRecordings.value++; // Clear recording session only after confirmed upload if (fileItem.onUploadSuccess) { await fileItem.onUploadSuccess(); } // Handle duplicate warning if (data.duplicate_warning) { const warning = data.duplicate_warning; const existingDate = warning.existing_created_at ? new Date(warning.existing_created_at).toLocaleDateString() : ''; const existingName = warning.existing_title || 'Unknown'; showToast( `⚠️ ${existingName} (${existingDate})`, 'fa-copy' ); fileItem.duplicateWarning = warning; } } catch (error) { console.error(`Upload Error for ${fileItem.file.name} (Client ID: ${fileItem.clientId}):`, error); fileItem.status = 'failed'; fileItem.error = error.message; fileItem.progress = 0; // Show friendly error message const friendlyErr = getFriendlyError(error.message, t); setGlobalError(`${friendlyErr.title}: ${friendlyErr.guidance}`); // Store failed upload in IndexedDB for background sync retry try { await FailedUploads.storeFailedUpload({ file: fileItem.file, fileName: fileItem.file.name, fileSize: fileItem.file.size, clientId: fileItem.clientId, notes: fileItem.notes, tags: fileItem.tags, asrOptions: fileItem.asrOptions, error: error.message }); if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) { const registration = await navigator.serviceWorker.ready; await registration.sync.register('sync-uploads'); console.log('[Upload] Registered background sync for failed upload'); } } catch (syncError) { console.warn('[Upload] Failed to register background sync:', syncError); } } finally { fileItem._xhr = null; releaseUploadSlot(); } }; /** * Start uploading all ready files in parallel (with concurrency limit). * Processing status is tracked via allJobs polling in app.modular.js. */ const startProcessingQueue = async () => { const readyItems = uploadQueue.value.filter(item => item.status === 'ready'); if (readyItems.length === 0) { console.log("No files ready to upload."); return; } isProcessingActive.value = true; console.log(`Starting parallel upload of ${readyItems.length} file(s) (max ${maxConcurrentUploads?.value || 3} concurrent)...`); // Fire off all uploads concurrently (semaphore handles limiting) const uploadPromises = readyItems.map(item => uploadSingleFile(item)); // Don't await - let them run in background. isProcessingActive is cleared by releaseUploadSlot. Promise.allSettled(uploadPromises).then(() => { console.log('All uploads settled.'); }); }; // Keep backward-compat aliases const startStatusPolling = (fileItem, recordingId) => { // No longer needed - allJobs polling handles status tracking fileItem.recordingId = recordingId; }; const pollProcessingStatus = () => { // No-op: status tracking is now handled by allJobs polling in app.modular.js }; // Tag selection helpers const addTagToSelection = (tagId) => { if (!selectedTagIds.value.includes(tagId)) { selectedTagIds.value.push(tagId); applyTagDefaults(); } }; const removeTagFromSelection = (tagId) => { const index = selectedTagIds.value.indexOf(tagId); if (index > -1) { selectedTagIds.value.splice(index, 1); applyTagDefaults(); } }; const applyTagDefaults = () => { const selectedTagsObjects = selectedTagIds.value.map(tagId => availableTags.value.find(tag => tag.id == tagId) ).filter(Boolean); const firstTag = selectedTagsObjects[0]; if (firstTag && connectorSupportsDiarization.value) { if (firstTag.default_language) { uploadLanguage.value = firstTag.default_language; } if (firstTag.default_min_speakers) { uploadMinSpeakers.value = firstTag.default_min_speakers; } if (firstTag.default_max_speakers) { uploadMaxSpeakers.value = firstTag.default_max_speakers; } } // Apply hotwords/initial_prompt from first tag (works for all connectors) if (firstTag) { if (firstTag.default_hotwords) { uploadHotwords.value = firstTag.default_hotwords; } if (firstTag.default_initial_prompt) { uploadInitialPrompt.value = firstTag.default_initial_prompt; } } }; // Computed property for filtered available tags in upload view const filteredAvailableTagsForUpload = computed(() => { const availableForSelection = availableTags.value.filter(tag => !selectedTagIds.value.includes(tag.id)); if (!uploadTagSearchFilter.value) return availableForSelection; const filter = uploadTagSearchFilter.value.toLowerCase(); return availableForSelection.filter(tag => tag.name.toLowerCase().includes(filter) ); }); // === INCOGNITO MODE FUNCTIONS === /** * Upload and process a file in incognito mode. * The file is processed synchronously and no data is saved to the database. * Results are stored only in sessionStorage. */ const startIncognitoUpload = async () => { const pendingFiles = uploadQueue.value.filter(item => item.status === 'queued'); if (pendingFiles.length === 0) { return; } // Only process the first file for incognito mode const fileItem = pendingFiles[0]; // Check if incognito mode state is available if (!incognitoMode || !incognitoProcessing || !incognitoRecording) { console.warn('[Incognito] Incognito state not available, falling back to normal upload'); startUpload(); return; } incognitoProcessing.value = true; processingMessage.value = t('incognito.processingInProgress'); processingProgress.value = 10; progressPopupMinimized.value = false; progressPopupClosed.value = false; try { const formData = new FormData(); formData.append('file', fileItem.file); // Add ASR options const asrOpts = fileItem.asrOptions || {}; const language = asrOpts.language || uploadLanguage.value; const minSpeakers = asrOpts.min_speakers || uploadMinSpeakers.value; const maxSpeakers = asrOpts.max_speakers || uploadMaxSpeakers.value; if (language) { formData.append('language', language); } if (minSpeakers && minSpeakers !== '') { formData.append('min_speakers', minSpeakers.toString()); } if (maxSpeakers && maxSpeakers !== '') { formData.append('max_speakers', maxSpeakers.toString()); } 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()); } // Request auto-summarization formData.append('auto_summarize', 'true'); processingMessage.value = t('incognito.uploadingFile'); processingProgress.value = 20; console.log('[Incognito] Uploading file:', fileItem.file.name); const response = await fetch('/api/recordings/incognito', { method: 'POST', body: formData }); processingProgress.value = 50; // Parse response const contentType = response.headers.get('content-type') || ''; if (!contentType.includes('application/json')) { const text = await response.text(); const titleMatch = text.match(/<title>([^<]+)<\/title>/i); throw new Error(titleMatch?.[1] || `Server error (${response.status})`); } const data = await response.json(); if (!response.ok || data.error) { throw new Error(data.error || `Processing failed with status ${response.status}`); } processingProgress.value = 80; processingMessage.value = t('incognito.processingComplete'); // Store result in sessionStorage const incognitoData = { id: 'incognito', incognito: true, title: data.title || t('incognito.recordingTitle'), transcription: data.transcription, summary: data.summary, summary_html: data.summary_html, created_at: data.created_at, original_filename: data.original_filename, file_size: data.file_size, audio_duration_seconds: data.audio_duration_seconds, processing_time_seconds: data.processing_time_seconds, status: 'COMPLETED' }; IncognitoStorage.saveIncognitoRecording(incognitoData); incognitoRecording.value = incognitoData; // Remove the processed file from queue const index = uploadQueue.value.findIndex(item => item.clientId === fileItem.clientId); if (index !== -1) { uploadQueue.value.splice(index, 1); } processingProgress.value = 100; processingMessage.value = t('incognito.recordingReady'); // Auto-select the incognito recording and switch to detail view selectedRecording.value = incognitoData; currentView.value = 'detail'; // Show toast showToast(t('incognito.recordingProcessed'), 'fa-user-secret'); console.log('[Incognito] Processing complete'); } catch (error) { console.error('[Incognito] Processing failed:', error); const friendlyErr = getFriendlyError(error.message, t); setGlobalError(`${friendlyErr.title}: ${friendlyErr.guidance}`); fileItem.status = 'failed'; fileItem.error = error.message; } finally { incognitoProcessing.value = false; processingProgress.value = 0; processingMessage.value = ''; } }; /** * Clear the incognito recording with confirmation */ const clearIncognitoRecordingWithConfirm = () => { if (incognitoRecording && incognitoRecording.value) { if (confirm(t('incognito.discardConfirm'))) { IncognitoStorage.clearIncognitoRecording(); incognitoRecording.value = null; // If the incognito recording was selected, clear selection if (selectedRecording.value?.id === 'incognito') { selectedRecording.value = null; } showToast(t('incognito.recordingDiscarded'), 'fa-trash'); } } }; /** * Select the incognito recording for viewing */ const selectIncognitoRecording = () => { if (incognitoRecording && incognitoRecording.value) { selectedRecording.value = incognitoRecording.value; currentView.value = 'detail'; } }; /** * Load incognito recording from sessionStorage on app init */ const loadIncognitoRecording = () => { const stored = IncognitoStorage.getIncognitoRecording(); if (stored && incognitoRecording) { incognitoRecording.value = stored; console.log('[Incognito] Loaded recording from sessionStorage'); } }; /** * Check if there's an incognito recording (for navigation guards) */ const hasIncognitoRecording = () => { return IncognitoStorage.hasIncognitoRecording(); }; return { handleDragOver, handleDragLeave, handleDrop, handleFileSelect, addFilesToQueue, removeFromQueue, cancelWaitingFile, clearCompletedUploads, startUpload, startProcessingQueue, resetCurrentFileProcessingState, startStatusPolling, pollProcessingStatus, addTagToSelection, removeTagFromSelection, applyTagDefaults, filteredAvailableTagsForUpload, // Tag drag-and-drop draggedTagIndex, dragOverTagIndex, handleTagDragStart, handleTagDragOver, handleTagDrop, handleTagDragEnd, handleTagTouchStart, handleTagTouchMove, handleTagTouchEnd, // Incognito mode startIncognitoUpload, clearIncognitoRecordingWithConfirm, selectIncognitoRecording, loadIncognitoRecording, hasIncognitoRecording }; }