diff --git a/.gitignore b/.gitignore index 8a9839df..aaa7577d 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ flask_session **/my_chart.png **/sample_pie.csv **/sample_stacked_column.csv +tmp**cwd +tmp_images +nul \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..4e9c2830 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,160 @@ +# SimpleChat — Project Instructions + +SimpleChat is a Flask web application using Azure Cosmos DB, Azure AI Search, and Azure OpenAI. It supports personal, group, and public workspaces for document management and AI-powered chat. + +## Code Style — Python + +- Start every file with a filename comment: `# filename.py` +- Place imports at the top, after the module docstring (exceptions must be documented) +- Use 4-space indentation, never tabs +- Use `log_event` from `functions_appinsights.py` for logging instead of `print()` + +## Code Style — JavaScript + +- Start every file with a filename comment: `// filename.js` +- Group imports at the top of the file (exceptions must be documented) +- Use 4-space indentation, never tabs +- Use camelCase for variables and functions: `myVariable`, `getUserData()` +- Use PascalCase for classes: `MyClass` +- Never use `display:none` in JavaScript; use Bootstrap's `d-none` class instead +- Use Bootstrap alert classes for notifications, not `alert()` calls + +## Route Decorators — Swagger Security + +**Every Flask route MUST include the `@swagger_route(security=get_auth_security())` decorator.** + +- Import `swagger_route` and `get_auth_security` from `swagger_wrapper` +- Place `@swagger_route(security=get_auth_security())` immediately after the `@app.route(...)` decorator and before any authentication decorators (`@login_required`, `@user_required`, etc.) +- This applies to all new and existing routes — no exceptions + +Correct pattern: +```python +from swagger_wrapper import swagger_route, get_auth_security + +@app.route("/api/example", methods=["GET"]) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +def example_route(): + ... +``` + +## Security — Settings Sanitization + +**NEVER send raw settings or configuration data to the frontend without sanitization.** + +- Always use `sanitize_settings_for_user()` from `functions_settings.py` before passing settings to `render_template()` or `jsonify()` +- **Exception**: Admin routes should NOT be sanitized (breaks admin features) +- Sanitization strips: API keys, Cosmos DB connection strings, Azure Search admin keys, Document Intelligence keys, authentication secrets, internal endpoint URLs, database credentials, and any field containing "key", "secret", "password", or "connection" + +Correct pattern: +```python +from functions_settings import get_settings, sanitize_settings_for_user + +settings = get_settings() +public_settings = sanitize_settings_for_user(settings) +return render_template('page.html', settings=public_settings) +``` + +## Version Management + +- Version is stored in `config.py`: `VERSION = "X.XXX.XXX"` +- When incrementing, only change the third segment (e.g., `0.238.024` -> `0.238.025`) +- Include the current version in functional test file headers and documentation files + +## Documentation Locations + +- **Feature documentation**: `docs/explanation/features/[FEATURE_NAME].md` (uppercase with underscores) +- **Fix documentation**: `docs/explanation/fixes/[ISSUE_NAME]_FIX.md` (uppercase with underscores) +- **Release notes**: `docs/explanation/release_notes.md` + +### Feature Documentation Structure + +1. Header: title, overview, version, dependencies +2. Technical specifications: architecture, APIs, configuration, file structure +3. Usage instructions: enable/configure, workflows, examples +4. Testing and validation: coverage, performance, limitations + +### Fix Documentation Structure + +1. Header: title, issue description, root cause, version +2. Technical details: files modified, code changes, testing, impact +3. Validation: test results, before/after comparison + +## Release Notes + +After completing code changes, offer to update `docs/explanation/release_notes.md`. + +- Add entries under the current version from `config.py` +- If the version was bumped, create a new section at the top: `### **(vX.XXX.XXX)**` +- Entry categories: **New Features**, **Bug Fixes**, **User Interface Enhancements**, **Breaking Changes** +- Format each entry with a bold title, bullet-point details, and a `(Ref: ...)` line referencing relevant files/concepts + +## Functional Tests + +- **Location**: `functional_tests/` +- **Naming**: `test_{feature_area}_{specific_test}.py` or `.js` +- **When to create**: bug fixes, new features, API changes, database migration, UI/UX changes, authentication/security changes + +Every test file must include a version header: +```python +#!/usr/bin/env python3 +""" +Functional test for [feature/fix name]. +Version: [current version from config.py] +Implemented in: [version when fix/feature was added] + +This test ensures that [description of what is being tested]. +""" +``` + +Test template pattern: +```python +import sys +import os +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +def test_primary_functionality(): + """Test the main functionality.""" + print("Testing [Feature Name]...") + try: + # Setup, execute, validate, cleanup + print("Test passed!") + return True + except Exception as e: + print(f"Test failed: {e}") + import traceback + traceback.print_exc() + return False + +if __name__ == "__main__": + success = test_primary_functionality() + sys.exit(0 if success else 1) +``` + + + +## Key Project Files + +| File | Purpose | +|------|---------| +| `application/single_app/config.py` | App configuration and `VERSION` | +| `application/single_app/functions_settings.py` | `get_settings()`, `sanitize_settings_for_user()` | +| `application/single_app/functions_appinsights.py` | `log_event()` for logging | +| `application/single_app/functions_documents.py` | Document CRUD, chunk operations, tag management | +| `application/single_app/functions_group.py` | Group workspace operations | +| `application/single_app/functions_public_workspaces.py` | Public workspace operations | +| `application/single_app/route_backend_documents.py` | Personal document API routes | +| `application/single_app/route_backend_group_documents.py` | Group document API routes | +| `application/single_app/route_external_public_documents.py` | Public document API routes | +| `application/single_app/route_backend_chats.py` | Chat API routes and AI search integration | + +## Frontend Architecture + +- Templates: `application/single_app/templates/` (Jinja2 HTML) +- Static JS: `application/single_app/static/js/` + - `chat/` — Chat interface modules (chat-messages.js, chat-documents.js, chat-citations.js, chat-streaming.js) + - `workspace/` — Personal workspace (workspace-documents.js, workspace-tags.js) + - `public/` — Public workspace (public_workspace.js) +- Group workspace JS is inline in `templates/group_workspaces.html` +- Uses Bootstrap 5 for UI components and styling diff --git a/application/single_app/config.py b/application/single_app/config.py index 989731f4..a6a3bc99 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -88,7 +88,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.237.049" +VERSION = "0.238.024" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/functions_activity_logging.py b/application/single_app/functions_activity_logging.py index df9cabf3..2a653a47 100644 --- a/application/single_app/functions_activity_logging.py +++ b/application/single_app/functions_activity_logging.py @@ -170,6 +170,60 @@ def log_web_search_consent_acceptance( debug_print(f"Error logging web search consent acceptance for user {user_id}: {str(e)}") +def log_index_auto_fix( + index_type: str, + missing_fields: list, + user_id: str = 'system', + admin_email: Optional[str] = None +) -> None: + """ + Log automatic Azure AI Search index field fixes to activity_logs and App Insights. + + Args: + index_type (str): Type of index fixed ('user', 'group', or 'public'). + missing_fields (list): List of field names that were added. + user_id (str, optional): User ID triggering the fix. Defaults to 'system'. + admin_email (str, optional): Admin email if triggered by admin. + """ + try: + activity_record = { + 'id': str(uuid.uuid4()), + 'activity_type': 'index_auto_fix', + 'user_id': user_id, + 'timestamp': datetime.utcnow().isoformat(), + 'created_at': datetime.utcnow().isoformat(), + 'index_type': index_type, + 'missing_fields': missing_fields, + 'fields_added': len(missing_fields), + 'trigger': 'automatic', + 'description': f"Automatically added {len(missing_fields)} missing field(s) to {index_type} index: {', '.join(missing_fields)}" + } + + if admin_email: + activity_record['admin_email'] = admin_email + + cosmos_activity_logs_container.create_item(body=activity_record) + + log_event( + message=f"Auto-fixed {index_type} index: added {len(missing_fields)} field(s)", + extra=activity_record, + level=logging.INFO + ) + debug_print(f"Logged index auto-fix for {index_type} index: {', '.join(missing_fields)}") + + except Exception as e: + log_event( + message=f"Error logging index auto-fix: {str(e)}", + extra={ + 'user_id': user_id, + 'index_type': index_type, + 'error': str(e) + }, + level=logging.ERROR + ) + debug_print(f"Error logging index auto-fix for {index_type}: {str(e)}") + + def log_document_upload( user_id: str, container_type: str, diff --git a/application/single_app/functions_conversation_metadata.py b/application/single_app/functions_conversation_metadata.py index 262b0955..7f829a5e 100644 --- a/application/single_app/functions_conversation_metadata.py +++ b/application/single_app/functions_conversation_metadata.py @@ -464,7 +464,30 @@ def collect_conversation_metadata(user_message, conversation_id, user_id, active } current_tags[semantic_key] = semantic_tag # Update the tags array conversation_item['tags'] = list(current_tags.values()) - + + # --- Scope Lock Logic --- + current_scope_locked = conversation_item.get('scope_locked') + + if document_map: + # Always update locked_contexts when search results exist (even if unlocked) + # This ensures re-locking uses the most up-to-date workspace list + locked_set = set() + for ctx in conversation_item.get('context', []): + if ctx.get('scope') != 'model_knowledge' and ctx.get('type') in ('primary', 'secondary'): + locked_set.add((ctx['scope'], ctx.get('id'))) + + # Merge with existing locked_contexts + for ctx in conversation_item.get('locked_contexts', []): + locked_set.add((ctx.get('scope'), ctx.get('id'))) + + conversation_item['locked_contexts'] = [ + {"scope": s, "id": i} for s, i in locked_set if s and i + ] + + # Only auto-lock the FIRST time (from null state) + if current_scope_locked is None: + conversation_item['scope_locked'] = True + return conversation_item diff --git a/application/single_app/functions_documents.py b/application/single_app/functions_documents.py index 9ae01a62..0078b456 100644 --- a/application/single_app/functions_documents.py +++ b/application/single_app/functions_documents.py @@ -88,7 +88,8 @@ def create_document(file_name, user_id, document_id, num_file_chunks, status, gr "document_classification": "None", "type": "document_metadata", "public_workspace_id": public_workspace_id, - "user_id": user_id + "user_id": user_id, + "tags": [] } elif is_group: document_metadata = { @@ -106,7 +107,8 @@ def create_document(file_name, user_id, document_id, num_file_chunks, status, gr "document_classification": "None", "type": "document_metadata", "group_id": group_id, - "shared_group_ids": [] + "shared_group_ids": [], + "tags": [] } else: document_metadata = { @@ -126,7 +128,8 @@ def create_document(file_name, user_id, document_id, num_file_chunks, status, gr "user_id": user_id, "shared_user_ids": [], "embedding_tokens": 0, - "embedding_model_deployment_name": None + "embedding_model_deployment_name": None, + "tags": [] } cosmos_container.upsert_item(document_metadata) @@ -283,6 +286,7 @@ def save_video_chunk( "chunk_sequence": seconds, "upload_date": current_time, "version": version, + "document_tags": meta.get('tags', []) if meta else [] } if is_group: @@ -1326,7 +1330,7 @@ def update_document(**kwargs): continue # Skip direct assignment if increment was used existing_document[key] = value update_occurred = True - if key in ['title', 'authors', 'file_name', 'document_classification']: + if key in ['title', 'authors', 'file_name', 'document_classification', 'tags']: updated_fields_requiring_chunk_sync.add(key) # Propagate shared_group_ids to group chunks if changed if is_group and key == 'shared_group_ids': @@ -1380,6 +1384,8 @@ def update_document(**kwargs): chunk_updates['file_name'] = existing_document.get('file_name') if 'document_classification' in updated_fields_requiring_chunk_sync: chunk_updates['document_classification'] = existing_document.get('document_classification') + if 'tags' in updated_fields_requiring_chunk_sync: + chunk_updates['document_tags'] = existing_document.get('tags', []) if chunk_updates: # Only call update if there's something to change # Build the call parameters @@ -1562,6 +1568,7 @@ def save_chunks(page_text_content, page_number, file_name, user_id, document_id, "author": author, "title": title, "document_classification": "None", + "document_tags": metadata.get('tags', []), "chunk_sequence": page_number, # or you can keep an incremental idx "upload_date": current_time, "version": version, @@ -1583,6 +1590,7 @@ def save_chunks(page_text_content, page_number, file_name, user_id, document_id, "author": author, "title": title, "document_classification": "None", + "document_tags": metadata.get('tags', []), "chunk_sequence": page_number, # or you can keep an incremental idx "upload_date": current_time, "version": version, @@ -1606,6 +1614,7 @@ def save_chunks(page_text_content, page_number, file_name, user_id, document_id, "author": author, "title": title, "document_classification": "None", + "document_tags": metadata.get('tags', []), "chunk_sequence": page_number, # or you can keep an incremental idx "upload_date": current_time, "version": version, @@ -1819,6 +1828,7 @@ def update_chunk_metadata(chunk_id, user_id, group_id=None, public_workspace_id= 'author', 'title', 'document_classification', + 'document_tags', 'shared_user_ids' ] @@ -6351,4 +6361,390 @@ def get_documents_shared_with_group(group_id): except Exception as e: print(f"Error getting documents shared with group {group_id}: {e}") - return [] \ No newline at end of file + return [] + + +# ============= TAG MANAGEMENT FUNCTIONS ============= + +def normalize_tag(tag): + """ + Normalize a tag by trimming whitespace and converting to lowercase. + Returns normalized tag string. + """ + if not isinstance(tag, str): + return "" + return tag.strip().lower() + + +def validate_tags(tags): + """ + Validate an array of tags. + Returns (is_valid, error_message, normalized_tags) + + Rules: + - Max 50 characters per tag + - Alphanumeric + hyphens/underscores only + - No empty tags + - Case-insensitive uniqueness + """ + if not isinstance(tags, list): + return False, "Tags must be an array", [] + + normalized = [] + seen = set() + + for tag in tags: + if not isinstance(tag, str): + return False, "All tags must be strings", [] + + normalized_tag = normalize_tag(tag) + + if not normalized_tag: + continue # Skip empty tags + + if len(normalized_tag) > 50: + return False, f"Tag '{normalized_tag}' exceeds 50 characters", [] + + # Check alphanumeric + hyphens/underscores + import re + if not re.match(r'^[a-z0-9_-]+$', normalized_tag): + return False, f"Tag '{normalized_tag}' contains invalid characters (only alphanumeric, hyphens, and underscores allowed)", [] + + # Check for duplicates + if normalized_tag in seen: + continue # Skip duplicate + + seen.add(normalized_tag) + normalized.append(normalized_tag) + + return True, None, normalized + + +def get_workspace_tags(user_id, group_id=None, public_workspace_id=None): + """ + Get all unique tags used in a workspace with document counts. + Returns: [{'name': 'tag1', 'count': 5, 'color': '#3b82f6'}, ...] + """ + from functions_settings import get_user_settings + + is_group = group_id is not None + is_public_workspace = public_workspace_id is not None + + # Choose the correct container + if is_public_workspace: + cosmos_container = cosmos_public_documents_container + partition_key = public_workspace_id + workspace_type = 'public' + elif is_group: + cosmos_container = cosmos_group_documents_container + partition_key = group_id + workspace_type = 'group' + else: + cosmos_container = cosmos_user_documents_container + partition_key = user_id + workspace_type = 'personal' + + try: + # Query all documents with tags + if is_public_workspace: + query = """ + SELECT c.tags + FROM c + WHERE c.public_workspace_id = @partition_key + AND IS_DEFINED(c.tags) + AND ARRAY_LENGTH(c.tags) > 0 + """ + elif is_group: + query = """ + SELECT c.tags + FROM c + WHERE c.group_id = @partition_key + AND IS_DEFINED(c.tags) + AND ARRAY_LENGTH(c.tags) > 0 + """ + else: + query = """ + SELECT c.tags + FROM c + WHERE c.user_id = @partition_key + AND IS_DEFINED(c.tags) + AND ARRAY_LENGTH(c.tags) > 0 + """ + + parameters = [{"name": "@partition_key", "value": partition_key}] + + documents = list( + cosmos_container.query_items( + query=query, + parameters=parameters, + enable_cross_partition_query=True + ) + ) + + # Count tag occurrences + tag_counts = {} + for doc in documents: + for tag in doc.get('tags', []): + normalized_tag = normalize_tag(tag) + if normalized_tag: + tag_counts[normalized_tag] = tag_counts.get(normalized_tag, 0) + 1 + + # Get tag definitions (colors) from the appropriate source + if is_public_workspace: + # Read from public workspace record (shared across all users) + from functions_public_workspaces import find_public_workspace_by_id + ws_doc = find_public_workspace_by_id(public_workspace_id) + workspace_tag_defs = (ws_doc or {}).get('tag_definitions', {}) + elif is_group: + # Read from group record (shared across all group members) + from functions_group import find_group_by_id + group_doc = find_group_by_id(group_id) + workspace_tag_defs = (group_doc or {}).get('tag_definitions', {}) + else: + # Personal: read from user settings + user_settings = get_user_settings(user_id) + settings_dict = user_settings.get('settings', {}) + tag_definitions = settings_dict.get('tag_definitions', {}) + workspace_tag_defs = tag_definitions.get('personal', {}) + + # Build result with colors from used tags + results = [] + for tag_name, count in tag_counts.items(): + tag_def = workspace_tag_defs.get(tag_name, {}) + results.append({ + 'name': tag_name, + 'count': count, + 'color': tag_def.get('color', get_default_tag_color(tag_name)) + }) + + # Add defined tags that haven't been used yet (count = 0) + for tag_name, tag_def in workspace_tag_defs.items(): + if tag_name not in tag_counts: + results.append({ + 'name': tag_name, + 'count': 0, + 'color': tag_def.get('color', get_default_tag_color(tag_name)) + }) + + # Sort by count descending, then name ascending + results.sort(key=lambda x: (-x['count'], x['name'])) + + return results + + except Exception as e: + print(f"Error getting workspace tags: {e}") + return [] + + +def get_default_tag_color(tag_name): + """ + Generate a consistent color for a tag based on its name. + Uses a predefined color palette and hashes the tag name. + """ + color_palette = [ + '#3b82f6', # blue + '#10b981', # green + '#f59e0b', # amber + '#ef4444', # red + '#8b5cf6', # purple + '#ec4899', # pink + '#06b6d4', # cyan + '#84cc16', # lime + '#f97316', # orange + '#6366f1', # indigo + ] + + # Simple hash function to pick color consistently + hash_val = sum(ord(c) for c in tag_name) + color_index = hash_val % len(color_palette) + return color_palette[color_index] + + +def get_or_create_tag_definition(user_id, tag_name, workspace_type='personal', color=None, group_id=None, public_workspace_id=None): + """ + Get or create a tag definition. + For personal: stored in user settings. + For group: stored on the group Cosmos record. + For public: stored on the public workspace Cosmos record. + + Args: + user_id: User ID + tag_name: Normalized tag name + workspace_type: 'personal', 'group', or 'public' + color: Optional hex color code + group_id: Group ID (required when workspace_type='group') + public_workspace_id: Public workspace ID (required when workspace_type='public') + + Returns: + Tag definition dict with color + """ + from datetime import datetime, timezone + + if workspace_type == 'group' and group_id: + from functions_group import find_group_by_id + group_doc = find_group_by_id(group_id) + if not group_doc: + return {'color': color or get_default_tag_color(tag_name)} + tag_defs = group_doc.get('tag_definitions', {}) + if tag_name not in tag_defs: + tag_defs[tag_name] = { + 'color': color if color else get_default_tag_color(tag_name), + 'created_at': datetime.now(timezone.utc).isoformat() + } + group_doc['tag_definitions'] = tag_defs + cosmos_groups_container.upsert_item(group_doc) + return tag_defs[tag_name] + elif workspace_type == 'public' and public_workspace_id: + from functions_public_workspaces import find_public_workspace_by_id + ws_doc = find_public_workspace_by_id(public_workspace_id) + if not ws_doc: + return {'color': color or get_default_tag_color(tag_name)} + tag_defs = ws_doc.get('tag_definitions', {}) + if tag_name not in tag_defs: + tag_defs[tag_name] = { + 'color': color if color else get_default_tag_color(tag_name), + 'created_at': datetime.now(timezone.utc).isoformat() + } + ws_doc['tag_definitions'] = tag_defs + cosmos_public_workspaces_container.upsert_item(ws_doc) + return tag_defs[tag_name] + else: + # Personal: store in user settings + from functions_settings import get_user_settings, update_user_settings + + user_settings = get_user_settings(user_id) + settings_dict = user_settings.get('settings', {}) + tag_definitions = settings_dict.get('tag_definitions', {}) + + if 'personal' not in tag_definitions: + tag_definitions['personal'] = {} + + workspace_tags = tag_definitions['personal'] + + if tag_name not in workspace_tags: + workspace_tags[tag_name] = { + 'color': color if color else get_default_tag_color(tag_name), + 'created_at': datetime.now(timezone.utc).isoformat() + } + update_user_settings(user_id, {'tag_definitions': tag_definitions}) + + return workspace_tags[tag_name] + + +def propagate_tags_to_blob_metadata(document_id, tags, user_id, group_id=None, public_workspace_id=None): + """ + Update blob metadata with document tags when enhanced citations is enabled. + Tags are stored as a comma-separated string in blob metadata. + + Args: + document_id: Document ID + tags: Array of normalized tag names + user_id: User ID + group_id: Optional group ID + public_workspace_id: Optional public workspace ID + """ + try: + settings = get_settings() + if not settings.get('enable_enhanced_citations', False): + return + + is_group = group_id is not None + is_public_workspace = public_workspace_id is not None + + # Read document from Cosmos DB to get file_name + if is_public_workspace: + cosmos_container = cosmos_public_documents_container + elif is_group: + cosmos_container = cosmos_group_documents_container + else: + cosmos_container = cosmos_user_documents_container + + doc_item = cosmos_container.read_item(document_id, partition_key=document_id) + file_name = doc_item.get('file_name') + if not file_name: + print(f"Warning: No file_name found for document {document_id}, skipping blob metadata update") + return + + # Determine container and blob path + if is_public_workspace: + storage_account_container_name = storage_account_public_documents_container_name + blob_path = f"{public_workspace_id}/{file_name}" + elif is_group: + storage_account_container_name = storage_account_group_documents_container_name + blob_path = f"{group_id}/{file_name}" + else: + storage_account_container_name = storage_account_user_documents_container_name + blob_path = f"{user_id}/{file_name}" + + blob_service_client = CLIENTS.get("storage_account_office_docs_client") + if not blob_service_client: + print(f"Warning: Blob service client not available, skipping blob metadata update") + return + + blob_client = blob_service_client.get_blob_client( + container=storage_account_container_name, + blob=blob_path + ) + + if not blob_client.exists(): + print(f"Warning: Blob not found at {blob_path}, skipping metadata update") + return + + # Get existing metadata and update with tags + properties = blob_client.get_blob_properties() + existing_metadata = dict(properties.metadata) if properties.metadata else {} + existing_metadata['document_tags'] = ','.join(tags) if tags else '' + blob_client.set_blob_metadata(metadata=existing_metadata) + + print(f"Successfully updated blob metadata tags for document {document_id} at {blob_path}") + + except Exception as e: + print(f"Warning: Failed to update blob metadata tags for document {document_id}: {e}") + # Non-fatal — tag propagation to chunks is the primary operation + + +def propagate_tags_to_chunks(document_id, tags, user_id, group_id=None, public_workspace_id=None): + """ + Update all chunks for a document with new tags. + This is called immediately after tag updates. + + Args: + document_id: Document ID + tags: Array of normalized tag names + user_id: User ID + group_id: Optional group ID + public_workspace_id: Optional public workspace ID + """ + try: + # Get all chunks for this document + chunks = get_all_chunks(document_id, user_id, group_id, public_workspace_id) + + if not chunks: + print(f"No chunks found for document {document_id}") + return + + # Update each chunk with new tags + chunk_count = 0 + for chunk in chunks: + try: + update_chunk_metadata( + chunk_id=chunk['id'], + user_id=user_id, + group_id=group_id, + public_workspace_id=public_workspace_id, + document_id=document_id, + document_tags=tags + ) + chunk_count += 1 + except Exception as chunk_error: + print(f"Error updating chunk {chunk['id']} with tags: {chunk_error}") + # Continue with other chunks + + print(f"Successfully propagated tags to {chunk_count} chunks for document {document_id}") + + # Also update blob metadata with tags if enhanced citations is enabled + propagate_tags_to_blob_metadata(document_id, tags, user_id, group_id, public_workspace_id) + + except Exception as e: + print(f"Error propagating tags to chunks for document {document_id}: {e}") + raise \ No newline at end of file diff --git a/application/single_app/functions_search.py b/application/single_app/functions_search.py index 561264e7..65406bd6 100644 --- a/application/single_app/functions_search.py +++ b/application/single_app/functions_search.py @@ -73,37 +73,80 @@ def normalize_scores(results: List[Dict[str, Any]], index_name: str = "unknown") return results -def hybrid_search(query, user_id, document_id=None, top_n=12, doc_scope="all", active_group_id=None, active_public_workspace_id=None, enable_file_sharing=True): +def build_tags_filter(tags_filter): + """ + Build OData filter clause for tags. + tags_filter: List of tag names (already normalized) + Returns: String like "document_tags/any(t: t in ('tag1', 'tag2'))" or empty string + """ + if not tags_filter or not isinstance(tags_filter, list) or len(tags_filter) == 0: + return "" + + # Escape single quotes in tag names + escaped_tags = [tag.replace("'", "''") for tag in tags_filter] + tags_list_str = "', '".join(escaped_tags) + + # For AND logic (all tags must be present), we need multiple any() clauses + # document_tags/any(t: t eq 'tag1') and document_tags/any(t: t eq 'tag2') + tag_conditions = [f"document_tags/any(t: t eq '{tag}')" for tag in escaped_tags] + return " and ".join(tag_conditions) + +def hybrid_search(query, user_id, document_id=None, document_ids=None, top_n=12, doc_scope="all", active_group_id=None, active_group_ids=None, active_public_workspace_id=None, enable_file_sharing=True, tags_filter=None): """ Hybrid search that queries the user doc index, group doc index, or public doc index depending on doc type. If document_id is None, we just search the user index for the user's docs OR you could unify that logic further (maybe search both). enable_file_sharing: If False, do not include shared_user_ids in filters. - + tags_filter: Optional list of tag names to filter documents by (AND logic - all tags must match) + document_ids: Optional list of document IDs to filter by (OR logic - any document matches) + active_group_ids: Optional list of group IDs for multi-group search (OR logic) + This function uses document-set-aware caching to ensure consistent results across identical queries against the same document set. """ + + # Backwards compat: wrap single group ID into list + if not active_group_ids and active_group_id: + active_group_ids = [active_group_id] + + # Resolve document_ids from single document_id for backwards compat + if document_ids and len(document_ids) > 0: + # Use the list; also set document_id to first for any legacy code paths + document_id = document_ids[0] if not document_id else document_id + elif document_id: + document_ids = [document_id] + + # Build document ID filter clause + doc_id_filter = None + if document_ids and len(document_ids) > 0: + if len(document_ids) == 1: + doc_id_filter = f"document_id eq '{document_ids[0]}'" + else: + conditions = " or ".join([f"document_id eq '{did}'" for did in document_ids]) + doc_id_filter = f"({conditions})" - # Generate cache key including document set fingerprints + # Generate cache key including document set fingerprints and tags filter cache_key = generate_search_cache_key( query=query, user_id=user_id, document_id=document_id, + document_ids=document_ids, doc_scope=doc_scope, - active_group_id=active_group_id, + active_group_ids=active_group_ids, active_public_workspace_id=active_public_workspace_id, top_n=top_n, - enable_file_sharing=enable_file_sharing + enable_file_sharing=enable_file_sharing, + tags_filter=tags_filter ) - + # Check cache first (pass scope parameters for correct partition key) cached_results = get_cached_search_results( - cache_key, - user_id, - doc_scope, - active_group_id, - active_public_workspace_id + cache_key, + user_id, + doc_scope, + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id ) if cached_results is not None: debug_print( @@ -149,40 +192,50 @@ def hybrid_search(query, user_id, document_id=None, top_n=12, doc_scope="all", a k_nearest_neighbors=top_n, fields="embedding" ) + + # Build tags filter clause if provided + tags_filter_clause = build_tags_filter(tags_filter) if doc_scope == "all": - if document_id: + if doc_id_filter: + # Build user filter with optional tags + user_base_filter = ( + ( + f"(user_id eq '{user_id}' or shared_user_ids/any(u: u eq '{user_id},approved')) " + if enable_file_sharing else + f"user_id eq '{user_id}' " + ) + + f"and {doc_id_filter}" + ) + user_filter = f"{user_base_filter} and {tags_filter_clause}" if tags_filter_clause else user_base_filter + user_results = search_client_user.search( search_text=query, vector_queries=[vector_query], - filter=( - ( - f"(user_id eq '{user_id}' or shared_user_ids/any(u: u eq '{user_id},approved')) " - if enable_file_sharing else - f"user_id eq '{user_id}' " - ) + - f"and document_id eq '{document_id}'" - ), + filter=user_filter, query_type="semantic", semantic_configuration_name="nexus-user-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "user_id", "version", "chunk_sequence", "upload_date", "document_classification", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=["id", "chunk_text", "chunk_id", "file_name", "user_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] ) - # Only search group index if active_group_id is provided - if active_group_id: + # Only search group index if active_group_ids is provided + if active_group_ids: + group_conditions = " or ".join([f"group_id eq '{gid}'" for gid in active_group_ids]) + shared_conditions = " or ".join([f"shared_group_ids/any(g: g eq '{gid},approved')" for gid in active_group_ids]) + group_base_filter = f"({group_conditions} or {shared_conditions}) and {doc_id_filter}" + group_filter = f"{group_base_filter} and {tags_filter_clause}" if tags_filter_clause else group_base_filter + group_results = search_client_group.search( search_text=query, vector_queries=[vector_query], - filter=( - f"(group_id eq '{active_group_id}' or shared_group_ids/any(g: g eq '{active_group_id},approved')) and document_id eq '{document_id}'" - ), + filter=group_filter, query_type="semantic", semantic_configuration_name="nexus-group-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "group_id", "version", "chunk_sequence", "upload_date", "document_classification", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=["id", "chunk_text", "chunk_id", "file_name", "group_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] ) else: group_results = [] @@ -194,10 +247,12 @@ def hybrid_search(query, user_id, document_id=None, top_n=12, doc_scope="all", a if visible_public_workspace_ids: # Use 'or' conditions instead of 'in' operator for OData compatibility workspace_conditions = " or ".join([f"public_workspace_id eq '{id}'" for id in visible_public_workspace_ids]) - public_filter = f"({workspace_conditions}) and document_id eq '{document_id}'" + public_base_filter = f"({workspace_conditions}) and {doc_id_filter}" else: # Fallback to active_public_workspace_id if no visible workspaces - public_filter = f"public_workspace_id eq '{active_public_workspace_id}' and document_id eq '{document_id}'" + public_base_filter = f"public_workspace_id eq '{active_public_workspace_id}' and {doc_id_filter}" + + public_filter = f"{public_base_filter} and {tags_filter_clause}" if tags_filter_clause else public_base_filter public_results = search_client_public.search( search_text=query, @@ -207,37 +262,44 @@ def hybrid_search(query, user_id, document_id=None, top_n=12, doc_scope="all", a semantic_configuration_name="nexus-public-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "public_workspace_id", "version", "chunk_sequence", "upload_date", "document_classification", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=["id", "chunk_text", "chunk_id", "file_name", "public_workspace_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] ) else: + # Build user filter with optional tags + user_base_filter = ( + f"(user_id eq '{user_id}' or shared_user_ids/any(u: u eq '{user_id},approved')) " + if enable_file_sharing else + f"user_id eq '{user_id}' " + ) + user_filter = f"{user_base_filter} and {tags_filter_clause}" if tags_filter_clause else user_base_filter.strip() + user_results = search_client_user.search( search_text=query, vector_queries=[vector_query], - filter=( - f"(user_id eq '{user_id}' or shared_user_ids/any(u: u eq '{user_id},approved')) " - if enable_file_sharing else - f"user_id eq '{user_id}' " - ), + filter=user_filter, query_type="semantic", semantic_configuration_name="nexus-user-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "user_id", "version", "chunk_sequence", "upload_date", "document_classification", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=["id", "chunk_text", "chunk_id", "file_name", "user_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] ) - # Only search group index if active_group_id is provided - if active_group_id: + # Only search group index if active_group_ids is provided + if active_group_ids: + group_conditions = " or ".join([f"group_id eq '{gid}'" for gid in active_group_ids]) + shared_conditions = " or ".join([f"shared_group_ids/any(g: g eq '{gid},approved')" for gid in active_group_ids]) + group_base_filter = f"({group_conditions} or {shared_conditions})" + group_filter = f"{group_base_filter} and {tags_filter_clause}" if tags_filter_clause else group_base_filter + group_results = search_client_group.search( search_text=query, vector_queries=[vector_query], - filter=( - f"(group_id eq '{active_group_id}' or shared_group_ids/any(g: g eq '{active_group_id},approved'))" - ), + filter=group_filter, query_type="semantic", semantic_configuration_name="nexus-group-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "group_id", "version", "chunk_sequence", "upload_date", "document_classification", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=["id", "chunk_text", "chunk_id", "file_name", "group_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] ) else: group_results = [] @@ -249,10 +311,12 @@ def hybrid_search(query, user_id, document_id=None, top_n=12, doc_scope="all", a if visible_public_workspace_ids: # Use 'or' conditions instead of 'in' operator for OData compatibility workspace_conditions = " or ".join([f"public_workspace_id eq '{id}'" for id in visible_public_workspace_ids]) - public_filter = f"({workspace_conditions})" + public_base_filter = f"({workspace_conditions})" else: # Fallback to active_public_workspace_id if no visible workspaces - public_filter = f"public_workspace_id eq '{active_public_workspace_id}'" + public_base_filter = f"public_workspace_id eq '{active_public_workspace_id}'" + + public_filter = f"{public_base_filter} and {tags_filter_clause}" if tags_filter_clause else public_base_filter public_results = search_client_public.search( search_text=query, @@ -262,7 +326,7 @@ def hybrid_search(query, user_id, document_id=None, top_n=12, doc_scope="all", a semantic_configuration_name="nexus-public-index-semantic-configuration", query_caption="extractive", query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "public_workspace_id", "version", "chunk_sequence", "upload_date", "document_classification", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + select=["id", "chunk_text", "chunk_id", "file_name", "public_workspace_id", "version", "chunk_sequence", "upload_date", "document_classification", "document_tags", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] ) # Extract results from each index @@ -293,7 +357,7 @@ def hybrid_search(query, user_id, document_id=None, top_n=12, doc_scope="all", a ) elif doc_scope == "personal": - if document_id: + if doc_id_filter: user_results = search_client_user.search( search_text=query, vector_queries=[vector_query], @@ -303,7 +367,7 @@ def hybrid_search(query, user_id, document_id=None, top_n=12, doc_scope="all", a if enable_file_sharing else f"user_id eq '{user_id}' " ) + - f"and document_id eq '{document_id}'" + f"and {doc_id_filter}" ), query_type="semantic", semantic_configuration_name="nexus-user-index-semantic-configuration", @@ -330,12 +394,16 @@ def hybrid_search(query, user_id, document_id=None, top_n=12, doc_scope="all", a results = extract_search_results(user_results, top_n) elif doc_scope == "group": - if document_id: + if not active_group_ids: + results = [] + elif doc_id_filter: + group_conditions = " or ".join([f"group_id eq '{gid}'" for gid in active_group_ids]) + shared_conditions = " or ".join([f"shared_group_ids/any(g: g eq '{gid},approved')" for gid in active_group_ids]) group_results = search_client_group.search( search_text=query, vector_queries=[vector_query], filter=( - f"(group_id eq '{active_group_id}' or shared_group_ids/any(g: g eq '{active_group_id},approved')) and document_id eq '{document_id}'" + f"({group_conditions} or {shared_conditions}) and {doc_id_filter}" ), query_type="semantic", semantic_configuration_name="nexus-group-index-semantic-configuration", @@ -345,11 +413,13 @@ def hybrid_search(query, user_id, document_id=None, top_n=12, doc_scope="all", a ) results = extract_search_results(group_results, top_n) else: + group_conditions = " or ".join([f"group_id eq '{gid}'" for gid in active_group_ids]) + shared_conditions = " or ".join([f"shared_group_ids/any(g: g eq '{gid},approved')" for gid in active_group_ids]) group_results = search_client_group.search( search_text=query, vector_queries=[vector_query], filter=( - f"(group_id eq '{active_group_id}' or shared_group_ids/any(g: g eq '{active_group_id},approved'))" + f"({group_conditions} or {shared_conditions})" ), query_type="semantic", semantic_configuration_name="nexus-group-index-semantic-configuration", @@ -360,18 +430,18 @@ def hybrid_search(query, user_id, document_id=None, top_n=12, doc_scope="all", a results = extract_search_results(group_results, top_n) elif doc_scope == "public": - if document_id: + if doc_id_filter: # Get visible public workspace IDs from user settings visible_public_workspace_ids = get_user_visible_public_workspace_ids_from_settings(user_id) - + # Create filter for visible public workspaces if visible_public_workspace_ids: # Use 'or' conditions instead of 'in' operator for OData compatibility workspace_conditions = " or ".join([f"public_workspace_id eq '{id}'" for id in visible_public_workspace_ids]) - public_filter = f"({workspace_conditions}) and document_id eq '{document_id}'" + public_filter = f"({workspace_conditions}) and {doc_id_filter}" else: # Fallback to active_public_workspace_id if no visible workspaces - public_filter = f"public_workspace_id eq '{active_public_workspace_id}' and document_id eq '{document_id}'" + public_filter = f"public_workspace_id eq '{active_public_workspace_id}' and {doc_id_filter}" public_results = search_client_public.search( search_text=query, @@ -473,12 +543,12 @@ def hybrid_search(query, user_id, document_id=None, top_n=12, doc_scope="all", a # Cache the results before returning (pass scope parameters for correct partition key) cache_search_results( - cache_key, - results, - user_id, - doc_scope, - active_group_id, - active_public_workspace_id + cache_key, + results, + user_id, + doc_scope, + active_group_ids=active_group_ids, + active_public_workspace_id=active_public_workspace_id ) debug_print( @@ -506,6 +576,7 @@ def extract_search_results(paged_results, top_n): "chunk_sequence": r["chunk_sequence"], "upload_date": r["upload_date"], "document_classification": r["document_classification"], + "document_tags": r.get("document_tags", []), "page_number": r["page_number"], "author": r["author"], "chunk_keywords": r["chunk_keywords"], diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 5fa59f12..d39e51ca 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -133,6 +133,7 @@ def get_settings(use_cosmos=False): 'enable_public_workspaces': False, 'require_member_of_create_public_workspace': False, 'enable_file_sharing': False, + 'enforce_workspace_scope_lock': True, # Multimedia 'enable_video_file_support': False, diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index 10ea1abe..e452fed4 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -21,7 +21,7 @@ from functions_search import * from functions_settings import * from functions_agents import get_agent_id_by_name -from functions_group import find_group_by_id +from functions_group import find_group_by_id, get_user_role_in_group from functions_chat import * from functions_conversation_metadata import collect_conversation_metadata, update_conversation_with_metadata from functions_debug import debug_print @@ -60,8 +60,13 @@ def chat_api(): hybrid_search_enabled = data.get('hybrid_search') web_search_enabled = data.get('web_search_enabled') selected_document_id = data.get('selected_document_id') + selected_document_ids = data.get('selected_document_ids', []) + # Backwards compat: if no multi-select but single ID is set, wrap in list + if not selected_document_ids and selected_document_id: + selected_document_ids = [selected_document_id] image_gen_enabled = data.get('image_generation') document_scope = data.get('doc_scope') + tags_filter = data.get('tags', []) # Extract tags filter reload_messages_required = False def parse_json_string(candidate: str) -> Any: @@ -123,6 +128,19 @@ def result_requires_message_reload(result: Any) -> bool: return dict_requires_reload(result) return False active_group_id = data.get('active_group_id') + active_group_ids = data.get('active_group_ids', []) + # Backwards compat: if new list not provided, wrap single ID + if not active_group_ids and active_group_id: + active_group_ids = [active_group_id] + # Permission validation: only keep groups user is a member of + validated_group_ids = [] + for gid in active_group_ids: + g_doc = find_group_by_id(gid) + if g_doc and get_user_role_in_group(g_doc, user_id): + validated_group_ids.append(gid) + active_group_ids = validated_group_ids + # Keep single ID for backwards compat in metadata/context + active_group_id = active_group_ids[0] if active_group_ids else data.get('active_group_id') active_public_workspace_id = data.get('active_public_workspace_id') # Extract active public workspace ID frontend_gpt_model = data.get('model_deployment') top_n_results = data.get('top_n') # Extract top_n parameter from request @@ -846,11 +864,11 @@ def result_requires_message_reload(result: Any) -> bool: "doc_scope": document_scope, } - # Add active_group_id when: + # Add active_group_ids when: # 1. Document scope is 'group' or chat_type is 'group', OR # 2. Document scope is 'all' and groups are enabled (so group search can be included) - if active_group_id and (document_scope == 'group' or document_scope == 'all' or chat_type == 'group'): - search_args["active_group_id"] = active_group_id + if active_group_ids and (document_scope == 'group' or document_scope == 'all' or chat_type == 'group'): + search_args["active_group_ids"] = active_group_ids # Add active_public_workspace_id when: # 1. Document scope is 'public' or @@ -858,9 +876,15 @@ def result_requires_message_reload(result: Any) -> bool: if active_public_workspace_id and (document_scope == 'public' or document_scope == 'all'): search_args["active_public_workspace_id"] = active_public_workspace_id - if selected_document_id: + if selected_document_ids: + search_args["document_ids"] = selected_document_ids + elif selected_document_id: search_args["document_id"] = selected_document_id + # Add tags filter if provided + if tags_filter and isinstance(tags_filter, list) and len(tags_filter) > 0: + search_args["tags_filter"] = tags_filter + # Log if a non-default top_n value is being used if top_n != default_top_n: debug_print(f"Using custom top_n value: {top_n} (requested: {top_n_results})") @@ -2707,9 +2731,27 @@ def generate(): hybrid_search_enabled = data.get('hybrid_search') web_search_enabled = data.get('web_search_enabled') selected_document_id = data.get('selected_document_id') + selected_document_ids = data.get('selected_document_ids', []) + # Backwards compat: if no multi-select but single ID is set, wrap in list + if not selected_document_ids and selected_document_id: + selected_document_ids = [selected_document_id] image_gen_enabled = data.get('image_generation') document_scope = data.get('doc_scope') + tags_filter = data.get('tags', []) # Extract tags filter active_group_id = data.get('active_group_id') + active_group_ids = data.get('active_group_ids', []) + # Backwards compat: if new list not provided, wrap single ID + if not active_group_ids and active_group_id: + active_group_ids = [active_group_id] + # Permission validation: only keep groups user is a member of + validated_group_ids = [] + for gid in active_group_ids: + g_doc = find_group_by_id(gid) + if g_doc and get_user_role_in_group(g_doc, user_id): + validated_group_ids.append(gid) + active_group_ids = validated_group_ids + # Keep single ID for backwards compat in metadata/context + active_group_id = active_group_ids[0] if active_group_ids else data.get('active_group_id') active_public_workspace_id = data.get('active_public_workspace_id') # Extract active public workspace ID frontend_gpt_model = data.get('model_deployment') classifications_to_send = data.get('classifications') @@ -3081,8 +3123,8 @@ def generate(): "doc_scope": document_scope, } - if active_group_id and (document_scope == 'group' or document_scope == 'all' or chat_type == 'group'): - search_args['active_group_id'] = active_group_id + if active_group_ids and (document_scope == 'group' or document_scope == 'all' or chat_type == 'group'): + search_args['active_group_ids'] = active_group_ids # Add active_public_workspace_id when: # 1. Document scope is 'public' or @@ -3090,9 +3132,15 @@ def generate(): if active_public_workspace_id and (document_scope == 'public' or document_scope == 'all'): search_args['active_public_workspace_id'] = active_public_workspace_id - if selected_document_id: + if selected_document_ids: + search_args['document_ids'] = selected_document_ids + elif selected_document_id: search_args['document_id'] = selected_document_id + # Add tags filter if provided + if tags_filter and isinstance(tags_filter, list) and len(tags_filter) > 0: + search_args['tags_filter'] = tags_filter + search_results = hybrid_search(**search_args) except Exception as e: debug_print(f"Error during hybrid search: {e}") diff --git a/application/single_app/route_backend_conversations.py b/application/single_app/route_backend_conversations.py index 179b7885..f267d729 100644 --- a/application/single_app/route_backend_conversations.py +++ b/application/single_app/route_backend_conversations.py @@ -795,7 +795,10 @@ def get_conversation_metadata_api(conversation_id): "tags": conversation_item.get('tags', []), "strict": conversation_item.get('strict', False), "is_pinned": conversation_item.get('is_pinned', False), - "is_hidden": conversation_item.get('is_hidden', False) + "is_hidden": conversation_item.get('is_hidden', False), + "scope_locked": conversation_item.get('scope_locked'), + "locked_contexts": conversation_item.get('locked_contexts', []), + "chat_type": conversation_item.get('chat_type') }), 200 except CosmosResourceNotFoundError: @@ -803,7 +806,59 @@ def get_conversation_metadata_api(conversation_id): except Exception as e: print(f"Error retrieving conversation metadata: {e}") return jsonify({'error': 'Failed to retrieve conversation metadata'}), 500 - + + @app.route('/api/conversations//scope_lock', methods=['PATCH']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def patch_conversation_scope_lock(conversation_id): + """ + Toggle the scope lock on a conversation. + Unlock is reversible — locked_contexts are preserved for re-locking. + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json() + if data is None or 'scope_locked' not in data: + return jsonify({'error': 'Missing scope_locked field'}), 400 + + new_value = data['scope_locked'] + if new_value is not True and new_value is not False: + return jsonify({'error': 'scope_locked must be true or false'}), 400 + + # Enforce scope lock if admin setting is enabled + if new_value is False: + settings = get_settings() + if settings.get('enforce_workspace_scope_lock', True): + return jsonify({'error': 'Scope unlock is disabled by administrator'}), 403 + + try: + conversation_item = cosmos_conversations_container.read_item( + item=conversation_id, partition_key=conversation_id + ) + if conversation_item.get('user_id') != user_id: + return jsonify({'error': 'Forbidden'}), 403 + + conversation_item['scope_locked'] = new_value + # locked_contexts are PRESERVED regardless — needed for re-locking + + from datetime import datetime + conversation_item['last_updated'] = datetime.utcnow().isoformat() + cosmos_conversations_container.upsert_item(conversation_item) + + return jsonify({ + "success": True, + "scope_locked": new_value, + "locked_contexts": conversation_item.get('locked_contexts', []) + }), 200 + except CosmosResourceNotFoundError: + return jsonify({'error': 'Conversation not found'}), 404 + except Exception as e: + print(f"Error updating scope lock: {e}") + return jsonify({'error': 'Failed to update scope lock'}), 500 + @app.route('/api/conversations/classifications', methods=['GET']) @swagger_route(security=get_auth_security()) @login_required diff --git a/application/single_app/route_backend_documents.py b/application/single_app/route_backend_documents.py index 31619f69..568b3b7d 100644 --- a/application/single_app/route_backend_documents.py +++ b/application/single_app/route_backend_documents.py @@ -236,7 +236,7 @@ def api_user_upload_document(): ) except Exception as log_error: # Don't let activity logging errors interrupt upload flow - print(f"Activity logging error for document upload: {log_error}") + debug_print(f"Activity logging error for document upload: {log_error}") except Exception as e: upload_errors.append(f"Failed to queue processing for {original_filename}: {e}") @@ -282,10 +282,19 @@ def api_get_user_documents(): author_filter = request.args.get('author', default=None, type=str) keywords_filter = request.args.get('keywords', default=None, type=str) abstract_filter = request.args.get('abstract', default=None, type=str) + tags_filter = request.args.get('tags', default=None, type=str) # Comma-separated tags + sort_by = request.args.get('sort_by', default='_ts', type=str) + sort_order = request.args.get('sort_order', default='desc', type=str) # Ensure page and page_size are positive if page < 1: page = 1 if page_size < 1: page_size = 10 + + # Validate sort parameters + allowed_sort_fields = {'_ts', 'file_name', 'title'} + if sort_by not in allowed_sort_fields: + sort_by = '_ts' + sort_order = sort_order.upper() if sort_order.lower() in ('asc', 'desc') else 'DESC' # Limit page size to prevent abuse? (Optional) # page_size = min(page_size, 100) @@ -317,8 +326,8 @@ def api_get_user_documents(): if classification_filter: param_name = f"@classification_{param_count}" if classification_filter.lower() == 'none': - # Filter for documents where classification is null, undefined, or empty string - query_conditions.append(f"(NOT IS_DEFINED(c.document_classification) OR c.document_classification = null OR c.document_classification = '')") + # Filter for documents where classification is null, undefined, empty string, or the literal "None" + query_conditions.append(f"(NOT IS_DEFINED(c.document_classification) OR c.document_classification = null OR c.document_classification = '' OR LOWER(c.document_classification) = 'none')") # No parameter needed for this specific condition else: query_conditions.append(f"c.document_classification = {param_name}") @@ -350,6 +359,19 @@ def api_get_user_documents(): query_conditions.append(f"CONTAINS(LOWER(c.abstract ?? ''), LOWER({param_name}))") query_params.append({"name": param_name, "value": abstract_filter}) param_count += 1 + + # Tags Filter (comma-separated, AND logic - document must have all specified tags) + if tags_filter: + from functions_documents import normalize_tag + tags_list = [normalize_tag(t.strip()) for t in tags_filter.split(',') if t.strip()] + + if tags_list: + # Each tag must exist in the document's tags array + for idx, tag in enumerate(tags_list): + param_name = f"@tag_{param_count}_{idx}" + query_conditions.append(f"ARRAY_CONTAINS(c.tags, {param_name})") + query_params.append({"name": param_name, "value": tag}) + param_count += len(tags_list) # Combine conditions into the WHERE clause where_clause = " AND ".join(query_conditions) @@ -367,19 +389,18 @@ def api_get_user_documents(): total_count = count_items[0] if count_items else 0 except Exception as e: - print(f"Error executing count query: {e}") # Log the error + debug_print(f"Error executing count query: {e}") # Log the error return jsonify({"error": f"Error counting documents: {str(e)}"}), 500 # --- 4) Second query: fetch the page of data based on filters --- try: offset = (page - 1) * page_size - # Note: ORDER BY c._ts DESC to show newest first data_query_str = f""" SELECT * FROM c WHERE {where_clause} - ORDER BY c._ts DESC + ORDER BY c.{sort_by} {sort_order} OFFSET {offset} LIMIT {page_size} """ # debug_print(f"Data Query: {data_query_str}") # Optional Debugging @@ -404,7 +425,7 @@ def api_get_user_documents(): break doc["shared_approval_status"] = status or "none" except Exception as e: - print(f"Error executing data query: {e}") # Log the error + debug_print(f"Error executing data query: {e}") # Log the error return jsonify({"error": f"Error fetching documents: {str(e)}"}), 500 @@ -425,7 +446,7 @@ def api_get_user_documents(): ) legacy_count = legacy_docs[0] if legacy_docs else 0 except Exception as e: - print(f"Error executing legacy query: {e}") + debug_print(f"Error executing legacy query: {e}") # --- 5) Return results --- return jsonify({ @@ -530,27 +551,53 @@ def api_patch_user_document(document_id): authors=authors_list ) updated_fields['authors'] = authors_list + + # Handle tags with validation and chunk propagation + if 'tags' in data: + from functions_documents import validate_tags, propagate_tags_to_chunks, get_or_create_tag_definition + + # Validate and normalize tags + tags_input = data['tags'] if isinstance(data['tags'], list) else [] + is_valid, error_msg, normalized_tags = validate_tags(tags_input) + + if not is_valid: + return jsonify({'error': error_msg}), 400 + + # Ensure tag definitions exist for new tags + for tag in normalized_tags: + get_or_create_tag_definition(user_id, tag, workspace_type='personal') + + # Update document with normalized tags + update_document( + document_id=document_id, + user_id=user_id, + tags=normalized_tags + ) + updated_fields['tags'] = normalized_tags + + # Propagate tags to all chunks immediately + try: + propagate_tags_to_chunks(document_id, normalized_tags, user_id) + except Exception as propagate_error: + debug_print(f"Warning: Failed to propagate tags to chunks: {propagate_error}") + # Continue - document tags are updated, chunk sync will be retried later # Save updates back to Cosmos try: # Log the metadata update transaction if any fields were updated if updated_fields: - # Get document details for logging - handle tuple return + # Get document details for logging doc_response = get_document(user_id, document_id) doc = None - - # Handle tuple return (response, status_code) if isinstance(doc_response, tuple): resp, status_code = doc_response - if hasattr(resp, "get_json"): + if status_code == 200 and hasattr(resp, 'get_json'): doc = resp.get_json() - else: - doc = resp - elif hasattr(doc_response, "get_json"): + elif hasattr(doc_response, 'get_json'): doc = doc_response.get_json() else: doc = doc_response - + if doc and isinstance(doc, dict): log_document_metadata_update_transaction( user_id=user_id, @@ -701,7 +748,528 @@ def api_upgrade_legacy_user_documents(): "message": f"Upgraded {count} document(s) to the new format." }), 200 - # Document Sharing API Endpoints + # ============= TAG MANAGEMENT API ENDPOINTS ============= + + @app.route('/api/documents/tags', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required("enable_user_workspace") + def api_get_workspace_tags(): + """Get all tags used in personal workspace with document counts""" + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + from functions_documents import get_workspace_tags + + try: + tags = get_workspace_tags(user_id) + return jsonify({'tags': tags}), 200 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @app.route('/api/documents/tags', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required("enable_user_workspace") + def api_create_tag(): + """ + Create a new tag in the workspace. + + Request body: + { + "tag_name": "new-tag", + "color": "#3b82f6" // optional + } + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json() + tag_name = data.get('tag_name') + color = data.get('color', '#0d6efd') # Default blue color + + if not tag_name: + return jsonify({'error': 'tag_name is required'}), 400 + + from functions_documents import normalize_tag, validate_tags + from functions_settings import get_user_settings, update_user_settings + from datetime import datetime, timezone + + try: + # Validate and normalize tag name + is_valid, error_msg, normalized_tags = validate_tags([tag_name]) + if not is_valid: + return jsonify({'error': error_msg}), 400 + + normalized_tag = normalized_tags[0] + + # Get existing tag definitions from settings + user_settings = get_user_settings(user_id) + settings_dict = user_settings.get('settings', {}) + tag_defs = settings_dict.get('tag_definitions', {}) + personal_tags = tag_defs.get('personal', {}) + + debug_print(f"[CREATE TAG] Retrieved user_settings keys: {list(user_settings.keys())}") + debug_print(f"[CREATE TAG] Retrieved settings_dict keys: {list(settings_dict.keys())}") + debug_print(f"[CREATE TAG] Retrieved tag_defs keys: {list(tag_defs.keys())}") + debug_print(f"[CREATE TAG] Retrieved personal_tags: {personal_tags}") + debug_print(f"[CREATE TAG] Existing personal tag count: {len(personal_tags)}") + + # Check if tag already exists + if normalized_tag in personal_tags: + return jsonify({'error': 'Tag already exists'}), 409 + + # Add new tag to existing tags (don't replace) + personal_tags[normalized_tag] = { + 'color': color, + 'created_at': datetime.now(timezone.utc).isoformat() + } + + debug_print(f"[CREATE TAG] After adding new tag, personal_tags: {personal_tags}") + debug_print(f"[CREATE TAG] New personal tag count: {len(personal_tags)}") + + tag_defs['personal'] = personal_tags + + debug_print(f"[CREATE TAG] Final tag_defs to save: {tag_defs}") + debug_print(f"[CREATE TAG] Calling update_user_settings with: {{'tag_definitions': tag_defs}}") + + # Only update the tag_definitions field, not the entire settings object + update_user_settings(user_id, {'tag_definitions': tag_defs}) + + return jsonify({ + 'message': f'Tag "{normalized_tag}" created successfully', + 'tag': { + 'name': normalized_tag, + 'color': color + } + }), 201 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @app.route('/api/documents/bulk-tag', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required("enable_user_workspace") + def api_bulk_tag_documents(): + """ + Apply tag operations to multiple documents. + + Request body: + { + "document_ids": ["doc1", "doc2", ...], + "action": "add_tags" | "remove_tags" | "set_tags", + "tags": ["tag1", "tag2", ...] + } + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + data = request.get_json() + document_ids = data.get('document_ids', []) + action = data.get('action') + tags_input = data.get('tags', []) + + debug_print(f"[Bulk Tag] Received request: user_id={user_id}, action={action}, tags={tags_input}, doc_count={len(document_ids)}") + + if not document_ids or not isinstance(document_ids, list): + return jsonify({'error': 'document_ids must be a non-empty array'}), 400 + + if action not in ['add_tags', 'remove_tags', 'set_tags']: + return jsonify({'error': 'action must be add_tags, remove_tags, or set_tags'}), 400 + + from functions_documents import ( + validate_tags, get_document, update_document, + propagate_tags_to_chunks, get_or_create_tag_definition + ) + + # Validate and normalize tags + is_valid, error_msg, normalized_tags = validate_tags(tags_input) + if not is_valid: + return jsonify({'error': error_msg}), 400 + + # Ensure tag definitions exist for new tags + for tag in normalized_tags: + get_or_create_tag_definition(user_id, tag, workspace_type='personal') + + results = { + 'success': [], + 'errors': [] + } + + try: + for doc_id in document_ids: + try: + # Query Cosmos DB directly (get_document returns Flask response tuple) + query = """ + SELECT TOP 1 * + FROM c + WHERE c.id = @document_id + AND ( + c.user_id = @user_id + OR ARRAY_CONTAINS(c.shared_user_ids, @user_id) + OR EXISTS(SELECT VALUE s FROM s IN c.shared_user_ids WHERE STARTSWITH(s, @user_id_prefix)) + ) + ORDER BY c.version DESC + """ + parameters = [ + {"name": "@document_id", "value": doc_id}, + {"name": "@user_id", "value": user_id}, + {"name": "@user_id_prefix", "value": f"{user_id},"} + ] + + document_results = list( + cosmos_user_documents_container.query_items( + query=query, + parameters=parameters, + enable_cross_partition_query=True + ) + ) + + if not document_results: + error_msg = 'Document not found or access denied' + debug_print(f"[Bulk Tag] Error for doc {doc_id}: {error_msg}") + results['errors'].append({ + 'document_id': doc_id, + 'error': error_msg + }) + continue + + doc = document_results[0] + debug_print(f"[Bulk Tag] Processing doc {doc_id}, current tags: {doc.get('tags', [])}") + + current_tags = doc.get('tags', []) + new_tags = [] + + if action == 'add_tags': + # Add new tags to existing (avoid duplicates) + new_tags = list(set(current_tags + normalized_tags)) + elif action == 'remove_tags': + # Remove specified tags + new_tags = [t for t in current_tags if t not in normalized_tags] + elif action == 'set_tags': + # Replace all tags + new_tags = normalized_tags + + debug_print(f"[Bulk Tag] New tags for doc {doc_id}: {new_tags}") + + # Update document + update_document( + document_id=doc_id, + user_id=user_id, + tags=new_tags + ) + + # Propagate to chunks + try: + propagate_tags_to_chunks(doc_id, new_tags, user_id) + except Exception as propagate_error: + debug_print(f"Warning: Failed to propagate tags for doc {doc_id}: {propagate_error}") + + results['success'].append({ + 'document_id': doc_id, + 'tags': new_tags + }) + debug_print(f"[Bulk Tag] Successfully updated doc {doc_id}") + + except Exception as doc_error: + error_msg = str(doc_error) + debug_print(f"[Bulk Tag] Exception for doc {doc_id}: {error_msg}") + import traceback + traceback.print_exc() + results['errors'].append({ + 'document_id': doc_id, + 'error': error_msg + }) + + # Invalidate cache + if results['success']: + invalidate_personal_search_cache(user_id) + + status_code = 200 if not results['errors'] else 207 # Multi-Status + return jsonify(results), status_code + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @app.route('/api/documents/tags/', methods=['PATCH']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required("enable_user_workspace") + def api_update_tag(tag_name): + """ + Update a tag (rename or change color). + + Request body: + { + "new_name": "new-tag-name", // optional + "color": "#3b82f6" // optional + } + """ + debug_print(f"[UPDATE TAG] Starting update for tag: {tag_name}") + + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + debug_print(f"[UPDATE TAG] User ID: {user_id}") + + data = request.get_json() + new_name = data.get('new_name') + new_color = data.get('color') + + debug_print(f"[UPDATE TAG] Request data - new_name: {new_name}, new_color: {new_color}") + + from functions_documents import ( + normalize_tag, validate_tags, get_documents, + update_document, propagate_tags_to_chunks + ) + from functions_settings import get_user_settings, update_user_settings + from utils_cache import invalidate_personal_search_cache + + try: + debug_print(f"[UPDATE TAG] Normalizing tag name...") + normalized_old_tag = normalize_tag(tag_name) + debug_print(f"[UPDATE TAG] Normalized old tag: {normalized_old_tag}") + + # Handle rename + if new_name: + debug_print(f"[UPDATE TAG] Handling rename operation...") + # Validate new name + is_valid, error_msg, normalized_new = validate_tags([new_name]) + if not is_valid: + debug_print(f"[UPDATE TAG] Validation failed: {error_msg}") + return jsonify({'error': error_msg}), 400 + + normalized_new_tag = normalized_new[0] + debug_print(f"[UPDATE TAG] Normalized new tag: {normalized_new_tag}") + + # Query documents directly from Cosmos DB + debug_print(f"[UPDATE TAG] Querying documents from database...") + + query = """ + SELECT * + FROM c + WHERE c.user_id = @user_id OR ARRAY_CONTAINS(c.shared_user_ids, @user_id) + """ + parameters = [{"name": "@user_id", "value": user_id}] + + documents = list( + cosmos_user_documents_container.query_items( + query=query, + parameters=parameters, + enable_cross_partition_query=True + ) + ) + + debug_print(f"[UPDATE TAG] Found {len(documents)} total documents") + + # Get latest version of each document + latest_documents = {} + for doc in documents: + file_name = doc['file_name'] + if file_name not in latest_documents or doc['version'] > latest_documents[file_name]['version']: + latest_documents[file_name] = doc + + all_docs = list(latest_documents.values()) + debug_print(f"[UPDATE TAG] Processing {len(all_docs)} unique documents") + + updated_count = 0 + + for doc in all_docs: + if normalized_old_tag in doc.get('tags', []): + # Replace old tag with new tag + current_tags = doc['tags'] + new_tags = [normalized_new_tag if t == normalized_old_tag else t for t in current_tags] + + update_document( + document_id=doc['id'], + user_id=user_id, + tags=new_tags + ) + + # Propagate to chunks + try: + propagate_tags_to_chunks(doc['id'], new_tags, user_id) + except Exception as propagate_error: + debug_print(f"Warning: Failed to propagate tags for doc {doc['id']}: {propagate_error}") + + updated_count += 1 + + debug_print(f"[UPDATE TAG] Updated {updated_count} documents") + + # Update tag definition + debug_print(f"[UPDATE TAG] Updating tag definition in settings...") + user_settings = get_user_settings(user_id) + settings_dict = user_settings.get('settings', {}) + tag_defs = settings_dict.get('tag_definitions', {}) + personal_tags = tag_defs.get('personal', {}) + + debug_print(f"[UPDATE TAG] Current personal_tags keys: {list(personal_tags.keys())}") + + if normalized_old_tag in personal_tags: + old_def = personal_tags.pop(normalized_old_tag) + personal_tags[normalized_new_tag] = old_def + debug_print(f"[UPDATE TAG] Renamed tag in definitions") + else: + debug_print(f"[UPDATE TAG] WARNING: Old tag not found in personal_tags!") + + tag_defs['personal'] = personal_tags + debug_print(f"[UPDATE TAG] Calling update_user_settings...") + update_user_settings(user_id, {'tag_definitions': tag_defs}) + + # Invalidate cache + debug_print(f"[UPDATE TAG] Invalidating search cache...") + invalidate_personal_search_cache(user_id) + + debug_print(f"[UPDATE TAG] Rename completed successfully") + return jsonify({ + 'message': f'Tag renamed from "{normalized_old_tag}" to "{normalized_new_tag}"', + 'documents_updated': updated_count + }), 200 + + # Handle color change only + if new_color: + debug_print(f"[UPDATE TAG] Handling color change operation...") + user_settings = get_user_settings(user_id) + settings_dict = user_settings.get('settings', {}) + tag_defs = settings_dict.get('tag_definitions', {}) + personal_tags = tag_defs.get('personal', {}) + + debug_print(f"[UPDATE TAG] Current personal_tags keys: {list(personal_tags.keys())}") + debug_print(f"[UPDATE TAG] Looking for tag: {normalized_old_tag}") + + if normalized_old_tag in personal_tags: + debug_print(f"[UPDATE TAG] Found tag, updating color to: {new_color}") + personal_tags[normalized_old_tag]['color'] = new_color + else: + debug_print(f"[UPDATE TAG] Tag not found, creating new entry with color: {new_color}") + from datetime import datetime, timezone + personal_tags[normalized_old_tag] = { + 'color': new_color, + 'created_at': datetime.now(timezone.utc).isoformat() + } + + tag_defs['personal'] = personal_tags + debug_print(f"[UPDATE TAG] Final tag_defs to save: {tag_defs}") + debug_print(f"[UPDATE TAG] Calling update_user_settings...") + update_user_settings(user_id, {'tag_definitions': tag_defs}) + + debug_print(f"[UPDATE TAG] Color change completed successfully") + return jsonify({ + 'message': f'Tag color updated for "{normalized_old_tag}"' + }), 200 + + debug_print(f"[UPDATE TAG] No updates specified!") + return jsonify({'error': 'No updates specified'}), 400 + + except Exception as e: + debug_print(f"[UPDATE TAG] ERROR: {str(e)}") + import traceback + traceback.print_exc() + return jsonify({'error': str(e)}), 500 + + @app.route('/api/documents/tags/', methods=['DELETE']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required("enable_user_workspace") + def api_delete_tag(tag_name): + """Delete a tag from all documents in workspace""" + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + from functions_documents import ( + normalize_tag, update_document, + propagate_tags_to_chunks + ) + from functions_settings import get_user_settings, update_user_settings + + try: + normalized_tag = normalize_tag(tag_name) + + # Query documents directly from Cosmos DB + debug_print(f"[DELETE TAG] Querying documents from database...") + + query = """ + SELECT * + FROM c + WHERE c.user_id = @user_id OR ARRAY_CONTAINS(c.shared_user_ids, @user_id) + """ + parameters = [{"name": "@user_id", "value": user_id}] + + documents = list( + cosmos_user_documents_container.query_items( + query=query, + parameters=parameters, + enable_cross_partition_query=True + ) + ) + + debug_print(f"[DELETE TAG] Found {len(documents)} total documents") + + # Get latest version of each document + latest_documents = {} + for doc in documents: + file_name = doc['file_name'] + if file_name not in latest_documents or doc['version'] > latest_documents[file_name]['version']: + latest_documents[file_name] = doc + + all_docs = list(latest_documents.values()) + debug_print(f"[DELETE TAG] Processing {len(all_docs)} unique documents") + + updated_count = 0 + + for doc in all_docs: + if normalized_tag in doc.get('tags', []): + # Remove tag + new_tags = [t for t in doc['tags'] if t != normalized_tag] + + update_document( + document_id=doc['id'], + user_id=user_id, + tags=new_tags + ) + + # Propagate to chunks + try: + propagate_tags_to_chunks(doc['id'], new_tags, user_id) + except Exception as propagate_error: + debug_print(f"Warning: Failed to propagate tags for doc {doc['id']}: {propagate_error}") + + updated_count += 1 + + # Remove tag definition + user_settings = get_user_settings(user_id) + settings_dict = user_settings.get('settings', {}) + tag_defs = settings_dict.get('tag_definitions', {}) + personal_tags = tag_defs.get('personal', {}) + + if normalized_tag in personal_tags: + personal_tags.pop(normalized_tag) + tag_defs['personal'] = personal_tags + update_user_settings(user_id, {'tag_definitions': tag_defs}) + + # Invalidate cache + if updated_count > 0: + invalidate_personal_search_cache(user_id) + + return jsonify({ + 'message': f'Tag "{normalized_tag}" deleted from {updated_count} document(s)' + }), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + # ============= DOCUMENT SHARING API ENDPOINTS ============= @app.route('/api/documents//share', methods=['POST']) @swagger_route(security=get_auth_security()) @login_required @@ -830,7 +1398,7 @@ def api_get_shared_users(document_id): 'email': '' }) except Exception as e: - print(f"Error fetching user details for {oid}: {e}") + debug_print(f"Error fetching user details for {oid}: {e}") shared_users.append({ 'id': oid, 'approval_status': approval_status, @@ -892,7 +1460,7 @@ def api_remove_self_from_document(document_id): return jsonify({'error': 'Failed to remove from shared document'}), 500 except Exception as e: - print(f"[ERROR] /api/documents/{document_id}/remove-self: {e}", flush=True) + debug_print(f"[ERROR] /api/documents/{document_id}/remove-self: {e}", flush=True) return jsonify({'error': f'Error removing from shared document: {str(e)}'}), 500 @app.route('/api/documents//approve-share', methods=['POST']) @@ -944,9 +1512,9 @@ def api_approve_shared_document(document_id): shared_user_ids=new_shared_user_ids ) except Exception as chunk_e: - print(f"Warning: Failed to update chunk {chunk_id}: {chunk_e}") + debug_print(f"Warning: Failed to update chunk {chunk_id}: {chunk_e}") except Exception as e: - print(f"Warning: Failed to update chunks for document {document_id}: {e}") + debug_print(f"Warning: Failed to update chunks for document {document_id}: {e}") # Invalidate cache for user who approved (their search results changed) if updated: diff --git a/application/single_app/route_backend_group_documents.py b/application/single_app/route_backend_group_documents.py index 68a1c0fa..0d4fa6eb 100644 --- a/application/single_app/route_backend_group_documents.py +++ b/application/single_app/route_backend_group_documents.py @@ -149,26 +149,49 @@ def api_upload_group_document(): @enabled_required("enable_group_workspaces") def api_get_group_documents(): """ - Return a paginated, filtered list of documents for the user's *active* group. - Mirrors logic of api_get_user_documents. + Return a paginated, filtered list of documents for the user's groups. + Accepts optional `group_ids` query param (comma-separated) to load from + multiple groups at once. Falls back to single active group from user settings. + Permission: user must be a member of each group (non-members silently excluded). """ user_id = get_current_user_id() if not user_id: return jsonify({'error': 'User not authenticated'}), 401 - user_settings = get_user_settings(user_id) - active_group_id = user_settings["settings"].get("activeGroupOid") - - if not active_group_id: - return jsonify({'error': 'No active group selected'}), 400 - - group_doc = find_group_by_id(group_id=active_group_id) - if not group_doc: - return jsonify({'error': 'Active group not found'}), 404 - - role = get_user_role_in_group(group_doc, user_id) - if not role: - return jsonify({'error': 'You are not a member of the active group'}), 403 + group_ids_param = request.args.get('group_ids', '') + + if group_ids_param: + # Multi-group mode: validate each group + requested_ids = [gid.strip() for gid in group_ids_param.split(',') if gid.strip()] + validated_group_ids = [] + for gid in requested_ids: + group_doc = find_group_by_id(gid) + if not group_doc: + continue + role = get_user_role_in_group(group_doc, user_id) + if not role: + continue + validated_group_ids.append(gid) + + if not validated_group_ids: + return jsonify({'documents': [], 'page': 1, 'page_size': 10, 'total_count': 0}), 200 + else: + # Fallback: single active group from user settings + user_settings = get_user_settings(user_id) + active_group_id = user_settings["settings"].get("activeGroupOid") + + if not active_group_id: + return jsonify({'error': 'No active group selected'}), 400 + + group_doc = find_group_by_id(group_id=active_group_id) + if not group_doc: + return jsonify({'error': 'Active group not found'}), 404 + + role = get_user_role_in_group(group_doc, user_id) + if not role: + return jsonify({'error': 'You are not a member of the active group'}), 403 + + validated_group_ids = [active_group_id] # --- 1) Read pagination and filter parameters --- page = request.args.get('page', default=1, type=int) @@ -178,14 +201,35 @@ def api_get_group_documents(): author_filter = request.args.get('author', default=None, type=str) keywords_filter = request.args.get('keywords', default=None, type=str) abstract_filter = request.args.get('abstract', default=None, type=str) + tags_filter = request.args.get('tags', default=None, type=str) + sort_by = request.args.get('sort_by', default='_ts', type=str) + sort_order = request.args.get('sort_order', default='desc', type=str) if page < 1: page = 1 if page_size < 1: page_size = 10 + allowed_sort_fields = {'_ts', 'file_name', 'title'} + if sort_by not in allowed_sort_fields: + sort_by = '_ts' + sort_order = sort_order.upper() if sort_order.lower() in ('asc', 'desc') else 'DESC' + # --- 2) Build dynamic WHERE clause and parameters --- - # Include documents owned by group OR shared with group via shared_group_ids - query_conditions = ["(c.group_id = @group_id OR ARRAY_CONTAINS(c.shared_group_ids, @group_id))"] - query_params = [{"name": "@group_id", "value": active_group_id}] + # Include documents owned by any validated group OR shared with any validated group + if len(validated_group_ids) == 1: + group_condition = "(c.group_id = @group_id_0 OR ARRAY_CONTAINS(c.shared_group_ids, @group_id_0))" + query_params = [{"name": "@group_id_0", "value": validated_group_ids[0]}] + else: + own_parts = [] + shared_parts = [] + query_params = [] + for i, gid in enumerate(validated_group_ids): + param_name = f"@group_id_{i}" + own_parts.append(f"c.group_id = {param_name}") + shared_parts.append(f"ARRAY_CONTAINS(c.shared_group_ids, {param_name})") + query_params.append({"name": param_name, "value": gid}) + group_condition = f"(({' OR '.join(own_parts)}) OR ({' OR '.join(shared_parts)}))" + + query_conditions = [group_condition] param_count = 0 if search_term: @@ -221,6 +265,16 @@ def api_get_group_documents(): query_params.append({"name": param_name, "value": abstract_filter}) param_count += 1 + if tags_filter: + from functions_documents import normalize_tag + tags_list = [normalize_tag(t.strip()) for t in tags_filter.split(',') if t.strip()] + if tags_list: + for idx, tag in enumerate(tags_list): + param_name = f"@tag_{param_count}_{idx}" + query_conditions.append(f"ARRAY_CONTAINS(c.tags, {param_name})") + query_params.append({"name": param_name, "value": tag}) + param_count += len(tags_list) + where_clause = " AND ".join(query_conditions) # --- 3) Get total count --- @@ -243,7 +297,7 @@ def api_get_group_documents(): SELECT * FROM c WHERE {where_clause} - ORDER BY c._ts DESC + ORDER BY c.{sort_by} {sort_order} OFFSET {offset} LIMIT {page_size} """ docs = list(cosmos_group_documents_container.query_items( @@ -257,21 +311,40 @@ def api_get_group_documents(): # --- new: do we have any legacy documents? --- + legacy_count = 0 try: - legacy_q = """ - SELECT VALUE COUNT(1) - FROM c - WHERE c.group_id = @group_id - AND NOT IS_DEFINED(c.percentage_complete) - """ - legacy_docs = list( - cosmos_group_documents_container.query_items( - query=legacy_q, - parameters=[{"name":"@group_id","value":active_group_id}], - enable_cross_partition_query=True + if len(validated_group_ids) == 1: + legacy_q = """ + SELECT VALUE COUNT(1) + FROM c + WHERE c.group_id = @group_id + AND NOT IS_DEFINED(c.percentage_complete) + """ + legacy_docs = list( + cosmos_group_documents_container.query_items( + query=legacy_q, + parameters=[{"name":"@group_id","value":validated_group_ids[0]}], + enable_cross_partition_query=True + ) ) - ) - legacy_count = legacy_docs[0] if legacy_docs else 0 + legacy_count = legacy_docs[0] if legacy_docs else 0 + else: + # For multi-group, check each group + for gid in validated_group_ids: + legacy_q = """ + SELECT VALUE COUNT(1) + FROM c + WHERE c.group_id = @group_id + AND NOT IS_DEFINED(c.percentage_complete) + """ + legacy_docs = list( + cosmos_group_documents_container.query_items( + query=legacy_q, + parameters=[{"name":"@group_id","value":gid}], + enable_cross_partition_query=True + ) + ) + legacy_count += legacy_docs[0] if legacy_docs else 0 except Exception as e: print(f"Error executing legacy query: {e}") @@ -416,43 +489,50 @@ def api_patch_group_document(document_id): ) updated_fields['authors'] = authors_list - # Save updates back to Cosmos - try: - # Log the metadata update transaction if any fields were updated - if updated_fields: - # Get document details for logging - handle tuple return + if 'tags' in data: + from functions_documents import validate_tags, get_or_create_tag_definition + tags_input = data['tags'] if isinstance(data['tags'], list) else [] + is_valid, error_msg, normalized_tags = validate_tags(tags_input) + if not is_valid: + return jsonify({'error': error_msg}), 400 + for tag in normalized_tags: + get_or_create_tag_definition(user_id, tag, workspace_type='group', group_id=active_group_id) + update_document( + document_id=document_id, + group_id=active_group_id, + user_id=user_id, + tags=normalized_tags + ) + updated_fields['tags'] = normalized_tags + + # Log the metadata update transaction if any fields were updated + if updated_fields: # Get document details for logging - from functions_documents import get_document - doc_response = get_document(user_id, document_id, group_id=active_group_id) - doc = None - - # Handle tuple return (response, status_code) - if isinstance(doc_response, tuple): - resp, status_code = doc_response - if hasattr(resp, "get_json"): - doc = resp.get_json() - else: - doc = resp - elif hasattr(doc_response, "get_json"): - doc = doc_response.get_json() - else: - doc = doc_response - - if doc and isinstance(doc, dict): - from functions_activity_logging import log_document_metadata_update_transaction - log_document_metadata_update_transaction( - user_id=user_id, - document_id=document_id, - workspace_type='group', - file_name=doc.get('file_name', 'Unknown'), - updated_fields=updated_fields, - file_type=doc.get('file_type'), - group_id=active_group_id - ) - - return jsonify({'message': 'Group document metadata updated successfully'}), 200 - except Exception as e: - return jsonify({'Error updating Group document metadata': str(e)}), 500 + from functions_documents import get_document + from functions_activity_logging import log_document_metadata_update_transaction + doc_response = get_document(user_id, document_id, group_id=active_group_id) + doc = None + if isinstance(doc_response, tuple): + resp, status_code = doc_response + if status_code == 200 and hasattr(resp, 'get_json'): + doc = resp.get_json() + elif hasattr(doc_response, 'get_json'): + doc = doc_response.get_json() + else: + doc = doc_response + + if doc and isinstance(doc, dict): + log_document_metadata_update_transaction( + user_id=user_id, + document_id=document_id, + workspace_type='group', + file_name=doc.get('file_name', 'Unknown'), + updated_fields=updated_fields, + file_type=doc.get('file_type'), + group_id=active_group_id + ) + + return jsonify({'message': 'Group document metadata updated successfully'}), 200 except Exception as e: return jsonify({'error': str(e)}), 500 @@ -882,3 +962,447 @@ def api_remove_self_from_group_document(document_id): return jsonify({'message': 'Successfully removed group from shared document'}), 200 except Exception as e: return jsonify({'error': f'Error removing group from shared document: {str(e)}'}), 500 + + @app.route('/api/group_documents/tags', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required("enable_group_workspaces") + def api_get_group_document_tags(): + """ + Get all unique tags used across one or more group workspaces with document counts. + Accepts optional `group_ids` query param (comma-separated). + Falls back to single active group from user settings if not provided. + Permission: user must be a member of each group (non-members silently excluded). + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + group_ids_param = request.args.get('group_ids', '') + + if group_ids_param: + group_ids = [gid.strip() for gid in group_ids_param.split(',') if gid.strip()] + else: + user_settings = get_user_settings(user_id) + active_group_id = user_settings["settings"].get("activeGroupOid") + group_ids = [active_group_id] if active_group_id else [] + + from functions_documents import get_workspace_tags + + all_tags = {} + for gid in group_ids: + group_doc = find_group_by_id(gid) + if not group_doc: + continue + role = get_user_role_in_group(group_doc, user_id) + if not role: + continue + + tags = get_workspace_tags(user_id, group_id=gid) + for tag in tags: + if tag['name'] in all_tags: + all_tags[tag['name']]['count'] += tag['count'] + else: + all_tags[tag['name']] = dict(tag) + + merged = sorted(all_tags.values(), key=lambda t: t['name']) + return jsonify({'tags': merged}), 200 + + @app.route('/api/group_documents/tags', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required("enable_group_workspaces") + def api_create_group_tag(): + """ + Create a new tag in the group workspace. + + Request body: + { + "tag_name": "new-tag", + "color": "#3b82f6" // optional + } + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + user_settings = get_user_settings(user_id) + active_group_id = user_settings["settings"].get("activeGroupOid") + if not active_group_id: + return jsonify({'error': 'No active group selected'}), 400 + + group_doc = find_group_by_id(group_id=active_group_id) + if not group_doc: + return jsonify({'error': 'Active group not found'}), 404 + + role = get_user_role_in_group(group_doc, user_id) + if role not in ["Owner", "Admin", "DocumentManager"]: + return jsonify({'error': 'You do not have permission to manage tags'}), 403 + + data = request.get_json() + tag_name = data.get('tag_name') + color = data.get('color', '#0d6efd') + + if not tag_name: + return jsonify({'error': 'tag_name is required'}), 400 + + from functions_documents import normalize_tag, validate_tags + from datetime import datetime, timezone + + try: + is_valid, error_msg, normalized_tags = validate_tags([tag_name]) + if not is_valid: + return jsonify({'error': error_msg}), 400 + + normalized_tag = normalized_tags[0] + + tag_defs = group_doc.get('tag_definitions', {}) + + if normalized_tag in tag_defs: + return jsonify({'error': 'Tag already exists'}), 409 + + tag_defs[normalized_tag] = { + 'color': color, + 'created_at': datetime.now(timezone.utc).isoformat() + } + group_doc['tag_definitions'] = tag_defs + cosmos_groups_container.upsert_item(group_doc) + + return jsonify({ + 'message': f'Tag "{normalized_tag}" created successfully', + 'tag': { + 'name': normalized_tag, + 'color': color + } + }), 201 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @app.route('/api/group_documents/bulk-tag', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required("enable_group_workspaces") + def api_bulk_tag_group_documents(): + """ + Apply tag operations to multiple group documents. + + Request body: + { + "document_ids": ["doc1", "doc2", ...], + "action": "add_tags" | "remove_tags" | "set_tags", + "tags": ["tag1", "tag2", ...] + } + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + user_settings = get_user_settings(user_id) + active_group_id = user_settings["settings"].get("activeGroupOid") + if not active_group_id: + return jsonify({'error': 'No active group selected'}), 400 + + group_doc = find_group_by_id(group_id=active_group_id) + if not group_doc: + return jsonify({'error': 'Active group not found'}), 404 + + role = get_user_role_in_group(group_doc, user_id) + if role not in ["Owner", "Admin", "DocumentManager"]: + return jsonify({'error': 'You do not have permission to manage tags'}), 403 + + data = request.get_json() + document_ids = data.get('document_ids', []) + action = data.get('action') + tags_input = data.get('tags', []) + + if not document_ids or not isinstance(document_ids, list): + return jsonify({'error': 'document_ids must be a non-empty array'}), 400 + + if action not in ['add_tags', 'remove_tags', 'set_tags']: + return jsonify({'error': 'action must be add_tags, remove_tags, or set_tags'}), 400 + + from functions_documents import ( + validate_tags, update_document, + propagate_tags_to_chunks, get_or_create_tag_definition + ) + + is_valid, error_msg, normalized_tags = validate_tags(tags_input) + if not is_valid: + return jsonify({'error': error_msg}), 400 + + for tag in normalized_tags: + get_or_create_tag_definition(user_id, tag, workspace_type='group', group_id=active_group_id) + + results = { + 'success': [], + 'errors': [] + } + + try: + for doc_id in document_ids: + try: + query = "SELECT TOP 1 * FROM c WHERE c.id = @document_id AND c.group_id = @group_id ORDER BY c.version DESC" + parameters = [ + {"name": "@document_id", "value": doc_id}, + {"name": "@group_id", "value": active_group_id} + ] + + document_results = list( + cosmos_group_documents_container.query_items( + query=query, + parameters=parameters, + enable_cross_partition_query=True + ) + ) + + if not document_results: + results['errors'].append({ + 'document_id': doc_id, + 'error': 'Document not found or access denied' + }) + continue + + doc = document_results[0] + current_tags = doc.get('tags', []) + new_tags = [] + + if action == 'add_tags': + new_tags = list(set(current_tags + normalized_tags)) + elif action == 'remove_tags': + new_tags = [t for t in current_tags if t not in normalized_tags] + elif action == 'set_tags': + new_tags = normalized_tags + + update_document( + document_id=doc_id, + group_id=active_group_id, + user_id=user_id, + tags=new_tags + ) + + try: + propagate_tags_to_chunks(doc_id, new_tags, user_id, group_id=active_group_id) + except Exception: + pass + + results['success'].append({ + 'document_id': doc_id, + 'tags': new_tags + }) + + except Exception as doc_error: + results['errors'].append({ + 'document_id': doc_id, + 'error': str(doc_error) + }) + + if results['success']: + invalidate_group_search_cache(active_group_id) + + status_code = 200 if not results['errors'] else 207 + return jsonify(results), status_code + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @app.route('/api/group_documents/tags/', methods=['PATCH']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required("enable_group_workspaces") + def api_update_group_tag(tag_name): + """ + Update a group tag (rename or change color). + + Request body: + { + "new_name": "new-tag-name", // optional + "color": "#3b82f6" // optional + } + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + user_settings = get_user_settings(user_id) + active_group_id = user_settings["settings"].get("activeGroupOid") + if not active_group_id: + return jsonify({'error': 'No active group selected'}), 400 + + group_doc = find_group_by_id(group_id=active_group_id) + if not group_doc: + return jsonify({'error': 'Active group not found'}), 404 + + role = get_user_role_in_group(group_doc, user_id) + if role not in ["Owner", "Admin", "DocumentManager"]: + return jsonify({'error': 'You do not have permission to manage tags'}), 403 + + data = request.get_json() + new_name = data.get('new_name') + new_color = data.get('color') + + from functions_documents import normalize_tag, validate_tags, update_document, propagate_tags_to_chunks + + try: + normalized_old_tag = normalize_tag(tag_name) + + if new_name: + is_valid, error_msg, normalized_new = validate_tags([new_name]) + if not is_valid: + return jsonify({'error': error_msg}), 400 + + normalized_new_tag = normalized_new[0] + + query = "SELECT * FROM c WHERE c.group_id = @group_id" + parameters = [{"name": "@group_id", "value": active_group_id}] + documents = list(cosmos_group_documents_container.query_items( + query=query, parameters=parameters, enable_cross_partition_query=True + )) + + latest_documents = {} + for doc in documents: + file_name = doc['file_name'] + if file_name not in latest_documents or doc['version'] > latest_documents[file_name]['version']: + latest_documents[file_name] = doc + + all_docs = list(latest_documents.values()) + updated_count = 0 + + for doc in all_docs: + if normalized_old_tag in doc.get('tags', []): + current_tags = doc['tags'] + new_tags = [normalized_new_tag if t == normalized_old_tag else t for t in current_tags] + + update_document( + document_id=doc['id'], + group_id=active_group_id, + user_id=user_id, + tags=new_tags + ) + + try: + propagate_tags_to_chunks(doc['id'], new_tags, user_id, group_id=active_group_id) + except Exception: + pass + + updated_count += 1 + + tag_defs = group_doc.get('tag_definitions', {}) + if normalized_old_tag in tag_defs: + old_def = tag_defs.pop(normalized_old_tag) + tag_defs[normalized_new_tag] = old_def + group_doc['tag_definitions'] = tag_defs + cosmos_groups_container.upsert_item(group_doc) + + invalidate_group_search_cache(active_group_id) + + return jsonify({ + 'message': f'Tag renamed from "{normalized_old_tag}" to "{normalized_new_tag}"', + 'documents_updated': updated_count + }), 200 + + if new_color: + tag_defs = group_doc.get('tag_definitions', {}) + + if normalized_old_tag in tag_defs: + tag_defs[normalized_old_tag]['color'] = new_color + else: + from datetime import datetime, timezone + tag_defs[normalized_old_tag] = { + 'color': new_color, + 'created_at': datetime.now(timezone.utc).isoformat() + } + + group_doc['tag_definitions'] = tag_defs + cosmos_groups_container.upsert_item(group_doc) + + return jsonify({ + 'message': f'Tag color updated for "{normalized_old_tag}"' + }), 200 + + return jsonify({'error': 'No updates specified'}), 400 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @app.route('/api/group_documents/tags/', methods=['DELETE']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required("enable_group_workspaces") + def api_delete_group_tag(tag_name): + """Delete a tag from all documents in the group workspace.""" + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + user_settings = get_user_settings(user_id) + active_group_id = user_settings["settings"].get("activeGroupOid") + if not active_group_id: + return jsonify({'error': 'No active group selected'}), 400 + + group_doc = find_group_by_id(group_id=active_group_id) + if not group_doc: + return jsonify({'error': 'Active group not found'}), 404 + + role = get_user_role_in_group(group_doc, user_id) + if role not in ["Owner", "Admin", "DocumentManager"]: + return jsonify({'error': 'You do not have permission to manage tags'}), 403 + + from functions_documents import normalize_tag, update_document, propagate_tags_to_chunks + + try: + normalized_tag = normalize_tag(tag_name) + + query = "SELECT * FROM c WHERE c.group_id = @group_id" + parameters = [{"name": "@group_id", "value": active_group_id}] + documents = list(cosmos_group_documents_container.query_items( + query=query, parameters=parameters, enable_cross_partition_query=True + )) + + latest_documents = {} + for doc in documents: + file_name = doc['file_name'] + if file_name not in latest_documents or doc['version'] > latest_documents[file_name]['version']: + latest_documents[file_name] = doc + + all_docs = list(latest_documents.values()) + updated_count = 0 + + for doc in all_docs: + if normalized_tag in doc.get('tags', []): + new_tags = [t for t in doc['tags'] if t != normalized_tag] + + update_document( + document_id=doc['id'], + group_id=active_group_id, + user_id=user_id, + tags=new_tags + ) + + try: + propagate_tags_to_chunks(doc['id'], new_tags, user_id, group_id=active_group_id) + except Exception: + pass + + updated_count += 1 + + tag_defs = group_doc.get('tag_definitions', {}) + if normalized_tag in tag_defs: + tag_defs.pop(normalized_tag) + group_doc['tag_definitions'] = tag_defs + cosmos_groups_container.upsert_item(group_doc) + + if updated_count > 0: + invalidate_group_search_cache(active_group_id) + + return jsonify({ + 'message': f'Tag "{normalized_tag}" deleted from {updated_count} document(s)' + }), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 diff --git a/application/single_app/route_backend_public_documents.py b/application/single_app/route_backend_public_documents.py index a209e9a2..c443127c 100644 --- a/application/single_app/route_backend_public_documents.py +++ b/application/single_app/route_backend_public_documents.py @@ -146,12 +146,65 @@ def api_list_public_documents(): # filters search = request.args.get('search', '').strip() + classification_filter = request.args.get('classification', default=None, type=str) + author_filter = request.args.get('author', default=None, type=str) + keywords_filter = request.args.get('keywords', default=None, type=str) + abstract_filter = request.args.get('abstract', default=None, type=str) + tags_filter = request.args.get('tags', default=None, type=str) + sort_by = request.args.get('sort_by', default='_ts', type=str) + sort_order = request.args.get('sort_order', default='desc', type=str) + + allowed_sort_fields = {'_ts', 'file_name', 'title'} + if sort_by not in allowed_sort_fields: + sort_by = '_ts' + sort_order = sort_order.upper() if sort_order.lower() in ('asc', 'desc') else 'DESC' + # build WHERE conds = ['c.public_workspace_id = @ws'] params = [{'name':'@ws','value':active_ws}] + param_count = 0 if search: conds.append('(CONTAINS(LOWER(c.file_name), LOWER(@search)) OR CONTAINS(LOWER(c.title), LOWER(@search)))') params.append({'name':'@search','value':search}) + param_count += 1 + + if classification_filter: + if classification_filter.lower() == 'none': + conds.append("(NOT IS_DEFINED(c.document_classification) OR c.document_classification = null OR c.document_classification = '' OR LOWER(c.document_classification) = 'none')") + else: + param_name = f"@classification_{param_count}" + conds.append(f"c.document_classification = {param_name}") + params.append({'name': param_name, 'value': classification_filter}) + param_count += 1 + + if author_filter: + param_name = f"@author_{param_count}" + conds.append(f"EXISTS(SELECT VALUE a FROM a IN c.authors WHERE CONTAINS(LOWER(a), LOWER({param_name})))") + params.append({'name': param_name, 'value': author_filter}) + param_count += 1 + + if keywords_filter: + param_name = f"@keywords_{param_count}" + conds.append(f"EXISTS(SELECT VALUE k FROM k IN c.keywords WHERE CONTAINS(LOWER(k), LOWER({param_name})))") + params.append({'name': param_name, 'value': keywords_filter}) + param_count += 1 + + if abstract_filter: + param_name = f"@abstract_{param_count}" + conds.append(f"CONTAINS(LOWER(c.abstract ?? ''), LOWER({param_name}))") + params.append({'name': param_name, 'value': abstract_filter}) + param_count += 1 + + if tags_filter: + from functions_documents import normalize_tag + tags_list = [normalize_tag(t.strip()) for t in tags_filter.split(',') if t.strip()] + if tags_list: + for idx, tag in enumerate(tags_list): + param_name = f"@tag_{param_count}_{idx}" + conds.append(f"ARRAY_CONTAINS(c.tags, {param_name})") + params.append({'name': param_name, 'value': tag}) + param_count += len(tags_list) + where = ' AND '.join(conds) # count @@ -162,7 +215,7 @@ def api_list_public_documents(): total_count = total[0] if total else 0 # data - data_q = f'SELECT * FROM c WHERE {where} ORDER BY c._ts DESC OFFSET {offset} LIMIT {page_size}' + data_q = f'SELECT * FROM c WHERE {where} ORDER BY c.{sort_by} {sort_order} OFFSET {offset} LIMIT {page_size}' docs = list(cosmos_public_documents_container.query_items( query=data_q, parameters=params, enable_cross_partition_query=True )) @@ -298,44 +351,34 @@ def api_patch_public_document(doc_id): if 'document_classification' in data: update_document(document_id=doc_id, public_workspace_id=active_ws, user_id=user_id, document_classification=data['document_classification']) updated_fields['document_classification'] = data['document_classification'] + if 'tags' in data: + from functions_documents import validate_tags, get_or_create_tag_definition + tags_input = data['tags'] if isinstance(data['tags'], list) else [] + is_valid, error_msg, normalized_tags = validate_tags(tags_input) + if not is_valid: + return jsonify({'error': error_msg}), 400 + for tag in normalized_tags: + get_or_create_tag_definition(user_id, tag, workspace_type='public', public_workspace_id=active_ws) + update_document(document_id=doc_id, public_workspace_id=active_ws, user_id=user_id, tags=normalized_tags) + updated_fields['tags'] = normalized_tags + + # Log the metadata update transaction if any fields were updated + if updated_fields: + from functions_documents import get_document + from functions_activity_logging import log_document_metadata_update_transaction + doc = get_document(user_id, doc_id, public_workspace_id=active_ws) + if doc: + log_document_metadata_update_transaction( + user_id=user_id, + document_id=doc_id, + workspace_type='public', + file_name=doc.get('file_name', 'Unknown'), + updated_fields=updated_fields, + file_type=doc.get('file_type'), + public_workspace_id=active_ws + ) - # Save updates back to Cosmos - try: - # Log the metadata update transaction if any fields were updated - if updated_fields: - # Get document details for logging - handle tuple return - # Get document details for logging - from functions_documents import get_document - doc_response = get_document(user_id, doc_id, public_workspace_id=active_ws) - doc = None - - # Handle tuple return (response, status_code) - if isinstance(doc_response, tuple): - resp, status_code = doc_response - if hasattr(resp, "get_json"): - doc = resp.get_json() - else: - doc = resp - elif hasattr(doc_response, "get_json"): - doc = doc_response.get_json() - else: - doc = doc_response - - if doc and isinstance(doc, dict): - from functions_activity_logging import log_document_metadata_update_transaction - log_document_metadata_update_transaction( - user_id=user_id, - document_id=doc_id, - workspace_type='public', - file_name=doc.get('file_name', 'Unknown'), - updated_fields=updated_fields, - file_type=doc.get('file_type'), - public_workspace_id=active_ws - ) - - return jsonify({'message': 'Public document metadata updated successfully'}), 200 - except Exception as e: - return jsonify({'Error updating Public document metadata': str(e)}), 500 + return jsonify({'message':'Metadata updated'}), 200 except Exception as e: return jsonify({'error': str(e)}), 500 @@ -412,3 +455,445 @@ def api_upgrade_legacy_public_documents(): return jsonify({'message':f'Upgraded {count} docs'}), 200 except Exception as e: return jsonify({'error':str(e)}), 500 + + @app.route('/api/public_workspace_documents/tags', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('enable_public_workspaces') + def api_get_public_workspace_document_tags(): + """ + Get all unique tags used across one or more public workspaces with document counts. + Accepts optional `workspace_ids` query param (comma-separated). + Falls back to all visible public workspaces from user settings if not provided. + Permission: only workspaces the user has visibility to are included. + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + ws_ids_param = request.args.get('workspace_ids', '') + + if ws_ids_param: + workspace_ids = [wid.strip() for wid in ws_ids_param.split(',') if wid.strip()] + else: + workspace_ids = get_user_visible_public_workspace_ids_from_settings(user_id) + + visible_ids = set(get_user_visible_public_workspace_ids_from_settings(user_id)) + validated_ids = [wid for wid in workspace_ids if wid in visible_ids] + + from functions_documents import get_workspace_tags + + all_tags = {} + for wid in validated_ids: + tags = get_workspace_tags(user_id, public_workspace_id=wid) + for tag in tags: + if tag['name'] in all_tags: + all_tags[tag['name']]['count'] += tag['count'] + else: + all_tags[tag['name']] = dict(tag) + + merged = sorted(all_tags.values(), key=lambda t: t['name']) + return jsonify({'tags': merged}), 200 + + @app.route('/api/public_workspace_documents/tags', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('enable_public_workspaces') + def api_create_public_workspace_tag(): + """ + Create a new tag in the public workspace. + + Request body: + { + "tag_name": "new-tag", + "color": "#3b82f6" // optional + } + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + user_cfg = get_user_settings(user_id) + active_ws = user_cfg['settings'].get('activePublicWorkspaceOid') + if not active_ws: + return jsonify({'error': 'No active public workspace selected'}), 400 + + ws_doc = find_public_workspace_by_id(active_ws) + if not ws_doc: + return jsonify({'error': 'Active public workspace not found'}), 404 + + from functions_public_workspaces import get_user_role_in_public_workspace + role = get_user_role_in_public_workspace(ws_doc, user_id) + if role not in ['Owner', 'Admin', 'DocumentManager']: + return jsonify({'error': 'You do not have permission to manage tags'}), 403 + + data = request.get_json() + tag_name = data.get('tag_name') + color = data.get('color', '#0d6efd') + + if not tag_name: + return jsonify({'error': 'tag_name is required'}), 400 + + from functions_documents import normalize_tag, validate_tags + from datetime import datetime, timezone + + try: + is_valid, error_msg, normalized_tags = validate_tags([tag_name]) + if not is_valid: + return jsonify({'error': error_msg}), 400 + + normalized_tag = normalized_tags[0] + + tag_defs = ws_doc.get('tag_definitions', {}) + + if normalized_tag in tag_defs: + return jsonify({'error': 'Tag already exists'}), 409 + + tag_defs[normalized_tag] = { + 'color': color, + 'created_at': datetime.now(timezone.utc).isoformat() + } + ws_doc['tag_definitions'] = tag_defs + cosmos_public_workspaces_container.upsert_item(ws_doc) + + return jsonify({ + 'message': f'Tag "{normalized_tag}" created successfully', + 'tag': { + 'name': normalized_tag, + 'color': color + } + }), 201 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @app.route('/api/public_workspace_documents/bulk-tag', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('enable_public_workspaces') + def api_bulk_tag_public_documents(): + """ + Apply tag operations to multiple public workspace documents. + + Request body: + { + "document_ids": ["doc1", "doc2", ...], + "action": "add_tags" | "remove_tags" | "set_tags", + "tags": ["tag1", "tag2", ...] + } + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + user_cfg = get_user_settings(user_id) + active_ws = user_cfg['settings'].get('activePublicWorkspaceOid') + if not active_ws: + return jsonify({'error': 'No active public workspace selected'}), 400 + + ws_doc = find_public_workspace_by_id(active_ws) + if not ws_doc: + return jsonify({'error': 'Active public workspace not found'}), 404 + + from functions_public_workspaces import get_user_role_in_public_workspace + role = get_user_role_in_public_workspace(ws_doc, user_id) + if role not in ['Owner', 'Admin', 'DocumentManager']: + return jsonify({'error': 'You do not have permission to manage tags'}), 403 + + data = request.get_json() + document_ids = data.get('document_ids', []) + action = data.get('action') + tags_input = data.get('tags', []) + + if not document_ids or not isinstance(document_ids, list): + return jsonify({'error': 'document_ids must be a non-empty array'}), 400 + + if action not in ['add_tags', 'remove_tags', 'set_tags']: + return jsonify({'error': 'action must be add_tags, remove_tags, or set_tags'}), 400 + + from functions_documents import ( + validate_tags, update_document, + propagate_tags_to_chunks, get_or_create_tag_definition + ) + + is_valid, error_msg, normalized_tags = validate_tags(tags_input) + if not is_valid: + return jsonify({'error': error_msg}), 400 + + for tag in normalized_tags: + get_or_create_tag_definition(user_id, tag, workspace_type='public', public_workspace_id=active_ws) + + results = { + 'success': [], + 'errors': [] + } + + try: + for doc_id in document_ids: + try: + query = "SELECT TOP 1 * FROM c WHERE c.id = @document_id AND c.public_workspace_id = @ws_id ORDER BY c.version DESC" + parameters = [ + {"name": "@document_id", "value": doc_id}, + {"name": "@ws_id", "value": active_ws} + ] + + document_results = list( + cosmos_public_documents_container.query_items( + query=query, + parameters=parameters, + enable_cross_partition_query=True + ) + ) + + if not document_results: + results['errors'].append({ + 'document_id': doc_id, + 'error': 'Document not found or access denied' + }) + continue + + doc = document_results[0] + current_tags = doc.get('tags', []) + new_tags = [] + + if action == 'add_tags': + new_tags = list(set(current_tags + normalized_tags)) + elif action == 'remove_tags': + new_tags = [t for t in current_tags if t not in normalized_tags] + elif action == 'set_tags': + new_tags = normalized_tags + + update_document( + document_id=doc_id, + public_workspace_id=active_ws, + user_id=user_id, + tags=new_tags + ) + + try: + propagate_tags_to_chunks(doc_id, new_tags, user_id, public_workspace_id=active_ws) + except Exception: + pass + + results['success'].append({ + 'document_id': doc_id, + 'tags': new_tags + }) + + except Exception as doc_error: + results['errors'].append({ + 'document_id': doc_id, + 'error': str(doc_error) + }) + + if results['success']: + invalidate_public_workspace_search_cache(active_ws) + + status_code = 200 if not results['errors'] else 207 + return jsonify(results), status_code + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @app.route('/api/public_workspace_documents/tags/', methods=['PATCH']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('enable_public_workspaces') + def api_update_public_workspace_tag(tag_name): + """ + Update a public workspace tag (rename or change color). + + Request body: + { + "new_name": "new-tag-name", // optional + "color": "#3b82f6" // optional + } + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + user_cfg = get_user_settings(user_id) + active_ws = user_cfg['settings'].get('activePublicWorkspaceOid') + if not active_ws: + return jsonify({'error': 'No active public workspace selected'}), 400 + + ws_doc = find_public_workspace_by_id(active_ws) + if not ws_doc: + return jsonify({'error': 'Active public workspace not found'}), 404 + + from functions_public_workspaces import get_user_role_in_public_workspace + role = get_user_role_in_public_workspace(ws_doc, user_id) + if role not in ['Owner', 'Admin', 'DocumentManager']: + return jsonify({'error': 'You do not have permission to manage tags'}), 403 + + data = request.get_json() + new_name = data.get('new_name') + new_color = data.get('color') + + from functions_documents import normalize_tag, validate_tags, update_document, propagate_tags_to_chunks + + try: + normalized_old_tag = normalize_tag(tag_name) + + if new_name: + is_valid, error_msg, normalized_new = validate_tags([new_name]) + if not is_valid: + return jsonify({'error': error_msg}), 400 + + normalized_new_tag = normalized_new[0] + + query = "SELECT * FROM c WHERE c.public_workspace_id = @ws_id" + parameters = [{"name": "@ws_id", "value": active_ws}] + documents = list(cosmos_public_documents_container.query_items( + query=query, parameters=parameters, enable_cross_partition_query=True + )) + + latest_documents = {} + for doc in documents: + file_name = doc['file_name'] + if file_name not in latest_documents or doc['version'] > latest_documents[file_name]['version']: + latest_documents[file_name] = doc + + all_docs = list(latest_documents.values()) + updated_count = 0 + + for doc in all_docs: + if normalized_old_tag in doc.get('tags', []): + current_tags = doc['tags'] + new_tags = [normalized_new_tag if t == normalized_old_tag else t for t in current_tags] + + update_document( + document_id=doc['id'], + public_workspace_id=active_ws, + user_id=user_id, + tags=new_tags + ) + + try: + propagate_tags_to_chunks(doc['id'], new_tags, user_id, public_workspace_id=active_ws) + except Exception: + pass + + updated_count += 1 + + tag_defs = ws_doc.get('tag_definitions', {}) + if normalized_old_tag in tag_defs: + old_def = tag_defs.pop(normalized_old_tag) + tag_defs[normalized_new_tag] = old_def + ws_doc['tag_definitions'] = tag_defs + cosmos_public_workspaces_container.upsert_item(ws_doc) + + invalidate_public_workspace_search_cache(active_ws) + + return jsonify({ + 'message': f'Tag renamed from "{normalized_old_tag}" to "{normalized_new_tag}"', + 'documents_updated': updated_count + }), 200 + + if new_color: + tag_defs = ws_doc.get('tag_definitions', {}) + + if normalized_old_tag in tag_defs: + tag_defs[normalized_old_tag]['color'] = new_color + else: + from datetime import datetime, timezone + tag_defs[normalized_old_tag] = { + 'color': new_color, + 'created_at': datetime.now(timezone.utc).isoformat() + } + + ws_doc['tag_definitions'] = tag_defs + cosmos_public_workspaces_container.upsert_item(ws_doc) + + return jsonify({ + 'message': f'Tag color updated for "{normalized_old_tag}"' + }), 200 + + return jsonify({'error': 'No updates specified'}), 400 + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @app.route('/api/public_workspace_documents/tags/', methods=['DELETE']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + @enabled_required('enable_public_workspaces') + def api_delete_public_workspace_tag(tag_name): + """Delete a tag from all documents in the public workspace.""" + user_id = get_current_user_id() + if not user_id: + return jsonify({'error': 'User not authenticated'}), 401 + + user_cfg = get_user_settings(user_id) + active_ws = user_cfg['settings'].get('activePublicWorkspaceOid') + if not active_ws: + return jsonify({'error': 'No active public workspace selected'}), 400 + + ws_doc = find_public_workspace_by_id(active_ws) + if not ws_doc: + return jsonify({'error': 'Active public workspace not found'}), 404 + + from functions_public_workspaces import get_user_role_in_public_workspace + role = get_user_role_in_public_workspace(ws_doc, user_id) + if role not in ['Owner', 'Admin', 'DocumentManager']: + return jsonify({'error': 'You do not have permission to manage tags'}), 403 + + from functions_documents import normalize_tag, update_document, propagate_tags_to_chunks + + try: + normalized_tag = normalize_tag(tag_name) + + query = "SELECT * FROM c WHERE c.public_workspace_id = @ws_id" + parameters = [{"name": "@ws_id", "value": active_ws}] + documents = list(cosmos_public_documents_container.query_items( + query=query, parameters=parameters, enable_cross_partition_query=True + )) + + latest_documents = {} + for doc in documents: + file_name = doc['file_name'] + if file_name not in latest_documents or doc['version'] > latest_documents[file_name]['version']: + latest_documents[file_name] = doc + + all_docs = list(latest_documents.values()) + updated_count = 0 + + for doc in all_docs: + if normalized_tag in doc.get('tags', []): + new_tags = [t for t in doc['tags'] if t != normalized_tag] + + update_document( + document_id=doc['id'], + public_workspace_id=active_ws, + user_id=user_id, + tags=new_tags + ) + + try: + propagate_tags_to_chunks(doc['id'], new_tags, user_id, public_workspace_id=active_ws) + except Exception: + pass + + updated_count += 1 + + tag_defs = ws_doc.get('tag_definitions', {}) + if normalized_tag in tag_defs: + tag_defs.pop(normalized_tag) + ws_doc['tag_definitions'] = tag_defs + cosmos_public_workspaces_container.upsert_item(ws_doc) + + if updated_count > 0: + invalidate_public_workspace_search_cache(active_ws) + + return jsonify({ + 'message': f'Tag "{normalized_tag}" deleted from {updated_count} document(s)' + }), 200 + + except Exception as e: + return jsonify({'error': str(e)}), 500 diff --git a/application/single_app/route_backend_settings.py b/application/single_app/route_backend_settings.py index 30e10cb2..7be73134 100644 --- a/application/single_app/route_backend_settings.py +++ b/application/single_app/route_backend_settings.py @@ -11,6 +11,102 @@ import redis +def auto_fix_index_fields(idx_type: str, user_id: str = 'system', admin_email: str = None) -> dict: + """ + Automatically fix missing fields in an Azure AI Search index. + + Args: + idx_type (str): Type of index ('user', 'group', or 'public') + user_id (str): User ID triggering the fix + admin_email (str): Admin email if available + + Returns: + dict: Result with 'status', 'added' fields, or 'error' + """ + try: + # Load the golden JSON schema + json_name = secure_filename(f'ai_search-index-{idx_type}.json') + base_path = os.path.join(current_app.root_path, 'static', 'json') + json_path = os.path.normpath(os.path.join(base_path, json_name)) + + if not json_path.startswith(base_path): + return {'error': 'Invalid file path'} + + with open(json_path, 'r') as f: + full_def = json.load(f) + + client = get_index_client() + index_obj = client.get_index(full_def['name']) + + existing_names = {fld.name for fld in index_obj.fields} + missing_defs = [fld for fld in full_def['fields'] if fld['name'] not in existing_names] + + if not missing_defs: + return {'status': 'nothingToAdd'} + + new_fields = [] + for fld in missing_defs: + name = fld['name'] + ftype = fld['type'] + + if ftype.lower() == "collection(edm.single)": + # Vector field + dims = fld.get('dimensions', 1536) + vp = fld.get('vectorSearchProfile') + new_fields.append( + SearchField( + name=name, + type=ftype, + searchable=True, + filterable=False, + retrievable=True, + sortable=False, + facetable=False, + vector_search_dimensions=dims, + vector_search_profile_name=vp + ) + ) + else: + # Regular field + new_fields.append( + SearchField( + name=name, + type=ftype, + searchable=fld.get('searchable', False), + filterable=fld.get('filterable', False), + retrievable=fld.get('retrievable', True), + sortable=fld.get('sortable', False), + facetable=fld.get('facetable', False), + key=fld.get('key', False), + analyzer_name=fld.get('analyzer'), + index_analyzer_name=fld.get('indexAnalyzer'), + search_analyzer_name=fld.get('searchAnalyzer'), + normalizer_name=fld.get('normalizer'), + synonym_map_names=fld.get('synonymMaps', []) + ) + ) + + # Update the index + index_obj.fields.extend(new_fields) + index_obj.etag = "*" + client.create_or_update_index(index_obj) + + added = [f.name for f in new_fields] + + # Log the automatic fix + log_index_auto_fix( + index_type=idx_type, + missing_fields=added, + user_id=user_id, + admin_email=admin_email + ) + + return {'status': 'success', 'added': added} + + except Exception as e: + return {'error': str(e)} + + def register_route_backend_settings(app): @app.route('/api/admin/settings/check_index_fields', methods=['POST']) @swagger_route(security=get_auth_security()) @@ -20,6 +116,7 @@ def check_index_fields(): try: data = request.get_json(force=True) idx_type = data.get('indexType') # 'user', 'group', or 'public' + auto_fix = data.get('autoFix', True) # Default to auto-fix enabled if not idx_type or idx_type not in ['user', 'group', 'public']: return jsonify({'error': 'Invalid indexType. Must be "user", "group", or "public"'}), 400 @@ -53,11 +150,49 @@ def check_index_fields(): expected_names = { fld['name'] for fld in expected['fields'] } missing = sorted(expected_names - existing_names) - return jsonify({ - 'missingFields': missing, - 'indexExists': True, - 'indexName': expected['name'] - }), 200 + if missing: + # Automatically fix if enabled + if auto_fix: + user = session.get('user', {}) + admin_email = user.get('preferred_username', user.get('email')) + user_id = get_current_user_id() or 'system' + + fix_result = auto_fix_index_fields( + idx_type=idx_type, + user_id=user_id, + admin_email=admin_email + ) + + if fix_result.get('status') == 'success': + return jsonify({ + 'indexExists': True, + 'missingFields': [], + 'autoFixed': True, + 'fieldsAdded': fix_result.get('added', []), + 'indexName': expected['name'] + }), 200 + else: + # Auto-fix failed, return missing fields for manual fix + return jsonify({ + 'indexExists': True, + 'missingFields': missing, + 'autoFixFailed': True, + 'error': fix_result.get('error'), + 'indexName': expected['name'] + }), 200 + else: + # Auto-fix disabled, return missing fields + return jsonify({ + 'indexExists': True, + 'missingFields': missing, + 'indexName': expected['name'] + }), 200 + else: + return jsonify({ + 'missingFields': [], + 'indexExists': True, + 'indexName': expected['name'] + }), 200 except ResourceNotFoundError as not_found_error: # Index doesn't exist - this is the specific exception for "index not found" diff --git a/application/single_app/route_external_public_documents.py b/application/single_app/route_external_public_documents.py index d3002d53..67bcbafa 100644 --- a/application/single_app/route_external_public_documents.py +++ b/application/single_app/route_external_public_documents.py @@ -360,8 +360,18 @@ def external_patch_public_document(document_id): if updated_fields: from functions_documents import get_document from functions_activity_logging import log_document_metadata_update_transaction - doc = get_document(user_id, document_id, public_workspace_id=active_workspace_id) - if doc: + doc_response = get_document(user_id, document_id, public_workspace_id=active_workspace_id) + doc = None + if isinstance(doc_response, tuple): + resp, status_code = doc_response + if status_code == 200 and hasattr(resp, 'get_json'): + doc = resp.get_json() + elif hasattr(doc_response, 'get_json'): + doc = doc_response.get_json() + else: + doc = doc_response + + if doc and isinstance(doc, dict): log_document_metadata_update_transaction( user_id=user_id, document_id=document_id, diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index ae361984..c7ee7dbc 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -193,6 +193,10 @@ def admin_settings(): if 'enable_left_nav_default' not in settings: settings['enable_left_nav_default'] = True + # --- Add defaults for workspace scope lock --- + if 'enforce_workspace_scope_lock' not in settings: + settings['enforce_workspace_scope_lock'] = True + # --- Add defaults for multimodal vision --- if 'enable_multimodal_vision' not in settings: settings['enable_multimodal_vision'] = False @@ -731,6 +735,7 @@ def is_valid_url(url): 'enable_group_creation': form_data.get('disable_group_creation') != 'on', 'enable_public_workspaces': form_data.get('enable_public_workspaces') == 'on', 'enable_file_sharing': form_data.get('enable_file_sharing') == 'on', + 'enforce_workspace_scope_lock': form_data.get('enforce_workspace_scope_lock') == 'on', 'enable_file_processing_logs': enable_file_processing_logs, 'file_processing_logs_timer_enabled': file_processing_logs_timer_enabled, 'file_timer_value': file_timer_value, diff --git a/application/single_app/route_frontend_chats.py b/application/single_app/route_frontend_chats.py index a7f8e6a0..61a2899e 100644 --- a/application/single_app/route_frontend_chats.py +++ b/application/single_app/route_frontend_chats.py @@ -1,15 +1,19 @@ # route_frontend_chats.py +import logging from config import * from functions_authentication import * from functions_content import * from functions_settings import * from functions_documents import * -from functions_group import find_group_by_id +from functions_group import find_group_by_id, get_user_groups +from functions_public_workspaces import find_public_workspace_by_id, get_user_visible_public_workspace_ids_from_settings from functions_appinsights import log_event from swagger_wrapper import swagger_route, get_auth_security from functions_debug import debug_print +logger = logging.getLogger(__name__) + def register_route_frontend_chats(app): @app.route('/chats', methods=['GET']) @swagger_route(security=get_auth_security()) @@ -38,10 +42,29 @@ def chats(): if not user_id: return redirect(url_for('login')) - + # Get user display name from user settings user_display_name = user_settings.get('display_name', '') - + + # Get all groups the user belongs to (for multi-scope selector) + user_groups_simple = [] + try: + user_groups_raw = get_user_groups(user_id) + user_groups_simple = [{'id': g['id'], 'name': g.get('name', 'Unnamed')} for g in user_groups_raw] + except Exception as e: + logger.warning(f"Failed to load user groups for chats page: {e}") + + # Get visible public workspaces with names (for multi-scope selector) + user_visible_public_workspaces = [] + try: + visible_ws_ids = get_user_visible_public_workspace_ids_from_settings(user_id) + for ws_id in visible_ws_ids: + ws_doc = find_public_workspace_by_id(ws_id) + if ws_doc: + user_visible_public_workspaces.append({'id': ws_id, 'name': ws_doc.get('name', 'Unknown')}) + except Exception as e: + logger.warning(f"Failed to load visible public workspaces for chats page: {e}") + return render_template( 'chats.html', settings=public_settings, @@ -55,6 +78,8 @@ def chats(): enable_extract_meta_data=enable_extract_meta_data, user_id=user_id, user_display_name=user_display_name, + user_groups=user_groups_simple, + user_visible_public_workspaces=user_visible_public_workspaces, ) @app.route('/upload', methods=['POST']) diff --git a/application/single_app/static/js/admin/admin_settings.js b/application/single_app/static/js/admin/admin_settings.js index 81f80f9e..85719128 100644 --- a/application/single_app/static/js/admin/admin_settings.js +++ b/application/single_app/static/js/admin/admin_settings.js @@ -2751,8 +2751,34 @@ document.addEventListener('DOMContentLoaded', () => { return r.json(); }) .then(response => { - if (response.missingFields && response.missingFields.length > 0) { + if (response.autoFixed) { + // Fields were automatically fixed + console.log(`✅ Auto-fixed ${type} index: added ${response.fieldsAdded.length} field(s):`, response.fieldsAdded.join(', ')); + if (warnDiv) { + warnDiv.className = 'alert alert-success'; + missingSpan.textContent = `Automatically added ${response.fieldsAdded.length} field(s): ${response.fieldsAdded.join(', ')}`; + warnDiv.style.display = 'block'; + if (fixBtn) fixBtn.style.display = 'none'; + + // Hide success message after 5 seconds + setTimeout(() => { + warnDiv.style.display = 'none'; + }, 5000); + } + } else if (response.autoFixFailed) { + // Auto-fix failed, show manual button + console.warn(`Auto-fix failed for ${type} index:`, response.error); + missingSpan.textContent = response.missingFields.join(', ') + ' (Auto-fix failed - please fix manually)'; + warnDiv.className = 'alert alert-warning'; + warnDiv.style.display = 'block'; + if (fixBtn) { + fixBtn.textContent = `Fix ${type} Index Fields`; + fixBtn.style.display = 'inline-block'; + } + } else if (response.missingFields && response.missingFields.length > 0) { + // Missing fields but auto-fix was disabled missingSpan.textContent = response.missingFields.join(', '); + warnDiv.className = 'alert alert-warning'; warnDiv.style.display = 'block'; if (fixBtn) { fixBtn.textContent = `Fix ${type} Index Fields`; diff --git a/application/single_app/static/js/chat/chat-citations.js b/application/single_app/static/js/chat/chat-citations.js index abad0af0..9ec6bad3 100644 --- a/application/single_app/static/js/chat/chat-citations.js +++ b/application/single_app/static/js/chat/chat-citations.js @@ -26,7 +26,7 @@ export function parseCitations(message) { // ... (keep existing implementation) const citationRegex = /\(Source:\s*([^,]+),\s*Page(?:s)?:\s*([^)]+)\)\s*((?:\[#.*?\]\s*)+)/gi; - return message.replace(citationRegex, (whole, filename, pages, bracketSection) => { + let result = message.replace(citationRegex, (whole, filename, pages, bracketSection) => { let filenameHtml; if (/^https?:\/\/.+/i.test(filename.trim())) { filenameHtml = `${filename.trim()}`; @@ -96,6 +96,14 @@ export function parseCitations(message) { const linkedPagesText = linkedTokens.join(', '); return `(Source: ${filenameHtml}, Pages: ${linkedPagesText})`; }); + + // Cleanup pass: strip any remaining [#guid...] bracket groups that the main regex didn't match. + // These appear when the model uses non-standard citation formats (e.g. "passim" instead of "Page: N"). + // Pattern matches brackets containing one or more UUID-like citation IDs (with optional _suffix parts). + const guidBracketRegex = /\s*\[#?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}[^\]]*\]/gi; + result = result.replace(guidBracketRegex, ''); + + return result; } diff --git a/application/single_app/static/js/chat/chat-conversation-details.js b/application/single_app/static/js/chat/chat-conversation-details.js index e700b758..19851bae 100644 --- a/application/single_app/static/js/chat/chat-conversation-details.js +++ b/application/single_app/static/js/chat/chat-conversation-details.js @@ -75,7 +75,7 @@ export async function showConversationDetails(conversationId) { * @returns {string} HTML string */ function renderConversationMetadata(metadata, conversationId) { - const { context = [], tags = [], strict = false, classification = [], last_updated, chat_type = 'personal', is_pinned = false, is_hidden = false } = metadata; + const { context = [], tags = [], strict = false, classification = [], last_updated, chat_type = 'personal', is_pinned = false, is_hidden = false, scope_locked, locked_contexts = [] } = metadata; // Organize tags by category const tagsByCategory = { @@ -123,6 +123,9 @@ function renderConversationMetadata(metadata, conversationId) {
Status: ${is_pinned ? 'Pinned' : ''} ${is_hidden ? 'Hidden' : ''}${!is_pinned && !is_hidden ? 'Normal' : ''}
+
+ Scope Lock: ${formatScopeLockStatus(scope_locked, locked_contexts)} +
@@ -476,6 +479,31 @@ function formatDate(dateString) { return date.toLocaleString(); } +function formatScopeLockStatus(scopeLocked, lockedContexts) { + if (scopeLocked === null || scopeLocked === undefined) { + return 'N/A'; + } + if (scopeLocked === true) { + const groups = window.userGroups || []; + const publicWorkspaces = window.userVisiblePublicWorkspaces || []; + const groupMap = {}; + groups.forEach(g => { groupMap[g.id] = g.name; }); + const pubMap = {}; + publicWorkspaces.forEach(ws => { pubMap[ws.id] = ws.name; }); + + const names = (lockedContexts || []).map(ctx => { + if (ctx.scope === 'personal') return 'Personal'; + if (ctx.scope === 'group') return groupMap[ctx.id] || ctx.id; + if (ctx.scope === 'public') return pubMap[ctx.id] || ctx.id; + return ctx.scope; + }); + return 'Locked' + + (names.length > 0 ? '
' + names.join(', ') + '' : ''); + } + // false — unlocked + return 'Unlocked'; +} + function formatClassifications(classifications) { if (!classifications || classifications.length === 0) { return 'None'; diff --git a/application/single_app/static/js/chat/chat-conversations.js b/application/single_app/static/js/chat/chat-conversations.js index 9eb3e61f..221b5aa5 100644 --- a/application/single_app/static/js/chat/chat-conversations.js +++ b/application/single_app/static/js/chat/chat-conversations.js @@ -5,6 +5,7 @@ import { loadMessages } from "./chat-messages.js"; import { isColorLight, toBoolean } from "./chat-utils.js"; import { loadSidebarConversations, setActiveConversation as setSidebarActiveConversation } from "./chat-sidebar-conversations.js"; import { toggleConversationInfoButton } from "./chat-conversation-info-button.js"; +import { restoreScopeLockState, resetScopeLock } from "./chat-documents.js"; const newConversationBtn = document.getElementById("new-conversation-btn"); const deleteSelectedBtn = document.getElementById("delete-selected-btn"); @@ -891,6 +892,11 @@ export async function selectConversation(conversationId) { console.log(`selectConversation: No context - model-only conversation (no badges)`); } } + + // Restore scope lock state from metadata + const metaScopeLocked = metadata.scope_locked !== undefined ? metadata.scope_locked : null; + const metaLockedContexts = metadata.locked_contexts || []; + restoreScopeLockState(metaScopeLocked, metaLockedContexts); } } catch (error) { console.warn('Failed to fetch conversation metadata:', error); @@ -1062,6 +1068,8 @@ export async function createNewConversation(callback) { } currentConversationId = data.conversation_id; + // Reset scope lock for new conversation + resetScopeLock(); // Add to list (pass empty classifications for new convo) addConversationToList(data.conversation_id, data.title /* Use title from API if provided */, []); @@ -1074,6 +1082,10 @@ export async function createNewConversation(callback) { if (titleEl) { titleEl.textContent = data.title || "New Conversation"; } + // Clear classification/tag badges from previous conversation + if (currentConversationClassificationsEl) { + currentConversationClassificationsEl.innerHTML = ""; + } updateConversationUrl(data.conversation_id); console.log('[createNewConversation] Created conversation without reload:', data.conversation_id); diff --git a/application/single_app/static/js/chat/chat-documents.js b/application/single_app/static/js/chat/chat-documents.js index 174a7c7d..44596872 100644 --- a/application/single_app/static/js/chat/chat-documents.js +++ b/application/single_app/static/js/chat/chat-documents.js @@ -1,7 +1,6 @@ // chat-documents.js import { showToast } from "./chat-toast.js"; -import { toBoolean } from "./chat-utils.js"; export const docScopeSelect = document.getElementById("doc-scope-select"); const searchDocumentsBtn = document.getElementById("search-documents-btn"); @@ -14,56 +13,532 @@ const docDropdownItems = document.getElementById("document-dropdown-items"); const docDropdownMenu = document.getElementById("document-dropdown-menu"); const docSearchInput = document.getElementById("document-search-input"); -// Classification elements -const classificationContainer = document.querySelector(".classification-container"); // Main container div -const classificationSelectInput = document.getElementById("classification-select"); // The input field (now dual purpose) -const classificationMultiselectDropdown = document.getElementById("classification-multiselect-dropdown"); // Wrapper for button+menu -const classificationDropdownBtn = document.getElementById("classification-dropdown-btn"); -const classificationDropdownMenu = document.getElementById("classification-dropdown-menu"); - -// --- Get Classification Categories --- -// Ensure classification_categories is correctly parsed and available -// It should be an array of objects like [{label: 'Confidential', color: '#ff0000'}, ...] -// If it's just a comma-separated string from settings, parse it first. -let classificationCategories = []; -try { - // Use the structure already provided in base.html - classificationCategories = window.classification_categories || []; - if (typeof classificationCategories === 'string') { - // If it was a simple string "cat1,cat2", convert to objects - classificationCategories = classificationCategories.split(',') - .map(cat => cat.trim()) - .filter(cat => cat) // Remove empty strings - .map(label => ({ label: label, color: '#6c757d' })); // Assign default color if only labels provided - } -} catch (e) { - console.error("Error parsing classification categories:", e); - classificationCategories = []; -} -// ---------------------------------- +// Tags filter elements +const chatTagsFilter = document.getElementById("chat-tags-filter"); +const tagsDropdown = document.getElementById("tags-dropdown"); +const tagsDropdownButton = document.getElementById("tags-dropdown-button"); +const tagsDropdownItems = document.getElementById("tags-dropdown-items"); + +// Scope dropdown elements +const scopeDropdownButton = document.getElementById("scope-dropdown-button"); +const scopeDropdownItems = document.getElementById("scope-dropdown-items"); +const scopeDropdownMenu = document.getElementById("scope-dropdown-menu"); // We'll store personalDocs/groupDocs/publicDocs in memory once loaded: export let personalDocs = []; export let groupDocs = []; export let publicDocs = []; -let activeGroupName = ""; -let activePublicWorkspaceName = ""; -let publicWorkspaceIdToName = {}; -let visiblePublicWorkspaceIds = []; // Store IDs of public workspaces visible to the user + +// Items removed from the DOM by tag filtering (stored so they can be re-added) +// Each entry: { element, nextSibling } +let tagFilteredOutItems = []; + +// Scope lock state +let scopeLocked = null; // null = auto-lockable, true = locked, false = user-unlocked +let lockedContexts = []; // Array of {scope, id} identifying locked workspaces + +// Build name maps from server-provided data (fixes activeGroupName bug) +const groupIdToName = {}; +(window.userGroups || []).forEach(g => { groupIdToName[g.id] = g.name; }); + +const publicWorkspaceIdToName = {}; +(window.userVisiblePublicWorkspaces || []).forEach(ws => { publicWorkspaceIdToName[ws.id] = ws.name; }); + +// Multi-scope selection state +let selectedPersonal = true; +let selectedGroupIds = (window.userGroups || []).map(g => g.id); +let selectedPublicWorkspaceIds = (window.userVisiblePublicWorkspaces || []).map(ws => ws.id); + +/* --------------------------------------------------------------------------- + Get Effective Scopes — used by chat-messages.js and internally +--------------------------------------------------------------------------- */ +export function getEffectiveScopes() { + return { + personal: selectedPersonal, + groupIds: [...selectedGroupIds], + publicWorkspaceIds: [...selectedPublicWorkspaceIds], + }; +} + +/* --------------------------------------------------------------------------- + Scope Lock — exported functions +--------------------------------------------------------------------------- */ + +/** Returns current scope lock state: null (auto-lockable), true (locked), false (user-unlocked). */ +export function isScopeLocked() { + return scopeLocked; +} + +/** + * Apply scope lock from metadata after a response. + * Called after AI response when backend sets scope_locked=true. + */ +export function applyScopeLock(contexts, lockState) { + if (lockState !== true) return; + scopeLocked = true; + lockedContexts = contexts || []; + rebuildScopeDropdownWithLock(); + updateHeaderLockIcon(); +} + +/** + * Toggle scope lock via API call. Can both lock and unlock. + * @param {string} conversationId + * @param {boolean} newState - true = lock, false = unlock + * @returns {Promise} + */ +export async function toggleScopeLock(conversationId, newState) { + if (!conversationId) return; + + const response = await fetch(`/api/conversations/${conversationId}/scope_lock`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ scope_locked: newState }) + }); + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.error || 'Failed to toggle scope lock'); + } + + const result = await response.json(); + scopeLocked = newState; + // lockedContexts preserved from API response (never cleared) + lockedContexts = result.locked_contexts || lockedContexts; + + if (newState === true) { + // Re-locking: narrow scope to locked workspaces, rebuild with lock + selectedPersonal = lockedContexts.some(c => c.scope === 'personal'); + selectedGroupIds = lockedContexts.filter(c => c.scope === 'group').map(c => c.id); + selectedPublicWorkspaceIds = lockedContexts.filter(c => c.scope === 'public').map(c => c.id); + rebuildScopeDropdownWithLock(); + } else { + // Unlocking: open all scopes, rebuild normally + const groups = window.userGroups || []; + const publicWorkspaces = window.userVisiblePublicWorkspaces || []; + selectedPersonal = true; + selectedGroupIds = groups.map(g => g.id); + selectedPublicWorkspaceIds = publicWorkspaces.map(ws => ws.id); + buildScopeDropdown(); + updateScopeLockIcon(); + } + + updateHeaderLockIcon(); + + // Reload docs for the new scope + loadAllDocs().then(() => { loadTagsForScope(); }); +} + +/** + * Restore scope lock state when switching conversations. + * Called from selectConversation() in chat-conversations.js. + */ +export function restoreScopeLockState(lockState, contexts) { + scopeLocked = lockState; + lockedContexts = contexts || []; + + if (scopeLocked === true && lockedContexts.length > 0) { + // Set scope selection to match locked contexts + selectedPersonal = lockedContexts.some(c => c.scope === 'personal'); + selectedGroupIds = lockedContexts.filter(c => c.scope === 'group').map(c => c.id); + selectedPublicWorkspaceIds = lockedContexts.filter(c => c.scope === 'public').map(c => c.id); + + rebuildScopeDropdownWithLock(); + // Reload docs for the locked scope + loadAllDocs().then(() => { loadTagsForScope(); }); + } else { + // Not locked (null or false) — rebuild dropdown normally + buildScopeDropdown(); + updateScopeLockIcon(); + } + + updateHeaderLockIcon(); +} + +/** + * Reset scope lock for a new conversation. + * Resets to "All" with no lock. + */ +export function resetScopeLock() { + scopeLocked = null; + lockedContexts = []; + + const groups = window.userGroups || []; + const publicWorkspaces = window.userVisiblePublicWorkspaces || []; + selectedPersonal = true; + selectedGroupIds = groups.map(g => g.id); + selectedPublicWorkspaceIds = publicWorkspaces.map(ws => ws.id); + + buildScopeDropdown(); + updateScopeLockIcon(); + updateHeaderLockIcon(); + + // Reload documents for the full "All" scope + loadAllDocs().then(() => { loadTagsForScope(); }); +} + +/* --------------------------------------------------------------------------- + Set scope from legacy URL parameter values (personal/group/public/all) +--------------------------------------------------------------------------- */ +export function setScopeFromUrlParam(scopeString, options = {}) { + const groups = window.userGroups || []; + const publicWorkspaces = window.userVisiblePublicWorkspaces || []; + + switch (scopeString) { + case "personal": + selectedPersonal = true; + selectedGroupIds = []; + selectedPublicWorkspaceIds = []; + break; + case "group": + selectedPersonal = false; + selectedGroupIds = options.groupId ? [options.groupId] : groups.map(g => g.id); + selectedPublicWorkspaceIds = []; + break; + case "public": + selectedPersonal = false; + selectedGroupIds = []; + selectedPublicWorkspaceIds = options.workspaceId ? [options.workspaceId] : publicWorkspaces.map(ws => ws.id); + break; + default: // "all" + selectedPersonal = true; + selectedGroupIds = groups.map(g => g.id); + selectedPublicWorkspaceIds = publicWorkspaces.map(ws => ws.id); + break; + } + + buildScopeDropdown(); +} + +/* --------------------------------------------------------------------------- + Build the Scope Dropdown (called once on init) +--------------------------------------------------------------------------- */ +function buildScopeDropdown() { + if (!scopeDropdownItems) return; + + scopeDropdownItems.innerHTML = ""; + + const groups = window.userGroups || []; + const publicWorkspaces = window.userVisiblePublicWorkspaces || []; + + // "Select All" / "Clear All" toggle + const allItem = document.createElement("button"); + allItem.type = "button"; + allItem.classList.add("dropdown-item", "d-flex", "align-items-center", "fw-bold"); + allItem.setAttribute("data-scope-action", "toggle-all"); + allItem.style.display = "flex"; + allItem.style.width = "100%"; + allItem.style.textAlign = "left"; + const allCb = document.createElement("input"); + allCb.type = "checkbox"; + allCb.classList.add("form-check-input", "me-2", "scope-checkbox-all"); + allCb.style.pointerEvents = "none"; + allCb.style.minWidth = "16px"; + allCb.checked = true; + // Compute initial "All" state from module variables + const totalPossibleInit = 1 + groups.length + publicWorkspaces.length; + const totalSelectedInit = (selectedPersonal ? 1 : 0) + selectedGroupIds.length + selectedPublicWorkspaceIds.length; + allCb.checked = (totalSelectedInit === totalPossibleInit); + allCb.indeterminate = (totalSelectedInit > 0 && totalSelectedInit < totalPossibleInit); + const allLabel = document.createElement("span"); + allLabel.textContent = "All"; + allItem.appendChild(allCb); + allItem.appendChild(allLabel); + scopeDropdownItems.appendChild(allItem); + + // Divider + const divider1 = document.createElement("div"); + divider1.classList.add("dropdown-divider"); + scopeDropdownItems.appendChild(divider1); + + // Personal item + const personalItem = createScopeItem("personal", "Personal", selectedPersonal); + scopeDropdownItems.appendChild(personalItem); + + // Groups section + if (groups.length > 0) { + const groupHeader = document.createElement("div"); + groupHeader.classList.add("dropdown-header", "small", "text-muted", "px-2", "pt-2", "pb-1"); + groupHeader.textContent = "Groups"; + scopeDropdownItems.appendChild(groupHeader); + + groups.forEach(g => { + const item = createScopeItem(`group:${g.id}`, g.name, selectedGroupIds.includes(g.id)); + scopeDropdownItems.appendChild(item); + }); + } + + // Public Workspaces section + if (publicWorkspaces.length > 0) { + const pubHeader = document.createElement("div"); + pubHeader.classList.add("dropdown-header", "small", "text-muted", "px-2", "pt-2", "pb-1"); + pubHeader.textContent = "Public Workspaces"; + scopeDropdownItems.appendChild(pubHeader); + + publicWorkspaces.forEach(ws => { + const item = createScopeItem(`public:${ws.id}`, ws.name, selectedPublicWorkspaceIds.includes(ws.id)); + scopeDropdownItems.appendChild(item); + }); + } + + syncScopeButtonText(); +} + +/* --------------------------------------------------------------------------- + Rebuild Scope Dropdown with Lock Indicators +--------------------------------------------------------------------------- */ +function rebuildScopeDropdownWithLock() { + if (scopeLocked !== true || !scopeDropdownItems) { + buildScopeDropdown(); + updateScopeLockIcon(); + return; + } + + // First build the dropdown normally + buildScopeDropdown(); + + // Build a set of locked scope keys for fast lookup (e.g. "personal", "group:abc", "public:xyz") + const lockedKeys = new Set(); + for (const ctx of lockedContexts) { + if (ctx.scope === 'personal') { + lockedKeys.add('personal'); + } else if (ctx.scope === 'group') { + lockedKeys.add(`group:${ctx.id}`); + } else if (ctx.scope === 'public') { + lockedKeys.add(`public:${ctx.id}`); + } + } + + // Force scope selection to match locked contexts + selectedPersonal = lockedKeys.has('personal'); + selectedGroupIds = lockedContexts.filter(c => c.scope === 'group').map(c => c.id); + selectedPublicWorkspaceIds = lockedContexts.filter(c => c.scope === 'public').map(c => c.id); + + // Iterate all scope items and apply lock/disable styling + scopeDropdownItems.querySelectorAll('.dropdown-item[data-scope-value]').forEach(item => { + const val = item.getAttribute('data-scope-value'); + const cb = item.querySelector('.scope-checkbox'); + const isLocked = lockedKeys.has(val); + + if (isLocked) { + // This workspace is locked — mark as active and locked + if (cb) cb.checked = true; + item.classList.add('scope-locked-item'); + item.classList.remove('scope-disabled-item'); + item.style.pointerEvents = 'none'; + + // Add lock icon if not already present + if (!item.querySelector('.bi-lock-fill')) { + const lockIcon = document.createElement('i'); + lockIcon.classList.add('bi', 'bi-lock-fill', 'ms-auto', 'text-warning', 'scope-lock-badge'); + item.appendChild(lockIcon); + } + } else { + // This workspace is not locked — gray it out + if (cb) cb.checked = false; + item.classList.add('scope-disabled-item'); + item.classList.remove('scope-locked-item'); + item.style.pointerEvents = 'none'; + item.title = 'Scope locked to other workspaces'; + } + }); + + // Disable the "All" toggle + const allToggle = scopeDropdownItems.querySelector('[data-scope-action="toggle-all"]'); + if (allToggle) { + allToggle.classList.add('scope-disabled-item'); + allToggle.style.pointerEvents = 'none'; + const allCb = allToggle.querySelector('.scope-checkbox-all'); + if (allCb) { + allCb.checked = false; + allCb.indeterminate = true; + } + } + + syncScopeButtonText(); + updateScopeLockIcon(); +} + +/* --------------------------------------------------------------------------- + Update Scope Lock Icon Visibility and Tooltip +--------------------------------------------------------------------------- */ +function updateScopeLockIcon() { + const indicator = document.getElementById('scope-lock-indicator'); + if (!indicator) return; + + if (scopeLocked === true) { + indicator.style.display = 'inline'; + + // Build tooltip showing locked workspace names + const names = []; + for (const ctx of lockedContexts) { + if (ctx.scope === 'personal') { + names.push('Personal'); + } else if (ctx.scope === 'group') { + const name = groupIdToName[ctx.id] || ctx.id; + names.push(`Group: ${name}`); + } else if (ctx.scope === 'public') { + const name = publicWorkspaceIdToName[ctx.id] || ctx.id; + names.push(`Public: ${name}`); + } + } + indicator.title = `Scope locked to: ${names.join(', ')}. Click to manage.`; + } else { + indicator.style.display = 'none'; + } + + updateHeaderLockIcon(); +} + +/* --------------------------------------------------------------------------- + Update Header Lock Icon (inline with classification badges) +--------------------------------------------------------------------------- */ +function updateHeaderLockIcon() { + const headerBtn = document.getElementById('header-scope-lock-btn'); + if (!headerBtn) return; + + if (scopeLocked === null || scopeLocked === undefined) { + // No data used yet — hide header lock + headerBtn.style.display = 'none'; + } else if (scopeLocked === true) { + // Locked + headerBtn.style.display = 'inline'; + headerBtn.className = 'text-warning'; + headerBtn.innerHTML = ''; + headerBtn.title = 'Scope locked — click to manage'; + } else { + // Unlocked (false) + headerBtn.style.display = 'inline'; + headerBtn.className = 'text-muted'; + headerBtn.innerHTML = ''; + headerBtn.title = 'Scope unlocked — click to re-lock'; + } +} + +function createScopeItem(value, label, checked) { + const item = document.createElement("button"); + item.type = "button"; + item.classList.add("dropdown-item", "d-flex", "align-items-center"); + item.setAttribute("data-scope-value", value); + item.style.display = "flex"; + item.style.width = "100%"; + item.style.textAlign = "left"; + + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.classList.add("form-check-input", "me-2", "scope-checkbox"); + cb.style.pointerEvents = "none"; + cb.style.minWidth = "16px"; + cb.checked = checked; + + const span = document.createElement("span"); + span.textContent = label; + span.style.overflow = "hidden"; + span.style.textOverflow = "ellipsis"; + span.style.whiteSpace = "nowrap"; + + item.appendChild(cb); + item.appendChild(span); + return item; +} + +/* --------------------------------------------------------------------------- + Sync scope state from checkboxes → module variables +--------------------------------------------------------------------------- */ +function syncScopeStateFromCheckboxes() { + if (!scopeDropdownItems) return; + + selectedPersonal = false; + selectedGroupIds = []; + selectedPublicWorkspaceIds = []; + + scopeDropdownItems.querySelectorAll(".dropdown-item[data-scope-value]").forEach(item => { + const cb = item.querySelector(".scope-checkbox"); + if (!cb || !cb.checked) return; + + const val = item.getAttribute("data-scope-value"); + if (val === "personal") { + selectedPersonal = true; + } else if (val.startsWith("group:")) { + selectedGroupIds.push(val.substring(6)); + } else if (val.startsWith("public:")) { + selectedPublicWorkspaceIds.push(val.substring(7)); + } + }); + + // Update the "All" checkbox state + const allCb = scopeDropdownItems.querySelector(".scope-checkbox-all"); + if (allCb) { + const totalItems = scopeDropdownItems.querySelectorAll(".scope-checkbox").length; + const checkedItems = scopeDropdownItems.querySelectorAll(".scope-checkbox:checked").length; + allCb.checked = (totalItems === checkedItems); + allCb.indeterminate = (checkedItems > 0 && checkedItems < totalItems); + } +} + +/* --------------------------------------------------------------------------- + Sync scope button text +--------------------------------------------------------------------------- */ +function syncScopeButtonText() { + if (!scopeDropdownButton) return; + const textEl = scopeDropdownButton.querySelector(".selected-scope-text"); + if (!textEl) return; + + const groups = window.userGroups || []; + const publicWorkspaces = window.userVisiblePublicWorkspaces || []; + + const totalPossible = 1 + groups.length + publicWorkspaces.length; // personal + groups + public + const totalSelected = (selectedPersonal ? 1 : 0) + selectedGroupIds.length + selectedPublicWorkspaceIds.length; + + if (totalSelected === 0) { + textEl.textContent = "None selected"; + } else if (totalSelected === totalPossible) { + textEl.textContent = "All"; + } else if (selectedPersonal && selectedGroupIds.length === 0 && selectedPublicWorkspaceIds.length === 0) { + textEl.textContent = "Personal"; + } else { + const parts = []; + if (selectedPersonal) parts.push("Personal"); + if (selectedGroupIds.length === 1) { + parts.push(groupIdToName[selectedGroupIds[0]] || "1 group"); + } else if (selectedGroupIds.length > 1) { + parts.push(`${selectedGroupIds.length} groups`); + } + if (selectedPublicWorkspaceIds.length === 1) { + parts.push(publicWorkspaceIdToName[selectedPublicWorkspaceIds[0]] || "1 workspace"); + } else if (selectedPublicWorkspaceIds.length > 1) { + parts.push(`${selectedPublicWorkspaceIds.length} workspaces`); + } + textEl.textContent = parts.join(", "); + } +} + +/* --------------------------------------------------------------------------- + Handle scope change — reload docs and tags +--------------------------------------------------------------------------- */ +function onScopeChanged() { + syncScopeStateFromCheckboxes(); + syncScopeButtonText(); + // Reload docs and tags for the new scope + loadAllDocs().then(() => { + loadTagsForScope(); + }); +} /* --------------------------------------------------------------------------- Populate the Document Dropdown Based on the Scope --------------------------------------------------------------------------- */ export function populateDocumentSelectScope() { - if (!docScopeSelect || !docSelectEl) return; + if (!docSelectEl) return; + + // Discard any items stored by the tag filter (they're about to be rebuilt) + tagFilteredOutItems = []; - console.log("Populating document dropdown with scope:", docScopeSelect.value); - console.log("Personal docs:", personalDocs.length); - console.log("Group docs:", groupDocs.length); + const scopes = getEffectiveScopes(); - const previousValue = docSelectEl.value; // Store previous selection if needed docSelectEl.innerHTML = ""; // Clear existing options - + // Clear the dropdown items container if (docDropdownItems) { docDropdownItems.innerHTML = ""; @@ -74,7 +549,7 @@ export function populateDocumentSelectScope() { allOpt.value = ""; // Use empty string for "All" allOpt.textContent = "All Documents"; // Consistent label docSelectEl.appendChild(allOpt); - + // Add "All Documents" item to custom dropdown if (docDropdownItems) { const allItem = document.createElement("button"); @@ -88,44 +563,43 @@ export function populateDocumentSelectScope() { docDropdownItems.appendChild(allItem); } - const scopeVal = docScopeSelect.value || "all"; - let finalDocs = []; - if (scopeVal === "all") { + + // Add personal docs if personal scope is selected + if (scopes.personal) { const pDocs = personalDocs.map((d) => ({ id: d.id, label: `[Personal] ${d.title || d.file_name}`, - classification: d.document_classification, // Store classification + tags: d.tags || [], + classification: d.document_classification || '', })); + finalDocs = finalDocs.concat(pDocs); + } + + // Add group docs — label each with its group name + if (scopes.groupIds.length > 0) { const gDocs = groupDocs.map((d) => ({ id: d.id, - label: `[Group: ${activeGroupName}] ${d.title || d.file_name}`, - classification: d.document_classification, // Store classification - })); - const pubDocs = publicDocs.map((d) => ({ - id: d.id, - label: `[Public: ${publicWorkspaceIdToName[d.public_workspace_id] || "Unknown"}] ${d.title || d.file_name}`, - classification: d.document_classification, // Store classification - })); - finalDocs = pDocs.concat(gDocs).concat(pubDocs); - } else if (scopeVal === "personal") { - finalDocs = personalDocs.map((d) => ({ - id: d.id, - label: `[Personal] ${d.title || d.file_name}`, - classification: d.document_classification, - })); - } else if (scopeVal === "group") { - finalDocs = groupDocs.map((d) => ({ - id: d.id, - label: `[Group: ${activeGroupName}] ${d.title || d.file_name}`, - classification: d.document_classification, - })); - } else if (scopeVal === "public") { - finalDocs = publicDocs.map((d) => ({ - id: d.id, - label: `[Public: ${publicWorkspaceIdToName[d.public_workspace_id] || "Unknown"}] ${d.title || d.file_name}`, - classification: d.document_classification, + label: `[Group: ${groupIdToName[d.group_id] || "Unknown"}] ${d.title || d.file_name}`, + tags: d.tags || [], + classification: d.document_classification || '', })); + finalDocs = finalDocs.concat(gDocs); + } + + // Add public docs — label each with its workspace name + if (scopes.publicWorkspaceIds.length > 0) { + // Filter publicDocs to only those in selected workspaces + const selectedWsSet = new Set(scopes.publicWorkspaceIds); + const pubDocs = publicDocs + .filter(d => selectedWsSet.has(d.public_workspace_id)) + .map((d) => ({ + id: d.id, + label: `[Public: ${publicWorkspaceIdToName[d.public_workspace_id] || "Unknown"}] ${d.title || d.file_name}`, + tags: d.tags || [], + classification: d.document_classification || '', + })); + finalDocs = finalDocs.concat(pubDocs); } // Add document options to the hidden select and populate the custom dropdown @@ -134,19 +608,37 @@ export function populateDocumentSelectScope() { const opt = document.createElement("option"); opt.value = doc.id; opt.textContent = doc.label; - opt.dataset.classification = doc.classification || ""; // Store classification or empty string + opt.dataset.tags = JSON.stringify(doc.tags || []); + opt.dataset.classification = doc.classification || ''; docSelectEl.appendChild(opt); - + // Add to custom dropdown if (docDropdownItems) { const dropdownItem = document.createElement("button"); dropdownItem.type = "button"; - dropdownItem.classList.add("dropdown-item"); + dropdownItem.classList.add("dropdown-item", "d-flex", "align-items-center"); dropdownItem.setAttribute("data-document-id", doc.id); - dropdownItem.textContent = doc.label; - dropdownItem.style.display = "block"; + dropdownItem.setAttribute("title", doc.label); + dropdownItem.dataset.tags = JSON.stringify(doc.tags || []); + dropdownItem.dataset.classification = doc.classification || ''; + dropdownItem.style.display = "flex"; dropdownItem.style.width = "100%"; dropdownItem.style.textAlign = "left"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.classList.add("form-check-input", "me-2", "doc-checkbox"); + checkbox.style.pointerEvents = "none"; // Click handled by button + checkbox.style.minWidth = "16px"; + + const label = document.createElement("span"); + label.textContent = doc.label; + label.style.overflow = "hidden"; + label.style.textOverflow = "ellipsis"; + label.style.whiteSpace = "nowrap"; + + dropdownItem.appendChild(checkbox); + dropdownItem.appendChild(label); docDropdownItems.appendChild(dropdownItem); } }); @@ -155,7 +647,7 @@ export function populateDocumentSelectScope() { if (docSearchInput && docDropdownItems) { const documentsCount = finalDocs.length; const searchContainer = docSearchInput.closest('.document-search-container'); - + if (searchContainer) { // Always show search if there are more than 0 documents if (documentsCount > 0) { @@ -166,43 +658,21 @@ export function populateDocumentSelectScope() { } } - // Try to restore previous selection if it still exists, otherwise default to "All" - if (finalDocs.some(doc => doc.id === previousValue)) { - docSelectEl.value = previousValue; - if (docDropdownButton) { - const selectedDoc = finalDocs.find(doc => doc.id === previousValue); - if (selectedDoc) { - docDropdownButton.querySelector(".selected-document-text").textContent = selectedDoc.label; - } - - // Update active state in dropdown - if (docDropdownItems) { - document.querySelectorAll("#document-dropdown-items .dropdown-item").forEach(item => { - item.classList.remove("active"); - if (item.getAttribute("data-document-id") === previousValue) { - item.classList.add("active"); - } - }); - } - } - } else { - docSelectEl.value = ""; // Default to "All Documents" - if (docDropdownButton) { - docDropdownButton.querySelector(".selected-document-text").textContent = "All Documents"; - - // Set "All Documents" as active - if (docDropdownItems) { - document.querySelectorAll("#document-dropdown-items .dropdown-item").forEach(item => { - item.classList.remove("active"); - if (item.getAttribute("data-document-id") === "") { - item.classList.add("active"); - } - }); - } + // Reset to "All Documents" (no specific documents selected) + // With multi-select, clear all selections + Array.from(docSelectEl.options).forEach(opt => { opt.selected = false; }); + if (docDropdownButton) { + docDropdownButton.querySelector(".selected-document-text").textContent = "All Documents"; + + // Clear all checkbox states + if (docDropdownItems) { + docDropdownItems.querySelectorAll(".doc-checkbox").forEach(cb => { + cb.checked = false; + }); } } - // IMPORTANT: Trigger the classification update after populating + // Trigger UI update after populating handleDocumentSelectChange(); } @@ -211,27 +681,23 @@ export function getDocumentMetadata(docId) { // Search personal docs first const personalMatch = personalDocs.find(doc => doc.id === docId || doc.document_id === docId); // Check common ID keys if (personalMatch) { - // console.log(`Metadata found in personalDocs for ${docId}`); return personalMatch; } // Then search group docs const groupMatch = groupDocs.find(doc => doc.id === docId || doc.document_id === docId); if (groupMatch) { - // console.log(`Metadata found in groupDocs for ${docId}`); return groupMatch; } // Finally search public docs const publicMatch = publicDocs.find(doc => doc.id === docId || doc.document_id === docId); if (publicMatch) { - // console.log(`Metadata found in publicDocs for ${docId}`); return publicMatch; } - // console.log(`Metadata NOT found for ${docId}`); return null; // Not found in any list } /* --------------------------------------------------------------------------- - Loading Documents (Keep existing loadPersonalDocs, loadGroupDocs, loadAllDocs) + Loading Documents --------------------------------------------------------------------------- */ export function loadPersonalDocs() { // Use a large page_size to load all documents at once, without pagination @@ -252,9 +718,15 @@ export function loadPersonalDocs() { }); } -export function loadGroupDocs() { - // Use a large page_size to load all documents at once, without pagination - return fetch("/api/group_documents?page_size=1000") +export function loadGroupDocs(groupIds) { + // Accept explicit group IDs list, fall back to selected scope + const ids = groupIds || selectedGroupIds || []; + if (ids.length === 0) { + groupDocs = []; + return Promise.resolve(); + } + const idsParam = ids.join(','); + return fetch(`/api/group_documents?group_ids=${encodeURIComponent(idsParam)}&page_size=1000`) .then((r) => { if (!r.ok) { // Handle 400 errors gracefully (e.g., no active group selected) @@ -290,113 +762,29 @@ export function loadPublicDocs() { if (data.error) { console.warn("Error fetching public workspace docs:", data.error); publicDocs = []; - activePublicWorkspaceName = ""; - publicWorkspaceIdToName = {}; return; } - // Fetch user settings to determine visible workspaces - return fetch("/api/user/settings") - .then((r) => r.json()) - .then((settingsData) => { - const userSettings = settingsData && settingsData.settings ? settingsData.settings : {}; - const publicDirectorySettings = userSettings.publicDirectorySettings || {}; - // Only include documents from visible public workspaces - publicDocs = (data.documents || []).filter( - (doc) => publicDirectorySettings[doc.public_workspace_id] === true - ); - // Now fetch the workspace list to build the ID->name mapping - return fetch("/api/public_workspaces/discover") - .then((r) => r.json()) - .then((workspaces) => { - publicWorkspaceIdToName = {}; - (workspaces || []).forEach(ws => { - publicWorkspaceIdToName[ws.id] = ws.name; - }); - // Determine if only one public workspace is visible - const visibleWorkspaceIds = Object.keys(publicDirectorySettings).filter( - id => publicDirectorySettings[id] === true - ); - visiblePublicWorkspaceIds = visibleWorkspaceIds; // Store for use in scope label updates - if (visibleWorkspaceIds.length === 1) { - activePublicWorkspaceName = publicWorkspaceIdToName[visibleWorkspaceIds[0]] || "Unknown"; - } else { - activePublicWorkspaceName = "All Public Workspaces"; - } - console.log( - `Loaded ${publicDocs.length} public workspace documents from user-visible public workspaces` - ); - }) - .catch((err) => { - // If workspace list can't be loaded, fallback to generic label - publicWorkspaceIdToName = {}; - activePublicWorkspaceName = "All Public Workspaces"; - console.warn("Could not load public workspace names:", err); - }); - }) - .catch((err) => { - // If user settings can't be loaded, default to showing all documents - console.warn("Could not load user settings, showing all public workspace documents:", err); - publicDocs = data.documents || []; - publicWorkspaceIdToName = {}; - activePublicWorkspaceName = "All Public Workspaces"; - visiblePublicWorkspaceIds = []; // Reset visible workspace IDs - }); + // Filter to only docs from currently selected public workspaces + const selectedWsSet = new Set(selectedPublicWorkspaceIds); + publicDocs = (data.documents || []).filter( + (doc) => selectedWsSet.has(doc.public_workspace_id) + ); + console.log( + `Loaded ${publicDocs.length} public workspace documents from selected public workspaces` + ); }) .catch((err) => { console.error("Error loading public workspace docs:", err); publicDocs = []; - publicWorkspaceIdToName = {}; - activePublicWorkspaceName = ""; - visiblePublicWorkspaceIds = []; // Reset visible workspace IDs }); } -/** - * Updates the scope option labels to show dynamic workspace names - */ -function updateScopeLabels() { - if (!docScopeSelect) return; - - // Update public option text based on visible workspaces - const publicOption = docScopeSelect.querySelector('option[value="public"]'); - if (publicOption) { - // Get names of visible public workspaces - const visibleWorkspaceNames = visiblePublicWorkspaceIds - .map(id => publicWorkspaceIdToName[id]) - .filter(name => name && name !== "Unknown"); - - let publicLabel = "Public"; - - if (visibleWorkspaceNames.length === 0) { - publicLabel = "Public"; - } else if (visibleWorkspaceNames.length === 1) { - publicLabel = `Public: ${visibleWorkspaceNames[0]}`; - } else if (visibleWorkspaceNames.length <= 3) { - publicLabel = `Public: ${visibleWorkspaceNames.join(", ")}`; - } else { - publicLabel = `Public: ${visibleWorkspaceNames.slice(0, 3).join(", ")}, 3+`; - } - - publicOption.textContent = publicLabel; - console.log(`Updated public scope label to: ${publicLabel}`); - } -} - export function loadAllDocs() { const hasDocControls = searchDocumentsBtn || docScopeSelect || docSelectEl; - - // Use the toBoolean helper for consistent checking - const classificationEnabled = toBoolean(window.enable_document_classification); if (!hasDocControls) { return Promise.resolve(); } - // Only hide the classification container if feature disabled - if (classificationContainer && !classificationEnabled) { - classificationContainer.style.display = 'none'; - } - // Ensure container is visible if feature is enabled - if (classificationContainer && classificationEnabled) classificationContainer.style.display = ''; // Initialize custom document dropdown if available if (docDropdownButton && docDropdownItems) { @@ -406,142 +794,513 @@ export function loadAllDocs() { // Initially show the search field as it will be useful for filtering documentSearchContainer.classList.remove('d-none'); } - - console.log("Setting up document dropdown event listeners..."); - - // Make sure dropdown shows when button is clicked - docDropdownButton.addEventListener('click', function(e) { - console.log("Dropdown button clicked"); - // Initialize dropdown after a short delay to ensure DOM is ready - setTimeout(() => { - initializeDocumentDropdown(); - }, 100); - }); - - // Additionally listen for the bootstrap shown.bs.dropdown event - const dropdownEl = document.querySelector('#document-dropdown'); - if (dropdownEl) { - dropdownEl.addEventListener('shown.bs.dropdown', function(e) { - console.log("Dropdown shown event fired"); - // Focus the search input for immediate searching - if (docSearchInput) { - setTimeout(() => { - docSearchInput.focus(); - initializeDocumentDropdown(); - }, 100); - } else { - initializeDocumentDropdown(); - } - }); - - // Handle dropdown hide event to clear search and reset item visibility - dropdownEl.addEventListener('hide.bs.dropdown', function(e) { - console.log("Dropdown hide event fired"); - if (docSearchInput) { - docSearchInput.value = ''; - // Reset all items to visible - if (docDropdownItems) { - const items = docDropdownItems.querySelectorAll('.dropdown-item'); - items.forEach(item => { - item.style.display = 'block'; - item.removeAttribute('data-filtered'); - }); - - // Remove any "no matches" message - const noMatchesEl = docDropdownItems.querySelector('.no-matches'); - if (noMatchesEl) { - noMatchesEl.remove(); - } - } - } - }); - } else { - console.error("Document dropdown element not found"); - } + + } + + const scopes = getEffectiveScopes(); + + // Build parallel load promises based on selected scopes + const promises = []; + if (scopes.personal) { + promises.push(loadPersonalDocs()); + } else { + personalDocs = []; + } + if (scopes.groupIds.length > 0) { + promises.push(loadGroupDocs(scopes.groupIds)); + } else { + groupDocs = []; + } + if (scopes.publicWorkspaceIds.length > 0) { + promises.push(loadPublicDocs()); + } else { + publicDocs = []; } - return Promise.all([loadPersonalDocs(), loadGroupDocs(), loadPublicDocs()]) + return Promise.all(promises) .then(() => { - console.log("All documents loaded. Personal:", personalDocs.length, "Group:", groupDocs.length, "Public:", publicDocs.length); - // Update scope labels after loading data - updateScopeLabels(); - // After loading, populate the select and set initial classification state + // After loading, populate the select and set initial state populateDocumentSelectScope(); - // handleDocumentSelectChange(); // Called within populateDocumentSelectScope now }) .catch(err => { console.error("Error loading documents:", err); }); } -// Function to ensure dropdown menu is properly displayed +// Function to adjust dropdown sizing when shown function initializeDocumentDropdown() { if (!docDropdownMenu) return; - - console.log("Initializing dropdown display"); - - // Make sure dropdown menu is visible and has proper z-index - docDropdownMenu.classList.add('show'); - docDropdownMenu.style.zIndex = "1050"; // Ensure it's above other elements - - // Reset visibility of items if no search term is active - if (!docSearchInput || !docSearchInput.value.trim()) { - console.log("Resetting item visibility"); - const items = docDropdownItems.querySelectorAll('.dropdown-item'); - items.forEach(item => { - // Only reset items that aren't already filtered by an active search - if (!item.hasAttribute('data-filtered')) { - item.style.display = 'block'; - } - }); - } - - // If there's a search term in the input, apply filtering immediately - if (docSearchInput && docSearchInput.value.trim()) { - console.log("Search term detected, triggering filter"); - // Create and dispatch both events for maximum browser compatibility - docSearchInput.dispatchEvent(new Event('input', { bubbles: true })); - docSearchInput.dispatchEvent(new Event('keyup', { bubbles: true })); - } - - // Set a fixed narrower width for the dropdown - let maxWidth = 400; // Updated to 400px width - - // Calculate parent container width (we want dropdown to fit inside right pane) + + // Clear any leftover search-filter inline styles on visible items + docDropdownItems.querySelectorAll('.dropdown-item').forEach(item => { + item.removeAttribute('data-filtered'); + item.style.display = ''; + }); + + // Re-apply tag filter (DOM removal approach — no CSS issues) + filterDocumentsBySelectedTags(); + + // Size the dropdown to fill its parent container const parentContainer = docDropdownButton.closest('.flex-grow-1'); - if (parentContainer) { - const parentWidth = parentContainer.offsetWidth; - // Use the smaller of our fixed width or 90% of parent width - maxWidth = Math.min(maxWidth, parentWidth * 0.9); - } - + const maxWidth = parentContainer ? parentContainer.offsetWidth : 400; + docDropdownMenu.style.maxWidth = `${maxWidth}px`; docDropdownMenu.style.width = `${maxWidth}px`; - + // Ensure dropdown stays within viewport bounds const menuRect = docDropdownMenu.getBoundingClientRect(); const viewportHeight = window.innerHeight; - - // If dropdown extends beyond viewport, adjust position or max-height + if (menuRect.bottom > viewportHeight) { - // Option 1: Adjust max-height to fit - const maxPossibleHeight = viewportHeight - menuRect.top - 10; // 10px buffer + const maxPossibleHeight = viewportHeight - menuRect.top - 10; docDropdownMenu.style.maxHeight = `${maxPossibleHeight}px`; - - // Also adjust the items container + if (docDropdownItems) { - // Account for search box height including its margin const searchContainer = docDropdownMenu.querySelector('.document-search-container'); const searchHeight = searchContainer ? searchContainer.offsetHeight : 40; docDropdownItems.style.maxHeight = `${maxPossibleHeight - searchHeight}px`; } } } +/* --------------------------------------------------------------------------- + Load Tags for Selected Scope +--------------------------------------------------------------------------- */ +export async function loadTagsForScope() { + if (!chatTagsFilter) return; + + // Clear existing options in both hidden select and custom dropdown + chatTagsFilter.innerHTML = ''; + if (tagsDropdownItems) tagsDropdownItems.innerHTML = ''; + + try { + const scopes = getEffectiveScopes(); + const fetchPromises = []; + + if (scopes.personal) { + fetchPromises.push(fetch('/api/documents/tags').then(r => r.json())); + } + if (scopes.groupIds.length > 0) { + const idsParam = scopes.groupIds.join(','); + fetchPromises.push(fetch(`/api/group_documents/tags?group_ids=${encodeURIComponent(idsParam)}`).then(r => r.json())); + } + if (scopes.publicWorkspaceIds.length > 0) { + const wsParam = scopes.publicWorkspaceIds.join(','); + fetchPromises.push(fetch(`/api/public_workspace_documents/tags?workspace_ids=${encodeURIComponent(wsParam)}`).then(r => r.json())); + } + + if (fetchPromises.length === 0) { + hideTagsDropdown(); + return; + } + + const results = await Promise.allSettled(fetchPromises); + + // Merge tags by name, summing counts + const tagMap = {}; + results.forEach(result => { + if (result.status === 'fulfilled' && result.value && result.value.tags) { + result.value.tags.forEach(tag => { + if (tagMap[tag.name]) { + tagMap[tag.name] += tag.count; + } else { + tagMap[tag.name] = tag.count; + } + }); + } + }); + + const allTags = Object.entries(tagMap).map(([name, count]) => ({ name, displayName: name, count, isClassification: false })); + allTags.sort((a, b) => a.name.localeCompare(b.name)); + + // Add classification categories if enabled + const classificationItems = []; + const classificationEnabled = (window.enable_document_classification === true + || String(window.enable_document_classification).toLowerCase() === 'true'); + if (classificationEnabled) { + const categories = window.classification_categories || []; + const scopesForCls = getEffectiveScopes(); + + // Gather all in-scope docs + const scopeDocs = []; + if (scopesForCls.personal) scopeDocs.push(...personalDocs); + if (scopesForCls.groupIds.length > 0) scopeDocs.push(...groupDocs); + if (scopesForCls.publicWorkspaceIds.length > 0) { + const wsSet = new Set(scopesForCls.publicWorkspaceIds); + scopeDocs.push(...publicDocs.filter(d => wsSet.has(d.public_workspace_id))); + } + + // Count classifications + const clsCounts = {}; + let unclassifiedCount = 0; + scopeDocs.forEach(doc => { + const cls = doc.document_classification; + if (!cls || cls === '' || cls.toLowerCase() === 'none') { + unclassifiedCount++; + } else { + clsCounts[cls] = (clsCounts[cls] || 0) + 1; + } + }); + + // Always show Unclassified entry + classificationItems.push({ name: '__unclassified__', displayName: 'Unclassified', count: unclassifiedCount, isClassification: true, color: '#6c757d' }); + // Always show all configured categories (even at 0 count) + categories.forEach(cat => { + const count = clsCounts[cat.label] || 0; + classificationItems.push({ name: cat.label, displayName: cat.label, count, isClassification: true, color: cat.color || '#6c757d' }); + }); + } + + const hasItems = allTags.length > 0 || classificationItems.length > 0; + + if (hasItems) { + showTagsDropdown(); + + // Populate hidden select with tags and classifications + allTags.forEach(tag => { + const option = document.createElement('option'); + option.value = tag.name; + option.textContent = `${tag.name} (${tag.count})`; + chatTagsFilter.appendChild(option); + }); + classificationItems.forEach(cls => { + const option = document.createElement('option'); + option.value = cls.name; + option.textContent = `${cls.displayName} (${cls.count})`; + chatTagsFilter.appendChild(option); + }); + + // Populate custom dropdown with checkboxes + if (tagsDropdownItems) { + // Add "Clear All" item + const allItem = document.createElement('button'); + allItem.type = 'button'; + allItem.classList.add('dropdown-item', 'text-muted', 'small'); + allItem.setAttribute('data-tag-value', ''); + allItem.textContent = 'Clear All'; + allItem.style.display = 'block'; + allItem.style.width = '100%'; + allItem.style.textAlign = 'left'; + tagsDropdownItems.appendChild(allItem); + + // Divider after Clear All + const divider1 = document.createElement('div'); + divider1.classList.add('dropdown-divider'); + tagsDropdownItems.appendChild(divider1); + + // Render regular tags + allTags.forEach(tag => { + const item = document.createElement('button'); + item.type = 'button'; + item.classList.add('dropdown-item', 'd-flex', 'align-items-center'); + item.setAttribute('data-tag-value', tag.name); + item.style.display = 'flex'; + item.style.width = '100%'; + item.style.textAlign = 'left'; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.classList.add('form-check-input', 'me-2', 'tag-checkbox'); + checkbox.style.pointerEvents = 'none'; + checkbox.style.minWidth = '16px'; + + const label = document.createElement('span'); + label.textContent = `${tag.name} (${tag.count})`; + + item.appendChild(checkbox); + item.appendChild(label); + tagsDropdownItems.appendChild(item); + }); + + // Render classification items with visual distinction + if (classificationItems.length > 0) { + // Divider before classifications + const divider2 = document.createElement('div'); + divider2.classList.add('dropdown-divider'); + tagsDropdownItems.appendChild(divider2); + + // Small header + const header = document.createElement('div'); + header.classList.add('dropdown-header', 'small', 'text-muted', 'px-3', 'py-1'); + header.textContent = 'Classifications'; + tagsDropdownItems.appendChild(header); + + classificationItems.forEach(cls => { + const item = document.createElement('button'); + item.type = 'button'; + item.classList.add('dropdown-item', 'd-flex', 'align-items-center'); + item.setAttribute('data-tag-value', cls.name); + item.style.display = 'flex'; + item.style.width = '100%'; + item.style.textAlign = 'left'; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.classList.add('form-check-input', 'me-2', 'tag-checkbox'); + checkbox.style.pointerEvents = 'none'; + checkbox.style.minWidth = '16px'; + + const icon = document.createElement('i'); + icon.classList.add('bi', 'bi-bookmark-fill', 'me-1'); + icon.style.color = cls.color; + icon.style.fontSize = '0.75rem'; + + const label = document.createElement('span'); + label.textContent = `${cls.displayName} (${cls.count})`; + + item.appendChild(checkbox); + item.appendChild(icon); + item.appendChild(label); + tagsDropdownItems.appendChild(item); + }); + } + } + } else { + hideTagsDropdown(); + } + } catch (error) { + console.error('Error loading tags:', error); + hideTagsDropdown(); + } +} + +function showTagsDropdown() { + if (tagsDropdown) tagsDropdown.style.display = 'block'; +} + +function hideTagsDropdown() { + if (tagsDropdown) tagsDropdown.style.display = 'none'; +} + +/* --------------------------------------------------------------------------- + Sync Tags Dropdown Button Text with Selection State +--------------------------------------------------------------------------- */ +function syncTagsDropdownButtonText() { + if (!tagsDropdownButton || !tagsDropdownItems) return; + + const checkedItems = tagsDropdownItems.querySelectorAll('.tag-checkbox:checked'); + const count = checkedItems.length; + const textEl = tagsDropdownButton.querySelector('.selected-tags-text'); + if (!textEl) return; + + if (count === 0) { + textEl.textContent = 'All Tags'; + } else if (count === 1) { + const parentItem = checkedItems[0].closest('.dropdown-item'); + const tagValue = parentItem ? parentItem.getAttribute('data-tag-value') : ''; + textEl.textContent = tagValue || '1 tag selected'; + } else { + textEl.textContent = `${count} tags selected`; + } +} + +/* --------------------------------------------------------------------------- + Get Selected Tags +--------------------------------------------------------------------------- */ +export function getSelectedTags() { + if (!chatTagsFilter) return []; + // Check if the tags dropdown is visible (the hidden select is always display:none via d-none class) + if (tagsDropdown && tagsDropdown.style.display === 'none') return []; + return Array.from(chatTagsFilter.selectedOptions).map(opt => opt.value); +} + +/* --------------------------------------------------------------------------- + Filter Document Dropdown by Selected Tags + Uses DOM removal instead of CSS hiding to guarantee items disappear. +--------------------------------------------------------------------------- */ +export function filterDocumentsBySelectedTags() { + if (!docDropdownItems) return; + + // 1) Re-add any items previously removed by this filter (preserve order) + for (let i = tagFilteredOutItems.length - 1; i >= 0; i--) { + const { element, nextSibling } = tagFilteredOutItems[i]; + if (nextSibling && nextSibling.parentNode === docDropdownItems) { + docDropdownItems.insertBefore(element, nextSibling); + } else { + docDropdownItems.appendChild(element); + } + } + tagFilteredOutItems = []; + + const selectedTags = getSelectedTags(); + + // Helper: check if a document matches by tag or classification + function matchesSelection(tags, classification) { + const matchesByTag = tags.some(tag => selectedTags.includes(tag)); + if (matchesByTag) return true; + const docCls = classification || ''; + return selectedTags.some(sel => { + if (sel === '__unclassified__') return !docCls || docCls === '' || docCls.toLowerCase() === 'none'; + return docCls === sel; + }); + } + + // 2) If tags/classifications are selected, remove non-matching items from the DOM + if (selectedTags.length > 0) { + const items = Array.from(docDropdownItems.querySelectorAll('.dropdown-item')); + items.forEach(item => { + const docId = item.getAttribute('data-document-id'); + // "All Documents" item stays + if (docId === '' || docId === null) return; + + let docTags = []; + try { docTags = JSON.parse(item.dataset.tags || '[]'); } catch (e) { docTags = []; } + const docClassification = item.dataset.classification || ''; + + if (!matchesSelection(docTags, docClassification)) { + const nextSibling = item.nextElementSibling; + docDropdownItems.removeChild(item); + tagFilteredOutItems.push({ element: item, nextSibling }); + } + }); + } + + // 3) Sync hidden select to keep state consistent + if (docSelectEl) { + Array.from(docSelectEl.options).forEach(opt => { + if (opt.value === '') return; + if (selectedTags.length === 0) { opt.disabled = false; return; } + + let optTags = []; + try { optTags = JSON.parse(opt.dataset.tags || '[]'); } catch (e) { optTags = []; } + const optClassification = opt.dataset.classification || ''; + opt.disabled = !matchesSelection(optTags, optClassification); + }); + } +} + +/* --------------------------------------------------------------------------- + Sync Dropdown Button Text with Selection State +--------------------------------------------------------------------------- */ +function syncDropdownButtonText() { + if (!docDropdownButton || !docDropdownItems) return; + + const checkedItems = docDropdownItems.querySelectorAll('.doc-checkbox:checked'); + const count = checkedItems.length; + const textEl = docDropdownButton.querySelector(".selected-document-text"); + if (!textEl) return; + + if (count === 0) { + textEl.textContent = "All Documents"; + } else if (count === 1) { + // Show the single document name + const parentItem = checkedItems[0].closest('.dropdown-item'); + const labelSpan = parentItem ? parentItem.querySelector('span') : null; + textEl.textContent = labelSpan ? labelSpan.textContent : "1 document selected"; + } else { + textEl.textContent = `${count} documents selected`; + } +} + /* --------------------------------------------------------------------------- UI Event Listeners --------------------------------------------------------------------------- */ -if (docScopeSelect) { - docScopeSelect.addEventListener("change", populateDocumentSelectScope); + +// Scope dropdown: prevent closing when clicking inside +if (scopeDropdownMenu) { + scopeDropdownMenu.addEventListener('click', function(e) { + e.stopPropagation(); + }); +} + +// Scope dropdown: click handler for scope items +if (scopeDropdownItems) { + scopeDropdownItems.addEventListener('click', function(e) { + e.stopPropagation(); + + // Guard: prevent changes when scope is locked + if (scopeLocked === true) { e.preventDefault(); return; } + + const item = e.target.closest('.dropdown-item'); + if (!item) return; + + const action = item.getAttribute('data-scope-action'); + const scopeValue = item.getAttribute('data-scope-value'); + + if (action === 'toggle-all') { + // Toggle all checkboxes + const allCb = item.querySelector('.scope-checkbox-all'); + if (allCb) { + const newState = !allCb.checked; + allCb.checked = newState; + allCb.indeterminate = false; + scopeDropdownItems.querySelectorAll('.scope-checkbox').forEach(cb => { + cb.checked = newState; + }); + } + onScopeChanged(); + return; + } + + if (scopeValue) { + // Toggle individual checkbox + const cb = item.querySelector('.scope-checkbox'); + if (cb) { + cb.checked = !cb.checked; + } + onScopeChanged(); + } + }); +} + +if (chatTagsFilter) { + chatTagsFilter.addEventListener("change", () => { + filterDocumentsBySelectedTags(); + }); +} + +// Tags dropdown: prevent closing when clicking inside +if (tagsDropdownItems) { + const tagsDropdownMenu = document.getElementById("tags-dropdown-menu"); + if (tagsDropdownMenu) { + tagsDropdownMenu.addEventListener('click', function(e) { + e.stopPropagation(); + }); + } + + // Click handler for tag items with checkbox toggling + tagsDropdownItems.addEventListener('click', function(e) { + e.stopPropagation(); + const item = e.target.closest('.dropdown-item'); + if (!item) return; + + const tagValue = item.getAttribute('data-tag-value'); + + // "Clear All" item unchecks everything + if (tagValue === '' || tagValue === null) { + tagsDropdownItems.querySelectorAll('.tag-checkbox').forEach(cb => { + cb.checked = false; + }); + // Clear hidden select + if (chatTagsFilter) { + Array.from(chatTagsFilter.options).forEach(opt => { opt.selected = false; }); + } + syncTagsDropdownButtonText(); + filterDocumentsBySelectedTags(); + return; + } + + // Toggle checkbox + const checkbox = item.querySelector('.tag-checkbox'); + if (checkbox) { + checkbox.checked = !checkbox.checked; + } + + // Sync hidden select with checked state + if (chatTagsFilter) { + Array.from(chatTagsFilter.options).forEach(opt => { opt.selected = false; }); + tagsDropdownItems.querySelectorAll('.dropdown-item').forEach(di => { + const cb = di.querySelector('.tag-checkbox'); + const val = di.getAttribute('data-tag-value'); + if (cb && cb.checked && val) { + const matchingOpt = Array.from(chatTagsFilter.options).find(o => o.value === val); + if (matchingOpt) matchingOpt.selected = true; + } + }); + } + + syncTagsDropdownButtonText(); + filterDocumentsBySelectedTags(); + }); } if (searchDocumentsBtn) { @@ -552,42 +1311,28 @@ if (searchDocumentsBtn) { if (this.classList.contains("active")) { searchDocumentsContainer.style.display = "block"; + // Build the scope dropdown on first open (respect lock state) + if (scopeLocked === true) { + rebuildScopeDropdownWithLock(); + } else { + buildScopeDropdown(); + } // Ensure initial population and state is correct when opening loadAllDocs().then(() => { - // Force Bootstrap to update the Popper positioning + // Load tags for the currently selected scope + loadTagsForScope(); + // Update Bootstrap Popper positioning if dropdown was already initialized try { const dropdownInstance = bootstrap.Dropdown.getInstance(docDropdownButton); if (dropdownInstance) { dropdownInstance.update(); - } else { - // Initialize dropdown if not already done - new bootstrap.Dropdown(docDropdownButton, { - boundary: 'viewport', - reference: 'toggle', - autoClose: 'outside', - popperConfig: { - strategy: 'fixed', - modifiers: [ - { - name: 'preventOverflow', - options: { - boundary: 'viewport', - padding: 10 - } - } - ] - } - }); } } catch (err) { - console.error("Error initializing dropdown:", err); + console.error("Error updating dropdown:", err); } - // handleDocumentSelectChange() is called by populateDocumentSelectScope within loadAllDocs }); } else { searchDocumentsContainer.style.display = "none"; - // Optional: Reset classification state when hiding? - // resetClassificationState(); // You might want a function for this } }); } @@ -603,12 +1348,12 @@ if (docDropdownMenu) { docDropdownMenu.addEventListener('click', function(e) { e.stopPropagation(); }); - + // Additional event handlers to prevent dropdown from closing docDropdownMenu.addEventListener('keydown', function(e) { e.stopPropagation(); }); - + docDropdownMenu.addEventListener('keyup', function(e) { e.stopPropagation(); }); @@ -619,89 +1364,82 @@ if (docDropdownItems) { docDropdownItems.addEventListener('click', function(e) { e.stopPropagation(); }); - - // Directly attach click handler to the container for better delegation + + // Multi-select click handler with checkbox toggling docDropdownItems.addEventListener('click', function(e) { - // Find closest dropdown-item whether clicked directly or on a child const item = e.target.closest('.dropdown-item'); - if (!item) return; // Exit if click wasn't on/in a dropdown item - + if (!item) return; + const docId = item.getAttribute('data-document-id'); - console.log("Document item clicked:", docId, item.textContent); - - // Update hidden select - if (docSelectEl) { - docSelectEl.value = docId; - - // Trigger change event - const event = new Event('change', { bubbles: true }); - docSelectEl.dispatchEvent(event); + + // "All Documents" item clears all selections + if (docId === '' || docId === null) { + // Uncheck all checkboxes + docDropdownItems.querySelectorAll('.doc-checkbox').forEach(cb => { + cb.checked = false; + }); + // Clear hidden select + if (docSelectEl) { + Array.from(docSelectEl.options).forEach(opt => { opt.selected = false; }); + } + syncDropdownButtonText(); + handleDocumentSelectChange(); + return; } - - // Update dropdown button text - if (docDropdownButton) { - docDropdownButton.querySelector('.selected-document-text').textContent = item.textContent; + + // Toggle checkbox + const checkbox = item.querySelector('.doc-checkbox'); + if (checkbox) { + checkbox.checked = !checkbox.checked; } - - // Update active state - document.querySelectorAll('#document-dropdown-items .dropdown-item').forEach(i => { - i.classList.remove('active'); - }); - item.classList.add('active'); - - // Close dropdown - try { - const dropdownInstance = bootstrap.Dropdown.getInstance(docDropdownButton); - if (dropdownInstance) { - dropdownInstance.hide(); - } - } catch (err) { - console.error("Error closing dropdown:", err); + + // Sync hidden select with checked state + if (docSelectEl) { + Array.from(docSelectEl.options).forEach(opt => { opt.selected = false; }); + docDropdownItems.querySelectorAll('.dropdown-item').forEach(di => { + const cb = di.querySelector('.doc-checkbox'); + const id = di.getAttribute('data-document-id'); + if (cb && cb.checked && id) { + const matchingOpt = Array.from(docSelectEl.options).find(o => o.value === id); + if (matchingOpt) matchingOpt.selected = true; + } + }); } + + syncDropdownButtonText(); + handleDocumentSelectChange(); + + // Do NOT close dropdown - allow multiple selections }); } // Add search functionality if (docSearchInput) { - // Define our filtering function to ensure consistent filtering logic + // Define our filtering function to ensure consistent filtering logic. + // Items hidden by tag filter are physically removed from the DOM, + // so querySelectorAll naturally excludes them. const filterDocumentItems = function(searchTerm) { - console.log("Filtering documents with search term:", searchTerm); - - if (!docDropdownItems) { - console.error("Document dropdown items container not found"); - return; - } - - // Get all dropdown items directly from the items container + if (!docDropdownItems) return; + const items = docDropdownItems.querySelectorAll('.dropdown-item'); - console.log(`Found ${items.length} document items to filter`); - - // Keep track if any items matched let matchFound = false; - - // Process each item + items.forEach(item => { - // Get the text content for comparison const docName = item.textContent.toLowerCase(); - - // Check if the document name includes the search term - if (docName.includes(searchTerm)) { - // Show matching item - item.style.display = 'block'; + + if (!searchTerm || docName.includes(searchTerm)) { + item.style.display = ''; item.setAttribute('data-filtered', 'visible'); matchFound = true; } else { - // Hide non-matching item item.style.display = 'none'; item.setAttribute('data-filtered', 'hidden'); } }); - - console.log(`Filter results: ${matchFound ? 'Matches found' : 'No matches found'}`); - + // Show a message if no matches found const noMatchesEl = docDropdownItems.querySelector('.no-matches'); - if (!matchFound && searchTerm.length > 0) { + if (!matchFound && searchTerm && searchTerm.length > 0) { if (!noMatchesEl) { const noMatchesMsg = document.createElement('div'); noMatchesMsg.className = 'no-matches text-center text-muted py-2'; @@ -709,58 +1447,30 @@ if (docSearchInput) { docDropdownItems.appendChild(noMatchesMsg); } } else { - // Remove the "no matches" message if it exists if (noMatchesEl) { noMatchesEl.remove(); } } - - // Make sure dropdown stays open and visible - if (docDropdownMenu) { - docDropdownMenu.classList.add('show'); - } }; - - // Attach input event directly + + // Attach input event directly docSearchInput.addEventListener('input', function() { const searchTerm = this.value.toLowerCase().trim(); filterDocumentItems(searchTerm); }); - + // Also attach keyup event as a fallback docSearchInput.addEventListener('keyup', function() { const searchTerm = this.value.toLowerCase().trim(); filterDocumentItems(searchTerm); }); - - // Clear search when dropdown closes - document.addEventListener('hidden.bs.dropdown', function(e) { - if (e.target.id === 'document-dropdown') { - docSearchInput.value = ''; // Clear search input - - // Reset visibility of all items - if (docDropdownItems) { - const items = docDropdownItems.querySelectorAll('.dropdown-item'); - items.forEach(item => { - item.style.display = 'block'; - item.removeAttribute('data-filtered'); - }); - } - - // Remove any "no matches" message - const noMatchesEl = docDropdownItems?.querySelector('.no-matches'); - if (noMatchesEl) { - noMatchesEl.remove(); - } - } - }); - + // Prevent dropdown from closing when clicking in search input docSearchInput.addEventListener('click', function(e) { e.stopPropagation(); e.preventDefault(); }); - + // Prevent dropdown from closing when pressing keys in search input docSearchInput.addEventListener('keydown', function(e) { e.stopPropagation(); @@ -768,195 +1478,50 @@ if (docSearchInput) { } /* --------------------------------------------------------------------------- - Handle Document Selection & Update Classification UI + Handle Document Selection & Update UI --------------------------------------------------------------------------- */ export function handleDocumentSelectChange() { - // Only require docSelectEl for document selection logic if (!docSelectEl) { console.error("Document select element not found, cannot update UI."); return; } - // Update custom dropdown button text to match selected document - if (docDropdownButton) { - const selectedOption = docSelectEl.options[docSelectEl.selectedIndex]; - if (selectedOption) { - docDropdownButton.querySelector(".selected-document-text").textContent = selectedOption.textContent; - - // Update active state in dropdown - if (docDropdownItems) { - document.querySelectorAll("#document-dropdown-items .dropdown-item").forEach(item => { - item.classList.remove("active"); - if (item.getAttribute("data-document-id") === selectedOption.value) { - item.classList.add("active"); - } - }); - } - } - } - - // Classification UI logic (optional, only if elements exist) - const classificationEnabled = toBoolean(window.enable_document_classification); - - if (classificationContainer) { - if (classificationEnabled) { - classificationContainer.style.display = ''; - } else { - classificationContainer.style.display = 'none'; - } - } - - // If classification is not enabled, skip classification UI logic, but allow document selection to work - if (!classificationEnabled) { - return; - } - - if (!classificationSelectInput || !classificationMultiselectDropdown || !classificationDropdownBtn || !classificationDropdownMenu) { - // If classification elements are missing, skip classification UI logic - return; - } - - const selectedOption = docSelectEl.options[docSelectEl.selectedIndex]; - const docId = selectedOption.value; - - // Case 1: "All Documents" is selected (value is empty string) - if (!docId) { - classificationSelectInput.style.display = "none"; // Hide the single display input - classificationSelectInput.value = ""; // Clear its value just in case - - classificationMultiselectDropdown.style.display = "block"; // Show the dropdown wrapper - - // Build the checkbox list (this function will also set the initial state) - buildClassificationCheckboxDropdown(); - } - // Case 2: A specific document is selected - else { - classificationMultiselectDropdown.style.display = "none"; // Hide the dropdown wrapper - - // Get the classification stored on the selected option element - const classification = selectedOption.dataset.classification || "N/A"; // Use "N/A" or similar if empty - - classificationSelectInput.value = classification; // Set the input's value - classificationSelectInput.style.display = "block"; // Show the input - // Input is already readonly via HTML, no need to disable JS-side unless you want extra safety - } -} - -/* --------------------------------------------------------------------------- - Build and Manage Classification Checkbox Dropdown (for "All Documents") ---------------------------------------------------------------------------- */ -function buildClassificationCheckboxDropdown() { - if (!classificationDropdownMenu || !classificationDropdownBtn || !classificationSelectInput) return; - - classificationDropdownMenu.innerHTML = ""; // Clear previous items - - // Stop propagation on menu clicks to prevent closing when clicking labels/checkboxes - classificationDropdownMenu.addEventListener('click', (e) => { - e.stopPropagation(); - }); - - - if (classificationCategories.length === 0) { - classificationDropdownMenu.innerHTML = ''; - classificationDropdownBtn.textContent = "No categories"; - classificationDropdownBtn.disabled = true; - classificationSelectInput.value = ""; // Ensure hidden value is empty - return; - } - - classificationDropdownBtn.disabled = false; - - // Create a checkbox item for each classification category - classificationCategories.forEach((cat) => { - // Use cat.label assuming cat is {label: 'Name', color: '#...'} - const categoryLabel = cat.label || cat; // Handle if it's just an array of strings - if (!categoryLabel) return; // Skip empty categories - - const li = document.createElement("li"); - const label = document.createElement("label"); - label.classList.add("dropdown-item", "d-flex", "align-items-center", "gap-2"); // Use flex for spacing - label.style.cursor = 'pointer'; // Make it clear the whole item is clickable - - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.value = categoryLabel.trim(); - checkbox.checked = true; // Default to checked - checkbox.classList.add('form-check-input', 'mt-0'); // Bootstrap class, mt-0 for alignment - - label.appendChild(checkbox); - label.appendChild(document.createTextNode(` ${categoryLabel.trim()}`)); // Add label text - - li.appendChild(label); - classificationDropdownMenu.appendChild(li); - - // Add listener to the checkbox itself - checkbox.addEventListener("change", () => { - updateClassificationDropdownLabelAndValue(); - }); - }); - - // Initialize the button label and the hidden input value after building - updateClassificationDropdownLabelAndValue(); + // Sync button text from current hidden select state + syncDropdownButtonText(); } -// Single function to update both the button label and the hidden input's value -function updateClassificationDropdownLabelAndValue() { - if (!classificationDropdownMenu || !classificationDropdownBtn || !classificationSelectInput) return; - - const checkboxes = classificationDropdownMenu.querySelectorAll("input[type='checkbox']"); - const checkedCheckboxes = classificationDropdownMenu.querySelectorAll("input[type='checkbox']:checked"); - - const totalCount = checkboxes.length; - const checkedCount = checkedCheckboxes.length; - - // Update Button Label - if (checkedCount === 0) { - classificationDropdownBtn.textContent = "None selected"; - } else if (checkedCount === totalCount) { - classificationDropdownBtn.textContent = "All selected"; - } else if (checkedCount === 1) { - // Find the single selected label - classificationDropdownBtn.textContent = checkedCheckboxes[0].value; // Show the actual label if only one selected - // classificationDropdownBtn.textContent = "1 selected"; // Alternative: Keep generic count - } else { - classificationDropdownBtn.textContent = `${checkedCount} selected`; - } - - // Update Hidden Input Value (comma-separated string) - const checkedValues = []; - checkedCheckboxes.forEach((cb) => checkedValues.push(cb.value)); - classificationSelectInput.value = checkedValues.join(","); // Store comma-separated list -} - -// Helper function (optional) to reset state if needed -// function resetClassificationState() { -// if (!docSelectEl || !classificationContainer) return; -// // Potentially reset docSelectEl to "All" -// // docSelectEl.value = ""; -// // Then trigger the update -// handleDocumentSelectChange(); -// } - // --- Ensure initial state is set after documents are loaded --- // The call within loadAllDocs -> populateDocumentSelectScope handles the initial setup. // Initialize the dropdown on page load document.addEventListener('DOMContentLoaded', function() { + // Initialize scope dropdown + if (scopeDropdownButton) { + try { + const scopeDropdownEl = document.getElementById('scope-dropdown'); + if (scopeDropdownEl) { + new bootstrap.Dropdown(scopeDropdownButton, { + autoClose: 'outside' + }); + } + } catch (err) { + console.error("Error initializing scope dropdown:", err); + } + } + // If search documents button exists, it needs to be clicked to show controls - if (searchDocumentsBtn && docScopeSelect && docDropdownButton) { + if (searchDocumentsBtn && docDropdownButton) { try { // Get the dropdown element const dropdownEl = document.getElementById('document-dropdown'); - + if (dropdownEl) { - console.log("Initializing Bootstrap dropdown with search functionality"); - // Initialize Bootstrap dropdown with the right configuration new bootstrap.Dropdown(docDropdownButton, { boundary: 'viewport', reference: 'toggle', - autoClose: 'outside', // Close when clicking outside, stay open when clicking inside + autoClose: 'outside', popperConfig: { strategy: 'fixed', modifiers: [ @@ -970,44 +1535,158 @@ document.addEventListener('DOMContentLoaded', function() { ] } }); - - // Listen for dropdown show event + + // Clear search when opening + dropdownEl.addEventListener('show.bs.dropdown', function() { + if (docSearchInput) { + docSearchInput.value = ''; + } + }); + + // Adjust sizing and focus search when shown dropdownEl.addEventListener('shown.bs.dropdown', function() { - console.log("Dropdown shown - making sure items are visible"); initializeDocumentDropdown(); - - // Focus the search input when dropdown is shown if (docSearchInput) { - setTimeout(() => { - docSearchInput.focus(); - }, 100); + setTimeout(() => docSearchInput.focus(), 50); } }); - - // Re-initialize the search filter every time the dropdown is shown - if (docSearchInput) { - // Clear any previous search when opening the dropdown - dropdownEl.addEventListener('show.bs.dropdown', function() { + + // Clean up inline styles and reset state when hidden + dropdownEl.addEventListener('hidden.bs.dropdown', function() { + if (docSearchInput) { docSearchInput.value = ''; - }); - - // Ensure the search filter is properly initialized when the dropdown is shown - dropdownEl.addEventListener('shown.bs.dropdown', function() { - // Explicitly focus and activate the search input - setTimeout(() => { - docSearchInput.focus(); - - // Add click handler for search input to prevent dropdown from closing - docSearchInput.onclick = function(e) { - e.stopPropagation(); - e.preventDefault(); - }; - }, 150); - }); - } + } + // Clear search filtering state + if (docDropdownItems) { + const items = docDropdownItems.querySelectorAll('.dropdown-item'); + items.forEach(item => { + item.removeAttribute('data-filtered'); + item.style.display = ''; + }); + const noMatchesEl = docDropdownItems.querySelector('.no-matches'); + if (noMatchesEl) noMatchesEl.remove(); + } + // Clear inline styles set by initializeDocumentDropdown so they + // don't interfere with Bootstrap's positioning on next open + if (docDropdownMenu) { + docDropdownMenu.style.maxHeight = ''; + docDropdownMenu.style.maxWidth = ''; + docDropdownMenu.style.width = ''; + } + if (docDropdownItems) { + docDropdownItems.style.maxHeight = ''; + } + }); } } catch (err) { console.error("Error initializing bootstrap dropdown:", err); } } -}); \ No newline at end of file + + // --- Scope Lock: Dual-mode modal event wiring --- + const confirmToggleBtn = document.getElementById('confirm-scope-lock-toggle-btn'); + if (confirmToggleBtn) { + confirmToggleBtn.addEventListener('click', async () => { + const conversationId = window.currentConversationId; + if (!conversationId) return; + + const newState = scopeLocked === true ? false : true; + + try { + confirmToggleBtn.disabled = true; + confirmToggleBtn.innerHTML = '' + + (newState ? 'Locking...' : 'Unlocking...'); + await toggleScopeLock(conversationId, newState); + + // Hide modal + const modalEl = document.getElementById('scopeLockModal'); + if (modalEl) { + const modalInstance = bootstrap.Modal.getInstance(modalEl); + if (modalInstance) modalInstance.hide(); + } + } catch (err) { + console.error('Failed to toggle scope lock:', err); + } finally { + confirmToggleBtn.disabled = false; + } + }); + } + + const scopeLockModal = document.getElementById('scopeLockModal'); + if (scopeLockModal) { + scopeLockModal.addEventListener('show.bs.modal', () => { + const titleEl = document.getElementById('scopeLockModalLabel'); + const descEl = document.getElementById('scope-lock-modal-description'); + const alertEl = document.getElementById('scope-lock-modal-alert'); + const toggleBtn = document.getElementById('confirm-scope-lock-toggle-btn'); + const listEl = document.getElementById('locked-workspaces-list'); + + // Build workspace list + const workspaceItems = []; + for (const ctx of lockedContexts) { + let name = ''; + let icon = ''; + if (ctx.scope === 'personal') { + name = 'Personal'; + icon = 'bi-person'; + } else if (ctx.scope === 'group') { + name = groupIdToName[ctx.id] || ctx.id; + icon = 'bi-people'; + } else if (ctx.scope === 'public') { + name = publicWorkspaceIdToName[ctx.id] || ctx.id; + icon = 'bi-globe'; + } + if (name) { + workspaceItems.push(`
  • ${name}
  • `); + } + } + + if (listEl) { + if (workspaceItems.length > 0) { + const listLabel = scopeLocked === true ? 'Currently locked to:' : 'Will lock to:'; + listEl.innerHTML = `

    ${listLabel}

      ${workspaceItems.join('')}
    `; + } else { + listEl.innerHTML = '

    No specific workspaces recorded.

    '; + } + } + + if (scopeLocked === true) { + // Currently locked — show unlock mode + if (titleEl) titleEl.innerHTML = 'Unlock Workspace Scope'; + if (descEl) descEl.textContent = 'This conversation\'s scope is locked to prevent accidental cross-contamination with other data sources.'; + if (alertEl) { + alertEl.className = 'alert alert-warning mb-0'; + alertEl.innerHTML = 'Unlocking allows you to select any workspace for this conversation. You can re-lock it later.'; + } + if (toggleBtn) { + toggleBtn.className = 'btn btn-warning'; + toggleBtn.innerHTML = 'Unlock Scope'; + } + + // Check if admin enforces scope lock — hide unlock button + if (window.appSettings && window.appSettings.enforce_workspace_scope_lock) { + if (toggleBtn) toggleBtn.classList.add('d-none'); + if (alertEl) { + alertEl.className = 'alert alert-info mb-0'; + alertEl.innerHTML = 'Workspace scope lock is enforced by your administrator. The scope cannot be unlocked.'; + } + } else { + if (toggleBtn) toggleBtn.classList.remove('d-none'); + } + } else { + // Currently unlocked — show lock mode + if (titleEl) titleEl.innerHTML = 'Lock Workspace Scope'; + if (descEl) descEl.textContent = 'Re-lock the scope to restrict this conversation to the workspaces that produced search results.'; + if (alertEl) { + alertEl.className = 'alert alert-info mb-0'; + alertEl.innerHTML = 'Locking will restrict the scope dropdown to only the workspaces listed above.'; + } + if (toggleBtn) { + toggleBtn.className = 'btn btn-success'; + toggleBtn.innerHTML = 'Lock Scope'; + toggleBtn.classList.remove('d-none'); + } + } + }); + } +}); diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js index 45dbf6f3..d4c54790 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -5,7 +5,7 @@ import { showLoadingIndicatorInChatbox, hideLoadingIndicatorInChatbox, } from "./chat-loading-indicator.js"; -import { docScopeSelect, getDocumentMetadata, personalDocs, groupDocs, publicDocs } from "./chat-documents.js"; +import { getDocumentMetadata, personalDocs, groupDocs, publicDocs, getSelectedTags, getEffectiveScopes, applyScopeLock } from "./chat-documents.js"; import { promptSelect } from "./chat-prompts.js"; import { createNewConversation, @@ -1366,24 +1366,16 @@ export function actuallySendMessage(finalMessageToSend) { } let selectedDocumentId = null; - let classificationsToSend = null; + let selectedDocumentIds = []; const docSel = document.getElementById("document-select"); - const classificationInput = document.getElementById("classification-select"); - // Always set selectedDocumentId if a document is selected, regardless of hybridSearchEnabled + // Read all selected document IDs (multi-select support) if (docSel) { - const selectedDocOption = docSel.options[docSel.selectedIndex]; - if (selectedDocOption && selectedDocOption.value !== "") { - selectedDocumentId = selectedDocOption.value; - } else { - selectedDocumentId = null; - } - } - - // Only set classificationsToSend if classificationInput exists - if (classificationInput) { - classificationsToSend = - classificationInput.value === "N/A" ? null : classificationInput.value; + selectedDocumentIds = Array.from(docSel.selectedOptions) + .map(o => o.value) + .filter(v => v); // Filter out empty strings + // For backwards compat, set single ID to first selected or null + selectedDocumentId = selectedDocumentIds.length > 0 ? selectedDocumentIds[0] : null; } let imageGenEnabled = false; @@ -1439,44 +1431,68 @@ export function actuallySendMessage(finalMessageToSend) { } } - // Determine the correct doc_scope, especially when "all" is selected but a specific document is chosen - let effectiveDocScope = docScopeSelect ? docScopeSelect.value : "all"; - - // If scope is "all" but a specific document is selected, determine the actual scope of that document - if (effectiveDocScope === "all" && selectedDocumentId) { - const documentMetadata = getDocumentMetadata(selectedDocumentId); - if (documentMetadata) { - // Check which list the document belongs to - if (personalDocs.find(doc => doc.id === selectedDocumentId || doc.document_id === selectedDocumentId)) { - effectiveDocScope = "personal"; - } else if (groupDocs.find(doc => doc.id === selectedDocumentId || doc.document_id === selectedDocumentId)) { - effectiveDocScope = "group"; - } else if (publicDocs.find(doc => doc.id === selectedDocumentId || doc.document_id === selectedDocumentId)) { - effectiveDocScope = "public"; + // Get effective scopes from multi-select scope dropdown + const scopes = getEffectiveScopes(); + + // Determine the correct doc_scope based on selected scopes + let effectiveDocScope = "all"; + if (scopes.personal && scopes.groupIds.length === 0 && scopes.publicWorkspaceIds.length === 0) { + effectiveDocScope = "personal"; + } else if (!scopes.personal && scopes.groupIds.length > 0 && scopes.publicWorkspaceIds.length === 0) { + effectiveDocScope = "group"; + } else if (!scopes.personal && scopes.groupIds.length === 0 && scopes.publicWorkspaceIds.length > 0) { + effectiveDocScope = "public"; + } + + // If documents are selected, determine the actual scope from the documents themselves + if (selectedDocumentIds.length > 0) { + const docScopes = new Set(); + selectedDocumentIds.forEach(docId => { + if (personalDocs.find(doc => doc.id === docId || doc.document_id === docId)) { + docScopes.add("personal"); + } else if (groupDocs.find(doc => doc.id === docId || doc.document_id === docId)) { + docScopes.add("group"); + } else if (publicDocs.find(doc => doc.id === docId || doc.document_id === docId)) { + docScopes.add("public"); } - console.log(`Document ${selectedDocumentId} scope detected as: ${effectiveDocScope}`); + }); + + // Only narrow scope if ALL selected docs are from the same scope + if (docScopes.size === 1) { + effectiveDocScope = docScopes.values().next().value; + console.log(`All selected documents are from scope: ${effectiveDocScope}`); + } else if (docScopes.size > 1) { + effectiveDocScope = "all"; + console.log(`Selected documents span ${docScopes.size} scopes (${[...docScopes].join(', ')}), keeping scope as "all"`); } } - // Fallback: if group_id is null/empty, use window.activeGroupId - const finalGroupId = group_id || window.activeGroupId || null; + // Use group IDs from scope selector; fall back to window.activeGroupId for backwards compat + const finalGroupIds = scopes.groupIds.length > 0 ? scopes.groupIds : (window.activeGroupId ? [window.activeGroupId] : []); + const finalGroupId = finalGroupIds[0] || window.activeGroupId || null; const webSearchToggle = document.getElementById("search-web-btn"); const webSearchEnabled = webSearchToggle ? webSearchToggle.classList.contains("active") : false; - + // Prepare message data object - // Get active public workspace ID from user settings (similar to active_group_id) - const finalPublicWorkspaceId = window.activePublicWorkspaceId || null; - + // Get public workspace IDs from scope selector; fall back to window.activePublicWorkspaceId + const finalPublicWorkspaceId = scopes.publicWorkspaceIds[0] || window.activePublicWorkspaceId || null; + + // Get selected tags from chat-documents module + const selectedTags = getSelectedTags(); + const messageData = { message: finalMessageToSend, conversation_id: currentConversationId, hybrid_search: hybridSearchEnabled, web_search_enabled: webSearchEnabled, selected_document_id: selectedDocumentId, - classifications: classificationsToSend, + selected_document_ids: selectedDocumentIds, + classifications: null, + tags: selectedTags, image_generation: imageGenEnabled, doc_scope: effectiveDocScope, chat_type: chat_type, + active_group_ids: finalGroupIds, active_group_id: finalGroupId, active_public_workspace_id: finalPublicWorkspaceId, model_deployment: modelDeployment, @@ -1658,6 +1674,18 @@ export function actuallySendMessage(finalMessageToSend) { console.log('[sendMessage] New conversation setup complete, conversation ID:', currentConversationId); } } + + // Apply scope lock if document search was used + if (data.augmented && currentConversationId) { + fetch(`/api/conversations/${currentConversationId}/metadata`, { credentials: 'same-origin' }) + .then(r => r.json()) + .then(metadata => { + if (metadata.scope_locked === true && metadata.locked_contexts) { + applyScopeLock(metadata.locked_contexts, metadata.scope_locked); + } + }) + .catch(err => console.warn('Failed to fetch scope lock metadata:', err)); + } }) .catch((error) => { hideLoadingIndicatorInChatbox(); diff --git a/application/single_app/static/js/chat/chat-onload.js b/application/single_app/static/js/chat/chat-onload.js index e20f7240..43e1eba3 100644 --- a/application/single_app/static/js/chat/chat-onload.js +++ b/application/single_app/static/js/chat/chat-onload.js @@ -2,7 +2,7 @@ import { loadConversations, selectConversation, ensureConversationPresent } from "./chat-conversations.js"; // Import handleDocumentSelectChange -import { loadAllDocs, populateDocumentSelectScope, handleDocumentSelectChange } from "./chat-documents.js"; +import { loadAllDocs, populateDocumentSelectScope, handleDocumentSelectChange, loadTagsForScope, filterDocumentsBySelectedTags, setScopeFromUrlParam } from "./chat-documents.js"; import { getUrlParameter } from "./chat-utils.js"; // Assuming getUrlParameter is in chat-utils.js now import { loadUserPrompts, loadGroupPrompts, initializePromptInteractions } from "./chat-prompts.js"; import { loadUserSettings } from "./chat-layout.js"; @@ -102,9 +102,13 @@ window.addEventListener('DOMContentLoaded', async () => { const localSearchDocsParam = getUrlParameter("search_documents") === "true"; const localDocScopeParam = getUrlParameter("doc_scope") || ""; const localDocumentIdParam = getUrlParameter("document_id") || ""; + const localDocumentIdsParam = getUrlParameter("document_ids") || ""; + const tagsParam = getUrlParameter("tags") || ""; const workspaceParam = getUrlParameter("workspace") || ""; const openSearchParam = getUrlParameter("openSearch") === "1"; const scopeParam = getUrlParameter("scope") || ""; + const groupIdParam = getUrlParameter("group_id") || ""; + const workspaceIdParam = getUrlParameter("workspace_id") || ""; const localSearchDocsBtn = document.getElementById("search-documents-btn"); const localDocScopeSel = document.getElementById("doc-scope-select"); const localDocSelectEl = document.getElementById("document-select"); @@ -134,11 +138,12 @@ window.addEventListener('DOMContentLoaded', async () => { searchDocumentsContainer.style.display = "block"; // Set scope to public - localDocScopeSel.value = "public"; - + setScopeFromUrlParam("public", { workspaceId: workspaceParam }); + // Populate documents for public scope populateDocumentSelectScope(); - + loadTagsForScope(); + // Trigger change to update UI handleDocumentSelectChange(); @@ -161,32 +166,112 @@ window.addEventListener('DOMContentLoaded', async () => { localSearchDocsBtn.classList.add("active"); searchDocumentsContainer.style.display = "block"; if (localDocScopeParam) { - localDocScopeSel.value = localDocScopeParam; + setScopeFromUrlParam(localDocScopeParam, { groupId: groupIdParam, workspaceId: workspaceIdParam }); } populateDocumentSelectScope(); // Populate based on scope (might be default or from URL) - if (localDocumentIdParam) { - // Wait a tiny moment for populateDocumentSelectScope potentially async operations - // This delay is necessary to ensure the document options are fully populated + // Pre-select tags from URL parameter + if (tagsParam) { + await loadTagsForScope(); + const chatTagsFilter = document.getElementById("chat-tags-filter"); + const tagsDropdownItems = document.getElementById("tags-dropdown-items"); + const tagsDropdownButton = document.getElementById("tags-dropdown-button"); + if (chatTagsFilter) { + const tagValues = tagsParam.split(",").map(t => t.trim()); + // Select matching options in hidden select + Array.from(chatTagsFilter.options).forEach(opt => { + if (tagValues.includes(opt.value)) { + opt.selected = true; + } + }); + // Also check matching checkboxes in custom dropdown + if (tagsDropdownItems) { + tagsDropdownItems.querySelectorAll('.dropdown-item').forEach(item => { + const tagVal = item.getAttribute('data-tag-value'); + const cb = item.querySelector('.tag-checkbox'); + if (cb && tagVal && tagValues.includes(tagVal)) { + cb.checked = true; + } + }); + } + // Update button text + if (tagsDropdownButton) { + const textEl = tagsDropdownButton.querySelector('.selected-tags-text'); + if (textEl) { + if (tagValues.length === 1) { + textEl.textContent = tagValues[0]; + } else { + textEl.textContent = `${tagValues.length} tags selected`; + } + } + } + filterDocumentsBySelectedTags(); + } + } else { + // Load tags for current scope even without URL tag param + await loadTagsForScope(); + } + + // Pre-select documents from URL parameters + const docIdsToSelect = localDocumentIdsParam + ? localDocumentIdsParam.split(",").map(id => id.trim()).filter(Boolean) + : localDocumentIdParam + ? [localDocumentIdParam] + : []; + + if (docIdsToSelect.length > 0) { + // Small delay to ensure document options are fully populated setTimeout(() => { - if ([...localDocSelectEl.options].some(option => option.value === localDocumentIdParam)) { - localDocSelectEl.value = localDocumentIdParam; - } else { - console.warn(`Document ID "${localDocumentIdParam}" not found for scope "${localDocScopeSel.value}".`); + const docDropdownItems = document.getElementById("document-dropdown-items"); + const docDropdownButton = document.getElementById("document-dropdown-button"); + + // Check matching checkboxes in custom dropdown + if (docDropdownItems) { + docDropdownItems.querySelectorAll('.dropdown-item').forEach(item => { + const docId = item.getAttribute('data-document-id'); + const cb = item.querySelector('.doc-checkbox'); + if (cb && docId && docIdsToSelect.includes(docId)) { + cb.checked = true; + } + }); } - // Ensure classification updates after setting document + + // Select matching options in hidden select + Array.from(localDocSelectEl.options).forEach(opt => { + if (docIdsToSelect.includes(opt.value)) { + opt.selected = true; + } + }); + + // Update dropdown button text + if (docDropdownButton) { + const textEl = docDropdownButton.querySelector('.selected-document-text'); + if (textEl) { + if (docIdsToSelect.length === 1) { + // Find the label from the dropdown item + const matchItem = docDropdownItems + ? docDropdownItems.querySelector(`.dropdown-item[data-document-id="${docIdsToSelect[0]}"] span`) + : null; + textEl.textContent = matchItem ? matchItem.textContent : "1 document selected"; + } else { + textEl.textContent = `${docIdsToSelect.length} documents selected`; + } + } + } + handleDocumentSelectChange(); - }, 100); // Small delay to ensure options are populated + }, 100); } else { - // If no specific doc ID, still might need to trigger change if scope changed + // If no specific doc IDs, still might need to trigger change if scope changed handleDocumentSelectChange(); } } else if (openSearchParam && scopeParam === "public" && localSearchDocsBtn && localDocScopeSel && searchDocumentsContainer) { // Handle openSearch=1&scope=public from public directory chat button localSearchDocsBtn.classList.add("active"); searchDocumentsContainer.style.display = "block"; - localDocScopeSel.value = "public"; + setScopeFromUrlParam("public"); populateDocumentSelectScope(); + loadTagsForScope(); handleDocumentSelectChange(); } else { // If not loading from URL params, maybe still populate default scope? diff --git a/application/single_app/static/js/chat/chat-prompts.js b/application/single_app/static/js/chat/chat-prompts.js index 06cc7412..01521098 100644 --- a/application/single_app/static/js/chat/chat-prompts.js +++ b/application/single_app/static/js/chat/chat-prompts.js @@ -2,7 +2,7 @@ import { userInput} from "./chat-messages.js"; import { updateSendButtonVisibility } from "./chat-messages.js"; -import { docScopeSelect } from "./chat-documents.js"; +import { docScopeSelect, getEffectiveScopes } from "./chat-documents.js"; const promptSelectionContainer = document.getElementById("prompt-selection-container"); export const promptSelect = document.getElementById("prompt-select"); // Keep export if needed elsewhere @@ -66,33 +66,32 @@ export function loadPublicPrompts() { export function populatePromptSelectScope() { if (!promptSelect) return; - console.log("Populating prompt dropdown with scope:", docScopeSelect?.value || "all"); + // Determine effective scope from multi-select dropdown + const scopes = getEffectiveScopes(); + console.log("Populating prompt dropdown with scopes:", scopes); console.log("User prompts:", userPrompts.length); console.log("Group prompts:", groupPrompts.length); console.log("Public prompts:", publicPrompts.length); const previousValue = promptSelect.value; // Store previous selection if needed promptSelect.innerHTML = ""; - + const defaultOpt = document.createElement("option"); defaultOpt.value = ""; defaultOpt.textContent = "Select a Prompt..."; promptSelect.appendChild(defaultOpt); - const scopeVal = docScopeSelect?.value || "all"; let finalPrompts = []; - if (scopeVal === "all") { - const pPrompts = userPrompts.map((p) => ({...p, scope: "Personal"})); - const gPrompts = groupPrompts.map((p) => ({...p, scope: "Group"})); - const pubPrompts = publicPrompts.map((p) => ({...p, scope: "Public"})); - finalPrompts = pPrompts.concat(gPrompts).concat(pubPrompts); - } else if (scopeVal === "personal") { - finalPrompts = userPrompts.map((p) => ({...p, scope: "Personal"})); - } else if (scopeVal === "group") { - finalPrompts = groupPrompts.map((p) => ({...p, scope: "Group"})); - } else if (scopeVal === "public") { - finalPrompts = publicPrompts.map((p) => ({...p, scope: "Public"})); + // Include prompts based on which scopes are selected + if (scopes.personal) { + finalPrompts = finalPrompts.concat(userPrompts.map((p) => ({...p, scope: "Personal"}))); + } + if (scopes.groupIds.length > 0) { + finalPrompts = finalPrompts.concat(groupPrompts.map((p) => ({...p, scope: "Group"}))); + } + if (scopes.publicWorkspaceIds.length > 0) { + finalPrompts = finalPrompts.concat(publicPrompts.map((p) => ({...p, scope: "Public"}))); } // Add prompt options diff --git a/application/single_app/static/js/chat/chat-streaming.js b/application/single_app/static/js/chat/chat-streaming.js index 1519890a..faf6f59e 100644 --- a/application/single_app/static/js/chat/chat-streaming.js +++ b/application/single_app/static/js/chat/chat-streaming.js @@ -4,6 +4,7 @@ import { hideLoadingIndicatorInChatbox, showLoadingIndicatorInChatbox } from './ import { loadUserSettings, saveUserSetting } from './chat-layout.js'; import { showToast } from './chat-toast.js'; import { updateSidebarConversationTitle } from './chat-sidebar-conversations.js'; +import { applyScopeLock } from './chat-documents.js'; let streamingEnabled = false; let currentEventSource = null; @@ -358,6 +359,18 @@ function finalizeStreamingMessage(messageId, userMessageId, finalData) { // Update sidebar conversation title in real-time updateSidebarConversationTitle(finalData.conversation_id, finalData.conversation_title); } + + // Apply scope lock if document search was used + if (finalData.augmented && finalData.conversation_id) { + fetch(`/api/conversations/${finalData.conversation_id}/metadata`, { credentials: 'same-origin' }) + .then(r => r.json()) + .then(metadata => { + if (metadata.scope_locked === true && metadata.locked_contexts) { + applyScopeLock(metadata.locked_contexts, metadata.scope_locked); + } + }) + .catch(err => console.warn('Failed to fetch scope lock metadata after streaming:', err)); + } } export function cancelStreaming() { diff --git a/application/single_app/static/js/group/manage_group.js b/application/single_app/static/js/group/manage_group.js index d6372838..228c81c1 100644 --- a/application/single_app/static/js/group/manage_group.js +++ b/application/single_app/static/js/group/manage_group.js @@ -575,7 +575,6 @@ function rejectRequest(requestId) { }); } -// Search users for manual add // Search users for manual add function searchUsers() { const term = $("#userSearchTerm").val().trim(); diff --git a/application/single_app/static/js/public/public_workspace.js b/application/single_app/static/js/public/public_workspace.js index f48c096d..5c20fcd9 100644 --- a/application/single_app/static/js/public/public_workspace.js +++ b/application/single_app/static/js/public/public_workspace.js @@ -20,9 +20,34 @@ let publicPromptsSearchTerm = ''; // Polling set for documents const publicActivePolls = new Set(); +// Document selection state +let publicSelectedDocuments = new Set(); +let publicSelectionMode = false; + +// Grid/folder view state +let publicCurrentView = 'list'; +let publicCurrentFolder = null; +let publicCurrentFolderType = null; +let publicFolderCurrentPage = 1; +let publicFolderPageSize = 10; +let publicGridSortBy = 'count'; +let publicGridSortOrder = 'desc'; +let publicFolderSortBy = '_ts'; +let publicFolderSortOrder = 'desc'; +let publicFolderSearchTerm = ''; +let publicWorkspaceTags = []; +let publicDocsSortBy = '_ts'; +let publicDocsSortOrder = 'desc'; +let publicDocsTagsFilter = ''; +let publicBulkSelectedTags = new Set(); +let publicDocSelectedTags = new Set(); +let publicEditingTag = null; + // Modals const publicPromptModal = new bootstrap.Modal(document.getElementById('publicPromptModal')); const publicDocMetadataModal = new bootstrap.Modal(document.getElementById('publicDocMetadataModal')); +const publicTagManagementModal = new bootstrap.Modal(document.getElementById('publicTagManagementModal')); +const publicTagSelectionModal = new bootstrap.Modal(document.getElementById('publicTagSelectionModal')); // Editors let publicSimplemde = null; @@ -121,8 +146,35 @@ document.addEventListener('DOMContentLoaded', ()=>{ } if (publicDocsPageSizeSelect) publicDocsPageSizeSelect.onchange = (e)=>{ publicDocsPageSize = +e.target.value; publicDocsCurrentPage=1; fetchPublicDocs(); }; - if (docsApplyBtn) docsApplyBtn.onclick = ()=>{ publicDocsSearchTerm = publicDocsSearchInput.value.trim(); publicDocsCurrentPage=1; fetchPublicDocs(); }; - if (docsClearBtn) docsClearBtn.onclick = ()=>{ publicDocsSearchInput.value=''; publicDocsSearchTerm=''; publicDocsCurrentPage=1; fetchPublicDocs(); }; + if (docsApplyBtn) docsApplyBtn.onclick = ()=>{ + publicDocsSearchTerm = publicDocsSearchInput ? publicDocsSearchInput.value.trim() : ''; + // Read tags filter + const tagsSelect = document.getElementById('public-docs-tags-filter'); + if (tagsSelect) { + publicDocsTagsFilter = Array.from(tagsSelect.selectedOptions).map(o => o.value).join(','); + } + publicDocsCurrentPage=1; + fetchPublicDocs(); + }; + if (docsClearBtn) docsClearBtn.onclick = ()=>{ + if (publicDocsSearchInput) publicDocsSearchInput.value=''; + publicDocsSearchTerm=''; + publicDocsSortBy='_ts'; publicDocsSortOrder='desc'; + publicDocsTagsFilter=''; + const classFilter = document.getElementById('public-docs-classification-filter'); + if (classFilter) classFilter.value=''; + const authorFilter = document.getElementById('public-docs-author-filter'); + if (authorFilter) authorFilter.value=''; + const keywordsFilter = document.getElementById('public-docs-keywords-filter'); + if (keywordsFilter) keywordsFilter.value=''; + const abstractFilter = document.getElementById('public-docs-abstract-filter'); + if (abstractFilter) abstractFilter.value=''; + const tagsSelect = document.getElementById('public-docs-tags-filter'); + if (tagsSelect) { Array.from(tagsSelect.options).forEach(o => o.selected = false); } + updatePublicListSortIcons(); + publicDocsCurrentPage=1; + fetchPublicDocs(); + }; if (publicDocsSearchInput) publicDocsSearchInput.onkeypress = e=>{ if(e.key==='Enter') docsApplyBtn && docsApplyBtn.click(); }; createPublicPromptBtn.onclick = ()=> openPublicPromptModal(); @@ -148,6 +200,26 @@ document.addEventListener('DOMContentLoaded', ()=>{ }); Array.from(publicDropdownItems.children).forEach(()=>{}); // placeholder + + // --- Document selection event listeners --- + // Event delegation for document checkboxes + document.addEventListener('change', function(event) { + if (event.target.classList.contains('document-checkbox')) { + const documentId = event.target.getAttribute('data-document-id'); + if (window.updatePublicSelectedDocuments) { + window.updatePublicSelectedDocuments(documentId, event.target.checked); + } + } + }); + + // Bulk action buttons + const publicDeleteSelectedBtn = document.getElementById('public-delete-selected-btn'); + const publicClearSelectionBtn = document.getElementById('public-clear-selection-btn'); + const publicChatSelectedBtn = document.getElementById('public-chat-selected-btn'); + + if (publicDeleteSelectedBtn) publicDeleteSelectedBtn.addEventListener('click', deletePublicSelectedDocuments); + if (publicClearSelectionBtn) publicClearSelectionBtn.addEventListener('click', clearPublicSelection); + if (publicChatSelectedBtn) publicChatSelectedBtn.addEventListener('click', chatWithPublicSelected); }); // Fetch User's Public Workspaces @@ -250,6 +322,7 @@ function loadActivePublicData(){ const activeTab = document.querySelector('#publicWorkspaceTab .nav-link.active').dataset.bsTarget; if(activeTab==='#public-docs-tab') fetchPublicDocs(); else fetchPublicPrompts(); updatePublicRoleDisplay(); updatePublicPromptsRoleUI(); updateWorkspaceStatusAlert(); + loadPublicWorkspaceTags(); } async function fetchPublicDocs(){ @@ -258,6 +331,30 @@ async function fetchPublicDocs(){ publicDocsPagination.innerHTML=''; const params=new URLSearchParams({page:publicDocsCurrentPage,page_size:publicDocsPageSize}); if(publicDocsSearchTerm) params.append('search',publicDocsSearchTerm); + + // Classification filter + const classFilter = document.getElementById('public-docs-classification-filter'); + if (classFilter && classFilter.value) params.append('classification', classFilter.value); + + // Author filter + const authorFilter = document.getElementById('public-docs-author-filter'); + if (authorFilter && authorFilter.value.trim()) params.append('author', authorFilter.value.trim()); + + // Keywords filter + const keywordsFilter = document.getElementById('public-docs-keywords-filter'); + if (keywordsFilter && keywordsFilter.value.trim()) params.append('keywords', keywordsFilter.value.trim()); + + // Abstract filter + const abstractFilter = document.getElementById('public-docs-abstract-filter'); + if (abstractFilter && abstractFilter.value.trim()) params.append('abstract', abstractFilter.value.trim()); + + // Tags filter + if (publicDocsTagsFilter) params.append('tags', publicDocsTagsFilter); + + // Sort + if (publicDocsSortBy !== '_ts') params.append('sort_by', publicDocsSortBy); + if (publicDocsSortOrder !== 'desc') params.append('sort_order', publicDocsSortOrder); + try { const r=await fetch(`/api/public_documents?${params}`); if(!r.ok) throw await r.json(); const data=await r.json(); @@ -283,18 +380,75 @@ function renderPublicDocumentRow(doc) { let firstTdHtml = ""; if (isComplete && !hasError) { - firstTdHtml = ``; + firstTdHtml = ` + + + + `; } else if (hasError) { firstTdHtml = ``; } else { firstTdHtml = ``; } + // Build actions column + let chatButton = ''; + let actionsDropdown = ''; + + if (isComplete && !hasError) { + chatButton = ``; + + actionsDropdown = ` + `; + } else if (canManage) { + actionsDropdown = ` + `; + } + + tr.classList.add('document-row'); tr.innerHTML = ` ${firstTdHtml} ${escapeHtml(doc.file_name)} ${escapeHtml(doc.title || '')} - ${canManage ? `` : ''}`; + ${chatButton}${actionsDropdown}`; // Create details row const detailsRow = document.createElement('tr'); @@ -634,11 +788,106 @@ async function onPublicUploadClick() { window.deletePublicDocument=async function(id, event){ if(!confirm('Delete?')) return; try{ await fetch(`/api/public_documents/${id}`,{method:'DELETE'}); fetchPublicDocs(); }catch(e){ alert(`Error deleting: ${e.error||e.message}`);} }; window.searchPublicDocumentInChat = function(docId) { - console.log(`Search public document in chat: ${docId}`); - // TODO: Implement search in chat functionality - alert('Search in chat functionality not yet implemented'); + window.location.href = `/chats?search_documents=true&doc_scope=public&document_id=${docId}&workspace_id=${activePublicId}`; }; +// --- Public Document Selection Functions --- +function updatePublicSelectedDocuments(documentId, isSelected) { + if (isSelected) { + publicSelectedDocuments.add(documentId); + } else { + publicSelectedDocuments.delete(documentId); + } + updatePublicBulkActionButtons(); +} + +function updatePublicBulkActionButtons() { + const bulkActionsBar = document.getElementById('publicBulkActionsBar'); + const selectedCountSpan = document.getElementById('publicSelectedCount'); + const deleteBtn = document.getElementById('public-delete-selected-btn'); + + if (publicSelectedDocuments.size > 0) { + if (bulkActionsBar) bulkActionsBar.style.display = 'block'; + if (selectedCountSpan) selectedCountSpan.textContent = publicSelectedDocuments.size; + const canManage = ['Owner', 'Admin', 'DocumentManager'].includes(userRoleInActivePublic); + if (deleteBtn) deleteBtn.style.display = canManage ? 'inline-block' : 'none'; + } else { + if (bulkActionsBar) bulkActionsBar.style.display = 'none'; + } +} + +function togglePublicSelectionMode() { + const table = document.getElementById('public-documents-table'); + const checkboxes = document.querySelectorAll('.document-checkbox'); + const expandContainers = document.querySelectorAll('.expand-collapse-container'); + const bulkActionsBar = document.getElementById('publicBulkActionsBar'); + + publicSelectionMode = !publicSelectionMode; + + if (publicSelectionMode) { + table.classList.add('selection-mode'); + checkboxes.forEach(cb => { cb.style.display = 'inline-block'; }); + expandContainers.forEach(c => { c.style.display = 'none'; }); + } else { + table.classList.remove('selection-mode'); + checkboxes.forEach(cb => { cb.style.display = 'none'; cb.checked = false; }); + expandContainers.forEach(c => { c.style.display = 'inline-block'; }); + if (bulkActionsBar) bulkActionsBar.style.display = 'none'; + publicSelectedDocuments.clear(); + } +} + +function clearPublicSelection() { + document.querySelectorAll('.document-checkbox').forEach(cb => { cb.checked = false; }); + publicSelectedDocuments.clear(); + updatePublicBulkActionButtons(); +} + +function deletePublicSelectedDocuments() { + if (publicSelectedDocuments.size === 0) return; + if (!confirm(`Are you sure you want to delete ${publicSelectedDocuments.size} selected document(s)? This action cannot be undone.`)) return; + + const deleteBtn = document.getElementById('public-delete-selected-btn'); + if (deleteBtn) { + deleteBtn.disabled = true; + deleteBtn.innerHTML = 'Deleting...'; + } + + const deletePromises = Array.from(publicSelectedDocuments).map(docId => + fetch(`/api/public_documents/${docId}`, { method: 'DELETE' }) + .then(r => r.ok ? r.json() : Promise.reject(r)) + ); + + Promise.allSettled(deletePromises) + .then(results => { + const successful = results.filter(r => r.status === 'fulfilled').length; + const failed = results.filter(r => r.status === 'rejected').length; + if (failed > 0) alert(`Deleted ${successful} document(s). ${failed} failed to delete.`); + publicSelectedDocuments.clear(); + updatePublicBulkActionButtons(); + fetchPublicDocs(); + }) + .finally(() => { + if (deleteBtn) { + deleteBtn.disabled = false; + deleteBtn.innerHTML = 'Delete Selected'; + } + }); +} + +function chatWithPublicSelected() { + if (publicSelectedDocuments.size === 0) return; + const idsParam = encodeURIComponent(Array.from(publicSelectedDocuments).join(',')); + window.location.href = `/chats?search_documents=true&doc_scope=public&document_ids=${idsParam}&workspace_id=${activePublicId}`; +} + +// Expose selection functions globally +window.updatePublicSelectedDocuments = updatePublicSelectedDocuments; +window.togglePublicSelectionMode = togglePublicSelectionMode; +window.deletePublicSelectedDocuments = deletePublicSelectedDocuments; +window.clearPublicSelection = clearPublicSelection; +window.chatWithPublicSelected = chatWithPublicSelected; + // Prompts async function fetchPublicPrompts(){ publicPromptsTableBody.innerHTML='
    Loading prompts...'; @@ -689,6 +938,10 @@ window.onEditPublicDocument = function(docId) { } } + // Load tags for the document + publicDocSelectedTags = new Set(Array.isArray(doc.tags) ? doc.tags : []); + updatePublicDocTagsDisplay(); + publicDocMetadataModal.show(); }) .catch(err => { @@ -736,6 +989,9 @@ async function onSavePublicDocMetadata(e) { } payload.document_classification = selectedClassification; + // Add tags + payload.tags = Array.from(publicDocSelectedTags); + try { const response = await fetch(`/api/public_documents/${docId}`, { method: "PATCH", @@ -751,6 +1007,7 @@ async function onSavePublicDocMetadata(e) { const updatedDoc = await response.json(); publicDocMetadataModal.hide(); fetchPublicDocs(); // Refresh the table + loadPublicWorkspaceTags(); // Refresh tag counts } catch (err) { console.error("Error updating public document:", err); alert("Error updating document: " + (err.message || "Unknown error")); @@ -823,3 +1080,884 @@ function togglePublicDetails(docId) { // Make the function globally available window.togglePublicDetails = togglePublicDetails; window.fetchPublicDocs = fetchPublicDocs; + +// === Grid/Folder/Tag Management Functions === + +function loadPublicWorkspaceTags() { + if (!activePublicId) return Promise.resolve(); + return fetch(`/api/public_workspace_documents/tags?workspace_ids=${activePublicId}`) + .then(r => r.ok ? r.json() : Promise.reject('Failed to load tags')) + .then(data => { + publicWorkspaceTags = data.tags || []; + const sel = document.getElementById('public-docs-tags-filter'); + if (sel) { + const prev = Array.from(sel.selectedOptions).map(o => o.value); + sel.innerHTML = ''; + publicWorkspaceTags.forEach(t => { + const opt = document.createElement('option'); + opt.value = t.name; + opt.textContent = `${t.name} (${t.count})`; + if (prev.includes(t.name)) opt.selected = true; + sel.appendChild(opt); + }); + } + updatePublicBulkTagsList(); + if (publicCurrentView === 'grid') renderPublicGridView(); + }) + .catch(err => console.error('Error loading public workspace tags:', err)); +} + +function setupPublicViewSwitcher() { + const listRadio = document.getElementById('public-docs-view-list'); + const gridRadio = document.getElementById('public-docs-view-grid'); + if (listRadio) listRadio.addEventListener('change', () => { if (listRadio.checked) switchPublicView('list'); }); + if (gridRadio) gridRadio.addEventListener('change', () => { if (gridRadio.checked) switchPublicView('grid'); }); +} + +function switchPublicView(view) { + publicCurrentView = view; + localStorage.setItem('publicWorkspaceViewPreference', view); + const listView = document.getElementById('public-documents-list-view'); + const gridView = document.getElementById('public-documents-grid-view'); + const viewInfo = document.getElementById('public-docs-view-info'); + const gridControls = document.getElementById('public-grid-controls-bar'); + const filterBtn = document.getElementById('public-docs-filters-toggle-btn'); + const filterCollapse = document.getElementById('public-docs-filters-collapse'); + const bulkBar = document.getElementById('publicBulkActionsBar'); + + if (view === 'list') { + publicCurrentFolder = null; + publicCurrentFolderType = null; + publicFolderCurrentPage = 1; + publicFolderSortBy = '_ts'; + publicFolderSortOrder = 'desc'; + publicFolderSearchTerm = ''; + const tagContainer = document.getElementById('public-tag-folders-container'); + if (tagContainer) tagContainer.className = 'row g-2'; + if (listView) listView.style.display = 'block'; + if (gridView) gridView.style.display = 'none'; + if (gridControls) gridControls.style.display = 'none'; + if (filterBtn) filterBtn.style.display = ''; + if (viewInfo) viewInfo.textContent = ''; + fetchPublicDocs(); + } else { + if (listView) listView.style.display = 'none'; + if (gridView) gridView.style.display = 'block'; + if (gridControls) gridControls.style.display = 'flex'; + if (filterBtn) filterBtn.style.display = 'none'; + if (filterCollapse) { + const bsCollapse = bootstrap.Collapse.getInstance(filterCollapse); + if (bsCollapse) bsCollapse.hide(); + } + if (bulkBar) bulkBar.style.display = 'none'; + renderPublicGridView(); + } +} + +async function renderPublicGridView() { + const container = document.getElementById('public-tag-folders-container'); + if (!container || !activePublicId) return; + + if (publicCurrentFolder && publicCurrentFolder !== '__untagged__' && publicCurrentFolder !== '__unclassified__') { + if (publicCurrentFolderType === 'classification') { + const categories = window.classification_categories || []; + if (!categories.some(cat => cat.label === publicCurrentFolder)) { + publicCurrentFolder = null; publicCurrentFolderType = null; publicFolderCurrentPage = 1; + } + } else { + if (!publicWorkspaceTags.some(t => t.name === publicCurrentFolder)) { + publicCurrentFolder = null; publicCurrentFolderType = null; publicFolderCurrentPage = 1; + } + } + } + + if (publicCurrentFolder) { renderPublicFolderContents(publicCurrentFolder); return; } + + const viewInfo = document.getElementById('public-docs-view-info'); + if (viewInfo) viewInfo.textContent = ''; + container.className = 'row g-2'; + container.innerHTML = '
    Loading...
    Loading tag folders...
    '; + + try { + const docsResponse = await fetch(`/api/public_documents?page_size=1000`); + const docsData = await docsResponse.json(); + const allDocs = docsData.documents || []; + const untaggedCount = allDocs.filter(doc => !doc.tags || doc.tags.length === 0).length; + + const classificationEnabled = (window.enable_document_classification === true || window.enable_document_classification === "true"); + const categories = classificationEnabled ? (window.classification_categories || []) : []; + const classificationCounts = {}; + let unclassifiedCount = 0; + if (classificationEnabled) { + allDocs.forEach(doc => { + const cls = doc.document_classification; + if (!cls || cls === '' || cls.toLowerCase() === 'none') { unclassifiedCount++; } + else { classificationCounts[cls] = (classificationCounts[cls] || 0) + 1; } + }); + } + + const folderItems = []; + if (untaggedCount > 0) { + folderItems.push({ type: 'tag', key: '__untagged__', displayName: 'Untagged', count: untaggedCount, icon: 'bi-folder2-open', color: '#6c757d', isSpecial: true }); + } + if (classificationEnabled && unclassifiedCount > 0) { + folderItems.push({ type: 'classification', key: '__unclassified__', displayName: 'Unclassified', count: unclassifiedCount, icon: 'bi-bookmark', color: '#6c757d', isSpecial: true }); + } + publicWorkspaceTags.forEach(tag => { + folderItems.push({ type: 'tag', key: tag.name, displayName: tag.name, count: tag.count, icon: 'bi-folder-fill', color: tag.color, isSpecial: false, tagData: tag }); + }); + if (classificationEnabled) { + categories.forEach(cat => { + const count = classificationCounts[cat.label] || 0; + if (count > 0) { + folderItems.push({ type: 'classification', key: cat.label, displayName: cat.label, count: count, icon: 'bi-bookmark-fill', color: cat.color || '#6c757d', isSpecial: false }); + } + }); + } + + folderItems.sort((a, b) => { + if (a.isSpecial && !b.isSpecial) return -1; + if (!a.isSpecial && b.isSpecial) return 1; + if (publicGridSortBy === 'name') { + const cmp = a.displayName.localeCompare(b.displayName, undefined, { sensitivity: 'base' }); + return publicGridSortOrder === 'asc' ? cmp : -cmp; + } + const cmp = a.count - b.count; + return publicGridSortOrder === 'asc' ? cmp : -cmp; + }); + + updatePublicGridSortIcons(); + + const canManageTags = ['Owner', 'Admin', 'DocumentManager'].includes(userRoleInActivePublic); + let html = ''; + folderItems.forEach(item => { + const ek = escapeHtml(item.key); + const en = escapeHtml(item.displayName); + const cl = `${item.count} file${item.count !== 1 ? 's' : ''}`; + let actionsHtml = ''; + if (item.type === 'tag' && !item.isSpecial && canManageTags) { + actionsHtml = ``; + } else if (item.type === 'classification') { + actionsHtml = `
    `; + } else if (item.type === 'tag' && item.isSpecial) { + actionsHtml = `
    `; + } + html += `
    +
    + ${actionsHtml} +
    +
    ${en}
    +
    ${cl}
    +
    `; + }); + + if (folderItems.length === 0) { + html = '

    No folders yet. Add tags to documents to organize them.

    '; + } + container.innerHTML = html; + container.querySelectorAll('.tag-folder-card').forEach(card => { + card.addEventListener('click', (e) => { + if (e.target.closest('.tag-folder-actions')) return; + publicCurrentFolder = card.getAttribute('data-tag'); + publicCurrentFolderType = card.getAttribute('data-folder-type') || 'tag'; + publicFolderCurrentPage = 1; + publicFolderSortBy = '_ts'; publicFolderSortOrder = 'desc'; publicFolderSearchTerm = ''; + renderPublicFolderContents(publicCurrentFolder); + }); + }); + } catch (error) { + console.error('Error rendering public grid view:', error); + container.innerHTML = '

    Error loading tag folders

    '; + } +} + +function buildPublicBreadcrumbHtml(displayName, tagColor, folderType) { + const icon = folderType === 'classification' ? 'bi-bookmark-fill' : 'bi-folder-fill'; + return `
    + All Folders + / + + ${escapeHtml(displayName)} +
    `; +} + +function wirePublicBackButton(container) { + container.querySelectorAll('.public-back-to-grid').forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + publicCurrentFolder = null; + publicCurrentFolderType = null; + publicFolderCurrentPage = 1; + publicFolderSortBy = '_ts'; publicFolderSortOrder = 'desc'; publicFolderSearchTerm = ''; + renderPublicGridView(); + }); + }); +} + +function buildPublicFolderDocumentsTable(docs) { + function getSortIcon(field) { + if (publicFolderSortBy === field) { + return publicFolderSortOrder === 'asc' ? 'bi-sort-up' : 'bi-sort-down'; + } + return 'bi-arrow-down-up text-muted'; + } + let html = ''; + html += ``; + html += ``; + html += ''; + docs.forEach(doc => { + const chatBtn = ``; + html += ` + + + + `; + }); + html += '
    File Name Title Actions
    ${escapeHtml(doc.file_name)}${escapeHtml(doc.title || '')}${chatBtn}
    '; + return html; +} + +function renderPublicFolderPagination(page, pageSize, totalCount) { + const container = document.getElementById('public-folder-pagination'); + if (!container) return; + container.innerHTML = ''; + const totalPages = Math.ceil(totalCount / pageSize); + if (totalPages <= 1) return; + const ul = document.createElement('ul'); + ul.className = 'pagination pagination-sm mb-0'; + function make(p, text, disabled, active) { + const li = document.createElement('li'); + li.className = `page-item${disabled ? ' disabled' : ''}${active ? ' active' : ''}`; + const a = document.createElement('a'); + a.className = 'page-link'; a.href = '#'; a.textContent = text; + if (!disabled && !active) a.onclick = e => { e.preventDefault(); publicFolderCurrentPage = p; renderPublicFolderContents(publicCurrentFolder); }; + li.append(a); return li; + } + ul.append(make(page - 1, '\u00AB', page <= 1, false)); + for (let p = 1; p <= totalPages; p++) ul.append(make(p, p, false, p === page)); + ul.append(make(page + 1, '\u00BB', page >= totalPages, false)); + container.append(ul); +} + +async function renderPublicFolderContents(tagName) { + const container = document.getElementById('public-tag-folders-container'); + if (!container) return; + const gridControls = document.getElementById('public-grid-controls-bar'); + if (gridControls) gridControls.style.display = 'none'; + container.className = ''; + + const isClassification = (publicCurrentFolderType === 'classification'); + let displayName, tagColor; + if (tagName === '__untagged__') { displayName = 'Untagged Documents'; tagColor = '#6c757d'; } + else if (tagName === '__unclassified__') { displayName = 'Unclassified Documents'; tagColor = '#6c757d'; } + else if (isClassification) { + const cat = (window.classification_categories || []).find(c => c.label === tagName); + displayName = tagName; tagColor = cat?.color || '#6c757d'; + } else { + const tagInfo = publicWorkspaceTags.find(t => t.name === tagName); + displayName = tagName; tagColor = tagInfo?.color || '#6c757d'; + } + + const viewInfo = document.getElementById('public-docs-view-info'); + if (viewInfo) viewInfo.textContent = `Viewing: ${displayName}`; + + container.innerHTML = buildPublicBreadcrumbHtml(displayName, tagColor, publicCurrentFolderType || 'tag') + + '
    Loading...
    Loading documents...
    '; + wirePublicBackButton(container); + + try { + let docs, totalCount; + if (tagName === '__untagged__') { + const resp = await fetch(`/api/public_documents?page_size=1000${publicFolderSearchTerm ? '&search=' + encodeURIComponent(publicFolderSearchTerm) : ''}`); + const data = await resp.json(); + let allUntagged = (data.documents || []).filter(d => !d.tags || d.tags.length === 0); + if (publicFolderSortBy !== '_ts') { + allUntagged.sort((a, b) => { + const va = (a[publicFolderSortBy] || '').toLowerCase(); + const vb = (b[publicFolderSortBy] || '').toLowerCase(); + const cmp = va.localeCompare(vb); + return publicFolderSortOrder === 'asc' ? cmp : -cmp; + }); + } + totalCount = allUntagged.length; + const start = (publicFolderCurrentPage - 1) * publicFolderPageSize; + docs = allUntagged.slice(start, start + publicFolderPageSize); + } else if (tagName === '__unclassified__') { + const params = new URLSearchParams({ page: publicFolderCurrentPage, page_size: publicFolderPageSize, classification: 'none' }); + if (publicFolderSearchTerm) params.append('search', publicFolderSearchTerm); + if (publicFolderSortBy !== '_ts') params.append('sort_by', publicFolderSortBy); + if (publicFolderSortOrder !== 'desc') params.append('sort_order', publicFolderSortOrder); + const resp = await fetch(`/api/public_documents?${params.toString()}`); + const data = await resp.json(); + docs = data.documents || []; totalCount = data.total_count || docs.length; + } else if (isClassification) { + const params = new URLSearchParams({ page: publicFolderCurrentPage, page_size: publicFolderPageSize, classification: tagName }); + if (publicFolderSearchTerm) params.append('search', publicFolderSearchTerm); + if (publicFolderSortBy !== '_ts') params.append('sort_by', publicFolderSortBy); + if (publicFolderSortOrder !== 'desc') params.append('sort_order', publicFolderSortOrder); + const resp = await fetch(`/api/public_documents?${params.toString()}`); + const data = await resp.json(); + docs = data.documents || []; totalCount = data.total_count || docs.length; + } else { + const params = new URLSearchParams({ page: publicFolderCurrentPage, page_size: publicFolderPageSize, tags: tagName }); + if (publicFolderSearchTerm) params.append('search', publicFolderSearchTerm); + if (publicFolderSortBy !== '_ts') params.append('sort_by', publicFolderSortBy); + if (publicFolderSortOrder !== 'desc') params.append('sort_order', publicFolderSortOrder); + const resp = await fetch(`/api/public_documents?${params.toString()}`); + const data = await resp.json(); + docs = data.documents || []; totalCount = data.total_count || docs.length; + } + + let html = buildPublicBreadcrumbHtml(displayName, tagColor, publicCurrentFolderType || 'tag'); + html += `
    +
    + + +
    + ${totalCount} document(s) +
    + + per page +
    +
    `; + + if (docs.length === 0) { + html += '

    No documents found in this folder.

    '; + } else { + html += buildPublicFolderDocumentsTable(docs); + html += '
    '; + } + + container.innerHTML = html; + wirePublicBackButton(container); + + const si = document.getElementById('public-folder-search-input'); + const sb = document.getElementById('public-folder-search-btn'); + if (si) { + const doSearch = () => { publicFolderSearchTerm = si.value.trim(); publicFolderCurrentPage = 1; renderPublicFolderContents(publicCurrentFolder); }; + sb?.addEventListener('click', doSearch); + si.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); doSearch(); } }); + si.addEventListener('search', doSearch); + } + + const fps = document.getElementById('public-folder-page-size-select'); + if (fps) fps.addEventListener('change', (e) => { publicFolderPageSize = parseInt(e.target.value, 10); publicFolderCurrentPage = 1; renderPublicFolderContents(publicCurrentFolder); }); + + container.querySelectorAll('.folder-sortable-header').forEach(th => { + th.addEventListener('click', () => { + const field = th.getAttribute('data-sort-field'); + if (publicFolderSortBy === field) { publicFolderSortOrder = publicFolderSortOrder === 'asc' ? 'desc' : 'asc'; } + else { publicFolderSortBy = field; publicFolderSortOrder = 'asc'; } + publicFolderCurrentPage = 1; + renderPublicFolderContents(publicCurrentFolder); + }); + }); + + if (docs.length > 0) renderPublicFolderPagination(publicFolderCurrentPage, publicFolderPageSize, totalCount); + } catch (error) { + console.error('Error loading public folder contents:', error); + container.innerHTML = buildPublicBreadcrumbHtml(displayName, tagColor, publicCurrentFolderType || 'tag') + + '

    Error loading documents.

    '; + wirePublicBackButton(container); + } +} + +function chatWithPublicFolder(folderType, folderName) { + const encoded = encodeURIComponent(folderName); + if (folderType === 'classification') { + window.location.href = `/chats?search_documents=true&doc_scope=public&classification=${encoded}&workspace_id=${activePublicId}`; + } else { + window.location.href = `/chats?search_documents=true&doc_scope=public&tags=${encoded}&workspace_id=${activePublicId}`; + } +} + +function renamePublicTag(tagName) { + const newName = prompt(`Rename tag "${tagName}" to:`, tagName); + if (!newName || newName.trim() === tagName) return; + fetch(`/api/public_workspace_documents/tags/${encodeURIComponent(tagName)}`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ new_name: newName.trim() }) + }).then(r => r.json().then(d => ({ ok: r.ok, data: d }))) + .then(({ ok, data }) => { + if (ok) { alert(data.message); loadPublicWorkspaceTags(); if (publicCurrentView === 'grid') renderPublicGridView(); else fetchPublicDocs(); } + else alert('Error: ' + (data.error || 'Failed to rename')); + }).catch(e => { console.error(e); alert('Error renaming tag'); }); +} + +function changePublicTagColor(tagName, currentColor) { + const newColor = prompt(`Enter new hex color for "${tagName}":`, currentColor || '#0d6efd'); + if (!newColor || newColor === currentColor) return; + fetch(`/api/public_workspace_documents/tags/${encodeURIComponent(tagName)}`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ color: newColor.trim() }) + }).then(r => r.json().then(d => ({ ok: r.ok, data: d }))) + .then(({ ok, data }) => { + if (ok) { alert(data.message); loadPublicWorkspaceTags(); if (publicCurrentView === 'grid') renderPublicGridView(); } + else alert('Error: ' + (data.error || 'Failed to change color')); + }).catch(e => { console.error(e); alert('Error changing tag color'); }); +} + +function deletePublicTag(tagName) { + if (!confirm(`Delete tag "${tagName}" from all documents?`)) return; + fetch(`/api/public_workspace_documents/tags/${encodeURIComponent(tagName)}`, { method: 'DELETE' }) + .then(r => r.json().then(d => ({ ok: r.ok, data: d }))) + .then(({ ok, data }) => { + if (ok) { alert(data.message); loadPublicWorkspaceTags(); if (publicCurrentView === 'grid') renderPublicGridView(); else fetchPublicDocs(); } + else alert('Error: ' + (data.error || 'Failed to delete')); + }).catch(e => { console.error(e); alert('Error deleting tag'); }); +} + +function updatePublicListSortIcons() { + document.querySelectorAll('#public-documents-table .sortable-header .sort-icon').forEach(icon => { + const field = icon.closest('.sortable-header').getAttribute('data-sort-field'); + icon.className = 'bi small sort-icon'; + if (publicDocsSortBy === field) { + icon.classList.add(publicDocsSortOrder === 'asc' ? 'bi-sort-up' : 'bi-sort-down'); + } else { + icon.classList.add('bi-arrow-down-up', 'text-muted'); + } + }); +} + +function updatePublicGridSortIcons() { + const bar = document.getElementById('public-grid-controls-bar'); + if (!bar) return; + bar.querySelectorAll('.public-grid-sort-icon').forEach(icon => { + const field = icon.getAttribute('data-sort'); + icon.className = 'bi ms-1 public-grid-sort-icon'; + icon.setAttribute('data-sort', field); + if (publicGridSortBy === field) { + icon.classList.add(field === 'name' ? (publicGridSortOrder === 'asc' ? 'bi-sort-alpha-down' : 'bi-sort-alpha-up') : (publicGridSortOrder === 'asc' ? 'bi-sort-numeric-down' : 'bi-sort-numeric-up')); + } else { + icon.classList.add('bi-arrow-down-up', 'text-muted'); + } + }); +} + +function isColorLight(hexColor) { + if (!hexColor) return true; + const hex = hexColor.replace('#', ''); + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + return (r * 299 + g * 587 + b * 114) / 1000 > 128; +} + +function updatePublicBulkTagsList() { + const listEl = document.getElementById('public-bulk-tags-list'); + if (!listEl) return; + if (publicWorkspaceTags.length === 0) { + listEl.innerHTML = '
    No tags available. Create some first.
    '; + return; + } + listEl.innerHTML = ''; + publicWorkspaceTags.forEach(tag => { + const el = document.createElement('span'); + el.className = `tag-badge ${isColorLight(tag.color) ? 'text-dark' : 'text-light'}`; + el.style.backgroundColor = tag.color; + el.style.border = publicBulkSelectedTags.has(tag.name) ? '3px solid #000' : '3px solid transparent'; + el.textContent = tag.name; + el.style.cursor = 'pointer'; + el.addEventListener('click', () => { + if (publicBulkSelectedTags.has(tag.name)) { publicBulkSelectedTags.delete(tag.name); el.style.border = '3px solid transparent'; } + else { publicBulkSelectedTags.add(tag.name); el.style.border = '3px solid #000'; } + }); + listEl.appendChild(el); + }); +} + +async function applyPublicBulkTagChanges() { + const action = document.getElementById('public-bulk-tag-action').value; + const selectedTags = Array.from(publicBulkSelectedTags); + const documentIds = Array.from(publicSelectedDocuments); + if (documentIds.length === 0) { alert('No documents selected'); return; } + if (selectedTags.length === 0) { alert('Please select at least one tag'); return; } + + const applyBtn = document.getElementById('public-bulk-tag-apply-btn'); + const btnText = applyBtn.querySelector('.button-text'); + const btnLoad = applyBtn.querySelector('.button-loading'); + applyBtn.disabled = true; btnText.classList.add('d-none'); btnLoad.classList.remove('d-none'); + + try { + const response = await fetch('/api/public_workspace_documents/bulk-tag', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ document_ids: documentIds, action: action, tags: selectedTags }) + }); + const result = await response.json(); + if (response.ok) { + const sc = result.success?.length || 0; + const ec = result.errors?.length || 0; + let msg = `Tags updated for ${sc} document(s)`; + if (ec > 0) msg += `\n${ec} document(s) had errors`; + alert(msg); + await loadPublicWorkspaceTags(); + fetchPublicDocs(); + publicSelectedDocuments.clear(); + const bar = document.getElementById('publicBulkActionsBar'); + if (bar) bar.style.display = 'none'; + const modal = bootstrap.Modal.getInstance(document.getElementById('publicBulkTagModal')); + if (modal) modal.hide(); + } else { alert('Error: ' + (result.error || 'Failed to update tags')); } + } catch (e) { console.error(e); alert('Error updating tags'); } + finally { applyBtn.disabled = false; btnText.classList.remove('d-none'); btnLoad.classList.add('d-none'); } +} + +// Expose grid/tag functions globally +window.chatWithPublicFolder = chatWithPublicFolder; +window.renamePublicTag = renamePublicTag; +window.changePublicTagColor = changePublicTagColor; +window.deletePublicTag = deletePublicTag; +window.loadPublicWorkspaceTags = loadPublicWorkspaceTags; + +// === Initialize Grid/Sort/Tag Features === +(function initPublicGridView() { + setupPublicViewSwitcher(); + + // Load saved view preference + const savedView = localStorage.getItem('publicWorkspaceViewPreference'); + if (savedView === 'grid') { + const gridRadio = document.getElementById('public-docs-view-grid'); + if (gridRadio) { gridRadio.checked = true; switchPublicView('grid'); } + } + + // Wire sortable headers in list view + document.querySelectorAll('#public-documents-table .sortable-header').forEach(th => { + th.addEventListener('click', () => { + const field = th.getAttribute('data-sort-field'); + if (publicDocsSortBy === field) { publicDocsSortOrder = publicDocsSortOrder === 'asc' ? 'desc' : 'asc'; } + else { publicDocsSortBy = field; publicDocsSortOrder = 'asc'; } + publicDocsCurrentPage = 1; + updatePublicListSortIcons(); + fetchPublicDocs(); + }); + }); + + // Wire grid sort buttons + document.querySelectorAll('#public-grid-controls-bar .public-grid-sort-btn').forEach(btn => { + btn.addEventListener('click', () => { + const field = btn.getAttribute('data-sort'); + if (publicGridSortBy === field) { publicGridSortOrder = publicGridSortOrder === 'asc' ? 'desc' : 'asc'; } + else { publicGridSortBy = field; publicGridSortOrder = field === 'name' ? 'asc' : 'desc'; } + renderPublicGridView(); + }); + }); + + // Wire grid page size + const gps = document.getElementById('public-grid-page-size-select'); + if (gps) gps.addEventListener('change', (e) => { publicFolderPageSize = parseInt(e.target.value, 10); publicFolderCurrentPage = 1; if (publicCurrentFolder) renderPublicFolderContents(publicCurrentFolder); }); + + // Wire bulk tag modal + const bulkTagModal = document.getElementById('publicBulkTagModal'); + if (bulkTagModal) { + bulkTagModal.addEventListener('show.bs.modal', () => { + document.getElementById('public-bulk-tag-doc-count').textContent = publicSelectedDocuments.size; + publicBulkSelectedTags.clear(); + updatePublicBulkTagsList(); + }); + } + const bulkApply = document.getElementById('public-bulk-tag-apply-btn'); + if (bulkApply) bulkApply.addEventListener('click', applyPublicBulkTagChanges); + + // Wire bulk create tag button + const bulkCreate = document.getElementById('public-bulk-create-tag-btn'); + if (bulkCreate) { + bulkCreate.addEventListener('click', async () => { + const name = prompt('Enter new tag name:'); + if (!name) return; + try { + const resp = await fetch('/api/public_workspace_documents/tags', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tag_name: name.trim() }) + }); + const data = await resp.json(); + if (resp.ok) { await loadPublicWorkspaceTags(); updatePublicBulkTagsList(); } + else alert('Error: ' + (data.error || 'Failed to create tag')); + } catch (e) { console.error(e); alert('Error creating tag'); } + }); + } +})(); + +// ============ Public Tag Management & Selection Functions ============ + +function isPublicColorLight(hex) { + if (!hex) return true; + hex = hex.replace('#', ''); + const r = parseInt(hex.substr(0,2),16), g = parseInt(hex.substr(2,2),16), b = parseInt(hex.substr(4,2),16); + return (r * 299 + g * 587 + b * 114) / 1000 > 155; +} + +function escapePublicHtml(text) { + const d = document.createElement('div'); + d.textContent = text; + return d.innerHTML; +} + +// --- Tag Management Modal --- +function showPublicTagManagementModal() { + loadPublicWorkspaceTags().then(() => { + refreshPublicTagManagementTable(); + publicTagManagementModal.show(); + }); +} + +function refreshPublicTagManagementTable() { + const tbody = document.getElementById('public-existing-tags-tbody'); + if (!tbody) return; + if (publicWorkspaceTags.length === 0) { + tbody.innerHTML = 'No tags yet. Add one above.'; + return; + } + let html = ''; + publicWorkspaceTags.forEach(tag => { + const ek = escapePublicHtml(tag.name); + html += ` +
    + ${ek} + ${tag.count} + + + + + `; + }); + tbody.innerHTML = html; +} + +function publicCancelEditMode() { + publicEditingTag = null; + const nameInput = document.getElementById('public-new-tag-name'); + const colorInput = document.getElementById('public-new-tag-color'); + const formTitle = document.getElementById('public-tag-form-title'); + const addBtn = document.getElementById('public-add-tag-btn'); + const cancelBtn = document.getElementById('public-cancel-edit-btn'); + if (nameInput) nameInput.value = ''; + if (colorInput) colorInput.value = '#0d6efd'; + if (formTitle) formTitle.textContent = 'Add New Tag'; + if (addBtn) { addBtn.innerHTML = ' Add'; addBtn.classList.remove('btn-success'); addBtn.classList.add('btn-primary'); } + if (cancelBtn) cancelBtn.classList.add('d-none'); +} + +window.editPublicTagInModal = function(tagName, currentColor) { + publicEditingTag = { originalName: tagName, originalColor: currentColor }; + const nameInput = document.getElementById('public-new-tag-name'); + const colorInput = document.getElementById('public-new-tag-color'); + const formTitle = document.getElementById('public-tag-form-title'); + const addBtn = document.getElementById('public-add-tag-btn'); + const cancelBtn = document.getElementById('public-cancel-edit-btn'); + if (nameInput) nameInput.value = tagName; + if (colorInput) colorInput.value = currentColor; + if (formTitle) formTitle.textContent = 'Edit Tag'; + if (addBtn) { addBtn.innerHTML = ' Save'; addBtn.classList.remove('btn-primary'); addBtn.classList.add('btn-success'); } + if (cancelBtn) cancelBtn.classList.remove('d-none'); + if (nameInput) nameInput.focus(); +}; + +window.deletePublicTagFromModal = async function(tagName) { + if (!confirm(`Delete tag "${tagName}"? This will remove it from all documents.`)) return; + try { + const resp = await fetch(`/api/public_workspace_documents/tags/${encodeURIComponent(tagName)}`, { method: 'DELETE' }); + const data = await resp.json(); + if (resp.ok) { + await loadPublicWorkspaceTags(); + refreshPublicTagManagementTable(); + } else { + alert('Error: ' + (data.error || 'Failed to delete tag')); + } + } catch (e) { console.error(e); alert('Error deleting tag'); } +}; + +async function handlePublicAddOrSaveTag() { + const nameInput = document.getElementById('public-new-tag-name'); + const colorInput = document.getElementById('public-new-tag-color'); + if (!nameInput || !colorInput) return; + const tagName = nameInput.value.trim().toLowerCase(); + const tagColor = colorInput.value; + + if (!tagName) { alert('Please enter a tag name'); return; } + if (!/^[a-z0-9_-]+$/.test(tagName)) { alert('Tag name must contain only lowercase letters, numbers, hyphens, and underscores'); return; } + + if (publicEditingTag) { + // Edit mode + const nameChanged = tagName !== publicEditingTag.originalName; + const colorChanged = tagColor !== publicEditingTag.originalColor; + if (!nameChanged && !colorChanged) { publicCancelEditMode(); return; } + if (nameChanged && publicWorkspaceTags.some(t => t.name === tagName && t.name !== publicEditingTag.originalName)) { + alert('A tag with this name already exists'); return; + } + try { + const body = {}; + if (nameChanged) body.new_name = tagName; + if (colorChanged) body.color = tagColor; + const resp = await fetch(`/api/public_workspace_documents/tags/${encodeURIComponent(publicEditingTag.originalName)}`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) + }); + const data = await resp.json(); + if (resp.ok) { + publicCancelEditMode(); + await loadPublicWorkspaceTags(); + refreshPublicTagManagementTable(); + if (publicCurrentView === 'grid') renderPublicGridView(); + } else { alert('Error: ' + (data.error || 'Failed to update tag')); } + } catch (e) { console.error(e); alert('Error updating tag'); } + } else { + // Add mode + if (publicWorkspaceTags.some(t => t.name === tagName)) { alert('A tag with this name already exists'); return; } + try { + const resp = await fetch('/api/public_workspace_documents/tags', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tag_name: tagName, color: tagColor }) + }); + const data = await resp.json(); + if (resp.ok) { + nameInput.value = ''; + colorInput.value = '#0d6efd'; + await loadPublicWorkspaceTags(); + refreshPublicTagManagementTable(); + if (publicCurrentView === 'grid') renderPublicGridView(); + } else { alert('Error: ' + (data.error || 'Failed to create tag')); } + } catch (e) { console.error(e); alert('Error creating tag'); } + } +} + +// --- Tag Selection Modal --- +function showPublicTagSelectionModal() { + loadPublicWorkspaceTags().then(() => { + renderPublicTagSelectionList(); + publicTagSelectionModal.show(); + }); +} + +function renderPublicTagSelectionList() { + const listContainer = document.getElementById('public-tag-selection-list'); + if (!listContainer) return; + if (publicWorkspaceTags.length === 0) { + listContainer.innerHTML = `
    +

    No tags available yet.

    + +
    `; + document.getElementById('public-create-first-tag-btn')?.addEventListener('click', () => { + publicTagSelectionModal.hide(); + showPublicTagManagementModal(); + }); + return; + } + let html = ''; + publicWorkspaceTags.forEach(tag => { + const isSelected = publicDocSelectedTags.has(tag.name); + const textColor = isPublicColorLight(tag.color) ? '#000' : '#fff'; + html += ``; + }); + listContainer.innerHTML = html; + listContainer.querySelectorAll('input[type="checkbox"]').forEach(cb => { + cb.addEventListener('change', (e) => { + if (e.target.checked) publicDocSelectedTags.add(e.target.value); + else publicDocSelectedTags.delete(e.target.value); + }); + }); +} + +// --- Document Tags Display --- +function updatePublicDocTagsDisplay() { + const container = document.getElementById('public-doc-selected-tags-container'); + if (!container) return; + if (publicDocSelectedTags.size === 0) { + container.innerHTML = 'No tags selected'; + return; + } + let html = ''; + publicDocSelectedTags.forEach(tagName => { + const tag = publicWorkspaceTags.find(t => t.name === tagName); + const color = tag ? tag.color : '#6c757d'; + const textColor = isPublicColorLight(color) ? '#000' : '#fff'; + html += ` + ${escapePublicHtml(tagName)} + + `; + }); + container.innerHTML = html; +} + +window.removePublicDocSelectedTag = function(tagName) { + publicDocSelectedTags.delete(tagName); + updatePublicDocTagsDisplay(); +}; + +// --- Wire up events --- +(function initPublicTagManagement() { + // Manage Tags button (next to view toggle) + const manageTagsBtn = document.getElementById('public-manage-tags-btn'); + if (manageTagsBtn) { + manageTagsBtn.addEventListener('click', showPublicTagManagementModal); + } + + // Manage Tags button inside metadata modal (opens Select Tags) + const docManageTagsBtn = document.getElementById('public-doc-manage-tags-btn'); + if (docManageTagsBtn) { + docManageTagsBtn.addEventListener('click', () => { + showPublicTagSelectionModal(); + }); + } + + // Tag Selection Done button + const tagSelectDoneBtn = document.getElementById('public-tag-selection-done-btn'); + if (tagSelectDoneBtn) { + tagSelectDoneBtn.addEventListener('click', () => { + updatePublicDocTagsDisplay(); + publicTagSelectionModal.hide(); + }); + } + + // Open Manage Tags from within Selection modal + const openMgmtBtn = document.getElementById('public-open-tag-mgmt-btn'); + if (openMgmtBtn) { + openMgmtBtn.addEventListener('click', () => { + publicTagSelectionModal.hide(); + showPublicTagManagementModal(); + }); + } + + // Add/Save tag button in management modal + const addTagBtn = document.getElementById('public-add-tag-btn'); + if (addTagBtn) addTagBtn.addEventListener('click', handlePublicAddOrSaveTag); + + // Cancel edit button + const cancelEditBtn = document.getElementById('public-cancel-edit-btn'); + if (cancelEditBtn) cancelEditBtn.addEventListener('click', publicCancelEditMode); + + // Enter key on tag name input + const tagNameInput = document.getElementById('public-new-tag-name'); + if (tagNameInput) { + tagNameInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { e.preventDefault(); handlePublicAddOrSaveTag(); } + }); + } + + // When tag management modal closes, reset edit mode + document.getElementById('publicTagManagementModal')?.addEventListener('hidden.bs.modal', () => { + publicCancelEditMode(); + }); +})(); diff --git a/application/single_app/static/js/workspace/workspace-documents.js b/application/single_app/static/js/workspace/workspace-documents.js index f6695fa3..481fe310 100644 --- a/application/single_app/static/js/workspace/workspace-documents.js +++ b/application/single_app/static/js/workspace/workspace-documents.js @@ -1,6 +1,8 @@ // static/js/workspace/workspace-documents.js import { escapeHtml } from "./workspace-utils.js"; +import { initializeTags, renderTagBadges, loadWorkspaceTags } from "./workspace-tags.js"; +import { getSelectedTagsArray, setSelectedTags, clearSelectedTags, updateDocumentTagsDisplay, loadWorkspaceTags as loadTagManagementTags } from './workspace-tag-management.js'; // ------------- State Variables ------------- let docsCurrentPage = 1; @@ -10,6 +12,9 @@ let docsClassificationFilter = ''; let docsAuthorFilter = ''; // Added for Author filter let docsKeywordsFilter = ''; // Added for Keywords filter let docsAbstractFilter = ''; // Added for Abstract filter +let docsTagsFilter = ''; // Added for Tags filter +let docsSortBy = '_ts'; // Current sort field +let docsSortOrder = 'desc'; // Current sort order const activePolls = new Set(); // ------------- DOM Elements (Documents Tab) ------------- @@ -38,10 +43,17 @@ const docsClassificationFilterSelect = (window.enable_document_classification == const docsAuthorFilterInput = document.getElementById('docs-author-filter'); const docsKeywordsFilterInput = document.getElementById('docs-keywords-filter'); const docsAbstractFilterInput = document.getElementById('docs-abstract-filter'); +const docsTagsFilterSelect = document.getElementById('docs-tags-filter'); // Buttons (get them regardless, they might be rendered in different places) const docsApplyFiltersBtn = document.getElementById('docs-apply-filters-btn'); const docsClearFiltersBtn = document.getElementById('docs-clear-filters-btn'); +// Expose state variables globally for workspace-tags.js +window.docsCurrentPage = docsCurrentPage; +window.docsTagsFilter = docsTagsFilter; +window.selectedDocuments = selectedDocuments; +window.fetchUserDocuments = fetchUserDocuments; + // ------------- Helper Functions ------------- function isColorLight(hexColor) { if (!hexColor) return true; // Default to light if no color @@ -92,6 +104,14 @@ if (docsApplyFiltersBtn) { docsAuthorFilter = docsAuthorFilterInput ? docsAuthorFilterInput.value.trim() : ''; docsKeywordsFilter = docsKeywordsFilterInput ? docsKeywordsFilterInput.value.trim() : ''; docsAbstractFilter = docsAbstractFilterInput ? docsAbstractFilterInput.value.trim() : ''; + + // Get selected tags + if (docsTagsFilterSelect) { + const selectedOptions = Array.from(docsTagsFilterSelect.selectedOptions); + docsTagsFilter = selectedOptions.map(opt => opt.value).join(','); + } else { + docsTagsFilter = ''; + } docsCurrentPage = 1; // Reset to first page fetchUserDocuments(); @@ -120,12 +140,19 @@ if (docsClearFiltersBtn) { if (docsAuthorFilterInput) docsAuthorFilterInput.value = ''; if (docsKeywordsFilterInput) docsKeywordsFilterInput.value = ''; if (docsAbstractFilterInput) docsAbstractFilterInput.value = ''; + if (docsTagsFilterSelect) { + Array.from(docsTagsFilterSelect.options).forEach(opt => opt.selected = false); + } docsSearchTerm = ''; docsClassificationFilter = ''; docsAuthorFilter = ''; docsKeywordsFilter = ''; docsAbstractFilter = ''; + docsTagsFilter = ''; + docsSortBy = '_ts'; + docsSortOrder = 'desc'; + updateListSortIcons(); docsCurrentPage = 1; // Reset to first page fetchUserDocuments(); @@ -159,6 +186,37 @@ if (docsSearchInput) { } }); +// Sortable column headers in list view +document.querySelectorAll('#documents-table .sortable-header').forEach(th => { + th.addEventListener('click', () => { + const field = th.getAttribute('data-sort-field'); + if (docsSortBy === field) { + docsSortOrder = docsSortOrder === 'asc' ? 'desc' : 'asc'; + } else { + docsSortBy = field; + docsSortOrder = 'asc'; + } + docsCurrentPage = 1; + updateListSortIcons(); + fetchUserDocuments(); + }); +}); + +function updateListSortIcons() { + document.querySelectorAll('#documents-table .sortable-header').forEach(th => { + const field = th.getAttribute('data-sort-field'); + const icon = th.querySelector('.sort-icon'); + if (!icon) return; + if (field === docsSortBy) { + icon.className = docsSortOrder === 'asc' + ? 'bi bi-sort-alpha-down small sort-icon' + : 'bi bi-sort-alpha-up small sort-icon'; + } else { + icon.className = 'bi bi-arrow-down-up text-muted small sort-icon'; + } + }); +} + // Metadata Modal Form Submission if (docMetadataForm && docMetadataModalEl) { // Check both exist @@ -184,6 +242,9 @@ if (docMetadataForm && docMetadataModalEl) { // Check both exist if (payload.authors) { payload.authors = payload.authors.split(",").map(a => a.trim()).filter(Boolean); } else { payload.authors = []; } + + // Get selected tags from the tag management system + payload.tags = getSelectedTagsArray(); // Add classification if enabled AND selected (handle 'none' value) // Use the window flag to check if classification is enabled @@ -206,6 +267,7 @@ if (docMetadataForm && docMetadataModalEl) { // Check both exist .then(updatedDoc => { if (docMetadataModalEl) docMetadataModalEl.hide(); fetchUserDocuments(); // Refresh the table + loadWorkspaceTags(); // Refresh tag counts and grid view }) .catch(err => { console.error("Error updating document:", err); @@ -450,10 +512,21 @@ function fetchUserDocuments() { if (docsAbstractFilter) { params.append('abstract', docsAbstractFilter); // Assumes backend uses 'abstract' } + // Add tags filter if selected + if (docsTagsFilter) { + params.append('tags', docsTagsFilter); // Comma-separated tags + } // Add shared only filter if (docsSharedOnlyFilter && docsSharedOnlyFilter.checked) { params.append('shared_only', 'true'); } + // Add sort parameters + if (docsSortBy !== '_ts') { + params.append('sort_by', docsSortBy); + } + if (docsSortOrder !== 'desc') { + params.append('sort_order', docsSortOrder); + } console.log("Fetching documents with params:", params.toString()); // Debugging: Check params @@ -467,7 +540,7 @@ function fetchUserDocuments() { documentsTableBody.innerHTML = ""; // Clear loading/existing rows if (!data.documents || data.documents.length === 0) { // Check if any filters are active - const filtersActive = docsSearchTerm || docsClassificationFilter || docsAuthorFilter || docsKeywordsFilter || docsAbstractFilter; + const filtersActive = docsSearchTerm || docsClassificationFilter || docsAuthorFilter || docsKeywordsFilter || docsAbstractFilter || docsTagsFilter; documentsTableBody.innerHTML = ` @@ -496,6 +569,15 @@ function fetchUserDocuments() { Array.isArray(doc.shared_user_ids) && doc.shared_user_ids.length > 0 ); } + // Client-side sort to ensure correct order + if (docsSortBy !== '_ts') { + docs.sort((a, b) => { + const valA = (a[docsSortBy] || '').toLowerCase(); + const valB = (b[docsSortBy] || '').toLowerCase(); + const cmp = valA.localeCompare(valB); + return docsSortOrder === 'asc' ? cmp : -cmp; + }); + } window.lastFetchedDocs = docs; docs.forEach(doc => renderDocumentRow(doc)); } @@ -602,10 +684,10 @@ function renderDocumentRow(doc) { `; } - // Add Search in Chat option + // Add Chat option actionsDropdown += `
  • - Search in Chat + Chat
  • `; @@ -724,6 +806,7 @@ function renderDocumentRow(doc) {

    Citations: ${doc.enhanced_citations ? 'Enhanced' : 'Standard'}

    Publication Date: ${escapeHtml(doc.publication_date || "N/A")}

    Keywords: ${escapeHtml(Array.isArray(doc.keywords) ? doc.keywords.join(", ") : doc.keywords || "N/A")}

    +

    Tags: ${renderTagBadges(doc.tags || [])}

    Abstract: ${escapeHtml(doc.abstract || "N/A")}


    @@ -1084,7 +1167,7 @@ window.onEditDocument = function(docId) { const docKeywordsInput = document.getElementById("doc-keywords"); const docPubDateInput = document.getElementById("doc-publication-date"); const docAuthorsInput = document.getElementById("doc-authors"); - const classificationSelect = document.getElementById("doc-classification"); // Use the correct ID + const classificationSelect = document.getElementById("doc-classification"); if (docIdInput) docIdInput.value = doc.id; if (docTitleInput) docTitleInput.value = doc.title || ""; @@ -1092,6 +1175,15 @@ window.onEditDocument = function(docId) { if (docKeywordsInput) docKeywordsInput.value = Array.isArray(doc.keywords) ? doc.keywords.join(", ") : (doc.keywords || ""); if (docPubDateInput) docPubDateInput.value = doc.publication_date || ""; if (docAuthorsInput) docAuthorsInput.value = Array.isArray(doc.authors) ? doc.authors.join(", ") : (doc.authors || ""); + + // Set selected tags in the new tag management system + const docTags = doc.tags || []; + setSelectedTags(docTags); + + // Load workspace tags (for color info) then update the display + loadTagManagementTags().then(() => { + updateDocumentTagsDisplay(); + }); // Handle classification dropdown visibility and value based on the window flag - CORRECTED CHECK if ((window.enable_document_classification === true || window.enable_document_classification === "true") && classificationSelect) { @@ -1268,6 +1360,13 @@ window.redirectToChat = function(documentId) { window.location.href = `/chats?search_documents=true&doc_scope=personal&document_id=${documentId}`; } +window.chatWithSelected = function() { + const docIds = Array.from(window.selectedDocuments); + if (docIds.length === 0) return; + const idsParam = encodeURIComponent(docIds.join(',')); + window.location.href = `/chats?search_documents=true&doc_scope=personal&document_ids=${idsParam}`; +} + // Make fetchUserDocuments globally available for workspace-init.js window.fetchUserDocuments = fetchUserDocuments; diff --git a/application/single_app/static/js/workspace/workspace-init.js b/application/single_app/static/js/workspace/workspace-init.js index d3974e8f..145493fd 100644 --- a/application/single_app/static/js/workspace/workspace-init.js +++ b/application/single_app/static/js/workspace/workspace-init.js @@ -3,8 +3,17 @@ // Make sure fetch functions are available globally or imported if using modules consistently // Assuming fetchUserDocuments and fetchUserPrompts are now globally available via window.* assignments in their respective files +import { initializeTags } from './workspace-tags.js'; +import { initializeTagManagement } from './workspace-tag-management.js'; + document.addEventListener('DOMContentLoaded', () => { console.log("Workspace initializing..."); + + // Initialize tags functionality + initializeTags(); + + // Initialize tag management workflow + initializeTagManagement(); // Function to load data for the currently active tab function loadActiveTabData() { diff --git a/application/single_app/static/js/workspace/workspace-tag-management.js b/application/single_app/static/js/workspace/workspace-tag-management.js new file mode 100644 index 00000000..4ca18bc6 --- /dev/null +++ b/application/single_app/static/js/workspace/workspace-tag-management.js @@ -0,0 +1,732 @@ +// workspace-tag-management.js +// Handles the step-through tag management workflow + +import { escapeHtml } from "./workspace-utils.js"; + +// Debug logging helper +function debugLog(...args) { + // Always log with prefix for debugging + console.log('[TagManagement]', ...args); +} + +// Log app_settings availability on load +console.log('[TagManagement] Initializing - app_settings:', window.app_settings); +console.log('[TagManagement] Debug logging enabled:', window.app_settings?.debug_logging); + +// State for tag management +let allWorkspaceTags = []; +let selectedTags = new Set(); +let managementContext = null; // 'document' or 'bulk' +let editingTag = null; // Track if we're in edit mode: { originalName, originalColor } + +// ============= Initialize Tag Management System ============= + +export function initializeTagManagement() { + setupTagManagementModal(); + setupTagSelectionModal(); + setupDocumentTagButton(); + setupBulkTagButton(); + setupWorkspaceManageTagsButton(); +} + +// ============= Setup Modal Event Listeners ============= + +function setupTagManagementModal() { + const addTagBtn = document.getElementById('add-tag-btn'); + const cancelEditBtn = document.getElementById('cancel-edit-btn'); + const newTagNameInput = document.getElementById('new-tag-name'); + + if (addTagBtn) { + addTagBtn.addEventListener('click', handleAddOrSaveTag); + } + + if (cancelEditBtn) { + cancelEditBtn.addEventListener('click', cancelEditMode); + } + + if (newTagNameInput) { + newTagNameInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddOrSaveTag(); + } + }); + + // Real-time validation + newTagNameInput.addEventListener('input', validateTagNameInput); + } +} + +function setupTagSelectionModal() { + const doneBtn = document.getElementById('tag-selection-done-btn'); + + if (doneBtn) { + doneBtn.addEventListener('click', handleTagSelectionDone); + } +} + +function setupDocumentTagButton() { + const manageTagsBtn = document.getElementById('doc-manage-tags-btn'); + + if (manageTagsBtn) { + manageTagsBtn.addEventListener('click', () => { + managementContext = 'document'; + showTagSelectionModal(); + }); + } +} + +function setupBulkTagButton() { + const bulkManageBtn = document.getElementById('bulk-manage-tags-btn'); + + if (bulkManageBtn) { + bulkManageBtn.addEventListener('click', () => { + managementContext = 'bulk'; + showTagSelectionModal(); + }); + } +} + +function setupWorkspaceManageTagsButton() { + const workspaceManageBtn = document.getElementById('workspace-manage-tags-btn'); + + if (workspaceManageBtn) { + workspaceManageBtn.addEventListener('click', () => { + showTagManagementModal(); + }); + } +} + +// ============= Load Tags from API ============= + +export async function loadWorkspaceTags() { + try { + debugLog('Loading workspace tags...'); + const response = await fetch('/api/documents/tags'); + const data = await response.json(); + + debugLog('Tags API response:', { ok: response.ok, tagsCount: data.tags?.length }); + + if (response.ok && data.tags) { + allWorkspaceTags = data.tags; + debugLog('Loaded tags:', allWorkspaceTags); + refreshTagManagementTable(); + } else { + console.error('Failed to load tags:', data.error); + debugLog('Failed to load tags:', data.error); + } + } catch (error) { + console.error('Error loading tags:', error); + debugLog('Exception loading tags:', error); + } +} + +// ============= Show Tag Selection Modal ============= + +function showTagSelectionModal() { + // Load current tags first + loadWorkspaceTags().then(() => { + renderTagSelectionList(); + + const modal = new bootstrap.Modal(document.getElementById('tagSelectionModal')); + modal.show(); + }); +} + +function renderTagSelectionList() { + const listContainer = document.getElementById('tag-selection-list'); + if (!listContainer) return; + + if (allWorkspaceTags.length === 0) { + listContainer.innerHTML = ` +
    +

    No tags available yet.

    + +
    + `; + + document.getElementById('open-tag-mgmt-from-selection')?.addEventListener('click', () => { + bootstrap.Modal.getInstance(document.getElementById('tagSelectionModal')).hide(); + showTagManagementModal(); + }); + return; + } + + let html = ''; + html += ` +
    + Select tags to apply: + +
    + `; + + allWorkspaceTags.forEach(tag => { + const isSelected = selectedTags.has(tag.name); + const textColor = isColorLight(tag.color) ? '#000' : '#fff'; + + html += ` + + `; + }); + + listContainer.innerHTML = html; + + // Add event listeners to checkboxes + listContainer.querySelectorAll('input[type="checkbox"]').forEach(checkbox => { + checkbox.addEventListener('change', (e) => { + if (e.target.checked) { + selectedTags.add(e.target.value); + } else { + selectedTags.delete(e.target.value); + } + }); + }); + + // Add manage tags button handler + document.getElementById('open-tag-mgmt-btn')?.addEventListener('click', () => { + bootstrap.Modal.getInstance(document.getElementById('tagSelectionModal')).hide(); + showTagManagementModal(); + }); +} + +// ============= Show Tag Management Modal ============= + +export function showTagManagementModal(editTagName, editTagColor) { + loadWorkspaceTags().then(() => { + refreshTagManagementTable(); + + // If a tag name/color was provided, auto-enter edit mode for that tag + if (editTagName) { + const tag = allWorkspaceTags.find(t => t.name === editTagName); + const color = editTagColor || tag?.color || '#0d6efd'; + window.editTag(editTagName, color); + } + + const modal = new bootstrap.Modal(document.getElementById('tagManagementModal')); + modal.show(); + }); +} + +function refreshTagManagementTable() { + const tbody = document.getElementById('existing-tags-tbody'); + if (!tbody) { + debugLog('Cannot refresh table: tbody element not found'); + return; + } + + debugLog('Refreshing tag management table with', allWorkspaceTags.length, 'tags'); + + if (allWorkspaceTags.length === 0) { + debugLog('No tags to display'); + tbody.innerHTML = 'No tags yet. Add one above.'; + return; + } + + let html = ''; + allWorkspaceTags.forEach(tag => { + html += ` + + +
    + + + + ${escapeHtml(tag.name)} + + + ${tag.count} + + + + + + `; + }); + + tbody.innerHTML = html; + debugLog('Tag management table refreshed with', allWorkspaceTags.length, 'rows'); +} + +// ============= Add New Tag or Save Edit ============= + +async function handleAddOrSaveTag() { + const nameInput = document.getElementById('new-tag-name'); + const colorInput = document.getElementById('new-tag-color'); + + if (!nameInput || !colorInput) { + debugLog('Tag input elements not found'); + return; + } + + const tagName = nameInput.value.trim().toLowerCase(); + const tagColor = colorInput.value; + + if (editingTag) { + // We're in edit mode - save the changes + await saveTagEdit(tagName, tagColor); + } else { + // We're in add mode - create new tag + await createNewTag(tagName, tagColor); + } +} + +async function createNewTag(tagName, tagColor) { + debugLog('Attempting to create tag:', { tagName, tagColor }); + + if (!tagName) { + alert('Please enter a tag name'); + debugLog('Tag name is empty'); + return; + } + + // Validate tag name + if (!/^[a-z0-9_-]+$/.test(tagName)) { + alert('Tag name must contain only lowercase letters, numbers, hyphens, and underscores'); + debugLog('Tag name validation failed:', tagName); + return; + } + + // Check if tag already exists + if (allWorkspaceTags.some(t => t.name === tagName)) { + alert('A tag with this name already exists'); + debugLog('Tag already exists:', tagName); + return; + } + + try { + debugLog('Sending POST request to create tag...'); + const response = await fetch('/api/documents/tags', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tag_name: tagName, + color: tagColor + }) + }); + + const data = await response.json(); + debugLog('Create tag response:', { ok: response.ok, status: response.status, data }); + + if (response.ok) { + debugLog('Tag created successfully, clearing inputs and reloading tags'); + + // Clear inputs + const nameInput = document.getElementById('new-tag-name'); + const colorInput = document.getElementById('new-tag-color'); + nameInput.value = ''; + colorInput.value = '#0d6efd'; + + // Reload tags + debugLog('Reloading workspace tags after creation...'); + await loadWorkspaceTags(); + debugLog('Tags reloaded, current count:', allWorkspaceTags.length); + + // Refresh grid view tags (cross-module) + window.refreshWorkspaceTags?.(); + + // Show success message + showToast('Tag created successfully', 'success'); + } else { + debugLog('Failed to create tag:', data.error); + alert('Failed to create tag: ' + (data.error || 'Unknown error')); + } + } catch (error) { + console.error('Error creating tag:', error); + debugLog('Exception creating tag:', error); + alert('Error creating tag'); + } +} + +// ============= Edit Tag (Enter Edit Mode) ============= + +window.editTag = function(tagName, currentColor) { + debugLog(`Entering edit mode for tag: ${tagName}, color: ${currentColor}`); + + // Store original values + editingTag = { + originalName: tagName, + originalColor: currentColor + }; + + // Populate form with current values + const nameInput = document.getElementById('new-tag-name'); + const colorInput = document.getElementById('new-tag-color'); + const formTitle = document.getElementById('tag-form-title'); + const addBtn = document.getElementById('add-tag-btn'); + const cancelBtn = document.getElementById('cancel-edit-btn'); + + if (nameInput) nameInput.value = tagName; + if (colorInput) colorInput.value = currentColor; + if (formTitle) formTitle.textContent = 'Edit Tag'; + + // Update button appearance + if (addBtn) { + addBtn.innerHTML = ' Save'; + addBtn.classList.remove('btn-primary'); + addBtn.classList.add('btn-success'); + } + + // Show cancel button + if (cancelBtn) { + cancelBtn.classList.remove('d-none'); + } + + // Focus on name input + if (nameInput) nameInput.focus(); + + debugLog('Edit mode activated'); +}; + +// ============= Save Tag Edit ============= + +async function saveTagEdit(newName, newColor) { + if (!editingTag) { + debugLog('ERROR: saveTagEdit called but editingTag is null'); + return; + } + + debugLog('Saving tag edit:', { + originalName: editingTag.originalName, + newName, + originalColor: editingTag.originalColor, + newColor + }); + + const nameChanged = newName !== editingTag.originalName; + const colorChanged = newColor !== editingTag.originalColor; + + if (!nameChanged && !colorChanged) { + debugLog('No changes detected, cancelling edit mode'); + cancelEditMode(); + return; + } + + // Validate new name if changed + if (nameChanged) { + if (!newName) { + alert('Please enter a tag name'); + return; + } + + if (!/^[a-z0-9_-]+$/.test(newName)) { + alert('Tag name must contain only lowercase letters, numbers, hyphens, and underscores'); + return; + } + + // Check if new name conflicts with existing tag (excluding current tag) + if (allWorkspaceTags.some(t => t.name === newName && t.name !== editingTag.originalName)) { + alert('A tag with this name already exists'); + return; + } + } + + try { + const requestBody = { + new_name: nameChanged ? newName : undefined, + color: colorChanged ? newColor : undefined + }; + + debugLog(`Sending PATCH request to: /api/documents/tags/${encodeURIComponent(editingTag.originalName)}`); + debugLog('Request body:', requestBody); + + const response = await fetch(`/api/documents/tags/${encodeURIComponent(editingTag.originalName)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody) + }); + + const data = await response.json(); + debugLog('PATCH response:', { ok: response.ok, status: response.status, data }); + + if (response.ok) { + debugLog('Tag updated successfully'); + + // Exit edit mode + cancelEditMode(); + + // Reload tags + await loadWorkspaceTags(); + + // Refresh grid view tags (cross-module) + window.refreshWorkspaceTags?.(); + + // Show success message + showToast('Tag updated successfully', 'success'); + } else { + debugLog('Failed to update tag:', data.error); + alert('Failed to update tag: ' + (data.error || 'Unknown error')); + } + } catch (error) { + console.error('Error updating tag:', error); + debugLog('Exception updating tag:', error); + alert('Error updating tag'); + } +} + +// ============= Cancel Edit Mode ============= + +function cancelEditMode() { + debugLog('Cancelling edit mode'); + + // Clear editing state + editingTag = null; + + // Reset form + const nameInput = document.getElementById('new-tag-name'); + const colorInput = document.getElementById('new-tag-color'); + const formTitle = document.getElementById('tag-form-title'); + const addBtn = document.getElementById('add-tag-btn'); + const cancelBtn = document.getElementById('cancel-edit-btn'); + + if (nameInput) nameInput.value = ''; + if (colorInput) colorInput.value = '#0d6efd'; + if (formTitle) formTitle.textContent = 'Add New Tag'; + + // Reset button appearance + if (addBtn) { + addBtn.innerHTML = ' Add'; + addBtn.classList.remove('btn-success'); + addBtn.classList.add('btn-primary'); + } + + // Hide cancel button + if (cancelBtn) { + cancelBtn.classList.add('d-none'); + } + + debugLog('Edit mode cancelled, form reset to add mode'); +} + +// ============= Input Validation ============= + +function validateTagNameInput(e) { + const input = e.target; + const value = input.value.toLowerCase(); + + // Remove invalid characters as user types + input.value = value.replace(/[^a-z0-9_-]/g, ''); +} + +// ============= Delete Tag ============= + +let pendingDeleteTagName = null; // Track which tag is pending deletion + +window.deleteTag = function(tagName) { + debugLog(`Delete tag clicked: ${tagName}`); + + // Store the tag name for deletion + pendingDeleteTagName = tagName; + + // Update modal content + const displayElement = document.getElementById('delete-tag-name-display'); + if (displayElement) { + displayElement.textContent = `"${tagName}"`; + } + + // Show confirmation modal + const modal = new bootstrap.Modal(document.getElementById('deleteTagConfirmModal')); + modal.show(); +}; + +// Setup delete confirmation button +function setupDeleteConfirmation() { + const confirmBtn = document.getElementById('confirm-delete-tag-btn'); + if (confirmBtn) { + confirmBtn.addEventListener('click', async () => { + if (!pendingDeleteTagName) { + debugLog('ERROR: No tag pending deletion'); + return; + } + + debugLog(`Confirming deletion of tag: ${pendingDeleteTagName}`); + + try { + const response = await fetch(`/api/documents/tags/${encodeURIComponent(pendingDeleteTagName)}`, { + method: 'DELETE' + }); + + const data = await response.json(); + + if (response.ok) { + debugLog('Tag deleted successfully'); + + // Close the confirmation modal + const modal = bootstrap.Modal.getInstance(document.getElementById('deleteTagConfirmModal')); + if (modal) modal.hide(); + + // Clear pending tag + pendingDeleteTagName = null; + + // Reload tags + await loadWorkspaceTags(); + + // Refresh grid view tags (cross-module) + window.refreshWorkspaceTags?.(); + + showToast('Tag deleted successfully', 'success'); + } else { + debugLog('Failed to delete tag:', data.error); + alert('Failed to delete tag: ' + (data.error || 'Unknown error')); + } + } catch (error) { + console.error('Error deleting tag:', error); + debugLog('Exception deleting tag:', error); + alert('Error deleting tag'); + } + }); + } +} + +// Call this during initialization +document.addEventListener('DOMContentLoaded', () => { + setupDeleteConfirmation(); +}); + +// ============= Handle Tag Selection Done ============= + +function handleTagSelectionDone() { + // Update the display based on context + if (managementContext === 'document') { + updateDocumentTagsDisplay(); + } else if (managementContext === 'bulk') { + updateBulkTagsDisplay(); + } + + // Close modal + bootstrap.Modal.getInstance(document.getElementById('tagSelectionModal')).hide(); +} + +export function updateDocumentTagsDisplay() { + const container = document.getElementById('doc-selected-tags-container'); + if (!container) return; + + if (selectedTags.size === 0) { + container.innerHTML = 'No tags selected'; + return; + } + + let html = ''; + selectedTags.forEach(tagName => { + const tag = allWorkspaceTags.find(t => t.name === tagName); + if (tag) { + const textColor = isColorLight(tag.color) ? '#000' : '#fff'; + html += ` + + ${escapeHtml(tag.name)} + + + `; + } + }); + + container.innerHTML = html; +} + +function updateBulkTagsDisplay() { + const listContainer = document.getElementById('bulk-tags-list'); + if (!listContainer) return; + + if (selectedTags.size === 0) { + listContainer.innerHTML = '
    No tags selected
    '; + return; + } + + let html = '
    '; + selectedTags.forEach(tagName => { + const tag = allWorkspaceTags.find(t => t.name === tagName); + if (tag) { + const textColor = isColorLight(tag.color) ? '#000' : '#fff'; + html += ` + + ${escapeHtml(tag.name)} + + `; + } + }); + html += '
    '; + + listContainer.innerHTML = html; +} + +window.removeSelectedTag = function(tagName) { + selectedTags.delete(tagName); + updateDocumentTagsDisplay(); +}; + +// ============= Export Selected Tags ============= + +export function getSelectedTagsArray() { + return Array.from(selectedTags); +} + +export function setSelectedTags(tags) { + selectedTags = new Set(tags || []); +} + +export function clearSelectedTags() { + selectedTags.clear(); +} + +// ============= Utility Functions ============= + +function isColorLight(color) { + // Convert hex to RGB + const hex = color.replace('#', ''); + const r = parseInt(hex.substr(0, 2), 16); + const g = parseInt(hex.substr(2, 2), 16); + const b = parseInt(hex.substr(4, 2), 16); + + // Calculate relative luminance + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + + return luminance > 0.5; +} + +function showToast(message, type = 'info') { + // Create toast element + const toastHtml = ` + + `; + + // Add to container + let container = document.getElementById('toast-container'); + if (!container) { + container = document.createElement('div'); + container.id = 'toast-container'; + container.className = 'toast-container position-fixed top-0 end-0 p-3'; + document.body.appendChild(container); + } + + const temp = document.createElement('div'); + temp.innerHTML = toastHtml; + const toastElement = temp.firstElementChild; + container.appendChild(toastElement); + + const toast = new bootstrap.Toast(toastElement); + toast.show(); + + // Remove after hidden + toastElement.addEventListener('hidden.bs.toast', () => { + toastElement.remove(); + }); +} diff --git a/application/single_app/static/js/workspace/workspace-tags.js b/application/single_app/static/js/workspace/workspace-tags.js new file mode 100644 index 00000000..a8e8e705 --- /dev/null +++ b/application/single_app/static/js/workspace/workspace-tags.js @@ -0,0 +1,1257 @@ +// static/js/workspace/workspace-tags.js +// Handles tag management for workspace documents + +import { escapeHtml } from "./workspace-utils.js"; +import { showTagManagementModal } from "./workspace-tag-management.js"; + +// ============= State Variables ============= +let workspaceTags = []; // All available workspace tags with colors +let currentView = 'list'; // 'list' or 'grid' +let selectedTagFilter = []; +let currentFolder = null; // null = folder overview, string = tag name being viewed +let currentFolderType = null; // null | 'tag' | 'classification' +let folderCurrentPage = 1; +let folderPageSize = 10; +let gridSortBy = 'count'; // 'count' or 'name' +let gridSortOrder = 'desc'; // 'asc' or 'desc' +let folderSortBy = '_ts'; // Sort field for folder drill-down +let folderSortOrder = 'desc'; // Sort order for folder drill-down +let folderSearchTerm = ''; // Search term for folder drill-down + +// ============= Initialization ============= + +export function initializeTags() { + // Load workspace tags + loadWorkspaceTags(); + + // Setup view switcher + setupViewSwitcher(); + + // Setup tag filter + setupTagFilter(); + + // Setup bulk tag management + setupBulkTagManagement(); + + // Wire static grid sort buttons + document.querySelectorAll('#grid-controls-bar .grid-sort-btn').forEach(btn => { + btn.addEventListener('click', () => { + const field = btn.getAttribute('data-sort'); + if (gridSortBy === field) { + gridSortOrder = gridSortOrder === 'asc' ? 'desc' : 'asc'; + } else { + gridSortBy = field; + gridSortOrder = field === 'name' ? 'asc' : 'desc'; + } + renderGridView(); + }); + }); + + // Wire grid page-size select + const gridPageSizeSelect = document.getElementById('grid-page-size-select'); + if (gridPageSizeSelect) { + gridPageSizeSelect.addEventListener('change', (e) => { + folderPageSize = parseInt(e.target.value, 10); + folderCurrentPage = 1; + if (currentFolder) { + renderFolderContents(currentFolder); + } + }); + } + + // Load saved view preference + const savedView = localStorage.getItem('personalWorkspaceViewPreference'); + if (savedView === 'grid') { + document.getElementById('docs-view-grid').checked = true; + switchView('grid'); + } +} + +// ============= Load Workspace Tags ============= + +// Expose for cross-module refresh (avoids circular imports) +window.refreshWorkspaceTags = () => loadWorkspaceTags(); + +export async function loadWorkspaceTags() { + try { + const response = await fetch('/api/documents/tags'); + const data = await response.json(); + + if (response.ok && data.tags) { + workspaceTags = data.tags; + updateTagFilterOptions(); + updateDocTagsSelect(); + updateBulkTagSelect(); + + // Update grid view if visible + if (currentView === 'grid') { + renderGridView(); + } + } else { + console.error('Failed to load workspace tags:', data.error); + } + } catch (error) { + console.error('Error loading workspace tags:', error); + } +} + +// ============= View Switcher ============= + +function setupViewSwitcher() { + const listRadio = document.getElementById('docs-view-list'); + const gridRadio = document.getElementById('docs-view-grid'); + + if (listRadio) { + listRadio.addEventListener('change', () => { + if (listRadio.checked) { + switchView('list'); + } + }); + } + + if (gridRadio) { + gridRadio.addEventListener('change', () => { + if (gridRadio.checked) { + switchView('grid'); + } + }); + } +} + +function switchView(view) { + currentView = view; + localStorage.setItem('personalWorkspaceViewPreference', view); + + const listView = document.getElementById('documents-list-view'); + const gridView = document.getElementById('documents-grid-view'); + const viewInfo = document.getElementById('docs-view-info'); + const listControls = document.getElementById('list-controls-bar'); + const gridControls = document.getElementById('grid-controls-bar'); + + const filterBtn = document.getElementById('docs-filters-toggle-btn'); + const filterCollapse = document.getElementById('docs-filters-collapse'); + + if (view === 'list') { + // Reset folder drill-down state + currentFolder = null; + currentFolderType = null; + folderCurrentPage = 1; + folderSortBy = '_ts'; + folderSortOrder = 'desc'; + folderSearchTerm = ''; + const tagContainer = document.getElementById('tag-folders-container'); + if (tagContainer) tagContainer.className = 'row g-2'; + + listView.style.display = 'block'; + gridView.style.display = 'none'; + if (listControls) listControls.style.display = 'flex'; + if (gridControls) gridControls.style.display = 'none'; + if (filterBtn) filterBtn.style.display = ''; + if (viewInfo) viewInfo.textContent = ''; + // Trigger reload of documents if needed + window.fetchUserDocuments?.(); + } else { + listView.style.display = 'none'; + gridView.style.display = 'block'; + if (listControls) listControls.style.display = 'none'; + if (gridControls) gridControls.style.display = 'flex'; + // Hide list view filters in grid folder overview + if (filterBtn) filterBtn.style.display = 'none'; + if (filterCollapse) { + const bsCollapse = bootstrap.Collapse.getInstance(filterCollapse); + if (bsCollapse) bsCollapse.hide(); + } + renderGridView(); + } +} + +// Update sort icons in the static grid control bar +function updateGridSortIcons() { + const bar = document.getElementById('grid-controls-bar'); + if (!bar) return; + bar.querySelectorAll('.grid-sort-icon').forEach(icon => { + const field = icon.getAttribute('data-sort'); + icon.className = 'bi ms-1 grid-sort-icon'; + icon.setAttribute('data-sort', field); + if (gridSortBy === field) { + if (field === 'name') { + icon.classList.add(gridSortOrder === 'asc' ? 'bi-sort-alpha-down' : 'bi-sort-alpha-up'); + } else { + icon.classList.add(gridSortOrder === 'asc' ? 'bi-sort-numeric-down' : 'bi-sort-numeric-up'); + } + } else { + icon.classList.add('bi-arrow-down-up', 'text-muted'); + } + }); +} + +// ============= Grid View Rendering ============= + +async function renderGridView() { + const container = document.getElementById('tag-folders-container'); + if (!container) return; + + // If inside a folder, check that the folder still exists + if (currentFolder && currentFolder !== '__untagged__' && currentFolder !== '__unclassified__') { + if (currentFolderType === 'classification') { + const categories = window.classification_categories || []; + const folderStillExists = categories.some(cat => cat.label === currentFolder); + if (!folderStillExists) { + currentFolder = null; + currentFolderType = null; + folderCurrentPage = 1; + } + } else { + const folderStillExists = workspaceTags.some(t => t.name === currentFolder); + if (!folderStillExists) { + currentFolder = null; + currentFolderType = null; + folderCurrentPage = 1; + } + } + } + + // If inside a folder, render folder contents instead + if (currentFolder) { + renderFolderContents(currentFolder); + return; + } + + // Clear view info + const viewInfo = document.getElementById('docs-view-info'); + if (viewInfo) viewInfo.textContent = ''; + + // Ensure container has grid layout + container.className = 'row g-2'; + + // Show loading + container.innerHTML = ` +
    +
    + Loading... +
    + Loading tag folders... +
    + `; + + // Get all documents to count untagged + try { + const docsResponse = await fetch('/api/documents?page_size=1000'); + const docsData = await docsResponse.json(); + const allDocs = docsData.documents || []; + + const untaggedCount = allDocs.filter(doc => !doc.tags || doc.tags.length === 0).length; + + // Classification folder data + const classificationEnabled = (window.enable_document_classification === true + || window.enable_document_classification === "true"); + const categories = classificationEnabled ? (window.classification_categories || []) : []; + const classificationCounts = {}; + let unclassifiedCount = 0; + if (classificationEnabled) { + allDocs.forEach(doc => { + const cls = doc.document_classification; + if (!cls || cls === '' || cls.toLowerCase() === 'none') { + unclassifiedCount++; + } else { + classificationCounts[cls] = (classificationCounts[cls] || 0) + 1; + } + }); + } + + // Build unified array of folder items + const folderItems = []; + + if (untaggedCount > 0) { + folderItems.push({ + type: 'tag', key: '__untagged__', displayName: 'Untagged', + count: untaggedCount, icon: 'bi-folder2-open', color: '#6c757d', isSpecial: true + }); + } + + if (classificationEnabled && unclassifiedCount > 0) { + folderItems.push({ + type: 'classification', key: '__unclassified__', displayName: 'Unclassified', + count: unclassifiedCount, icon: 'bi-bookmark', color: '#6c757d', isSpecial: true + }); + } + + workspaceTags.forEach(tag => { + folderItems.push({ + type: 'tag', key: tag.name, displayName: tag.name, + count: tag.count, icon: 'bi-folder-fill', color: tag.color, + isSpecial: false, tagData: tag + }); + }); + + if (classificationEnabled) { + categories.forEach(cat => { + const count = classificationCounts[cat.label] || 0; + if (count > 0) { + folderItems.push({ + type: 'classification', key: cat.label, displayName: cat.label, + count: count, icon: 'bi-bookmark-fill', color: cat.color || '#6c757d', + isSpecial: false + }); + } + }); + } + + // Sort: special folders first, then by user-selected sort + folderItems.sort((a, b) => { + if (a.isSpecial && !b.isSpecial) return -1; + if (!a.isSpecial && b.isSpecial) return 1; + if (gridSortBy === 'name') { + const cmp = a.displayName.localeCompare(b.displayName, undefined, { sensitivity: 'base' }); + return gridSortOrder === 'asc' ? cmp : -cmp; + } + // Default: sort by count + const cmp = a.count - b.count; + return gridSortOrder === 'asc' ? cmp : -cmp; + }); + + // Update sort icons in the static control bar + updateGridSortIcons(); + + let html = ''; + + // Render folder cards + folderItems.forEach(item => { + const escapedKey = escapeHtml(item.key); + const escapedName = escapeHtml(item.displayName); + const countLabel = `${item.count} file${item.count !== 1 ? 's' : ''}`; + + let actionsHtml = ''; + if (item.type === 'tag' && !item.isSpecial) { + actionsHtml = ` + `; + } else if (item.type === 'classification') { + actionsHtml = ` +
    + +
    `; + } else if (item.type === 'tag' && item.isSpecial) { + actionsHtml = ` +
    + +
    `; + } + + html += ` +
    +
    + ${actionsHtml} +
    +
    ${escapedName}
    +
    ${countLabel}
    +
    +
    + `; + }); + + if (folderItems.length === 0) { + html = ` +
    + +

    No folders yet. Add tags to documents to organize them into folders.

    +
    + `; + } + + container.innerHTML = html; + + // Add click handlers to folder cards + container.querySelectorAll('.tag-folder-card').forEach(card => { + card.addEventListener('click', (e) => { + if (e.target.closest('.tag-folder-actions')) return; + const tagName = card.getAttribute('data-tag'); + const folderType = card.getAttribute('data-folder-type') || 'tag'; + currentFolder = tagName; + currentFolderType = folderType; + folderCurrentPage = 1; + folderSortBy = '_ts'; + folderSortOrder = 'desc'; + folderSearchTerm = ''; + renderFolderContents(tagName); + }); + }); + + } catch (error) { + console.error('Error rendering grid view:', error); + container.innerHTML = ` +
    + +

    Error loading tag folders

    +
    + `; + } +} + +// ============= Folder Drill-Down ============= + +function buildBreadcrumbHtml(displayName, tagColor, folderType = 'tag') { + const icon = (folderType === 'classification') ? 'bi-bookmark-fill' : 'bi-folder-fill'; + return ` +
    + + All + + / + + + ${escapeHtml(displayName)} + +
    `; +} + +function wireBackButton(container) { + container.querySelectorAll('.grid-back-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + currentFolder = null; + currentFolderType = null; + folderCurrentPage = 1; + folderSortBy = '_ts'; + folderSortOrder = 'desc'; + folderSearchTerm = ''; + container.className = 'row g-2'; + // Show the grid controls bar again + const gridControls = document.getElementById('grid-controls-bar'); + if (gridControls) gridControls.style.display = 'flex'; + renderGridView(); + }); + }); +} + +function buildFolderDocumentsTable(docs) { + const fnIcon = folderSortBy === 'file_name' + ? (folderSortOrder === 'asc' ? 'bi-sort-alpha-down' : 'bi-sort-alpha-up') + : 'bi-arrow-down-up text-muted'; + const titleIcon = folderSortBy === 'title' + ? (folderSortOrder === 'asc' ? 'bi-sort-alpha-down' : 'bi-sort-alpha-up') + : 'bi-arrow-down-up text-muted'; + + let html = ` + + + + + + + + + + `; + + docs.forEach(doc => { + const docId = doc.id; + const pctStr = String(doc.percentage_complete); + const pct = /^\d+(\.\d+)?$/.test(pctStr) ? parseFloat(pctStr) : 0; + const docStatus = doc.status || ''; + const isComplete = pct >= 100 + || docStatus.toLowerCase().includes('complete') + || docStatus.toLowerCase().includes('error'); + const hasError = docStatus.toLowerCase().includes('error'); + + const currentUserId = window.current_user_id; + const isOwner = doc.user_id === currentUserId; + + // First column: expand/collapse or status indicator + let firstColHtml = ''; + if (isComplete && !hasError) { + firstColHtml = ` + `; + } else if (hasError) { + firstColHtml = ``; + } else { + firstColHtml = ``; + } + + // Chat button + let chatButton = ''; + if (isComplete && !hasError) { + chatButton = ` + `; + } + + // Ellipsis dropdown menu + let actionsDropdown = ''; + if (isComplete && !hasError) { + actionsDropdown = ` + `; + } else if (isOwner) { + actionsDropdown = ` + `; + } + + html += ` + + + + + + `; + }); + + html += '
    + File Name + + Title + Actions
    ${firstColHtml}${escapeHtml(doc.file_name || '')}${escapeHtml(doc.title || 'N/A')}${chatButton}${actionsDropdown}
    '; + return html; +} + +function renderFolderPagination(page, pageSize, totalCount) { + const paginationContainer = document.getElementById('folder-pagination'); + if (!paginationContainer) return; + paginationContainer.innerHTML = ''; + + const totalPages = Math.ceil(totalCount / pageSize); + if (totalPages <= 1) return; + + const ul = document.createElement('ul'); + ul.classList.add('pagination', 'pagination-sm', 'mb-0'); + + // Previous button + const prevLi = document.createElement('li'); + prevLi.classList.add('page-item'); + if (page <= 1) prevLi.classList.add('disabled'); + const prevA = document.createElement('a'); + prevA.classList.add('page-link'); + prevA.href = '#'; + prevA.innerHTML = '«'; + prevA.addEventListener('click', (e) => { + e.preventDefault(); + if (folderCurrentPage > 1) { + folderCurrentPage -= 1; + renderFolderContents(currentFolder); + } + }); + prevLi.appendChild(prevA); + ul.appendChild(prevLi); + + // Page numbers + const maxPages = 5; + let startPage = 1; + let endPage = totalPages; + if (totalPages > maxPages) { + const before = Math.floor(maxPages / 2); + const after = Math.ceil(maxPages / 2) - 1; + if (page <= before) { startPage = 1; endPage = maxPages; } + else if (page + after >= totalPages) { startPage = totalPages - maxPages + 1; endPage = totalPages; } + else { startPage = page - before; endPage = page + after; } + } + + if (startPage > 1) { + const firstLi = document.createElement('li'); firstLi.classList.add('page-item'); + const firstA = document.createElement('a'); firstA.classList.add('page-link'); firstA.href = '#'; firstA.textContent = '1'; + firstA.addEventListener('click', (e) => { e.preventDefault(); folderCurrentPage = 1; renderFolderContents(currentFolder); }); + firstLi.appendChild(firstA); ul.appendChild(firstLi); + if (startPage > 2) { + const ellipsis = document.createElement('li'); ellipsis.classList.add('page-item', 'disabled'); + ellipsis.innerHTML = '...'; ul.appendChild(ellipsis); + } + } + + for (let p = startPage; p <= endPage; p++) { + const li = document.createElement('li'); li.classList.add('page-item'); + if (p === page) { li.classList.add('active'); li.setAttribute('aria-current', 'page'); } + const a = document.createElement('a'); a.classList.add('page-link'); a.href = '#'; a.textContent = p; + a.addEventListener('click', ((pageNum) => (e) => { + e.preventDefault(); + if (folderCurrentPage !== pageNum) { + folderCurrentPage = pageNum; + renderFolderContents(currentFolder); + } + })(p)); + li.appendChild(a); ul.appendChild(li); + } + + if (endPage < totalPages) { + if (endPage < totalPages - 1) { + const ellipsis = document.createElement('li'); ellipsis.classList.add('page-item', 'disabled'); + ellipsis.innerHTML = '...'; ul.appendChild(ellipsis); + } + const lastLi = document.createElement('li'); lastLi.classList.add('page-item'); + const lastA = document.createElement('a'); lastA.classList.add('page-link'); lastA.href = '#'; lastA.textContent = totalPages; + lastA.addEventListener('click', (e) => { e.preventDefault(); folderCurrentPage = totalPages; renderFolderContents(currentFolder); }); + lastLi.appendChild(lastA); ul.appendChild(lastLi); + } + + // Next button + const nextLi = document.createElement('li'); + nextLi.classList.add('page-item'); + if (page >= totalPages) nextLi.classList.add('disabled'); + const nextA = document.createElement('a'); + nextA.classList.add('page-link'); + nextA.href = '#'; + nextA.innerHTML = '»'; + nextA.addEventListener('click', (e) => { + e.preventDefault(); + if (folderCurrentPage < totalPages) { + folderCurrentPage += 1; + renderFolderContents(currentFolder); + } + }); + nextLi.appendChild(nextA); + ul.appendChild(nextLi); + + paginationContainer.appendChild(ul); +} + +async function renderFolderContents(tagName) { + const container = document.getElementById('tag-folders-container'); + if (!container) return; + + // Hide the grid controls bar (folder sort buttons don't apply inside a folder) + const gridControls = document.getElementById('grid-controls-bar'); + if (gridControls) gridControls.style.display = 'none'; + + // Switch container from grid layout to single-column + container.className = ''; + + // Determine display values based on folder type + const isClassification = (currentFolderType === 'classification'); + let displayName, tagColor; + + if (tagName === '__untagged__') { + displayName = 'Untagged Documents'; + tagColor = '#6c757d'; + } else if (tagName === '__unclassified__') { + displayName = 'Unclassified Documents'; + tagColor = '#6c757d'; + } else if (isClassification) { + const categories = window.classification_categories || []; + const cat = categories.find(c => c.label === tagName); + displayName = tagName; + tagColor = cat?.color || '#6c757d'; + } else { + const tagInfo = workspaceTags.find(t => t.name === tagName); + displayName = tagName; + tagColor = tagInfo?.color || '#6c757d'; + } + + // Update view info + const viewInfo = document.getElementById('docs-view-info'); + if (viewInfo) viewInfo.textContent = `Viewing: ${displayName}`; + + // Show breadcrumb + loading spinner + container.innerHTML = buildBreadcrumbHtml(displayName, tagColor, currentFolderType || 'tag') + + `
    +
    + Loading... +
    + Loading documents... +
    `; + wireBackButton(container); + + try { + let docs, totalCount; + + if (tagName === '__untagged__') { + // Fetch all and filter client-side for untagged + const untaggedParams = new URLSearchParams({ page_size: 1000 }); + if (folderSearchTerm) untaggedParams.append('search', folderSearchTerm); + const allResponse = await fetch(`/api/documents?${untaggedParams.toString()}`); + const allData = await allResponse.json(); + const allUntagged = (allData.documents || []).filter( + doc => !doc.tags || doc.tags.length === 0 + ); + // Client-side sorting for untagged + if (folderSortBy !== '_ts') { + allUntagged.sort((a, b) => { + const valA = (a[folderSortBy] || '').toLowerCase(); + const valB = (b[folderSortBy] || '').toLowerCase(); + const cmp = valA.localeCompare(valB); + return folderSortOrder === 'asc' ? cmp : -cmp; + }); + } + totalCount = allUntagged.length; + const start = (folderCurrentPage - 1) * folderPageSize; + docs = allUntagged.slice(start, start + folderPageSize); + } else if (tagName === '__unclassified__') { + // Server-side filter for unclassified documents + const params = new URLSearchParams({ + page: folderCurrentPage, + page_size: folderPageSize, + classification: 'none' + }); + if (folderSearchTerm) params.append('search', folderSearchTerm); + if (folderSortBy !== '_ts') params.append('sort_by', folderSortBy); + if (folderSortOrder !== 'desc') params.append('sort_order', folderSortOrder); + const response = await fetch(`/api/documents?${params.toString()}`); + const data = await response.json(); + docs = data.documents || []; + totalCount = data.total_count || docs.length; + } else if (isClassification) { + // Server-side filter for a specific classification category + const params = new URLSearchParams({ + page: folderCurrentPage, + page_size: folderPageSize, + classification: tagName + }); + if (folderSearchTerm) params.append('search', folderSearchTerm); + if (folderSortBy !== '_ts') params.append('sort_by', folderSortBy); + if (folderSortOrder !== 'desc') params.append('sort_order', folderSortOrder); + const response = await fetch(`/api/documents?${params.toString()}`); + const data = await response.json(); + docs = data.documents || []; + totalCount = data.total_count || docs.length; + } else { + // Use server-side tag filtering with pagination + const params = new URLSearchParams({ + page: folderCurrentPage, + page_size: folderPageSize, + tags: tagName + }); + if (folderSearchTerm) params.append('search', folderSearchTerm); + if (folderSortBy !== '_ts') params.append('sort_by', folderSortBy); + if (folderSortOrder !== 'desc') params.append('sort_order', folderSortOrder); + const response = await fetch(`/api/documents?${params.toString()}`); + const data = await response.json(); + docs = data.documents || []; + totalCount = data.total_count || docs.length; + } + + // Client-side sort to ensure correct order (fallback if server-side ORDER BY is ignored) + if (folderSortBy !== '_ts' && docs.length > 1) { + docs.sort((a, b) => { + const valA = (a[folderSortBy] || '').toLowerCase(); + const valB = (b[folderSortBy] || '').toLowerCase(); + const cmp = valA.localeCompare(valB); + return folderSortOrder === 'asc' ? cmp : -cmp; + }); + } + + // Build the full view + let html = buildBreadcrumbHtml(displayName, tagColor, currentFolderType || 'tag'); + // Inline search bar for folder drill-down + html += `
    +
    + + +
    + ${totalCount} document(s) +
    + + items per page +
    +
    `; + + if (docs.length === 0) { + html += ` +
    + +

    No documents found in this folder.

    +
    `; + } else { + html += buildFolderDocumentsTable(docs); + html += '
    '; + } + + container.innerHTML = html; + wireBackButton(container); + + // Wire up folder page-size select + const folderPageSizeSelect = document.getElementById('folder-page-size-select'); + if (folderPageSizeSelect) { + folderPageSizeSelect.addEventListener('change', (e) => { + folderPageSize = parseInt(e.target.value, 10); + folderCurrentPage = 1; + renderFolderContents(currentFolder); + }); + } + + // Wire up folder search + const folderSearchInput = document.getElementById('folder-search-input'); + const folderSearchBtn = document.getElementById('folder-search-btn'); + if (folderSearchInput) { + const doSearch = () => { + folderSearchTerm = folderSearchInput.value.trim(); + folderCurrentPage = 1; + renderFolderContents(currentFolder); + }; + folderSearchBtn?.addEventListener('click', doSearch); + folderSearchInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); doSearch(); } + }); + // Clear search on the 'x' button in type=search + folderSearchInput.addEventListener('search', doSearch); + } + + // Wire up sortable column headers in folder drill-down table + container.querySelectorAll('.folder-sortable-header').forEach(th => { + th.addEventListener('click', () => { + const field = th.getAttribute('data-sort-field'); + if (folderSortBy === field) { + folderSortOrder = folderSortOrder === 'asc' ? 'desc' : 'asc'; + } else { + folderSortBy = field; + folderSortOrder = 'asc'; + } + folderCurrentPage = 1; + renderFolderContents(currentFolder); + }); + }); + + if (docs.length > 0) { + renderFolderPagination(folderCurrentPage, folderPageSize, totalCount); + } + } catch (error) { + console.error('Error loading folder contents:', error); + container.innerHTML = buildBreadcrumbHtml(displayName, tagColor, currentFolderType || 'tag') + + `
    + +

    Error loading documents.

    +
    `; + wireBackButton(container); + } +} + +// ============= Color Utility ============= + +function isColorLight(hexColor) { + if (!hexColor) return true; + const cleanHex = hexColor.startsWith('#') ? hexColor.substring(1) : hexColor; + if (cleanHex.length < 3) return true; + + let r, g, b; + try { + if (cleanHex.length === 3) { + r = parseInt(cleanHex[0] + cleanHex[0], 16); + g = parseInt(cleanHex[1] + cleanHex[1], 16); + b = parseInt(cleanHex[2] + cleanHex[2], 16); + } else if (cleanHex.length >= 6) { + r = parseInt(cleanHex.substring(0, 2), 16); + g = parseInt(cleanHex.substring(2, 4), 16); + b = parseInt(cleanHex.substring(4, 6), 16); + } else { + return true; + } + } catch (e) { + return true; + } + + if (isNaN(r) || isNaN(g) || isNaN(b)) return true; + const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; + return luminance > 0.5; +} + +// ============= Tag Filter Setup ============= + +function setupTagFilter() { + const filterSelect = document.getElementById('docs-tags-filter'); + if (!filterSelect) return; + + filterSelect.addEventListener('change', () => { + selectedTagFilter = Array.from(filterSelect.selectedOptions).map(opt => opt.value); + }); +} + +function updateTagFilterOptions() { + const filterSelect = document.getElementById('docs-tags-filter'); + if (!filterSelect) return; + + filterSelect.innerHTML = workspaceTags.map(tag => { + const textColor = isColorLight(tag.color) ? 'color: #212529' : 'color: #fff'; + return ``; + }).join(''); +} + +// ============= Document Tags Select (Metadata Modal) ============= + +function updateDocTagsSelect() { + const select = document.getElementById('doc-tags'); + if (!select) return; + + select.innerHTML = workspaceTags.map(tag => { + const textColor = isColorLight(tag.color) ? 'color: #212529' : 'color: #fff'; + return ``; + }).join(''); +} + +// ============= Bulk Tag Management ============= + +// Track selected tags for bulk operations +let bulkSelectedTags = new Set(); + +function setupBulkTagManagement() { + const manageTagsBtn = document.getElementById('manage-tags-btn'); + const bulkTagModal = document.getElementById('bulkTagModal'); + const bulkTagApplyBtn = document.getElementById('bulk-tag-apply-btn'); + + if (manageTagsBtn && bulkTagModal) { + const modalInstance = new bootstrap.Modal(bulkTagModal); + + manageTagsBtn.addEventListener('click', () => { + const count = window.selectedDocuments?.size || 0; + document.getElementById('bulk-tag-doc-count').textContent = count; + + // Clear selection and populate tags list + bulkSelectedTags.clear(); + updateBulkTagsList(); + + modalInstance.show(); + }); + + if (bulkTagApplyBtn) { + bulkTagApplyBtn.addEventListener('click', async () => { + await applyBulkTagChanges(); + modalInstance.hide(); + }); + } + } +} + +function updateBulkTagsList() { + const listContainer = document.getElementById('bulk-tags-list'); + if (!listContainer) return; + + if (workspaceTags.length === 0) { + listContainer.innerHTML = '
    No tags available. Click "Create New Tag" to add some.
    '; + return; + } + + let html = ''; + workspaceTags.forEach(tag => { + const textColor = isColorLight(tag.color) ? '#000' : '#fff'; + const isSelected = bulkSelectedTags.has(tag.name); + const selectedClass = isSelected ? 'selected border-dark' : ''; + const opacity = isSelected ? '1' : '0.7'; + + html += ` + + ${escapeHtml(tag.name)} + ${isSelected ? '' : ''} + + `; + }); + + listContainer.innerHTML = html; +} + +// Make toggle function global so onclick can access it +window.toggleBulkTag = function(tagName, color, element) { + if (bulkSelectedTags.has(tagName)) { + bulkSelectedTags.delete(tagName); + element.classList.remove('selected', 'border-dark'); + element.style.opacity = '0.7'; + // Remove checkmark + const icon = element.querySelector('.bi-check-circle-fill'); + if (icon) icon.remove(); + } else { + bulkSelectedTags.add(tagName); + element.classList.add('selected', 'border-dark'); + element.style.opacity = '1'; + // Add checkmark + element.innerHTML = `${escapeHtml(tagName)} `; + } +}; + +function updateBulkTagSelect() { + // This function is deprecated - now using updateBulkTagsList() + updateBulkTagsList(); +} + +async function applyBulkTagChanges() { + console.log('[Bulk Tag] Starting applyBulkTagChanges...'); + + const action = document.getElementById('bulk-tag-action').value; + console.log('[Bulk Tag] Action:', action); + + // Get selected tags from the bulkSelectedTags Set + const selectedTags = Array.from(bulkSelectedTags); + console.log('[Bulk Tag] Selected tags:', selectedTags); + console.log('[Bulk Tag] bulkSelectedTags Set:', bulkSelectedTags); + + const documentIds = Array.from(window.selectedDocuments || []); + console.log('[Bulk Tag] Document IDs:', documentIds); + console.log('[Bulk Tag] window.selectedDocuments:', window.selectedDocuments); + + if (documentIds.length === 0) { + console.log('[Bulk Tag] ERROR: No documents selected'); + alert('No documents selected'); + return; + } + + if (selectedTags.length === 0) { + console.log('[Bulk Tag] ERROR: No tags selected'); + alert('Please select at least one tag by clicking on it'); + return; + } + + // Show loading state + const applyBtn = document.getElementById('bulk-tag-apply-btn'); + const buttonText = applyBtn.querySelector('.button-text'); + const buttonLoading = applyBtn.querySelector('.button-loading'); + + applyBtn.disabled = true; + buttonText.classList.add('d-none'); + buttonLoading.classList.remove('d-none'); + + console.log('[Bulk Tag] Preparing request with:', { + document_ids: documentIds, + action: action, + tags: selectedTags + }); + + try { + console.log('[Bulk Tag] Sending POST to /api/documents/bulk-tag...'); + const response = await fetch('/api/documents/bulk-tag', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + document_ids: documentIds, + action: action, + tags: selectedTags + }) + }); + + console.log('[Bulk Tag] Response status:', response.status); + + const result = await response.json(); + console.log('[Bulk Tag] Response data:', result); + + // Log error details if any + if (result.errors && result.errors.length > 0) { + console.error('[Bulk Tag] Error details:', result.errors); + result.errors.forEach((err, idx) => { + console.error(`[Bulk Tag] Error ${idx + 1}:`, err); + }); + } + + if (response.ok) { + const successCount = result.success?.length || 0; + const errorCount = result.errors?.length || 0; + + console.log('[Bulk Tag] Success count:', successCount); + console.log('[Bulk Tag] Error count:', errorCount); + + let message = `Tags updated for ${successCount} document(s)`; + if (errorCount > 0) { + message += `\n${errorCount} document(s) had errors`; + } + alert(message); + + // Reload workspace tags and documents + console.log('[Bulk Tag] Reloading tags and documents...'); + await loadWorkspaceTags(); + window.fetchUserDocuments?.(); + + // Clear selection + window.selectedDocuments?.clear(); + updateSelectionUI(); + } else { + alert('Error: ' + (result.error || 'Failed to update tags')); + } + } catch (error) { + console.error('Error applying bulk tag changes:', error); + alert('Error updating tags'); + } finally { + // Reset button state + applyBtn.disabled = false; + buttonText.classList.remove('d-none'); + buttonLoading.classList.add('d-none'); + } +} + +function updateSelectionUI() { + const bulkActionsBar = document.getElementById('bulkActionsBar'); + const selectedCount = document.getElementById('selectedCount'); + const count = window.selectedDocuments?.size || 0; + + if (selectedCount) { + selectedCount.textContent = count; + } + + if (bulkActionsBar) { + bulkActionsBar.style.display = count > 0 ? 'block' : 'none'; + } +} + +// ============= Tag Display Helper ============= + +export function renderTagBadges(tags, maxDisplay = 3) { + if (!tags || tags.length === 0) { + return 'No tags'; + } + + let html = ''; + const displayTags = tags.slice(0, maxDisplay); + + displayTags.forEach(tagName => { + const tag = workspaceTags.find(t => t.name === tagName); + const color = tag?.color || '#6c757d'; + const textClass = isColorLight(color) ? 'text-dark' : 'text-light'; + + html += ` + ${escapeHtml(tagName)} + `; + }); + + if (tags.length > maxDisplay) { + html += `+${tags.length - maxDisplay}`; + } + + return html; +} + +// ============= Tag Management Actions (exposed globally) ============= + +window.renameTag = function(tagName) { + const tag = workspaceTags.find(t => t.name === tagName); + showTagManagementModal(tagName, tag?.color); +}; + +window.changeTagColor = function(tagName, currentColor) { + showTagManagementModal(tagName, currentColor); +}; + +window.deleteTag = async function(tagName) { + if (!confirm(`Delete tag "${tagName}" from all documents?`)) return; + + try { + const response = await fetch(`/api/documents/tags/${encodeURIComponent(tagName)}`, { + method: 'DELETE' + }); + + const result = await response.json(); + + if (response.ok) { + alert(result.message); + await loadWorkspaceTags(); + if (currentView === 'grid') { + renderGridView(); + } else { + window.fetchUserDocuments?.(); + } + } else { + alert('Error: ' + (result.error || 'Failed to delete tag')); + } + } catch (error) { + console.error('Error deleting tag:', error); + alert('Error deleting tag'); + } +}; + +window.chatWithFolder = function(folderType, folderName) { + const encoded = encodeURIComponent(folderName); + window.location.href = `/chats?search_documents=true&doc_scope=personal&tags=${encoded}`; +}; + +// ============= Export for use in other modules ============= + +export { workspaceTags, currentView, selectedTagFilter }; diff --git a/application/single_app/static/json/ai_search-index-group.json b/application/single_app/static/json/ai_search-index-group.json index 552bf49e..23538b78 100644 --- a/application/single_app/static/json/ai_search-index-group.json +++ b/application/single_app/static/json/ai_search-index-group.json @@ -230,6 +230,25 @@ "vectorEncoding": null, "synonymMaps": [] }, + { + "name": "document_tags", + "type": "Collection(Edm.String)", + "searchable": true, + "filterable": true, + "retrievable": true, + "stored": true, + "sortable": false, + "facetable": true, + "key": false, + "indexAnalyzer": null, + "searchAnalyzer": null, + "analyzer": "standard.lucene", + "normalizer": null, + "dimensions": null, + "vectorSearchProfile": null, + "vectorEncoding": null, + "synonymMaps": [] + }, { "name": "chunk_summary", "type": "Edm.String", diff --git a/application/single_app/static/json/ai_search-index-public.json b/application/single_app/static/json/ai_search-index-public.json index a465ddbe..fe5d987a 100644 --- a/application/single_app/static/json/ai_search-index-public.json +++ b/application/single_app/static/json/ai_search-index-public.json @@ -146,6 +146,19 @@ "analyzer": "standard.lucene", "synonymMaps": [] }, + { + "name": "document_tags", + "type": "Collection(Edm.String)", + "searchable": true, + "filterable": true, + "retrievable": true, + "stored": true, + "sortable": false, + "facetable": true, + "key": false, + "analyzer": "standard.lucene", + "synonymMaps": [] + }, { "name": "chunk_summary", "type": "Edm.String", diff --git a/application/single_app/static/json/ai_search-index-user.json b/application/single_app/static/json/ai_search-index-user.json index e8bc802d..32a68fda 100644 --- a/application/single_app/static/json/ai_search-index-user.json +++ b/application/single_app/static/json/ai_search-index-user.json @@ -230,6 +230,25 @@ "vectorEncoding": null, "synonymMaps": [] }, + { + "name": "document_tags", + "type": "Collection(Edm.String)", + "searchable": true, + "filterable": true, + "retrievable": true, + "stored": true, + "sortable": false, + "facetable": true, + "key": false, + "indexAnalyzer": null, + "searchAnalyzer": null, + "analyzer": "standard.lucene", + "normalizer": null, + "dimensions": null, + "vectorSearchProfile": null, + "vectorEncoding": null, + "synonymMaps": [] + }, { "name": "chunk_summary", "type": "Edm.String", diff --git a/application/single_app/templates/admin_settings.html b/application/single_app/templates/admin_settings.html index 70edcc45..f88eae27 100644 --- a/application/single_app/templates/admin_settings.html +++ b/application/single_app/templates/admin_settings.html @@ -2673,6 +2673,27 @@
    Default Retention Policies
    + +
    +
    + Workspace Scope Lock +
    +

    + Control whether users can unlock workspace scope in chat conversations. When scope is locked, conversations are restricted to the workspaces that produced search results, preventing accidental cross-contamination with other data sources. +

    +
    + + + +
    +
    +
    diff --git a/application/single_app/templates/chats.html b/application/single_app/templates/chats.html index 38d1c8c0..37c8c27d 100644 --- a/application/single_app/templates/chats.html +++ b/application/single_app/templates/chats.html @@ -208,6 +208,24 @@ border-top-left-radius: 0 !important; border-top-right-radius: 0 !important; } + + /* Scope lock styles */ + .scope-locked-item { + background-color: rgba(var(--bs-primary-rgb), 0.05) !important; + cursor: default !important; + pointer-events: none; + } + .scope-disabled-item { + opacity: 0.4; + cursor: not-allowed !important; + pointer-events: none; + } + .scope-lock-badge { + font-size: 0.65rem; + } + #scope-lock-indicator:hover { + color: var(--bs-primary) !important; + } {% endblock %} @@ -249,7 +267,17 @@
    -
    +
    + +
    +
    @@ -404,15 +432,55 @@
    {% endif %} @@ -895,6 +950,31 @@
    + + + {% endblock %} {% block scripts %} @@ -904,6 +984,8 @@ window.activeGroupId = "{{ active_group_id }}"; window.activeGroupName = "{{ active_group_name }}"; window.activePublicWorkspaceId = "{{ active_public_workspace_id }}"; + window.userGroups = {{ user_groups|tojson|safe }}; + window.userVisiblePublicWorkspaces = {{ user_visible_public_workspaces|tojson|safe }}; window.enableEnhancedCitations = "{{ enable_enhanced_citations }}"; window.enable_document_classification = "{{ enable_document_classification }}"; window.classification_categories = JSON.parse('{{ settings.document_classification_categories|tojson(indent=None)|safe }}' || '[]'); @@ -921,7 +1003,8 @@ window.appSettings = { enable_text_to_speech: {{ 'true' if app_settings.enable_text_to_speech else 'false' }}, enable_speech_to_text_input: {{ 'true' if app_settings.enable_speech_to_text_input else 'false' }}, - enable_web_search_user_notice: {{ 'true' if settings.enable_web_search_user_notice else 'false' }} + enable_web_search_user_notice: {{ 'true' if settings.enable_web_search_user_notice else 'false' }}, + enforce_workspace_scope_lock: {{ 'true' if settings.enforce_workspace_scope_lock else 'false' }} }; // Layout related globals (can stay here or move entirely into chat-layout.js if preferred) diff --git a/application/single_app/templates/group_workspaces.html b/application/single_app/templates/group_workspaces.html index e5e75e58..66960370 100644 --- a/application/single_app/templates/group_workspaces.html +++ b/application/single_app/templates/group_workspaces.html @@ -167,6 +167,52 @@ background-color: #dc3545; color: #fff; } + + /* === Grid View (Tag Folders) Styles === */ + .tag-folder-card { + border: 1px solid transparent; + border-radius: 0.5rem; + padding: 0.75rem 0.5rem; + text-align: center; + cursor: pointer; + transition: all 0.2s ease; + background: transparent; + position: relative; + } + .tag-folder-card:hover { + background: rgba(0,0,0,0.04); + border-color: #dee2e6; + } + .tag-folder-icon { font-size: 2.5rem; margin-bottom: 0.25rem; } + .tag-folder-name { + font-weight: 500; font-size: 0.8rem; margin-bottom: 0.15rem; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + max-width: 120px; margin-left: auto; margin-right: auto; + } + .tag-folder-count { color: #6c757d; font-size: 0.75rem; } + .tag-folder-actions { + position: absolute; top: 0.25rem; right: 0.25rem; + opacity: 0; transition: opacity 0.2s; + } + .tag-folder-card:hover .tag-folder-actions { opacity: 1; } + .tag-folder-menu-btn { + background: rgba(255,255,255,0.9); border: 1px solid #dee2e6; + border-radius: 0.25rem; padding: 0.1rem 0.3rem; + font-size: 0.75rem; cursor: pointer; + } + .tag-folder-menu-btn:hover { background: #fff; } + .folder-breadcrumb { padding: 0.5rem 0; margin-bottom: 0.75rem; border-bottom: 1px solid #dee2e6; } + .folder-breadcrumb a { text-decoration: none; color: #0d6efd; } + .folder-breadcrumb a:hover { text-decoration: underline; } + .sortable-header { cursor: pointer; user-select: none; } + .tag-badge { + display: inline-block; padding: 0.25em 0.5em; margin: 0.1rem; + font-size: 0.8rem; font-weight: 500; border-radius: 0.25rem; + cursor: pointer; transition: opacity 0.2s; + } + .tag-badge:hover { opacity: 0.8; } + .tag-badge.text-light { color: #fff !important; } + .tag-badge.text-dark { color: #212529 !important; } {% endblock %} {% block content %}
    @@ -291,10 +337,6 @@

    Group Workspace

    >
    -
    Group Documents
    -

    - Documents uploaded here are visible to all group members. -

    + +
    +
    + + + + +
    +
    + +
    +
    +
    + + +
    - - + + @@ -507,6 +586,41 @@
    Group Documents
    + + + + + + + + @@ -520,8 +634,6 @@
    Group Documents
    aria-labelledby="prompts-tab-btn" >
    -
    Group Prompts
    -
    Group Prompts id="create-group-prompt-section" style="display: none" > -
    @@ -640,7 +752,6 @@
    Group Prompts
    >
    -
    Group Agents
    File NameTitle + File Name + + Title + Actions