diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..841c429f --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,3 @@ +FROM mcr.microsoft.com/devcontainers/base:ubuntu +# Install the xz-utils package +RUN apt-get update && apt-get install -y xz-utils \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 9ebace40..bf3f4cd0 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -3,7 +3,14 @@ { "name": "Python 3", // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile - "image": "mcr.microsoft.com/devcontainers/python:0-3.11", + //"image": "mcr.microsoft.com/devcontainers/python:0-3.11", + "build": { + "dockerfile": "Dockerfile", + "args": { + "VARIANT": "3.11" + } + }, + // Features to add to the dev container. More info: https://containers.dev/features. "features": { "ghcr.io/devcontainers/features/docker-in-docker:2": { "version": "latest" @@ -14,18 +21,16 @@ "installBicep": true, "installUsingPython": true, "version": "2.72.0", - "bicepVersion": "latest" + "bicepVersion": "latest" }, "ghcr.io/devcontainers/features/terraform:1": {}, "ghcr.io/devcontainers/features/powershell:1": {}, "ghcr.io/azure/azure-dev/azd:latest": {} }, - // Features to add to the dev container. More info: https://containers.dev/features. - // "features": {}, // Use 'forwardPorts' to make a list of ports inside the container available locally. // "forwardPorts": [], // Use 'postCreateCommand' to run commands after the container is created. - "postCreateCommand": "pip3 install --user -r application/single_app/requirements.txt", + "postCreateCommand": "python3 -m venv .venv && .venv/bin/pip install -r application/single_app/requirements.txt", // Configure tool-specific properties. "customizations": { "vscode": { @@ -36,7 +41,10 @@ "ms-vscode.azurecli", "HashiCorp.terraform", "ms-vscode.powershell" - ] + ], + "settings": { + "python.defaultInterpreterPath": "${containerWorkspaceFolder}/.venv/bin/python" + } } } // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 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 ee1bc63d..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.009" +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_personal_agents.py b/application/single_app/functions_personal_agents.py index bf721842..a4a5e47d 100644 --- a/application/single_app/functions_personal_agents.py +++ b/application/single_app/functions_personal_agents.py @@ -137,31 +137,31 @@ def save_personal_agent(user_id, agent_data): # Validate required fields required_fields = ['name', 'display_name', 'description', 'instructions'] for field in required_fields: - if field not in agent_data: - agent_data[field] = '' - + if field not in cleaned_agent: + cleaned_agent[field] = '' + # Set defaults for optional fields - agent_data.setdefault('azure_openai_gpt_deployment', '') - agent_data.setdefault('azure_openai_gpt_api_version', '') - agent_data.setdefault('azure_agent_apim_gpt_deployment', '') - agent_data.setdefault('azure_agent_apim_gpt_api_version', '') - agent_data.setdefault('enable_agent_gpt_apim', False) - agent_data.setdefault('reasoning_effort', '') - agent_data.setdefault('actions_to_load', []) - agent_data.setdefault('other_settings', {}) - + cleaned_agent.setdefault('azure_openai_gpt_deployment', '') + cleaned_agent.setdefault('azure_openai_gpt_api_version', '') + cleaned_agent.setdefault('azure_agent_apim_gpt_deployment', '') + cleaned_agent.setdefault('azure_agent_apim_gpt_api_version', '') + cleaned_agent.setdefault('enable_agent_gpt_apim', False) + cleaned_agent.setdefault('reasoning_effort', '') + cleaned_agent.setdefault('actions_to_load', []) + cleaned_agent.setdefault('other_settings', {}) + # Remove empty reasoning_effort to avoid schema validation errors - if agent_data.get('reasoning_effort') == '': - agent_data.pop('reasoning_effort', None) - agent_data['is_global'] = False - agent_data['is_group'] = False - agent_data.setdefault('agent_type', 'local') - + if cleaned_agent.get('reasoning_effort') == '': + cleaned_agent.pop('reasoning_effort', None) + cleaned_agent['is_global'] = False + cleaned_agent['is_group'] = False + cleaned_agent.setdefault('agent_type', 'local') + # Store sensitive keys in Key Vault if enabled - agent_data = keyvault_agent_save_helper(agent_data, agent_data.get('id', ''), scope="user") - if agent_data.get('max_completion_tokens') is None: - agent_data['max_completion_tokens'] = -1 - result = cosmos_personal_agents_container.upsert_item(body=agent_data) + cleaned_agent = keyvault_agent_save_helper(cleaned_agent, cleaned_agent.get('id', ''), scope="user") + if cleaned_agent.get('max_completion_tokens') is None: + cleaned_agent['max_completion_tokens'] = -1 + result = cosmos_personal_agents_container.upsert_item(body=cleaned_agent) # Remove Cosmos metadata from response cleaned_result = {k: v for k, v in result.items() if not k.startswith('_')} cleaned_result.setdefault('is_global', False) 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..f7325585 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -130,9 +130,11 @@ def get_settings(use_cosmos=False): 'enable_group_workspaces': True, 'enable_group_creation': True, 'require_member_of_create_group': False, + 'require_owner_for_group_agent_management': 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/json_schema_validation.py b/application/single_app/json_schema_validation.py index c7c58a3c..f7952ca6 100644 --- a/application/single_app/json_schema_validation.py +++ b/application/single_app/json_schema_validation.py @@ -3,7 +3,7 @@ import os import json from functools import lru_cache -from jsonschema import validate, ValidationError, Draft7Validator, Draft6Validator +from jsonschema import validate, ValidationError, Draft7Validator, Draft6Validator, RefResolver SCHEMA_DIR = os.path.join(os.path.dirname(__file__), 'static', 'json', 'schemas') @@ -16,7 +16,10 @@ def load_schema(schema_name): def validate_agent(agent): schema = load_schema('agent.schema.json') - validator = Draft7Validator(schema['definitions']['Agent']) + if schema.get("$ref") and schema.get("definitions"): + validator = Draft7Validator(schema, resolver=RefResolver.from_schema(schema)) + else: + validator = Draft7Validator(schema) errors = sorted(validator.iter_errors(agent), key=lambda e: e.path) if errors: return '; '.join([e.message for e in errors]) @@ -40,7 +43,9 @@ def validate_plugin(plugin): plugin_copy['endpoint'] = f'sql://{plugin_type}' # First run schema validation - validator = Draft7Validator(schema['definitions']['Plugin']) + # Use RefResolver so $ref pointers (e.g. #/definitions/AuthType) resolve against the full schema + resolver = RefResolver.from_schema(schema) + validator = Draft7Validator(schema['definitions']['Plugin'], resolver=resolver) errors = sorted(validator.iter_errors(plugin_copy), key=lambda e: e.path) if errors: return '; '.join([f"{plugin.get('name', '')}: {e.message}" for e in errors]) diff --git a/application/single_app/route_backend_agents.py b/application/single_app/route_backend_agents.py index b3a8220a..57097ee5 100644 --- a/application/single_app/route_backend_agents.py +++ b/application/single_app/route_backend_agents.py @@ -249,7 +249,9 @@ def create_group_agent_route(): user_id = get_current_user_id() try: active_group = require_active_group(user_id) - assert_group_role(user_id, active_group) + app_settings = get_settings() + allowed_roles = ("Owner",) if app_settings.get('require_owner_for_group_agent_management') else ("Owner", "Admin") + assert_group_role(user_id, active_group, allowed_roles=allowed_roles) except ValueError as exc: return jsonify({'error': str(exc)}), 400 except LookupError as exc: @@ -285,7 +287,9 @@ def update_group_agent_route(agent_id): user_id = get_current_user_id() try: active_group = require_active_group(user_id) - assert_group_role(user_id, active_group) + app_settings = get_settings() + allowed_roles = ("Owner",) if app_settings.get('require_owner_for_group_agent_management') else ("Owner", "Admin") + assert_group_role(user_id, active_group, allowed_roles=allowed_roles) except ValueError as exc: return jsonify({'error': str(exc)}), 400 except LookupError as exc: @@ -338,7 +342,9 @@ def delete_group_agent_route(agent_id): user_id = get_current_user_id() try: active_group = require_active_group(user_id) - assert_group_role(user_id, active_group) + app_settings = get_settings() + allowed_roles = ("Owner",) if app_settings.get('require_owner_for_group_agent_management') else ("Owner", "Admin") + assert_group_role(user_id, active_group, allowed_roles=allowed_roles) except ValueError as exc: return jsonify({'error': str(exc)}), 400 except LookupError as exc: @@ -501,17 +507,7 @@ def add_agent(): result = save_global_agent(cleaned_agent) if not result: return jsonify({'error': 'Failed to save agent.'}), 500 - - # Enforce that if there are agents, one must match global_selected_agent - settings = get_settings() - global_selected_agent = settings.get('global_selected_agent', {}) - global_selected_name = global_selected_agent.get('name') - updated_agents = get_global_agents() - if len(updated_agents) > 0: - found = any(a.get('name') == global_selected_name for a in updated_agents) - if not found: - return jsonify({'error': 'There must be at least one agent matching the global_selected_agent.'}), 400 - + log_event("Agent added", extra={"action": "add", "agent": {k: v for k, v in cleaned_agent.items() if k != 'id'}, "user": str(get_current_user_id())}) # --- HOT RELOAD TRIGGER --- setattr(builtins, "kernel_reload_needed", True) @@ -622,17 +618,7 @@ def edit_agent(agent_name): result = save_global_agent(cleaned_agent) if not result: return jsonify({'error': 'Failed to save agent.'}), 500 - - # Enforce that if there are agents, one must match global_selected_agent - settings = get_settings() - global_selected_agent = settings.get('global_selected_agent', {}) - global_selected_name = global_selected_agent.get('name') - updated_agents = get_global_agents() - if len(updated_agents) > 0: - found = any(a.get('name') == global_selected_name for a in updated_agents) - if not found: - return jsonify({'error': 'There must be at least one agent matching the global_selected_agent.'}), 400 - + log_event( f"Agent {agent_name} edited", extra={ 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_plugins.py b/application/single_app/route_backend_plugins.py index 6f24c932..77aab866 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -438,7 +438,9 @@ def create_group_action_route(): user_id = get_current_user_id() try: active_group = require_active_group(user_id) - assert_group_role(user_id, active_group) + app_settings = get_settings() + allowed_roles = ("Owner",) if app_settings.get('require_owner_for_group_agent_management') else ("Owner", "Admin") + assert_group_role(user_id, active_group, allowed_roles=allowed_roles) except ValueError as exc: return jsonify({'error': str(exc)}), 400 except LookupError as exc: @@ -482,7 +484,9 @@ def update_group_action_route(action_id): user_id = get_current_user_id() try: active_group = require_active_group(user_id) - assert_group_role(user_id, active_group) + app_settings = get_settings() + allowed_roles = ("Owner",) if app_settings.get('require_owner_for_group_agent_management') else ("Owner", "Admin") + assert_group_role(user_id, active_group, allowed_roles=allowed_roles) except ValueError as exc: return jsonify({'error': str(exc)}), 400 except LookupError as exc: @@ -541,7 +545,9 @@ def delete_group_action_route(action_id): user_id = get_current_user_id() try: active_group = require_active_group(user_id) - assert_group_role(user_id, active_group) + app_settings = get_settings() + allowed_roles = ("Owner",) if app_settings.get('require_owner_for_group_agent_management') else ("Owner", "Admin") + assert_group_role(user_id, active_group, allowed_roles=allowed_roles) except ValueError as exc: return jsonify({'error': str(exc)}), 400 except LookupError as exc: 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..578e1545 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 @@ -298,6 +302,7 @@ def admin_settings(): multimodal_vision_model = form_data.get('multimodal_vision_model', '') require_member_of_create_group = form_data.get('require_member_of_create_group') == 'on' + require_owner_for_group_agent_management = form_data.get('require_owner_for_group_agent_management') == 'on' require_member_of_create_public_workspace = form_data.get('require_member_of_create_public_workspace') == 'on' require_member_of_safety_violation_admin = form_data.get('require_member_of_safety_violation_admin') == 'on' require_member_of_control_center_admin = form_data.get('require_member_of_control_center_admin') == 'on' @@ -731,12 +736,14 @@ 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, 'file_timer_unit': file_timer_unit, 'file_processing_logs_turnoff_time': file_processing_logs_turnoff_time_str, 'require_member_of_create_group': require_member_of_create_group, + 'require_owner_for_group_agent_management': require_owner_for_group_agent_management, 'require_member_of_create_public_workspace': require_member_of_create_public_workspace, # Retention Policy diff --git a/application/single_app/route_frontend_chats.py b/application/single_app/route_frontend_chats.py index 8e34c0f4..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']) @@ -118,7 +143,8 @@ def upload_file(): file.seek(0) filename = secure_filename(file.filename) - file_ext = os.path.splitext(filename)[1].lower() + file_ext = os.path.splitext(filename)[1].lower() # e.g., '.png' + file_ext_nodot = file_ext.lstrip('.') # e.g., 'png' with tempfile.NamedTemporaryFile(delete=False) as tmp_file: file.save(tmp_file.name) @@ -131,9 +157,9 @@ def upload_file(): try: # Check if this is an image file - is_image_file = file_ext in IMAGE_EXTENSIONS + is_image_file = file_ext_nodot in IMAGE_EXTENSIONS - if file_ext in ['.pdf', '.docx', '.pptx', '.ppt', '.html'] or is_image_file: + if file_ext_nodot in (DOCUMENT_EXTENSIONS | {'html'}) or is_image_file: extracted_content_raw = extract_content_with_azure_di(temp_file_path) # Convert pages_data list to string @@ -191,25 +217,25 @@ def upload_file(): print(f"Warning: Vision analysis failed for chat upload: {vision_error}") # Continue without vision analysis - elif file_ext in ['.doc', '.docm']: + elif file_ext_nodot in {'doc', 'docm'}: # Use docx2txt for .doc and .docm files try: import docx2txt extracted_content = docx2txt.process(temp_file_path) except ImportError: return jsonify({'error': 'docx2txt library required for .doc/.docm files'}), 500 - elif file_ext == '.txt': + elif file_ext_nodot == 'txt': extracted_content = extract_text_file(temp_file_path) - elif file_ext == '.md': + elif file_ext_nodot == 'md': extracted_content = extract_markdown_file(temp_file_path) - elif file_ext == '.json': + elif file_ext_nodot == 'json': with open(temp_file_path, 'r', encoding='utf-8') as f: parsed_json = json.load(f) extracted_content = json.dumps(parsed_json, indent=2) - elif file_ext in ['.xml', '.yaml', '.yml', '.log']: + elif file_ext_nodot in {'xml', 'yaml', 'yml', 'log'}: # Handle XML, YAML, and LOG files as text for inline chat extracted_content = extract_text_file(temp_file_path) - elif file_ext in TABULAR_EXTENSIONS: + elif file_ext_nodot in TABULAR_EXTENSIONS: extracted_content = extract_table_file(temp_file_path, file_ext) is_table = True else: 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 a6b00cc4..228c81c1 100644 --- a/application/single_app/static/js/group/manage_group.js +++ b/application/single_app/static/js/group/manage_group.js @@ -92,39 +92,6 @@ $(document).ready(function () { rejectRequest(requestId); }); - // Add event delegation for select user button in search results - $(document).on("click", ".select-user-btn", function () { - const id = $(this).data("user-id"); - const name = $(this).data("user-name"); - const email = $(this).data("user-email"); - selectUserForAdd(id, name, email); - }); - - // Add event delegation for remove member button - $(document).on("click", ".remove-member-btn", function () { - const userId = $(this).data("user-id"); - removeMember(userId); - }); - - // Add event delegation for change role button - $(document).on("click", ".change-role-btn", function () { - const userId = $(this).data("user-id"); - const currentRole = $(this).data("user-role"); - openChangeRoleModal(userId, currentRole); - $("#changeRoleModal").modal("show"); - }); - - // Add event delegation for approve/reject request buttons - $(document).on("click", ".approve-request-btn", function () { - const requestId = $(this).data("request-id"); - approveRequest(requestId); - }); - - $(document).on("click", ".reject-request-btn", function () { - const requestId = $(this).data("request-id"); - rejectRequest(requestId); - }); - // CSV Bulk Upload Events $("#addBulkMemberBtn").on("click", function () { $("#csvBulkUploadModal").modal("show"); @@ -504,11 +471,21 @@ function setRole(userId, newRole) { data: JSON.stringify({ role: newRole }), success: function () { $("#changeRoleModal").modal("hide"); + showToast("success", "Role updated successfully"); loadMembers(); }, error: function (err) { - console.error(err); - alert("Failed to update role."); + console.error("Error updating role:", err); + let errorMsg = "Failed to update role."; + if (err.status === 404) { + errorMsg = "Member not found. They may have been removed."; + loadMembers(); // Refresh the member list + } else if (err.status === 403) { + errorMsg = "You don't have permission to change this member's role."; + } else if (err.responseJSON && err.responseJSON.message) { + errorMsg = err.responseJSON.message; + } + showToast("error", errorMsg); }, }); } @@ -519,11 +496,21 @@ function removeMember(userId) { url: `/api/groups/${groupId}/members/${userId}`, method: "DELETE", success: function () { + showToast("success", "Member removed successfully"); loadMembers(); }, error: function (err) { - console.error(err); - alert("Failed to remove member."); + console.error("Error removing member:", err); + let errorMsg = "Failed to remove member."; + if (err.status === 404) { + errorMsg = "Member not found. They may have already been removed."; + loadMembers(); // Refresh the member list + } else if (err.status === 403) { + errorMsg = "You don't have permission to remove this member."; + } else if (err.responseJSON && err.responseJSON.message) { + errorMsg = err.responseJSON.message; + } + showToast("error", errorMsg); }, }); } @@ -588,7 +575,6 @@ function rejectRequest(requestId) { }); } -// Search users for manual add // Search users for manual add function searchUsers() { const term = $("#userSearchTerm").val().trim(); @@ -631,7 +617,6 @@ function searchUsers() { }); } -// Render user-search results in add-member modal // Render user-search results in add-member modal function renderUserSearchResults(users) { let html = ""; diff --git a/application/single_app/static/js/public/public_workspace.js b/application/single_app/static/js/public/public_workspace.js index f48c096d..dc6770a0 100644 --- a/application/single_app/static/js/public/public_workspace.js +++ b/application/single_app/static/js/public/public_workspace.js @@ -1,4 +1,6 @@ // static/js/public_workspace.js +import { showToast } from "./chat/chat-toast.js"; + 'use strict'; // --- Global State --- @@ -20,9 +22,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 +148,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 +202,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 @@ -195,6 +269,13 @@ function updatePublicRoleDisplay(){ if (display) display.style.display = 'block'; if (uploadSection) uploadSection.style.display = ['Owner','Admin','DocumentManager'].includes(userRoleInActivePublic) ? 'block' : 'none'; // uploadHr was removed from template, so skip + + // Control visibility of Settings tab (only for Owners and Admins) + const settingsTabNav = document.getElementById('public-settings-tab-nav'); + const canManageSettings = ['Owner', 'Admin'].includes(userRoleInActivePublic); + if (settingsTabNav) { + settingsTabNav.classList.toggle('d-none', !canManageSettings); + } } else { if (display) display.style.display = 'none'; } @@ -246,10 +327,135 @@ function updateWorkspaceUIBasedOnStatus(status) { } } +// ===================== PUBLIC RETENTION POLICY ===================== + +async function loadPublicRetentionSettings() { + if (!activePublicId) return; + + const convSelect = document.getElementById('public-conversation-retention-days'); + const docSelect = document.getElementById('public-document-retention-days'); + + if (!convSelect || !docSelect) return; // Settings tab not available + + console.log('Loading public workspace retention settings for:', activePublicId); + + try { + // Fetch organization defaults for public workspace retention + const orgDefaultsResp = await fetch('/api/retention-policy/defaults/public'); + const orgData = await orgDefaultsResp.json(); + + if (orgData.success) { + const convDefaultOption = convSelect.querySelector('option[value="default"]'); + const docDefaultOption = docSelect.querySelector('option[value="default"]'); + + if (convDefaultOption) { + convDefaultOption.textContent = `Using organization default (${orgData.default_conversation_label})`; + } + if (docDefaultOption) { + docDefaultOption.textContent = `Using organization default (${orgData.default_document_label})`; + } + console.log('Loaded org defaults:', orgData); + } + } catch (error) { + console.error('Error loading public workspace retention defaults:', error); + } + + // Load current public workspace's retention policy settings + try { + const workspaceResp = await fetch(`/api/public_workspaces/${activePublicId}`); + + if (!workspaceResp.ok) { + throw new Error(`Failed to fetch workspace: ${workspaceResp.status}`); + } + + const workspaceData = await workspaceResp.json(); + console.log('Loaded workspace data:', workspaceData); + + // API returns workspace object directly (not wrapped in success/workspace) + if (workspaceData && workspaceData.retention_policy) { + const retentionPolicy = workspaceData.retention_policy; + let convRetention = retentionPolicy.conversation_retention_days; + let docRetention = retentionPolicy.document_retention_days; + + console.log('Found retention policy:', retentionPolicy); + + // If undefined, use 'default' + if (convRetention === undefined || convRetention === null) convRetention = 'default'; + if (docRetention === undefined || docRetention === null) docRetention = 'default'; + + convSelect.value = convRetention; + docSelect.value = docRetention; + console.log('Set retention values to:', { conv: convRetention, doc: docRetention }); + } else { + // Set to organization default if no retention policy set + console.log('No retention policy found, using defaults'); + convSelect.value = 'default'; + docSelect.value = 'default'; + } + } catch (error) { + console.error('Error loading public workspace retention settings:', error); + // Set defaults on error + convSelect.value = 'default'; + docSelect.value = 'default'; + } +} + +async function savePublicRetentionSettings() { + if (!activePublicId) { + showToast('No active public workspace selected.', 'warning'); + return; + } + + const convSelect = document.getElementById('public-conversation-retention-days'); + const docSelect = document.getElementById('public-document-retention-days'); + const statusSpan = document.getElementById('public-retention-save-status'); + + if (!convSelect || !docSelect) return; + + const retentionData = { + conversation_retention_days: convSelect.value, + document_retention_days: docSelect.value + }; + + console.log('Saving public workspace retention settings:', retentionData); + + // Show saving status + if (statusSpan) { + statusSpan.innerHTML = ' Saving...'; + } + + try { + const response = await fetch(`/api/retention-policy/public/${activePublicId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(retentionData) + }); + + const data = await response.json(); + console.log('Save response:', data); + + if (response.ok && data.success) { + if (statusSpan) { + statusSpan.innerHTML = ' Saved successfully!'; + setTimeout(() => { statusSpan.innerHTML = ''; }, 3000); + } + console.log('Public workspace retention settings saved successfully'); + } else { + throw new Error(data.error || 'Failed to save retention settings'); + } + } catch (error) { + console.error('Error saving public workspace retention settings:', error); + if (statusSpan) { + statusSpan.innerHTML = ` Error: ${error.message}`; + } + showToast(`Error saving retention settings: ${error.message}`, 'danger'); + } +} + function loadActivePublicData(){ const activeTab = document.querySelector('#publicWorkspaceTab .nav-link.active').dataset.bsTarget; if(activeTab==='#public-docs-tab') fetchPublicDocs(); else fetchPublicPrompts(); - updatePublicRoleDisplay(); updatePublicPromptsRoleUI(); updateWorkspaceStatusAlert(); + updatePublicRoleDisplay(); updatePublicPromptsRoleUI(); updateWorkspaceStatusAlert(); loadPublicRetentionSettings(); } async function fetchPublicDocs(){ @@ -258,6 +464,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 +513,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 +921,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 +1071,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 +1122,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 +1140,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 +1213,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/group_agents.js b/application/single_app/static/js/workspace/group_agents.js index 91b6e57f..f97dbd07 100644 --- a/application/single_app/static/js/workspace/group_agents.js +++ b/application/single_app/static/js/workspace/group_agents.js @@ -33,6 +33,9 @@ function escapeHtml(value) { function canManageAgents() { const role = currentContext?.userRole; + if (currentContext?.requireOwnerForAgentManagement) { + return role === "Owner"; + } return role === "Owner" || role === "Admin"; } diff --git a/application/single_app/static/js/workspace/group_plugins.js b/application/single_app/static/js/workspace/group_plugins.js index cbe347b6..60a7f42e 100644 --- a/application/single_app/static/js/workspace/group_plugins.js +++ b/application/single_app/static/js/workspace/group_plugins.js @@ -30,6 +30,9 @@ function escapeHtml(value) { function canManagePlugins() { const role = currentContext?.userRole; + if (currentContext?.requireOwnerForAgentManagement) { + return role === "Owner"; + } return role === "Owner" || role === "Admin"; } 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..7d01f7da 100644 --- a/application/single_app/templates/admin_settings.html +++ b/application/single_app/templates/admin_settings.html @@ -2174,6 +2174,24 @@

    If enabled, only users who are members of the 'CreateGroups' role (defined in your App registration) can create new groups. If disabled, any authenticated user can create groups (provided 'Enable My Groups' is active).

    + +
    + + +
    + + + +
    +

    + If enabled, only the group Owner can create, edit, and delete group agents and group actions. Admins and other roles will only be able to view them. +

    @@ -2673,6 +2691,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..92012a23 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 %}
    @@ -278,6 +324,22 @@

    Group Workspace

    {% endif %} + {% if app_settings.enable_retention_policy_group %} + + {% endif %} @@ -291,10 +353,6 @@

    Group Workspace

    >
    -
    Group Documents
    -

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

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