diff --git a/application/single_app/route_backend_documents.py b/application/single_app/route_backend_documents.py index 18ac783d..97b23f50 100644 --- a/application/single_app/route_backend_documents.py +++ b/application/single_app/route_backend_documents.py @@ -1279,6 +1279,69 @@ def api_extract_user_metadata(document_id): 'document_id': document_id }), 200 + @bp.route('/api/documents/extract_metadata', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required("enable_user_workspace") + def api_extract_user_metadata_batch(): + """ + POST /api/documents/extract_metadata + Queues background metadata extraction jobs for selected user documents. + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + settings = get_settings() + if not settings.get('enable_extract_meta_data'): + return jsonify({'error': 'Metadata extraction not enabled'}), 403 + + payload = request.get_json(silent=True) or {} + document_ids = payload.get('document_ids') + if not isinstance(document_ids, list): + document_id = payload.get('document_id') + document_ids = [document_id] if document_id else [] + document_ids = list(dict.fromkeys( + str(document_id).strip() + for document_id in document_ids + if str(document_id or '').strip() + )) + if not document_ids: + return jsonify({'error': 'At least one document ID is required.'}), 400 + + queued = [] + errors = [] + for document_id in document_ids: + try: + document_item = get_document_metadata(document_id=document_id, user_id=user_id) + if not document_item: + errors.append({'document_id': document_id, 'error': 'Document not found.'}) + continue + if document_item.get('user_id') != user_id: + errors.append({'document_id': document_id, 'error': 'Only the document owner can extract metadata for this document.'}) + continue + + current_app.extensions['executor'].submit_stored( + f"{document_id}_metadata", + process_metadata_extraction_background, + document_id=document_id, + user_id=user_id + ) + queued.append({'document_id': document_id}) + except Exception as e: + errors.append({'document_id': document_id, 'error': str(e)}) + + if queued: + invalidate_personal_search_cache(user_id) + + status_code = 202 if queued and not errors else (207 if queued else 400) + return jsonify({ + 'message': f'Queued {len(queued)} document(s) for metadata extraction.', + 'queued': queued, + 'errors': errors, + }), status_code + @bp.route('/api/documents/reprocess_extraction', methods=['POST']) @swagger_route(security=get_auth_security()) @login_required diff --git a/application/single_app/route_backend_group_documents.py b/application/single_app/route_backend_group_documents.py index 386a976a..ff4af993 100644 --- a/application/single_app/route_backend_group_documents.py +++ b/application/single_app/route_backend_group_documents.py @@ -1185,6 +1185,86 @@ def api_extract_group_metadata(document_id): 'document_id': document_id }), 200 + @bp.route('/api/group_documents/extract_metadata', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required("enable_group_workspaces") + def api_extract_group_metadata_batch(): + """ + POST /api/group_documents/extract_metadata + Queues background metadata extraction jobs for selected group documents. + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + settings = get_settings() + if not settings.get('enable_extract_meta_data'): + return jsonify({'error': 'Metadata extraction not enabled'}), 403 + + active_group_id, group_doc, _, error_response = _require_active_group_document_context( + user_id, + allowed_roles=("Owner", "Admin", "DocumentManager"), + permission_message='You do not have permission to extract metadata for group documents', + ) + if error_response: + return error_response + + allowed, reason = check_group_status_allows_operation(group_doc, 'upload') + if not allowed: + return jsonify({'error': reason}), 403 + + payload = request.get_json(silent=True) or {} + document_ids = payload.get('document_ids') + if not isinstance(document_ids, list): + document_id = payload.get('document_id') + document_ids = [document_id] if document_id else [] + document_ids = list(dict.fromkeys( + str(document_id).strip() + for document_id in document_ids + if str(document_id or '').strip() + )) + if not document_ids: + return jsonify({'error': 'At least one document ID is required.'}), 400 + + queued = [] + errors = [] + for document_id in document_ids: + try: + document_item = get_document_metadata( + document_id=document_id, + user_id=user_id, + group_id=active_group_id, + ) + if not document_item: + errors.append({'document_id': document_id, 'error': 'Document not found.'}) + continue + if document_item.get('group_id') != active_group_id: + errors.append({'document_id': document_id, 'error': 'Only documents in the active group can have metadata extracted.'}) + continue + + current_app.extensions['executor'].submit_stored( + f"{document_id}_group_metadata", + process_metadata_extraction_background, + document_id=document_id, + user_id=user_id, + group_id=active_group_id + ) + queued.append({'document_id': document_id}) + except Exception as e: + errors.append({'document_id': document_id, 'error': str(e)}) + + if queued: + invalidate_group_search_cache(active_group_id) + + status_code = 202 if queued and not errors else (207 if queued else 400) + return jsonify({ + 'message': f'Queued {len(queued)} document(s) for metadata extraction.', + 'queued': queued, + 'errors': errors, + }), status_code + @bp.route('/api/group_documents/reprocess_extraction', methods=['POST']) @swagger_route(security=get_auth_security()) @login_required diff --git a/application/single_app/route_backend_public_documents.py b/application/single_app/route_backend_public_documents.py index f9da07f4..f2db5771 100644 --- a/application/single_app/route_backend_public_documents.py +++ b/application/single_app/route_backend_public_documents.py @@ -1019,6 +1019,81 @@ def api_extract_metadata_public_document(doc_id): executor.submit(process_metadata_extraction_background, document_id=doc_id, user_id=user_id, public_workspace_id=active_ws) return jsonify({'message':'Extraction queued'}), 200 + @bp.route('/api/public_documents/extract_metadata', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('enable_public_workspaces') + def api_extract_metadata_public_documents_batch(): + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + settings = get_settings() + if not settings.get('enable_extract_meta_data'): + return jsonify({'error': 'Metadata extraction not enabled'}), 403 + + active_ws, ws_doc, _role, error_response = _require_active_public_workspace_response( + user_id, + PUBLIC_WORKSPACE_MANAGER_ROLES, + ) + if error_response: + return error_response + + allowed, reason = check_public_workspace_status_allows_operation(ws_doc, 'upload') + if not allowed: + return jsonify({'error': reason}), 403 + + payload = request.get_json(silent=True) or {} + document_ids = payload.get('document_ids') + if not isinstance(document_ids, list): + doc_id = payload.get('document_id') + document_ids = [doc_id] if doc_id else [] + document_ids = list(dict.fromkeys( + str(document_id).strip() + for document_id in document_ids + if str(document_id or '').strip() + )) + if not document_ids: + return jsonify({'error': 'At least one document ID is required.'}), 400 + + queued = [] + errors = [] + for document_id in document_ids: + try: + document_item = get_document_metadata( + document_id=document_id, + user_id=user_id, + public_workspace_id=active_ws, + ) + if not document_item: + errors.append({'document_id': document_id, 'error': 'Document not found.'}) + continue + if document_item.get('public_workspace_id') != active_ws: + errors.append({'document_id': document_id, 'error': 'Only documents in the active public workspace can have metadata extracted.'}) + continue + + current_app.extensions['executor'].submit_stored( + f"{document_id}_public_metadata", + process_metadata_extraction_background, + document_id=document_id, + user_id=user_id, + public_workspace_id=active_ws + ) + queued.append({'document_id': document_id}) + except Exception as e: + errors.append({'document_id': document_id, 'error': str(e)}) + + if queued: + invalidate_public_workspace_search_cache(active_ws) + + status_code = 202 if queued and not errors else (207 if queued else 400) + return jsonify({ + 'message': f'Queued {len(queued)} document(s) for metadata extraction.', + 'queued': queued, + 'errors': errors, + }), status_code + @bp.route('/api/public_documents/reprocess_extraction', methods=['POST']) @swagger_route(security=get_auth_security()) @login_required diff --git a/application/single_app/static/js/public/public_workspace.js b/application/single_app/static/js/public/public_workspace.js index 3a81ca31..947b0640 100644 --- a/application/single_app/static/js/public/public_workspace.js +++ b/application/single_app/static/js/public/public_workspace.js @@ -81,6 +81,60 @@ function getPublicDeleteModalContent(documentCount) { }; } +async function requestPublicSelectedMetadataExtraction(documentIds) { + const response = await fetch('/api/public_documents/extract_metadata', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ document_ids: documentIds }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok && !(Array.isArray(data.queued) && data.queued.length > 0)) { + throw new Error(data.error || data.message || 'Unable to queue metadata extraction.'); + } + return data; +} + +function showPublicSelectedMetadataExtractionResult(data) { + const queuedCount = Array.isArray(data.queued) ? data.queued.length : 0; + const errorCount = Array.isArray(data.errors) ? data.errors.length : 0; + const message = errorCount > 0 + ? `Queued metadata extraction for ${queuedCount} document(s); ${errorCount} item(s) were skipped.` + : (data.message || `Queued metadata extraction for ${queuedCount} document(s).`); + showPublicWorkspaceToast(message, errorCount > 0 ? 'warning' : 'success'); +} + +async function extractPublicSelectedMetadata() { + const documentIds = Array.from(publicSelectedDocuments); + if (documentIds.length === 0) { + return; + } + if (!(window.enable_extract_meta_data === true || window.enable_extract_meta_data === 'true')) { + showPublicWorkspaceToast('Metadata extraction is not enabled.', 'info'); + return; + } + + const extractMetadataBtn = document.getElementById('public-extract-selected-metadata-btn'); + if (extractMetadataBtn) { + extractMetadataBtn.disabled = true; + extractMetadataBtn.innerHTML = 'Extracting...'; + } + + try { + const data = await requestPublicSelectedMetadataExtraction(documentIds); + showPublicSelectedMetadataExtractionResult(data); + publicSelectedDocuments.clear(); + syncPublicSelectionModeUI(); + fetchPublicDocs(); + } catch (error) { + showPublicWorkspaceToast(error.message, 'danger'); + } finally { + if (extractMetadataBtn) { + extractMetadataBtn.disabled = false; + extractMetadataBtn.innerHTML = 'Extract Metadata'; + } + } +} + function showPublicDocumentDeleteFeedback(message, variant = 'danger') { if (typeof window.showToast === 'function') { window.showToast(message, variant); @@ -614,11 +668,13 @@ document.addEventListener('DOMContentLoaded', ()=>{ const publicDownloadSelectedBtn = document.getElementById('public-download-selected-btn'); const publicClearSelectionBtn = document.getElementById('public-clear-selection-btn'); const publicChatSelectedBtn = document.getElementById('public-chat-selected-btn'); + const publicExtractSelectedMetadataBtn = document.getElementById('public-extract-selected-metadata-btn'); if (publicDeleteSelectedBtn) publicDeleteSelectedBtn.addEventListener('click', deletePublicSelectedDocuments); if (publicDownloadSelectedBtn) publicDownloadSelectedBtn.addEventListener('click', downloadPublicSelectedDocuments); if (publicClearSelectionBtn) publicClearSelectionBtn.addEventListener('click', clearPublicSelection); if (publicChatSelectedBtn) publicChatSelectedBtn.addEventListener('click', chatWithPublicSelected); + if (publicExtractSelectedMetadataBtn) publicExtractSelectedMetadataBtn.addEventListener('click', extractPublicSelectedMetadata); document.getElementById('public-toggle-selection-btn')?.addEventListener('click', togglePublicSelectionMode); document.addEventListener('click', handlePublicDocumentCardClick); }); @@ -2227,17 +2283,22 @@ function updatePublicBulkActionButtons() { const deleteBtn = document.getElementById('public-delete-selected-btn'); const downloadBtn = document.getElementById('public-download-selected-btn'); const reprocessDropdown = document.getElementById('public-reprocess-selected-dropdown'); + const extractMetadataBtn = document.getElementById('public-extract-selected-metadata-btn'); if (publicSelectedDocuments.size > 0) { if (bulkActionsBar) bulkActionsBar.style.display = 'block'; if (selectedCountSpan) selectedCountSpan.textContent = publicSelectedDocuments.size; const canManage = ['Owner', 'Admin', 'DocumentManager'].includes(userRoleInActivePublic); + const canModify = canManage && (window.currentPublicStatus || 'active') === 'active'; + const metadataEnabled = window.enable_extract_meta_data === true || window.enable_extract_meta_data === 'true'; if (deleteBtn) deleteBtn.style.display = canManage ? 'inline-block' : 'none'; if (downloadBtn) downloadBtn.classList.toggle('d-none', !publicFileDownloadsEnabled); if (reprocessDropdown) reprocessDropdown.classList.toggle('d-none', !canManage); + if (extractMetadataBtn) extractMetadataBtn.classList.toggle('d-none', !(canModify && metadataEnabled)); } else { if (bulkActionsBar) bulkActionsBar.style.display = 'none'; if (downloadBtn) downloadBtn.classList.add('d-none'); + if (extractMetadataBtn) extractMetadataBtn.classList.add('d-none'); } } @@ -2511,6 +2572,7 @@ window.clearPublicSelection = clearPublicSelection; window.chatWithPublicSelected = chatWithPublicSelected; window.reprocessPublicDocumentExtraction = reprocessPublicDocumentExtraction; window.reprocessPublicSelectedDocumentExtraction = reprocessPublicSelectedDocumentExtraction; +window.extractPublicSelectedMetadata = extractPublicSelectedMetadata; // Prompts function canManagePublicPrompts() { diff --git a/application/single_app/static/js/workspace/workspace-documents.js b/application/single_app/static/js/workspace/workspace-documents.js index ddabc09d..76c53f53 100644 --- a/application/single_app/static/js/workspace/workspace-documents.js +++ b/application/single_app/static/js/workspace/workspace-documents.js @@ -32,6 +32,7 @@ const docsSharedOnlyFilter = document.getElementById("docs-shared-only-filter"); const deleteSelectedBtn = document.getElementById("delete-selected-btn"); const downloadSelectedBtn = document.getElementById("download-selected-btn"); const chatSelectedBtn = document.getElementById("chat-selected-btn"); +const extractSelectedMetadataBtn = document.getElementById("extract-selected-metadata-btn"); const clearSelectionBtn = document.getElementById("clear-selection-btn"); const documentDeleteModalElement = document.getElementById("documentDeleteModal"); const documentDeleteModal = documentDeleteModalElement ? new bootstrap.Modal(documentDeleteModalElement) : null; @@ -55,6 +56,10 @@ function getDocumentConversationUrl(doc) { return ""; } +function isWorkspaceMetadataExtractionEnabled() { + return window.enable_extract_meta_data === true || window.enable_extract_meta_data === "true"; +} + function setDocumentConversationStatusElement(element, doc) { if (!element) { return; @@ -2575,6 +2580,60 @@ window.reprocessSelectedDocumentExtraction = async function(extractionMode) { } }; +async function requestSelectedDocumentMetadataExtraction(documentIds) { + const response = await fetch('/api/documents/extract_metadata', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ document_ids: documentIds }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok && !(Array.isArray(data.queued) && data.queued.length > 0)) { + throw new Error(data.error || data.message || 'Unable to queue metadata extraction.'); + } + return data; +} + +function showSelectedDocumentMetadataExtractionResult(data) { + const queuedCount = Array.isArray(data.queued) ? data.queued.length : 0; + const errorCount = Array.isArray(data.errors) ? data.errors.length : 0; + const message = errorCount > 0 + ? `Queued metadata extraction for ${queuedCount} document(s); ${errorCount} item(s) were skipped.` + : (data.message || `Queued metadata extraction for ${queuedCount} document(s).`); + + showToast(message, errorCount > 0 ? 'warning' : 'success'); +} + +window.extractSelectedMetadata = async function() { + const documentIds = Array.from(selectedDocuments); + if (documentIds.length === 0) { + return; + } + if (!isWorkspaceMetadataExtractionEnabled()) { + showToast("Metadata extraction is not enabled.", 'info'); + return; + } + + if (extractSelectedMetadataBtn) { + extractSelectedMetadataBtn.disabled = true; + extractSelectedMetadataBtn.innerHTML = 'Extracting...'; + } + + try { + const data = await requestSelectedDocumentMetadataExtraction(documentIds); + showSelectedDocumentMetadataExtractionResult(data); + selectedDocuments.clear(); + syncDocumentSelectionModeUI(); + fetchUserDocuments(); + } catch (error) { + showToast(error.message, 'danger'); + } finally { + if (extractSelectedMetadataBtn) { + extractSelectedMetadataBtn.disabled = false; + extractSelectedMetadataBtn.innerHTML = 'Extract Metadata'; + } + } +}; + // Make fetchUserDocuments globally available for workspace-init.js window.fetchUserDocuments = fetchUserDocuments; @@ -2610,6 +2669,7 @@ function updateBulkActionButtons() { const bulkActionsBar = document.getElementById('bulkActionsBar'); const selectedCountSpan = document.getElementById('selectedCount'); const downloadBtn = document.getElementById('download-selected-btn'); + const extractMetadataBtn = document.getElementById('extract-selected-metadata-btn'); if (selectedDocuments.size > 0) { // Show bulk actions bar with count @@ -2623,7 +2683,10 @@ function updateBulkActionButtons() { if (downloadBtn) { downloadBtn.classList.toggle('d-none', !personalWorkspaceFileDownloadsEnabled); } - + if (extractMetadataBtn) { + extractMetadataBtn.classList.toggle('d-none', !isWorkspaceMetadataExtractionEnabled()); + } + } else { // Hide bulk actions bar if (bulkActionsBar) { @@ -2633,6 +2696,9 @@ function updateBulkActionButtons() { if (downloadBtn) { downloadBtn.classList.add('d-none'); } + if (extractMetadataBtn) { + extractMetadataBtn.classList.add('d-none'); + } } } @@ -2799,7 +2865,11 @@ document.addEventListener('DOMContentLoaded', function() { if (chatSelectedBtn) { chatSelectedBtn.addEventListener('click', window.chatWithSelected); } - + + if (extractSelectedMetadataBtn) { + extractSelectedMetadataBtn.addEventListener('click', window.extractSelectedMetadata); + } + // Clear selection button if (clearSelectionBtn) { clearSelectionBtn.addEventListener('click', window.clearDocumentSelection); diff --git a/application/single_app/templates/group_workspaces.html b/application/single_app/templates/group_workspaces.html index 5cb0d7b6..d1b91c6f 100644 --- a/application/single_app/templates/group_workspaces.html +++ b/application/single_app/templates/group_workspaces.html @@ -873,6 +873,9 @@