diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5bae3ed --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,67 @@ +# Contributing to Specif AI + +We're excited you're interested in contributing to Specif AI! This document outlines the process for contributing to our project. We welcome contributions from everyone, whether you're fixing a typo, improving documentation, or adding a new feature. + +## Getting Started + +1. Fork the repository on GitHub. +2. Clone your fork locally: + ``` + git clone https://github.com/presidio-oss/specif-ai + cd https://github.com/presidio-oss/specif-ai + ``` +3. To set up and run the application locally, follow the guides provided for: + - Backend: [Backend setup guide](./backend/README.md) + - Electron: [Electron setup guide](./electron/README.md) + +## Making Changes + +1. Create a new branch for your changes: + ``` + git checkout -b your-branch-name + ``` +2. Make your changes in the codebase: + - UI Changes: Make your UI code file changes in the `ui/` and `electron/` folders respectively. + - Backend Changes: Make backend changes in `backend/` folder. +3. Write or update tests as necessary. +4. Format your code. +5. Run linting checks. + +## Submitting Changes + +1. Commit your changes: + ``` + git commit -m "Your detailed commit message" + ``` +2. Push to your fork: + ``` + git push origin your-branch-name + ``` +3. Submit a pull request through the GitHub website to + +## Pull Request Guidelines + +- Provide a clear title and description of your changes. +- Include any relevant issue numbers in the PR description. +- Ensure all tests pass and there are no linting errors. +- Update documentation if you're changing functionality. + +Before submitting a pull request, verify the changes in your local environment. + +## Reporting Bugs + +Use the GitHub issue tracker at to report bugs. When filing an issue, please include: + +- A clear title and description. +- As much relevant information as possible. +- A code sample or an executable test case demonstrating the expected behavior that is not occurring. + +## Feature Requests + +Feature requests are welcome. Please provide a clear description of the feature and why it would be beneficial to the project. You can submit feature requests through the GitHub issue tracker. + +## Questions? + +If you have any questions, feel free to open an issue or reach out to the maintainers through the GitHub repository. + +Thank you for contributing to Specif AI! diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b3cf2a7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,26 @@ +MIT License + +Copyright (c) Presidio, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +The Software may include logos, trademarks, and other branding assets +(β€œBranding”) of Presidio, Inc. Use of the Branding in modified or redistributed +versions of the Software requires removal or replacement unless prior written +permission is obtained from Presidio, Inc. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7077d9f --- /dev/null +++ b/README.md @@ -0,0 +1,221 @@ +
+ License + Issues + Stars + Forks +
+
+
+ + + + HAI Logo + +
+
+
+ Accelerate your SDLC process with AI-powered intelligence.
+ From ideas to actionable tasks in minutes.
+
+
+ +# πŸš€ Specif AI + +**Specif AI** is an AI-powered platform that transforms the project requirements management. It combines AI technology with intuitive workflows to automate documentation, generate and manage tasks - all while adapting to your team's specific needs. +
+ Specif AI in Action +
+ +## Table of Contents +- [🌟 Overview](#overview) +- [πŸ“₯ Getting Started](#getting-started) +- [✨ Key Features](#key-features) + - [πŸ”Œ Integrations](#integrations) +- [πŸ— Architecture](#architecture) +- [πŸ“ Version-Controlled Requirements Management](#version-controlled-requirements-management) +- [πŸ—Ί Roadmap](#roadmap) +- [🀝 Contributing](#contributing) +- [πŸ“œ License](#license) +- [πŸ“§ Contact](#-contact) + +## 🌟 Overview + +In today's fast-paced software development landscape, delivering high-quality solutions quickly is more critical than ever. Specif AI is a cutting-edge platform that revolutionizes how teams generate, manage, and refine software requirements by combining AI intelligence with human context. + +By simply providing a solution name, description, and tech stack details, Specif AI automatically generates comprehensive documentation, including: + +- πŸ“„ Business Requirement Documents (BRD) +- πŸ”§ Non-Functional Requirements Documents (NFRD) +- πŸ“± Product Requirement Documents (PRD) +- 🎨 User Interface Requirements (UIR) +- πŸ”„ Business Process Flows + +
+ Document Generation Demo +
+ +## πŸ“₯ Getting Started + +### Setup Instructions + +1. **Clone the repository** + ```bash + git clone https://github.com/presidio-oss/specif-ai.git + cd specif-ai + ``` + +2. **Set up the Backend** + + Prerequisites: Python >= 3.11 + + ```bash + # Navigate to backend directory + cd backend + + # In env.sh file, Add your OpenAI API key to the OPENAI_API_KEY variable. + # Configure additional settings as needed in env.sh + # Then, Activate the environment variables by running: + source env.sh + + # Build the image + docker build . --tag hai-build-requirement-backend + + # Run the container + docker run -p 5001:5001 \ + -e APP_PASSCODE_KEY=$APP_PASSCODE_KEY \ + -e OPENAI_API_KEY=$OPENAI_API_KEY \ + -e OPENAI_API_BASE=$OPENAI_API_BASE \ + -e AZUREAI_API_BASE=$AZUREAI_API_BASE \ + -e AZUREAI_API_KEY=$AZUREAI_API_KEY \ + -e AZUREAI_API_VERSION=$AZUREAI_API_VERSION \ + -e CLAUDE_API_KEY=$CLAUDE_API_KEY \ + -e CLAUDE_ENDPOINT=$CLAUDE_ENDPOINT \ + -e HOST=$HOST \ + -e PORT=$PORT \ + -e DEBUG=$DEBUG \ + -e ENABLE_SENTRY=$ENABLE_SENTRY \ + -e SENTRY_DSN=$SENTRY_DSN \ + -e SENTRY_ENVIRONMENT=$SENTRY_ENVIRONMENT \ + -e SENTRY_RELEASE=$SENTRY_RELEASE \ + -it hai-build-requirement-backend + ``` + +3. **Download the Specif AI desktop application** from the [releases page](https://github.com/presidio-oss/specif-ai/releases). +4. **Run** the Desktop Application + +For detailed setup instructions, refer to: +- [Backend Server Configuration Setup](./backend/README.md) +- [Electron Desktop Application Setup](./electron/README.md) + - [Angular UI Setup](./ui/README.md) + +### πŸ’» App Setup + +Welcome Page + +1. **APP URL:** The app's backend base URL (For local development: `http://127.0.0.1:5001/`). + +2. **APP PASSCODE:** Use the same passcode provided during the [Backend](./backend/README.md) setup (For local development: `7654321`). + +For more details, refer to the [Backend Server Setup Configuration Settings](./backend/README.md). + +## ✨ Key Features + +- **πŸ€– AI-Powered Document Generation**: Effortlessly create detailed SDLC documentation. +- **πŸ’¬ Intelligent Chat Interface**: Get real-time requirement edits and context-specific suggestions. + +
+ +![AI powered chat feature in action](assets/gifs/specif-ai-chat.gif) +*AI powered chat feature in action* + +
+ +- **πŸ“Š Business Process Visualization**: Easily generate and manage process flows. +- **πŸ“‹ User Story Generation**: Convert requirements into actionable user stories and tasks. + +
+ +![User story and task generation](assets/gifs/specif-ai-user-stories.gif) +*User story and task generation* +
+ +- **πŸ”„ Real-time Collaboration**: Collaborate and refine requirements with team members. +- **πŸ“± Desktop Integration**: Seamlessly integrate with your existing workflow tools. + +- **πŸ”„ Multi-Modal Support**: Choose the model that best suits your needs. Supported models include: + - Azure OpenAI + - gpt-4o + - gpt-4o-mini + - OpenAI Native + - gpt-4o + - gpt-4o-mini + - AWS Bedrock + - anthropic.claude-3-5-sonnet-20240620-v1:0 + +
+ +![Model Switch](assets/gifs/specif-ai-settings.gif) +*Switch between models seamlessly* + +
+ + + + +### πŸ”Œ Integrations + +Specif AI seamlessly integrates with popular tools to enhance your workflow: + +#### Jira Integration +The stories and tasks generated as part of the solutions can be used to create actual stories and tasks in your Jira instance using the Jira integration provided by the application. Features include: +- Automatic story and task creation in Jira. +- Bulk export capabilities. + +For Jira setup instructions, please refer to our [Jira Setup Guide](/ui/JIRA-README.md). + +#### AWS Bedrock Knowledge Base +> **Note**: The AWS Bedrock Knowledge Base features are configurable when the backend server is deployed in AWS. Local deployments will not have access to these enhanced capabilities. + +The enterprise knowledge base is integrated with AI-powered chat to enhance suggestions and enable iterative conversations for Business Requirement Documents (BRDs), Product Requirement Documents (PRDs), Non-functional Requirements, User Stories, and Tasks. Features include: + +- Enhanced chat suggestions through enterprise knowledge. +- Context-aware requirement generation. +- Historical data integration. + +## πŸ— Architecture + +Specif AI follows a modern, scalable architecture designed for optimal performance and maintainability. + +
+ Application Architecture Diagram +
+ +## πŸ“ Version Controlled Requirements Management Made Easy + +Specif AI is a powerful desktop application built to streamline and organize your project requirements. With Specif AI, users can create a unified directory where all essential files are not only accessible and editable but also seamlessly synced with platforms like OneDrive, Dropbox, or any git-enabled local folder. This setup allows users to point to specific artifacts and data sources in a version-controlled environment, making collaboration and tracking effortless. Our goal is to enhance your development workflow by integrating seamlessly with the tools you already use, without adding complexity or obstacles. + + +## πŸ—Ί Roadmap + +- [ ] Advanced BRD-PRD linking capabilities. +- [ ] Enhanced collaboration features. +- [ ] Custom template support. +- [ ] v2.0 - Web version with enhanced collaboration capabilities. + +## 🀝 Contributing + +To contribute to the project, start by exploring [open issues](https://github.com/presidio-oss/specif-ai/issues) or checking our [feature request board](https://github.com/presidio-oss/specif-ai/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop). + +Please read our [Contributing Guidelines](./CONTRIBUTING.md) for more details. + +## πŸ“œ License + +This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details. + +## πŸ™ Acknowledgments + +Thanks to all contributors and users for their support and feedback. + +## πŸ“§ Contact + +For any questions or feedback, please contact us at [hai-feedback@presidio.com](mailto:hai-feedback@presidio.com). \ No newline at end of file diff --git a/assets/gifs/specif-ai-chat.gif b/assets/gifs/specif-ai-chat.gif new file mode 100644 index 0000000..92e5eb8 Binary files /dev/null and b/assets/gifs/specif-ai-chat.gif differ diff --git a/assets/gifs/specif-ai-overview.gif b/assets/gifs/specif-ai-overview.gif new file mode 100644 index 0000000..e7a83f2 Binary files /dev/null and b/assets/gifs/specif-ai-overview.gif differ diff --git a/assets/gifs/specif-ai-sections.gif b/assets/gifs/specif-ai-sections.gif new file mode 100644 index 0000000..40842fe Binary files /dev/null and b/assets/gifs/specif-ai-sections.gif differ diff --git a/assets/gifs/specif-ai-settings.gif b/assets/gifs/specif-ai-settings.gif new file mode 100644 index 0000000..0c8bb73 Binary files /dev/null and b/assets/gifs/specif-ai-settings.gif differ diff --git a/assets/gifs/specif-ai-user-stories.gif b/assets/gifs/specif-ai-user-stories.gif new file mode 100644 index 0000000..468a244 Binary files /dev/null and b/assets/gifs/specif-ai-user-stories.gif differ diff --git a/assets/img/hai-build-logo-light.png b/assets/img/hai-build-logo-light.png new file mode 100644 index 0000000..68a91bf Binary files /dev/null and b/assets/img/hai-build-logo-light.png differ diff --git a/assets/img/hai-build-logo-theme.png b/assets/img/hai-build-logo-theme.png new file mode 100644 index 0000000..ca631bc Binary files /dev/null and b/assets/img/hai-build-logo-theme.png differ diff --git a/assets/img/hai-build-logo-white-bg.png b/assets/img/hai-build-logo-white-bg.png new file mode 100644 index 0000000..3daab4d Binary files /dev/null and b/assets/img/hai-build-logo-white-bg.png differ diff --git a/assets/img/specif-ai-architecture.png b/assets/img/specif-ai-architecture.png new file mode 100644 index 0000000..8c5bbc6 Binary files /dev/null and b/assets/img/specif-ai-architecture.png differ diff --git a/assets/img/specif-ai-welcome-page.png b/assets/img/specif-ai-welcome-page.png new file mode 100644 index 0000000..799f0ad Binary files /dev/null and b/assets/img/specif-ai-welcome-page.png differ diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..716eda3 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,52 @@ +# These are some examples of commonly ignored file patterns. +# You should customize this list as applicable to your project. +# Learn more about .gitignore: +# https://www.atlassian.com/git/tutorials/saving-changes/gitignore + +# Node artifact files +node_modules/ +dist/ +env/ +.venv/ + +# Compiled Java class files +*.class + +# Compiled Python bytecode +*.py[cod] + +# Log files +*.log + +# Package files +*.jar + +# Maven +target/ +dist/ + +# JetBrains IDE +.idea/ + +# Unit test reports +TEST*.xml + +# Generated by MacOS +.DS_Store + +# Generated by Windows +Thumbs.db + +# Applications +*.app +*.exe +*.war + +# Large media files +*.mp4 +*.tiff +*.avi +*.flv +*.mov +*.wmv + diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..29c75d3 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,20 @@ +# Use the official Python 3.12 image as the base +FROM python:3.12 + +# Set the working directory inside the container +WORKDIR /usr/src/app + +# Copy the requirements.txt file to the container +COPY requirements.txt . + +# Install the Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy the rest of the application code to the container +COPY . . + +# Expose port 5001 to the host machine +EXPOSE 5001 + +# Set the default command to run the application +CMD ["python", "init.py"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..9a86c42 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,144 @@ +# πŸš€ Specif AI Backend Server + +The backend service that powers Specif AI's intelligent document generation and processing capabilities. + +## Table of Contents +- [Setup Options](#setup-options) +- [Standalone Setup](#standalone-setup) +- [Docker Setup](#docker-setup) +- [Contributing](#contributing) +- [Troubleshooting](#troubleshooting) + +## πŸ›  Setup Options + +You can set up the backend in two ways: +- [Standalone Setup](#standalone-setup). +- [Docker Setup](#docker-setup). + +## ⚑ Standalone Setup + +### Prerequisites +- Python >= 3.11 +- pip package manager +- Virtual environment tool + +### Step-by-Step Guide + +#### 1️⃣ Create Virtual Environment + +```bash +# MacOS/Linux +python3 -m venv .venv + +# Windows +py -m venv .venv +``` + +#### 2️⃣ Activate Virtual Environment + +```bash +# MacOS/Linux +source .venv/bin/activate + +# Windows +.\.venv\Scripts\activate +``` + +> πŸ’‘ **Tip**: Verify activation by checking Python interpreter location: +> ```bash +> # MacOS/Linux +> which python +> +> # Windows +> where python +> ``` + +#### 3️⃣ Install Dependencies + +```bash +pip install -r requirements.txt +``` + +#### 4️⃣ Setup Environment +Ensure that an `env.sh` file is present in the root of the backend directory. Update the `env.sh` file with the appropriate values, then source it using the following command: + +```bash +source env.sh +``` + +Available environment variables: +``` +APP_PASSCODE_KEY="7654321" # Electron app passcode (For local development only) +HOST="0.0.0.0" # Backend server host +PORT=5001 # Backend server port +DEBUG=false # Log level +ENABLE_SENTRY=false # Enable sentry monitoring +SENTRY_DSN= # Sentry DNS +SENTRY_ENVIRONMENT= # Sentry environment +SENTRY_RELEASE= # Sentry release name + +DEFAULT_API_PROVIDER="OPENAI_COMPATIBLE_AZURE" # Default API provider +DEFAULT_MODEL="gpt-4o" # Default model + +# OpenAI Config +OPENAI_API_KEY="" +OPENAI_API_BASE="" + +# Azure Config +AZUREAI_API_BASE="" +AZUREAI_API_KEY="" +AZUREAI_API_VERSION="" + +# Bedrock Config +CLAUDE_API_KEY="" +CLAUDE_ENDPOINT="" +``` + +#### 5️⃣ Launch Application + +```bash +python init.py +``` + +## 🐳 Docker Setup + +For containerized deployment, use Docker: + +```bash +# Update the env.sh file with the appropriate values, then source it using the following command +source env.sh + +# Build the image +docker build . --tag hai-build-requirement-backend + +# Run the container +docker run -p 5001:5001 \ +-e APP_PASSCODE_KEY=$APP_PASSCODE_KEY \ +-e OPENAI_API_KEY=$OPENAI_API_KEY \ +-e OPENAI_API_BASE=$OPENAI_API_BASE \ +-e AZUREAI_API_BASE=$AZUREAI_API_BASE \ +-e AZUREAI_API_KEY=$AZUREAI_API_KEY \ +-e AZUREAI_API_VERSION=$AZUREAI_API_VERSION \ +-e CLAUDE_API_KEY=$CLAUDE_API_KEY \ +-e CLAUDE_ENDPOINT=$CLAUDE_ENDPOINT \ +-e HOST=$HOST \ +-e PORT=$PORT \ +-e DEBUG=$DEBUG \ +-e ENABLE_SENTRY=$ENABLE_SENTRY \ +-e SENTRY_DSN=$SENTRY_DSN \ +-e SENTRY_ENVIRONMENT=$SENTRY_ENVIRONMENT \ +-e SENTRY_RELEASE=$SENTRY_RELEASE \ +-it hai-build-requirement-backend +``` + +## 🀝 Contributing + +Please read our [Contributing Guidelines](../CONTRIBUTING.md) for details on submitting patches and the contribution workflow. + +## πŸ› οΈ Troubleshooting + +- **Issue**: Backend server not starting. + - **Solution**: Ensure the virtual environment is activated and all dependencies are installed. + +- **Issue**: Docker container fails to run. + - **Solution**: Verify that the Docker daemon is running and the `env.sh` file is correctly configured. diff --git a/backend/api/auth_api.py b/backend/api/auth_api.py new file mode 100644 index 0000000..f253cb3 --- /dev/null +++ b/backend/api/auth_api.py @@ -0,0 +1,26 @@ +from flask import Blueprint, request, jsonify +from utils.env_utils import get_env_variable, EnvVariables +from config.exceptions import CustomAppException +from config.logging_config import logger +import base64 + +auth_api = Blueprint('auth_api', __name__) + +@auth_api.route("/api/auth/verify_access_token", methods=["POST"]) +def verify_access_token(): + try: + data = request.get_json() + app_access_code = get_env_variable(EnvVariables.APP_PASSCODE_KEY) + encoded_access_token = data.get("accessToken") + if not encoded_access_token: + return jsonify({"error": "Access token is required"}), 401 + decoded_access_token = base64.b64decode(encoded_access_token).decode("utf-8") + + if decoded_access_token == app_access_code: + return jsonify({"valid": True}), 200 + else: + return jsonify({"valid": False}), 401 + + except Exception as e: + logger.error("An error occurred during token verification: %s", str(e)) + return CustomAppException(e, status_code=500) diff --git a/backend/api/chat_api.py b/backend/api/chat_api.py new file mode 100644 index 0000000..e413923 --- /dev/null +++ b/backend/api/chat_api.py @@ -0,0 +1,143 @@ +import json +from flask import request, jsonify, g, Blueprint +from marshmallow import ValidationError +from decorators.require_access_token import require_access_code +from config.exceptions import CustomAppException +from config.logging_config import logger +from utils.common_utils import render_template, get_template_env +from llm.llm_service import LLMService +from schemas.schemas import ( + chat_generic_schema, + chat_improve_suggestion_schema, + chat_update_requirement_schema, + chat_update_user_story_schema +) +from llm.prompts import ( + p_chat_improved_suggestions, +) + + +chat_api = Blueprint('chat_api', __name__) +jinja_template_env = get_template_env() +llm_service = LLMService() # Singleton instance of LLMService + +@chat_api.route("/api/chat/generic", methods=["POST"]) +@require_access_code() +def chat_generic(): + logger.info(f"Request {g.request_id}: Entered ") + try: + data = chat_generic_schema.load(request.get_json()) + except ValidationError as err: + logger.error(f"Request {g.request_id}: Payload validation failed: {err.messages}") + raise CustomAppException("Payload validation failed.", status_code=400) from err + + message = data['message'] + knowledge_base = data.get("knowledgeBase", "") + + llm_response = llm_service.call_llm_for_chat_agent( + prompt=message, chat_history=data.get("chatHistory", []), system_message=None, knowledge_base=knowledge_base + ) + logger.info(f"Request {g.request_id}: Exited ") + return jsonify(llm_response) + + +@chat_api.route("/api/chat/get_suggestions", methods=["POST"]) +@require_access_code() +def get_suggestions(): + """To give improved suggestions for the selected requirement""" + logger.info(f"Request {g.request_id}: Entered ") + try: + data = chat_improve_suggestion_schema.load(request.get_json()) + except ValidationError as err: + logger.error(f"Request {g.request_id}: Payload validation failed: {err.messages}") + raise CustomAppException("Payload validation failed.", status_code=400) from err + knowledge_base = data.get("knowledgeBase", "") + llm_response_list = [] + template = render_template(p_chat_improved_suggestions) + template = template.render( + n="3", + name=data["name"], + description=data["description"], + type=data["type"], + requirement=data["requirement"], + ) + llm_response = llm_service.call_llm(template, knowledge_base=knowledge_base) + + try: + llm_response_list = json.loads(llm_response) + except json.JSONDecodeError as exc: + logger.error(f"Request {g.request_id}: Failed to parse LLM response") + raise CustomAppException( + "Invalid JSON format. Please try again.", + status_code=500, + payload={"llm_response": llm_response}, + ) from exc + logger.info(f"Request {g.request_id}: Exited ") + return llm_response_list + + +@chat_api.route("/api/chat/update_requirement", methods=["POST"]) +@require_access_code() +def chat_update_requirement(): + """Based on the type and app, helps to build requirement over conversation""" + logger.info(f"Request {g.request_id}: Entered ") + try: + data = chat_update_requirement_schema.load(request.get_json()) + except ValidationError as err: + logger.error(f"Request {g.request_id}: Payload validation failed: {err.messages}") + raise CustomAppException("Payload validation failed.", status_code=400) from err + + template = jinja_template_env.get_template('update_requirement.jinja2') + requirement = data["requirement"] + user_message = data["userMessage"] + knowledge_base = data.get('knowledgeBase', '') + system_prompt = template.render( + name=data["name"], + description=data["description"], + type=data["type"], + r_abbr=data["requirementAbbr"], + requirement=requirement, + ) + chat_history = data.get("chatHistory", []) + try: + llm_response = llm_service.call_llm_for_chat_agent( + prompt=user_message, chat_history=chat_history, system_message=system_prompt, knowledge_base=knowledge_base + ) + logger.info(f"Request {g.request_id}: Exited ") + return jsonify(llm_response) + except Exception as e: + logger.info("Exited ") + logger.error(e) + raise CustomAppException("Something went wrong. Please try again.", status_code=500) from e + +@chat_api.route("/api/chat/update_user_story_task", methods=["POST"]) +@require_access_code() +def chat_update_user_story_task(): + """Based on the type and app, helps to build requirement over conversation""" + logger.info(f"Request {g.request_id}: Entered ") + try: + data = chat_update_user_story_schema.load(request.get_json()) + except ValidationError as err: + logger.error(f"Request {g.request_id}: Payload validation failed: {err.messages}") + raise CustomAppException("Payload validation failed.", status_code=400) from err + template = jinja_template_env.get_template('update_user_story_task.jinja2') + requirement = data["requirement"] + user_message = data["userMessage"] + knowledge_base = data.get('knowledgeBase', '') + prd = data.get('prd', '') + us = data.get('us', '') + system_prompt = template.render( + name=data["name"], + description=data["description"], + type=data["type"], + requirement=requirement, + prd=prd, + us=us + ) + chat_history = data.get("chatHistory", []) + + llm_response = llm_service.call_llm_for_chat_agent( + prompt=user_message, chat_history=chat_history, system_message=system_prompt, knowledge_base=knowledge_base + ) + logger.info(f"Request {g.request_id}: Exited ") + return jsonify(llm_response) diff --git a/backend/api/common_api.py b/backend/api/common_api.py new file mode 100644 index 0000000..902ff71 --- /dev/null +++ b/backend/api/common_api.py @@ -0,0 +1,59 @@ +from llm.llm_service import LLMService +from flask import Blueprint, request, g, jsonify +from config.exceptions import CustomAppException +from config.logging_config import logger + +common_api = Blueprint('common_api', __name__) +llMService = LLMService() + +@common_api.route("/api/hello", methods=["GET"]) +def test(): + logger.info("Entered ") + try: + response = {"LLM": llMService.current_llm()} + logger.info("Response: %s", response) + except Exception as e: + logger.error("An unexpected error occurred: %s", str(e)) + raise CustomAppException(f"An error occurred: {str(e)}", status_code=500) from e + logger.info("Exited ") + return jsonify(response) + +@common_api.route("/api/model/config-verification", methods=["POST"]) +def verify_provider_config(): + logger.info("Entered ") + try: + data = request.get_json() + g.current_provider = data.get('provider') + g.current_model = data.get('model') + + if not g.current_provider or not g.current_model: + raise CustomAppException("Provider and model are required", status_code=400) + + # Make a test call to the LLM with a simple prompt + test_prompt = "This is a test prompt to verify the provider configuration." + result = llMService.call_llm(test_prompt) + + response = { + "status": "success", + "message": "Provider configuration verified successfully", + "provider": g.current_provider, + "model": g.current_model, + "test_response": result + } + logger.info(f"Provider configuration verified successfully for {g.current_provider}:{g.current_model}") + + except CustomAppException as e: + logger.error(f"In , Provider configuration verification failed: {str(e)}") + response = { + "status": "failed", + "message": "Model connection failed. Please validate the credentials.", + "provider": g.current_provider, + "model": g.current_model + } + return jsonify(response) + except Exception as e: + logger.error(f"An unexpected error occurred during provider verification: {str(e)}") + raise CustomAppException(f"Provider configuration verification failed: {str(e)}", status_code=500) from e + + logger.info("Exited ") + return jsonify(response) diff --git a/backend/api/solution_api.py b/backend/api/solution_api.py new file mode 100644 index 0000000..3b16acc --- /dev/null +++ b/backend/api/solution_api.py @@ -0,0 +1,565 @@ +import json +from flask import abort, request, g, Blueprint +from marshmallow import ValidationError +from decorators.require_access_token import require_access_code +from langchain_aws.retrievers import AmazonKnowledgeBasesRetriever +from config.exceptions import CustomAppException +from config.logging_config import logger +from utils.common_utils import render_template, get_template_env +from llm.llm_service import LLMService +from llm.prompts import ( + p_process_flow_chart, + p_add_business_process, + p_update_business_process, + p_update_user_story, + p_add_task, + p_update_task, +) +from schemas.schemas import ( + create_process_flow_chart_schema, +) +from config.executor import ExecutorConfig +import concurrent.futures + + +solution_api = Blueprint('solution_api', __name__) +jinja_template_env = get_template_env() +llm_service = LLMService() # Singleton instance of LLMService + +@solution_api.route("/api/solutions/flowchart", methods=["POST"]) +@require_access_code() +def create_process_flow_chart(): + logger.info(f"Request {g.request_id}: Entered ") + try: + data = create_process_flow_chart_schema.load(request.get_json()) + except ValidationError as err: + logger.error(f"Request {g.request_id}: Payload validation failed: {err.messages}") + raise CustomAppException("Payload validation failed.", status_code=400) from err + try: + data = request.get_json() + process_flow_template = render_template(p_process_flow_chart) + process_flow_req = process_flow_template.render(title=data["title"], description=data["description"]) + process_flow_description = llm_service.call_llm(process_flow_req) + parsed_res = json.dumps(process_flow_description) + except json.JSONDecodeError as exc: + logger.error(f"Request {g.request_id}: Failed to parse LLM response") + raise CustomAppException( + "Invalid JSON format. Please try again.", + status_code=500, + payload={"features": process_flow_description}, + ) from exc + flow_chart_data = parsed_res + logger.info(f"Request {g.request_id}: Exited ") + return flow_chart_data + +# Create solutions without yaml +@solution_api.route("/api/solutions/create", methods=["POST"]) +@require_access_code() +def create_solutions(): + logger.info(f"Request {g.request_id}: Entered ") + data = request.get_json() + final_llm_response_dict = {} + errors = [] + templates = [] + + def get_llm_response(template_path): + logger.info(f"Request {g.request_id}: Fetching LLM response for template: {template_path}") + template = jinja_template_env.get_template(template_path) + template = template.render(name=data["name"], description=data["description"]) + try: + llm_response = llm_service.call_llm(template) + logger.info(f"Request {g.request_id}: Successfully received LLM response for template: {template_path}") + return json.loads(llm_response) + except json.JSONDecodeError: + logger.error(f"Request {g.request_id}: Failed to parse LLM response for template: {template_path}") + abort(500, description="Invalid JSON format. Please try again.") + + if data["createReqt"]: + logger.info(f"Request {g.request_id}: Creating requirements using LLM") + clean_solution = data['cleanSolution'] if ('cleanSolution' in data) and isinstance(data['cleanSolution'], + bool) else False + if clean_solution is False: + templates = ['create_brd.jinja2', 'create_prd.jinja2', 'create_nfr.jinja2', 'create_uir.jinja2'] + executor = ExecutorConfig().get_executor() + futures = [executor.submit(get_llm_response, template) for template in templates] + for future in concurrent.futures.as_completed(futures): + try: + llm_response_dict = future.result() + final_llm_response_dict.update(llm_response_dict) + except Exception as e: + logger.exception(f"Request {g.request_id}: Error in one or more LLM responses: {str(e)}") + import traceback + errors.append(traceback.format_exc()) + traceback.print_exc() + raise CustomAppException("Error in one or more LLM responses", status_code=500, payload={"errors": errors}) + + merged_data = {**data, **final_llm_response_dict} + logger.info(f"Request {g.request_id}: Exited successfully") + return merged_data + + +@solution_api.route("/api/solutions/update", methods=["POST"]) +@require_access_code() +def update_solution_reqt(): + logger.info(f"Request {g.request_id}: Entered ") + data = request.get_json() + llm_response_dict = {} # Initialize the variable here + template = jinja_template_env.get_template('02_update.jinja2') + updatedReqt = data["updatedReqt"] + fileContent = data["fileContent"] + if data["useGenAI"] or fileContent: + template = template.render( + name=data["name"], + description=data["description"], + existingReqt=data["reqDesc"], + fileContent=fileContent, + updatedReqt=updatedReqt, + reqId=data["reqId"], + addReqtType=data["addReqtType"] + ) + + llm_response = llm_service.call_llm(template) + else: + updatedReqt = f'{updatedReqt} {data["reqDesc"]}' + llm_response = json.dumps( + {"updated": {"title": data["title"], "requirement": updatedReqt}} + ) + try: + llm_response_dict = json.loads(llm_response) + logger.info(f"Request {g.request_id}: Successfully updated solution") + except json.JSONDecodeError as e: + logger.error(f"Request {g.request_id}: Failed to parse LLM response: {llm_response}") + raise CustomAppException( + "Invalid JSON format. Please try again.", + status_code=500, + payload={"llm_response": llm_response}, + ) from e + + merged_data = {**data, **llm_response_dict} + logger.info(f"Request {g.request_id}: Exited successfully") + return merged_data + + +@solution_api.route("/api/solutions/add", methods=["POST"]) +@require_access_code() +def add_solution_reqt(): + logger.info(f"Request {g.request_id}: Entered ") + data = request.get_json() + llm_response_dict = {} # Initialize the variable here + template = jinja_template_env.get_template('03_add.jinja2') + newReqt = data["reqt"] + fileContent = data["fileContent"] + if data["useGenAI"] or fileContent: + template = template.render( + name=data["name"], + description=data["description"], + newReqt=newReqt, + fileContent=fileContent, + addReqtType=data["addReqtType"], + ) + llm_response = llm_service.call_llm(template) + else: + llm_response = json.dumps( + {"LLMreqt": {"title": data["title"], "requirement": newReqt}} + ) + try: + llm_response_dict = json.loads(llm_response) + logger.info(f"Request {g.request_id}: Successfully added solution requirement") + except json.JSONDecodeError as exc: + logger.error(f"Request {g.request_id}: Failed to parse LLM response: {llm_response}") + raise CustomAppException( + "Invalid JSON format. Please try again.", + status_code=500, + payload={"llm_response": llm_response}, + ) from exc + merged_data = {**data, **llm_response_dict} + logger.info(f"Request {g.request_id}: Exited ") + return merged_data + + +@solution_api.route("/api/solutions/stories", methods=["POST"]) +@require_access_code() +def create_stories(): + logger.info(f"Request {g.request_id}: Entered ") + try: + data = request.get_json() + # 1. Generate User Stories/Features based on the inputs + feature_template = jinja_template_env.get_template('05_refine.jinja2') + feature_req = feature_template.render( + requirements=data["reqDesc"], extraContext=data["extraContext"], technologies=data['technicalDetails'] + ) + features = llm_service.call_llm(feature_req) + splits = [line for line in features.strip().split("\n") if line.strip()] + # 2. Evaluation of generated user stories/features + feature_evaluation_template = jinja_template_env.get_template('06_evaluate.jinja2') + features_splits_json = json.dumps(splits) + parsed_evaluation = feature_evaluation_template.render( + requirements=data["reqDesc"], features=features_splits_json + ) + evaluation = llm_service.call_llm(parsed_evaluation) + # 3. After the evaluation the response is sent back to LLM, generate the final set of US/features + final_features = feature_template.render( + requirements=data["reqDesc"], features=features, evaluation=evaluation + ) + final_features_res = llm_service.call_llm(final_features) + try: + pre_format_response = json.loads(final_features_res) + llm_response_dict = {"features": [{"id": i["id"], i["title"]: i["description"]} for i in pre_format_response['features']]} + except json.JSONDecodeError as exc: + logger.error(f"Request {g.request_id}: Failed to parse LLM response: {final_features_res}") + raise CustomAppException( + "Invalid JSON format. Please try again.", + status_code=500, + payload={"features": final_features_res}, + ) from exc + merged_data = {**data, **llm_response_dict} + logger.info(f"Request {g.request_id}: Exited ") + return merged_data + except Exception as e: + logger.error(f"Request {g.request_id}: An unexpected error occurred in : {str(e)}") + raise CustomAppException( + "An unexpected error occurred while creating the user stories.", + status_code=500, + ) from e + +# Generate task without yaml +@solution_api.route("/api/solutions/task", methods=["POST"]) +@require_access_code() +def create_task(): + logger.info(f"Request {g.request_id}: Entered ") + try: + data = request.get_json() + task_template = jinja_template_env.get_template('07_task.jinja2') + task_req = task_template.render( + name=data["name"], userstories=data["description"], extraContext=data["extraContext"], technologies=data['technicalDetails'] + ) + llm_response = llm_service.call_llm(task_req) + try: + pre_format_response = json.loads(llm_response) + llm_response_dict = {"tasks": [{"id": i["id"], i["name"]: i["acceptance"]} for i in pre_format_response['tasks']]} + except json.JSONDecodeError as exc: + logger.error(f"Request {g.request_id}: Failed to parse LLM response: {llm_response}") + raise CustomAppException( + "Invalid JSON format. Please try again.", + status_code=500, + payload={"llm_response": llm_response}, + ) from exc + merged_data = {**data, **llm_response_dict} + logger.info(f"Request {g.request_id}: Exited ") + return merged_data + except Exception as e: + logger.error(f"Request {g.request_id}: An unexpected error occurred in : {str(e)}") + raise CustomAppException( + "An unexpected error occurred while creating the task for user stories.", + status_code=500, + ) from e + +@solution_api.route("/api/solutions/task/update", methods=["PUT"]) +@require_access_code() +def update_task(): + logger.info(f"Request {g.request_id}: Entered ") + data = request.get_json() + llm_response_dict = {} + template = render_template(p_update_task) + reqDesc = data["reqDesc"] + taskId = data["taskId"] + fileContent = data["fileContent"] + + if data["contentType"] == "fileContent": + if data["reqDesc"] and data["useGenAI"]: + fileContent = data["fileContent"] + reqDesc = data["reqDesc"] + else: + fileContent = data["fileContent"] + reqDesc = "" + elif data["reqDesc"] and data["useGenAI"]: + reqDesc = data["reqDesc"] + fileContent = "" + else: + reqDesc = data["reqDesc"] + fileContent = "" + + template = template.render( + name=data["name"], + description=data["description"], + taskId=taskId, + taskName=data["taskName"], + existingTaskDescription=data["existingTaskDesc"], + taskDescription=reqDesc, + fileContent=fileContent, + ) + llm_response = llm_service.call_llm(template) + try: + llm_response_dict = json.loads(llm_response) + except json.JSONDecodeError as exc: + logger.error(f"Request {g.request_id}: Failed to parse LLM response: {llm_response}") + raise CustomAppException( + "Invalid JSON format. Please try again.", + status_code=500, + payload={"llm_response": llm_response}, + ) from exc + merged_data = {**data, **llm_response_dict} + logger.info(f"Request {g.request_id}: Exited ") + return merged_data + + +@solution_api.route("/api/solutions/task/add", methods=["POST"]) +@require_access_code() +def add_task(): + logger.info(f"Request {g.request_id}: Entered ") + data = request.get_json() + llm_response_dict = {} + template = render_template(p_add_task) + reqDesc = data["reqDesc"] + taskId = data["taskId"] + fileContent = data["fileContent"] + + if data.get("contentType") == "fileContent": + if data["reqDesc"] and data["useGenAI"]: + fileContent = data["fileContent"] + reqDesc = data["reqDesc"] + else: + fileContent = data["fileContent"] + reqDesc = "" + elif data["reqDesc"] and data["useGenAI"]: + reqDesc = data["reqDesc"] + fileContent = "" + else: + reqDesc = data["reqDesc"] + fileContent = "" + + template = template.render( + name=data["name"], + description=data["description"], + taskId=taskId, + taskName=data["taskName"], + taskDescription=reqDesc, + fileContent=fileContent, + ) + llm_response = llm_service.call_llm(template) + try: + llm_response_dict = json.loads(llm_response) + logger.info(f"Request {g.request_id}: Successfully processed task creation.") + except json.JSONDecodeError as exc: + logger.error(f"Request {g.request_id}: Failed to parse LLM response: {llm_response}") + raise CustomAppException( + "Invalid JSON format. Please try again.", + status_code=500, + payload={"llm_response": llm_response}, + ) from exc + merged_data = {**data, **llm_response_dict} + logger.info(f"Request {g.request_id}: Exited ") + return merged_data + + +@solution_api.route("/api/solutions/story/update", methods=["PUT"]) +@require_access_code() +def update_user_story(): + logger.info(f"Request {g.request_id}: Entered ") + data = request.get_json() + llm_response_dict = {} + template = render_template(p_update_user_story) + reqDesc = data["reqDesc"] + featureId = data["featureId"] + featureRequest = data["featureRequest"] + fileContent = data["fileContent"] + + if data.get("contentType") == "fileContent": + if data["featureRequest"] and data["useGenAI"]: + fileContent = data["fileContent"] + featureRequest = data["featureRequest"] + else: + fileContent = data["fileContent"] + featureRequest = "" + elif data["featureRequest"] and data["useGenAI"]: + featureRequest = data["featureRequest"] + fileContent = "" + else: + featureRequest = data["featureRequest"] + fileContent = "" + + template = template.render( + name=data["name"], + description=data["description"], + reqDesc=reqDesc, + featureId=featureId, + existingFeatureDescription=data["existingFeatureDesc"], + newFeatureDescription=featureRequest, + fileContent=fileContent, + ) + llm_response = llm_service.call_llm(template) + try: + llm_response_dict = json.loads(llm_response) + logger.info(f"Request {g.request_id}: Successfully processed user story update.") + except json.JSONDecodeError as exc: + logger.error(f"Request {g.request_id}: Failed to parse LLM response: {llm_response}") + raise CustomAppException( + "Invalid JSON format. Please try again.", + status_code=500, + payload={"llm_response": llm_response}, + ) from exc + merged_data = {**data, **llm_response_dict} + logger.info(f"Request {g.request_id}: Exited successfully.") + return merged_data + + +@solution_api.route("/api/solutions/story/add", methods=["POST"]) +@require_access_code() +def add_user_story(): + try: + logger.info(f"Request {g.request_id}: Entered ") + data = request.get_json() + llm_response_dict = {} + template = jinja_template_env.get_template('11_add_user_story.jinja2') + reqDesc = data["reqDesc"] + featureId = data["featureId"] + fileContent = data["fileContent"] + + if data.get("contentType") == "fileContent": + if data["featureRequest"] and data["useGenAI"]: + fileContent = data["fileContent"] + featureRequest = data["featureRequest"] + else: + fileContent = data["fileContent"] + featureRequest = "" + elif data["featureRequest"] and data["useGenAI"]: + featureRequest = data["featureRequest"] + fileContent = "" + else: + featureRequest = "" + fileContent = "" + + + template = template.render( + name=data["name"], + description=data["description"], + reqDesc=reqDesc, + featureId=featureId, + featureRequest=featureRequest, + fileContent=fileContent, + ) + llm_response = llm_service.call_llm(template) + try: + llm_response_dict = json.loads(llm_response) + logger.info(f"Request {g.request_id}: Successfully processed user story addition.") + except json.JSONDecodeError as exc: + logger.error(f"Request {g.request_id}: Failed to parse LLM response: {llm_response}") + raise CustomAppException( + "Invalid JSON format. Please try again.", + status_code=500, + payload={"llm_response": llm_response}, + ) from exc + merged_data = {**data, **llm_response_dict} + logger.info(f"Request {g.request_id}: Exited ") + return merged_data + except Exception as exception: + logger.error("An error occurred during create Story: %s", str(exception)) + raise CustomAppException("Something went wrong! Error in Create Story Api") + + +@solution_api.route("/api/solutions/business_process/add", methods=["POST"]) +@require_access_code() +def add_business_process(): + logger.info(f"Request {g.request_id}: Entered ") + data = request.get_json() + llm_response_dict = {} + template = render_template(p_add_business_process) + newReqt = data["reqt"] + BRDS = " ".join(data["selectedBRDs"]) + PRDS = " ".join(data["selectedPRDs"]) + if data["useGenAI"]: + template = template.render( + name=data["name"], + description=data["description"], + newReqt=newReqt, + BRDS=BRDS, + PRDS=PRDS, + ) + llm_response = llm_service.call_llm(template) + else: + newReqt = f"{newReqt} {BRDS} {PRDS}" + llm_response = json.dumps( + {"LLMreqt": {"title": data["title"], "requirement": newReqt}} + ) + try: + llm_response_dict = json.loads(llm_response) + except json.JSONDecodeError as exc: + logger.error(f"Request {g.request_id}: Failed to parse LLM response: {llm_response}") + raise CustomAppException( + "Invalid JSON format. Please try again.", + status_code=500, + payload={"llm_response": llm_response}, + ) from exc + + merged_data = {**data, **llm_response_dict} + logger.info(f"Request {g.request_id}: Exited ") + return merged_data + + +@solution_api.route("/api/solutions/business_process/update", methods=["PUT"]) +@require_access_code() +def update_business_process(): + logger.info(f"Request {g.request_id}: Entered ") + data = request.get_json() + llm_response_dict = {} # Initialize the variable here + template = render_template(p_update_business_process) + updatedReqt = data["updatedReqt"] + BRDS = " ".join(data["selectedBRDs"]) + PRDS = " ".join(data["selectedPRDs"]) + if data["useGenAI"]: + template = template.render( + name=data["name"], + description=data["description"], + existingReqt=data["reqDesc"], + updatedReqt=updatedReqt, + reqId=data["reqId"], + BRDS=" ".join(data["selectedBRDs"]), + PRDS=" ".join(data["selectedPRDs"]), + ) + llm_response = llm_service.call_llm(template) + else: + updatedReqt = f'{updatedReqt} {BRDS} {PRDS}' + llm_response = json.dumps( + {"updated": {"title": data["title"], "requirement": updatedReqt}} + ) + try: + llm_response_dict = json.loads(llm_response) + except json.JSONDecodeError as exc: + logger.error(f"Request {g.request_id}: Failed to parse LLM response: {llm_response}") + raise CustomAppException( + "Invalid JSON format. Please try again.", + status_code=500, + payload={"llm_response": llm_response}, + ) from exc + merged_data = {**data, **llm_response_dict} + logger.info(f"Request {g.request_id}: Exited ") + return merged_data + +@solution_api.route("/api/solutions/integration/knowledgebase/validation", methods=["POST"]) +@require_access_code() +def validate_bedrock_id(): + logger.info(f"Request {g.request_id}: Entered ") + data = request.get_json() + + if not data or 'bedrockId' not in data: + logger.error( + f"Request {g.request_id}: Missing bedrock_id in payload") + raise CustomAppException( + "Payload must include 'bedrock_id'", + status_code=400 + ) + + bedrock_id = data['bedrockId'] + + try: + AmazonKnowledgeBasesRetriever( + knowledge_base_id=bedrock_id, + retrieval_config={ + "vectorSearchConfiguration": {"numberOfResults": 1}}, + ).invoke("test connection") + + logger.info(f"Request {g.request_id}: Exited ") + return json.dumps({"isValid": True}), 200 + except Exception as e: + logger.error(f"Request {g.request_id}: Failed to validate bedrock_id: {str(e)}") + return json.dumps({"isValid": False}), 200 diff --git a/backend/config/errors.py b/backend/config/errors.py new file mode 100644 index 0000000..5466dc4 --- /dev/null +++ b/backend/config/errors.py @@ -0,0 +1,16 @@ +from flask import jsonify +from config.exceptions import CustomAppException + + +def register_error_handlers(app): + @app.errorhandler(CustomAppException) + def handle_custom_app_exception(e): + response = jsonify(e.to_dict()) + response.status_code = e.status_code + return response + + @app.errorhandler(Exception) + def handle_general_exception(e): + response = jsonify({"error": "An unexpected error occurred", "message": str(e)}) + response.status_code = 500 + return response diff --git a/backend/config/exceptions.py b/backend/config/exceptions.py new file mode 100644 index 0000000..1977101 --- /dev/null +++ b/backend/config/exceptions.py @@ -0,0 +1,27 @@ +class CustomAppException(Exception): + status_code = 500 + + def __init__(self, message, status_code=None, payload=None): + super().__init__() + self.message = message + if status_code is not None: + self.status_code = status_code + self.payload = payload + + def to_dict(self): + rv = dict(self.payload or ()) + rv["message"] = self.message + return rv + +# Define custom exceptions +class ConfigurationError(Exception): + """Custom exception for configuration errors.""" + pass + +class InvalidAPIKeyError(ConfigurationError): + """Raised when the API key is invalid or missing.""" + pass + +class InvalidEndpointError(ConfigurationError): + """Raised when the endpoint is invalid or missing.""" + pass \ No newline at end of file diff --git a/backend/config/executor.py b/backend/config/executor.py new file mode 100644 index 0000000..d345b55 --- /dev/null +++ b/backend/config/executor.py @@ -0,0 +1,17 @@ +from flask_executor import Executor + +class ExecutorConfig: + _instance = None + _executor = None + + def __new__(cls, app=None): + if cls._instance is None: + cls._instance = super(ExecutorConfig, cls).__new__(cls) + if app is not None: + cls._executor = Executor(app) + return cls._instance + + def get_executor(self): + if self._executor is None and app is not None: + self._executor = Executor(app) + return self._executor diff --git a/backend/config/logging_config.py b/backend/config/logging_config.py new file mode 100644 index 0000000..ba2dd0a --- /dev/null +++ b/backend/config/logging_config.py @@ -0,0 +1,28 @@ +import logging + + +# Uncomment Cloud Watch Related code to use it - with your own AWS Creds +logger = logging.getLogger("requirementapp-backend") +logger.setLevel(logging.INFO) + +if not logger.handlers: + + # # Create a CloudWatch log handler + # cloudwatch_handler = watchtower.CloudWatchLogHandler(log_group="backend", boto3_client=boto_client) + + # Create a console log handler + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.DEBUG) + + # Define log message format + formatter = logging.Formatter( + "[%(asctime)s] %(levelname)s [%(name)s] [%(pathname)s:%(lineno)d] in %(module)s: %(message)s", + "%Y-%m-%d %H:%M:%S", + ) + console_handler.setFormatter(formatter) + + # Add handlers to the logger + + # cloudwatch_handler.setFormatter(formatter) + # logger.addHandler(cloudwatch_handler) + logger.addHandler(console_handler) diff --git a/backend/decorators/require_access_token.py b/backend/decorators/require_access_token.py new file mode 100644 index 0000000..cf1ef55 --- /dev/null +++ b/backend/decorators/require_access_token.py @@ -0,0 +1,33 @@ +from functools import wraps +from flask import request, jsonify +import base64 +from utils.env_utils import get_env_variable, EnvVariables + +def require_access_code(): + access_code = get_env_variable(EnvVariables.APP_PASSCODE_KEY) + + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + # Skip auth for verify_access_token endpoint + if request.endpoint == 'verify_access_token': + return f(*args, **kwargs) + + request_access_code = request.headers.get('X-Access-Code') + # Check if access code is provided + if not request_access_code: + return jsonify({"error": "Access code is required"}), 401 + + # Attempt to decode the access code, handling errors gracefully + try: + decoded_request_access_code = base64.b64decode(request_access_code).decode("utf-8") + except (base64.binascii.Error, UnicodeDecodeError): + return jsonify({"error": "Invalid access code format"}), 400 + + # Compare decoded access code to expected code + if decoded_request_access_code != access_code: + return jsonify({"error": "Invalid access code"}), 401 + + return f(*args, **kwargs) + return decorated_function + return decorator diff --git a/backend/env.sh b/backend/env.sh new file mode 100755 index 0000000..7c57ac3 --- /dev/null +++ b/backend/env.sh @@ -0,0 +1,24 @@ +export APP_PASSCODE_KEY="7654321" # For local development only, change this in production +export HOST="0.0.0.0" +export PORT=5001 +export DEBUG=false +export ENABLE_SENTRY=false +export SENTRY_DSN= +export SENTRY_ENVIRONMENT= +export SENTRY_RELEASE= + +export DEFAULT_API_PROVIDER="OPENAI_NATIVE" +export DEFAULT_MODEL="gpt-4o" + +# OpenAI Config +export OPENAI_API_KEY="" +export OPENAI_API_BASE=https://api.openai.com/v1/models/ + +# Azure Config +export AZUREAI_API_BASE="" +export AZUREAI_API_KEY="" +export AZUREAI_API_VERSION="" + +# Bedrock Config +export CLAUDE_API_KEY="" +export CLAUDE_ENDPOINT="" diff --git a/backend/init.py b/backend/init.py new file mode 100644 index 0000000..9a7afff --- /dev/null +++ b/backend/init.py @@ -0,0 +1,100 @@ +import uuid +import sentry_sdk +from sentry_sdk.integrations.logging import LoggingIntegration + +from flask_cors import CORS +from flask import Flask, request, g +from config.errors import register_error_handlers +from config.logging_config import logger +from utils.env_utils import get_env_variable, EnvVariables +from api.auth_api import auth_api +from api.chat_api import chat_api +from api.common_api import common_api +from api.solution_api import solution_api +from config.executor import ExecutorConfig + +# from dotenv import load_dotenv + +# load_dotenv('.env') +enableSentry = str(get_env_variable(EnvVariables.ENABLE_SENTRY)).strip().lower() == 'true' +host = get_env_variable(EnvVariables.HOST) +port = get_env_variable(EnvVariables.PORT) +debug = str(get_env_variable(EnvVariables.DEBUG)).strip().lower() == 'true' +sentry_dsn = get_env_variable(EnvVariables.SENTRY_DSN) +environment = get_env_variable(EnvVariables.SENTRY_ENVIRONMENT) +release = get_env_variable(EnvVariables.SENTRY_RELEASE) + + +if enableSentry: + print("sentry configuration is enabled.") + sentry_sdk.init( + dsn=get_env_variable(EnvVariables.SENTRY_DSN), + environment=get_env_variable(EnvVariables.SENTRY_ENVIRONMENT), + integrations=[ + LoggingIntegration( + level=None, # Capture all logs as breadcrumbs + event_level="ERROR" # Send only logs of level ERROR and higher as events + ) + ], + release=get_env_variable(EnvVariables.SENTRY_RELEASE), + enable_tracing=True, + attach_stacktrace=True, + profiles_sample_rate=1.0, + ) +else: + print("sentry configuration is disabled.") + + +app = Flask(__name__) +# Configure the executor using the ConfigureExecutor singleton class +executor = ExecutorConfig(app).get_executor() +app.config['EXECUTOR_MAX_WORKERS'] = 5 + +# Middleware to handle `X-Model` header +@app.before_request +def set_model_env(): + model = request.headers.get('X-Model') + provider = request.headers.get('X-Provider') + if model and provider: + g.current_model = model + g.current_provider = provider + app.logger.info(f"Set PROVIDER to {provider}") + app.logger.info(f"Set MODEL to {model}") + else: + g.current_model = get_env_variable(EnvVariables.DEFAULT_MODEL) + g.current_provider = get_env_variable(EnvVariables.DEFAULT_API_PROVIDER) + app.logger.info(f"Set MODEL to default") + app.logger.info(f"Set PROVIDER to default") + +@app.after_request +def add_model_used_header(response): + response.headers['X-Model-Used'] = g.current_model + response.headers['X-Provider-Used'] = g.current_provider + app.logger.info(f"Request completed with MODEL {g.current_model} and APIProvider {g.current_provider}") + return response + +register_error_handlers(app) + +CORS(app, resources={r"/*": {"origins": "*", "send_wildcard": "False"}}) + +@app.before_request +def start_request(): + g.request_id = uuid.uuid4() # Generate a unique ID for each request + logger.info(f"Request {g.request_id}: {request.method} {request.path} started") + +@app.after_request +def log_request(response): + logger.info(f"Request {g.request_id}: {request.method} {request.path} completed with status {response.status_code}") + return response + +def log_exception(e): + logger.exception(f"Request {g.request_id} failed with error: {str(e)}") + +app.register_blueprint(common_api) # Register the common_api blueprint +app.register_blueprint(auth_api) # Register the auth_api blueprint +app.register_blueprint(chat_api) # Register the chat_api blueprint +app.register_blueprint(solution_api) # Register the solution_api blueprint + +if __name__ == "__main__": + app.run(host=host, port=port, debug=debug) + logger.info("Server started") diff --git a/backend/llm/chat_agent.py b/backend/llm/chat_agent.py new file mode 100644 index 0000000..c3ada80 --- /dev/null +++ b/backend/llm/chat_agent.py @@ -0,0 +1,22 @@ +from config.logging_config import logger +class ChatAgent(): + """Agent is built to handle the chat related functions""" + def __init__(self, + system_message: str = "You are helpful assistant") -> None: + self.system_message: str = system_message + + def prepare_chat_messages_with_history(self, message: str, chat_history: list[dict]) -> list[dict]: + """Prepare messages for LLM with system message and chat history.""" + logger.info("Entering <__prepare_messages> function") + messages = [{"role": "system", "content": self.system_message}] + + # Append chat history if available + if chat_history: + for chat in chat_history: + for key, value in chat.items(): + messages.append({"role": key, "content": value}) + + # Prioritize the user message last + messages.append({"role": "user", "content": message}) + logger.info("Exiting <__prepare_messages> function") + return messages \ No newline at end of file diff --git a/backend/llm/llm_constants.py b/backend/llm/llm_constants.py new file mode 100644 index 0000000..c1313e9 --- /dev/null +++ b/backend/llm/llm_constants.py @@ -0,0 +1,55 @@ +from enum import Enum +from utils.env_utils import EnvVariables + +# Define API providers as an Enum +class Providers(Enum): + OPENAI_NATIVE = 'OPENAI_NATIVE' + # OpenAI-compatible models refer to language models that are API-compatible with OpenAI's architecture and usage patterns + OPENAI_COMPATIBLE_AZURE = 'OPENAI_COMPATIBLE_AZURE' + OPENAI_COMPATIBLE_CLAUDE = 'OPENAI_COMPATIBLE_CLAUDE' + AWS_BEDROCK_CLAUDE = 'AWS_BEDROCK_CLAUDE' + +# Defined supported models as an Enum +class Models(Enum): + GPT_4O = 'gpt-4o' + GPT_4O_MINI = 'gpt-4o-mini' + CLAUDE_3_5 = 'anthropic.claude-3-5-sonnet-20240620-v1:0' + +# Defined the Provider and Model Config Map +PROVIDER_MODEL_CONFIG_MAP = { + Providers.OPENAI_NATIVE.value: { + 'SUPPORTED_MODELS': [Models.GPT_4O.value, Models.GPT_4O_MINI.value], + 'MODEL_CONFIG_MAP': { + Models.GPT_4O.value: { + 'CONFIG': [EnvVariables.OPENAI_API_KEY, EnvVariables.OPENAI_API_BASE], + 'HANDLER': ("llm.providers.openai_native_handler", "OpenAiNativeHandler") + }, + Models.GPT_4O_MINI.value: { + 'CONFIG': [EnvVariables.OPENAI_API_KEY, EnvVariables.OPENAI_API_BASE], + 'HANDLER': ("llm.providers.openai_native_handler", "OpenAiNativeHandler") + }, + }, + }, + Providers.OPENAI_COMPATIBLE_AZURE.value: { + 'SUPPORTED_MODELS': [Models.GPT_4O.value, Models.GPT_4O_MINI.value], + 'MODEL_CONFIG_MAP': { + Models.GPT_4O.value: { + 'CONFIG': [EnvVariables.AZUREAI_API_KEY, EnvVariables.AZUREAI_API_BASE, EnvVariables.AZUREAI_API_VERSION], + 'HANDLER': ("llm.providers.openai_compatible_azure_handler", "OpenAiCompatibleAzureHandler") + }, + Models.GPT_4O_MINI.value: { + 'CONFIG': [EnvVariables.AZUREAI_API_KEY, EnvVariables.AZUREAI_API_BASE, EnvVariables.AZUREAI_API_VERSION], + 'HANDLER': ("llm.providers.openai_compatible_azure_handler", "OpenAiCompatibleAzureHandler") + }, + }, + }, + Providers.OPENAI_COMPATIBLE_CLAUDE.value: { + 'SUPPORTED_MODELS': [Models.CLAUDE_3_5.value], + 'MODEL_CONFIG_MAP': { + Models.CLAUDE_3_5.value: { + 'CONFIG': [EnvVariables.CLAUDE_API_KEY, EnvVariables.CLAUDE_ENDPOINT], + 'HANDLER': ("llm.providers.openai_compatible_claude_handler", "OpenAiCompatibleClaudeHandler") + }, + } + }, +} diff --git a/backend/llm/llm_service.py b/backend/llm/llm_service.py new file mode 100644 index 0000000..0def169 --- /dev/null +++ b/backend/llm/llm_service.py @@ -0,0 +1,112 @@ +from flask import g +from config.exceptions import CustomAppException +from config.logging_config import logger +from llm.chat_agent import ChatAgent +from utils.common_utils import add_knowledge_base_to_prompt +from llm.multi_model_router import multiModalRouter +from utils.env_utils import get_env_variable, EnvVariables + +class LLMService: + """ + LLMService is a singleton class responsible for handling interactions with the Language Model. + It provides methods to call the LLM with or without chat history and knowledge base integration. + """ + _instance = None + chat_agent_service = ChatAgent() + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super(LLMService, cls).__new__(cls) + return cls._instance + + def _prepare_messages(self, prompt, chat_history=None, system_message=None): + """ + Prepare messages for the LLM with optional chat history and system message. + + :param prompt: The main prompt to be sent to the LLM. + :param chat_history: Optional list of previous chat messages. + :param system_message: Optional system message to guide the LLM. + :return: A list of message dictionaries formatted for the LLM. + """ + messages = [] + if chat_history: + if system_message: + chat_agent_custom_system_msg_service = ChatAgent(system_message) + messages = chat_agent_custom_system_msg_service.prepare_chat_messages_with_history(message=prompt, chat_history=chat_history) + else: + messages = self.chat_agent_service.prepare_chat_messages_with_history(message=prompt, chat_history=chat_history) + else: + messages.append({"role": "user", "content": prompt}) + return messages + + def call_llm(self, prompt, knowledge_base: str = None): + """ + Call the LLM with a given prompt and optional knowledge base. + + :param prompt: The prompt to be sent to the LLM. + :param knowledge_base: Optional knowledge base to enhance the prompt. + :return: The result from the LLM. + :raises CustomAppException: If an error occurs during the LLM call. + """ + try: + if knowledge_base: + logger.info(f"Request {g.request_id}: Using Knowledge Base") + prompt = add_knowledge_base_to_prompt(prompt, knowledge_base) + + messages = self._prepare_messages(prompt) + + logger.info(f"Request {g.request_id}: Invoking with provider = {g.current_provider}") + logger.info(f"Request {g.request_id}: Invoking with model = {g.current_model}") + + result = multiModalRouter.execute(prompt=messages, provider=g.current_provider, model=g.current_model) + + logger.info(f"Request {g.request_id}: LLM response received successfully") + logger.info(f"Request {g.request_id}: Exited ") + return result + + except Exception as e: + logger.error(f"Request {g.request_id}: LLM error occurred: {str(e)}") + raise CustomAppException( + message="Failed to get a response from LLM", status_code=500 + ) + + def call_llm_for_chat_agent(self, prompt, chat_history: list = None, knowledge_base: str = None, system_message: str = None): + """ + Call the LLM for a chat agent with a given prompt, chat history, and optional knowledge base and system message. + + :param prompt: The prompt to be sent to the LLM. + :param chat_history: Optional list of previous chat messages. + :param knowledge_base: Optional knowledge base to enhance the prompt. + :param system_message: Optional system message to guide the LLM. + :return: The result from the LLM. + :raises CustomAppException: If an error occurs during the LLM call. + """ + try: + if knowledge_base: + logger.info(f"Request {g.request_id}: Using Knowledge Base") + prompt = add_knowledge_base_to_prompt(prompt, knowledge_base) + + messages = self._prepare_messages(prompt, chat_history, system_message) + + logger.info(f"Request {g.request_id}: Invoking with provider = {g.current_provider}") + logger.info(f"Request {g.request_id}: Invoking with model = {g.current_model}") + + result = multiModalRouter.execute(prompt=messages, provider=g.current_provider, model=g.current_model) + + logger.info(f"Request {g.request_id}: LLM response received successfully") + logger.info(f"Request {g.request_id}: Exited ") + return result + except Exception as e: + logger.error(f"Request {g.request_id}: LLM error occurred: {str(e)}") + raise CustomAppException( + message="Failed to get a response from LLM", status_code=500 + ) + + + def current_llm(self): + """ + Get the current LLM provider and model being used. + + :return: A string describing the current API provider and model. + """ + return f"Default API Provider:{g.current_provider if g.current_provider else get_env_variable(EnvVariables.DEFAULT_API_PROVIDER)} - Default Model: {g.current_provider if g.current_provider else get_env_variable(EnvVariables.DEFAULT_MODEL)}" diff --git a/backend/llm/multi_model_router.py b/backend/llm/multi_model_router.py new file mode 100644 index 0000000..7ffbafa --- /dev/null +++ b/backend/llm/multi_model_router.py @@ -0,0 +1,60 @@ +from llm.llm_constants import * +from config.exceptions import CustomAppException +from utils.common_utils import create_instance +from utils.env_utils import get_env_variable +import os + +class MultiModelRouter(): + _instance = None + _llm_handler_cache = {} + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super(MultiModelRouter, cls).__new__(cls) + return cls._instance + + def check_handler_env_config(self, provider, model): + provider_config = PROVIDER_MODEL_CONFIG_MAP.get(provider, None) + if (provider_config is None): + raise CustomAppException("Provider Not Found") + + if (model not in provider_config.get('SUPPORTED_MODELS')): + raise CustomAppException("Model under given Provider Not Found") + + model_env_config = dict(((i, get_env_variable(i)) for i in provider_config.get('MODEL_CONFIG_MAP').get(model).get('CONFIG'))) + for i in model_env_config: + if model_env_config[i] is None or model_env_config[i] == "": + raise CustomAppException(f"{i} is missing the give Model config") + else: + return + + def create_handler(self, provider, model): + handler_cache_key = (provider, model) + if handler_cache_key in self._llm_handler_cache: + return self._llm_handler_cache[handler_cache_key] + + provider_config = PROVIDER_MODEL_CONFIG_MAP.get(provider, None) + if (provider_config is None): + raise CustomAppException("Provider Not Found") + + if (model not in provider_config.get('SUPPORTED_MODELS')): + raise CustomAppException("Model under given Provider Not Found") + + # Get Handler + module_name, class_name = provider_config.get('MODEL_CONFIG_MAP').get(model).get('HANDLER', (None, None)) + + if module_name and class_name: + handler_instance = create_instance(module_name, class_name, model) + self._llm_handler_cache[handler_cache_key] = handler_instance + return handler_instance + else: + raise CustomAppException("Handler Not Found") + + + def execute(self, prompt, provider, model): + handler = self.create_handler(provider, model) + self.check_handler_env_config(provider, model) + response = handler.completion(prompt) + return response + +multiModalRouter = MultiModelRouter() diff --git a/backend/llm/prompts.py b/backend/llm/prompts.py new file mode 100644 index 0000000..f5bf212 --- /dev/null +++ b/backend/llm/prompts.py @@ -0,0 +1,22 @@ +p_create_solution = open("llm/prompts/01_create.jinja2", encoding="utf-8").read().strip() +p_update_requirement = open("llm/prompts/02_update.jinja2", encoding="utf-8").read().strip() +p_add_requirement = open("llm/prompts/03_add.jinja2", encoding="utf-8").read().strip() +p_features = open("llm/prompts/05_refine.jinja2", encoding="utf-8").read().strip() +p_features_evaluation = open("llm/prompts/06_evaluate.jinja2", encoding="utf-8").read().strip() +p_task = open("llm/prompts/07_task.jinja2", encoding="utf-8").read().strip() +p_process_flow_chart = open("llm/prompts/08_process_flow_chart.jinja2", encoding="utf-8").read().strip() +p_add_business_process = open("llm/prompts/09_add_business_process.jinja2", encoding="utf-8").read().strip() +p_update_business_process = open("llm/prompts/10_update_business_process.jinja2", encoding="utf-8").read().strip() +p_add_user_story = open("llm/prompts/11_add_user_story.jinja2", encoding="utf-8").read().strip() +p_update_user_story = open("llm/prompts/12_update_user_story.jinja2", encoding="utf-8").read().strip() +p_add_task = open("llm/prompts/13_add_task.jinja2", encoding="utf-8").read().strip() +p_update_task = open("llm/prompts/14_update_task.jinja2", encoding="utf-8").read().strip() +# Chat Prompts +p_chat_improved_suggestions = open("llm/prompts/chat/improve_suggestions.jinja2", encoding="utf-8").read().strip() +p_chat_update_requirement = open("llm/prompts/chat/update_requirement.jinja2", encoding="utf-8").read().strip() +p_chat_update_user_story_task = open("llm/prompts/chat/update_user_story_task.jinja2", encoding="utf-8").read().strip() +# Solution Creation Prompts +p_solution_create_brd = open("llm/prompts/solution/create_brd.jinja2", encoding="utf-8").read().strip() +p_solution_create_prd = open("llm/prompts/solution/create_prd.jinja2", encoding="utf-8").read().strip() +p_solution_create_nfr = open("llm/prompts/solution/create_nfr.jinja2", encoding="utf-8").read().strip() +p_solution_create_uir = open("llm/prompts/solution/create_uir.jinja2", encoding="utf-8").read().strip() \ No newline at end of file diff --git a/backend/llm/prompts/01_create.jinja2 b/backend/llm/prompts/01_create.jinja2 new file mode 100644 index 0000000..c5c97ca --- /dev/null +++ b/backend/llm/prompts/01_create.jinja2 @@ -0,0 +1,116 @@ +You are a requirements analyst tasked with extracting detailed Business Requirements, Product Requirements, Non-Functional Requirements and User Interface Requirements from the provided app description. Below is the description of the app: + +App Name: +{{name}} + +App Description: +{{description}} + + +Instructions: + +Generate an apt title for all the following requirements. Title should be a one-liner not more than 5 words. + +Business Requirements (BRD): + +Identify the high-level objectives and goals the business aims to achieve with this app. +Consider the business context, target audience, and key stakeholders. +Focus on the strategic outcomes and benefits for the business. + +Product Requirements (PRD): + +Detail the specific functionalities and features the app must have. +Include user interface (UI) requirements, user experience (UX) considerations, and any necessary integrations with other systems. +Address the needs and expectations of end-users. +Generate Screens and Personas based on each PRD: + Screens: Define the various screens or pages within the app, their purpose, and key elements based on the specific product requirements detailed in each PRD. + Personas: Identify the different user types who will interact with the app, their goals, and how they will use the app, tailored to the particular needs and features outlined in each PRD. + +Non-Functional Requirements (NFR): + +Define the performance, scalability, and reliability expectations. +Include security, compliance, and regulatory requirements. +Specify any constraints related to deployment, maintenance, and technology stack. + +User Interface Requirements (UIR): + + Task: Generate user interface requirements for the following application description. + + Output Format : Each requirement is articulated in a sentence format and avoid using bullet points,numbering and section titles. + + Application Description: {{description}} + + Instructions for Generating UI Requirements: + + User Interactions: + Identify and describe all key user interactions within the application. + Specify any particular user flows or processes that need to be supported. + Highlight any specific actions users must perform and how they interact with various UI components. + + Visual Elements: + Detail the visual design requirements including layout, colors, fonts, and icons. + Describe how each screen or page should look and feel. + Include any specific design patterns or themes that should be applied. + + Functionality: + List and explain all functional elements required on the user interface, such as buttons, forms, navigation menus, and feedback messages. + Describe the behavior of dynamic elements like dropdowns, modals, and tooltips. + Specify any conditions or validations that need to be handled within the UI. + + Accessibility: + Outline the accessibility features that must be included, such as keyboard navigation, screen reader support, and color contrast requirements. + Mention any standards or guidelines (e.g., WCAG) that the UI must comply with. + Describe any additional features to support users with disabilities. + + Performance Considerations: + Identify performance requirements for the UI, such as load times and responsiveness. + Mention any considerations for optimizing user experience on various devices and screen sizes. + Describe any fallback or degradation strategies for low-performance environments. + + User Feedback and Testing: + Explain how user feedback should be gathered and incorporated into the UI design. + Specify any usability testing methods that should be used to validate the UI requirements. + Include details on how iterative improvements based on user testing will be managed. + + Documentation and Guidelines: + List the documentation that should accompany the UI, such as style guides, design system specifications, and user guides. + Specify how UI guidelines will be communicated and maintained. + + +Output Structure should be a valid JSON: Here is the sample Structure: + +{ + "brd": [ + { + "id": "BRD1", "title": ,"requirement": "[Business Requirement]" + }, + { + "id": "BRD2", "title": <title> ,"requirement": "[Business Requirement]" + }.. + ], + "prd": [ + { + "id": "PRD1", "title": <title> ,"requirement": "[Product Requirements] Screens: [Screen Description] Personas: [Persona Description]" + }, + { + "id": "PRD2", "title": <title> ,"requirement": "[Product Requirements] Screens: [Screen Description] Personas: [Persona Description]" + }... + ], + "nfr": [ + { + "id": "NFR1", "title": <title> ,"requirement": "[Non-Functional Requirements]" + }, + { + "id": "NFR2", "title": <title> ,"requirement": "[Non-Functional Requirements]" + }... + ] + "uir": [ + { + "id": "UIR1", "title": <title> ,"requirement": "[User Interface Requirements]" + }, + { + "id": "UIR2", "title": <title> ,"requirement": "[User Interface Requirements]" + }... + ] +} +Please ensure the requirements are clear, concise, and comprehensive. Output only valid JSON. Do not include ```json ``` on start and end of the response. diff --git a/backend/llm/prompts/02_update.jinja2 b/backend/llm/prompts/02_update.jinja2 new file mode 100644 index 0000000..2f5be7f --- /dev/null +++ b/backend/llm/prompts/02_update.jinja2 @@ -0,0 +1,46 @@ +{% set type = addReqtType %} +{% set format = '{"title": <title>, "requirement": <requirement>}' %} + +{% if type == 'BRD' %} +{% set requirementType = 'Business Requirements' %} +{% set context = 'brd.jinja2' %} +{% elif type == 'PRD' %} +{% set requirementType = 'Product Requirements' %} +{% set context = 'prd.jinja2' %} +{% set format = '{"title": <title>, "requirement": "<requirement> Screens: <Screen Description> Personas: <Persona Description>"}' %} +{% elif type == 'NFR' %} +{% set requirementType = 'Non-Functional Requirements' %} +{% set context = 'nfr.jinja2' %} +{% elif type == 'UIR' %} +{% set requirementType = 'User Interface Requirements' %} +{% set context = 'uir.jinja2' %} +{% endif %} + +You are a requirements analyst tasked with extracting detailed {{requirementType}} from the provided app description. Below is the description of the app: + +App Name: {{name}} +App Description: {{description}} + +Here is the existing requirement: +{{existingReqt}} + +Client Request: +{{updatedReqt}} + +{% if fileContent %} +FileContent: {{fileContent}} +{% endif %} + +Context: +{% include context %} + +Based on the above context, update the existing requirement by incorporating the client's requests and the information from the provided file content. + +Output Structure should be a valid JSON: Here is the sample Structure: +{ + "updated": {{format}} +} + +Updated requirement should be strictly in string format. + +Output only valid JSON. Do not include ```json ``` on start and end of the response. diff --git a/backend/llm/prompts/03_add.jinja2 b/backend/llm/prompts/03_add.jinja2 new file mode 100644 index 0000000..4d73dc1 --- /dev/null +++ b/backend/llm/prompts/03_add.jinja2 @@ -0,0 +1,42 @@ +{% set type = addReqtType %} +{% set format = '{"title": <title>, "requirement": <requirement>}' %} + +{% if type == 'BRD' %} +{% set requirementType = 'Business Requirements' %} +{% set context = 'brd.jinja2' %} +{% elif type == 'PRD' %} +{% set requirementType = 'Product Requirements' %} +{% set context = 'prd.jinja2' %} +{% set format = '{"title": <title>,"requirement": "<requirement> Screens: <Screen Description> Personas: <Persona Description>"}' %} +{% elif type == 'NFR' %} +{% set requirementType = 'Non-Functional Requirements' %} +{% set context = 'nfr.jinja2' %} +{% elif type == 'UIR' %} +{% set requirementType = 'User Interface Requirements' %} +{% set context = 'uir.jinja2' %} +{% endif %} + +You are a requirements analyst tasked with extracting detailed {{requirementType}} from the provided app description. Below is the description of the app: + +App Name: {{name}} +App Description: {{description}} + +Client Request: {{newReqt}} + +{% if fileContent %} +FileContent: {{fileContent}} +{% endif %} + +Context: +{% include context %} + +Based on the above context create a one apt requirement justifies the Client Request + +Output Structure should be a valid JSON: Here is the sample Structure. Follow this exactly. Don't add or change the response JSON: +{ + "LLMreqt": {{format}} +} + +New requirement should be strictly in string format. + +Output only valid JSON. Do not include ```json ``` on start and end of the response. diff --git a/backend/llm/prompts/05_refine.jinja2 b/backend/llm/prompts/05_refine.jinja2 new file mode 100644 index 0000000..abc3d40 --- /dev/null +++ b/backend/llm/prompts/05_refine.jinja2 @@ -0,0 +1,91 @@ +# REQUIREMENTS +{{requirements}} + +{% if technologies %} +# Technical Details +{{technologies}} +{% endif %} + +{% if features %} +# FEATURES +{{features}} +{% endif %} + +{% if evaluation %} +# EVALUATION +{{evaluation}} +{% endif %} + +{% if extraContext %} +# extraContext +{{extraContext}} +{% endif %} + +# GUIDELINES +1. Review the current features split and the previous evaluation. +2. Split the requirements into features based on evaluation, these guidelines, and any additional insights from the extraContext. +3. Title them by their business goal and purpose: DON'T start their name with "Implement" +4. To determine the appropriate granularity of the features make sure each feature is + - focused on a single business aspect of the requirements + - cohesive business wise + - prefer delivering incremental value to the user + - for example if something can be delivered read only with a follow-up feature to edit or change, prefer that +5. Ensure that an individual feature is NOT too small to implement. + - example of TOO SMALL: + - "edit one field" + - "when navigated to a link user can see a view" + - "display a button, component on the screen" + - "validate email field", etc. +6. Return each feature on a separate line. Do not add empty lines. +7. Return no other information, only a list of features. + - NO additional text, headers, or footers +8. Avoid all generic features like Accessibility, error handling, scalability, Responsive Web Design, Data Security, sync integrity, ajax localstorage, secure application, Cross device compatibility etc., because these features are applicable for all the other features. +9. Combine all the features to maximum count of 5 features. + +Remember, a feature is not a small task; it is a business functionality. +Make sure that the list of feature names FULLY addresses every aspect of the requirements and is enhanced by the extraContext provided. +Think step-by-step before providing a response. + +{% include 'user_story.jinja2' %} + +# RESPONSE FORMAT EXAMPLE +{ + "features": [ + { + "id": "US1" + "title": "<title of feature 1>" + "description": "[description of feature 1] Acceptance Criteria: [acceptance criteria for the feature 1 as sentences]" + }, + { + "id": "US2" + "title": "<title of feature 2>" + "description": "[description of feature 2] Acceptance Criteria: [acceptance criteria for the feature 2 as sentences]" + }, + { + "id": "US3" + "title": "<title of feature 3>" + "description": "[description of feature 3] Acceptance Criteria: [acceptance criteria for the feature 3 as sentences]" + }, + { + "id": "US4" + "title": "<title of feature 4>" + "description": "[description of feature 4] Acceptance Criteria: [acceptance criteria for the feature 4 as sentences]" + }, + { + "id": "US5" + "title": "<title of feature 5>" + "description": "[description of feature 5] Acceptance Criteria: [acceptance criteria for the feature 5 as sentences]" + }, + ... + ] +} +... +Strictly consider this format to generate user story - + +Ability to <user action> the <feature> +In order to <mention the user need> +As a <the persona or user> + +I want the <end goal or objective of the feature> + +(!) return a list of features ONLY: no other headers, footers, or additional text. Output only valid JSON. Do not include ```json ``` on start and end of the response. \ No newline at end of file diff --git a/backend/llm/prompts/06_evaluate.jinja2 b/backend/llm/prompts/06_evaluate.jinja2 new file mode 100644 index 0000000..83b1d68 --- /dev/null +++ b/backend/llm/prompts/06_evaluate.jinja2 @@ -0,0 +1,45 @@ +# INSTRUCTIONS +As a Business Analyst, your task is to evaluate the following feature names against the original requirements and decide whether the feedback is needed. + +Your goal is to make sure requirements are split into features that are: + - focused on a single business aspect of the requirements + - cohesive business-wise + - clear, concise + - simple to implement by the engineering team + +Remember feature is not a small task but a business functionality + +# FEATURES +{{features}} + +# REQUIREMENTS +{{requirements}} + +# RESPONSE FORMAT + +**chain of thought** +<before providing a response think step by step> + +**feedback about each feature** +<feedback for individual features> +<DON'T include features that are satisfactory> + +# GUIDELINES +1. Review features and assess whether it aligns with the original requirements + - ask to remove any features that are not aligned with the requirements +2. Determine whether this split needs to change, provide feedback accordingly +3. (!) Prefer delivering incremental value to the user + - for example if something can be delivered read only with a follow-up feature to edit or change, prefer that +4. Ensure that an individual feature is NOT too small to implement. + - example of TOO SMALL: + - "edit one field" + - "when navigated to a link user can see a view" + - "display a button, component on the screen" + - "validate email field", etc. +5. Don't include features that are satisfactory in the response +6. Strictly follow the "RESPONSE FORMAT" provided above +7. Only in case there no suggestions or improvements to the split, reply with "APPROVED AND READY FOR REFINEMENT" in uppercase +8. If the features are not split well and require to be split differently, provide feedback indicating so. + +Make sure that the list of feature names FULLY addresses every aspect of the requirements. +Remember, these are not full descriptions of the features, but a high-level feature names. \ No newline at end of file diff --git a/backend/llm/prompts/07_task.jinja2 b/backend/llm/prompts/07_task.jinja2 new file mode 100644 index 0000000..4a09825 --- /dev/null +++ b/backend/llm/prompts/07_task.jinja2 @@ -0,0 +1,135 @@ +You are a solution architect tasked with creating granular implementation tasks from the provided user story. The tasks should be specific, actionable and directly tied to the technical details mentioned in the user story. + +Module Name: +{{name}} + +User Story: +{{userstories}} + +{% if technologies %} +Technical Details: +{{technologies}} +{% endif %} + +{% if extraContext %} +Extra Context: +{{extraContext}} +{% endif %} + +REQUIREMENTS FOR TASK GENERATION: + +1. Technical Details Extraction: +- Extract and use ONLY technical specifications mentioned in the user story (paths, identifiers, data structures etc.) +- Do not add or assume technical details not present in the story +- Use the exact naming conventions, paths and patterns specified +- Reference only the specific technology stack if mentioned in technical details +- DO NOT include project setup, environment configuration, or initialization tasks + +2. Task Creation Rules: +- Break down ONLY the feature implementation into granular, independent tasks +- Each task must implement a specific functionality from the user story +- Tasks should follow a logical order of implementation +- No generic descriptions or assumptions +- Ensure tasks are code-only, with a specific focus on implementing individual functions, modules, or methods. Avoid any high-level or organizational tasks. +- Focus exclusively on the feature requirements mentioned in the user story +- Exclude any infrastructure, project setup, or configuration management tasks +- Assume the project environment and required dependencies are already set up + +3. Task Description Must Include: +- Specific resources/paths being modified or created for this feature +- Exact data structures and patterns to implement +- Clear input/output specifications +- Error scenarios from user story +- Required validations and business rules +- Dependencies between feature implementation tasks only +- Performance/scaling requirements if specified in user story +- Testing scope specific to the new functionality + +4. Acceptance Criteria Rules: +- Must be technically verifiable steps +- Include exact validations required +- Specify error handling requirements +- Define testing requirements specific to the feature +- Include any compliance/audit requirements mentioned in user story +- Reference exact user story requirements being fulfilled +- Focus on feature behavior and functionality only + +RESPONSE FORMAT EXAMPLE +{ + "tasks": [ + { + "id": "TASK1", + "name": <task 1> + "acceptance": "[description of the task 1] Acceptance Criteria: [acceptance criteria for the task 1 as sentences]" + }, + { + "id": "TASK2", + "name": <task 2> + "acceptance": "[description of the task 2] Acceptance Criteria: [acceptance criteria for the task 2 as sentences]" + }, + { + "id": "TASK3", + "name": <task 3> + "acceptance": "[description of the task 3] Acceptance Criteria: [acceptance criteria for the task 3 as sentences]" + }, + { + "id": "TASK4", + "name": <task 4> + "acceptance": "[description of the task 4] Acceptance Criteria: [acceptance criteria for the task 4 as sentences]" + }, + { + "id": "TASK5", + "name": <task 5> + "acceptance": "[description of the task 5] Acceptance Criteria: [acceptance criteria for the task 5 as sentences]" + }, + ... + ] +} + +SCOPE BOUNDARIES: +DO NOT INCLUDE tasks for: +- Project initialization or setup +- Development environment configuration +- Dependency installation or management +- Database creation or schema initialization +- Basic project structure creation +- Library or framework installation +- Only high-level overview without code-implementation + +INCLUDE ONLY tasks for: +- New feature implementation +- Feature-specific API endpoints +- Feature-specific database changes +- Feature-specific UI components +- Feature-specific Cloud Infrastructure setup or modifications +- Feature-specific business logic +- Feature-specific validations +- Integration with existing systems +- Logic and Code Implementation +- Feature-specific testing + +VALIDATION CHECKLIST: +- Does each task directly implement a feature requirement from the user story? +- Are tasks specific to feature implementation without project setup elements? +- Does each task map to a clear user story requirement? +- Is the implementation sequence logical? +- Are feature-specific dependencies identified? +- Can tasks be implemented without additional clarification? +- Are all tasks directly related to feature functionality? +- Are tasks limited to code implementation, with no high-level system or compliance descriptions? + +STRICTLY ENFORCE: +(!) Generate only feature code-implementation tasks based on the user story requirements +(!) Include only tasks that directly contribute to the feature functionality +(!) Avoid high-level tasks; tasks should be precise, atomic, and specific to code. +(!) Ensure tasks are strictly based on the provided user stories and enhanced by any additional insights from the extraContext +(!) Ensure that no tasks or acceptance criteria stray from the provided user stories or extraContext +(!) Tasks must be clear, concise, and comprehensive +(!) Output only valid JSON as per the specified format. Do not include any other text or formatting. Do not include ```json``` on start and end of the response. +(!) Extract and use only technical details present in user story +(!) No generic descriptions or assumed requirements +(!) Each task must be directly implementable +(!) Follow exact naming and patterns from user story +(!) Only reference technologies specified in technical details(if any) +(!) Tasks must add up to fulfill complete user story requirement +(!) No project setup tasks diff --git a/backend/llm/prompts/08_process_flow_chart.jinja2 b/backend/llm/prompts/08_process_flow_chart.jinja2 new file mode 100644 index 0000000..d26ab0d --- /dev/null +++ b/backend/llm/prompts/08_process_flow_chart.jinja2 @@ -0,0 +1,63 @@ +You are a team lead tasked with creating detailed breakdown of the business process flow for a given business process. + +Below is the business process for which the mermaid flowchart diagram code syntax has to be generated: + +Business Process: +Title: {{title}} +{{ description }} + +The mermaid diagram code syntax should be STRICTLY derived based on the provided business process description, with no additional or irrelevant information included. +Ensure that each step in the flowchart diagram code is clear, concise, and comprehensive. +Maintain the title for Mermaid as same as Business Process. + +Output Structure should be a valid mermaid code syntax: Here is the sample Structure. Follow this response format exactly. + +RESPONSE FORMAT EXAMPLES +Example 1: +Business Process: +Title: Login +User login functionality. + +Mermaid Flow Response: +--- +title: Login +--- +flowchart TD + A([User]) -- enters login details ---> B[[Application]] + B -- Send login request ---> C[[Server]] + C -- Server verifies credentials ---D[(Database)] + D --> E{Are credentials valid?} + E -->|Yes| F[Generate authentication token] + F --> G[Send token to Application] + G --> H[Grant access to user] + H --> L[End] + E -->|No| J[Send error message to Application] + J --> K[Show error message to user] + K --> L[End] +... + +Note: +1. The actual code syntax for a given business process may differ but the format should not be changed. +2. Use the right representation for each node. For instance: + a. For nodes representing database use [()] + b. For nodes representing entities like application, UI, Server etc, use [[]] + c. For any decision-making scenario use {} + d. Display the actions like Send login request, verify credentials as text on links. Do not add a separate node for those. + e. Always have single start and end nodes. +3. Generate appropriate title for the flowchart only based on the business process information. Do not assume anything. Title should be crisp and catchy and should not exceed 3 words. +4. Do not generate more than 10 steps in the flow chart for better readability. Keep the diagram crisp and to the point. + +STRICT: +(!) RESPONSE SHOULD STRICTLY FOLLOW SYNTAX OF MERMAID FLOWCHART DIAGRAMS and should be parsed without errors. +(!) DO NOT include INVALID notations/symbols like [(())], (([])) that throws SYNTAX ERROR in mermaid editor. +(!) DO NOT include double quotes(") inside text of a node. USE single quotes (') instead. +(!) Response should contain mermaid flowchart diagram code syntax of business process ONLY: no other additional text. +(!) Please ensure the RESPONSE containing mermaid flowchart diagram CODE is WITHOUT ANY SYNTAX ERROR and strictly based on the provided business process context and the diagram is clear, concise, and comprehensive with complete design. + +If you follow the above STRICT rules, 100$ tip will be given to you. + +The response should be a mermaid flowchart diagram code syntax by CLEARLY MARKING the RELATIONSHIPS between different ENTITIES, ADDING DECISION PATH with success and failure path wherever required and comply with PEP8 standards. +The response diagram should be strictly based on provided business process description and should be comprehensive with complete design. + +Output only valid mermaid diagram code syntax. Do not include ```mermaid``` on start and end of the response. +Do not include ... at the end of response. \ No newline at end of file diff --git a/backend/llm/prompts/09_add_business_process.jinja2 b/backend/llm/prompts/09_add_business_process.jinja2 new file mode 100644 index 0000000..1627387 --- /dev/null +++ b/backend/llm/prompts/09_add_business_process.jinja2 @@ -0,0 +1,38 @@ +You are an expert in creating detailed and efficient Business Process Flows from the provided app description. Below is the description of the app: + +App Name: +{{name}} + +App Description: +{{description}} + +Business Process should clearly articulate the sequence of actions, decision points, and outcomes necessary to achieve the business objectives. +1. Identify Key Stakeholders +2. Define Process Phases +3. Sequence of Actions +4. Decision Points +5. Inputs and Outputs +6. Roles and Responsibilities + +Client Request: +{{newReqt}} + +Business Requirements: +{{BRDS}} + +Product Requirements: +{{PRDS}} + +Provide the Business Process Flow that effectively addresses both the client's requests and the information from the provided Business and Product Requirements. +final_business_process_flow must be a single paragraph, not a list or json format. +Generate an apt title for final_business_process_flow. Title should be a one-liner not more than 5 words. + +Output Structure should be a valid JSON: Here is the sample Structure. Follow this exactly. Don't add or change the response JSON: + +{ + "LLMreqt": {"title": <title>, "requirement": "<final_business_process_flow>"} +} + +Business Process Flow should be strictly in string format. + +Please ensure the requirements are clear, concise, and comprehensive. Output only valid JSON. Do not include ```json ``` on start and end of the response. diff --git a/backend/llm/prompts/10_update_business_process.jinja2 b/backend/llm/prompts/10_update_business_process.jinja2 new file mode 100644 index 0000000..068f93a --- /dev/null +++ b/backend/llm/prompts/10_update_business_process.jinja2 @@ -0,0 +1,41 @@ +You are an expert in creating detailed and efficient Business Process Flows from the provided app description. Below is the description of the app: + +App Name: +{{name}} + +App Description: +{{description}} + +Business Process should clearly articulate the sequence of actions, decision points, and outcomes necessary to achieve the business objectives. +1. Identify Key Stakeholders +2. Define Process Phases +3. Sequence of Actions +4. Decision Points +5. Inputs and Outputs +6. Roles and Responsibilities + +Here is the existing requirement: +{{existingReqt}} + +Client Request: +{{updatedReqt}} + +Business Requirements: +{{BRDS}} + +Product Requirements: +{{PRDS}} + +Update the existing Business Process Flow that effectively addresses both the client's requests and the information from the provided Business and Product Requirements. +final_business_process_flow must be a single paragraph, not a list or json format. +Generate an apt title for final_business_process_flow. Title should be an one-liner not more than 5 words. + +Output Structure should be a valid JSON: Here is the sample Structure: + +{ + "updated": {"title": <title>, "requirement": "<final_business_process_flow>"} +} + +Business Process Flow should be strictly in string format. + +Please ensure the requirements are clear, concise, and comprehensive. Output only valid JSON. Do not include ```json ``` on start and end of the response. diff --git a/backend/llm/prompts/11_add_user_story.jinja2 b/backend/llm/prompts/11_add_user_story.jinja2 new file mode 100644 index 0000000..cc2784d --- /dev/null +++ b/backend/llm/prompts/11_add_user_story.jinja2 @@ -0,0 +1,40 @@ +You are a senior architect tasked with extracting detailed feature for the provided app from the User Story description provided by client. Below is the description of the app and inputs from client: +App Name: +{{name}} + +App Description: +{{description}} + +Requirement type: Product Requirement +{{reqDesc}} + +User Story Id: +{{featureId}} + +Client Request - User Story Description: +{{featureRequest}} + +FileContent: +{{fileContent}} + +{% include 'user_story.jinja2' %} + +Output Structure should be a valid JSON: Here is the sample Structure. Follow this exactly. Do not add or change the response JSON: +# RESPONSE FORMAT EXAMPLE +{ + "features": [ + { + "id": "<featureId>" + "<feature name>": "[description of feature] Acceptance Criteria: [acceptance criteria for the feature as sentences]" + } + ] +} + ... + +Special Instruction: +1. id returned in the response should be same id sent to you (User Story Id -> <featureId> in json response) +2. Strictly return ONLY ONE OBJECT in the response features array. + +STRICT: +(!) return a list of features ONLY: no other headers, footers, or additional text +Please ensure the feature name and description are clear, concise, and comprehensive. Output only valid JSON. Do not include ```json ``` on start and end of the response. \ No newline at end of file diff --git a/backend/llm/prompts/12_update_user_story.jinja2 b/backend/llm/prompts/12_update_user_story.jinja2 new file mode 100644 index 0000000..4b9187e --- /dev/null +++ b/backend/llm/prompts/12_update_user_story.jinja2 @@ -0,0 +1,61 @@ +You are a senior architect tasked with updating existing feature for the provided app from the new user story description provided by client. Below is the description of the app and inputs from client: + +App Name: +{{name}} + +App Description: +{{description}} + +Requirement type: Product Requirement + +Product Requirement Description: +{{reqDesc}} + +User Story Id: +{{featureId}} + +Existing User Story Description: +{{existingFeatureDescription}} + +Client Request - New User Story Description: +{{newFeatureDescription}} + +FileContent: +{{fileContent}} + +Update the existing feature by incorporating the client's requests and the information from the provided file content. +Ensure that the revised feature is clear, concise, and comprehensive. + +The feature should be strictly derived from the provided client's request and any information from the provided file content, with no additional or irrelevant information included. + +STRICT: +(!) Output Structure should be a valid JSON: Here is the sample Structure. Follow this exactly. Don't add or change the response JSON: + +# RESPONSE FORMAT EXAMPLE +{ + "features": [ + { + "id": "<featureId>" + "<feature name>": "[description of feature] Acceptance Criteria: [acceptance criteria for the feature as sentences]" + } + ] +} + + ... +Consider this format to generate user story - +Ability to <user action> the < feature > + +In order to < mention the user need > + +As a < the persona or user > + +I want the < end goal or objective of the feature > + +Special Instructions: +1. id returned in the response should be same id sent to you (User Story Id -> <featureId> in json response) +2. Strictly return ONLY ONE OBJECT in the response features array. + +STRICT: +(!) return a list of features ONLY: no other headers, footers, or additional text + +Please ensure the feature name and description are clear, concise, and comprehensive. Output only valid JSON. Do not include ```json ``` on start and end of the response. diff --git a/backend/llm/prompts/13_add_task.jinja2 b/backend/llm/prompts/13_add_task.jinja2 new file mode 100644 index 0000000..d31ba4a --- /dev/null +++ b/backend/llm/prompts/13_add_task.jinja2 @@ -0,0 +1,47 @@ +You are a senior architect tasked with creating detailed task from the provided client input. Below is all the information that you need to derive a task. + +User Story Name: +{{name}} + +User Story Description: +{{description}} + +Task Id: +{{taskId}} + +Client Request - Task Name: +{{taskName}} + +Client Request - Task Description: +{{taskDescription}} + +FileContent: +{{fileContent}} + +Develop a detailed and well-structured task strictly derived from provided client requests and information from file content, with no additional or irrelevant information included. +The "Client Request - Task Description" can be expanded to derive acceptance criteria based on provided input but do not include additional or irrelevant content. +An apt task name should be generated based on the updated acceptance criteria. + +STRICT: +(!) Ensure to categorize the task based on client request as frontend, backend, API integration, UX screens, Infra changes if applicable. +(!) Output Structure should be a valid JSON: Here is the sample Structure. Follow this exactly. Do not add or change the response JSON: + +# RESPONSE FORMAT EXAMPLE +{ + "tasks": [ + { + "id": "<taskId>" + "<task name>": "[description of the task] Acceptance Criteria: [acceptance criteria for the task as sentences]" + } + ] +} + +STRICT: + +Special Instruction: +1. id returned in the response should be same id sent to you (Task Id -> <taskId> in json response) +2. Strictly return ONLY ONE OBJECT in the response tasks array. + +(!) return a list of task ONLY: no other headers, footers, or additional text + +Please ensure that task name and acceptance criteria is clear, concise, and comprehensive. Output only valid JSON. Do not include ```json ``` on start and end of the response. diff --git a/backend/llm/prompts/14_update_task.jinja2 b/backend/llm/prompts/14_update_task.jinja2 new file mode 100644 index 0000000..376cf11 --- /dev/null +++ b/backend/llm/prompts/14_update_task.jinja2 @@ -0,0 +1,52 @@ +You are a senior architect tasked with updated existing task from the provided client input. Below is all the information that you need to derive a task. + +User Story Name: +{{name}} + +User Story Description: +{{description}} + +Task Id: +{{taskId}} + +Existing Task Description: +{{existingTaskDescription}} + +Client Request - Task Name: +{{taskName}} + +Client Request - Task Description: +{{taskDescription}} + +FileContent: +{{fileContent}} + +Update the existing task by incorporating the client's requests and the information from the provided file content. +Ensure that the revised task is clear, concise, and comprehensive. + +The task should be strictly derived from the provided user story description and client request and any information from the file content, with no additional or irrelevant information included. +The "Client Request - Task Description" can be expanded to derive acceptance criteria based on provided input but DO NOT include additional or irrelevant content. +An apt task name should be generated based on the updated acceptance criteria. + +STRICT: +(!) Ensure to categorize the task as frontend, backend, API integration, UX screens, Infra changes if applicable. +(!) Output Structure should be a valid JSON: Here is the sample Structure. Follow this exactly. Don't add or change the response JSON + +# RESPONSE FORMAT EXAMPLE +{ + "tasks": [ + { + "id": "<taskId>" + "<task name>": "[description of the task] Acceptance Criteria: [acceptance criteria for the task as sentences]" + } + ] +} + +Special Instructions: +1. id returned in the response should be same id sent to you (Task Id -> <taskId> in json response) +2. Strictly return ONLY ONE OBJECT in the response tasks array. + +STRICT: +(!) return a list of task ONLY: no other headers, footers, or additional text + +Please ensure the task name and acceptance criteria is clear, concise, and comprehensive. Output only valid JSON. Do not include ```json ``` on start and end of the response. diff --git a/backend/llm/prompts/chat/improve_suggestions.jinja2 b/backend/llm/prompts/chat/improve_suggestions.jinja2 new file mode 100644 index 0000000..0a94d7d --- /dev/null +++ b/backend/llm/prompts/chat/improve_suggestions.jinja2 @@ -0,0 +1,20 @@ +You are an AI assistant tasked with generating {{n}} creative and practical one-liner suggestions(not more than 5 words) to improve an abstract requirement. +The requirement is not fully detailed and may lack specific context, so your suggestions should be broad, versatile, and aimed at enhancing clarity, feasibility, and overall effectiveness. Consider potential gaps, ambiguities, and areas for innovation. + +Application +Name: {{name}} +Description: {{description}} +Requirement Type: {{type}} +Abstract Requirement: {{requirement}} +----------- +Sample suggestions for improvement can be: +Suggestion 1: +[Provide a brief and clear one-liner suggestion focusing on enhancing clarity and filling potential gaps.] +Suggestion 2: +[Provide a concise one-liner suggestion aimed at feasibility and practical implementation.] +Suggestion 3: +[Provide a creative one-liner suggestion encouraging innovation and new solutions.] +----------- +Output Structure should be a valid JSON: Here is the sample Structure. Follow this exactly. Don't add or change the response JSON: +["Suggestion 1", "Suggestion 2", ... "Suggestion n"] +Output only valid JSON. Do not include ```json ``` on start and end of the response. \ No newline at end of file diff --git a/backend/llm/prompts/chat/update_requirement.jinja2 b/backend/llm/prompts/chat/update_requirement.jinja2 new file mode 100644 index 0000000..b06105f --- /dev/null +++ b/backend/llm/prompts/chat/update_requirement.jinja2 @@ -0,0 +1,37 @@ +{% if r_abbr == 'BRD' %} +{% set requirementType = 'Business Requirements' %} +{% set context = 'brd.jinja2' %} +{% elif r_abbr == 'PRD' %} +{% set requirementType = 'Product Requirements' %} +{% set context = 'prd.jinja2' %} +{% set additional_instruction = '<requirement> Screens: <Screen Description> Personas: <Persona Description>' %} +{% elif r_abbr == 'NFR' %} +{% set requirementType = 'Non-Functional Requirements' %} +{% set context = 'nfr.jinja2' %} +{% elif r_abbr == 'UIR' %} +{% set requirementType = 'User Interface Requirements' %} +{% set context = 'uir.jinja2' %} +{% elif r_abbr == 'BP' %} +{% set requirementType = 'Business Process' %} +{% set context = 'bp.jinja2' %} +{% endif %} + +You are a requirements analyst tasked to assist users in refining and enhancing their existing {{type}} by gathering detailed input, providing expert advice, and suggesting improvements. + +App Details: +App Name: {{name}} +App Description: {{description}} +{{type}}: {{requirement}} + +Base Context: +{% include context %} + +----------- +Maintain a professional and {{type}} tone. +DON'T INCLUDE the same content of {{type}} in the response. +Ensure your suggestions are practical, relevant to the user's specific context that aligns with attached {{type}} and Base Context. +{% if additional_instruction %} +{{additional_instruction}} +{% endif %} +Provide precise and a single paragraph answer, detailed add-on kind of answer without any prefix labels (labels example: To enhance the existing requirement...). +----------- \ No newline at end of file diff --git a/backend/llm/prompts/chat/update_user_story_task.jinja2 b/backend/llm/prompts/chat/update_user_story_task.jinja2 new file mode 100644 index 0000000..4c29440 --- /dev/null +++ b/backend/llm/prompts/chat/update_user_story_task.jinja2 @@ -0,0 +1,21 @@ +You are a requirements analyst tasked to assist users in refining and enhancing their existing {{type}} by gathering detailed input, providing expert advice, and suggesting improvements. + +App Details: +App Name: {{name}} +App Description: {{description}} +{{type}}: {{requirement}} +{% if prd %} +Product Requirement: {{prd}} +{% endif %} +{% if us %} +User Story: {{us}} +{% else %} + +{% include 'user_story.jinja2' %} +{% endif %} +----------- +Maintain a professional and {{type}} tone. +DON'T INCLUDE the same content of {{type}} in the response. +Ensure your suggestions are practical and relevant to the user's specific context. +Provide precise and a single paragraph answer, detailed add-on kind of answer without any prefix labels (label example like: To enhance the existing...). +----------- \ No newline at end of file diff --git a/backend/llm/prompts/context/bp.jinja2 b/backend/llm/prompts/context/bp.jinja2 new file mode 100644 index 0000000..c4a7a2a --- /dev/null +++ b/backend/llm/prompts/context/bp.jinja2 @@ -0,0 +1,42 @@ +A business process flow document outlines the sequential steps, interactions, and decision points needed to achieve specific business objectives. It provides a clear roadmap for implementing operational workflows and organizational procedures. +Key Components: +- The Business Process Flow must identify all stakeholders and their roles in the process +- Include clear sequential steps showing how tasks flow from initiation to completion +- Define decision points and their associated outcomes +- Specify inputs required and outputs generated at each stage +- Document roles and responsibilities for each process phase +- Address cross-functional interactions and handoffs between teams + +Process Elements: +1. Stakeholder Mapping: + - Identify all parties involved in the process + - Define their interests and impact + - Establish communication channels + +2. Process Phases: + - Break down the workflow into logical phases + - Set entry and exit criteria for each phase + - Define phase dependencies + +3. Action Sequences: + - Detail step-by-step activities + - Establish workflow triggers + - Define completion criteria + +4. Decision Framework: + - Identify key decision points + - Document decision criteria + - Outline alternative paths + + +STRICT: +- (!) The examples below are for formatting purposes only and should not be replicated verbatim in actual documents. +- Customer onboarding process includes identity verification, account setup, and welcome package distribution with defined SLAs for each step. +- Change management process requires documentation, impact analysis, stakeholder approval, and implementation planning before execution. + +Instructions: +- Create a comprehensive process flow that captures all necessary steps and interactions +- Ensure clear definition of roles, responsibilities, and handoffs +- Include decision points and their impact on process flow +- Define measurable outcomes and success criteria +- Maintain focus on business objectives and value delivery \ No newline at end of file diff --git a/backend/llm/prompts/context/brd.jinja2 b/backend/llm/prompts/context/brd.jinja2 new file mode 100644 index 0000000..c4aae76 --- /dev/null +++ b/backend/llm/prompts/context/brd.jinja2 @@ -0,0 +1,17 @@ +A business requirement is a detailed description of the needs and objectives that a business aims to achieve through the project. These requirements guide the project's direction, +ensure alignment with strategic goals, manage expectations, and define metrics for success, thus providing a clear roadmap for project execution and stakeholder engagement. +- Identify the high-level business needs and objectives of the application for solving the business problem. +- Consider the business context and target audience/ users of the application. +- Focus on the strategic outcomes and benefits for the business objectives. + +Instructions: +- Generate an apt title for all the following requirements. The title should be a one-liner not more than 5 words. +- Generate only relevant BRD based on the use case and domain. +- Generate a comprehensive list of requirements to meet the business needs of the use case given. +- If the solution involves technical needs, include technical best practices as part of the business requirements. If not do not include. +- Ensure that the requirements are unique and do not repeat similar content. Avoid generating repetitive requirements. + +Consider these as an example and generate business requirements like this +Example 1: We would like to automate our customer relationship management system so that we can offer better customer services and improve customer response time by 70% in the next 6 months. +Example 2: Develop a centralized platform to enable employees to view, book, and manage meeting rooms seamlessly. This system should have the ability to display room availability in real-time, provide options to book resources for specific time slots, and allow modifications or cancellations of bookings. It must support varying levels of user access permissions to accommodate both regular employees and administrators. +Example 3: Create capabilities for patients to fill out and submit medical forms digitally prior to their visits. This functionality will help streamline the check-in process, reduce waiting times, and improve the accuracy of medical data collected, significantly enhancing patient intake efficiency \ No newline at end of file diff --git a/backend/llm/prompts/context/nfr.jinja2 b/backend/llm/prompts/context/nfr.jinja2 new file mode 100644 index 0000000..a734816 --- /dev/null +++ b/backend/llm/prompts/context/nfr.jinja2 @@ -0,0 +1,7 @@ +Non-Functional Requirements (NFR): +- Define the performance, scalability, and reliability expectations. +- Include security, compliance, and regulatory requirements. +- Specify any constraints related to deployment, maintenance, and technology stack. + +Instructions: +- Generate an apt title for all the following requirements. Title should be a one-liner not more than 5 words. \ No newline at end of file diff --git a/backend/llm/prompts/context/prd.jinja2 b/backend/llm/prompts/context/prd.jinja2 new file mode 100644 index 0000000..df90c96 --- /dev/null +++ b/backend/llm/prompts/context/prd.jinja2 @@ -0,0 +1,24 @@ +Product Requirements (PRD): + +A product requirement document describes the capabilities and qualities of a solution that meets the business needs. They provide the appropriate functionality level of detail to allow for the development and implementation of the solution. +- The PRD must contain specific functionalities and features the app must have. +- Include user interface (UI) requirements, user experience (UX) considerations, and any necessary integrations with other systems. +- Address the needs and expectations of end-users. +- Generate Screens and Personas based on each PRD: + Screens: Define the various screens or pages within the app, their purpose, and key elements based on the specific product requirements detailed in each PRD. + Personas: Identify the different user types who will interact with the app, their goals, and how they will use the app, tailored to the particular needs and features outlined in each PRD. + +STRICT: +(!) The examples below are for formatting purposes only and should not be replicated verbatim in actual documents. + +"A search feature allows users to search content/items by entering the product details in the search bar." + +"The user can review items in the cart, change their number, or remove them before checkout." + +"The app should allow users to create accounts and log in using credentials like email and password." + + +Instructions: +- Generate an apt title for all the following requirements. Title should be a one-liner not more than 5 words. +- Generate feature specifications related to the use case and domain. +- List the capabilities and qualities the solution should have. Provide the appropriate level of detail to allow for the development and implementation of the solution. \ No newline at end of file diff --git a/backend/llm/prompts/context/uir.jinja2 b/backend/llm/prompts/context/uir.jinja2 new file mode 100644 index 0000000..a6e1612 --- /dev/null +++ b/backend/llm/prompts/context/uir.jinja2 @@ -0,0 +1,35 @@ +User Interface Requirements (UIR): +- Task: Generate user interface requirements for the following application description. +- Output Format: Each requirement is articulated in a sentence format and avoid using bullet points, numbering and section titles. +- Application Description: {{description}} +- Instructions for Generating UI Requirements: + User Interactions: + Identify and describe all key user interactions within the application. + Specify any particular user flows or processes that need to be supported. + Highlight any specific actions users must perform and how they interact with various UI components. + Visual Elements: + Detail the visual design requirements including layout, colors, fonts, and icons. + Describe how each screen or page should look and feel. + Include any specific design patterns or themes that should be applied. + Functionality: + List and explain all functional elements required on the user interface, such as buttons, forms, navigation menus, and feedback messages. + Describe the behavior of dynamic elements like dropdowns, modals, and tooltips. + Specify any conditions or validations that need to be handled within the UI. + Accessibility: + Outline the accessibility features that must be included, such as keyboard navigation, screen reader support, and color contrast requirements. + Mention any standards or guidelines (e.g., WCAG) that the UI must comply with. + Describe any additional features to support users with disabilities. + Performance Considerations: + Identify performance requirements for the UI, such as load times and responsiveness. + Mention any considerations for optimizing user experience on various devices and screen sizes. + Describe any fallback or degradation strategies for low-performance environments. + User Feedback and Testing: + Explain how user feedback should be gathered and incorporated into the UI design. + Specify any usability testing methods that should be used to validate the UI requirements. + Include details on how iterative improvements based on user testing will be managed. + Documentation and Guidelines: + List the documentation that should accompany the UI, such as style guides, design system specifications, and user guides. + Specify how UI guidelines will be communicated and maintained. + +Instructions: +- Generate an apt title for all the following requirements. Title should be a one-liner not more than 5 words. \ No newline at end of file diff --git a/backend/llm/prompts/context/user_story.jinja2 b/backend/llm/prompts/context/user_story.jinja2 new file mode 100644 index 0000000..53563d9 --- /dev/null +++ b/backend/llm/prompts/context/user_story.jinja2 @@ -0,0 +1,44 @@ +User Story(US): +User story should ideally describe the feature on a high level. Develop a detailed and well-structured user story for feature that effectively addresses both the client's requests and the provided file content. Ensure that the feature is clear, concise, and comprehensive. + +Consider the below format to generate user story: +Ability to <user action> the <feature> +In order to <mention the user need> +As a <the persona or user> +I want the <end goal or objective of the feature> + +Include the Acceptance Criteria as part of the description. +Consider this as acceptance criteria format - + +Acceptance Criteria +- Describe how the interface should look or behave. +- Specify any filters, views, or user interactions needed. +- Define core actions the user can take. +- Include specific constraints (e.g., time ranges, available options). +- Outline how the system should respond to user actions (e.g., confirmation notifications). +- Specify error handling and validation for incorrect or conflicting actions (e.g., error notifications) + + + +Consider this as an example - + +Feature Description + +Ability to login using SSO login. In order to access the system. As an admin, I want the login to the app using my credentials. + +Acceptance Criteria + +The login page must display an option to sign in using Single Sign-On (SSO). +A button for "Sign in with SSO" should be prominently visible. +The user must be redirected to their organization's identity provider (IdP) login page after clicking the SSO button. +The system should authenticate users based on their organization’s credentials. +After successful authentication, the user must be redirected back to the application and automatically logged in. +After successful login, the system must display the user dashboard. +If authentication fails, the system must display an error message and allow the user to retry. +SSO should support multiple identity providers (e.g., Google, Microsoft). +Session management must handle expired or invalid sessions by prompting users to reauthenticate. + +Instruction +- Ensure the user story is generating relevant tasks in detailed manner +- Ensure to include and mention the fields, button , icons and the fields validation while writing an acceptance. +- Ensure to include / consider the non functional requirements of the use case while generating user story. diff --git a/backend/llm/prompts/solution/create_brd.jinja2 b/backend/llm/prompts/solution/create_brd.jinja2 new file mode 100644 index 0000000..65d3ebd --- /dev/null +++ b/backend/llm/prompts/solution/create_brd.jinja2 @@ -0,0 +1,22 @@ +You are a requirements analyst tasked with extracting detailed Business Requirements from the provided app description. + +Below is the description of the app: +App Name: {{name}} +App Description: {{description}} + +{% include 'brd.jinja2' %} + +Output Structure should be a valid JSON: Here is the sample Structure: + +{ + "brd": [{ + "id": "BRD1", "title": [Title] ,"requirement": "[Provide the Requirement in a single paragraph. Combine all subsections within the same paragraph]" + }, { + "id": "BRD2", "title": [Title] ,"requirement": "[Provide the Requirement in a single paragraph. Combine all subsections within the same paragraph]" + }, + ... + ] +} + +Please ensure the requirements are clear and comprehensive. Output only valid JSON. Do not include ```json ``` on the start and end of the response. +Generate the required number of Business requirements to meet business needs or generate at least 15 BRDs and sort them based on business impact. \ No newline at end of file diff --git a/backend/llm/prompts/solution/create_nfr.jinja2 b/backend/llm/prompts/solution/create_nfr.jinja2 new file mode 100644 index 0000000..43b900f --- /dev/null +++ b/backend/llm/prompts/solution/create_nfr.jinja2 @@ -0,0 +1,21 @@ +You are a requirements analyst tasked with extracting detailed Non-Functional Requirements from the provided app description. Below is the description of the app: + +App Name: {{name}} +App Description: {{description}} + +{% include 'nfr.jinja2' %} + +Output Structure should be a valid JSON: Here is the sample Structure: + +{ + "nfr": [ + { + "id": "NFR1", "title": <title> ,"requirement": "[Non-Functional Requirements]" + }, + { + "id": "NFR2", "title": <title> ,"requirement": "[Non-Functional Requirements]" + }... + ] +} + +Please ensure the requirements are clear, concise, and comprehensive. Output only valid JSON. Do not include ```json ``` on start and end of the response. \ No newline at end of file diff --git a/backend/llm/prompts/solution/create_prd.jinja2 b/backend/llm/prompts/solution/create_prd.jinja2 new file mode 100644 index 0000000..b0b0936 --- /dev/null +++ b/backend/llm/prompts/solution/create_prd.jinja2 @@ -0,0 +1,22 @@ +You are a requirements analyst tasked with extracting detailed Product Requirements from the provided app description. Below is the description of the app: + +App Name: {{name}} +App Description: {{description}} + +{% include 'prd.jinja2' %} +- Generate 15 PRD + +Output Structure should be a valid JSON: Here is the sample Structure: + +{ + "prd": [ + { + "id": "PRD1", "title": <title> ,"requirement": "[Product Requirement in one to two lines] Screens: [Screen Description] Personas: [Persona Description]" + }, + { + "id": "PRD2", "title": <title> ,"requirement": "[Product Requirement in one to two lines] Screens: [Screen Description] Personas: [Persona Description]" + }... + ] +} + +Please ensure the requirements are descriptive and also clear, concise. Output only valid JSON. Do not include ```json ``` on start and end of the response. \ No newline at end of file diff --git a/backend/llm/prompts/solution/create_uir.jinja2 b/backend/llm/prompts/solution/create_uir.jinja2 new file mode 100644 index 0000000..0cdeabb --- /dev/null +++ b/backend/llm/prompts/solution/create_uir.jinja2 @@ -0,0 +1,21 @@ +You are a requirements analyst tasked with extracting User Interface Requirements from the provided app description. Below is the description of the app: + +App Name: {{name}} +App Description: {{description}} + +{% include 'uir.jinja2' %} + +Output Structure should be a valid JSON: Here is the sample Structure: + +{ + "uir": [ + { + "id": "UIR1", "title": <title> ,"requirement": "[User Interface Requirements]" + }, + { + "id": "UIR2", "title": <title> ,"requirement": "[User Interface Requirements]" + }... + ] +} + +Please ensure the requirements are clear, concise, and comprehensive. Output only valid JSON. Do not include ```json ``` on start and end of the response. \ No newline at end of file diff --git a/backend/llm/providers/openai_compatible_azure_handler.py b/backend/llm/providers/openai_compatible_azure_handler.py new file mode 100644 index 0000000..6c9adcd --- /dev/null +++ b/backend/llm/providers/openai_compatible_azure_handler.py @@ -0,0 +1,32 @@ +from openai import AzureOpenAI +from utils.env_utils import EnvVariables, get_env_variable + +class OpenAiCompatibleAzureHandler(): + def __init__(self, model): + self.api_key = get_env_variable(EnvVariables.AZUREAI_API_KEY) + self.api_base = get_env_variable(EnvVariables.AZUREAI_API_BASE) + self.api_version = get_env_variable(EnvVariables.AZUREAI_API_VERSION) + self.client = None + self.model = model + self.configure_client() + + def configure_client(self): + self.client = AzureOpenAI( + azure_endpoint = self.api_base, + api_key=self.api_key, + api_version=self.api_version + ) + return self.client + + + def completion(self, prompt): + try: + response = self.client.chat.completions.create( + model = self.model, + messages = prompt + ) + return response.choices[0].message.content + except Exception as e: + # Log the error and raise a custom exception + print(f"Error during AZUREAI completion: {str(e)}") + raise Exception("Failed to get a response from AZUREAI API") \ No newline at end of file diff --git a/backend/llm/providers/openai_compatible_claude_handler.py b/backend/llm/providers/openai_compatible_claude_handler.py new file mode 100644 index 0000000..4f6a059 --- /dev/null +++ b/backend/llm/providers/openai_compatible_claude_handler.py @@ -0,0 +1,31 @@ +from openai import OpenAI +from utils.env_utils import EnvVariables, get_env_variable + +class OpenAiCompatibleClaudeHandler(): + def __init__(self, model): + self.api_key = get_env_variable(EnvVariables.CLAUDE_API_KEY) + self.api_base = get_env_variable(EnvVariables.CLAUDE_ENDPOINT) + self.model = model + self.client = None + self.configure_client() + + def configure_client(self): + self.client = OpenAI( + api_key = self.api_key, + base_url = self.api_base + ) + return self.client + + + def completion(self, prompt): + try: + response = self.client.chat.completions.create( + model = self.model, + messages = prompt + ) + return response.choices[0].message.content + except Exception as e: + # Log the error and raise a custom exception + print(f"Error during CLAUDEAI completion: {str(e)}") + raise Exception("Failed to get a response from CLAUDEAI API") + \ No newline at end of file diff --git a/backend/llm/providers/openai_native_handler.py b/backend/llm/providers/openai_native_handler.py new file mode 100644 index 0000000..eb3c929 --- /dev/null +++ b/backend/llm/providers/openai_native_handler.py @@ -0,0 +1,28 @@ +from openai import OpenAI +from utils.env_utils import EnvVariables, get_env_variable + +class OpenAiNativeHandler(): + def __init__(self, model): + self.api_key = get_env_variable(EnvVariables.OPENAI_API_KEY) + self.api_base = get_env_variable(EnvVariables.OPENAI_API_BASE) + self.client = None + self.model = model + self.configure_client() + + def configure_client(self): + self.client = OpenAI( + api_key = self.api_key, + ) + return self.client + + def completion(self, prompt): + try: + response = self.client.chat.completions.create( + model = self.model, + messages = prompt + ) + return response.choices[0].message.content + except Exception as e: + # Log the error and raise a custom exception + print(f"Error during OpenAI completion: {str(e)}") + raise Exception("Failed to get a response from OpenAI API") diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..cdea76e --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,15 @@ +Flask==3.0.3 +Flask_Cors==4.0.1 +Jinja2==3.1.2 +openai==1.55.3 +PyYAML==6.0.1 +Requests==2.32.3 +gitpython==3.1.43 +watchtower==3.2.0 +marshmallow==3.21.3 +python-dotenv==1.0.1 +boto3==1.34.144 +sentry-sdk==2.13.0 +flask-executor==1.0.0 +langchain==0.2.16 +langchain_aws==0.1.18 diff --git a/backend/schemas/businessprocess_schema.py b/backend/schemas/businessprocess_schema.py new file mode 100644 index 0000000..3cea46f --- /dev/null +++ b/backend/schemas/businessprocess_schema.py @@ -0,0 +1,33 @@ +from marshmallow import Schema, fields, validate + + +class CreateFlowChartSchema(Schema): + id = fields.String(required=True) + title = fields.String(required=True) + description = fields.String(required=True, validate=validate.Length(min=5, error="Description must not be empty")) + +class CreateBusinessProcessSchema(Schema): + reqt = fields.String(required=False) + contentType = fields.String(required=True) + id = fields.String(required=True) + title = fields.String(required=False) + addReqtType = fields.String(required=True) + name = fields.String(required=True) + description = fields.String(required=True) + useGenAI = fields.Boolean(required=True) + selectedBRDs = fields.List(fields.String(), required=False) + selectedPRDs = fields.List(fields.String(), required=False) + + +class UpdateBusinessProcessSchema(Schema): + updatedReqt = fields.String(required=False) + contentType = fields.String(required=True) + id = fields.String(required=True) + title = fields.String(required=False) + reqId = fields.String(required=True) + reqDesc = fields.String(required=True) + name = fields.String(required=True) + description = fields.String(required=True) + useGenAI = fields.Boolean(required=True) + selectedBRDs = fields.List(fields.String(), required=False) + selectedPRDs = fields.List(fields.String(), required=False) diff --git a/backend/schemas/chat_schema.py b/backend/schemas/chat_schema.py new file mode 100644 index 0000000..9d88fbf --- /dev/null +++ b/backend/schemas/chat_schema.py @@ -0,0 +1,36 @@ +from marshmallow import Schema, fields + +class GenericChatSchema(Schema): + message=fields.String(required=True) + chatHistory=fields.List(fields.Dict, required=False) + knowledgeBase=fields.String(required=False) + +class ImproveSuggestionSchema(Schema): + name=fields.String(required=True) + description=fields.String(required=True) + type=fields.String(required=True) + requirement=fields.String(required=True) + knowledgeBase=fields.String(required=False) + + + +class ConverseRequirementSchema(Schema): + name=fields.String(required=True) + description=fields.String(required=True) + type=fields.String(required=True) + requirement=fields.String(required=True) + chatHistory=fields.List(fields.Dict, required=False) + knowledgeBase=fields.String(required=False) + userMessage=fields.String(required=True) + requirementAbbr=fields.String(required=True) + +class ConverseUserStoryTaskSchema(Schema): + name=fields.String(required=True) + description=fields.String(required=True) + type=fields.String(required=True) + requirement=fields.String(required=True) + chatHistory=fields.List(fields.Dict, required=False) + knowledgeBase=fields.String(required=False) + userMessage=fields.String(required=True) + prd=fields.String(required=True) + us=fields.String(required=False) \ No newline at end of file diff --git a/backend/schemas/requirement_schema.py b/backend/schemas/requirement_schema.py new file mode 100644 index 0000000..5ac5a5c --- /dev/null +++ b/backend/schemas/requirement_schema.py @@ -0,0 +1,26 @@ +from marshmallow import Schema, fields + + +class UpdateRequirementSchema(Schema): + updatedReqt = fields.String(required=False) + fileContent = fields.String(required=False) + contentType = fields.String(required=True) + title = fields.String(required=False) + id = fields.String(required=True) + reqId = fields.String(required=True) + reqDesc = fields.String(required=True) + name = fields.String(required=True) + description = fields.String(required=True) + useGenAI = fields.Boolean(required=True) + + +class AddRequirementSchema(Schema): + reqt = fields.String(required=False) + fileContent = fields.String(required=False) + title = fields.String(required=False) + contentType = fields.String(required=True) + id = fields.String(required=True) + addReqtType = fields.String(required=True) + name = fields.String(required=True) + description = fields.String(required=True) + useGenAI = fields.Boolean(required=True) diff --git a/backend/schemas/schemas.py b/backend/schemas/schemas.py new file mode 100644 index 0000000..9765b63 --- /dev/null +++ b/backend/schemas/schemas.py @@ -0,0 +1,41 @@ +from schemas.businessprocess_schema import ( + CreateBusinessProcessSchema, + CreateFlowChartSchema, + UpdateBusinessProcessSchema, +) +from schemas.requirement_schema import ( + AddRequirementSchema, + UpdateRequirementSchema, +) +from schemas.solution_schema import CreateSolutionSchema, SolutionIdSchema +from schemas.task_schema import ( + CreateTaskSchema, + AddTaskSchema, + UpdateTaskSchema +) +from schemas.userstories_schema import ( + CreateUserStorySchema, + AddUserStorySchema, + UpdateUserStorySchema +) + +from schemas.chat_schema import GenericChatSchema, ImproveSuggestionSchema, ConverseRequirementSchema, ConverseUserStoryTaskSchema + + +create_solution_schema = CreateSolutionSchema() +solution_id_schema = SolutionIdSchema() +update_requirement_schema = UpdateRequirementSchema() +add_requirement_schema = AddRequirementSchema() +create_task_schema = CreateTaskSchema() +create_user_story_schema = CreateUserStorySchema() +create_process_flow_chart_schema = CreateFlowChartSchema() +create_business_process_schema = CreateBusinessProcessSchema() +update_business_process_schema = UpdateBusinessProcessSchema() +add_user_story_schema = AddUserStorySchema() +update_user_story_schema = UpdateUserStorySchema() +add_task_schema = AddTaskSchema() +update_task_schema = UpdateTaskSchema() +chat_generic_schema = GenericChatSchema() +chat_improve_suggestion_schema = ImproveSuggestionSchema() +chat_update_requirement_schema = ConverseRequirementSchema() +chat_update_user_story_schema = ConverseUserStoryTaskSchema() \ No newline at end of file diff --git a/backend/schemas/solution_schema.py b/backend/schemas/solution_schema.py new file mode 100644 index 0000000..3902129 --- /dev/null +++ b/backend/schemas/solution_schema.py @@ -0,0 +1,16 @@ +from marshmallow import Schema, ValidationError, fields, validate + + +class CreateSolutionSchema(Schema): + name = fields.String(required=True, validate=validate.Length(min=1)) + description = fields.String(required=True, validate=validate.Length(min=1)) + frontend = fields.Boolean(required=False) + backend = fields.Boolean(required=False) + database = fields.Boolean(required=False) + deployment = fields.Boolean(required=False) + createReqt = fields.Boolean(required=False) + created_on = fields.DateTime(required=True) + + +class SolutionIdSchema(Schema): + id = fields.String(required=True) diff --git a/backend/schemas/task_schema.py b/backend/schemas/task_schema.py new file mode 100644 index 0000000..1301f1c --- /dev/null +++ b/backend/schemas/task_schema.py @@ -0,0 +1,45 @@ +from marshmallow import Schema, fields, validate, ValidationError + +class StringDictOrList(fields.Field): + def _deserialize(self, value, attr, data, **kwargs): + if isinstance(value, (str, dict, list)): + return value + raise ValidationError("Field must be either a string, a dictionary, or a list") + + def _serialize(self, value, attr, obj, **kwargs): + return value + +class CreateTaskSchema(Schema): + name = fields.String(required=True, validate=validate.Length(min=1)) + description = fields.String(required=True, validate=validate.Length(min=1)) + +class AddTaskSchema(Schema): + appId = fields.String(required=True) + name = fields.String(required=True, validate=validate.Length(min=1, error="User story title must not be empty")) + description = fields.String(required=True, validate=validate.Length(min=1, error="User story description must not be empty")) + taskId = fields.String(required=True, validate=validate.Length(min=1, error="Task Id must not be empty")) + taskName = fields.String(required=True, validate=validate.Length(min=1, error="Task Name must not be empty")) + featureId = fields.String(required=True, validate=validate.Length(min=1, error="Feature Id must not be empty")) + reqId = fields.String(required=True, validate=validate.Length(min=1, error="Product requirement Id must not be empty")) + reqDesc = fields.String(required=True, validate=validate.Length(min=1, error="Task description must not be empty")) + contentType = fields.String(required=True) + fileContent = fields.String(required=True) + useGenAI = fields.Boolean(required=True) + usIndex = fields.Number(required=True) + +class UpdateTaskSchema(Schema): + appId = fields.String(required=True) + name = fields.String(required=True, validate=validate.Length(min=1, error="User story title must not be empty")) + description = fields.String(required=True, validate=validate.Length(min=1, error="User story description must not be empty")) + taskId = fields.String(required=True, validate=validate.Length(min=1, error="Task Id must not be empty")) + taskName = fields.String(required=True, validate=validate.Length(min=1, error="Task Name must not be empty")) + featureId = fields.String(required=True, validate=validate.Length(min=1, error="Feature Id must not be empty")) + reqId = fields.String(required=True, validate=validate.Length(min=1, error="Product requirement Id must not be empty")) + reqDesc = fields.String(required=True, validate=validate.Length(min=1, error="Task description must not be empty")) + contentType = fields.String(required=True) + fileContent = fields.String(required=True) + useGenAI = fields.Boolean(required=True) + existingTaskTitle = fields.String(required=True, validate=validate.Length(min=1, error="Existing task title must not be empty")) + existingTaskDesc = StringDictOrList(required=True) + usIndex = fields.Number(required=True) + diff --git a/backend/schemas/userstories_schema.py b/backend/schemas/userstories_schema.py new file mode 100644 index 0000000..7e3d3c8 --- /dev/null +++ b/backend/schemas/userstories_schema.py @@ -0,0 +1,31 @@ +from marshmallow import Schema, fields, validate + + +class CreateUserStorySchema(Schema): + reqDesc = fields.String(required=True) + +class AddUserStorySchema(Schema): + appId = fields.String(required=True) + name = fields.String(required=True, validate=validate.Length(min=1, error="App Name must not be empty")) + description = fields.String(required=True, validate=validate.Length(min=1, error="App Description must not be empty")) + reqId = fields.String(required=True, validate=validate.Length(min=1, error="Product requirement Id must not be empty")) + reqDesc = fields.String(required=True, validate=validate.Length(min=1, error="Product requirement must not be empty")) + featureId = fields.String(required=True, validate=validate.Length(min=1, error="Feature id must not be empty")) + featureRequest = fields.String(required=True, validate=validate.Length(min=1, error="Feature request must not be empty")) + contentType = fields.String(required=True) + fileContent = fields.String(required=True) + useGenAI = fields.Boolean(required=True) + +class UpdateUserStorySchema(Schema): + appId = fields.String(required=True) + name = fields.String(required=True, validate=validate.Length(min=1, error="App Name must not be empty")) + description = fields.String(required=True, validate=validate.Length(min=1, error="App Description must not be empty")) + reqId = fields.String(required=True, validate=validate.Length(min=1, error="Product requirement Id must not be empty")) + reqDesc = fields.String(required=True, validate=validate.Length(min=1, error="Product requirement must not be empty")) + featureId = fields.String(required=True, validate=validate.Length(min=1, error="Feature id must not be empty")) + featureRequest = fields.String(required=True, validate=validate.Length(min=1, error="Feature request must not be empty")) + existingFeatureTitle = fields.String(required=True, validate=validate.Length(min=1, error="Existing feature title must not be empty")) + existingFeatureDesc = fields.String(required=True) + contentType = fields.String(required=True) + fileContent = fields.String(required=True) + useGenAI = fields.Boolean(required=True) diff --git a/backend/utils/common_utils.py b/backend/utils/common_utils.py new file mode 100644 index 0000000..bab06fa --- /dev/null +++ b/backend/utils/common_utils.py @@ -0,0 +1,97 @@ +import importlib +import os +from git import Repo +from jinja2 import BaseLoader, Environment, FileSystemLoader +from langchain_aws.retrievers import AmazonKnowledgeBasesRetriever +from config.logging_config import logger + + +def render_template(prompt_src: str) -> str: + env = Environment(loader=BaseLoader(), autoescape=True) + template = env.from_string(prompt_src) + return template + +def get_template_env() -> Environment: + env = Environment(loader=FileSystemLoader([ + 'llm/prompts', 'llm/prompts/solution', 'llm/prompts/context', 'llm/prompts/chat' + ]), autoescape=True) + return env + +def create_git_project(path): + repo = Repo.init(path) + return repo + +def commit_all_changes(repo, commit_message): + # Add all changes to the staging area + repo.git.add(A=True) + # Commit the changes + repo.git.commit(m=commit_message) + +def push_changes(repo, remote_name='origin', branch='master'): + # Push the changes to the remote repository + repo.git.push(remote_name, branch) + +def get_project_info(repo): + latest_commit_message = repo.head.commit.message + latest_commit_author = repo.head.commit.author + latest_commit_date = repo.head.commit.committed_date + return latest_commit_message, latest_commit_author, latest_commit_date + +def check_file_exists(file_path): + return os.path.isfile(file_path) + +def update_llm_response_with_user_input(data, value, key_name=None): + for item in data: + for key in item: + if key != 'id': + if key_name: + item[key_name] = (item[key] + ' ' + value).strip() + else: + item[key] += value + return data + + +def override_llm_response_with_user_input(data, existing_value, value, key_name=None): + for item in data: + for key in item: + if key != 'id': + if key_name: + item[key_name] = value + else: + item[key] = (existing_value + ' ' + value).strip() + return data + + +def add_knowledge_base_to_prompt(prompt, knowledge_base): + """ + Adds knowledge base content to the prompt if a valid knowledge base is provided. + + :param prompt: Original user query prompt + :param knowledge_base: The name of the knowledge base to retrieve + :return: Updated prompt with knowledge base content + """ + logger.info("Entering <add_knowledge_base_to_prompt>") + if knowledge_base: + logger.info("Using Bedrock Knowledge Base") + retriever = AmazonKnowledgeBasesRetriever( + knowledge_base_id=knowledge_base, + retrieval_config={"vectorSearchConfiguration": {"numberOfResults": 4}}, + ) + + result = retriever.invoke(prompt) + references = [i.dict()['page_content'] for i in result] + + # Prioritize the knowledge base references in the prompt + knowledge_base_message = "\n\nConsider these references as strict constraints:\n" + "\n".join(references) + \ + "\n\nMake sure all responses adhere to these strict exclusivity rules." + prompt = knowledge_base_message + "\n\nUser Query:\n" + prompt + logger.info("Exiting <add_knowledge_base_to_prompt>") + return prompt + + +def create_instance(module_name, class_name, *args, **kwargs): + module = importlib.import_module(module_name) + cls = getattr(module, class_name, None) + if cls is None: + raise ValueError(f"Class '{class_name}' not found in module '{module_name}'!") + return cls(*args, **kwargs) diff --git a/backend/utils/env_utils.py b/backend/utils/env_utils.py new file mode 100644 index 0000000..36f4b6d --- /dev/null +++ b/backend/utils/env_utils.py @@ -0,0 +1,47 @@ +import os +from enum import Enum + +# Define an Enum for environment variable keys +class EnvVariables(Enum): + DEFAULT_API_PROVIDER = "DEFAULT_API_PROVIDER" + DEFAULT_MODEL = "DEFAULT_MODEL" + AZUREAI_API_BASE = "AZUREAI_API_BASE" + AZUREAI_API_KEY = "AZUREAI_API_KEY" + AZUREAI_API_VERSION = "AZUREAI_API_VERSION" + OPENAI_API_KEY = "OPENAI_API_KEY" + OPENAI_API_BASE = "OPENAI_API_BASE" + CLAUDE_API_KEY = "CLAUDE_API_KEY" + CLAUDE_ENDPOINT = "CLAUDE_ENDPOINT" + APP_PASSCODE_KEY = "APP_PASSCODE_KEY" + HOST = "HOST" + PORT = "PORT" + DEBUG = "DEBUG" + ENABLE_SENTRY = "ENABLE_SENTRY" + SENTRY_DSN = "SENTRY_DSN" + SENTRY_ENVIRONMENT = "SENTRY_ENVIRONMENT" + SENTRY_RELEASE = "SENTRY_RELEASE" + +# Define a dictionary for default values +DEFAULT_VALUES = { + EnvVariables.DEFAULT_API_PROVIDER: "OPENAI_NATIVE", + EnvVariables.DEFAULT_MODEL: "gpt-4o", + EnvVariables.AZUREAI_API_BASE: "", + EnvVariables.AZUREAI_API_KEY: "", + EnvVariables.AZUREAI_API_VERSION: "", + EnvVariables.OPENAI_API_KEY: "", + EnvVariables.OPENAI_API_BASE: "https://api.openai.com/v1/models/", + EnvVariables.CLAUDE_API_KEY: "", + EnvVariables.CLAUDE_ENDPOINT: "", + EnvVariables.APP_PASSCODE_KEY: "", + EnvVariables.HOST: "0.0.0.0", + EnvVariables.PORT: 5001, + EnvVariables.DEBUG: False, + EnvVariables.ENABLE_SENTRY: False, + EnvVariables.SENTRY_DSN: "", + EnvVariables.SENTRY_ENVIRONMENT: "", + EnvVariables.SENTRY_RELEASE: "" +} + +def get_env_variable(key: EnvVariables): + """Retrieve an environment variable or return its default value.""" + return os.environ.get(key.value, DEFAULT_VALUES[key]) diff --git a/electron/.env b/electron/.env new file mode 100644 index 0000000..ef1f8f1 --- /dev/null +++ b/electron/.env @@ -0,0 +1,5 @@ +ENABLE_SENTRY=false +SENTRY_DSN= +SENTRY_ENVIRONMENT= +SENTRY_RELEASE= +THEME_CONFIGURATION='{"appIcons":{"mac":"assets/icons/mac_icon.icns","win":"assets/icons/win_icon.ico","linux":"assets/icons/linux_icon.png"}}' diff --git a/electron/.env.example b/electron/.env.example new file mode 100644 index 0000000..36beb59 --- /dev/null +++ b/electron/.env.example @@ -0,0 +1,10 @@ +ENABLE_SENTRY=false +SENTRY_DSN= +SENTRY_ENVIRONMENT= +SENTRY_RELEASE= +THEME_CONFIGURATION='{ + "appIcons":{ + "mac":"assets/icons/mac_icon.icns", + "win":"assets/icons/win_icon.ico", + "linux":"assets/icons/linux_icon.png" + }}' diff --git a/electron/README.md b/electron/README.md new file mode 100644 index 0000000..7f18c98 --- /dev/null +++ b/electron/README.md @@ -0,0 +1,137 @@ +# πŸ–₯️ Specif AI Desktop Application - Electron + +The Electron-based desktop application for Specif AI facilitates local file system integration for all generated documents, providing a seamless user experience across platforms. + +## Table of Contents +- [Features](#features) +- [Prerequisites](#prerequisites) +- [Development Setup](#development-setup) +- [Building](#building) +- [Contributing](#contributing) +- [Troubleshooting](#troubleshooting) + +## ✨ Features + +- πŸ“‚ Local File System Integration. +- πŸ“„ Document Management. +- πŸš€ Cross-Platform Support. + +## πŸ› οΈ Prerequisites + +- Node.js >= 20.x +- npm >= 9.6.7 + +## πŸ’» Development Setup + +### Installation + +To install the necessary dependencies, run the following command: + +```bash +npm install +``` + +### Development Mode + +1. **Enable Development Features**: In `app.js`, uncomment the following lines to enable hot-reloading and open developer tools for debugging: + ```javascript + mainWindow.webContents.openDevTools(); + + require('electron-reload')(__dirname, { + electron: path.join(__dirname, "node_modules", ".bin", "electron"), + forceHardReset: true, + hardResetMethod: 'exit' + }); + ``` + +2. **Build Angular UI Application**: + - Navigate to the `ui/` directory and install all necessary npm packages using the command below: + ```bash + npm install + ``` + - Then, navigate back to the `electron` directory and build the Angular application to prepare it for integration with Electron. This will copy the build files to the `electron/ui` directory: + ```bash + npm run build:ui + ``` + - To automatically rebuild and reflect changes in the Electron application when making changes to `ui/`, run: + ```bash + npm run watch:ui + ``` + +3. **Start the Application**: Once the Angular UI is compiled and the build files are copied to the Electron directory, launch the Electron application: + ```bash + npm run serve:electron + ``` + +> **Note**: Ensure the Angular UI build (step 2) is completed before starting step 3. + +## πŸ—οΈ Building + +1. **Disable Development Features**: Ensure that developer tools are disabled in production to prevent exposure of sensitive information. Comment out the development-specific lines in `app.js`: + ```javascript + // mainWindow.webContents.openDevTools(); + + // require('electron-reload')(__dirname, { + // electron: path.join(__dirname, "node_modules", ".bin", "electron"), + // forceHardReset: true, + // hardResetMethod: 'exit' + // }); + ``` + +2. **Configure Code Signing**: + - **Default**: Enabled for macOS. + - **Disable**: Remove the following from `package.json` to disable: + ```json + "appId": "<your app id>", + "forceCodeSigning": true + ``` + - **Certificate Configuration**: Set up the certificate in `build-assets/build-mac.sh`. + - **Notarization**: Update `package.json` for Mac application notarization: + ```json + { + "notarize": { + "teamId": "<team id>" + } + } + ``` + - **Enforcement**: Ensure code signing is enforced by verifying the configuration in `package.json` and `build-assets/build-mac.sh`. + +3. **Build the Application**: Execute the build script: + ```bash + ./build-assets/build-mac.sh + ``` + This script compiles the application into a distributable format, ready for deployment. + +## 🀝 Contributing + +Please read our [Contributing Guidelines](../CONTRIBUTING.md) for details on submitting patches and the contribution workflow. + +## πŸ› οΈ Troubleshooting + +- **Issue**: Application fails to start. + - **Solution**: Ensure all dependencies are installed and the Angular UI is built before starting the Electron app. + +- **Issue**: Code signing errors occur. + - **Solution**: Verify the certificate configuration in `build-assets/build-mac.sh` and `package.json`. + +- **Issue**: If you encounter an issue such as "Port Error: Port 49153 is already in use by another application" while running Electron, follow these steps to resolve it: + + 1. Check for processes running on the port: + + Use the following command to identify the process using the port: + ``` + lsof -ti:<port-number> + + #For MAC users + sudo lsof -i :<port-number> + ``` + + 2. Kill the process: + + Terminate the process using the command below, then re-run the Electron app: + ``` + kill -9 <process-id> + + #For MAC users + sudo kill -9 <process-id> + ``` \ No newline at end of file diff --git a/electron/app.js b/electron/app.js new file mode 100644 index 0000000..96c9e8b --- /dev/null +++ b/electron/app.js @@ -0,0 +1,395 @@ +const { app, ipcMain, BrowserWindow, dialog, shell } = require("electron"); +const path = require("path"); +const fs = require("fs"); +const express = require("express"); +const axios = require("axios"); +require("dotenv").config({ + path: app.isPackaged + ? path.join(process.resourcesPath, ".env") + : path.resolve(process.cwd(), ".env"), +}); + +const indexPath = app.isPackaged + ? path.join(process.resourcesPath, "ui") + : path.resolve(process.cwd(), "ui"); +const net = require("net"); +let store; + +(async () => { + const Store = (await import("electron-store")).default; + store = new Store(); +})(); + +ipcMain.handle("store-get", async (event, key) => { + return store ? store.get(key) : null; +}); + +ipcMain.handle("store-set", async (event, key, value) => { + if (store) { + store.set(key, value); + return true; + } + return false; +}); + +ipcMain.handle("reloadApp", () => onAppReload()); + +// Register handler for removeStoreValue +ipcMain.handle("removeStoreValue", async (event, key) => { + if (store) { + store.delete(key); + return true; + } + return false; +}); + +// Update env variable ENABLE_SENTRY in sentry.env to true for enabling sentry. +// Also update SENTRY_DSN, SENTRY_ENVIRONMENT, SENTRY_RELEASE values accordingly. +const enableSentry = process.env.ENABLE_SENTRY; +const authServer = express(); +authServer.disable("x-powered-by"); + +const themeConfiguration = JSON.parse(process.env.THEME_CONFIGURATION); + +ipcMain.handle("get-theme-configuration", () => themeConfiguration); + +if (enableSentry) { + console.debug("Configuring sentry for the electron application."); + const { init, IPCMode } = require("@sentry/electron/main"); + init({ + dsn: process.env.SENTRY_DSN, + debug: false, // Set debug value to false for production and true for development or debugging + ipcMode: IPCMode.Protocol, + environment: process.env.SENTRY_ENVIRONMENT, + release: process.env.SENTRY_RELEASE, + }); +} else { + console.debug("Sentry configuration is disabled."); +} + +const { utilityFunctionMap } = require("./file-system.utility"); + +let mainWindow; +let clientId, clientSecret, redirectUri; + +// Set the path to the icon file +function getIconPath() { + const icons = themeConfiguration.appIcons; + if (process.platform === "darwin") { + return path.join(__dirname, icons.mac); + } else if (process.platform === "win32") { + return path.join(__dirname, icons.win); + } else { + return path.join(__dirname, icons.linux); + } +} + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1200, + minWidth: 1200, + minHeight: 850, + height: 850, + webPreferences: { + preload: path.join(__dirname, "preload.js"), + contextIsolation: true, + enableRemoteModule: false, + nodeIntegration: false, + }, + // Uncomment the below line to set the icon for the application + icon: path.join(__dirname, getIconPath()), + }); + + mainWindow.loadFile(`${indexPath}/index.html`).then(() => { + console.debug("Welcome Page loaded successfully"); + }); + + // Open the DevTools - uncomment this line before production build + // mainWindow.webContents.openDevTools(); + + // Add electron-reload to watch the electron directory + // require('electron-reload')(__dirname, { + // electron: path.join(__dirname, "node_modules", ".bin", "electron"), + // forceHardReset: true, + // hardResetMethod: 'exit' + // }); + + mainWindow.on("closed", () => { + mainWindow = null; + app.quit(); + }); + + mainWindow.on("reload", () => onAppReload()); + + mainWindow.webContents.setWindowOpenHandler(() => { + return { action: "deny" }; + }); +} + +app.whenReady().then(() => { + createWindow(); + + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); + + app.on("window-all-closed", () => app.quit()); + mainWindow.webContents.setWindowOpenHandler(() => { + return { action: "deny" }; + }); + + mainWindow.webContents.on( + "did-fail-load", + (event, errorCode, errorDescription, validatedURL) => { + if (errorCode === -6) { + // ERR_FILE_NOT_FOUND + console.error( + `Failed to load URL: ${validatedURL}, error: ${errorDescription}`, + ); + onAppReload(); + } + }, + ); + + app.on("reload", () => onAppReload()); + + function generateState() { + return Math.random().toString(36).substring(2); + } + + ipcMain.on("start-server", () => { + // Auth Server used for Jira Integration OAuth Process + + const port = 49153; + + const server = net.createServer(); + server.once("error", (err) => { + if (err.code === "EADDRINUSE") { + mainWindow.webContents.send( + "port-error", + `Port ${port} is already in use by another application.`, + ); + } else { + mainWindow.webContents.send( + "port-error", + `Failed to start server: ${err.message}`, + ); + } + }); + + server.once("listening", () => { + server.close(); + + authServer.listen(port, () => { + console.debug( + `OAuth callback server listening on http://localhost:${port}/callback`, + ); + mainWindow.webContents.send("server-started"); + }); + + authServer.on("error", (err) => { + if (err.code === "EADDRINUSE") { + mainWindow.webContents.send( + "port-error", + `Port ${port} is already in use.`, + ); + } else { + mainWindow.webContents.send( + "port-error", + `Server error: ${err.message}`, + ); + } + }); + }); + + server.listen(port); + }); + + ipcMain.on("start-jira-oauth", (event, oauthParams) => { + console.debug("Received OAuth parameters."); + clientId = oauthParams.clientId; + clientSecret = oauthParams.clientSecret; + redirectUri = oauthParams.redirectUri; + + if (!clientId || !clientSecret || !redirectUri) { + console.error("Missing OAuth parameters"); + return; + } + + const authURL = `https://auth.atlassian.com/authorize?audience=api.atlassian.com&client_id=${clientId}&scope=read%3Ajira-user%20read%3Ajira-work%20write%3Ajira-work%20offline_access&redirect_uri=${encodeURIComponent(redirectUri)}&state=${generateState()}&response_type=code&prompt=consent`; + + console.log("Opening authorization URL."); + shell.openExternal(authURL).then(); + }); + + ipcMain.on("refresh-jira-token", async (event, { refreshToken }) => { + console.debug("Received refresh token request."); + try { + const authResponse = await exchangeToken("refresh_token", refreshToken); + event.sender.send("oauth-reply", authResponse); + console.log("Access token refreshed and sent to renderer."); + } catch (error) { + console.error("Error refreshing access token."); + event.sender.send("oauth-reply", null); + } + }); + + ipcMain.handle("dialog:openFile", async () => { + const { canceled, filePaths } = await dialog.showOpenDialog(); + if (canceled) { + return null; + } else { + const filePath = filePaths[0]; + const fileContent = fs.readFileSync(filePath, "utf-8"); + return { filePath, fileContent }; + } + }); + + ipcMain.handle( + "dialog:saveFile", + async (event, fileContent, options = null) => { + let filePath = options.rootPath; + + if (!filePath) { + const response = await dialog.showSaveDialog(); + filePath = response.filePath; + if (response.canceled) { + return null; + } + } + const dirForSave = `${filePath}/${options.fileName.split(options.fileName.split("/").pop())[0]}`; + if (!fs.existsSync(dirForSave)) { + fs.mkdirSync(dirForSave, { recursive: true }); + } + fs.writeFileSync(`${filePath}/${options.fileName}`, fileContent, "utf-8"); + return filePath; + }, + ); + + ipcMain.handle("dialog:openDirectory", async (_event, _message) => { + const { canceled, filePaths } = await dialog.showOpenDialog({ + properties: ["openDirectory"], + }); + if (canceled) { + return []; + } else { + return filePaths; + } + }); + + ipcMain.handle("invokeCustomFunction", async (event, message) => { + console.debug("message on invokeCustomFunction."); + console.debug("map: ", utilityFunctionMap); + return utilityFunctionMap[message.functionName](message.params); + }); + + // New handler to show error message + ipcMain.handle("show-error-message", async (event, errorMessage) => { + mainWindow.webContents.send("display-error", errorMessage); + }); + + authServer.get("/callback", async (req, res) => { + const authorizationCode = req.query.code; + try { + const authResponse = await exchangeToken( + "authorization_code", + authorizationCode, + ); + mainWindow.webContents.send("oauth-reply", authResponse); + res.send("Authentication successful. You can close this tab."); + } catch (error) { + console.error( + "Error exchanging authorization code for access token.", + ); + res.status(500).send("Authentication failed."); + } + }); + + ipcMain.on("load-url", (event, serverConfig) => { + if (serverConfig && isValidUrl(serverConfig)) { + mainWindow + .loadURL(serverConfig) + .then(() => { + console.debug("URL loaded successfully"); + }) + .catch((error) => { + console.error("Failed to load URL."); + }); + } else { + console.error("Invalid or no server URL provided."); + } + }); +}); + +ipcMain.handle("get-style-url", () => { + return path.join(process.resourcesPath, "tailwind.output.css"); +}); + +async function exchangeToken(grantType, codeOrToken) { + const tokenUrl = "https://auth.atlassian.com/oauth/token"; + const params = { + grant_type: grantType, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: grantType === "authorization_code" ? redirectUri : undefined, + }; + + if (grantType === "authorization_code") { + params.code = codeOrToken; + } else if (grantType === "refresh_token") { + params.refresh_token = codeOrToken; + } + + const response = await axios.post(tokenUrl, params, { + headers: { + "Content-Type": "application/json", + }, + }); + + const { access_token, refresh_token, expires_in, token_type } = response.data; + + const expirationDate = new Date(); + expirationDate.setSeconds(expirationDate.getSeconds() + expires_in); + + const cloudId = await getCloudId(access_token); + + return { + accessToken: access_token, + refreshToken: refresh_token, + expirationDate: expirationDate.toISOString(), + tokenType: token_type, + cloudId: cloudId, + }; +} + +async function getCloudId(accessToken) { + const accessibleResourcesUrl = + "https://api.atlassian.com/oauth/token/accessible-resources"; + const cloudIdResponse = await axios.get(accessibleResourcesUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + }); + + const resources = cloudIdResponse.data; + return resources.length > 0 ? resources[0].id : null; +} + +function onAppReload() { + mainWindow.loadFile(`${indexPath}/index.html`).then(() => { + console.debug("Welcome Page reloaded successfully"); + }); +} + +// Helper function to validate URLs +function isValidUrl(url) { + try { + new URL(url); + return true; + } catch (_) { + return false; + } +} diff --git a/electron/assets/icons/hai_icon.icns b/electron/assets/icons/hai_icon.icns new file mode 100644 index 0000000..bf2bedb Binary files /dev/null and b/electron/assets/icons/hai_icon.icns differ diff --git a/electron/assets/icons/hai_icon.ico b/electron/assets/icons/hai_icon.ico new file mode 100644 index 0000000..ff67416 Binary files /dev/null and b/electron/assets/icons/hai_icon.ico differ diff --git a/electron/assets/icons/hai_icon.png b/electron/assets/icons/hai_icon.png new file mode 100644 index 0000000..c7e1c0c Binary files /dev/null and b/electron/assets/icons/hai_icon.png differ diff --git a/electron/build-assets/build-mac.sh b/electron/build-assets/build-mac.sh new file mode 100755 index 0000000..7211999 --- /dev/null +++ b/electron/build-assets/build-mac.sh @@ -0,0 +1,5 @@ +export CSC_LINK="$(pwd)/build-assets/applep12devcert.p12" +export CSC_KEY_PASSWORD="" + +npm run package:mac && npm run package:win + diff --git a/electron/build-assets/entitlements.mac.plist b/electron/build-assets/entitlements.mac.plist new file mode 100644 index 0000000..2348b6e --- /dev/null +++ b/electron/build-assets/entitlements.mac.plist @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>com.apple.security.cs.allow-jit</key> + <true/> + <key>com.apple.security.cs.allow-unsigned-executable-memory</key> + <true/> + <key>com.apple.security.cs.debugger</key> + <true/> +</dict> +</plist> diff --git a/electron/file-system.utility.js b/electron/file-system.utility.js new file mode 100644 index 0000000..b95518f --- /dev/null +++ b/electron/file-system.utility.js @@ -0,0 +1,259 @@ +const fs = require("fs"); +const pathModule = require("path"); +const fsPromise = require("fs").promises; + +const utilityFunctionMap = { + createDirectoryWithMetadata: createDirectoryWithMetadata, + readDirectoryMetadata: readDirectoryMetadata, + createEmptyFile: createEmptyFile, + createFileWithContent: createFileWithContent, + readFromFile: readFromFile, + writeFile: writeFile, + getDirectoryList: getDirectoryList, + readFileChunk: readFileChunk, + appendFile: appendFile, + fileExists: fileExists, + readMetadataFile: readMetadataFile, + createRequestedDirectory: createRequestedDirectory, + archiveFile: archiveFile, +}; + +function createDirectoryWithMetadata(param) { + const { path, metadata } = param; + if (!fs.existsSync(path)) { + fs.mkdirSync(path, { recursive: true }); + } + fs.writeFileSync(`${path}/.metadata.json`, JSON.stringify(metadata), "utf-8"); + return path; +} + +function readDirectoryMetadata(param) { + const { path } = param; + const projects = fs.readdirSync(path); + return projects + .filter((project) => !project.startsWith(".")) + .map((project) => { + const metadata = readMetadataFile({ path: `${path}/${project}` }); + return { metadata, project }; + }); +} + +function readMetadataFile(param) { + const { path } = param; + const metadata = fs.readFileSync(`${path}/.metadata.json`, "utf-8"); + return JSON.parse(metadata); +} + +function createEmptyFile(param) { + const { path } = param; + fs.writeFileSync(path, "", "utf-8"); +} + +function createFileWithContent(param) { + const { path, content } = param; + createDirectory(path.split("/").slice(0, -1).join("/")); + fs.writeFileSync(path, content, "utf-8"); +} + +function createRequestedDirectory(param) { + const { path } = param; + console.log(path, "Create dir path"); + createDirectory(path); +} + +function readFromFile(param) { + const { path } = param; + console.log("path: ", path); + if (fs.existsSync(path)) { + return fs.readFileSync(path, "utf-8"); + } +} + +function writeFile(param) { + const { path, content } = param; + fs.writeFileSync(path, content, "utf-8"); +} + +function archiveFile(param) { + const { path } = param; + if (fs.existsSync(path)) { + try { + const newPath = path.replace(".json", "-archived.json"); + fs.renameSync(path, newPath); + console.log(`File renamed to: ${newPath}`); + } catch (error) { + console.error("Error renaming file:", error); + } + } else { + console.error("File does not exist:", path); + } +} + +function getDirectoryList(param) { + const { path, constructTree, filterString } = param; + + const projects = fs.readdirSync(path); + const folders = projects.filter( + (project) => + !project.startsWith(".") && + fs.statSync(`${path}/${project}`).isDirectory(), + ); + + const regex = new RegExp(`-${filterString}\.json$`, "i"); + + const filterFiles = (fileName) => regex.test(fileName); + + if (constructTree) { + return folders.map((folder) => { + const files = fs.readdirSync(`${path}/${folder}`); + return { + name: folder, + children: files.filter( + (file) => !file.startsWith(".") && filterFiles(file), + ), + }; + }); + } else { + const files = projects.filter( + (project) => + !project.startsWith(".") && + filterFiles(project) && + fs.statSync(`${path}/${project}`).isFile(), + ); + return files; + } +} + +async function appendFile({ path, content, featureFile }) { + const keyName = pathModule.basename(path); + + try { + await fsPromise.mkdir(path, { recursive: true }); + console.log( + path, + content, + featureFile, + "Directory created or already exists.", + ); + } catch (err) { + console.error("Error creating directory:", err); + return; + } + + try { + const files = await fsPromise.readdir(path); + let fileCount = 0; + files.forEach((file) => { + if (file.startsWith(keyName) && file.includes("-base")) { + fileCount++; + } + }); + + let newFileName = + featureFile === "" + ? `${keyName}${(fileCount + 1).toString().padStart(2, "0")}-base.json` + : `${featureFile}-feature.json`; + const newFilePath = pathModule.join(path, newFileName); + + await fsPromise.writeFile(newFilePath, content, "utf-8"); + + if (directoryPath.includes("PRD") && featureFile === "") { + const prdFileName = `${keyName}${(fileCount + 1).toString().padStart(2, "0")}-feature.json`; + const prdFilePath = pathModule.join(path, prdFileName); + await fsPromise.writeFile( + prdFilePath, + JSON.stringify({ features: [] }), + "utf-8", + ); + } + } catch (err) { + console.error("Error handling files:", err); + } +} + +function createDirectory(path) { + if (!fs.existsSync(path)) { + fs.mkdirSync(path, { recursive: true }); + } +} + +function readFileChunk(param) { + const { path, filterString } = param; + const CHUNK_SIZE = 400; + const buffer = Buffer.alloc(CHUNK_SIZE); + let accumulatedData = ""; + let dataExtracted = { requirement: null, title: null }; + const fileName = path.split("/").pop(); + + // Build regex based on the filter string + const regex = new RegExp(`-${filterString}\.json$`, "i"); + + return new Promise((resolve, reject) => { + if (!regex.test(fileName)) { + return resolve({ + message: "File name does not match the specified pattern.", + }); + } + + fs.open(path, "r", (err, fd) => { + if (err) return reject(err); + + const tryParse = () => { + try { + const parsed = JSON.parse(accumulatedData); + if (parsed.requirement && !dataExtracted.requirement) { + dataExtracted.requirement = parsed.requirement; + } + if (parsed.title && !dataExtracted.title) { + dataExtracted.title = parsed.title; + } + if (dataExtracted.requirement && dataExtracted.title) { + fs.close(fd, () => {}); + resolve(dataExtracted); + } + } catch (parseError) { + // Continue reading if not all data has been parsed + } + }; + + const readNextChunk = () => { + fs.read(fd, buffer, 0, CHUNK_SIZE, null, (err, nread) => { + if (err) { + fs.close(fd, () => {}); + return reject(err); + } + + if (nread === 0) { + fs.close(fd, (err) => { + if (err) reject(err); + if (!dataExtracted.requirement || !dataExtracted.title) { + reject( + new Error( + "Could not find 'requirement' or 'title' field in the available data.", + ), + ); + } + }); + return; + } + + const chunk = buffer.slice(0, nread).toString(); + accumulatedData += chunk; + tryParse(); + + if (!dataExtracted.requirement || !dataExtracted.title) { + readNextChunk(); + } + }); + }; + readNextChunk(); + }); + }); +} + +function fileExists(param) { + const { path } = param; + return fs.existsSync(path); +} + +module.exports.utilityFunctionMap = utilityFunctionMap; diff --git a/electron/package-lock.json b/electron/package-lock.json new file mode 100644 index 0000000..9be0715 --- /dev/null +++ b/electron/package-lock.json @@ -0,0 +1,7277 @@ +{ + "name": "specif_ai", + "version": "1.9.5", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "specif_ai", + "version": "1.9.5", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "@sentry/electron": "^5.3.0", + "axios": "^1.7.7", + "dotenv": "^16.4.5", + "electron-notarize": "^1.2.2", + "electron-squirrel-startup": "^1.0.1", + "electron-store": "^10.0.0", + "express": "^4.21.0" + }, + "devDependencies": { + "@electron/fuses": "^1.8.0", + "@electron/rebuild": "^3.6.0", + "autoprefixer": "^10.4.20", + "concurrently": "^8.2.2", + "electron": "^31.2.1", + "electron-builder": "^24.13.3", + "electron-reload": "^2.0.0-alpha.1", + "onchange": "^7.1.0", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.14" + }, + "engines": { + "node": ">=18.17" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/runtime": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", + "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@blakeembrey/deque": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@blakeembrey/deque/-/deque-1.0.5.tgz", + "integrity": "sha512-6xnwtvp9DY1EINIKdTfvfeAtCYw4OqBZJhtiqkT3ivjnEfa25VQ3TsKvaFfKm8MyGIEfE95qLe+bNEt3nB0Ylg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@blakeembrey/template": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@blakeembrey/template/-/template-1.2.0.tgz", + "integrity": "sha512-w/63nURdkRPpg3AXbNr7lPv6HgOuVDyefTumiXsbXxtIwcuk5EXayWR5OpSwDjsQPgaYsfUSedMduaNOjAYY8A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@develar/schema-utils": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", + "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "dev": true, + "dependencies": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@electron/asar": { + "version": "3.2.17", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.2.17.tgz", + "integrity": "sha512-OcWImUI686w8LkghQj9R2ynZ2ME693Ek6L1SiaAgqGKzBaTIZw3fHDqN82Rcl+EU1Gm9EgkJ5KLIY/q5DCRbbA==", + "dev": true, + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "dev": true, + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@electron/get/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@electron/get/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/@electron/node-gyp": { + "version": "10.2.0-electron.1", + "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "integrity": "sha512-CrYo6TntjpoMO1SHjl5Pa/JoUsECNqNdB7Kx49WLQpWzPw53eEITJ2Hs9fh/ryUYDn4pxZz11StaBYBrLFJdqg==", + "dev": true, + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^8.1.0", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^10.2.1", + "nopt": "^6.0.0", + "proc-log": "^2.0.1", + "semver": "^7.3.5", + "tar": "^6.2.1", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/@electron/notarize": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.2.1.tgz", + "integrity": "sha512-aL+bFMIkpR0cmmj5Zgy0LMKEpgy43/hw5zadEArgmAMWWlKc5buwFvFT9G/o/YJkvXAJm5q3iuTuLaiaXW39sg==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.0.5.tgz", + "integrity": "sha512-k9ZzUQtamSoweGQDV2jILiRIHUu7lYlJ3c6IEmjv1hC17rclE+eb9U+f6UFlOOETo0JzY1HNlXy4YOlCvl+Lww==", + "dev": true, + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/rebuild": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-3.7.1.tgz", + "integrity": "sha512-sKGD+xav4Gh25+LcLY0rjIwcCFTw+f/HU1pB48UVbwxXXRGaXEqIH0AaYKN46dgd/7+6kuiDXzoyAEvx1zCsdw==", + "dev": true, + "dependencies": { + "@electron/node-gyp": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "@malept/cross-spawn-promise": "^2.0.0", + "chalk": "^4.0.0", + "debug": "^4.1.1", + "detect-libc": "^2.0.1", + "fs-extra": "^10.0.0", + "got": "^11.7.0", + "node-abi": "^3.45.0", + "node-api-version": "^0.2.0", + "ora": "^5.1.0", + "read-binary-file-arch": "^1.0.6", + "semver": "^7.3.5", + "tar": "^6.0.5", + "yargs": "^17.0.1" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/@electron/rebuild/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/universal": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-1.5.1.tgz", + "integrity": "sha512-kbgXxyEauPJiQQUNG2VgUeyfQNFk6hBF11ISN2PNI6agUgPl55pv4eQmaqHzTAzchBvqZ2tQuRVaPStGf0mxGw==", + "dev": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "@malept/cross-spawn-promise": "^1.1.0", + "debug": "^4.3.1", + "dir-compare": "^3.0.0", + "fs-extra": "^9.0.1", + "minimatch": "^3.0.4", + "plist": "^3.0.4" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/@electron/universal/node_modules/@malept/cross-spawn-promise": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", + "integrity": "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "dev": true + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npmcli/fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", + "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", + "dev": true, + "dependencies": { + "@gar/promisify": "^1.1.3", + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@npmcli/move-file": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", + "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "dev": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.53.0.tgz", + "integrity": "sha512-8HArjKx+RaAI8uEIgcORbZIPklyh1YLjPSBus8hjRmvLi6DeFzgOcdZ7KwPabKj8mXF8dX0hyfAyGfycz0DbFw==", + "dependencies": { + "@opentelemetry/api": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.27.0.tgz", + "integrity": "sha512-CdZ3qmHCwNhFAzjTgHqrDQ44Qxcpz43cVxZRhOs+Ns/79ug+Mr84Bkb626bkJLkA3+BLimA5YAEVRlJC6pFb7g==", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.27.0.tgz", + "integrity": "sha512-yQPKnK5e+76XuiqUH/gKyS8wv/7qITd5ln56QkBTf3uggr0VkXOXfcaAuG330UfdYu83wsyoBwqwxigpIG+Jkg==", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.53.0.tgz", + "integrity": "sha512-DMwg0hy4wzf7K73JJtl95m/e0boSoWhH07rfvHvYzQtBD3Bmv0Wc1x733vyZBqmFm8OjJD0/pfiUg1W3JjFX0A==", + "dependencies": { + "@opentelemetry/api-logs": "0.53.0", + "@types/shimmer": "^1.2.0", + "import-in-the-middle": "^1.8.1", + "require-in-the-middle": "^7.1.1", + "semver": "^7.5.2", + "shimmer": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-amqplib": { + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.42.0.tgz", + "integrity": "sha512-fiuU6OKsqHJiydHWgTRQ7MnIrJ2lEqsdgFtNIH4LbAUJl/5XmrIeoDzDnox+hfkgWK65jsleFuQDtYb5hW1koQ==", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-connect": { + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.39.0.tgz", + "integrity": "sha512-pGBiKevLq7NNglMgqzmeKczF4XQMTOUOTkK8afRHMZMnrK3fcETyTH7lVaSozwiOM3Ws+SuEmXZT7DYrrhxGlg==", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/connect": "3.4.36" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-dataloader": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.12.0.tgz", + "integrity": "sha512-pnPxatoFE0OXIZDQhL2okF//dmbiWFzcSc8pUg9TqofCLYZySSxDCgQc69CJBo5JnI3Gz1KP+mOjS4WAeRIH4g==", + "dependencies": { + "@opentelemetry/instrumentation": "^0.53.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-express": { + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.43.0.tgz", + "integrity": "sha512-bxTIlzn9qPXJgrhz8/Do5Q3jIlqfpoJrSUtVGqH+90eM1v2PkPHc+SdE+zSqe4q9Y1UQJosmZ4N4bm7Zj/++MA==", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-fastify": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fastify/-/instrumentation-fastify-0.40.0.tgz", + "integrity": "sha512-74qj4nG3zPtU7g2x4sm2T4R3/pBMyrYstTsqSZwdlhQk1SD4l8OSY9sPRX1qkhfxOuW3U4KZQAV/Cymb3fB6hg==", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-fs": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.15.0.tgz", + "integrity": "sha512-JWVKdNLpu1skqZQA//jKOcKdJC66TWKqa2FUFq70rKohvaSq47pmXlnabNO+B/BvLfmidfiaN35XakT5RyMl2Q==", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.53.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-generic-pool": { + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.39.0.tgz", + "integrity": "sha512-y4v8Y+tSfRB3NNBvHjbjrn7rX/7sdARG7FuK6zR8PGb28CTa0kHpEGCJqvL9L8xkTNvTXo+lM36ajFGUaK1aNw==", + "dependencies": { + "@opentelemetry/instrumentation": "^0.53.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-graphql": { + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.43.0.tgz", + "integrity": "sha512-aI3YMmC2McGd8KW5du1a2gBA0iOMOGLqg4s9YjzwbjFwjlmMNFSK1P3AIg374GWg823RPUGfVTIgZ/juk9CVOA==", + "dependencies": { + "@opentelemetry/instrumentation": "^0.53.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-hapi": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.41.0.tgz", + "integrity": "sha512-jKDrxPNXDByPlYcMdZjNPYCvw0SQJjN+B1A+QH+sx+sAHsKSAf9hwFiJSrI6C4XdOls43V/f/fkp9ITkHhKFbQ==", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-http": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.53.0.tgz", + "integrity": "sha512-H74ErMeDuZfj7KgYCTOFGWF5W9AfaPnqLQQxeFq85+D29wwV2yqHbz2IKLYpkOh7EI6QwDEl7rZCIxjJLyc/CQ==", + "dependencies": { + "@opentelemetry/core": "1.26.0", + "@opentelemetry/instrumentation": "0.53.0", + "@opentelemetry/semantic-conventions": "1.27.0", + "semver": "^7.5.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/core": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.26.0.tgz", + "integrity": "sha512-1iKxXXE8415Cdv0yjG3G6hQnB5eVEsJce3QaawX8SjDn0mAS0ZM8fAbZZJD4ajvhC15cePvosSCut404KrIIvQ==", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation-ioredis": { + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.43.0.tgz", + "integrity": "sha512-i3Dke/LdhZbiUAEImmRG3i7Dimm/BD7t8pDDzwepSvIQ6s2X6FPia7561gw+64w+nx0+G9X14D7rEfaMEmmjig==", + "dependencies": { + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/redis-common": "^0.36.2", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-kafkajs": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.3.0.tgz", + "integrity": "sha512-UnkZueYK1ise8FXQeKlpBd7YYUtC7mM8J0wzUSccEfc/G8UqHQqAzIyYCUOUPUKp8GsjLnWOOK/3hJc4owb7Jg==", + "dependencies": { + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-koa": { + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.43.0.tgz", + "integrity": "sha512-lDAhSnmoTIN6ELKmLJBplXzT/Jqs5jGZehuG22EdSMaTwgjMpxMDI1YtlKEhiWPWkrz5LUsd0aOO0ZRc9vn3AQ==", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-lru-memoizer": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.40.0.tgz", + "integrity": "sha512-21xRwZsEdMPnROu/QsaOIODmzw59IYpGFmuC4aFWvMj6stA8+Ei1tX67nkarJttlNjoM94um0N4X26AD7ff54A==", + "dependencies": { + "@opentelemetry/instrumentation": "^0.53.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mongodb": { + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.47.0.tgz", + "integrity": "sha512-yqyXRx2SulEURjgOQyJzhCECSh5i1uM49NUaq9TqLd6fA7g26OahyJfsr9NE38HFqGRHpi4loyrnfYGdrsoVjQ==", + "dependencies": { + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/sdk-metrics": "^1.9.1", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mongoose": { + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.42.0.tgz", + "integrity": "sha512-AnWv+RaR86uG3qNEMwt3plKX1ueRM7AspfszJYVkvkehiicC3bHQA6vWdb6Zvy5HAE14RyFbu9+2hUUjR2NSyg==", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mysql": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.41.0.tgz", + "integrity": "sha512-jnvrV6BsQWyHS2qb2fkfbfSb1R/lmYwqEZITwufuRl37apTopswu9izc0b1CYRp/34tUG/4k/V39PND6eyiNvw==", + "dependencies": { + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/mysql": "2.15.26" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mysql2": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.41.0.tgz", + "integrity": "sha512-REQB0x+IzVTpoNgVmy5b+UnH1/mDByrneimP6sbDHkp1j8QOl1HyWOrBH/6YWR0nrbU3l825Em5PlybjT3232g==", + "dependencies": { + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@opentelemetry/sql-common": "^0.40.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-nestjs-core": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.40.0.tgz", + "integrity": "sha512-WF1hCUed07vKmf5BzEkL0wSPinqJgH7kGzOjjMAiTGacofNXjb/y4KQ8loj2sNsh5C/NN7s1zxQuCgbWbVTGKg==", + "dependencies": { + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-pg": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.44.0.tgz", + "integrity": "sha512-oTWVyzKqXud1BYEGX1loo2o4k4vaU1elr3vPO8NZolrBtFvQ34nx4HgUaexUDuEog00qQt+MLR5gws/p+JXMLQ==", + "dependencies": { + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@opentelemetry/sql-common": "^0.40.1", + "@types/pg": "8.6.1", + "@types/pg-pool": "2.0.6" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-redis-4": { + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis-4/-/instrumentation-redis-4-0.42.0.tgz", + "integrity": "sha512-NaD+t2JNcOzX/Qa7kMy68JbmoVIV37fT/fJYzLKu2Wwd+0NCxt+K2OOsOakA8GVg8lSpFdbx4V/suzZZ2Pvdjg==", + "dependencies": { + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/redis-common": "^0.36.2", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-undici": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.6.0.tgz", + "integrity": "sha512-ABJBhm5OdhGmbh0S/fOTE4N69IZ00CsHC5ijMYfzbw3E5NwLgpQk5xsljaECrJ8wz1SfXbO03FiSuu5AyRAkvQ==", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.53.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.7.0" + } + }, + "node_modules/@opentelemetry/redis-common": { + "version": "0.36.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.36.2.tgz", + "integrity": "sha512-faYX1N0gpLhej/6nyp6bgRjzAKXn5GOEMYY7YhciSfCoITAktLUtQ36d24QEWNA1/WA1y6qQunCe0OhHRkVl9g==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.27.0.tgz", + "integrity": "sha512-jOwt2VJ/lUD5BLc+PMNymDrUCpm5PKi1E9oSVYAvz01U/VdndGmrtV3DU1pG4AwlYhJRHbHfOUIlpBeXCPw6QQ==", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.27.0.tgz", + "integrity": "sha512-JzWgzlutoXCydhHWIbLg+r76m+m3ncqvkCcsswXAQ4gqKS+LOHKhq+t6fx1zNytvLuaOUBur7EvWxECc4jPQKg==", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.27.0.tgz", + "integrity": "sha512-btz6XTQzwsyJjombpeqCX6LhiMQYpzt2pIYNPnw0IPO/3AhT6yjnf8Mnv3ZC2A4eRYOjqrg+bfaXg9XHDRJDWQ==", + "dependencies": { + "@opentelemetry/core": "1.27.0", + "@opentelemetry/resources": "1.27.0", + "@opentelemetry/semantic-conventions": "1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sql-common": { + "version": "0.40.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.40.1.tgz", + "integrity": "sha512-nSDlnHSqzC3pXn/wZEZVLuAuJ1MYMXPBwtv2qAbCa3847SaHItdE7SzUq/Jtb0KZmh1zfAbNi3AAMjztTT4Ugg==", + "dependencies": { + "@opentelemetry/core": "^1.1.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@prisma/instrumentation": { + "version": "5.19.1", + "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-5.19.1.tgz", + "integrity": "sha512-VLnzMQq7CWroL5AeaW0Py2huiNKeoMfCH3SUxstdzPrlWQi6UQ9UrfcbUkNHlVFqOMacqy8X/8YtE0kuKDpD9w==", + "dependencies": { + "@opentelemetry/api": "^1.8", + "@opentelemetry/instrumentation": "^0.49 || ^0.50 || ^0.51 || ^0.52.0", + "@opentelemetry/sdk-trace-base": "^1.22" + } + }, + "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/api-logs": { + "version": "0.52.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.52.1.tgz", + "integrity": "sha512-qnSqB2DQ9TPP96dl8cDubDvrUyWc0/sK81xHTK8eSUspzDM3bsewX903qclQFvVhgStjRWdC5bLb3kQqMkfV5A==", + "dependencies": { + "@opentelemetry/api": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/instrumentation": { + "version": "0.52.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.52.1.tgz", + "integrity": "sha512-uXJbYU/5/MBHjMp1FqrILLRuiJCs3Ofk0MeRDk8g1S1gD47U8X3JnSwcMO1rtRo1x1a7zKaQHaoYu49p/4eSKw==", + "dependencies": { + "@opentelemetry/api-logs": "0.52.1", + "@types/shimmer": "^1.0.2", + "import-in-the-middle": "^1.8.1", + "require-in-the-middle": "^7.1.1", + "semver": "^7.5.2", + "shimmer": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@sentry-internal/browser-utils": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.35.0.tgz", + "integrity": "sha512-uj9nwERm7HIS13f/Q52hF/NUS5Al8Ma6jkgpfYGeppYvU0uSjPkwMogtqoJQNbOoZg973tV8qUScbcWY616wNA==", + "dependencies": { + "@sentry/core": "8.35.0", + "@sentry/types": "8.35.0", + "@sentry/utils": "8.35.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry-internal/feedback": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.35.0.tgz", + "integrity": "sha512-7bjSaUhL0bDArozre6EiIhhdWdT/1AWNWBC1Wc5w1IxEi5xF7nvF/FfvjQYrONQzZAI3HRxc45J2qhLUzHBmoQ==", + "dependencies": { + "@sentry/core": "8.35.0", + "@sentry/types": "8.35.0", + "@sentry/utils": "8.35.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry-internal/replay": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.35.0.tgz", + "integrity": "sha512-3wkW03vXYMyWtTLxl9yrtkV+qxbnKFgfASdoGWhXzfLjycgT6o4/04eb3Gn71q9aXqRwH17ISVQbVswnRqMcmA==", + "dependencies": { + "@sentry-internal/browser-utils": "8.35.0", + "@sentry/core": "8.35.0", + "@sentry/types": "8.35.0", + "@sentry/utils": "8.35.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry-internal/replay-canvas": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.35.0.tgz", + "integrity": "sha512-TUrH6Piv19kvHIiRyIuapLdnuwxk/Un/l1WDCQfq7mK9p1Pac0FkQ7Uufjp6zY3lyhDDZQ8qvCS4ioCMibCwQg==", + "dependencies": { + "@sentry-internal/replay": "8.35.0", + "@sentry/core": "8.35.0", + "@sentry/types": "8.35.0", + "@sentry/utils": "8.35.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry/browser": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.35.0.tgz", + "integrity": "sha512-WHfI+NoZzpCsmIvtr6ChOe7yWPLQyMchPnVhY3Z4UeC70bkYNdKcoj/4XZbX3m0D8+71JAsm0mJ9s9OC3Ue6MQ==", + "dependencies": { + "@sentry-internal/browser-utils": "8.35.0", + "@sentry-internal/feedback": "8.35.0", + "@sentry-internal/replay": "8.35.0", + "@sentry-internal/replay-canvas": "8.35.0", + "@sentry/core": "8.35.0", + "@sentry/types": "8.35.0", + "@sentry/utils": "8.35.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry/core": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.35.0.tgz", + "integrity": "sha512-Ci0Nmtw5ETWLqQJGY4dyF+iWh7PWKy6k303fCEoEmqj2czDrKJCp7yHBNV0XYbo00prj2ZTbCr6I7albYiyONA==", + "dependencies": { + "@sentry/types": "8.35.0", + "@sentry/utils": "8.35.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry/electron": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@sentry/electron/-/electron-5.7.0.tgz", + "integrity": "sha512-3Zq9TB9gWoZOIjPXD2msbEb8itS1Tu/+Dr47tweSuhbQDG/vVsVxOyKBqnt+xNdqjgbf/F2avfLwSjk0fa+gsg==", + "dependencies": { + "@sentry/browser": "8.35.0", + "@sentry/core": "8.35.0", + "@sentry/node": "8.35.0", + "@sentry/types": "8.35.0", + "@sentry/utils": "8.35.0", + "deepmerge": "4.3.1" + } + }, + "node_modules/@sentry/node": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-8.35.0.tgz", + "integrity": "sha512-B0FLOcZEfYe3CJ2t0l1N0HJcHXcIrLlGENQ2kf5HqR2zcOcOzRxyITJTSV5brCnmzVNgkz9PG8VWo3w0HXZQpA==", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.25.1", + "@opentelemetry/core": "^1.25.1", + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/instrumentation-amqplib": "^0.42.0", + "@opentelemetry/instrumentation-connect": "0.39.0", + "@opentelemetry/instrumentation-dataloader": "0.12.0", + "@opentelemetry/instrumentation-express": "0.43.0", + "@opentelemetry/instrumentation-fastify": "0.40.0", + "@opentelemetry/instrumentation-fs": "0.15.0", + "@opentelemetry/instrumentation-generic-pool": "0.39.0", + "@opentelemetry/instrumentation-graphql": "0.43.0", + "@opentelemetry/instrumentation-hapi": "0.41.0", + "@opentelemetry/instrumentation-http": "0.53.0", + "@opentelemetry/instrumentation-ioredis": "0.43.0", + "@opentelemetry/instrumentation-kafkajs": "0.3.0", + "@opentelemetry/instrumentation-koa": "0.43.0", + "@opentelemetry/instrumentation-lru-memoizer": "0.40.0", + "@opentelemetry/instrumentation-mongodb": "0.47.0", + "@opentelemetry/instrumentation-mongoose": "0.42.0", + "@opentelemetry/instrumentation-mysql": "0.41.0", + "@opentelemetry/instrumentation-mysql2": "0.41.0", + "@opentelemetry/instrumentation-nestjs-core": "0.40.0", + "@opentelemetry/instrumentation-pg": "0.44.0", + "@opentelemetry/instrumentation-redis-4": "0.42.0", + "@opentelemetry/instrumentation-undici": "0.6.0", + "@opentelemetry/resources": "^1.26.0", + "@opentelemetry/sdk-trace-base": "^1.26.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@prisma/instrumentation": "5.19.1", + "@sentry/core": "8.35.0", + "@sentry/opentelemetry": "8.35.0", + "@sentry/types": "8.35.0", + "@sentry/utils": "8.35.0", + "import-in-the-middle": "^1.11.2" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry/opentelemetry": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-8.35.0.tgz", + "integrity": "sha512-2mWMpEiIFop/omia9BqTJa+0Khe+tSsiZSUrxbnSpxM0zgw8DFIzJMHbiqw/I7Qaluz9pnO2HZXqgUTwNPsU8A==", + "dependencies": { + "@sentry/core": "8.35.0", + "@sentry/types": "8.35.0", + "@sentry/utils": "8.35.0" + }, + "engines": { + "node": ">=14.18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.25.1", + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/sdk-trace-base": "^1.26.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + } + }, + "node_modules/@sentry/types": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-8.35.0.tgz", + "integrity": "sha512-AVEZjb16MlYPifiDDvJ19dPQyDn0jlrtC1PHs6ZKO+Rzyz+2EX2BRdszvanqArldexPoU1p5Bn2w81XZNXThBA==", + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry/utils": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-8.35.0.tgz", + "integrity": "sha512-MdMb6+uXjqND7qIPWhulubpSeHzia6HtxeJa8jYI09OCvIcmNGPydv/Gx/LZBwosfMHrLdTWcFH7Y7aCxrq7cg==", + "dependencies": { + "@sentry/types": "8.35.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/connect": { + "version": "3.4.36", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.36.tgz", + "integrity": "sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dev": true, + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "dev": true + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "0.7.34", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", + "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==", + "dev": true + }, + "node_modules/@types/mysql": { + "version": "2.15.26", + "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.26.tgz", + "integrity": "sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.9.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz", + "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==", + "dependencies": { + "undici-types": "~6.19.8" + } + }, + "node_modules/@types/pg": { + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.6.1.tgz", + "integrity": "sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w==", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/pg-pool": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.6.tgz", + "integrity": "sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==", + "dependencies": { + "@types/pg": "*" + } + }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "optional": true, + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/shimmer": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.2.0.tgz", + "integrity": "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==" + }, + "node_modules/@types/verror": { + "version": "1.10.10", + "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.10.tgz", + "integrity": "sha512-l4MM0Jppn18hb9xmM6wwD1uTdShpf9Pn80aXTStnK1C94gtPvJcV2FrDmbOQUAQfJ1cKZHktkQUDwEqaAKXMMg==", + "dev": true, + "optional": true + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", + "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/7zip-bin": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", + "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "dev": true + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agentkeepalive": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz", + "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==", + "dev": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/app-builder-bin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-4.0.0.tgz", + "integrity": "sha512-xwdG0FJPQMe0M0UA4Tz0zEB8rBJTRA5a476ZawAqiBkMv16GRK5xpXThOjMaEOFnZ6zabejjG4J3da0SXG63KA==", + "dev": true + }, + "node_modules/app-builder-lib": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-24.13.3.tgz", + "integrity": "sha512-FAzX6IBit2POXYGnTCT8YHFO/lr5AapAII6zzhQO3Rw4cEDOgK+t1xhLc5tNcKlicTHlo9zxIwnYCX9X2DLkig==", + "dev": true, + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/notarize": "2.2.1", + "@electron/osx-sign": "1.0.5", + "@electron/universal": "1.5.1", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "bluebird-lst": "^1.0.9", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "chromium-pickle-js": "^0.2.0", + "debug": "^4.3.4", + "ejs": "^3.1.8", + "electron-publish": "24.13.1", + "form-data": "^4.0.0", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "is-ci": "^3.0.0", + "isbinaryfile": "^5.0.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "minimatch": "^5.1.1", + "read-config-file": "6.3.2", + "sanitize-filename": "^1.6.3", + "semver": "^7.3.8", + "tar": "^6.1.12", + "temp-file": "^3.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "24.13.3", + "electron-builder-squirrel-windows": "24.13.3" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "dev": true, + "peer": true, + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "dev": true, + "peer": true, + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "peer": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "peer": true + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/atomically": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.0.3.tgz", + "integrity": "sha512-kU6FmrwZ3Lx7/7y3hPS5QnbJfaohcIul5fGqf7ok+4KklIEk9tJ0C2IQPdacSbVUWv6zVHXEBWoWd6NrVMT7Cw==", + "dependencies": { + "stubborn-fs": "^1.2.5", + "when-exit": "^2.1.1" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", + "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.23.3", + "caniuse-lite": "^1.0.30001646", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true + }, + "node_modules/bluebird-lst": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/bluebird-lst/-/bluebird-lst-1.0.9.tgz", + "integrity": "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==", + "dev": true, + "dependencies": { + "bluebird": "^3.5.5" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "optional": true + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", + "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001669", + "electron-to-chromium": "^1.5.41", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.1.tgz", + "integrity": "sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg==", + "dev": true, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "node_modules/builder-util": { + "version": "24.13.1", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-24.13.1.tgz", + "integrity": "sha512-NhbCSIntruNDTOVI9fdXz0dihaqX2YuE1D6zZMrwiErzH4ELZHE6mdiB40wEgZNprDia+FghRFgKoAqMZRRjSA==", + "dev": true, + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "4.0.0", + "bluebird-lst": "^1.0.9", + "builder-util-runtime": "9.2.4", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.3", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-ci": "^3.0.0", + "js-yaml": "^4.1.0", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.4.tgz", + "integrity": "sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA==", + "dev": true, + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/builder-util/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacache": { + "version": "16.1.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", + "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==", + "dev": true, + "dependencies": { + "@npmcli/fs": "^2.1.0", + "@npmcli/move-file": "^2.0.0", + "chownr": "^2.0.0", + "fs-minipass": "^2.1.0", + "glob": "^8.0.1", + "infer-owner": "^1.0.4", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "mkdirp": "^1.0.4", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^9.0.0", + "tar": "^6.1.11", + "unique-filename": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001680", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001680.tgz", + "integrity": "sha512-rPQy70G6AGUMnbwS1z6Xg+RkHYPAi18ihs47GH0jcxIG7wArmPgY3XbS2sRdBbxJljp3thdT8BIqv9ccCypiPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz", + "integrity": "sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA==" + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "optional": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "dev": true, + "peer": true, + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/concurrently": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz", + "integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "date-fns": "^2.30.0", + "lodash": "^4.17.21", + "rxjs": "^7.8.1", + "shell-quote": "^1.8.1", + "spawn-command": "0.0.2", + "supports-color": "^8.1.1", + "tree-kill": "^1.2.2", + "yargs": "^17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": "^14.13.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/conf": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/conf/-/conf-13.0.1.tgz", + "integrity": "sha512-l9Uwc9eOnz39oADzGO2cSBDi7siv8lwO+31ocQ2nOJijnDiW3pxqm9VV10DPYUO28wW83DjABoUqY1nfHRR2hQ==", + "dependencies": { + "ajv": "^8.16.0", + "ajv-formats": "^3.0.1", + "atomically": "^2.0.3", + "debounce-fn": "^6.0.0", + "dot-prop": "^9.0.0", + "env-paths": "^3.0.0", + "json-schema-typed": "^8.0.1", + "semver": "^7.6.2", + "uint8array-extras": "^1.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/conf/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/conf/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/conf/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/config-file-ts": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.6.tgz", + "integrity": "sha512-6boGVaglwblBgJqGyxm4+xCmEGcWgnWHSWHY5jad58awQhB6gftq0G8HbzU39YqCIYHMLAiL1yjwiZ36m/CL8w==", + "dev": true, + "dependencies": { + "glob": "^10.3.10", + "typescript": "^5.3.3" + } + }, + "node_modules/config-file-ts/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true + }, + "node_modules/crc": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", + "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "dev": true, + "optional": true, + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "peer": true, + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "dev": true, + "peer": true, + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz", + "integrity": "sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "node_modules/debounce-fn": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz", + "integrity": "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "optional": true + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "node_modules/dir-compare": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-3.3.0.tgz", + "integrity": "sha512-J7/et3WlGUCxjdnD3HAAzQ6nsnc0WL6DD7WcwJb7c39iH1+AWfg+9OqzJNaI6PkBwBvm1mhZNL9iY/nRiZXlPg==", + "dev": true, + "dependencies": { + "buffer-equal": "^1.0.0", + "minimatch": "^3.0.4" + } + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "node_modules/dmg-builder": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-24.13.3.tgz", + "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", + "dev": true, + "dependencies": { + "app-builder-lib": "24.13.3", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "fs-extra": "^10.1.0", + "iconv-lite": "^0.6.2", + "js-yaml": "^4.1.0" + }, + "optionalDependencies": { + "dmg-license": "^1.0.11" + } + }, + "node_modules/dmg-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dmg-license": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", + "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@types/plist": "^3.0.1", + "@types/verror": "^1.10.3", + "ajv": "^6.10.0", + "crc": "^3.8.0", + "iconv-corefoundation": "^1.1.7", + "plist": "^3.0.4", + "smart-buffer": "^4.0.2", + "verror": "^1.10.0" + }, + "bin": { + "dmg-license": "bin/dmg-license.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dot-prop": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", + "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", + "dependencies": { + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dot-prop/node_modules/type-fest": { + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.26.1.tgz", + "integrity": "sha512-yOGpmOAL7CkKe/91I5O3gPICmJNLJ1G4zFYVAsRHg7M64biSnPtRj0WNQt++bRkjYOqjWXrhnUw1utzmVErAdg==", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", + "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==", + "dev": true + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "31.7.3", + "resolved": "https://registry.npmjs.org/electron/-/electron-31.7.3.tgz", + "integrity": "sha512-pZ3ChhQL5THjagmhVqgb2dsePDIyUKSalv8bARziTSTk1pLbtJRja0OLIuuHktl387vRcDJZ0x4YWzI52x/TlQ==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^20.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-builder": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-24.13.3.tgz", + "integrity": "sha512-yZSgVHft5dNVlo31qmJAe4BVKQfFdwpRw7sFp1iQglDRCDD6r22zfRJuZlhtB5gp9FHUxCMEoWGq10SkCnMAIg==", + "dev": true, + "dependencies": { + "app-builder-lib": "24.13.3", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "chalk": "^4.1.2", + "dmg-builder": "24.13.3", + "fs-extra": "^10.1.0", + "is-ci": "^3.0.0", + "lazy-val": "^1.0.5", + "read-config-file": "6.3.2", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-24.13.3.tgz", + "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", + "dev": true, + "peer": true, + "dependencies": { + "app-builder-lib": "24.13.3", + "archiver": "^5.3.1", + "builder-util": "24.13.1", + "fs-extra": "^10.1.0" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-notarize": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/electron-notarize/-/electron-notarize-1.2.2.tgz", + "integrity": "sha512-ZStVWYcWI7g87/PgjPJSIIhwQXOaw4/XeXU+pWqMMktSLHaGMLHdyPPN7Cmao7+Cr7fYufA16npdtMndYciHNw==", + "deprecated": "Please use @electron/notarize moving forward. There is no API change, just a package name change", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-publish": { + "version": "24.13.1", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-24.13.1.tgz", + "integrity": "sha512-2ZgdEqJ8e9D17Hwp5LEq5mLQPjqU3lv/IALvgp+4W8VeNhryfGhYEQC/PgDPMrnWUp+l60Ou5SJLsu+k4mhQ8A==", + "dev": true, + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "chalk": "^4.1.2", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-publish/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-reload": { + "version": "2.0.0-alpha.1", + "resolved": "https://registry.npmjs.org/electron-reload/-/electron-reload-2.0.0-alpha.1.tgz", + "integrity": "sha512-hTde7gv0TEqxbxlB3pj2CwoyCQ9sdiQrcP8GkpzhosxyVeYM3mZbMEVKCZK3L0fED7Mz5A9IWmK7zEvi4H3P1g==", + "dev": true, + "dependencies": { + "chokidar": "^3.5.2" + } + }, + "node_modules/electron-squirrel-startup": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/electron-squirrel-startup/-/electron-squirrel-startup-1.0.1.tgz", + "integrity": "sha512-sTfFIHGku+7PsHLJ7v0dRcZNkALrV+YEozINTW8X1nM//e5O3L+rfYuvSW00lmGHnYmUjARZulD8F2V8ISI9RA==", + "dependencies": { + "debug": "^2.2.0" + } + }, + "node_modules/electron-squirrel-startup/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/electron-squirrel-startup/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/electron-store": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/electron-store/-/electron-store-10.0.0.tgz", + "integrity": "sha512-BU/QZh+5twHBprRdLu3YZX/rIarmZzhTNpJvAvqG1/yN0mNCrsMh0kl7bM4xaUKDNRiHz1r7wP/7Prjh7cleIw==", + "dependencies": { + "conf": "^13.0.0", + "type-fest": "^4.20.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/electron-store/node_modules/type-fest": { + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.26.1.tgz", + "integrity": "sha512-yOGpmOAL7CkKe/91I5O3gPICmJNLJ1G4zFYVAsRHg7M64biSnPtRj0WNQt++bRkjYOqjWXrhnUw1utzmVErAdg==", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.57", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.57.tgz", + "integrity": "sha512-xS65H/tqgOwUBa5UmOuNSLuslDo7zho0y/lgQw35pnrqiZh7UOWHCeL/Bt6noJATbA6tpQJGCifsFsIRZj1Fqg==", + "dev": true + }, + "node_modules/electron/node_modules/@types/node": { + "version": "20.17.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.6.tgz", + "integrity": "sha512-VEI7OdvK2wP7XHnsuXbAJnEpEkF6NjSN45QJlL4VGqZSXsnicpesdTWsg9RISeSdYd3yeRj/y3k5KGjUXYnFwQ==", + "dev": true, + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "optional": true + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz", + "integrity": "sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==", + "dev": true + }, + "node_modules/express": { + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", + "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.10", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "optional": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-uri": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", + "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==" + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dev": true, + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "peer": true + }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "dev": true + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dev": true, + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-corefoundation": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", + "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "cli-truncate": "^2.1.0", + "node-addon-api": "^1.6.3" + }, + "engines": { + "node": "^8.11.2 || >=10" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-in-the-middle": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.11.2.tgz", + "integrity": "sha512-gK6Rr6EykBcc6cVWRSBR5TWf8nn6hZMYSRYqCcHa0l0d1fPK7JSYo6+Mlmck76jIX9aL/IZ71c06U2VpFwl1zA==", + "dependencies": { + "acorn": "^8.8.2", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^1.2.2", + "module-details-from-path": "^1.0.3" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "dev": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "dev": true, + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-ci": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", + "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", + "dev": true, + "dependencies": { + "ci-info": "^3.2.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-core-module": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", + "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "dev": true + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "peer": true + }, + "node_modules/isbinaryfile": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.4.tgz", + "integrity": "sha512-YKBKVkKhty7s8rxddb40oOkuP0NbaeXrQvLin6QMHL7Ypiy2RW9LwOVrVgZRyOrhQlayMd9t+D8yDy8MKFTSDQ==", + "dev": true, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", + "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", + "dev": true, + "dependencies": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.4", + "minimatch": "^3.1.2" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jake/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/jake/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/jiti": { + "version": "1.21.6", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", + "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "dev": true + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-schema-typed": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.1.tgz", + "integrity": "sha512-XQmWYj2Sm4kn4WeTYvmpKEbyPsL7nBsb647c7pMe6l02/yx2+Jfc4dT6UZkEXnIUb5LhD55r2HPsJ1milQ4rDg==" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "peer": true, + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "peer": true + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "dev": true, + "peer": true + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "dev": true, + "peer": true + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "dev": true, + "peer": true + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "peer": true + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "dev": true, + "peer": true + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-fetch-happen": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", + "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", + "dev": true, + "dependencies": { + "agentkeepalive": "^4.2.1", + "cacache": "^16.1.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^2.0.3", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^9.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-2.1.2.tgz", + "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==", + "dev": true, + "dependencies": { + "minipass": "^3.1.6", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/module-details-from-path": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.3.tgz", + "integrity": "sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.71.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.71.0.tgz", + "integrity": "sha512-SZ40vRiy/+wRTf21hxkkEjPJZpARzUMVcJoQse2EF8qkUWbbO2z7vd5oA/H6bVH6SZQ5STGcu0KRDS7biNRfxw==", + "dev": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "dev": true, + "optional": true + }, + "node_modules/node-api-version": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.0.tgz", + "integrity": "sha512-fthTTsi8CxaBXMaBAD7ST2uylwvsnYxh2PfaScwpMhos6KlSFajXQPcM4ogNE1q2s3Lbz9GCGqeIHC+C6OZnKg==", + "dev": true, + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-releases": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "dev": true + }, + "node_modules/nopt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", + "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", + "dev": true, + "dependencies": { + "abbrev": "^1.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onchange": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/onchange/-/onchange-7.1.0.tgz", + "integrity": "sha512-ZJcqsPiWUAUpvmnJri5TPBooqJOPmC0ttN65juhN15Q8xA+Nbg3BaxBHXQ45EistKKlKElb0edmbPWnKSBkvMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@blakeembrey/deque": "^1.0.5", + "@blakeembrey/template": "^1.0.0", + "arg": "^4.1.3", + "chokidar": "^3.3.1", + "cross-spawn": "^7.0.1", + "ignore": "^5.1.4", + "tree-kill": "^1.2.2" + }, + "bin": { + "onchange": "dist/bin.js" + } + }, + "node_modules/onchange/node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz", + "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/postcss": { + "version": "8.4.49", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", + "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/lilconfig": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", + "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proc-log": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-2.0.1.tgz", + "integrity": "sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw==", + "dev": true, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "peer": true + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/pump": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "dev": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/read-config-file": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/read-config-file/-/read-config-file-6.3.2.tgz", + "integrity": "sha512-M80lpCjnE6Wt6zb98DoW8WHR09nzMSpu8XHtPkiTHrJ5Az9CybfeQhTJ8D7saeBHpGhLPIVyA8lcL6ZmdKwY6Q==", + "dev": true, + "dependencies": { + "config-file-ts": "^0.2.4", + "dotenv": "^9.0.2", + "dotenv-expand": "^5.1.0", + "js-yaml": "^4.1.0", + "json5": "^2.2.0", + "lazy-val": "^1.0.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/read-config-file/node_modules/dotenv": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz", + "integrity": "sha512-I9OvvrHp4pIARv4+x9iuewrWycX6CcZtoAu1XrzPxc5UygMJXJZYmBsynku8IkrJwgypE5DGNjDPmPRhDCptUg==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "peer": true, + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-in-the-middle": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.4.0.tgz", + "integrity": "sha512-X34iHADNbNDfr6OTStIAHWSAvvKQRYgLO6duASaVf7J2VA3lvmNYboAHOuLC2huav1IwgZJtyEcJCKVzFxOSMQ==", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3", + "resolve": "^1.22.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/sanitize-filename": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", + "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "dev": true, + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "dev": true + }, + "node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "optional": true + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", + "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/shimmer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", + "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", + "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", + "dev": true, + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", + "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", + "dev": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spawn-command": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz", + "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==", + "dev": true + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true + }, + "node_modules/ssri": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", + "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==", + "dev": true, + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stubborn-fs": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-1.2.5.tgz", + "integrity": "sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==" + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.14.tgz", + "integrity": "sha512-IcSvOcTRcUtQQ7ILQL5quRDg7Xs93PdJEk1ZLbhhvJc7uj/OAhYOnruEiwnGgBvUtaUAJ8/mhSw1o8L2jCiENA==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.0", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.0", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "peer": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/temp-file/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tmp": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", + "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", + "dev": true, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uint8array-extras": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.4.0.tgz", + "integrity": "sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" + }, + "node_modules/unique-filename": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", + "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==", + "dev": true, + "dependencies": { + "unique-slug": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/unique-slug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-3.0.0.tgz", + "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", + "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/when-exit": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.3.tgz", + "integrity": "sha512-uVieSTccFIr/SFQdFWN/fFaQYmV37OKtuaGphMAzi4DmmUlrvRBJW5WSLkHyjNQY/ePJMz3LoiX9R3yy1Su6Hw==" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/yaml": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", + "integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==", + "dev": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "dev": true, + "peer": true, + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "dev": true, + "peer": true, + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/zip-stream/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "peer": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/zip-stream/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + } + } +} diff --git a/electron/package.json b/electron/package.json new file mode 100644 index 0000000..25c3296 --- /dev/null +++ b/electron/package.json @@ -0,0 +1,73 @@ +{ + "name": "specif_ai", + "version": "1.9.5", + "description": "AI-powered requirements management and specification platform", + "productName": "Specif AI", + "main": "app.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "postinstall": "electron-builder install-app-deps", + "package:mac": "electron-builder build -m --arm64 --x64", + "package:win": "electron-builder build -w --x64 --ia32", + "serve:electron": "electron .", + "build:ui": "rm -rf ui && cd ../ui && npm run build:dev && cp -r ./dist/ui ../electron/ui/", + "build-watch:ui": "rm -rf ui && mkdir ui && cd ../ui && npm run watch:dev", + "watch:ui": "concurrently \"npm run build-watch:ui\" \"onchange '../ui/dist/**/*' -- cp -r ../ui/dist/* ./\"" + }, + "engines": { + "node": ">=18.17" + }, + "author": "presidio", + "license": "ISC", + "devDependencies": { + "@electron/fuses": "^1.8.0", + "@electron/rebuild": "^3.6.0", + "autoprefixer": "^10.4.20", + "electron": "^31.2.1", + "electron-builder": "^24.13.3", + "electron-reload": "^2.0.0-alpha.1", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.14", + "concurrently": "^8.2.2", + "onchange": "^7.1.0" + }, + "dependencies": { + "@sentry/electron": "^5.3.0", + "axios": "^1.7.7", + "dotenv": "^16.4.5", + "electron-notarize": "^1.2.2", + "electron-squirrel-startup": "^1.0.1", + "electron-store": "^10.0.0", + "express": "^4.21.0" + }, + "build": { + "files": "!build-assets${/*}", + "appId": "", + "extraResources": [ + ".env", + "ui" + ], + "mac": { + "forceCodeSigning": true, + "category": "public.app-category.utilities", + "icon": "assets/icons/hai_icon.icns", + "gatekeeperAssess": false, + "notarize": { + "teamId": "9NENB68LF9" + }, + "hardenedRuntime": true, + "target": "dmg", + "entitlements": "build-assets/entitlements.mac.plist", + "entitlementsInherit": "build-assets/entitlements.mac.plist" + }, + "dmg": { + "sign": false + }, + "win": { + "icon": "assets/icons/hai_icon.ico" + }, + "linux": { + "icon": "assets/icons/hai_icon.png" + } + } +} diff --git a/electron/preload.js b/electron/preload.js new file mode 100644 index 0000000..e9e6ea6 --- /dev/null +++ b/electron/preload.js @@ -0,0 +1,17 @@ +const { contextBridge, ipcRenderer } = require("electron"); + +contextBridge.exposeInMainWorld("electronAPI", { + openFile: () => ipcRenderer.invoke("dialog:openFile"), + saveFile: (fileContent, filePath) => + ipcRenderer.invoke("dialog:saveFile", fileContent, filePath), + openDirectory: () => ipcRenderer.invoke("dialog:openDirectory"), + getStoreValue: (key) => ipcRenderer.invoke("store-get", "APP_CONFIG"), + setStoreValue: (key, value) => ipcRenderer.invoke("store-set", key, value), + loadURL: (serverConfig) => ipcRenderer.send("load-url", serverConfig), + invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), + send: (channel, ...args) => ipcRenderer.send(channel, ...args), + on: (channel, listener) => ipcRenderer.on(channel, listener), + once: (channel, listener) => ipcRenderer.once(channel, listener), + removeListener: (channel, listener) => + ipcRenderer.removeListener(channel, listener), +}); diff --git a/electron/sentry.env b/electron/sentry.env new file mode 100644 index 0000000..849394f --- /dev/null +++ b/electron/sentry.env @@ -0,0 +1,4 @@ +ENABLE_SENTRY=false +SENTRY_DSN= +SENTRY_ENVIRONMENT=development +SENTRY_RELEASE= diff --git a/ui/.editorconfig b/ui/.editorconfig new file mode 100644 index 0000000..59d9a3a --- /dev/null +++ b/ui/.editorconfig @@ -0,0 +1,16 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000..6afeea4 --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,47 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history/* + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings + +# System files +.DS_Store +Thumbs.db +package-lock.json +release + +# Sentry Config File +.sentryclirc diff --git a/ui/.husky/pre-commit b/ui/.husky/pre-commit new file mode 100644 index 0000000..2312dc5 --- /dev/null +++ b/ui/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/ui/.prettierignore b/ui/.prettierignore new file mode 100644 index 0000000..e69de29 diff --git a/ui/.prettierrc b/ui/.prettierrc new file mode 100644 index 0000000..e69de29 diff --git a/ui/JIRA-README.md b/ui/JIRA-README.md new file mode 100644 index 0000000..b1647d3 --- /dev/null +++ b/ui/JIRA-README.md @@ -0,0 +1,81 @@ +# Jira OAuth 2.0 Integration Guide for Specif AI + +This guide explains how to integrate the Specif AI solution with Jira using OAuth 2.0. Follow these steps to create an OAuth app, configure permissions, and set up the integration. + +## Step 1: Create an OAuth 2.0 Integration + +1. **Access the Atlassian Developer Console:** + - Open a web browser and navigate to the [Atlassian Developer Console](https://developer.atlassian.com/console/myapps/). + - Sign up with Atlassian credentials if you haven't created an account yet. + +2. **Create a New OAuth App:** + - Log in and click the **Create New App** button. + - Select **OAuth 2.0 Integration** from the options. + - Enter a name for the app, such as "Specif AI Integration." + - Click **Create App** to proceed. + +3. **Navigate to the App Configuration:** + - After creating the app, access the configuration page for the new OAuth app. + +## Step 2: Configure Permissions for the App + +1. **Access the Permissions Tab:** + - Open the **Permissions** tab in the app's configuration screen to define the APIs accessible by the app. + +2. **Add the Required APIs:** + - Under the Permissions section, click **Add APIs** and select the following: + - **User Identity API**: Identifies users interacting with the app. + - **Jira API**: Grants access to Jira. + +3. **Configure Classic Scopes for Jira API:** + - After adding the Jira API, click **Edit Scopes** under the Jira API section to set the level of access. + - Add the following scopes: + - `read:jira-work`: Allows reading Jira project and issue information. + - `read:jira-user`: Enables reading Jira user information. + - `write:jira-work`: Provides write access to Jira issues, such as creating or updating tasks. + - Save the changes. + +## Step 3: Configure Authorization + +1. **Go to the Authorization Tab:** + - Open the **Authorization** tab in the app configuration screen. + +2. **Select OAuth 2.0 Authorization:** + - Choose **OAuth 2.0** as the authorization type to enable OAuth-based authentication between Jira and the Specif AI. + +3. **Set the Callback URL:** + - Enter the **Callback URL** for the Requirements app. This URL is where users will be redirected after authorizing the app. + - Expected default value : `http://localhost:49153/callback` + > **Note**: If you modify the port value, replace it with your app's actual callback URL, updating the port accordingly. + - Click **Save** to apply the configuration. + +## Step 4: Retrieve OAuth Credentials + +1. **Go to the Settings Tab:** + - Open the **Settings** tab in the app configuration screen. + +2. **Copy the Client ID and Client Secret:** + - Locate the **Client ID** and **Client Secret**: + - **Client ID**: Serves as a unique identifier for the app. + - **Client Secret**: Used for authentication. Store it securely. + - Keep both credentials safe, as they are required for OAuth authentication in the Requirements app. + +## Step 5: Set Up the Integration in the Electron App + +1. **Open the Solution Page:** + - Launch the Requirements app and navigate to the **Solution** -> **Integration** page. + +2. **Enter the OAuth Credentials:** + - Provide the following information in the Jira Integration section: + - **Client ID**: Enter the unique identifier from the Atlassian Developer Console. + - **Client Secret**: Enter the secret from the console. + - **Project Key**: Specify the Jira project for integration. The key can be found on the Jira project settings page. + - **Callback URL**: Enter the URL specified during the authorization setup. + +3. **Verify the Details:** + - Ensure that the **Client ID**, **Client Secret**, and **Callback URL** match the information entered in the Atlassian Developer Console. + - Confirm that the **Project Key** is correct, as it identifies the Jira project that the app will interact with. + +--- + +By following these steps, you can successfully integrate the Requirements app with Jira using OAuth 2.0. diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000..ba26d24 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,63 @@ +# 🎨 Specif AI - Angular UI + +The modern, responsive frontend application for Specif AI, built with Angular and integrated with Electron for desktop deployment. + +## Table of Contents +- [Features](#features) +- [Technical Stack](#technical-stack) +- [Development Setup](#development-setup) +- [Screenshots](#screenshots) +- [Contributing](#contributing) +- [Troubleshooting](#troubleshooting) + +## ✨ Features + +- πŸ“ Document Generation Interface. +- πŸ’¬ AI-Powered Chat Interface. +- πŸ“Š Business Process Flow Visualization. +- πŸ”„ Real-time Updates. + +## πŸ›  Technical Stack + +- **Framework**: Angular 16.2.1 - Chosen for its robust features, scalability, and ease of integration with Electron. + +## πŸ’» Development Setup + +### Prerequisites + +- Node.js >= 20.x +- npm >= 9.6.7 +- Angular CLI + +### Installation + +To install the necessary dependencies, run the following command: + +```bash +# Install dependencies +npm install +``` +This command installs all the required Node.js packages listed in the `package.json` file. + +### Setup Environment + +Environments are managed in the `src/environments/<filename>.ts` directory. You can create multiple environment files for different stages as required. + +### UI Build + +As this is an Electron application built with Angular, the UI can be directly built from the `electron` directory and then executed as an Electron app. + +Please refer to the [Electron Desktop Application Setup](../electron/README.md) for detailed instructions on building and running the application. + +## 🀝 Contributing + +Please read our [Contributing Guidelines](../CONTRIBUTING.md) for details on submitting patches and the contribution workflow. + +## Troubleshooting + +- **Issue**: UI does not render correctly. + - **Solution**: Ensure all dependencies are installed and the environment is correctly set up. +- **Issue**: Build errors occur. + - **Solution**: Verify that the Angular CLI version and Node.js version match the prerequisites. +- **Issue**: Environment configuration errors occur. + - **Solution**: Ensure that the environment files in `src/environments/` are correctly configured. diff --git a/ui/angular.json b/ui/angular.json new file mode 100644 index 0000000..c6b9c90 --- /dev/null +++ b/ui/angular.json @@ -0,0 +1,149 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "ui": { + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "style": "scss" + } + }, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/ui", + "index": "src/index.html", + "main": "src/main.ts", + "polyfills": [ + "zone.js" + ], + "tsConfig": "tsconfig.app.json", + "inlineStyleLanguage": "scss", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.scss" + ], + "scripts": [], + "allowedCommonJsDependencies": [ + "@braintree/sanitize-url", + "dayjs", + "dompurify", + "debug", + "html2canvas", + "file-saver", + "panzoom" + ] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "2mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kb", + "maximumError": "4kb" + } + ], + "outputHashing": "all", + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ] + }, + "dev": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "2mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kb", + "maximumError": "4kb" + } + ], + "outputHashing": "all", + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.development.ts" + } + ] + }, + "development": { + "buildOptimizer": false, + "optimization": false, + "vendorChunk": true, + "extractLicenses": false, + "sourceMap": true, + "namedChunks": true, + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.development.ts" + } + ] + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "browserTarget": "ui:build:production" + }, + "development": { + "browserTarget": "ui:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "browserTarget": "ui:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "polyfills": [ + "zone.js", + "zone.js/testing" + ], + "tsConfig": "tsconfig.spec.json", + "inlineStyleLanguage": "scss", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.scss" + ], + "scripts": [] + } + } + } + } + }, + "cli": { + "analytics": "25bd0e8f-bac2-42b8-9d2f-70fb66be2cbb" + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..96e188f --- /dev/null +++ b/ui/package.json @@ -0,0 +1,94 @@ +{ + "name": "ui", + "version": "1.9.5", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "test": "ng test", + "build:prod": "ng build --configuration production", + "build:qa": "ng build --configuration qa", + "build:dev": "ng build --configuration dev --base-href=./", + "build:uat": "ng build --configuration uat", + "prepare": "husky", + "watch:dev": "ng build --configuration dev --watch --base-href=./" + }, + "engines": { + "node": ">=18.17" + }, + "lint-staged": { + "**/*": "prettier --write --ignore-unknown" + }, + "private": true, + "dependencies": { + "@angular/animations": "^16.2.0", + "@angular/cdk": "^16.2.0", + "@angular/common": "^16.2.0", + "@angular/compiler": "^16.2.0", + "@angular/core": "^16.2.0", + "@angular/forms": "^16.2.0", + "@angular/material": "^16.2.12", + "@angular/platform-browser": "^16.2.0", + "@angular/platform-browser-dynamic": "^16.2.0", + "@angular/router": "^16.2.0", + "@electron/remote": "^2.1.2", + "@ng-icons/core": "^28.1.0", + "@ng-icons/heroicons": "^28.1.0", + "@ngxs/devtools-plugin": "18.0.0", + "@ngxs/form-plugin": "18.0.0", + "@ngxs/hmr-plugin": "18.0.0", + "@ngxs/logger-plugin": "18.0.0", + "@ngxs/router-plugin": "18.0.0", + "@ngxs/storage-plugin": "18.0.0", + "@ngxs/store": "^18.0.0", + "@sentry/angular": "^8.26.0", + "@sentry/browser": "^8.26.0", + "@sentry/cli": "^2.33.1", + "@sentry/electron": "^5.3.0", + "@sentry/node": "^8.26.0", + "@sentry/replay": "^7.116.0", + "@sentry/tracing": "^7.114.0", + "dotenv": "^16.4.5", + "file-saver": "^2.0.5", + "html2canvas": "^1.4.1", + "mermaid": "^10.9.1", + "net": "^1.0.2", + "ng-angular-popup": "^0.6.1", + "ng-toasty": "^0.0.7", + "ngx-loading": "^16.0.0", + "ngx-modal-dialog": "^4.0.0", + "ngx-smart-modal": "^14.0.3", + "npm-run-all": "^4.1.5", + "panzoom": "^9.4.3", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "uuid": "^10.0.0", + "xlsx": "^0.18.5", + "zone.js": "~0.13.0" + }, + "devDependencies": { + "@angular-devkit/build-angular": "^16.2.1", + "@angular/cli": "~16.2.1", + "@angular/compiler-cli": "^16.2.0", + "@types/d3": "^7.4.3", + "@types/dompurify": "~3.0.5", + "@types/file-saver": "^2.0.7", + "@types/jasmine": "~4.3.0", + "@types/uuid": "^10.0.0", + "autoprefixer": "^10.4.19", + "concurrently": "^8.2.2", + "husky": "^9.0.11", + "jasmine-core": "~4.6.0", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.1.0", + "lint-staged": "^15.2.7", + "ngx-logger": "^5.0.12", + "postcss": "^8.4.39", + "prettier": "3.3.2", + "tailwindcss": "^3.4.4", + "typescript": "~5.1.3" + } +} diff --git a/ui/polyfills.ts b/ui/polyfills.ts new file mode 100644 index 0000000..1eb37ab --- /dev/null +++ b/ui/polyfills.ts @@ -0,0 +1,3 @@ +(window as any).global = window; +global.Buffer = global.Buffer || require('buffer').Buffer; +global.process = require('process'); diff --git a/ui/src/app/app-routing.module.ts b/ui/src/app/app-routing.module.ts new file mode 100644 index 0000000..91b7189 --- /dev/null +++ b/ui/src/app/app-routing.module.ts @@ -0,0 +1,124 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { AppInfoComponent } from './pages/app-info/app-info.component'; +import { AppsComponent } from './pages/apps/apps.component'; +import { CreateSolutionComponent } from './pages/create-solution/create-solution.component'; +import { EditSolutionComponent } from './pages/edit-solution/edit-solution.component'; +import { UserStoriesComponent } from './pages/user-stories/user-stories.component'; +import { AddTaskComponent } from './pages/tasks/add-task/add-task.component'; +import { EditUserStoriesComponent } from './pages/edit-user-stories/edit-user-stories.component'; +import { TaskListComponent } from './pages/tasks/task-list/task-list.component'; +import { BusinessProcessComponent } from './pages/business-process/business-process.component'; +import { AuthGuard } from './guards/auth.guard'; +import { BusinessProcessFlowComponent } from './pages/business-process-flow/business-process-flow.component'; +import { CanDeactivateGuard } from './guards/can-deactivate.guard'; +import { LoginComponent } from './pages/login/login.component'; + +const routes: Routes = [ + { + path: 'login', + component: LoginComponent, + }, + { + path: 'apps/create', + component: CreateSolutionComponent, + canActivate: [AuthGuard], + canDeactivate: [CanDeactivateGuard], + data: { + breadcrumb: { + name: 'Create Solution', + link: '/apps', + icon: 'add', + }, + }, + }, + { + path: 'apps/:id', + component: AppInfoComponent, + canActivate: [AuthGuard], + data: { + breadcrumb: { + link: '/apps', + icon: 'add', + }, + }, + }, + { path: 'apps', component: AppsComponent, canActivate: [AuthGuard] }, + { + path: 'user-stories/:prdId', + component: UserStoriesComponent, + canActivate: [AuthGuard], + }, + { + path: 'task-list/:userStoryId', + component: TaskListComponent, + canActivate: [AuthGuard], + }, + { + path: 'task/:mode/:userStoryId', + component: AddTaskComponent, + canActivate: [AuthGuard], + canDeactivate: [CanDeactivateGuard], + }, + { + path: 'task/:mode/:userStoryId/:taskId', + component: AddTaskComponent, + canActivate: [AuthGuard], + canDeactivate: [CanDeactivateGuard], + }, + { + path: 'story/:mode', + component: EditUserStoriesComponent, + canActivate: [AuthGuard], + canDeactivate: [CanDeactivateGuard], + }, + { + path: 'story/:mode/:userStoryId', + component: EditUserStoriesComponent, + canActivate: [AuthGuard], + canDeactivate: [CanDeactivateGuard], + }, + { + path: 'edit', + component: EditSolutionComponent, + canActivate: [AuthGuard], + canDeactivate: [CanDeactivateGuard], + }, + { + path: 'add', + component: EditSolutionComponent, + canActivate: [AuthGuard], + canDeactivate: [CanDeactivateGuard], + }, + { path: '', redirectTo: '/login', pathMatch: 'full' }, + { + path: 'bp-add', + component: BusinessProcessComponent, + canActivate: [AuthGuard], + canDeactivate: [CanDeactivateGuard], + }, + { + path: 'bp-edit', + component: BusinessProcessComponent, + canActivate: [AuthGuard], + canDeactivate: [CanDeactivateGuard], + }, + { + path: 'bp-flow/:mode/:id', + component: BusinessProcessFlowComponent, + canActivate: [AuthGuard], + }, +]; + +@NgModule({ + imports: [ + RouterModule.forRoot(routes, { + scrollPositionRestoration: 'enabled', + anchorScrolling: 'enabled', + paramsInheritanceStrategy: 'always', + useHash: true, + }), + ], + exports: [RouterModule], +}) +export class AppRoutingModule {} diff --git a/ui/src/app/app.component.html b/ui/src/app/app.component.html new file mode 100644 index 0000000..d332851 --- /dev/null +++ b/ui/src/app/app.component.html @@ -0,0 +1,15 @@ +<div class="h-screen w-screen relative bg-slate-50"> + <div class="h-64 w-full bg-secondary-950"></div> + <div class="flex flex-col absolute inset-0 w-screen h-screen"> + <header class="w-screen"> + <app-header /> + </header> + <div class="h-screen w-screen overflow-y-auto"> + <app-alert /> + <app-loading /> + <router-outlet /> + </div> + <app-toaster /> + <app-footer /> + </div> +</div> diff --git a/ui/src/app/app.component.scss b/ui/src/app/app.component.scss new file mode 100644 index 0000000..6a59cf8 --- /dev/null +++ b/ui/src/app/app.component.scss @@ -0,0 +1,4 @@ +main { + height: calc(100vh - 189px); + overflow: hidden; +} diff --git a/ui/src/app/app.component.spec.ts b/ui/src/app/app.component.spec.ts new file mode 100644 index 0000000..dc77ac4 --- /dev/null +++ b/ui/src/app/app.component.spec.ts @@ -0,0 +1,27 @@ +import { TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { AppComponent } from './app.component'; + +describe('AppComponent', () => { + beforeEach(() => + TestBed.configureTestingModule({ + imports: [RouterTestingModule], + declarations: [AppComponent], + }), + ); + + it('should create the app', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + + it('should render title', () => { + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('.content span')?.textContent).toContain( + 'ui app is running!', + ); + }); +}); diff --git a/ui/src/app/app.component.ts b/ui/src/app/app.component.ts new file mode 100644 index 0000000..a2bb569 --- /dev/null +++ b/ui/src/app/app.component.ts @@ -0,0 +1,29 @@ +import { Component, inject, OnInit } from '@angular/core'; +import { NGXLogger } from 'ngx-logger'; +import { Router } from '@angular/router'; +import { ElectronService } from './services/electron/electron.service'; + +@Component({ + selector: 'app-root', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], +}) +export class AppComponent implements OnInit { + electronService = inject(ElectronService); + logger = inject(NGXLogger); + router = inject(Router); + + ngOnInit() { + if (sessionStorage.getItem('serverActive') !== 'true') { + this.electronService + .listenPort() + .then(() => { + // Success logic if needed + }) + .catch((error) => { + this.logger.error('Error listening to port', error); + alert('An error occurred while trying to listen to the port.'); + }); + } + } +} diff --git a/ui/src/app/app.module.ts b/ui/src/app/app.module.ts new file mode 100644 index 0000000..e0e30f5 --- /dev/null +++ b/ui/src/app/app.module.ts @@ -0,0 +1,132 @@ +import { APP_INITIALIZER, NgModule, ErrorHandler } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { HTTP_INTERCEPTORS } from '@angular/common/http'; +import { AppRoutingModule } from './app-routing.module'; +import { HttpClientModule } from '@angular/common/http'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { NgxLoadingModule } from 'ngx-loading'; +import { NgxSmartModalModule } from 'ngx-smart-modal'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { UtilityService } from './services/utility.service'; +import { MatMenuModule } from '@angular/material/menu'; +import { ExportService } from './services/export.service'; +import { SharedModule } from './modules/shared/shared.module'; +import { NgxsModule } from '@ngxs/store'; +import { NgxsReduxDevtoolsPluginModule } from '@ngxs/devtools-plugin'; +import { NgxsFormPluginModule } from '@ngxs/form-plugin'; +import { NgxsLoggerPluginModule } from '@ngxs/logger-plugin'; +import { NgxsRouterPluginModule } from '@ngxs/router-plugin'; +import { NgxsStoragePluginModule, StorageOption } from '@ngxs/storage-plugin'; +import { ProjectsState } from './store/projects/projects.state'; +import { UserStoriesState } from './store/user-stories/user-stories.state'; +import { LoadingInterceptor } from './interceptor/http.interceptor'; +import { BreadcrumbState } from './store/breadcrumb/breadcrumb.state'; +import { FooterComponent } from './components/layout/footer/footer.component'; +import { BusinessProcessState } from './store/business-process/business-process.state'; +import { MatDialogModule } from '@angular/material/dialog'; +import * as Sentry from '@sentry/angular'; +import { Router } from '@angular/router'; +import { LLMConfigState } from './store/llm-config/llm-config.state'; +import { NgIconsModule } from '@ng-icons/core'; +import { NgOptimizedImage } from '@angular/common'; +import { AppComponent } from './app.component'; +import { ToasterComponent } from './components/toaster/toaster.component'; +import { HeaderComponent } from './components/layout/header/header.component'; +import { LoadingComponent } from './components/core/loading/loading.component'; +import { AlertComponent } from './components/core/alert/alert.component'; +import { environment } from '../environments/environment'; +import { MatSelectModule } from '@angular/material/select'; +import { MatBadgeModule } from '@angular/material/badge'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { ChatSettingsState } from './store/chat-settings/chat-settings.state'; +import { Title } from '@angular/platform-browser'; +import { InputFieldComponent } from './components/core/input-field/input-field.component'; +import { ButtonComponent } from './components/core/button/button.component'; +import { AuthService } from './services/auth/auth.service'; +import { AuthInterceptor } from './interceptor/auth.interceptor'; +import { AuthStateService } from './services/auth/auth-state.service'; + +@NgModule({ + declarations: [AppComponent], + imports: [ + BrowserModule, + HttpClientModule, + AppRoutingModule, + ReactiveFormsModule, + NgxLoadingModule.forRoot({}), + NgxSmartModalModule.forRoot(), + MatProgressBarModule, + BrowserAnimationsModule, + MatMenuModule, + MatDialogModule, + MatSelectModule, + MatBadgeModule, + SharedModule, + FormsModule, + NgxsModule.forRoot( + [ + ProjectsState, + UserStoriesState, + BreadcrumbState, + BusinessProcessState, + LLMConfigState, + ChatSettingsState, + ], + { + developmentMode: environment.DEBUG_MODE, + }, + ), + NgxsReduxDevtoolsPluginModule.forRoot({ + disabled: !environment.DEBUG_MODE, + }), + NgxsFormPluginModule.forRoot(), + NgxsLoggerPluginModule.forRoot({ + disabled: !environment.DEBUG_MODE, + collapsed: environment.DEBUG_MODE, + }), + NgxsRouterPluginModule.forRoot(), + NgxsStoragePluginModule.forRoot({ + keys: '*', + storage: StorageOption.LocalStorage, + }), + NgIconsModule, + NgOptimizedImage, + ToasterComponent, + FooterComponent, + HeaderComponent, + LoadingComponent, + AlertComponent, + MatSnackBarModule, + InputFieldComponent, + ButtonComponent, + ], + providers: [ + AuthStateService, + AuthService, + UtilityService, + ExportService, + { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }, + { provide: HTTP_INTERCEPTORS, useClass: LoadingInterceptor, multi: true }, + { + provide: ErrorHandler, + useValue: Sentry.createErrorHandler(), + }, + { + provide: Sentry.TraceService, + deps: [Router], + }, + { + provide: APP_INITIALIZER, + deps: [Sentry.TraceService], + multi: true, + useFactory: () => () => {} + } + ], + bootstrap: [AppComponent], +}) +export class AppModule { + constructor(private titleService: Title) { + this.titleService.setTitle(environment.ThemeConfiguration.appName); + } +} diff --git a/ui/src/app/components/accordion/accordion.component.html b/ui/src/app/components/accordion/accordion.component.html new file mode 100644 index 0000000..c20130c --- /dev/null +++ b/ui/src/app/components/accordion/accordion.component.html @@ -0,0 +1,40 @@ +<div class="w-full mb-2 border-[0.5px] border-secondary-300 rounded-lg bg-secondary-50"> + <div + class="flex justify-between items-center py-3 px-4 rounded-md cursor-pointer" + (click)="onToggleAccordion()" + (keydown.enter)="onToggleAccordion()" + (keydown.space)="onToggleAccordion(); $event.preventDefault()" + role="button" + tabindex="0" + > + <div class="flex justify-start items-center"> + <img *ngIf="iconImage" [src]="iconImage" alt="Icon" class="h-[15h]" /> + <div [ngClass]="dynamicClass">{{ title }}</div> + </div> + <div class="flex"> + <ng-container *ngIf="withConnectionStatus"> + <span + *ngIf="isConnected && isOpen" + class="text-success-600 font-medium text-xs border-[0.5px] border-success-300 rounded-full mr-4 px-[10px] py-[3px] flex items-center space-x-2" + > + <span class="w-1 h-1 bg-green-600 rounded-full"></span> + <span>Connected</span> + </span> + <span + *ngIf="!isConnected && isOpen" + class="text-danger-600 font-medium text-xs border-[0.5px] border-danger-300 rounded-full mr-4 px-[10px] py-[3px] flex items-center space-x-2" + > + <span class="w-1 h-1 bg-red-600 rounded-full"></span> + <span>Not Connected</span> + </span> + </ng-container> + <span class="transition-transform duration-300 flex items-center"> + <ng-icon *ngIf="isOpen" name="heroChevronUp" class="font-bold text-xl text-secondary-500"></ng-icon> + <ng-icon *ngIf="!isOpen" name="heroChevronDown" class="font-bold text-xl text-secondary-500"></ng-icon> + </span> + </div> + </div> + <div *ngIf="isOpen" class="p-4 transition-all duration-300"> + <ng-content></ng-content> + </div> +</div> diff --git a/ui/src/app/components/accordion/accordion.component.scss b/ui/src/app/components/accordion/accordion.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/components/accordion/accordion.component.spec.ts b/ui/src/app/components/accordion/accordion.component.spec.ts new file mode 100644 index 0000000..2a0332b --- /dev/null +++ b/ui/src/app/components/accordion/accordion.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AccordionComponent } from './accordion.component'; + +describe('AccordionComponent', () => { + let component: AccordionComponent; + let fixture: ComponentFixture<AccordionComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [AccordionComponent] + }); + fixture = TestBed.createComponent(AccordionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/components/accordion/accordion.component.ts b/ui/src/app/components/accordion/accordion.component.ts new file mode 100644 index 0000000..3b351ee --- /dev/null +++ b/ui/src/app/components/accordion/accordion.component.ts @@ -0,0 +1,25 @@ +import { NgClass, NgIf } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { NgIconComponent } from '@ng-icons/core'; + +@Component({ + selector: 'app-accordion', + templateUrl: './accordion.component.html', + styleUrls: ['./accordion.component.scss'], + standalone: true, + imports: [NgClass, NgIconComponent, NgIf], +}) +export class AccordionComponent { + @Input() title: string = ''; + @Input() iconImage: string = ''; + @Input() isOpen: boolean = false; + @Input() dynamicClass: string = ''; + @Input() isConnected: boolean = false; + @Input() withConnectionStatus: boolean = false; + + @Output() toggleAccordion = new EventEmitter<void>(); + + onToggleAccordion() { + this.toggleAccordion.emit(); + } +} diff --git a/ui/src/app/components/ai-chat/ai-chat.component.html b/ui/src/app/components/ai-chat/ai-chat.component.html new file mode 100644 index 0000000..6a5ae88 --- /dev/null +++ b/ui/src/app/components/ai-chat/ai-chat.component.html @@ -0,0 +1,191 @@ +<div [ngClass]="requirementAbbrivation != 'BP' ? 'h-[calc(100dvh-150px)]' : 'h-[calc(100dvh-200px)]'" +class="min-h-[620px] relative flex flex-col bg-white rounded-lg overflow-hidden border border-slate-300"> + <!-- Fixed Header --> + <div *ngIf="isKbAvailable || requirementAbbrivation != 'BP'" class="flex-none px-6 py-3 border-b border-slate-300"> + <p *ngIf="!isKbAvailable && requirementAbbrivation != 'BP'" class="text-md font-medium">AI Chat</p> + <div *ngIf="isKbAvailable" class="kb-container flex justify-between items-center"> + <div class="flex items-center space-x-2"> + <img src="./assets/img/logo/aws_dark_bg_white_logo.svg" class="h-[30px]" alt="AWS logo"/> + <p class="text-sm text-gray-600">AWS Bedrock Knowledge Base</p> + <ng-icon + name="heroInformationCircle" + class="text-lg cursor-pointer" + matTooltip="{{APP_MESSAGES.AWS_BEDROCK_TOOLTIP_MESSAGE}}" + matTooltipPosition="below" + ></ng-icon> + </div> + <div class="flex items-center"> + <app-toggle *ngIf="isKbAvailable" [isActive]="isKbActive" (toggleChange)="onKbToggle($event)"/> + </div> + </div> + </div> + + <!-- Scrollable Chat Area --> + <div class="flex-1 min-h-0 overflow-y-auto px-3"> + <div class="relative py-3 space-y-6"> + <!-- Chat Messages --> + <div class="space-y-6"> + <div *ngFor="let chat of chatHistory" class="transition-all duration-300 ease-in-out"> + <!-- User Message and Files --> + <div class="flex flex-col items-end gap-2" *ngIf="chat.user || chat.files"> + <!-- User text message --> + <div class="px-5 py-3 bg-indigo-600 text-white rounded-2xl w-fit max-w-[75%] text-sm" *ngIf="chat.user"> + {{ chat.user }} + </div> + <!-- File attachments --> + <div class="p-2 bg-gray-100 border border-slate-300 rounded-2xl w-fit max-w-[75%]" *ngIf="chat.files"> + <div class="flex flex-col gap-2"> + <div *ngFor="let file of chat.files" + class="flex items-center justify-between gap-2 px-3 py-2 rounded-lg"> + <div class="flex items-center gap-2"> + <ng-icon name="heroDocumentText" class="text-xl text-gray-600"></ng-icon> + <div class="flex flex-col"> + <span class="text-sm">{{ file.name }}</span> + <span class="text-xs text-gray-500">{{ file.size }}</span> + </div> + </div> + </div> + </div> + </div> + </div> + + <!-- Assistant Message --> + <div *ngIf="chat.assistant" class="flex items-start"> + <div class="flex items-start gap-3 w-full"> + <div class="flex-none bg-gray-100 rounded-full flex items-center justify-center w-10 h-10"> + <img src="assets/img/logo/haibuild_onlylogo.svg" alt="AI" class="w-6 h-6"> + </div> + <div class="flex-1 max-w-[75%]"> + <div class="inline-block bg-gray-100 px-4 py-3 rounded-2xl text-sm border border-slate-300"> + {{ chat.assistant }} + <div class="mt-3 flex items-start"> + <button *ngIf="!chat.isAdded" + class="text-sm bg-white text-emerald-600 py-1 px-3 rounded-full flex items-center gap-1 transition-colors duration-200 border border-slate-300" + (click)="update(chat)"> + <ng-icon name="heroDocumentPlus" class="text-lg"></ng-icon> + Add to Description + </button> + <div *ngIf="chat.isAdded" + class="text-sm bg-emerald-600 text-white flex items-center py-1 px-3 gap-1 rounded-full"> + <ng-icon name="heroCheck" class="text-lg text-white"></ng-icon> + Added + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + + <!-- Thinking State --> + <div class="flex items-start" *ngIf="generateLoader"> + <div class="flex items-start gap-3 w-full"> + <div class="flex-none bg-gray-100 rounded-full flex items-center justify-center w-10 h-10"> + <img src="assets/img/logo/haibuild_onlylogo.svg" alt="AI" class="w-6 h-6"> + </div> + <div class="text-sm text-gray-600 mt-2">Thinking...</div> + </div> + </div> + + <div #scrollToBottom></div> + </div> + </div> + + <!-- Suggestions Area --> + <div *ngIf="chatHistory.length == 0" class="flex-none bg-white px-6 py-4"> + <!-- Loading State --> + <div *ngIf="loadingChat" class="flex items-start gap-3 mb-3"> + <div class="flex-none bg-gray-100 rounded-full flex items-center justify-center w-10 h-10"> + <img src="assets/img/logo/haibuild_onlylogo.svg" alt="AI" class="w-6 h-6"> + </div> + <div class="text-sm text-gray-600 mt-2">Generating suggestions for you...</div> + </div> + + <!-- Suggestions List --> + <div *ngIf="!loadingChat && chatSuggestions.length > 0"> + <div class="text-sm text-gray-600 font-medium mb-3">Suggestions to improve:</div> + <ul class="flex flex-wrap gap-2"> + <li *ngFor="let suggestion of chatSuggestions" + class="flex items-center gap-2 text-sm py-2 px-4 rounded-full bg-gray-100 shadow-sm hover:shadow-md cursor-pointer transition-shadow duration-200 border border-slate-200" + (click)="converse(suggestion)" + (keydown.enter)="converse(suggestion)" + (keydown.space)="converse(suggestion); $event.preventDefault()" + role="button" + tabindex="0"> + <ng-icon name="heroSparklesSolid" class="text-lg text-amber-400"></ng-icon> + <span class="text-gray-600">{{ suggestion }}</span> + </li> + </ul> + </div> + </div> + + <!-- Fixed Input Area --> + <div class="flex-none w-full bg-gray-100"> + <div class="relative"> + <!-- Selected Files Display --> + <div *ngIf="selectedFiles.length > 0" class="max-h-28 overflow-y-auto border-t border-slate-300"> + <div *ngFor="let file of selectedFiles; let i = index" + class="flex items-center gap-2 bg-transparent px-4 py-2" + [class.border-t]="i > 0" + [class.border-gray-200]="i > 0"> + <ng-icon name="heroDocumentText" class="text-gray-600"></ng-icon> + <div class="flex-1 flex items-center justify-between"> + <div class="flex items-center"> + <span class="text-sm text-gray-700 font-medium">{{file.name}}</span> + </div> + <div class="flex items-center gap-2"> + <span class="text-xs text-gray-500">{{formatFileSize(file.size)}}</span> + <button (click)="removeFile(i)" class="text-gray-500 flex flex-col items-center hover:text-gray-700"> + <ng-icon name="heroXMark" class="text-lg"></ng-icon> + </button> + </div> + </div> + </div> + </div> + + <div class="h-content border-t border-slate-300 px-4 py-3 transition-all duration-300 ease-in-out"> + <div class="relative flex flex-col h-full"> + <div class="flex-grow h-16"> + <textarea + placeholder="Chat to add or modify your requirement" + [disabled]="generateLoader" + [(ngModel)]="message" + class="w-full h-14 text-sm bg-transparent text-gray-700 focus:outline-none placeholder:text-gray-500 focus:outline-none focus:right-0 resize-none overflow-x-hidden overflow-y-auto transition-all duration-300 ease-in-out" + ></textarea> + </div> + + <div class="flex mt-2 justify-between items-center transition-transform duration-300 ease-in-out"> + <div class="flex items-center"> + <input + type="file" + (change)="onFileSelected($event)" + accept=".js,.ts,.tsx,.jsx,.html,.css,.json,.xml,.py,.java,.c,.cpp,.cs,.php,.rb,.go,.swift" + multiple + #fileInput + class="hidden" + /> + <button + (click)="fileInput.click()" + class="flex items-center justify-center w-8 h-8 hover:bg-gray-100 rounded-full transition-colors duration-200" + matTooltip="{{TOOLTIP_CONTENT.IMPORT_FROM_CODE_BUTTON}}" + matTooltipPosition="right"> + <ng-icon + name="heroPaperClip" + class="text-xl text-gray-700" + ></ng-icon> + </button> + </div> + + <button + [disabled]="isSendDisabled" + class="bg-indigo-600 text-white px-4 py-1.5 rounded-lg text-sm font-medium hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200" + (click)="converse(message)"> + Send + </button> + </div> + </div> + </div> + </div> + </div> +</div> diff --git a/ui/src/app/components/ai-chat/ai-chat.component.scss b/ui/src/app/components/ai-chat/ai-chat.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/components/ai-chat/ai-chat.component.spec.ts b/ui/src/app/components/ai-chat/ai-chat.component.spec.ts new file mode 100644 index 0000000..5ab7300 --- /dev/null +++ b/ui/src/app/components/ai-chat/ai-chat.component.spec.ts @@ -0,0 +1,20 @@ +import { AiChatComponent } from './ai-chat.component'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +describe('AiChatComponent', () => { + let component: AiChatComponent; + let fixture: ComponentFixture<AiChatComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [AiChatComponent], + }); + fixture = TestBed.createComponent(AiChatComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/components/ai-chat/ai-chat.component.ts b/ui/src/app/components/ai-chat/ai-chat.component.ts new file mode 100644 index 0000000..e0b748b --- /dev/null +++ b/ui/src/app/components/ai-chat/ai-chat.component.ts @@ -0,0 +1,366 @@ +import { + Component, + EventEmitter, + Input, + OnInit, + Output, + ViewChild, +} from '@angular/core'; +import { + conversePayload, + suggestionPayload, +} from '../../model/interfaces/chat.interface'; +import { ChatService } from '../../services/chat/chat.service'; +import { UtilityService } from '../../services/utility.service'; +import { TOOLTIP_CONTENT, APP_MESSAGES, CHAT_TYPES, TOASTER_MESSAGES } from '../../constants/app.constants'; +import { Store } from '@ngxs/store'; +import { Observable } from 'rxjs'; +import { ChatSettings } from 'src/app/model/interfaces/ChatSettings'; +import { ChatSettingsState } from 'src/app/store/chat-settings/chat-settings.state'; +import { FormsModule } from '@angular/forms'; +import { NgIconComponent, provideIcons } from '@ng-icons/core'; +import { + heroDocumentPlus, + heroCheck, + heroPaperClip, + heroInformationCircle, + heroXMark, + heroDocumentText +} from '@ng-icons/heroicons/outline'; +import { heroSparklesSolid } from '@ng-icons/heroicons/solid' +import { environment } from '../../../environments/environment'; +import { NgClass, NgForOf, NgIf } from '@angular/common'; +import { ProjectsState } from 'src/app/store/projects/projects.state'; +import { ToggleComponent } from '../toggle/toggle.component'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { ToasterService } from 'src/app/services/toaster/toaster.service'; +import { ERROR_MESSAGES } from '../../constants/app.constants'; +@Component({ + selector: 'app-chat', + templateUrl: './ai-chat.component.html', + styleUrls: ['./ai-chat.component.scss'], + standalone: true, + imports: [ + NgForOf, + NgIf, + FormsModule, + NgIconComponent, + ToggleComponent, + MatTooltipModule, + NgClass, + ], + providers: [ + provideIcons({ + heroDocumentPlus, + heroCheck, + heroPaperClip, + heroInformationCircle, + heroXMark, + heroSparklesSolid, + heroDocumentText + }) + ] +}) +export class AiChatComponent implements OnInit { + protected readonly APP_MESSAGES = APP_MESSAGES; + protected readonly TOOLTIP_CONTENT = TOOLTIP_CONTENT; + protected readonly themeConfiguration = environment.ThemeConfiguration; + @Input() chatType: string = 'requirement'; + @Input() fileName: string = ''; + @Input() name: string = ''; + @Input() description: string = ''; + @Input() baseContent: string = ''; + @Input() chatHistory: any = []; + @Input() supportsAddFromCode: boolean = true; + @Input() prd: string | undefined; + @Input() userStory: string | undefined; + + metadata: any = {}; + isKbAvailable: boolean = false; + + chatSettings$: Observable<ChatSettings> = this.store.select( + ChatSettingsState.getConfig, + ); + + @Output() getContent: EventEmitter<any> = new EventEmitter<any>(); + @Output() updateChatHistory: EventEmitter<any> = new EventEmitter<any>(); + @ViewChild('scrollToBottom') scrollToBottom: any; + + basePayload: suggestionPayload = { + name: '', + type: '', + description: '', + requirement: '', + }; + type: string = ''; + requirementAbbrivation: string = ''; + projectId: string = ''; + message: string = ''; + chatSuggestions: Array<string> = []; + generateLoader: boolean = false; + loadingChat: boolean = false; + kb: string = ''; + isKbActive: boolean = false; + + selectedFiles: File[] = []; + selectedFilesContent: string = ''; + formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; + } + + constructor( + private chatService: ChatService, + private utilityService: UtilityService, + private store: Store, + private toastService: ToasterService, + ) {} + + smoothScroll() { + setTimeout(() => { + this.scrollToBottom.nativeElement.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + }); + }, 500); + } + + ngOnInit() { + this.store.select(ProjectsState.getMetadata).subscribe((res) => { + this.metadata = res; + }); + + this.isKbAvailable = !!this.metadata.integration?.bedrock?.kbId; + + if (this.isKbAvailable) { + this.chatSettings$.subscribe((settings) => { + this.kb = settings?.kb; + this.isKbActive = settings?.kb !== ''; + }); + } + + if (this.chatType == CHAT_TYPES.REQUIREMENT) { + this.requirementAbbrivation = this.utilityService.getRequirementType( + this.fileName, + ); + this.type = this.utilityService + .expandRequirementName(this.fileName) + .slice(0, -2); + } else if (this.chatType == CHAT_TYPES.USERSTORY) { + this.type = 'User Story'; + } else if (this.chatType == CHAT_TYPES.TASK) { + this.type = 'Task for User Story'; + } + this.smoothScroll(); + setTimeout(() => { + this.generateLoader = false; + this.basePayload = { + name: this.name, + description: this.description, + type: this.type, + requirement: this.baseContent, + knowledgeBase: this.kb, + }; + if (this.chatHistory.length == 0) this.getSuggestion(); + }, 1000); + } + + getSuggestion() { + this.loadingChat = true; + this.chatService + .generateSuggestions(this.basePayload).subscribe({ + next: (response: Array<''>) => { + this.chatSuggestions = response; + this.loadingChat = false; + }, + error: (err) => { + this.toastService.showError(ERROR_MESSAGES.GENERATE_SUGGESTIONS_FAILED); + this.loadingChat = false; + } + }); + } + + finalCall(message: string) { + let payload: conversePayload = { + ...this.basePayload, + chatHistory: this.chatHistory + .map((item: any) => { + if (item.user) return { user: item.user }; + else return { assistant: item.assistant }; + }) + .slice(0, -1), + userMessage: message, + knowledgeBase: this.kb, + }; + if (this.chatType === CHAT_TYPES.REQUIREMENT) + payload = { ...payload, requirementAbbr: this.requirementAbbrivation }; + else payload = { ...payload, prd: this.prd, us: this.userStory }; + this.chatService + .chatWithLLM(this.chatType, payload) + .subscribe((response) => { + this.generateLoader = false; + this.chatHistory = [...this.chatHistory, { assistant: response }]; + this.returnChatHistory(); + this.smoothScroll(); + }); + } + + codeLLMCall(fileList: any, content: string) { + this.generateLoader = true; + this.chatHistory = [...this.chatHistory, { user: 'Files', list: fileList }]; + const message = ` + Code Snippet: + ${content} + ------------- + Improve the requirement context according to the code snippet attached + -------------`; + this.finalCall(message); + } + + update(chat: any) { + let data = { + chat, + chatHistory: this.chatHistory, + }; + this.getContent.emit(data); + } + + returnChatHistory() { + this.updateChatHistory.emit(this.chatHistory); + } + + onFileSelected(event: any): void { + const newFiles: File[] = event.target.files; + if (newFiles.length > 0) { + const errorFiles: string[] = []; + const duplicateFiles: string[] = []; + const validFiles: File[] = []; + + // Check each new file + + for (const file of Array.from(newFiles)) { + if (file.size === 0) { + errorFiles.push(file.name); + continue; + } + + // Check if file already exists in selectedFiles + const isDuplicate = this.selectedFiles.some( + existingFile => existingFile.name === file.name + ); + + if (isDuplicate) { + duplicateFiles.push(file.name); + } else { + validFiles.push(file); + } + } + + // Add new valid files to existing ones + validFiles.forEach(file => { + this.selectedFiles.push(file); + this.readFileContent(file); + }); + + // Show appropriate error messages + if (errorFiles.length > 0) { + this.toastService.showError(`Empty file(s): ${errorFiles.join(', ')}`); + } + if (duplicateFiles.length > 0) { + this.toastService.showError(`Duplicate file(s): ${duplicateFiles.join(', ')}`); + } + } + + } + readFileContent(file: File): void { const reader = new FileReader(); + reader.onload = (event: any) => { + // Append new content without clearing existing content + this.selectedFilesContent += file.name + '\n\n' + event.target.result + '\n\n'; + }; + reader.readAsText(file); + } + + removeFile(index: number) { + // Remove the file from selectedFiles array + this.selectedFiles.splice(index, 1); + + // Reset and rebuild selectedFilesContent from remaining files + this.selectedFilesContent = ''; + this.selectedFiles.forEach(file => { + const reader = new FileReader(); + reader.onload = (event: any) => { + this.selectedFilesContent += file.name + '\n\n' + event.target.result + '\n\n'; + }; + reader.readAsText(file); + }); + } + + converse(message: string) { + if (message || this.selectedFiles.length > 0) { + this.generateLoader = true; + + // Add user message and files to chat history + if (message && this.selectedFiles.length > 0) { + // Both message and files + this.chatHistory = [...this.chatHistory, { + user: message, + files: this.selectedFiles.map(f => ({ + name: f.name, + size: this.formatFileSize(f.size) + })) + }]; + } else if (this.selectedFiles.length > 0) { + // Only files + this.chatHistory = [...this.chatHistory, { + files: this.selectedFiles.map(f => ({ + name: f.name, + size: this.formatFileSize(f.size) + })) + }]; + } else { + // Only message + this.chatHistory = [...this.chatHistory, { user: message }]; + } + + // Construct API message + let apiMessage = ''; + if (message) { + apiMessage += message + '\n\n'; + } + if (this.selectedFiles.length > 0) { + apiMessage += 'Code Snippets:\n'; + apiMessage += this.selectedFilesContent; + } + + this.finalCall(apiMessage); + + // Clear message and files after sending + this.message = ''; + this.selectedFiles = []; + this.selectedFilesContent = ''; + } + } + + get isSendDisabled(): boolean { + return this.generateLoader || (!this.message && this.selectedFiles.length === 0); + } + + onKbToggle(isActive: boolean) { + this.isKbActive = isActive; + this.kb = isActive ? this.metadata.integration?.bedrock?.kbId : ''; + + // Update base payload with new KB setting + this.basePayload = { + ...this.basePayload, + knowledgeBase: this.kb + }; + + // Only refresh suggestions if we're at the initial state + if (this.chatHistory.length === 0) { + this.getSuggestion(); + } + } +} diff --git a/ui/src/app/components/confirmation-dialog/confirmation-dialog.component.html b/ui/src/app/components/confirmation-dialog/confirmation-dialog.component.html new file mode 100644 index 0000000..ae42ffd --- /dev/null +++ b/ui/src/app/components/confirmation-dialog/confirmation-dialog.component.html @@ -0,0 +1,20 @@ +<div class="p-6 flex flex-col justify-start"> + <h1 class="text-lg font-medium mb-4">{{ data.title }}</h1> + <p class="text-sm mb-4" [innerHTML]="data.description"></p> + <div class="flex justify-end gap-4"> + <app-button + [buttonContent]="data.cancelButtonText || 'Cancel'" + theme="secondary" + size="sm" + rounded="lg" + (click)="onLeave()" + /> + <app-button + [buttonContent]="data.proceedButtonText || 'Proceed'" + theme="primary" + size="sm" + rounded="lg" + (click)="onStay()" + /> + </div> +</div> diff --git a/ui/src/app/components/confirmation-dialog/confirmation-dialog.component.scss b/ui/src/app/components/confirmation-dialog/confirmation-dialog.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/components/confirmation-dialog/confirmation-dialog.component.spec.ts b/ui/src/app/components/confirmation-dialog/confirmation-dialog.component.spec.ts new file mode 100644 index 0000000..73d304b --- /dev/null +++ b/ui/src/app/components/confirmation-dialog/confirmation-dialog.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ConfirmationDialogComponent } from './confirmation-dialog.component'; + +describe('ConfirmationDialogComponent', () => { + let component: ConfirmationDialogComponent; + let fixture: ComponentFixture<ConfirmationDialogComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ConfirmationDialogComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ConfirmationDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/components/confirmation-dialog/confirmation-dialog.component.ts b/ui/src/app/components/confirmation-dialog/confirmation-dialog.component.ts new file mode 100644 index 0000000..1997185 --- /dev/null +++ b/ui/src/app/components/confirmation-dialog/confirmation-dialog.component.ts @@ -0,0 +1,25 @@ +import { Component, inject, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { ModalDialogCustomComponent } from '../modal-dialog/modal-dialog.component'; +import { ButtonComponent } from '../core/button/button.component'; + +@Component({ + selector: 'app-confirmation-dialog', + templateUrl: './confirmation-dialog.component.html', + styleUrls: ['./confirmation-dialog.component.scss'], + standalone: true, + imports: [ButtonComponent], +}) +export class ConfirmationDialogComponent { + constructor(@Inject(MAT_DIALOG_DATA) public data: any) {} + + readonly dialogRef = inject(MatDialogRef<ModalDialogCustomComponent>); + + onStay() { + this.dialogRef.close(false); + } + + onLeave() { + this.dialogRef.close(true); + } +} diff --git a/ui/src/app/components/core/alert/alert.component.html b/ui/src/app/components/core/alert/alert.component.html new file mode 100644 index 0000000..3ab6635 --- /dev/null +++ b/ui/src/app/components/core/alert/alert.component.html @@ -0,0 +1,28 @@ +<div + *ngIf="isVisible" + [@fadeInOut] + class="fixed inset-0 z-50 flex items-center justify-center" +> + <div class="fixed inset-0 bg-black opacity-50 z-40"></div> + <div + class="bg-white text-indigo-600 px-4 py-4 rounded-md shadow-lg max-w-lg w-full z-50" + > + <div class="flex items-start"> + <ng-icon class="mt-1 mr-3 h-5 w-5" name="heroCheckCircleSolid"></ng-icon> + <div class="w-full"> + <p class="font-bold">Success</p> + <p class="mt-1 text-sm"> + {{ message }} + </p> + <div class="flex justify-end mt-4"> + <button + class="rounded-md px-3 py-2 shadow-sm text-sm bg-indigo-600 hover:bg-indigo-500 text-white" + (click)="closeAlert()" + > + <span class="font-medium text-md">Dismiss</span> + </button> + </div> + </div> + </div> + </div> +</div> diff --git a/ui/src/app/components/core/alert/alert.component.scss b/ui/src/app/components/core/alert/alert.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/components/core/alert/alert.component.spec.ts b/ui/src/app/components/core/alert/alert.component.spec.ts new file mode 100644 index 0000000..a7bd4f8 --- /dev/null +++ b/ui/src/app/components/core/alert/alert.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AlertComponent } from './alert.component'; + +describe('AlertComponent', () => { + let component: AlertComponent; + let fixture: ComponentFixture<AlertComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [AlertComponent] + }); + fixture = TestBed.createComponent(AlertComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/components/core/alert/alert.component.ts b/ui/src/app/components/core/alert/alert.component.ts new file mode 100644 index 0000000..71c6844 --- /dev/null +++ b/ui/src/app/components/core/alert/alert.component.ts @@ -0,0 +1,50 @@ +import { Component, OnInit } from '@angular/core'; +import { + trigger, + state, + style, + animate, + transition, +} from '@angular/animations'; +import { AlertService } from '../../../services/alert.service'; +import { NgIf } from '@angular/common'; +import { NgIconComponent } from '@ng-icons/core'; + +@Component({ + selector: 'app-alert', + templateUrl: './alert.component.html', + styleUrls: ['./alert.component.scss'], + standalone: true, + animations: [ + trigger('fadeInOut', [ + state( + 'void', + style({ + opacity: 0, + transform: 'translateY(-20px)', + }), + ), + transition('void <=> *', [animate('300ms ease-in-out')]), + ]), + ], + imports: [NgIf, NgIconComponent], +}) +export class AlertComponent implements OnInit { + message: string = ''; + isVisible: boolean = false; + + constructor(private alertService: AlertService) {} + + ngOnInit() { + this.alertService.currentMessage.subscribe( + (message) => (this.message = message), + ); + this.alertService.currentVisibility.subscribe( + (isVisible) => (this.isVisible = isVisible), + ); + } + + closeAlert() { + this.alertService.hideAlert(); + } +} diff --git a/ui/src/app/components/core/badge/badge.component.html b/ui/src/app/components/core/badge/badge.component.html new file mode 100644 index 0000000..38e3f15 --- /dev/null +++ b/ui/src/app/components/core/badge/badge.component.html @@ -0,0 +1,5 @@ +<span + class="ml-2 bg-secondary-50 border border-secondary-300 text-gray-900 rounded-lg px-2 py-1 text-xs max-w-20 h-6 flex items-center justify-center" +> + {{ badgeText }} +</span> diff --git a/ui/src/app/components/core/badge/badge.component.scss b/ui/src/app/components/core/badge/badge.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/components/core/badge/badge.component.spec.ts b/ui/src/app/components/core/badge/badge.component.spec.ts new file mode 100644 index 0000000..e625e96 --- /dev/null +++ b/ui/src/app/components/core/badge/badge.component.spec.ts @@ -0,0 +1,15 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BadgeComponent } from './badge.component'; + +describe('BadgeComponent', () => { + let component: BadgeComponent; + let fixture: ComponentFixture<BadgeComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [BadgeComponent], + }); + fixture = TestBed.createComponent(BadgeComponent); + }); +}); diff --git a/ui/src/app/components/core/badge/badge.component.ts b/ui/src/app/components/core/badge/badge.component.ts new file mode 100644 index 0000000..fdc5dd4 --- /dev/null +++ b/ui/src/app/components/core/badge/badge.component.ts @@ -0,0 +1,12 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'app-badge', + templateUrl: './badge.component.html', + styleUrls: ['./badge.component.scss'], + standalone: true, +}) +export class BadgeComponent { + @Input() badgeText!: string | number; + @Input() class?: string; +} diff --git a/ui/src/app/components/core/breadcrumbs/breadcrumbs.component.html b/ui/src/app/components/core/breadcrumbs/breadcrumbs.component.html new file mode 100644 index 0000000..2a96eef --- /dev/null +++ b/ui/src/app/components/core/breadcrumbs/breadcrumbs.component.html @@ -0,0 +1,41 @@ +<nav class="flex" aria-label="Breadcrumb" *ngIf="breadcrumbs$ | async as breadcrumbs"> + <!--Back button--> + <div class="flex mr-3"> + <button [disabled]="breadcrumbs.length < 1" (click)="navigateToPreviousPage()" + class="inline-flex items-center text-xs font-normal text-gray-300" + [ngClass]="{'hover:text-white': breadcrumbs.length > 0, 'text-gray-500': breadcrumbs.length < 1}"> + <ng-icon class="text-lg" name="heroArrowLeft"></ng-icon> + </button> + </div> + <!--Solutions Breadcrumbs--> + <ol class="inline-flex items-center space-x-1 md:space-x-2"> + <li class="inline-flex items-center"> + <a routerLink="/apps" (click)="navigateTo({ label: 'Solutions', url: '/apps' })" class="inline-flex items-center text-sm font-normal text-gray-300 hover:text-white cursor-pointer" + [class.text-white]="breadcrumbs.length == 0" [class.font-semibold]="breadcrumbs.length == 0"> + <ng-icon class="text-sm mr-1" name="heroHome"></ng-icon> + Solutions + </a> + </li> + <li *ngFor="let breadcrumb of breadcrumbs; let last = last"> + <div class="flex items-center"> + <span class="text-gray-300 text-xs">/</span> + <span + (click)="navigateTo(breadcrumb)" + (keydown.enter)="navigateTo(breadcrumb)" + (keydown.space)="navigateTo(breadcrumb); $event.preventDefault()" + role="button" + tabindex="0" + [class.text-white]="last" + [class.font-semibold]="last" + [class.cursor-pointer]="breadcrumb.url" + class="ml-1 text-sm text-gray-300 hover:text-white md:ml-2 cursor-pointer" + [matTooltip]="breadcrumb.tooltipLabel || ''" + matTooltipPosition="below" + matTooltipShowDelay="500" + matTooltipHideDelay="500" + >{{ breadcrumb.label }}</span + > + </div> + </li> + </ol> +</nav> diff --git a/ui/src/app/components/core/breadcrumbs/breadcrumbs.component.scss b/ui/src/app/components/core/breadcrumbs/breadcrumbs.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/components/core/breadcrumbs/breadcrumbs.component.spec.ts b/ui/src/app/components/core/breadcrumbs/breadcrumbs.component.spec.ts new file mode 100644 index 0000000..8f205e7 --- /dev/null +++ b/ui/src/app/components/core/breadcrumbs/breadcrumbs.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BreadcrumbsComponent } from './breadcrumbs.component'; + +describe('BreadcrumbsComponent', () => { + let component: BreadcrumbsComponent; + let fixture: ComponentFixture<BreadcrumbsComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [BreadcrumbsComponent] + }); + fixture = TestBed.createComponent(BreadcrumbsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/components/core/breadcrumbs/breadcrumbs.component.ts b/ui/src/app/components/core/breadcrumbs/breadcrumbs.component.ts new file mode 100644 index 0000000..3051682 --- /dev/null +++ b/ui/src/app/components/core/breadcrumbs/breadcrumbs.component.ts @@ -0,0 +1,66 @@ +import { Component, inject, OnInit } from '@angular/core'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { Router } from '@angular/router'; +import { Store } from '@ngxs/store'; +import { BreadcrumbState } from '../../../store/breadcrumb/breadcrumb.state'; +import { IBreadcrumb } from '../../../model/interfaces/projects.interface'; +import { AsyncPipe, NgClass, NgForOf, NgIf } from '@angular/common'; +import { NgIconComponent, provideIcons } from '@ng-icons/core'; +import { heroArrowLeft, heroHome } from '@ng-icons/heroicons/outline'; + +@Component({ + selector: 'app-breadcrumbs', + templateUrl: './breadcrumbs.component.html', + styleUrls: ['./breadcrumbs.component.scss'], + standalone: true, + imports: [ + NgClass, + NgIconComponent, + NgForOf, + NgIf, + AsyncPipe, + MatTooltipModule, + ], + viewProviders: [provideIcons({ heroArrowLeft, heroHome })], +}) +export class BreadcrumbsComponent implements OnInit { + pageHistory: IBreadcrumb[] = []; + + store = inject(Store); + router = inject(Router); + breadcrumbs$ = this.store.select(BreadcrumbState.getBreadcrumbs); + + ngOnInit() { + this.breadcrumbs$.subscribe((pages) => { + this.pageHistory = [{ label: 'Apps', url: '/apps', state: {} }, ...pages]; + }); + } + + navigateTo(breadcrumb: IBreadcrumb) { + if (breadcrumb.url) { + this.router + .navigate( + [breadcrumb.url], + breadcrumb.state + ? { + state: breadcrumb.state, + } + : undefined, + ) + .then(); + } + } + + navigateToPreviousPage() { + if (this.pageHistory.length > 1) { + const previousPage = this.pageHistory[this.pageHistory.length - 2]; + if (previousPage.url) { + this.router.navigate([previousPage.url], { + state: previousPage.state || {}, + }); + } + } else { + console.log('No previous page to navigate to.'); + } + } +} diff --git a/ui/src/app/components/core/button/button.component.html b/ui/src/app/components/core/button/button.component.html new file mode 100644 index 0000000..a045cf7 --- /dev/null +++ b/ui/src/app/components/core/button/button.component.html @@ -0,0 +1,28 @@ +<button + [ngClass]="[ + 'flex', + 'items-center', + 'justify-center', + 'font-medium', + 'opacity-100', + 'transition-colors', + 'duration-300', + 'font-medium', + sizeClass, + roundedClass, + themeClasses.border, + disabled ? themeClasses.disabledBg : themeClasses.bg, + disabled ? themeClasses.disabledText : themeClasses.text, + !disabled ? themeClasses.hoverBg : '', + disabled ? 'cursor-not-allowed' : '' + ]" + [type]="type" + [disabled]="disabled" +> + <ng-container *ngIf="icon"> + <ng-icon [name]="icon" [ngClass]="isIconButton ? 'w-5 h-5 text-lg' : 'w-4 h-4 mr-2'"></ng-icon> + <!-- Or use an SVG/image like this --> + <!-- <img [src]="iconSrc" alt="" class="w-4 h-4 mr-2" *ngIf="iconSrc"/> --> + </ng-container> + <span *ngIf="!isIconButton" class="whitespace-nowrap">{{ buttonContent }}</span> +</button> diff --git a/ui/src/app/components/core/button/button.component.scss b/ui/src/app/components/core/button/button.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/components/core/button/button.component.spec.ts b/ui/src/app/components/core/button/button.component.spec.ts new file mode 100644 index 0000000..fe0230c --- /dev/null +++ b/ui/src/app/components/core/button/button.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ButtonComponent } from './button.component'; + +describe('ButtonComponent', () => { + let component: ButtonComponent; + let fixture: ComponentFixture<ButtonComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ButtonComponent] + }); + fixture = TestBed.createComponent(ButtonComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/components/core/button/button.component.ts b/ui/src/app/components/core/button/button.component.ts new file mode 100644 index 0000000..41a0820 --- /dev/null +++ b/ui/src/app/components/core/button/button.component.ts @@ -0,0 +1,105 @@ +import { Component, Input } from '@angular/core'; +import { NgClass, NgIf } from '@angular/common'; +import { NgIconComponent } from '@ng-icons/core'; + +@Component({ + selector: 'app-button', + templateUrl: './button.component.html', + styleUrls: ['./button.component.scss'], + standalone: true, + imports: [NgClass, NgIconComponent, NgIf], +}) +export class ButtonComponent { + @Input() buttonContent: string = ''; + @Input() theme: + | 'primary' + | 'primary_outline' + | 'secondary' + | 'secondary_outline' + | 'danger' + | 'green' = 'primary'; + @Input() size: 'xs' | 'sm' | 'md' | 'lg' = 'md'; + @Input() rounded: 'none' | 'sm' | 'md' | 'lg' = 'md'; + @Input() roundedLeft: 'none' | 'sm' | 'md' | 'lg' = 'none'; + @Input() roundedRight: 'none' | 'sm' | 'md' | 'lg' = 'none'; + @Input() disabled: boolean = false; + @Input() icon?: string; + @Input() type: string = 'button'; + @Input() isIconButton: boolean = false; + + get themeClasses() { + const styles = { + primary: { + bg: 'bg-primary-600', + text: 'text-white', + hoverBg: 'hover:bg-primary-700', + disabledBg: 'bg-primary-500', + disabledText: 'text-primary-200', + border: '' + }, + primary_outline: { + bg: 'bg-transparent', + text: 'text-primary-600', + hoverBg: 'hover:bg-primary-200', + disabledBg: 'bg-transparent', + disabledText: 'text-primary-300', + border: 'border border-primary-300', + }, + secondary: { + bg: 'bg-primary-50', + text: 'text-primary-600', + hoverBg: 'hover:bg-secondary-200', + disabledBg: 'bg-secondary-50', + disabledText: 'text-secondary-300', + border: 'border border-primary-300', + }, + secondary_outline: { + bg: 'bg-transparent', + text: 'text-secondary-900', + hoverBg: 'hover:bg-secondary-200', + disabledBg: 'bg-transparent', + disabledText: 'text-secondary-300', + border: 'border border-secondary-200', + }, + danger: { + bg: 'bg-red-600', + text: 'text-secondary-50', + hoverBg: 'hover:bg-red-700', + disabledBg: 'bg-red-600', + disabledText: 'text-secondary-50', + border: '' + }, + green: { + bg: 'bg-green-500', + text: 'text-white', + hoverBg: 'hover:bg-green-600', + disabledBg: 'bg-green-400', + disabledText: 'text-green-300', + border: '' + }, + }; + return styles[this.theme] || styles.primary; + } + + get sizeClass(): string { + switch (this.size) { + case "xs": + return 'px-2 py-1 text-xs'; + case 'sm': + return 'px-3 py-2 text-xs'; + case 'md': + return 'text-sm px-5 py-2.5'; + case 'lg': + return 'text-base px-5 py-3'; + default: + return 'text-sm px-5 py-2.5'; + } + } + + get roundedClass(): string { + const leftClass = this.roundedLeft !== 'none' ? `rounded-l-${this.roundedLeft}` : ''; + const rightClass = this.roundedRight !== 'none' ? `rounded-r-${this.roundedRight}` : ''; + const baseClass = this.rounded !== 'none' ? `rounded-${this.rounded}` : ''; + return `${baseClass} ${leftClass} ${rightClass}`.trim(); + } +} diff --git a/ui/src/app/components/core/error-message/error-message.component.html b/ui/src/app/components/core/error-message/error-message.component.html new file mode 100644 index 0000000..b498388 --- /dev/null +++ b/ui/src/app/components/core/error-message/error-message.component.html @@ -0,0 +1,4 @@ +<div *ngIf="errorControl.touched" + class="text-red-500 text-xs"> + <span>{{ errorMessages }}</span> +</div> diff --git a/ui/src/app/components/core/error-message/error-message.component.scss b/ui/src/app/components/core/error-message/error-message.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/components/core/error-message/error-message.component.spec.ts b/ui/src/app/components/core/error-message/error-message.component.spec.ts new file mode 100644 index 0000000..5c8af37 --- /dev/null +++ b/ui/src/app/components/core/error-message/error-message.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ErrorMessageComponent } from './error-message.component'; + +describe('ErrorMessageComponent', () => { + let component: ErrorMessageComponent; + let fixture: ComponentFixture<ErrorMessageComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ErrorMessageComponent] + }); + fixture = TestBed.createComponent(ErrorMessageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/components/core/error-message/error-message.component.ts b/ui/src/app/components/core/error-message/error-message.component.ts new file mode 100644 index 0000000..a3c59dd --- /dev/null +++ b/ui/src/app/components/core/error-message/error-message.component.ts @@ -0,0 +1,26 @@ +import { Component, Input } from '@angular/core'; +import { FORM_ERROR_MESSAGES } from '../../../constants/messages.constants'; +import { NgIf } from '@angular/common'; + +@Component({ + selector: 'app-error-message', + templateUrl: './error-message.component.html', + styleUrls: ['./error-message.component.scss'], + standalone: true, + imports: [NgIf], +}) +export class ErrorMessageComponent { + @Input() errorControl!: any; + + get errorMessages() { + const { errors } = this.errorControl; + if (!errors) { + return; + } + const errorKeys = Object.keys(errors); + const errorMessages = errorKeys.map((key) => { + return FORM_ERROR_MESSAGES[key]; + }); + return errorMessages.join(', '); + } +} diff --git a/ui/src/app/components/core/input-field/input-field.component.html b/ui/src/app/components/core/input-field/input-field.component.html new file mode 100644 index 0000000..1cb3b16 --- /dev/null +++ b/ui/src/app/components/core/input-field/input-field.component.html @@ -0,0 +1,20 @@ +<div class="my-2"> + <label + [for]="elementId" + class="block mb-2 text-sm font-medium text-secondary-500" + *ngIf="showLabel" + > + {{ elementName }} + <span class="text-red-500 text-xs" *ngIf="required">*</span> + </label> + <input + [type]="elementType" + [id]="elementId" + (change)="onChange($event.target)" + (keydown.enter)="onKeyDown()" + [value]="value" + [disabled]="isDisabled" + class="border border-secondary-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 disabled:bg-secondary-100" + [placeholder]="elementPlaceHolder" + /> +</div> diff --git a/ui/src/app/components/core/input-field/input-field.component.scss b/ui/src/app/components/core/input-field/input-field.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/components/core/input-field/input-field.component.spec.ts b/ui/src/app/components/core/input-field/input-field.component.spec.ts new file mode 100644 index 0000000..2bdc524 --- /dev/null +++ b/ui/src/app/components/core/input-field/input-field.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { InputFieldComponent } from './input-field.component'; + +describe('InputFieldComponent', () => { + let component: InputFieldComponent; + let fixture: ComponentFixture<InputFieldComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [InputFieldComponent] + }); + fixture = TestBed.createComponent(InputFieldComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/components/core/input-field/input-field.component.ts b/ui/src/app/components/core/input-field/input-field.component.ts new file mode 100644 index 0000000..1c93259 --- /dev/null +++ b/ui/src/app/components/core/input-field/input-field.component.ts @@ -0,0 +1,58 @@ +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { NgIf } from '@angular/common'; + +@Component({ + selector: 'app-input-field', + templateUrl: './input-field.component.html', + styleUrls: ['./input-field.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: InputFieldComponent, + multi: true, + }, + ], + standalone: true, + imports: [NgIf], +}) +export class InputFieldComponent implements ControlValueAccessor { + @Input() elementId!: string; + @Input() elementName!: string; + @Input() elementPlaceHolder!: string; + @Input() required?: boolean = false; + @Input() elementType: string = 'text'; + @Input() showLabel: boolean = true; + + @Output() enterPressed = new EventEmitter<void>(); + + value: string = ''; + onValueChangeFn: any; + onTouchFn: any; + isDisabled: boolean = false; + + onChange(target: any) { + this.onValueChangeFn(target.value); + this.onTouchFn(); + } + + onKeyDown() { + this.enterPressed.emit(); + } + + writeValue(obj: any): void { + this.value = obj; + } + + registerOnChange(fn: any): void { + this.onValueChangeFn = fn; + } + + registerOnTouched(fn: any): void { + this.onTouchFn = fn; + } + + setDisabledState?(isDisabled: boolean): void { + this.isDisabled = isDisabled; + } +} diff --git a/ui/src/app/components/core/list-item/list-item.component.html b/ui/src/app/components/core/list-item/list-item.component.html new file mode 100644 index 0000000..e6b6bc4 --- /dev/null +++ b/ui/src/app/components/core/list-item/list-item.component.html @@ -0,0 +1,25 @@ +<div class="grid grid-cols-1 gap-5 sm:gap-6 lg:gap-6 mt-3"> + <div class="col-span-1 flex rounded-md shadow-sm"> + <div + class="flex flex-1 items-center justify-between truncate rounded-lg border border-gray-200 bg-white cursor-pointer hover:bg-secondary-50 transition-colors relative" + > + <div class="flex-1 truncate p-4 text-sm"> + <a class="font-semibold text-secondary-500 text-sm"> + {{ tag }} + <span *ngIf="payload.jiraTicketId" + >• {{ payload.jiraTicketId }}</span + > + </a> + <h1 class="text-gray-900 text-base font-medium truncate mt-2"> + {{ payload.name }} + </h1> + <p class="text-gray-500 text-xs leading-4 pt-2 text-wrap"> + {{ payload.description | truncateWithEllipsis }} + </p> + </div> + <div class="flex-shrink-0 pr-2 justify-between gap-x-6"> + <ng-content /> + </div> + </div> + </div> +</div> diff --git a/ui/src/app/components/core/list-item/list-item.component.scss b/ui/src/app/components/core/list-item/list-item.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/components/core/list-item/list-item.component.spec.ts b/ui/src/app/components/core/list-item/list-item.component.spec.ts new file mode 100644 index 0000000..48b27b8 --- /dev/null +++ b/ui/src/app/components/core/list-item/list-item.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ListItemComponent } from './list-item.component'; + +describe('ListItemComponent', () => { + let component: ListItemComponent; + let fixture: ComponentFixture<ListItemComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ListItemComponent] + }); + fixture = TestBed.createComponent(ListItemComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/components/core/list-item/list-item.component.ts b/ui/src/app/components/core/list-item/list-item.component.ts new file mode 100644 index 0000000..aa3f43f --- /dev/null +++ b/ui/src/app/components/core/list-item/list-item.component.ts @@ -0,0 +1,20 @@ +import { Component, Input } from '@angular/core'; +import { NgIf } from '@angular/common'; +import { TruncateEllipsisPipe } from '../../../pipes/truncate-ellipsis-pipe'; + +@Component({ + selector: 'app-list-item', + templateUrl: './list-item.component.html', + styleUrls: ['./list-item.component.scss'], + standalone: true, + imports: [TruncateEllipsisPipe, NgIf], +}) +export class ListItemComponent { + @Input() payload!: { + name: string; + description: string; + id: string; + jiraTicketId?: string; + }; + @Input() tag!: string; +} diff --git a/ui/src/app/components/core/loading/loading.component.html b/ui/src/app/components/core/loading/loading.component.html new file mode 100644 index 0000000..b647969 --- /dev/null +++ b/ui/src/app/components/core/loading/loading.component.html @@ -0,0 +1,4 @@ +<ngx-loading + [show]="isLoading" + [config]="{ backdropBorderRadius: '3px', fullScreenBackdrop: true }" +/> \ No newline at end of file diff --git a/ui/src/app/components/core/loading/loading.component.scss b/ui/src/app/components/core/loading/loading.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/components/core/loading/loading.component.spec.ts b/ui/src/app/components/core/loading/loading.component.spec.ts new file mode 100644 index 0000000..5bb22ce --- /dev/null +++ b/ui/src/app/components/core/loading/loading.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LoadingComponent } from './loading.component'; + +describe('LoadingComponent', () => { + let component: LoadingComponent; + let fixture: ComponentFixture<LoadingComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [LoadingComponent] + }); + fixture = TestBed.createComponent(LoadingComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/components/core/loading/loading.component.ts b/ui/src/app/components/core/loading/loading.component.ts new file mode 100644 index 0000000..43af16a --- /dev/null +++ b/ui/src/app/components/core/loading/loading.component.ts @@ -0,0 +1,27 @@ +import { Component, ChangeDetectorRef, AfterViewInit } from '@angular/core'; +import { LoadingService } from '../../../services/loading.service'; +import { NgxLoadingModule } from 'ngx-loading'; + +@Component({ + selector: 'app-loading', + templateUrl: './loading.component.html', + styleUrls: ['./loading.component.scss'], + standalone: true, + imports: [NgxLoadingModule], +}) +export class LoadingComponent implements AfterViewInit { + loading$ = this.loadingService.loading$; + isLoading = false; + + constructor( + private loadingService: LoadingService, + private cdr: ChangeDetectorRef, + ) {} + + ngAfterViewInit() { + this.loading$.subscribe((change) => { + this.isLoading = change; + this.cdr.detectChanges(); + }); + } +} diff --git a/ui/src/app/components/core/search-input/search-input.component.html b/ui/src/app/components/core/search-input/search-input.component.html new file mode 100644 index 0000000..fe7f0f7 --- /dev/null +++ b/ui/src/app/components/core/search-input/search-input.component.html @@ -0,0 +1,16 @@ +<div class="mt-4 relative"> + <div + class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none" + > + <ng-icon + name="heroMagnifyingGlass" + class="h-5 w-5 text-secondary-500" + ></ng-icon> + </div> + <input + [formControl]="searchControl" + type="text" + [placeholder]="placeholder" + class="w-full pl-10 px-4 py-2 border border-secondary-300 rounded-md text-secondary-500 focus:outline-none" + /> +</div> diff --git a/ui/src/app/components/core/search-input/search-input.component.scss b/ui/src/app/components/core/search-input/search-input.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/components/core/search-input/search-input.component.ts b/ui/src/app/components/core/search-input/search-input.component.ts new file mode 100644 index 0000000..cab74f7 --- /dev/null +++ b/ui/src/app/components/core/search-input/search-input.component.ts @@ -0,0 +1,28 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { distinctUntilChanged } from 'rxjs/operators'; +import { heroMagnifyingGlass } from '@ng-icons/heroicons/outline'; +import { NgIconComponent, provideIcons } from '@ng-icons/core'; + +@Component({ + selector: 'app-search-input', + templateUrl: './search-input.component.html', + styleUrls: ['./search-input.component.scss'], + standalone: true, + imports: [ReactiveFormsModule, NgIconComponent], + viewProviders: [provideIcons({ heroMagnifyingGlass })] +}) +export class SearchInputComponent { + @Input() placeholder: string = 'Search...'; + @Output() searchChange = new EventEmitter<string>(); + + searchControl = new FormControl(''); + + constructor() { + this.searchControl.valueChanges + .pipe(distinctUntilChanged()) + .subscribe((value) => { + this.searchChange.emit(value?.toLowerCase() || ''); + }); + } +} diff --git a/ui/src/app/components/core/textarea-field/textarea-field.component.html b/ui/src/app/components/core/textarea-field/textarea-field.component.html new file mode 100644 index 0000000..462e868 --- /dev/null +++ b/ui/src/app/components/core/textarea-field/textarea-field.component.html @@ -0,0 +1,18 @@ +<div class="my-2"> + <label + *ngIf="showLabel" + [for]="elementId" + class="block mb-2 text-sm font-medium text-secondary-500" + >{{ elementName }} + <span class="text-red-500 text-xs" *ngIf="required">*</span> + </label> + <textarea + #textarea + [id]="elementId" + [rows]="rows" + (input)="onChange($event.target)" + class="block p-2.5 w-full text-sm text-gray-900 rounded-lg border border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 resize-none overflow-auto" + [placeholder]="elementPlaceHolder" + [value]="value" + ></textarea> +</div> diff --git a/ui/src/app/components/core/textarea-field/textarea-field.component.scss b/ui/src/app/components/core/textarea-field/textarea-field.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/components/core/textarea-field/textarea-field.component.spec.ts b/ui/src/app/components/core/textarea-field/textarea-field.component.spec.ts new file mode 100644 index 0000000..661b038 --- /dev/null +++ b/ui/src/app/components/core/textarea-field/textarea-field.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TextareaFieldComponent } from './textarea-field.component'; + +describe('TextareaFieldComponent', () => { + let component: TextareaFieldComponent; + let fixture: ComponentFixture<TextareaFieldComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [TextareaFieldComponent] + }); + fixture = TestBed.createComponent(TextareaFieldComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/components/core/textarea-field/textarea-field.component.ts b/ui/src/app/components/core/textarea-field/textarea-field.component.ts new file mode 100644 index 0000000..06af807 --- /dev/null +++ b/ui/src/app/components/core/textarea-field/textarea-field.component.ts @@ -0,0 +1,53 @@ +import { Component, Input, ViewChild, ElementRef } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { NgIf } from '@angular/common'; + +@Component({ + selector: 'app-textarea-field', + templateUrl: './textarea-field.component.html', + styleUrls: ['./textarea-field.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: TextareaFieldComponent, + multi: true, + }, + ], + standalone: true, + imports: [NgIf], +}) +export class TextareaFieldComponent implements ControlValueAccessor { + @Input() elementPlaceHolder!: string; + @Input() elementId!: string; + @Input() elementName!: string; + @Input() required?: boolean = false; + @Input() rows: number = 18; + @Input() showLabel: boolean = true; + @ViewChild('textarea') textarea!: ElementRef<HTMLTextAreaElement>; + + value: string = ''; + onTouchedFn: any; + onValueChangeFn: any; + isDisabled: boolean = false; + + onChange(target: any) { + this.onValueChangeFn(target.value); + this.onTouchedFn(); + } + + writeValue(obj: any): void { + this.value = obj; + } + + registerOnChange(fn: any): void { + this.onValueChangeFn = fn; + } + + registerOnTouched(fn: any): void { + this.onTouchedFn = fn; + } + + setDisabledState?(isDisabled: boolean): void { + this.isDisabled = isDisabled; + } +} diff --git a/ui/src/app/components/document-listing/document-listing.component.html b/ui/src/app/components/document-listing/document-listing.component.html new file mode 100644 index 0000000..9710cf3 --- /dev/null +++ b/ui/src/app/components/document-listing/document-listing.component.html @@ -0,0 +1,156 @@ +<div + *ngIf="documentList$ | async as projectList; else noProjectList" + class="container mx-auto px-6 py-2" +> + <div class="mb-4"> + <div class="flex items-center mt-2 justify-between"> + <div class="flex items-center"> + <h1 class="text-normal font-semibold text-gray-700"> + {{ getDescription(projectList[0].folderName) }} + </h1> + <app-badge [badgeText]="projectList.length" /> + </div> + <app-button + buttonContent="Add" + icon="heroPlus" + theme="secondary" + size="sm" + rounded="lg" + (click)="navigateToAdd(projectList[0].id, projectList[0].folderName)" + /> + </div> + <app-search-input + *ngIf="projectList.length > 0" + placeholder="Search..." + (searchChange)="onSearch($event)" + ></app-search-input> + </div> + <div class="doc-section-height"> + <ng-container *ngIf="filteredDocumentList$ | async as filteredProjectList"> + <div + *ngIf="projectList.length > 0; else noDocuments" + class="grid grid-cols-1 gap-3 sm:gap-4 lg:gap-4 overflow-y-auto" + > + <ng-container + *ngIf="filteredProjectList.length > 0; else noSearchResults" + > + <div + *ngFor="let item of filteredProjectList" + (click)="navigateToEdit(item)" + (keydown.enter)="navigateToEdit(item)" + (keydown.space)="navigateToEdit(item); $event.preventDefault()" + class="col-span-1 flex rounded-md shadow-sm relative" + role="button" + tabindex="0" + > + <div + class="flex flex-1 items-center justify-between truncate rounded-lg border border-gray-200 bg-white hover:bg-secondary-50 transition-colors" + > + <div class="flex-1 truncate p-4 text-sm"> + <a class="font-semibold text-secondary-500"> + {{ item.fileName.replace("-base.json", "") }} + </a> + <h1 + class="doc-section__item-title text-base pt-2 pb-1 font-medium truncate pr-[80px]" + > + {{ item.content.title }} + </h1> + <div *ngIf="item.folderName === requirementTypes.PRD"> + <ng-container *ngIf="item.content.requirement as requirement"> + <p class="text-gray-500 text-wrap"> + {{ + getTruncatedRequirement( + item.content.requirement + .split("Screens:")[0] + .split("Personas:")[0] + ) + }} + </p> + <div *ngIf="item.content.requirement?.includes('Screens:')"> + <h4 class="text-sm pt-2 pb-1 font-medium truncate"> + Screens: + </h4> + <p class="text-gray-500 text-wrap"> + {{ + getTruncatedRequirement( + item.content.requirement + .split("Screens:")[1] + .split("Personas:")[0] + ) + }} + </p> + </div> + <div + *ngIf="item.content.requirement?.includes('Personas:')" + > + <h4 class="text-sm pt-2 pb-1 font-medium truncate"> + Personas: + </h4> + <p class="text-gray-500 text-wrap"> + {{ + getTruncatedRequirement( + item.content.requirement.split("Personas:").pop() + ) + }} + </p> + </div> + </ng-container> + </div> + + <p + *ngIf="item.folderName !== requirementTypes.PRD" + class="text-[#666666] text-xs text-wrap leading-4" + > + {{ getTruncatedRequirement(item.content.requirement) }} + </p> + </div> + <div class="absolute top-4 right-4 flex space-x-2"> + <app-button + *ngIf="item.folderName === requirementTypes.PRD" + routerLink="/user-stories/{{ item.id }}" + [state]="{ + data: this.appInfo, + id: item.id, + folderName: item?.folderName, + fileName: item?.fileName, + req: item.content, + }" + buttonContent="Stories" + theme="secondary_outline" + size="sm" + rounded="lg" + /> + <div + *ngIf="item.folderName === 'BP'" + (click)="navigateToBPFlow(item)" + (keydown.enter)="navigateToBPFlow(item)" + (keydown.space)="navigateToBPFlow(item); $event.preventDefault()" + role="button" + tabindex="0" + class="inline-flex h-8 w-8 items-center justify-center rounded-full bg-transparent bg-white text-gray-400 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" + > + <ng-icon + class="text-xl" + name="heroArrowsPointingOut" + ></ng-icon> + </div> + </div> + </div> + </div> + </ng-container> + </div> + </ng-container> + </div> +</div> + +<ng-template #noProjectList> + <p class="text-center text-gray-500">No project list available.</p> +</ng-template> + +<ng-template #noDocuments> + <p class="text-center text-gray-500">No documents available.</p> +</ng-template> + +<ng-template #noSearchResults> + <p class="text-center text-gray-500">No search results found.</p> +</ng-template> \ No newline at end of file diff --git a/ui/src/app/components/document-listing/document-listing.component.scss b/ui/src/app/components/document-listing/document-listing.component.scss new file mode 100644 index 0000000..6f216d7 --- /dev/null +++ b/ui/src/app/components/document-listing/document-listing.component.scss @@ -0,0 +1,8 @@ +.doc-section-height { + height: calc(100vh - 275px); + overflow: auto; +} + +.doc-section__item-title { + padding-right: 80px; +} \ No newline at end of file diff --git a/ui/src/app/components/document-listing/document-listing.component.spec.ts b/ui/src/app/components/document-listing/document-listing.component.spec.ts new file mode 100644 index 0000000..7300354 --- /dev/null +++ b/ui/src/app/components/document-listing/document-listing.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DocumentListingComponent } from './document-listing.component'; + +describe('DocumentListingComponent', () => { + let component: DocumentListingComponent; + let fixture: ComponentFixture<DocumentListingComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [DocumentListingComponent] + }); + fixture = TestBed.createComponent(DocumentListingComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/components/document-listing/document-listing.component.ts b/ui/src/app/components/document-listing/document-listing.component.ts new file mode 100644 index 0000000..2e3e72e --- /dev/null +++ b/ui/src/app/components/document-listing/document-listing.component.ts @@ -0,0 +1,229 @@ +import { Component, Input, OnInit, OnDestroy, AfterViewInit } from '@angular/core'; +import { Store } from '@ngxs/store'; +import { ProjectsState } from '../../store/projects/projects.state'; +import { BehaviorSubject, combineLatest, Observable, Subscription, first } from 'rxjs'; +import { BulkReadFiles } from '../../store/projects/projects.actions'; +import { + getDescriptionFromInput, + truncateWithEllipsis, +} from '../../utils/common.utils'; +import { filter, map, switchMap } from 'rxjs/operators'; +import { Router, RouterLink } from '@angular/router'; +import { IList } from '../../model/interfaces/IList'; +import { RequirementTypeEnum } from '../../model/enum/requirement-type.enum'; +import { AsyncPipe, NgForOf, NgIf } from '@angular/common'; +import { BadgeComponent } from '../core/badge/badge.component'; +import { ButtonComponent } from '../core/button/button.component'; +import { NgIconComponent } from '@ng-icons/core'; +import { SearchInputComponent } from '../core/search-input/search-input.component'; +import { SearchService } from '../../services/search/search.service'; +import { APP_INFO_COMPONENT_ERROR_MESSAGES } from '../../constants/messages.constants'; +import { ToasterService } from 'src/app/services/toaster/toaster.service'; + +@Component({ + selector: 'app-document-listing', + templateUrl: './document-listing.component.html', + styleUrls: ['./document-listing.component.scss'], + standalone: true, + imports: [ + NgIf, + AsyncPipe, + BadgeComponent, + ButtonComponent, + RouterLink, + NgIconComponent, + NgForOf, + SearchInputComponent, + ], +}) +export class DocumentListingComponent implements OnInit, OnDestroy, AfterViewInit { + loadingProjectFiles$ = this.store.select(ProjectsState.loadingProjectFiles); + requirementTypes: any = RequirementTypeEnum; + private searchTerm$ = new BehaviorSubject<string>(''); + + appInfo: any = {}; + originalDocumentList$: Observable<IList[]> = this.store.select(ProjectsState.getSelectedFileContents); + documentList$!: Observable<(IList & { id: string })[]>; + filteredDocumentList$!: Observable<(IList & { id: string })[]>; + selectedFolder: any = {}; + private combinedSubject = new BehaviorSubject<{ title: string; id: string }>({ title: '', id: '' }); + private subscription: Subscription = new Subscription(); + private scrollContainer: HTMLElement | null = null; + + @Input() set folder(value: { title: string; id: string; metadata: any }) { + this.appInfo = value.metadata; + this.selectedFolder = value; + this.combinedSubject.next({ title: value.title, id: value.id }); + + // Reset scroll position when a new folder is set + if (this.scrollContainer) { + this.scrollContainer.scrollTop = 0; + } + } + + currentRoute: string; + constructor( + private store: Store, + private router: Router, + private searchService: SearchService, + private toast: ToasterService) { + this.currentRoute = this.router.url; + this.documentList$ = combineLatest([ + this.originalDocumentList$, + this.combinedSubject, + ]).pipe( + map(([documents, folder]) => + documents.map((doc) => ({ + ...doc, + id: folder.id, + })), + ), + ); + + this.filteredDocumentList$ = this.searchService.filterItems( + this.documentList$, + this.searchTerm$, + (doc) => [doc.fileName, doc.content?.title], + ); + } + + onSearch(term: string) { + this.searchTerm$.next(term); + } + + ngOnInit() { + this.subscription.add( + combineLatest([this.combinedSubject, this.loadingProjectFiles$]) + .pipe( + filter(([folder, isLoading]) => !!folder && !isLoading), + switchMap(([folder, _]) => { + return this.store.dispatch(new BulkReadFiles(folder.title)); + }), + ) + .subscribe(), + ); + } + + ngAfterViewInit() { + // Set up the scroll container reference to the correct element + this.scrollContainer = document.querySelector('.doc-section-height'); + + // Add scroll event listener to the scrollable container + if (this.scrollContainer) { + this.scrollContainer.addEventListener('scroll', () => { + this.saveScrollPosition(); + }); + } + + // Restore scroll position if available + this.restoreScrollPosition(); + } + + ngOnDestroy() { + if (this.subscription) { + this.subscription.unsubscribe(); + } + + this.saveScrollPosition(); + if (this.scrollContainer) { + this.scrollContainer.removeEventListener('scroll', this.saveScrollPosition.bind(this)); // Clean up event listener + } + } + + private saveScrollPosition() { + if (this.scrollContainer) { + const scrollY = this.scrollContainer.scrollTop; + sessionStorage.setItem('scrollPosition', scrollY.toString()); + } + } + + private restoreScrollPosition() { + const savedScrollPosition = sessionStorage.getItem('scrollPosition'); + if (savedScrollPosition && this.scrollContainer) { + this.scrollContainer.scrollTop = parseInt(savedScrollPosition, 10); + } + } + + navigateToEdit({ id, folderName, fileName, content }: any) { + const url = folderName === this.requirementTypes.BP ? '/bp-edit' : '/edit'; + this.router.navigate([url], { + state: { data: this.appInfo, id, folderName, fileName, req: content }, + }); + } + + navigateToAdd(id: any, folderName: any) { + if (folderName === this.requirementTypes.BP) { + // Check if any non-archived PRD or BRD exists + this.store.select(ProjectsState.getProjectsFolders).pipe(first()).subscribe(directories => { + const prdDir = directories.find(dir => dir.name === 'PRD'); + const brdDir = directories.find(dir => dir.name === 'BRD'); + + // For PRD, only check base files that aren't archived + const hasPRD = prdDir && prdDir.children + .filter(child => child.includes('-base.json')) + .some(child => !child.includes('-archived')); + + // For BRD, only check base files that aren't archived + const hasBRD = brdDir && brdDir.children + .filter(child => child.includes('-base.json')) + .some(child => !child.includes('-archived')); + + if (!hasPRD && !hasBRD) { + this.toast.showWarning(APP_INFO_COMPONENT_ERROR_MESSAGES.REQUIRES_PRD_OR_BRD); + return; + } + + this.router.navigate(['/bp-add'], { + state: { + data: this.appInfo, + id, + folderName, + breadcrumb: { + name: 'Add Document', + link: this.currentRoute, + icon: 'add', + }, + }, + }); + }); + } else { + this.router.navigate(['/add'], { + state: { + data: this.appInfo, + id, + folderName, + breadcrumb: { + name: 'Add Document', + link: this.currentRoute, + icon: 'add', + }, + }, + }); + } + } + + navigateToBPFlow(item: any) { + this.router.navigate(['/bp-flow/view', item.id], { + state: { + data: this.appInfo, + id: item.id, + folderName: item.folderName, + fileName: item.fileName, + req: item.content, + selectedFolder: { + title: item.folderName, + id: this.appInfo.id, + metadata: this.appInfo, + }, + }, + }); + } + + getDescription(input: string | undefined): string | null { + return getDescriptionFromInput(input); + } + + getTruncatedRequirement(requirement: string | undefined): string | null { + return truncateWithEllipsis(requirement); + } +} diff --git a/ui/src/app/components/dropdown/dropdown.component.html b/ui/src/app/components/dropdown/dropdown.component.html new file mode 100644 index 0000000..6290341 --- /dev/null +++ b/ui/src/app/components/dropdown/dropdown.component.html @@ -0,0 +1,23 @@ +<div class="relative"> + <button class="w-full bg-white border border-gray-300 text-gray-700 py-2 px-4 rounded-md flex items-center justify-between focus:outline-none" + (click)="toggleDropdown()"> + <span>{{ selectedOption || 'Select an option' }}</span> + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> + <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 011.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/> + </svg> + </button> + + <ul *ngIf="isOpen" class="absolute z-10 w-full bg-white border border-gray-200 rounded-md mt-2 shadow-lg"> + <li + *ngFor="let option of options" + (click)="selectOption(option)" + (keydown.enter)="selectOption(option)" + (keydown.space)="selectOption(option); $event.preventDefault()" + class="px-4 py-2 cursor-pointer hover:bg-gray-100" + role="button" + tabindex="0" + > + {{ option }} + </li> + </ul> +</div> diff --git a/ui/src/app/components/dropdown/dropdown.component.scss b/ui/src/app/components/dropdown/dropdown.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/components/dropdown/dropdown.component.spec.ts b/ui/src/app/components/dropdown/dropdown.component.spec.ts new file mode 100644 index 0000000..84ca80b --- /dev/null +++ b/ui/src/app/components/dropdown/dropdown.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DropdownComponent } from './dropdown.component'; + +describe('DropdownComponent', () => { + let component: DropdownComponent; + let fixture: ComponentFixture<DropdownComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [DropdownComponent] + }); + fixture = TestBed.createComponent(DropdownComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/components/dropdown/dropdown.component.ts b/ui/src/app/components/dropdown/dropdown.component.ts new file mode 100644 index 0000000..674037c --- /dev/null +++ b/ui/src/app/components/dropdown/dropdown.component.ts @@ -0,0 +1,72 @@ +import { + Component, + ElementRef, + EventEmitter, + Input, + Output, + Renderer2, + OnDestroy, +} from '@angular/core'; + +@Component({ + selector: 'hai-dropdown', + templateUrl: './dropdown.component.html', + styleUrls: ['./dropdown.component.scss'], +}) +export class DropdownComponent implements OnDestroy { + @Input() selectedOption: string = ''; + @Input() options: string[] = []; + @Output() selectionChange = new EventEmitter<string>(); + + isOpen = false; + private globalClickListener: (() => void) | null = null; + + constructor( + private elementRef: ElementRef, + private renderer: Renderer2, + ) {} + + toggleDropdown() { + this.isOpen = !this.isOpen; + + if (this.isOpen) { + this.globalClickListener = this.renderer.listen( + 'document', + 'click', + (event: Event) => { + this.handleClickOutside(event); + }, + ); + } else { + this.removeGlobalClickListener(); + } + } + + selectOption(option: string) { + this.selectedOption = option; + this.selectionChange.emit(option); + this.closeDropdown(); + } + + handleClickOutside(event: Event) { + if (!this.elementRef.nativeElement.contains(event.target)) { + this.closeDropdown(); + } + } + + closeDropdown() { + this.isOpen = false; + this.removeGlobalClickListener(); + } + + removeGlobalClickListener() { + if (this.globalClickListener) { + this.globalClickListener(); + this.globalClickListener = null; + } + } + + ngOnDestroy() { + this.removeGlobalClickListener(); + } +} diff --git a/ui/src/app/components/layout/footer/footer.component.html b/ui/src/app/components/layout/footer/footer.component.html new file mode 100644 index 0000000..349e138 --- /dev/null +++ b/ui/src/app/components/layout/footer/footer.component.html @@ -0,0 +1,12 @@ +<footer *ngIf="authService.isLoggedIn$ | async"> + <div class="mx-auto px-8 py-3 border-t"> + <div + class="flex items-center justify-center space-x-1 text-center text-xs text-secondary-900 relative"> + <ng-container *ngIf="themeConfiguration.companyName.length > 0"> + <span>Accelerate SDLC process with {{ themeConfiguration.companyName }}</span> + </ng-container> + <span class="px-2 mx-2 font-medium text-xs text-white absolute right-0 bg-primary-400 rounded-full" + *ngIf="version.length > 0">{{ version }}</span> + </div> + </div> +</footer> diff --git a/ui/src/app/components/layout/footer/footer.component.scss b/ui/src/app/components/layout/footer/footer.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/components/layout/footer/footer.component.spec.ts b/ui/src/app/components/layout/footer/footer.component.spec.ts new file mode 100644 index 0000000..832b03a --- /dev/null +++ b/ui/src/app/components/layout/footer/footer.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FooterComponent } from './footer.component'; + +describe('FooterComponent', () => { + let component: FooterComponent; + let fixture: ComponentFixture<FooterComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [FooterComponent] + }); + fixture = TestBed.createComponent(FooterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/components/layout/footer/footer.component.ts b/ui/src/app/components/layout/footer/footer.component.ts new file mode 100644 index 0000000..05f5b10 --- /dev/null +++ b/ui/src/app/components/layout/footer/footer.component.ts @@ -0,0 +1,19 @@ +import { Component, inject } from '@angular/core'; +import { environment } from '../../../../environments/environment'; +import { AuthService } from '../../../services/auth/auth.service'; +import { AsyncPipe, NgIf } from '@angular/common'; + +@Component({ + selector: 'app-footer', + templateUrl: './footer.component.html', + styleUrls: ['./footer.component.scss'], + standalone: true, + imports: [NgIf, AsyncPipe], +}) +export class FooterComponent { + protected themeConfiguration = environment.ThemeConfiguration; + + authService = inject(AuthService); + version: string = environment.APP_VERSION; + currentYear = new Date().getFullYear(); +} diff --git a/ui/src/app/components/layout/header/header.component.html b/ui/src/app/components/layout/header/header.component.html new file mode 100644 index 0000000..ca5670b --- /dev/null +++ b/ui/src/app/components/layout/header/header.component.html @@ -0,0 +1,69 @@ +<ng-container *ngIf="(authService.isLoggedIn$ | async) === true"> + <div class="relative bg-secondary-950"> + <div + class="flex items-center justify-between py-4 px-8" + > + <div class="hidden lg:flex lg:items-center lg:pr-0.5"> + <a routerLink="/apps"> + <ng-container *ngIf="themeConfiguration.appLogo.length > 0;else showAppName"> + <span class="sr-only">{{ themeConfiguration.appName }}</span> + <img [src]="themeConfiguration.appLogo" class="mx-auto h-6" [alt]="themeConfiguration.appName"/> + </ng-container> + <ng-template #showAppName> + <h1 class="text-2xl text-secondary-50">{{ themeConfiguration.appName }}</h1> + </ng-template> + </a> + </div> + + <!-- Logo --> + <div + class="flex items-center gap-4" + > + <img + *ngIf="themeConfiguration.companyLogo.length > 0" + class="h-6 w-auto" + [src]="themeConfiguration.companyLogo" + [alt]="themeConfiguration.companyName" + /> + <div class="grid gap-2 grid-cols-3"> + + <div + class="flex justify-center items-center h-8 w-8 rounded-full bg-transparent text-gray-400 hover:text-gray-600 hover:bg-gray-200 cursor-pointer" + role="button" + tabindex="0" + (click)="openSettingsModal()" + (keydown.enter)="openSettingsModal()" + (keydown.space)="openSettingsModal(); $event.preventDefault()" + > + <ng-icon name="heroCog8Tooth"/> + </div> + + <div + class="flex justify-center items-center h-8 w-8 rounded-full bg-transparent text-gray-400 hover:text-gray-600 hover:bg-gray-200 cursor-pointer" + role="button" + tabindex="0" + (click)="openSelectRootDirectoryModal()" + (keydown.enter)="openSelectRootDirectoryModal()" + (keydown.space)="openSelectRootDirectoryModal(); $event.preventDefault()" + > + <ng-icon name="heroFolder"/> + </div> + <div + class="flex justify-center items-center h-8 w-8 rounded-full bg-transparent text-gray-400 hover:text-gray-600 hover:bg-gray-200 cursor-pointer" + role="button" + tabindex="0" + (click)="logout()" + (keydown.enter)="logout()" + (keydown.space)="logout(); $event.preventDefault()" + > + <ng-icon name="heroArrowRightOnRectangle"/> + </div> + + </div> + </div> + </div> + </div> + <div class="w-full px-8 py-3 flex items-center justify-start"> + <app-breadcrumbs/> + </div> +</ng-container> diff --git a/ui/src/app/components/layout/header/header.component.scss b/ui/src/app/components/layout/header/header.component.scss new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ui/src/app/components/layout/header/header.component.scss @@ -0,0 +1 @@ + diff --git a/ui/src/app/components/layout/header/header.component.spec.ts b/ui/src/app/components/layout/header/header.component.spec.ts new file mode 100644 index 0000000..f8d8ed5 --- /dev/null +++ b/ui/src/app/components/layout/header/header.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { HeaderComponent } from './header.component'; + +describe('HeaderComponent', () => { + let component: HeaderComponent; + let fixture: ComponentFixture<HeaderComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [HeaderComponent] + }); + fixture = TestBed.createComponent(HeaderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/components/layout/header/header.component.ts b/ui/src/app/components/layout/header/header.component.ts new file mode 100644 index 0000000..cc9d7c1 --- /dev/null +++ b/ui/src/app/components/layout/header/header.component.ts @@ -0,0 +1,98 @@ +import { Component, inject } from '@angular/core'; +import { APP_CONSTANTS } from '../../../constants/app.constants'; +import { SelectRootDirectoryComponent } from '../../select-root-directory/select-root-directory.component'; +import { MatDialog } from '@angular/material/dialog'; +import { ElectronService } from '../../../services/electron/electron.service'; +import { NGXLogger } from 'ngx-logger'; +import { Router, RouterLink } from '@angular/router'; +import { LlmSettingsComponent } from '../../llm-settings/llm-settings.component'; +import { AuthService } from '../../../services/auth/auth.service'; +import { environment } from '../../../../environments/environment'; +import { NgIconComponent, provideIcons } from '@ng-icons/core'; +import { BreadcrumbsComponent } from '../../core/breadcrumbs/breadcrumbs.component'; +import { AsyncPipe, NgIf } from '@angular/common'; +import { + heroArrowRightOnRectangle, + heroCog8Tooth, + heroFolder, +} from '@ng-icons/heroicons/outline'; +import { ConfirmationDialogComponent } from '../../../components/confirmation-dialog/confirmation-dialog.component'; +import { CONFIRMATION_DIALOG } from '../../../constants/app.constants'; + +@Component({ + selector: 'app-header', + templateUrl: './header.component.html', + styleUrls: ['./header.component.scss'], + standalone: true, + imports: [BreadcrumbsComponent, NgIconComponent, NgIf, RouterLink, AsyncPipe], + viewProviders: [ + provideIcons({ heroCog8Tooth, heroFolder, heroArrowRightOnRectangle }), + ], +}) +export class HeaderComponent { + protected themeConfiguration = environment.ThemeConfiguration; + + authService = inject(AuthService); + electronService = inject(ElectronService); + logger = inject(NGXLogger); + dialog = inject(MatDialog); + router = inject(Router); + + /** + * Prompts the user to select a root directory, saves the selected directory to local storage, + * and navigates to the '/apps' route or reloads the current page based on the current URL. + * + * @return {Promise<void>} A promise that resolves when the directory selection and navigation are complete. + */ + async selectRootDirectory(): Promise<void> { + const response = await this.electronService.openDirectory(); + this.logger.debug(response); + if (response.length > 0) { + localStorage.setItem(APP_CONSTANTS.WORKING_DIR, response[0]); + const currentConfig = await this.electronService.getStoreValue("APP_CONFIG") || {}; + const updatedConfig = { ...currentConfig, directoryPath: response[0] }; + await this.electronService.setStoreValue("APP_CONFIG", updatedConfig); + + this.logger.debug('===>', this.router.url); + if (this.router.url === '/apps') { + await this.electronService.reloadApp(); + } else { + await this.router.navigate(['/apps']); + } + } + } + + openSelectRootDirectoryModal() { + const modalRef = this.dialog.open(SelectRootDirectoryComponent, { + disableClose: true, + }); + + modalRef.afterClosed().subscribe((res) => { + if (res === true) { + this.selectRootDirectory().then(); + } + }); + } + + openSettingsModal() { + this.dialog.open(LlmSettingsComponent, { + disableClose: true, + }); + } + + logout() { + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + width: '500px', + data: { + title: CONFIRMATION_DIALOG.LOGOUT.TITLE, + description: CONFIRMATION_DIALOG.LOGOUT.DESCRIPTION, + cancelButtonText: CONFIRMATION_DIALOG.LOGOUT.CANCEL_BUTTON_TEXT, + proceedButtonText: CONFIRMATION_DIALOG.LOGOUT.PROCEED_BUTTON_TEXT, + }, + }); + + dialogRef.afterClosed().subscribe((res) => { + if (!res) this.authService.logout(); + }); + } +} diff --git a/ui/src/app/components/llm-settings/llm-settings.component.html b/ui/src/app/components/llm-settings/llm-settings.component.html new file mode 100644 index 0000000..39a8b59 --- /dev/null +++ b/ui/src/app/components/llm-settings/llm-settings.component.html @@ -0,0 +1,76 @@ +<div class="modal-wrapper w-[32rem]"> + <div class="modal-header flex items-center justify-between p-4 border-b"> + <h4 class="font-medium">Settings</h4> + <button + type="button" + class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center focus:outline-none focus:ring-0" + data-dismiss="modal" + aria-label="Close" + (click)="closeModal()" + > + <ng-icon name="heroXMark"></ng-icon> + </button> + </div> + <div class="modal-body p-4"> + <!-- Providers Dropdown --> + <div class="gap-4 mb-4"> + <label for="providerSelect" class="block text-sm font-medium text-secondary-500 mb-2">Providers</label> + <div class="relative w-full"> + <select + id="providerSelect" + [formControl]="selectedProvider" + class="block w-full px-4 py-3 pr-10 rounded-lg border-0 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 appearance-none cursor-pointer hover:bg-gray-50 sm:text-sm" + > + <option *ngFor="let provider of availableProviders" [value]="provider.key" class="py-2"> + {{ provider.displayName }} + </option> + </select> + <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-4"> + <svg class="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path> + </svg> + </div> + </div> + </div> + <!-- Model Dropdown --> + <div class="gap-4"> + <label for="modelSelect" class="block text-sm font-medium text-secondary-500 mb-2">Model</label> + <div class="relative w-full"> + <select + id="modelSelect" + [formControl]="selectedModel" + class="block w-full px-4 py-3 pr-10 rounded-lg border-0 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 appearance-none cursor-pointer hover:bg-gray-50 sm:text-sm" + > + <option *ngFor="let model of filteredModels" [value]="model" class="py-2"> + {{ model }} + </option> + </select> + <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-4"> + <svg class="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path> + </svg> + </div> + </div> + </div> + <!-- Error Message --> + <div *ngIf="errorMessage && hasChanges" class="mt-4 p-2 text-danger-600 text-sm"> + {{ errorMessage }} + </div> + </div> + <div *ngIf="hasChanges" class="modal-footer flex justify-end gap-4 p-4"> + <app-button + buttonContent="Cancel" + theme="secondary" + size="sm" + rounded="lg" + (click)="closeModal()" + /> + <app-button + buttonContent="Save" + theme="primary" + size="sm" + rounded="lg" + (click)="onSave()" + /> + </div> +</div> \ No newline at end of file diff --git a/ui/src/app/components/llm-settings/llm-settings.component.scss b/ui/src/app/components/llm-settings/llm-settings.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/components/llm-settings/llm-settings.component.spec.ts b/ui/src/app/components/llm-settings/llm-settings.component.spec.ts new file mode 100644 index 0000000..438c9e6 --- /dev/null +++ b/ui/src/app/components/llm-settings/llm-settings.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LlmSettingsComponent } from './llm-settings.component'; + +describe('LlmSettingsComponent', () => { + let component: LlmSettingsComponent; + let fixture: ComponentFixture<LlmSettingsComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [LlmSettingsComponent] + }); + fixture = TestBed.createComponent(LlmSettingsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/components/llm-settings/llm-settings.component.ts b/ui/src/app/components/llm-settings/llm-settings.component.ts new file mode 100644 index 0000000..42f0bad --- /dev/null +++ b/ui/src/app/components/llm-settings/llm-settings.component.ts @@ -0,0 +1,147 @@ +import { Component, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core'; +import { LLMConfigState } from 'src/app/store/llm-config/llm-config.state'; +import { distinctUntilChanged, Observable, Subscription } from 'rxjs'; +import { LLMConfigModel } from '../../model/interfaces/ILLMConfig'; +import { Store } from '@ngxs/store'; +import { AvailableProviders, providerModelMap } from '../../constants/llm.models.constants'; +import { SetLLMConfig } from '../../store/llm-config/llm-config.actions'; +import { MatDialogRef } from '@angular/material/dialog'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { NgIconComponent } from '@ng-icons/core'; +import { NgForOf, NgIf } from '@angular/common'; +import { AuthService } from '../../services/auth/auth.service'; +import { ToasterService } from '../../services/toaster/toaster.service'; +import { ButtonComponent } from '../core/button/button.component'; + +@Component({ + selector: 'app-llm-settings', + templateUrl: './llm-settings.component.html', + styleUrls: ['./llm-settings.component.scss'], + standalone: true, + imports: [ReactiveFormsModule, NgIconComponent, NgForOf, NgIf, ButtonComponent], +}) +export class LlmSettingsComponent implements OnInit, OnDestroy { + llmConfig$: Observable<LLMConfigModel> = this.store.select( + LLMConfigState.getConfig, + ); + currentLLMConfig!: LLMConfigModel; + availableProviders = AvailableProviders; + filteredModels: string[] = []; + selectedModel: FormControl = new FormControl(); + selectedProvider: FormControl = new FormControl(); + errorMessage: string = ''; + hasChanges: boolean = false; + private subscriptions: Subscription = new Subscription(); + private initialModel: string = ''; + private initialProvider: string = ''; + + constructor( + private modalRef: MatDialogRef<LlmSettingsComponent>, + private store: Store, + private authService: AuthService, + private toasterService: ToasterService, + private cdr: ChangeDetectorRef + ) {} + + ngOnInit(): void { + // Store initial values + const config = this.store.selectSnapshot(LLMConfigState.getConfig); + this.initialModel = config.model; + this.initialProvider = config.provider; + + // Set form controls + this.selectedModel.setValue(this.initialModel); + this.selectedProvider.setValue(this.initialProvider); + this.hasChanges = false; + + this.subscriptions.add( + this.llmConfig$.subscribe((config) => { + this.currentLLMConfig = config; + this.updateFilteredModels(config?.provider); + }) + ); + this.onModelChange(); + this.onProviderChange(); + } + + onModelChange() { + this.subscriptions.add( + this.selectedModel.valueChanges + .pipe(distinctUntilChanged()) + .subscribe((res) => { + // Only update filtered models, don't update store + this.updateFilteredModels(this.selectedProvider.value); + this.errorMessage = ''; // Clear error message on change + this.hasChanges = + this.selectedModel.value !== this.initialModel || + this.selectedProvider.value !== this.initialProvider; + this.cdr.markForCheck(); + }) + ); + } + + onProviderChange() { + this.subscriptions.add( + this.selectedProvider.valueChanges + .pipe(distinctUntilChanged()) + .subscribe((res) => { + this.updateFilteredModels(res); + this.selectedModel.setValue(providerModelMap[res][0]); + this.errorMessage = ''; // Clear error message on change + this.hasChanges = + this.selectedModel.value !== this.initialModel || + this.selectedProvider.value !== this.initialProvider; + this.cdr.detectChanges(); + }) + ); + } + + updateFilteredModels(provider: string) { + this.filteredModels = providerModelMap[provider] || []; + } + + closeModal() { + // Revert to initial values in the store + this.store.dispatch( + new SetLLMConfig({ + ...this.currentLLMConfig, + model: this.initialModel, + provider: this.initialProvider, + }) + ); + this.modalRef.close(false); + } + + onSave() { + const provider = this.selectedProvider.value; + const model = this.selectedModel.value; + + this.authService.verifyProviderConfig(provider, model).subscribe({ + next: (response) => { + if (response.status === "success") { + // Update store with new values only on successful verification + this.store.dispatch( + new SetLLMConfig({ + ...this.currentLLMConfig, + model: model, + provider: provider, + }) + ); + this.toasterService.showSuccess('Provider configuration verified successfully'); + this.modalRef.close(true); + } else { + this.errorMessage = "Connection Failed! Please verify your model credentials in the backend configuration."; + this.cdr.markForCheck(); + } + }, + error: (error) => { + this.errorMessage = error.error?.message || 'Failed to verify provider configuration'; + this.cdr.markForCheck(); + } + }); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } +} diff --git a/ui/src/app/components/modal-dialog/modal-dialog.component.html b/ui/src/app/components/modal-dialog/modal-dialog.component.html new file mode 100644 index 0000000..e372c9a --- /dev/null +++ b/ui/src/app/components/modal-dialog/modal-dialog.component.html @@ -0,0 +1,31 @@ +<div class="p-6 flex flex-col justify-start"> + <div> + <h1 class="text-2xl font-semibold mb-6">{{ data.title }}</h1> + </div> + <div class="rounded-md" [formGroup]="emittedForm"> + <label class="block text-gray-700 text-sm font-medium mb-2"> + {{ data.description }} <span class="text-gray-400">(Optional)</span> + </label> + <app-textarea-field + [elementPlaceHolder]="data.placeholder" + elementId="extraContext" + formControlName="extraContext" + /> + </div> + <div class="flex justify-between mt-6"> + <app-button + buttonContent="Cancel" + theme="secondary" + size="sm" + rounded="lg" + (click)="onClose()" + /> + <app-button + buttonContent="Generate" + theme="primary" + size="sm" + rounded="lg" + (click)="onGenerate()" + /> + </div> +</div> diff --git a/ui/src/app/components/modal-dialog/modal-dialog.component.scss b/ui/src/app/components/modal-dialog/modal-dialog.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/components/modal-dialog/modal-dialog.component.spec.ts b/ui/src/app/components/modal-dialog/modal-dialog.component.spec.ts new file mode 100644 index 0000000..6e434c9 --- /dev/null +++ b/ui/src/app/components/modal-dialog/modal-dialog.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ModalDialogComponent } from './modal-dialog.component'; + +describe('ModalDialogComponent', () => { + let component: ModalDialogComponent; + let fixture: ComponentFixture<ModalDialogComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ModalDialogComponent] + }); + fixture = TestBed.createComponent(ModalDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/components/modal-dialog/modal-dialog.component.ts b/ui/src/app/components/modal-dialog/modal-dialog.component.ts new file mode 100644 index 0000000..4f2cb12 --- /dev/null +++ b/ui/src/app/components/modal-dialog/modal-dialog.component.ts @@ -0,0 +1,40 @@ +import { + Component, + EventEmitter, + inject, + Inject, + OnInit, + Output, +} from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { TextareaFieldComponent } from '../core/textarea-field/textarea-field.component'; +import { ButtonComponent } from '../core/button/button.component'; + +@Component({ + selector: 'app-modal-dialog', + templateUrl: './modal-dialog.component.html', + styleUrls: ['./modal-dialog.component.scss'], + standalone: true, + imports: [TextareaFieldComponent, ReactiveFormsModule, ButtonComponent], +}) +export class ModalDialogCustomComponent implements OnInit { + constructor(@Inject(MAT_DIALOG_DATA) public data: any) {} + emittedForm!: FormGroup; + @Output() generate = new EventEmitter<string>(); + readonly dialogRef = inject(MatDialogRef<ModalDialogCustomComponent>); + + onGenerate() { + this.generate.emit(this.emittedForm.getRawValue().extraContext); + } + + onClose(): void { + this.dialogRef.close(); + } + + ngOnInit(): void { + this.emittedForm = new FormGroup({ + extraContext: new FormControl(''), + }); + } +} diff --git a/ui/src/app/components/multi-upload/multi-upload.component.html b/ui/src/app/components/multi-upload/multi-upload.component.html new file mode 100644 index 0000000..504ba26 --- /dev/null +++ b/ui/src/app/components/multi-upload/multi-upload.component.html @@ -0,0 +1,27 @@ +<div> + <input + type="file" + (change)="onFileSelected($event)" + accept=".js,.ts,.tsx,.jsx.html,.css,.json,.xml,.py,.java,.c,.cpp,.cs,.php,.rb,.go,.swift" + multiple + #fileInput + class="hidden" + /> + + <div class="button-container"> + <app-button buttonContent="Import from code" + theme="secondary" + size="sm" + rounded="md" + (click)="triggerFileInput()"/> + </div> + <div *ngIf="files.length > 0" class="mt-2"> + <div class="text-sm font-medium"> + <span *ngIf="files.length === 1">Selected File</span> + <span *ngIf="files.length > 1">Selected Files</span> + </div> + <div *ngFor="let file of files" class="text-xs"> + {{ file }} + </div> + </div> +</div> diff --git a/ui/src/app/components/multi-upload/multi-upload.component.scss b/ui/src/app/components/multi-upload/multi-upload.component.scss new file mode 100644 index 0000000..fbe19e9 --- /dev/null +++ b/ui/src/app/components/multi-upload/multi-upload.component.scss @@ -0,0 +1,8 @@ +mat-progress-bar { + margin-top: 5px; + margin-bottom: 5px; +} + +.button-container { + display: inline-block; +} \ No newline at end of file diff --git a/ui/src/app/components/multi-upload/multi-upload.component.spec.ts b/ui/src/app/components/multi-upload/multi-upload.component.spec.ts new file mode 100644 index 0000000..3724c99 --- /dev/null +++ b/ui/src/app/components/multi-upload/multi-upload.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MultiUploadComponent } from './multi-upload.component'; + +describe('MultiUploadComponent', () => { + let component: MultiUploadComponent; + let fixture: ComponentFixture<MultiUploadComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [MultiUploadComponent], + }); + fixture = TestBed.createComponent(MultiUploadComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/components/multi-upload/multi-upload.component.ts b/ui/src/app/components/multi-upload/multi-upload.component.ts new file mode 100644 index 0000000..223b799 --- /dev/null +++ b/ui/src/app/components/multi-upload/multi-upload.component.ts @@ -0,0 +1,81 @@ +import { + Component, + EventEmitter, + Output, + ViewChild, + AfterViewInit, + OnDestroy, + inject, +} from '@angular/core'; +import { NgForOf, NgIf } from '@angular/common'; +import { ButtonComponent } from '../core/button/button.component'; +import { ToasterService } from '../../services/toaster/toaster.service'; + +@Component({ + selector: 'app-multi-upload', + templateUrl: './multi-upload.component.html', + styleUrls: ['./multi-upload.component.scss'], + standalone: true, + imports: [NgIf, NgForOf, ButtonComponent], +}) +export class MultiUploadComponent implements AfterViewInit, OnDestroy { + files: string[] = []; + allFilesContent: any = ''; + @Output() fileContent = new EventEmitter<string>(); + @Output() filesList = new EventEmitter<string[]>(); + @ViewChild('fileInput') fileInput: any; + + toastService = inject(ToasterService); + + ngAfterViewInit(): void { + this.clearFileInput(); + } + + ngOnDestroy(): void { + this.clearFileInput(); + } + + clearFileInput(): void { + this.files = []; + if (this.fileInput) { + this.fileInput.nativeElement.value = ''; + } + } + + onFileSelected(event: any): void { + const selectedFiles = event.target.files; + if (selectedFiles.length > 0) { + this.files = []; + const errorFiles = []; + for (let i = 0; i < selectedFiles.length; i++) { + if (selectedFiles[i].size > 0) { + this.files.push(selectedFiles[i].name); + this.readFileContent(selectedFiles[i], i); + } else { + errorFiles.push(selectedFiles[i].name); + } + } + if (errorFiles.length > 0) { + this.toastService.showError(`Empty file(s): ${errorFiles.join(', ')}`); + this.fileInput.nativeElement.value = ''; + } + } + } + + readFileContent(file: File, index: number): void { + const reader = new FileReader(); + reader.onload = (event: any) => { + this.allFilesContent += file.name + '\n\n' + event.target.result + '\n\n'; + if (index === this.files.length - 1) { + this.fileContent.emit(this.allFilesContent); + this.filesList.emit(this.files); + } + this.toastService.showSuccess(`${file.name} added successfully!`); + }; + reader.readAsText(file); + } + + triggerFileInput(): void { + this.fileInput.nativeElement.click(); + } +} diff --git a/ui/src/app/components/select-root-directory/select-root-directory.component.html b/ui/src/app/components/select-root-directory/select-root-directory.component.html new file mode 100644 index 0000000..9a2c235 --- /dev/null +++ b/ui/src/app/components/select-root-directory/select-root-directory.component.html @@ -0,0 +1,35 @@ +<div class="modal-wrapper"> + <div class="modal-header flex items-center justify-between p-4 border-b"> + <h4 class="text-lg font-medium">Choose Destination Folder</h4> + <button + type="button" + class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center focus:outline-none focus:ring-0" + data-dismiss="modal" + aria-label="Close" + (click)="closeModal()" + > + <ng-icon name="heroXMark"></ng-icon> + </button> + </div> + <div class="modal-body p-6"> + <div class="flex w-full"> + <input + type="text" + [value]="workingDir" + placeholder="Choose Destination Folder" + disabled + class="bg-gray-100 border border-gray-300 text-gray-400 text-sm rounded-l-lg block w-full p-2" + /> + <app-button + [buttonContent]="'Browse'" + [theme]="'primary'" + [rounded]="'none'" + [roundedRight]="'lg'" + (click)="openFolderSelector()" + ></app-button> + </div> + <p class="mt-4 text-sm text-gray-500"> + Select a folder to store the requirements generated by {{appName}} + </p> + </div> +</div> diff --git a/ui/src/app/components/select-root-directory/select-root-directory.component.scss b/ui/src/app/components/select-root-directory/select-root-directory.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/components/select-root-directory/select-root-directory.component.spec.ts b/ui/src/app/components/select-root-directory/select-root-directory.component.spec.ts new file mode 100644 index 0000000..cf7ecb1 --- /dev/null +++ b/ui/src/app/components/select-root-directory/select-root-directory.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SelectRootDirectoryComponent } from './select-root-directory.component'; + +describe('SelectRootDirectoryComponent', () => { + let component: SelectRootDirectoryComponent; + let fixture: ComponentFixture<SelectRootDirectoryComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [SelectRootDirectoryComponent] + }); + fixture = TestBed.createComponent(SelectRootDirectoryComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/components/select-root-directory/select-root-directory.component.ts b/ui/src/app/components/select-root-directory/select-root-directory.component.ts new file mode 100644 index 0000000..b5a6770 --- /dev/null +++ b/ui/src/app/components/select-root-directory/select-root-directory.component.ts @@ -0,0 +1,32 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { MatDialogRef } from '@angular/material/dialog'; +import { NgIconComponent } from '@ng-icons/core'; +import { environment } from 'src/environments/environment'; +import { APP_CONSTANTS } from '../../constants/app.constants'; +import { NgIf } from '@angular/common'; +import { ButtonComponent } from '../core/button/button.component'; + +@Component({ + selector: 'app-select-root-directory', + templateUrl: './select-root-directory.component.html', + styleUrls: ['./select-root-directory.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [NgIconComponent, ButtonComponent], +}) +export class SelectRootDirectoryComponent { + workingDir: string | null; + appName = environment.ThemeConfiguration.appName; + + constructor(private modalRef: MatDialogRef<SelectRootDirectoryComponent>) { + this.workingDir = localStorage.getItem(APP_CONSTANTS.WORKING_DIR); + } + + openFolderSelector() { + this.modalRef.close(true); + } + + closeModal() { + this.modalRef.close(false); + } +} diff --git a/ui/src/app/components/toaster/toaster.component.html b/ui/src/app/components/toaster/toaster.component.html new file mode 100644 index 0000000..3955d31 --- /dev/null +++ b/ui/src/app/components/toaster/toaster.component.html @@ -0,0 +1,39 @@ +<div + *ngFor="let toast of toasts" + class="fixed bottom-4 right-4 z-50 text-xs" + (click)="removeToast(toast.id)" + (keydown.enter)="removeToast(toast.id)" + (keydown.space)="removeToast(toast.id); $event.preventDefault()" + role="button" + tabindex="0" +> + <div + class="w-72 p-4 rounded-lg border-[0.5px]" + [ngClass]="{ + 'bg-success-50 border-success-300 text-success-600': + toast.type === 'success', + 'bg-danger-50 border-danger-300 text-danger-600': toast.type === 'error', + 'bg-blue-50 border-blue-300 text-blue-600': toast.type === 'info', + 'bg-warning-50 border-warning-300 text-warning-600': toast.type === 'warning' + }" + > + <div class="flex items-center overflow-hidden gap-2"> + <!-- Display the appropriate icon based on toast type --> + <div class="flex items-center justify-center"> + <ng-icon + [name]=" + toast.type === 'success' + ? 'heroCheckCircle' + : toast.type === 'error' + ? 'heroExclamationCircle' + : toast.type === 'warning' + ? 'heroExclamationTriangle' + : 'heroInformationCircle' + " + class="text-lg" + /> + </div> + <div class="w-full break-words" [innerHTML]="toast.message"></div> + </div> + </div> +</div> diff --git a/ui/src/app/components/toaster/toaster.component.scss b/ui/src/app/components/toaster/toaster.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/components/toaster/toaster.component.spec.ts b/ui/src/app/components/toaster/toaster.component.spec.ts new file mode 100644 index 0000000..9622982 --- /dev/null +++ b/ui/src/app/components/toaster/toaster.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ToasterComponent } from './toaster.component'; + +describe('ToasterComponent', () => { + let component: ToasterComponent; + let fixture: ComponentFixture<ToasterComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ToasterComponent] + }); + fixture = TestBed.createComponent(ToasterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/components/toaster/toaster.component.ts b/ui/src/app/components/toaster/toaster.component.ts new file mode 100644 index 0000000..24e8a83 --- /dev/null +++ b/ui/src/app/components/toaster/toaster.component.ts @@ -0,0 +1,39 @@ +import { ChangeDetectorRef, Component } from '@angular/core'; +import { ToasterService } from '../../services/toaster/toaster.service'; +import { NgClass, NgForOf, NgIf } from '@angular/common'; +import { NgIconComponent } from '@ng-icons/core'; +import { heroCheckCircle, heroExclamationCircle, heroInformationCircle, heroExclamationTriangle } from '@ng-icons/heroicons/outline'; + +@Component({ + selector: 'app-toaster', + templateUrl: './toaster.component.html', + styleUrls: ['./toaster.component.scss'], + standalone: true, + imports: [NgForOf, NgClass, NgIf, NgIconComponent], + providers: [ + { provide: 'icons', useValue: { heroCheckCircle, heroExclamationCircle, heroInformationCircle, heroExclamationTriangle } } + ] +}) +export class ToasterComponent { + toasts: any[] = []; + + constructor( + private toasterService: ToasterService, + private cdr: ChangeDetectorRef, + ) { + this.toasterService.getToasts().subscribe((toast) => { + // Clear the previous toast (if any) to ensure only one toast is visible + this.toasts = [toast]; + this.cdr.detectChanges(); + // Auto-remove the toast after 5 seconds + setTimeout(() => { + this.removeToast(toast.id); + this.cdr.markForCheck(); + }, 5000); // 5 seconds timeout + }); + } + + removeToast(id: number) { + this.toasts = this.toasts.filter((toast) => toast.id !== id); + } +} diff --git a/ui/src/app/components/toggle/toggle.component.html b/ui/src/app/components/toggle/toggle.component.html new file mode 100644 index 0000000..c00181a --- /dev/null +++ b/ui/src/app/components/toggle/toggle.component.html @@ -0,0 +1,14 @@ +<div + class="relative inline-flex items-center cursor-pointer w-10 h-5 rounded-full transition-colors duration-200 ease-in-out" + [ngClass]="{'bg-indigo-500': isActive, 'bg-gray-300': !isActive}" + (click)="toggle()" + (keydown.enter)="toggle()" + (keydown.space)="toggle(); $event.preventDefault()" + role="button" + tabindex="0" +> + <span + class="absolute left-1 top-1 w-3 h-3 rounded-full bg-white transition-transform duration-200 ease-in-out" + [ngClass]="{'transform translate-x-5': isActive}" + ></span> +</div> diff --git a/ui/src/app/components/toggle/toggle.component.scss b/ui/src/app/components/toggle/toggle.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/components/toggle/toggle.component.ts b/ui/src/app/components/toggle/toggle.component.ts new file mode 100644 index 0000000..89c14c2 --- /dev/null +++ b/ui/src/app/components/toggle/toggle.component.ts @@ -0,0 +1,59 @@ +import { + booleanAttribute, + Component, + EventEmitter, + Input, + OnInit, + Output, +} from '@angular/core'; +import { SetChatSettings } from '../../store/chat-settings/chat-settings.action'; +import { Observable } from 'rxjs'; +import { ChatSettings } from '../../model/interfaces/ChatSettings'; +import { ChatSettingsState } from '../../store/chat-settings/chat-settings.state'; +import { Store } from '@ngxs/store'; +import { NgClass } from '@angular/common'; +import { ProjectsState } from '../../store/projects/projects.state'; + +@Component({ + selector: 'app-toggle', + templateUrl: './toggle.component.html', + styleUrls: ['./toggle.component.scss'], + imports: [NgClass], + standalone: true, +}) +export class ToggleComponent implements OnInit { + constructor(private store: Store) {} + @Input({ transform: booleanAttribute }) isActive: boolean = false; + @Output() toggleChange = new EventEmitter<boolean>(); + @Input({ transform: booleanAttribute }) isPlainToggle: boolean = false; + chatSettings$: Observable<ChatSettings> = this.store.select( + ChatSettingsState.getConfig, + ); + currentSettings!: ChatSettings; + metadata: any = {}; + + ngOnInit(): void { + if (!this.isPlainToggle) { + this.chatSettings$.subscribe((settings) => { + this.currentSettings = settings; + }); + this.isActive = this.currentSettings?.kb !== ''; + this.store.select(ProjectsState.getMetadata).subscribe((res) => { + this.metadata = res; + }); + } + } + + toggle() { + this.isActive = !this.isActive; + this.toggleChange.emit(this.isActive); + if (!this.isPlainToggle) { + this.store.dispatch( + new SetChatSettings({ + ...this.currentSettings, + kb: this.isActive ? this.metadata.integration.bedrock.kbId : '', + }), + ); + } + } +} diff --git a/ui/src/app/components/warning-root-modal/warning-root-modal.component.html b/ui/src/app/components/warning-root-modal/warning-root-modal.component.html new file mode 100644 index 0000000..9fb1def --- /dev/null +++ b/ui/src/app/components/warning-root-modal/warning-root-modal.component.html @@ -0,0 +1,26 @@ +<div class="modal-wrapper"> + <div class="modal-header flex items-center justify-between p-4 border-b"> + <h4 class="text-lg font-medium">Choose Valid Root Folder</h4> + </div> + <div class="modal-body p-6"> + <div class="flex w-full"> + <input + type="text" + [value]="workingDir" + placeholder="Choose Valid Root Folder" + disabled + class="bg-gray-100 border border-gray-300 text-gray-400 text-sm rounded-l-lg block w-full p-2" + /> + <app-button + [buttonContent]="'Browse'" + [theme]="'primary'" + [rounded]="'none'" + [roundedRight]="'md'" + (click)="openFolderSelector()" + ></app-button> + </div> + <p class="mt-4 text-sm text-gray-500"> + Please select a valid requirements folder to store the requirements generated. + </p> + </div> +</div> diff --git a/ui/src/app/components/warning-root-modal/warning-root-modal.component.scss b/ui/src/app/components/warning-root-modal/warning-root-modal.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/components/warning-root-modal/warning-root-modal.component.spec.ts b/ui/src/app/components/warning-root-modal/warning-root-modal.component.spec.ts new file mode 100644 index 0000000..648b531 --- /dev/null +++ b/ui/src/app/components/warning-root-modal/warning-root-modal.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { WarningRootModalComponent } from './warning-root-modal.component'; + +describe('WarningRootModalComponent', () => { + let component: WarningRootModalComponent; + let fixture: ComponentFixture<WarningRootModalComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [WarningRootModalComponent] + }); + fixture = TestBed.createComponent(WarningRootModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/components/warning-root-modal/warning-root-modal.component.ts b/ui/src/app/components/warning-root-modal/warning-root-modal.component.ts new file mode 100644 index 0000000..83b1552 --- /dev/null +++ b/ui/src/app/components/warning-root-modal/warning-root-modal.component.ts @@ -0,0 +1,29 @@ +import { Component, OnInit } from '@angular/core'; +import { MatDialogRef } from '@angular/material/dialog'; +import { APP_CONSTANTS } from 'src/app/constants/app.constants'; +import { ButtonComponent } from '../core/button/button.component'; + +@Component({ + selector: 'app-warning-root-modal', + templateUrl: './warning-root-modal.component.html', + styleUrls: ['./warning-root-modal.component.scss'], + standalone: true, + imports: [ButtonComponent], +}) +export class WarningRootModalComponent implements OnInit { + workingDir: string | null | undefined; + + constructor(private modalRef: MatDialogRef<WarningRootModalComponent>) {} + + ngOnInit() { + this.workingDir = localStorage.getItem(APP_CONSTANTS.WORKING_DIR); + } + + openFolderSelector() { + this.modalRef.close(true); + } + + // closeModal() { + // this.modalRef.close(false); + // } +} diff --git a/ui/src/app/constants/app.constants.ts b/ui/src/app/constants/app.constants.ts new file mode 100644 index 0000000..ab74c71 --- /dev/null +++ b/ui/src/app/constants/app.constants.ts @@ -0,0 +1,155 @@ +import { environment } from '../../environments/environment'; + +export const APP_CONSTANTS = { + WORKING_DIR: 'WORKING_DIR', + VERSION: 'APP_VERSION', + APP_URL: 'APP_URL', + APP_PASSCODE_KEY: 'APP_PASSCODE_KEY', +}; +export const FILTER_STRINGS = { BASE: 'base', FEATURE: 'feature', ARCHIVED: 'archived' }; +export const CHAT_TYPES = { + REQUIREMENT: 'requirement', + USERSTORY: 'userstory', + TASK: 'task', +}; + +export const ERROR_MESSAGES = { + PASSWORD_ERROR: 'Passcode does not match. Please try again.', + GENERATE_SUGGESTIONS_FAILED: 'Failed to generate suggestions' +}; + +export const SOLUTION_CREATION_TOGGLE_MESSAGES = { + BROWNFIELD_SOLUTION: + 'Enabling this toggle will not generate any business or solution requirements for the given solution.', + GREENFIELD_SOLUTION: + 'You can create requirements based on the solution context!', +}; + +export const CONFIRMATION_DIALOG = { + DELETION: { + TITLE: 'Confirm Deletion', + DESCRIPTION: (entityName: string) => + `Are you sure you want to delete <span class="font-semibold">${entityName}</span>?`, + CANCEL_BUTTON_TEXT: 'Cancel', + PROCEED_BUTTON_TEXT: 'Delete', + }, + UNSAVED_CHANGES: { + TITLE: 'Confirm', + DESCRIPTION: + 'You have unsaved changes on this page. Are you sure you want to navigate to another page without saving?', + PROCEED_BUTTON_TEXT: 'Stay', + CANCEL_BUTTON_TEXT: 'Leave', + }, + LOGOUT: { + TITLE: 'Confirm Logout', + DESCRIPTION: 'Are you sure you want to log out?', + CANCEL_BUTTON_TEXT: 'Cancel', + PROCEED_BUTTON_TEXT: 'Log Out', + }, + JIRA_REAUTHENTICATION: { + TITLE: 'Reauthenticate with Jira', + DESCRIPTION: + 'Your session with Jira has expired. Please reauthenticate to continue.', + CANCEL_BUTTON_TEXT: 'Cancel', + PROCEED_BUTTON_TEXT: 'Reauthenticate', + }, + JIRA_DETAILS_MISSING: { + TITLE: 'Jira Integration Incomplete', + DESCRIPTION: + 'It looks like your Jira details are missing. Please return to the integration settings, fill in your details, and save them to continue.', + CANCEL_BUTTON_TEXT: 'Cancel', + PROCEED_BUTTON_TEXT: 'Open integration settings', + }, +}; + +export enum ENTITY_DISPLAY_NAME_MAP { + STORIES = 'User Story', + BP = 'Business Process', + BRD = 'Business Requirement', + NFR = 'Non Functional Requirement', + PRD = 'Product Requirement', + UIR = 'User Interface Requirement', + TASK = 'Task', +} + +const getEntityDisplayName = (folderId: string): string => { + return ( + ENTITY_DISPLAY_NAME_MAP[folderId as keyof typeof ENTITY_DISPLAY_NAME_MAP] || + 'Unknown Requirement' + ); +}; + +const TOASTER_MESSAGES_DEFAULT_TEMPLATE = { + SUCCESS: (entityKey: string, action: string, entityId?: string) => { + return entityId + ? `${getEntityDisplayName(entityKey)} - <span class="font-semibold">${entityId}</span> ${action} successfully!` + : `${getEntityDisplayName(entityKey)} ${action} successfully!`; + }, + FAILURE: (entityKey: string, action: string, entityId?: string) => { + return entityId + ? `Failed to ${action} ${getEntityDisplayName(entityKey)} - <span class="highlight">${entityId}</span>. Please try again.` + : `Failed to ${action} ${getEntityDisplayName(entityKey)}. Please try again.`; + }, +}; + +export const TOASTER_MESSAGES = { + ENTITY: { + ADD: { + SUCCESS: (entityType: string) => + TOASTER_MESSAGES_DEFAULT_TEMPLATE.SUCCESS(entityType, 'added'), + FAILURE: (entityType: string) => + TOASTER_MESSAGES_DEFAULT_TEMPLATE.FAILURE(entityType, 'add'), + }, + UPDATE: { + SUCCESS: (entityType: string, entityId: string) => + TOASTER_MESSAGES_DEFAULT_TEMPLATE.SUCCESS( + entityType, + 'updated', + entityId, + ), + FAILURE: (entityType: string, entityId: string) => + TOASTER_MESSAGES_DEFAULT_TEMPLATE.FAILURE( + entityType, + 'update', + entityId, + ), + }, + DELETE: { + SUCCESS: (entityType: string, entityId: string) => + TOASTER_MESSAGES_DEFAULT_TEMPLATE.SUCCESS( + entityType, + 'deleted', + entityId, + ), + FAILURE: (entityType: string, entityId: string) => + TOASTER_MESSAGES_DEFAULT_TEMPLATE.FAILURE( + entityType, + 'delete', + entityId, + ), + }, + COPY: { + SUCCESS: (entityType: string, entityId: string) => + TOASTER_MESSAGES_DEFAULT_TEMPLATE.SUCCESS( + entityType, + 'copied', + entityId, + ), + FAILURE: (entityType: string, entityId: string) => + TOASTER_MESSAGES_DEFAULT_TEMPLATE.FAILURE(entityType, 'copy', entityId), + }, + }, +}; + +export const APP_MESSAGES = { + AWS_BEDROCK_TOOLTIP_MESSAGE: `Enabling the AWS Bedrock Knowledge Base enriches ${environment.ThemeConfiguration.appName} with enterprise-specific context, enhancing its ability to generate precise, business-relevant responses. This added context improves accuracy and ensures deeper alignment with the overall solution.`, + JIRA_ACCORDION: + 'JIRA Integration allows users to publish generated user stories as Jira issues by configuring their Jira account and authenticating with Jira. Following configuration of details users can authenticate with Jira to establish a secure sync. Once synchronized, users can select the\n' + + 'Sync with Jira\n' + + 'option to create tickets directly in JIRA with the configured Project Key.', + AWS_BEDROCK_ACCORDION_MESSAGE: `Using the AWS Bedrock Knowledge Base enriches ${environment.ThemeConfiguration.appName} with enterprise-specific context, enhancing its ability to generate precise, business-relevant responses. This added context improves accuracy and ensures deeper alignment with the overall solution.`, +}; + +export const TOOLTIP_CONTENT = { + IMPORT_FROM_CODE_BUTTON: "Import from Code", +} \ No newline at end of file diff --git a/ui/src/app/constants/icons.constants.ts b/ui/src/app/constants/icons.constants.ts new file mode 100644 index 0000000..519383d --- /dev/null +++ b/ui/src/app/constants/icons.constants.ts @@ -0,0 +1,81 @@ +import { + heroArrowDownTray, + heroArrowLeft, + heroArrowsPointingOut, + heroCheck, + heroCpuChip, + heroDocument, + heroHome, + heroInbox, + heroInformationCircle, + heroListBullet, + heroPencil, + heroPencilSquare, + heroPlus, + heroSquaresPlus, + heroXMark, + heroSquare3Stack3d, + heroArrowRightOnRectangle, + heroFolder, + heroTrash, + heroServerStack, + heroArrowPath, + heroCog8Tooth, + heroLink, + heroChevronUp, + heroChevronDown, + heroExclamationCircle, + heroExclamationTriangle, + heroSquares2x2, + heroCube, + heroWindow, + heroBriefcase, + heroCheckCircle, + heroDocumentDuplicate +} from '@ng-icons/heroicons/outline'; +import { + heroCheckCircleSolid, + heroCloudArrowUpSolid, + heroExclamationCircleSolid, + heroInformationCircleSolid, + heroExclamationTriangleSolid, +} from '@ng-icons/heroicons/solid'; + +export const APP_ICONS = { + heroArrowsPointingOut, + heroArrowDownTray, + heroListBullet, + heroCpuChip, + heroCheck, + heroCloudArrowUpSolid, + heroInformationCircle, + heroXMark, + heroPlus, + heroPencil, + heroCheckCircleSolid, + heroSquaresPlus, + heroInbox, + heroHome, + heroDocument, + heroArrowLeft, + heroPencilSquare, + heroSquare3Stack3d, + heroArrowRightOnRectangle, + heroFolder, + heroTrash, + heroServerStack, + heroArrowPath, + heroCog8Tooth, + heroExclamationCircle, + heroInformationCircleSolid, + heroLink, + heroChevronUp, + heroChevronDown, + heroExclamationTriangle, + heroSquares2x2, + heroCube, + heroWindow, + heroBriefcase, + heroCheckCircle, + heroDocumentDuplicate +}; diff --git a/ui/src/app/constants/llm.models.constants.ts b/ui/src/app/constants/llm.models.constants.ts new file mode 100644 index 0000000..14fb2a5 --- /dev/null +++ b/ui/src/app/constants/llm.models.constants.ts @@ -0,0 +1,16 @@ + +export const AvailableProviders = [ + { displayName: 'Azure OpenAI', key: 'OPENAI_COMPATIBLE_AZURE' }, + { displayName: 'OpenAI Native', key: 'OPENAI_NATIVE' }, + { displayName: 'AWS Bedrock', key: 'OPENAI_COMPATIBLE_CLAUDE' }, +]; + +export const DefaultProvider = AvailableProviders[0].key; + +export const providerModelMap: { [key: string]: string[] } = { + OPENAI_NATIVE: ['gpt-4o', 'gpt-4o-mini'], + OPENAI_COMPATIBLE_AZURE: ['gpt-4o', 'gpt-4o-mini'], + OPENAI_COMPATIBLE_CLAUDE: ['anthropic.claude-3-5-sonnet-20240620-v1:0'] +}; + + export const DefaultLLMModel = providerModelMap[DefaultProvider][0] diff --git a/ui/src/app/constants/messages.constants.ts b/ui/src/app/constants/messages.constants.ts new file mode 100644 index 0000000..e2b8775 --- /dev/null +++ b/ui/src/app/constants/messages.constants.ts @@ -0,0 +1,45 @@ +export const APP_INFO_COMPONENT_SUCCESS_MESSAGES = { + BRD_UPDATED_SUCCESSFULLY: 'The business requirement has been updated.', + PRD_UPDATED_SUCCESSFULLY: + 'The product requirement has been updated. Please regenerate the user stories as needed.', + NFR_UPDATED_SUCCESSFULLY: 'The non-functional requirement has been updated.', + UIR_UPDATED_SUCCESSFULLY: 'The user-interface requirement has been updated.', + BP_UPDATED_SUCCESSFULLY: 'The business process has been updated.', +}; + +export const APP_INFO_COMPONENT_ERROR_MESSAGES = { + BRD_UPDATE_ERROR: 'Error in updating the business requirement.', + PRD_UPDATE_ERROR: 'Error in updating the product requirement.', + NFR_UPDATE_ERROR: 'Error in updating the non-functional requirement.', + UIR_UPDATE_ERROR: 'Error in updating the user-interface requirement.', + BP_UPDATE_ERROR: 'Error in updating the business process.', + REQUIRES_PRD_OR_BRD: 'Please add at least one active PRD or BRD before creating a Business Process', +}; + +export const FEATURE_BREAKDOWN_COMPONENT_SUCCESS_MESSAGES = { + ADDED_USER_STORY_SUCCESSFULLY: + 'New user story added! Please refine it into tasks as needed.', + UPDATED_USER_STORY_SUCCESSFULLY: + 'User story updated! Please refine it into tasks as needed.', + ADDED_TASK_SUCCESSFULLY: + 'New task added! Consider using the AI feature for implementation.', + UPDATED_TASK_SUCCESSFULLY: + 'Task updated! Consider using the AI feature for implementation.', +}; + +export const FEATURE_BREAKDOWN_COMPONENT_ERROR_MESSAGES = { + ADD_USER_STORY_ERROR: 'Error in adding user story.', + UPDATE_USER_STORY_ERROR: 'Error in updating user story.', + ADD_TASK_ERROR: 'Error in adding task.', + UPDATE_TASK_ERROR: 'Error in updating task.', +}; + +export const LOGIN_ERROR_MESSAGES = { + ON_INCORRECT_PASSCODE: 'Incorrect passcode. Please enter correct Access Code', + ON_GENERIC_ERROR: 'An error occurred. Please try again later.', + ON_EMPTY_PASSCODE: 'Please enter a passcode', +}; + +export const FORM_ERROR_MESSAGES: { [key in string]: string } = { + required: 'This is a required field', +}; diff --git a/ui/src/app/constants/toast.constant.ts b/ui/src/app/constants/toast.constant.ts new file mode 100644 index 0000000..14bb1a8 --- /dev/null +++ b/ui/src/app/constants/toast.constant.ts @@ -0,0 +1,19 @@ +export const JIRA_TOAST = { + SUCCESS: 'JIRA Sync Successful', + ERROR: 'JIRA Sync Failed', + INFO: 'JIRA Sync Initiated', +}; + +export const APP_INTEGRATIONS = { + JIRA: { + SUCCESS: 'Successfully Authenticated with JIRA', + DISCONNECT: 'Successfully Disconnected from JIRA', + ERROR: 'Failed to Authenticate with JIRA', + }, + BEDROCK: { + SUCCESS: 'AWS Bedrock Knowledge Base Config Updated', + DISCONNECT: 'Successfully Disconnected from AWS Bedrock Knowledge Base', + INVALID: 'Invalid AWS Knowledge Base Id please check the id and try again', + ERROR: 'Failed to Update AWS Bedrock Knowledge Base Config', + }, +}; diff --git a/ui/src/app/guards/auth.guard.spec.ts b/ui/src/app/guards/auth.guard.spec.ts new file mode 100644 index 0000000..4ae275e --- /dev/null +++ b/ui/src/app/guards/auth.guard.spec.ts @@ -0,0 +1,17 @@ +import { TestBed } from '@angular/core/testing'; +import { CanActivateFn } from '@angular/router'; + +import { authGuard } from './auth.guard'; + +describe('authGuard', () => { + const executeGuard: CanActivateFn = (...guardParameters) => + TestBed.runInInjectionContext(() => authGuard(...guardParameters)); + + beforeEach(() => { + TestBed.configureTestingModule({}); + }); + + it('should be created', () => { + expect(executeGuard).toBeTruthy(); + }); +}); diff --git a/ui/src/app/guards/auth.guard.ts b/ui/src/app/guards/auth.guard.ts new file mode 100644 index 0000000..a3e9e70 --- /dev/null +++ b/ui/src/app/guards/auth.guard.ts @@ -0,0 +1,16 @@ +import { CanActivateFn, Router } from '@angular/router'; +import { inject } from '@angular/core'; +import { AuthStateService } from '../services/auth/auth-state.service'; + +export const AuthGuard: CanActivateFn = (route, state) => { + const router = inject(Router); + const authState = inject(AuthStateService); + + if (!authState.isAuthenticated()) { + router.navigate(['/login'], { + queryParams: { returnUrl: state.url } + }); + return false; + } + return true; +}; \ No newline at end of file diff --git a/ui/src/app/guards/can-deactivate.guard.spec.ts b/ui/src/app/guards/can-deactivate.guard.spec.ts new file mode 100644 index 0000000..b8c732a --- /dev/null +++ b/ui/src/app/guards/can-deactivate.guard.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { CanDeactivateGuard } from './can-deactivate.guard'; + +describe('CanDeactivateGuard', () => { + let guard: CanDeactivateGuard; + + beforeEach(() => { + TestBed.configureTestingModule({}); + guard = TestBed.inject(CanDeactivateGuard); + }); + + it('should be created', () => { + expect(guard).toBeTruthy(); + }); +}); diff --git a/ui/src/app/guards/can-deactivate.guard.ts b/ui/src/app/guards/can-deactivate.guard.ts new file mode 100644 index 0000000..00fc263 --- /dev/null +++ b/ui/src/app/guards/can-deactivate.guard.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { CanDeactivate } from '@angular/router'; +import { Observable } from 'rxjs'; +import { CONFIRMATION_DIALOG } from '../constants/app.constants'; +import { ConfirmationDialogComponent } from '../components/confirmation-dialog/confirmation-dialog.component'; + +// Define the interface for the component that can be deactivated +export interface CanComponentDeactivate { + canDeactivate: () => boolean | Observable<boolean> | Promise<boolean>; +} + +@Injectable({ + providedIn: 'root' +}) +export class CanDeactivateGuard implements CanDeactivate<CanComponentDeactivate> { + constructor(private dialog: MatDialog) {} + + canDeactivate( + component: CanComponentDeactivate + ): Observable<boolean> | Promise<boolean> | boolean { + if (component.canDeactivate()) { + return this.openDialog(); + } + return true; + } + + private openDialog(): Observable<boolean> { + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + width: '500px', + data: { + title: CONFIRMATION_DIALOG.UNSAVED_CHANGES.TITLE, + description: CONFIRMATION_DIALOG.UNSAVED_CHANGES.DESCRIPTION, + proceedButtonText: + CONFIRMATION_DIALOG.UNSAVED_CHANGES.PROCEED_BUTTON_TEXT, + cancelButtonText: + CONFIRMATION_DIALOG.UNSAVED_CHANGES.CANCEL_BUTTON_TEXT, + }, + }); + + return dialogRef.afterClosed(); + } +} + +// return component.canDeactivate ? component.canDeactivate() : true; \ No newline at end of file diff --git a/ui/src/app/integrations/jira/jira.service.ts b/ui/src/app/integrations/jira/jira.service.ts new file mode 100644 index 0000000..58ea29d --- /dev/null +++ b/ui/src/app/integrations/jira/jira.service.ts @@ -0,0 +1,315 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable, of, throwError, from } from 'rxjs'; +import { catchError, map, switchMap, mergeMap, concatMap, toArray } from 'rxjs/operators'; +import { JIRA_TOAST } from '../../constants/toast.constant'; +import { ToasterService } from '../../services/toaster/toaster.service'; +import { environment } from '../../../environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class JiraService { + constructor( + private http: HttpClient, + private toast: ToasterService, + ) {} + + private getHeaders(token: string): HttpHeaders { + return new HttpHeaders({ + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + skipLoader: 'true', + }); + } + + private createEpic(payload: any, token: string): Observable<any> { + const issueUrl = `${payload.jiraUrl}/rest/api/latest/issue`; + const issueData = { + fields: { + project: { key: payload.projectKey }, + summary: payload.epicName, + description: payload.epicDescription, + issuetype: { name: 'Epic' }, + }, + }; + + return this.http + .post(issueUrl, issueData, { headers: this.getHeaders(token) }) + .pipe( + map((epic: any) => epic), + catchError(this.handleError), + ); + } + + private updateEpic(payload: any, token: string): Observable<any> { + const issueUrl = `${payload.jiraUrl}/rest/api/latest/issue/${payload.epicTicketId}`; + const updateData = { + fields: { + summary: payload.epicName, + description: payload.epicDescription, + }, + }; + + return this.http + .put(issueUrl, updateData, { headers: this.getHeaders(token) }) + .pipe( + map((updated: any) => updated), + catchError(this.handleError), + ); + } + + createOrUpdateEpic(payload: any, token: string): Observable<any> { + if (payload.epicTicketId) { + const issueUrl = `${payload.jiraUrl}/rest/api/latest/issue/${payload.epicTicketId}`; + return this.http.get(issueUrl, { headers: this.getHeaders(token) }).pipe( + switchMap((issue: any) => { + if ( + issue.fields.summary !== payload.epicName || + issue.fields.description !== payload.epicDescription + ) { + return this.updateEpic(payload, token); + } else { + return of(issue); + } + }), + catchError((error) => { + if (error.status === 404) { + return this.createEpic(payload, token); + } else { + return throwError(error); + } + }), + ); + } else { + return this.createEpic(payload, token); + } + } + + createOrUpdateStory( + payload: any, + feature: any, + token: string, + ): Observable<any> { + if (feature.storyTicketId) { + const issueUrl = `${payload.jiraUrl}/rest/api/latest/issue/${feature.storyTicketId}`; + return this.http.get(issueUrl, { headers: this.getHeaders(token) }).pipe( + switchMap((issue: any) => { + if ( + issue.fields.summary !== feature.name || + issue.fields.description !== feature.description + ) { + return this.updateStory(payload, feature, token); + } else { + return of(issue); + } + }), + catchError((error) => { + if (error.status === 404) { + return this.createStory(payload, feature, token); + } else { + return throwError(error); + } + }), + ); + } else { + return this.createStory(payload, feature, token); + } + } + + private createStory( + payload: any, + feature: any, + token: string, + ): Observable<any> { + const issueUrl = `${payload.jiraUrl}/rest/api/latest/issue`; + const issueData = { + fields: { + project: { key: payload.projectKey }, + summary: feature.name, + description: feature.description, + issuetype: { name: 'Story' }, + parent: { key: payload.epicTicketId }, + }, + }; + + return this.http + .post(issueUrl, issueData, { headers: this.getHeaders(token) }) + .pipe( + map((story: any) => story), + catchError(this.handleError), + ); + } + + private updateStory( + payload: any, + feature: any, + token: string, + ): Observable<any> { + const issueUrl = `${payload.jiraUrl}/rest/api/latest/issue/${feature.storyTicketId}`; + const updateData = { + fields: { + summary: feature.name, + description: feature.description, + }, + }; + + return this.http + .put(issueUrl, updateData, { headers: this.getHeaders(token) }) + .pipe( + map((updated: any) => updated), + catchError(this.handleError), + ); + } + + createOrUpdateSubTask( + payload: any, + storyKey: string, + task: any, + token: string, + ): Observable<any> { + if (task.subTaskTicketId) { + const issueUrl = `${payload.jiraUrl}/rest/api/latest/issue/${task.subTaskTicketId}`; + return this.http.get(issueUrl, { headers: this.getHeaders(token) }).pipe( + switchMap((issue: any) => { + if ( + issue.fields.summary !== task.list || + issue.fields.description !== task.acceptance + ) { + return this.updateSubTask(payload, task, token); + } else { + return of(issue); + } + }), + catchError((error) => { + if (error.status === 404) { + return this.createSubTask(payload, storyKey, task, token); // Sub-task not found, create a new one + } else { + return throwError(error); + } + }), + ); + } else { + return this.createSubTask(payload, storyKey, task, token); + } + } + + private createSubTask( + payload: any, + storyKey: string, + task: any, + token: string, + ): Observable<any> { + const issueUrl = `${payload.jiraUrl}/rest/api/latest/issue`; + const issueData = { + fields: { + project: { key: payload.projectKey }, + summary: task.list, + description: task.acceptance, + issuetype: { name: 'Sub-task' }, + parent: { key: storyKey }, + }, + }; + + return this.http + .post(issueUrl, issueData, { headers: this.getHeaders(token) }) + .pipe( + map((subTask: any) => subTask), + catchError(this.handleError), + ); + } + + private updateSubTask( + payload: any, + task: any, + token: string, + ): Observable<any> { + const issueUrl = `${payload.jiraUrl}/rest/api/latest/issue/${task.subTaskTicketId}`; + const updateData = { + fields: { + summary: task.list, + description: task.acceptance, + }, + }; + + return this.http + .put(issueUrl, updateData, { headers: this.getHeaders(token) }) + .pipe( + map((updated: any) => updated), + catchError(this.handleError), + ); + } + + createOrUpdateTickets(payload: any): Observable<any> { + this.toast.showInfo(JIRA_TOAST.INFO); + return this.createOrUpdateEpic(payload, payload.token).pipe( + switchMap((epic: any) => { + payload.epicTicketId = epic.key; + + const result = { + epicName: payload.epicName, + epicTicketId: epic.key, + features: [] as any[], + }; + + const storyRequests = from(payload.features).pipe( + mergeMap( + (feature: any) => + this.createOrUpdateStory(payload, feature, payload.token).pipe( + switchMap((story: any) => { + const storyDetails = { + storyName: feature.name, + storyTicketId: story.key, + tasks: [] as any[], + }; + + if (feature.tasks) { + const taskRequests = from(feature.tasks).pipe( + mergeMap( + (task: any) => + this.createOrUpdateSubTask( + payload, + story.key, + task, + payload.token, + ).pipe( + map((subTask: any) => { + storyDetails.tasks.push({ + subTaskName: task.list, + subTaskTicketId: subTask.key, + }); + }), + ), + environment.JIRA_RATE_LIMIT_CONFIG, + ), + ); + + return taskRequests.pipe( + map(() => { + result.features.push(storyDetails); + }), + ); + } else { + result.features.push(storyDetails); + return of(null); + } + }), + ), + environment.JIRA_RATE_LIMIT_CONFIG, + ), + ); + + return storyRequests.pipe( + concatMap(() => of(null)), + toArray(), + map(() => result)); + }), + ); + } + + private handleError(error: any): Observable<never> { + console.error('An error occurred:', error); + return throwError(() => new Error(error.message || 'Server Error')); + } +} diff --git a/ui/src/app/integrations/jira/jira.utils.ts b/ui/src/app/integrations/jira/jira.utils.ts new file mode 100644 index 0000000..2e4f038 --- /dev/null +++ b/ui/src/app/integrations/jira/jira.utils.ts @@ -0,0 +1,56 @@ +export interface JiraTokenInfo { + token: string | null; + tokenExpiration: string | null; + jiraURL: string | null; + refreshToken: string | null; + projectKey: string | null; +} + +export function getJiraTokenInfo(projectId: string): JiraTokenInfo { + const token = sessionStorage.getItem(`${projectId}-jiraToken`); + const tokenExpiration = sessionStorage.getItem( + `${projectId}-jiraTokenExpiration`, + ); + const jiraURL = sessionStorage.getItem(`${projectId}-jiraUrl`); + const refreshToken = sessionStorage.getItem(`${projectId}-jiraRefreshToken`); + const projectKey = sessionStorage.getItem(`${projectId}-projectKey`); + + return { + token, + tokenExpiration, + jiraURL, + refreshToken, + projectKey, + }; +} + +export function storeJiraToken( + authResponse: any, + projectKey: string, + projectId: string, +): void { + sessionStorage.setItem(`${projectId}-jiraToken`, authResponse.accessToken); + sessionStorage.setItem( + `${projectId}-jiraTokenExpiration`, + authResponse.expirationDate, + ); + sessionStorage.setItem( + `${projectId}-jiraRefreshToken`, + authResponse.refreshToken, + ); + sessionStorage.setItem(`${projectId}-jiraTokenType`, authResponse.tokenType); + sessionStorage.setItem( + `${projectId}-jiraUrl`, + `https://api.atlassian.com/ex/jira/${authResponse.cloudId}`, + ); + sessionStorage.setItem(`${projectId}-projectKey`, projectKey); +} + +export function resetJiraToken(projectId: string): void { + sessionStorage.removeItem(`${projectId}-jiraToken`); + sessionStorage.removeItem(`${projectId}-jiraTokenExpiration`); + sessionStorage.removeItem(`${projectId}-jiraRefreshToken`); + sessionStorage.removeItem(`${projectId}-jiraTokenType`); + sessionStorage.removeItem(`${projectId}-jiraUrl`); + sessionStorage.removeItem(`${projectId}-projectKey`); +} diff --git a/ui/src/app/interceptor/auth.interceptor.ts b/ui/src/app/interceptor/auth.interceptor.ts new file mode 100644 index 0000000..28c8d5a --- /dev/null +++ b/ui/src/app/interceptor/auth.interceptor.ts @@ -0,0 +1,74 @@ +import { Injectable } from '@angular/core'; +import { + HttpInterceptor, + HttpRequest, + HttpHandler, + HttpEvent, + HttpErrorResponse, +} from '@angular/common/http'; +import { Observable, throwError } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { APP_CONSTANTS } from '../constants/app.constants'; +import { AuthStateService } from '../services/auth/auth-state.service'; + +@Injectable() +export class AuthInterceptor implements HttpInterceptor { + private excludedUrls = ['https://api.atlassian.com']; + + constructor(private authState: AuthStateService) {} + + intercept( + request: HttpRequest<any>, + next: HttpHandler, + ): Observable<HttpEvent<any>> { + // Check if the URL should be excluded + if (this.isExcludedUrl(request.url)) { + return next.handle(request); + } + + const encodedAccessCode = localStorage.getItem( + APP_CONSTANTS.APP_PASSCODE_KEY, + ); + + // Only redirect if not already on login page + if (!encodedAccessCode && !window.location.href.includes('login')) { + this.authState.logout(); + return throwError(() => new Error('Access code required')); + } + + request = request.clone({ + url: this.appendBaseUrl(request.url), + }); + + // Add decoded access code header if available + if (encodedAccessCode) { + request = request.clone({ + setHeaders: { + 'X-Access-Code': encodedAccessCode, + }, + }); + } + + return next.handle(request).pipe( + catchError((error: HttpErrorResponse) => { + if (!request.url.includes('auth/verify_access_token')) { + if (error.status === 401 || error.status === 403) { + this.authState.logout('Session expired. Please login again.'); + } + } + return throwError(() => error); + }), + ); + } + + private isExcludedUrl(url: string): boolean { + return this.excludedUrls.some( + (excludedUrl) => url.includes(excludedUrl) + ); + } + + private appendBaseUrl(url: string): string { + const baseURL = localStorage.getItem(APP_CONSTANTS.APP_URL); + return `${baseURL}/api/${url}`; + } +} diff --git a/ui/src/app/interceptor/http.interceptor.ts b/ui/src/app/interceptor/http.interceptor.ts new file mode 100644 index 0000000..57745ff --- /dev/null +++ b/ui/src/app/interceptor/http.interceptor.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@angular/core'; +import { + HttpInterceptor, + HttpRequest, + HttpHandler, + HttpEvent, + HttpErrorResponse, +} from '@angular/common/http'; +import { Observable, timer } from 'rxjs'; +import { finalize, retry } from 'rxjs/operators'; +import { Store } from '@ngxs/store'; +import { LoadingService } from '../services/loading.service'; +import { LLMConfigState } from '../store/llm-config/llm-config.state'; +import { ToasterService } from '../services/toaster/toaster.service'; +import { DefaultLLMModel, DefaultProvider } from '../constants/llm.models.constants'; + +@Injectable() +export class LoadingInterceptor implements HttpInterceptor { + constructor( + private loadingService: LoadingService, + private store: Store, + private toasterService: ToasterService, + ) {} + + intercept( + request: HttpRequest<any>, + next: HttpHandler, + ): Observable<HttpEvent<any>> { + const skipLoader = request.headers.has('skipLoader'); + + const model = this.store.selectSnapshot(LLMConfigState.getConfig).model; + const provider = this.store.selectSnapshot(LLMConfigState.getConfig).provider; + + const modifiedRequest = request.clone({ + setHeaders: { + 'X-Provider': provider || DefaultProvider, + 'X-Model': model || DefaultLLMModel, + }, + }); + + if (!skipLoader) { + this.loadingService.setLoading(true); + } + + return next.handle(modifiedRequest).pipe( + retry({ + count: 3, + delay: (error: HttpErrorResponse) => { + if (error.status >= 500) { + return timer(1); + } + this.toasterService.showError(error?.message); + throw error; + }, + }), + finalize(() => { + if (!skipLoader) { + this.loadingService.setLoading(false); + } + }), + ); + } +} diff --git a/ui/src/app/model/enum/file-type.enum.ts b/ui/src/app/model/enum/file-type.enum.ts new file mode 100644 index 0000000..cdd3593 --- /dev/null +++ b/ui/src/app/model/enum/file-type.enum.ts @@ -0,0 +1,15 @@ +export enum FileTypeEnum { + BRD = 'Business Requirements', + PRD = 'Product Requirements', + UIR = 'User Interface Requirements', + NFR = 'Non Functional Requirements', + BP = 'Business Process', +} + +export enum IconPairingEnum { + BRD = 'heroBriefcase', + PRD = 'heroSquares2x2', + UIR = 'heroCube', + NFR = 'heroWindow', + BP = 'heroSquare3Stack3d', +} diff --git a/ui/src/app/model/enum/modal-type.enum.ts b/ui/src/app/model/enum/modal-type.enum.ts new file mode 100644 index 0000000..c99dc90 --- /dev/null +++ b/ui/src/app/model/enum/modal-type.enum.ts @@ -0,0 +1,13 @@ +export enum ModalTypeEnum { + ADD_USER_STORY = 'addUserStory', + ADD_TASK = 'addTask', + UPDATE_USER_STORY = 'updateUserStory', + UPDATE_TASK = 'updateTask' +} + +export enum ModalNameEnum { + ADD_USER_STORY = 'addUserStoryModal', + ADD_TASK = 'addTaskModal', + UPDATE_USER_STORY = 'updateUserStoryModal', + UPDATE_TASK = 'updateTaskModal' +} diff --git a/ui/src/app/model/enum/requirement-type.enum.ts b/ui/src/app/model/enum/requirement-type.enum.ts new file mode 100644 index 0000000..0431378 --- /dev/null +++ b/ui/src/app/model/enum/requirement-type.enum.ts @@ -0,0 +1,7 @@ +export enum RequirementTypeEnum { + BRD = 'BRD', + PRD = 'PRD', + UIR = 'UIR', + NFR = 'NFR', + BP = 'BP' +} diff --git a/ui/src/app/model/enum/timezone.enum.ts b/ui/src/app/model/enum/timezone.enum.ts new file mode 100644 index 0000000..05cdd30 --- /dev/null +++ b/ui/src/app/model/enum/timezone.enum.ts @@ -0,0 +1,7 @@ +export enum TimeZoneEnum { + IST = 'IST', + ET = 'ET', + CT = 'CT', + MT = 'MT', + PT = 'PT' +} diff --git a/ui/src/app/model/interfaces/ChatSettings.ts b/ui/src/app/model/interfaces/ChatSettings.ts new file mode 100644 index 0000000..f7c2bbe --- /dev/null +++ b/ui/src/app/model/interfaces/ChatSettings.ts @@ -0,0 +1,3 @@ +export interface ChatSettings { + kb: string; +} diff --git a/ui/src/app/model/interfaces/IBusinessProcess.ts b/ui/src/app/model/interfaces/IBusinessProcess.ts new file mode 100644 index 0000000..cfc956d --- /dev/null +++ b/ui/src/app/model/interfaces/IBusinessProcess.ts @@ -0,0 +1,71 @@ +export interface ILLMresponse { + requirement: string; + title: string; +} + +export interface IRequirementDetail { + reqId: string; + reqDesc: string; +} + +export interface IFlowChartRequest { + id: string; + title: string; + description: string; +} + +export interface IUpdateProcessRequest { + updatedReqt: string; + contentType: string; + id: string; + title: string; + name: string; + description: string; + useGenAI: boolean; + selectedBRDs: string[]; + selectedPRDs: string[]; + reqId: string; + reqDesc: string; +} + +export interface IAddBusinessProcessRequest { + reqt: string; + contentType: string; + id: string; + title: string; + addReqtType: string; + name: string; + description: string; + useGenAI: boolean; + selectedBRDs: string[]; + selectedPRDs: string[]; +} + +export interface IAddBusinessProcessResponse { + reqt: string; + contentType: string; + id: string; + title: string; + addReqtType: string; + name: string; + description: string; + useGenAI: boolean; + selectedBRDs: string[]; + selectedPRDs: string[]; + LLMreqt: ILLMresponse; +} + +export interface IUpdateProcessResponse { + contentType: string; + description: string; + id: string; + name: string; + reqDesc: string; + reqId: string; + selectedBRDs: string[]; + selectedPRDs: any[]; + title: string; + updated: ILLMresponse + updatedReqt: string; + useGenAI: boolean; +} diff --git a/ui/src/app/model/interfaces/ILLMConfig.ts b/ui/src/app/model/interfaces/ILLMConfig.ts new file mode 100644 index 0000000..9fe9bde --- /dev/null +++ b/ui/src/app/model/interfaces/ILLMConfig.ts @@ -0,0 +1,6 @@ +export interface LLMConfigModel { + apiKey: string; + model: string; + provider: string; + apiUrl: string; +} diff --git a/ui/src/app/model/interfaces/IList.ts b/ui/src/app/model/interfaces/IList.ts new file mode 100644 index 0000000..6b5527b --- /dev/null +++ b/ui/src/app/model/interfaces/IList.ts @@ -0,0 +1,25 @@ +export interface IList{ + folderName: string + fileName: string + content: IProjectDocument +} + +export interface IProjectDocument { + requirement?: string; + title?: string; + features?: IFeature[]; +} + +export interface IFeature { + id?: string; + name?: string; + description?: string; + tasks?: ITask[]; +} + +export interface ITask { + list?: string; + acceptance?: string; + id?: string; + chatHistory?: []; +} diff --git a/ui/src/app/model/interfaces/IRequirement.ts b/ui/src/app/model/interfaces/IRequirement.ts new file mode 100644 index 0000000..d5693b2 --- /dev/null +++ b/ui/src/app/model/interfaces/IRequirement.ts @@ -0,0 +1,24 @@ +export interface IUpdateRequirementRequest { + updatedReqt: string; + fileContent: string; + contentType: string; + id: string; + reqId: string; + reqDesc: string; + addReqtType: string; + name: string; + description: string; + useGenAI: boolean; +} + +export interface IAddRequirementRequest { + reqt?: string; + fileContent?: string; + contentType: string; + id: string; + title: string; + addReqtType: string; + name: string; + description: string; + useGenAI: boolean; +} \ No newline at end of file diff --git a/ui/src/app/model/interfaces/ITask.ts b/ui/src/app/model/interfaces/ITask.ts new file mode 100644 index 0000000..d64ba63 --- /dev/null +++ b/ui/src/app/model/interfaces/ITask.ts @@ -0,0 +1,98 @@ +export interface ITask { + subTaskTicketId?: string; + id: string; + list: string; + acceptance: string; + chatHistory?: []; +} + +export interface ITasksResponse { + appId: string; + description: string; + featureId: string; + name: string; + tasks: ITaskResponse[]; + regenerate: boolean; + reqDesc: string; + reqId: string; +} + +export interface ITaskResponse { + id: string; + [key: string]: string; +} + +export interface ITaskRequest { + appId: string; + reqId: string; + featureId: string; + name: string; + description: string; + regenerate: boolean; + technicalDetails: string; + extraContext?: string; +} + +export interface IEditTaskRequest { + name: string; + description: string; + appId: string; + featureId: string; + taskId: string; + contentType: string; + fileContent: string; + reqId: string; + reqDesc: string; + useGenAI: boolean; + usIndex: number; + existingTaskTitle: string; + existingTaskDesc: string; + taskName: string; +} + +export interface IAddTaskRequest{ + name: string; + description: string; + appId: string; + featureId: string; + taskId: string; + contentType: string; + fileContent: string; + reqId: string; + taskName: string; + reqDesc: string; + useGenAI: boolean; + usIndex: number; +} + +export interface IEditTaskResponse{ + contentType: string; + description: string; + fileContent: string; + id: string; + name: string; + reqDesc: string; + reqId: string; + updated: { + requirement: string; + title: string; + }; + updatedReqt: string; + useGenAI: boolean; +} + +export interface IAddTaskResponse{ + LLMreqt: { + requirement: string; + title: string; + }; + addReqtType: string; + contentType: string; + description: string; + fileContent: string; + id: string; + name: string; + reqt: string; + title: string; + useGenAI: boolean; +} diff --git a/ui/src/app/model/interfaces/IUserStory.ts b/ui/src/app/model/interfaces/IUserStory.ts new file mode 100644 index 0000000..37a108e --- /dev/null +++ b/ui/src/app/model/interfaces/IUserStory.ts @@ -0,0 +1,68 @@ +import { ITask } from "./ITask"; + +export interface IUserStory { + storyTicketId?: string; + id: string; + name: string; + description: string; + tasks?: ITask[]; + archivedTasks?: ITask[]; + chatHistory?: []; +} + +export interface IUserStoryResponse { + appId: string; + features: IFeatureResponse[]; + regenerate: boolean; + reqDesc: string; + reqId: string; +} + +interface IFeatureResponse { + id: string; + [key: string]: string; +} + +export interface IUserStoriesRequest { + appId: string; + reqId: string; + reqDesc: string; + regenerate: boolean; + technicalDetails: string; + extraContext?: string; +} + +export interface IUserStoryRequest { + name: string; + description: string; + appId: string; + featureId: string; + featureRequest: string; + contentType: string; + fileContent: string; + reqId: string; + reqDesc: string; + useGenAI: boolean; + existingFeatureTitle: string; + existingFeatureDesc: string; +} + +export interface IUpdateUserStoryRequest{ + name: string; + description: string; + appId: string; + featureId: string; + featureRequest: string; + existingFeatureTitle?: string; + existingFeatureDesc?: string; + contentType: string; + fileContent: string; + reqId: string; + reqDesc: string; + useGenAI: boolean; +} + +export interface EpicResponse { + epicName: string; + epicTicketId: string; +} diff --git a/ui/src/app/model/interfaces/Jira.ts b/ui/src/app/model/interfaces/Jira.ts new file mode 100644 index 0000000..d8945e0 --- /dev/null +++ b/ui/src/app/model/interfaces/Jira.ts @@ -0,0 +1,48 @@ +export interface RequestEpic { + epicName: string; + epicDescription: string; + epicTicketId: string; + projectKey: string; + features: RequestFeature[]; +} + +export interface RequestFeature { + id: string; + name: string; + storyTicketId: string; + description: string; + tasks: RequestTask[]; + chatHistory: RequestChatHistory[]; +} + +export interface RequestTask { + id: string; + subTaskTicketId: string; + list: string; + acceptance: string | string[]; +} + +export interface RequestChatHistory { + user?: string; + assistant?: string; + isAdded?: boolean; +} + +export interface ResponseEpic { + epicName: string; + epicTicketId: string; + features: ResponseFeature[]; +} + +export interface ResponseFeature { + storyName: string; + storyTicketId: string; + tasks: ResponseTask[]; +} + +export interface ResponseTask { + subTaskName: string; + subTaskTicketId: string; +} + + diff --git a/ui/src/app/model/interfaces/chat.interface.ts b/ui/src/app/model/interfaces/chat.interface.ts new file mode 100644 index 0000000..c7306c6 --- /dev/null +++ b/ui/src/app/model/interfaces/chat.interface.ts @@ -0,0 +1,20 @@ +export interface suggestionPayload { + name: string; + description: string; + type: string; + requirement: string; + knowledgeBase?: string; +} + +export interface conversePayload { + name: string; + description: string; + type: string; + requirement: string; + userMessage: string; + requirementAbbr?: string; + knowledgeBase?: string; + us?: string; + prd?: string; + chatHistory?: Array<{}>; +} diff --git a/ui/src/app/model/interfaces/projects.interface.ts b/ui/src/app/model/interfaces/projects.interface.ts new file mode 100644 index 0000000..a2acb54 --- /dev/null +++ b/ui/src/app/model/interfaces/projects.interface.ts @@ -0,0 +1,35 @@ +export interface IProject { + project: string; + metadata: IProjectMetadata; +} + +export interface IProjectMetadata { + name?: string; + description: string; + frontend?: boolean; + backend?: boolean; + database?: boolean; + deployment?: boolean; + createReqt?: boolean; + id: string; + createdAt: string; +} + +export interface ISolutionResponse { + brd?: { [key in string]: string }[]; + nfr?: { [key in string]: string }[]; + prd?: { [key in string]: string }[]; + uir?: { [key in string]: string }[]; + createReqt: boolean; + description: string; + name: string; +} + +export interface IBreadcrumb { + label: string; + url?: string; + state?: { + [key: string]: any; + }; + tooltipLabel?: string; +} diff --git a/ui/src/app/modules/shared/shared.module.ts b/ui/src/app/modules/shared/shared.module.ts new file mode 100644 index 0000000..dc7a32a --- /dev/null +++ b/ui/src/app/modules/shared/shared.module.ts @@ -0,0 +1,28 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { LoggerModule, NgxLoggerLevel } from 'ngx-logger'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { NgxSmartModalModule } from 'ngx-smart-modal'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { NgIconsModule } from '@ng-icons/core'; +import { APP_ICONS } from '../../constants/icons.constants'; + +@NgModule({ + imports: [ + CommonModule, + LoggerModule.forRoot({ + level: NgxLoggerLevel.DEBUG, + serverLogLevel: NgxLoggerLevel.ERROR, + }), + MatProgressBarModule, + NgxSmartModalModule, + FormsModule, + ReactiveFormsModule, + BrowserAnimationsModule, + + NgIconsModule.withIcons({ ...APP_ICONS }), + ], + exports: [NgIconsModule], +}) +export class SharedModule {} diff --git a/ui/src/app/pages/app-info/app-info.component.html b/ui/src/app/pages/app-info/app-info.component.html new file mode 100644 index 0000000..0b3b32e --- /dev/null +++ b/ui/src/app/pages/app-info/app-info.component.html @@ -0,0 +1,365 @@ +<div + class="mx-auto max-w-3xl px-4 py-1 sm:px-6 lg:max-w-7xl lg:px-8 h-full" + *ngIf="directories$ | async as directories" +> + <!-- Main 3 column grid --> + <div class="grid items-start gap-4 grid-cols-3 h-full"> + <section aria-labelledby="section-2-title" class="h-full"> + <div + class="overflow-hidden rounded-lg bg-white shadow flex flex-col py-4 px-6 space-y-4" + > + <div class=""> + <h1 class="font-semibold text-normal capitalize">{{ appName }}</h1> + </div> + <hr class="my-4" /> + <ul role="list" class="min-h space-y-3"> + <li + class="flex items-center justify-between p-2.5 rounded-lg cursor-pointer hover:bg-secondary-50" + [ngClass]="{ + 'bg-secondary-50 border border-secondary-300': selectedFolder?.title === 'solution', + 'bg-white': selectedFolder?.title !== 'solution', + }" + (click)="selectFolder({ name: 'solution', children: [] })" + (keydown.enter)="selectFolder({ name: 'solution', children: [] })" + (keydown.space)="selectFolder({ name: 'solution', children: [] }); $event.preventDefault()" + role="button" + tabindex="0" + > + <div class="flex items-center"> + <svg + class="text-xl mr-2" + [ngClass]="{ + 'text-primary-600': selectedFolder?.title === 'solution', + 'text-secondary-500': selectedFolder?.title !== 'solution', + }" + width="18" + height="18" + viewBox="0 0 18 18" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M15 5.25H3C2.17157 5.25 1.5 5.92157 1.5 6.75V14.25C1.5 15.0784 2.17157 15.75 3 15.75H15C15.8284 15.75 16.5 15.0784 16.5 14.25V6.75C16.5 5.92157 15.8284 5.25 15 5.25Z" + stroke="currentColor" + stroke-linecap="round" + stroke-linejoin="round" + /> + <path + d="M12 15.75V3.75C12 3.35218 11.842 2.97064 11.5607 2.68934C11.2794 2.40804 10.8978 2.25 10.5 2.25H7.5C7.10218 2.25 6.72064 2.40804 6.43934 2.68934C6.15804 2.97064 6 3.35218 6 3.75V15.75" + stroke="currentColor" + stroke-linecap="round" + stroke-linejoin="round" + /> + </svg> + <h4 + class="text-sm font-medium" + [ngClass]="{ + 'text-primary-600': selectedFolder?.title === 'solution', + 'text-secondary-500': selectedFolder?.title !== 'solution', + }" + > + Solution + </h4> + </div> + </li> + + <ng-container *ngFor="let folder of directories; index as i"> + <li + *ngIf="!isArchived(folder)" + class="flex items-center justify-between p-2.5 rounded-lg cursor-pointer hover:bg-secondary-50" + [ngClass]="{ + 'bg-secondary-50 border border-secondary-300': selectedFolder?.title === folder.name, + 'bg-white': selectedFolder?.title !== folder.name, + }" + (click)="selectFolder(folder)" + (keydown.enter)="selectFolder(folder)" + (keydown.space)="selectFolder(folder); $event.preventDefault()" + role="button" + tabindex="0" + > + <div class="flex items-center"> + <ng-icon + class="w-5 h-5 text-xl mr-2" + *ngIf="folder.name === 'BRD'" + [ngClass]="{ + 'text-primary-600': selectedFolder?.title === folder.name, + 'text-secondary-500': selectedFolder?.title !== folder.name, + }" + name="heroBriefcase" + ></ng-icon> + <ng-icon + class="w-5 h-5 text-xl mr-2" + *ngIf="folder.name === 'PRD'" + [ngClass]="{ + 'text-primary-600': selectedFolder?.title === folder.name, + 'text-secondary-500': selectedFolder?.title !== folder.name, + }" + name="heroSquares2x2" + ></ng-icon> + <ng-icon + class="w-5 h-5 text-xl mr-2" + *ngIf="folder.name === 'NFR'" + [ngClass]="{ + 'text-primary-600': selectedFolder?.title === folder.name, + 'text-secondary-500': selectedFolder?.title !== folder.name, + }" + name="heroCube" + ></ng-icon> + + <ng-icon + class="w-5 h-5 text-xl mr-2" + *ngIf="folder.name === 'UIR'" + [ngClass]="{ + 'text-primary-600': selectedFolder?.title === folder.name, + 'text-secondary-500': selectedFolder?.title !== folder.name, + }" + name="heroWindow" + ></ng-icon> + <ng-icon + *ngIf="folder.name === 'BP'" + class="text-xl w-5 h-5 mr-2" + [ngClass]="{ + 'text-primary-600': selectedFolder?.title === folder.name, + 'text-secondary-500': selectedFolder?.title !== folder.name, + }" + name="heroSquare3Stack3d" + > + </ng-icon> + <h4 + class="text-sm font-medium" + [ngClass]="{ + 'text-primary-600': selectedFolder?.title === folder.name, + 'text-secondary-500': selectedFolder?.title !== folder.name, + }" + > + {{ getDescription(folder.name) }} + </h4> + </div> + <app-badge [badgeText]="folder.children.length" /> + </li> + </ng-container> + <ng-container *ngFor="let folder of haiFolder"> + <li + *ngIf="!directoryContainsFolder(folder.key, directories)" + class="flex items-center justify-between p-2.5 rounded-lg bg-white" + > + <div class="flex items-center"> + <ng-icon + class="text-secondary-500 w-5 h-5 text-xl mr-2" + [name]="getIconName(folder.key)" + ></ng-icon> + <h4 class="text-sm text-secondary-500 font-medium">{{ folder.value }}</h4> + </div> + <app-button + buttonContent="Add" + theme="secondary" + size="xs" + rounded="lg" + (click)="folder.key === 'BP' ? navigateToBPAdd() : navigateToAdd(folder.key)" + /> + </li> + </ng-container> + <li + (click)="selectFolder({ name: 'app-integrations', children: [] })" + (keydown.enter)="selectFolder({ name: 'app-integrations', children: [] })" + (keydown.space)="selectFolder({ name: 'app-integrations', children: [] }); $event.preventDefault()" + role="button" + tabindex="0" + [ngClass]="{ + 'bg-secondary-50 border border-secondary-300': selectedFolder?.title === 'app-integrations', + 'bg-white': selectedFolder?.title !== 'app-integrations', + }" + class="flex cursor-pointer items-center justify-between p-2.5 rounded-lg bg-white hover:bg-secondary-50" + > + <div class="flex items-center"> + <ng-icon + class="w-5 h-5 text-xl mr-2" + [ngClass]="{ + 'text-primary-600': + selectedFolder?.title === 'app-integrations', + 'text-secondary-500': + selectedFolder?.title !== 'app-integrations', + }" + name="heroLink" + ></ng-icon> + <h4 + class="text-sm font-medium" + [ngClass]="{ + 'text-primary-600': + selectedFolder?.title === 'app-integrations', + 'text-secondary-500': + selectedFolder?.title !== 'app-integrations', + }" + > + App Integrations + </h4> + </div> + </li> + </ul> + </div> + </section> + <!-- Left column --> + <section aria-labelledby="section-1-title" class="col-span-2 h-full"> + <div class="overflow-hidden rounded-lg bg-white shadow h-full"> + <div *ngIf="selectedFolder?.title !== 'solution' && selectedFolder?.title !== 'app-integrations'" class="h-full"> + <app-document-listing + *ngIf="directoryContainsFolder(selectedFolder?.title, directories)" + [folder]="selectedFolder" + /> + <div *ngIf="!directoryContainsFolder(selectedFolder?.title, directories)" class="flex items-center justify-center h-full"> + No documents available. + </div> + </div> + <div class="p-6" *ngIf="selectedFolder?.title === 'solution'"> + <h1 class="text-title font-semibold text-gray-700 mb-3 pb-3 border-b"> + Solution Information + </h1> + <div class="h-[calc(100vh-275px)] overflow-auto"> + <h1 class="text-xl capitalize">{{ appInfo.name }}</h1> + <p class="text-gray-700 pt-2 pb-3">{{ appInfo.description }}</p> + <div class="py-3 flex gap-3" *ngIf="appInfo.technicalDetails"> + <div> + <ng-icon class="text-2xl" name="heroServerStack"></ng-icon> + </div> + <div> + <div class="text-gray-500 text-xs">Technical Details</div> + <div>{{ appInfo.technicalDetails }}</div> + </div> + </div> + </div> + </div> + <div class="p-6" *ngIf="selectedFolder?.title === 'app-integrations'"> + <h1 class="text-title font-semibold text-secondary-950 mb-3 pb-3 border-b"> + App Integrations + </h1> + <app-accordion + [withConnectionStatus]="true" + [isConnected]="isJiraConnected" + title="Jira" + dynamicClass="text-sm font-medium text-secondary-950 ml-3" + iconImage="./assets/img/logo/mark_gradient_blue_jira.svg" + [isOpen]="accordionState['jira']" + (toggleAccordion)="toggleAccordion('jira')" + > + <div class="flex justify-start pb-4"> + <p class="text-sm font-normal text-secondary-500"> + {{ APP_MESSAGES.JIRA_ACCORDION }} + </p> + </div> + <div [formGroup]="jiraForm" class="grid grid-cols-2 gap-6 w-full"> + <div class="flex items-start gap-2"> + <div class="flex-1"> + <app-input-field + formControlName="jiraProjectKey" + elementId="jiraProjectKey" + elementName="Jira Project Key" + elementPlaceHolder="Enter Jira Project Key" + ></app-input-field> + </div> + </div> + + <!-- Jira Client ID Field --> + <div class="flex items-start gap-2"> + <div class="flex-1"> + <app-input-field + formControlName="clientId" + elementId="clientId" + elementName="Jira App Client ID" + elementPlaceHolder="Enter Jira Client ID" + elementType="password" + ></app-input-field> + </div> + </div> + + <!-- Client Secret Field --> + <div class="flex items-start gap-2"> + <div class="flex-1"> + <app-input-field + formControlName="clientSecret" + elementId="clientSecret" + elementName="Jira App Client Secret" + elementPlaceHolder="Enter Client Secret" + elementType="password" + ></app-input-field> + </div> + </div> + + <!-- Redirect URL Field --> + <div class="flex items-start gap-2"> + <div class="flex-1"> + <app-input-field + formControlName="redirectUrl" + elementId="redirectUrl" + elementName="Redirect URL" + elementPlaceHolder="Enter Redirect URL" + ></app-input-field> + </div> + </div> + </div> + + <div class="mt-6 flex justify-end items-center"> + <app-button + [buttonContent]=" + isJiraConnected ? 'Disconnect' : 'Connect' + " + [theme]="isJiraConnected ? 'danger' : 'primary'" + size="sm" + (click)=" + isJiraConnected + ? disconnectJira() + : handleJiraAuthentication() + " + [disabled]="!isJiraConnected && editButtonDisabled" + > + </app-button> + </div> + </app-accordion> + + <app-accordion + [withConnectionStatus]="true" + [isConnected]="isBedrockConnected" + title="AWS Bedrock Knowledge Base" + dynamicClass="text-sm font-medium text-secondary-950 ml-3" + iconImage="./assets/img/logo/aws_dark_bg_transparent_logo.svg" + [isOpen]="accordionState['knowledgeBase']" + (toggleAccordion)="toggleAccordion('knowledgeBase')" + > + <div class="flex justify-start pb-4"> + <p class="text-sm font-normal text-secondary-500"> + {{ APP_MESSAGES.AWS_BEDROCK_ACCORDION_MESSAGE }} + </p> + </div> + <div + [formGroup]="bedrockForm" + class="grid grid-cols-2 gap-6 w-full" + > + <div class="flex items-start"> + <div class="flex-1"> + <app-input-field + formControlName="kbId" + elementId="kbId" + elementName="Knowledge Base ID" + elementPlaceHolder="Enter Knowledge Base ID" + ></app-input-field> + </div> + </div> + </div> + <div class="mt-6 flex justify-between items-center"> + <div></div> + <app-button + [buttonContent]="isBedrockConnected ? 'Disconnect' : 'Connect'" + [theme]="isBedrockConnected ? 'danger' : 'primary'" + size="sm" + (click)=" + isBedrockConnected ? disconnectBedrock() : saveBedrockData() + " + [disabled]="!isBedrockConnected && bedrockEditButtonDisabled" + > + </app-button> + </div> + </app-accordion> + </div> + </div> + </section> + </div> +</div> diff --git a/ui/src/app/pages/app-info/app-info.component.scss b/ui/src/app/pages/app-info/app-info.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/pages/app-info/app-info.component.spec.ts b/ui/src/app/pages/app-info/app-info.component.spec.ts new file mode 100644 index 0000000..45c2985 --- /dev/null +++ b/ui/src/app/pages/app-info/app-info.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AppInfoComponent } from './app-info.component'; + +describe('AppInfoComponent', () => { + let component: AppInfoComponent; + let fixture: ComponentFixture<AppInfoComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [AppInfoComponent], + }); + fixture = TestBed.createComponent(AppInfoComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/pages/app-info/app-info.component.ts b/ui/src/app/pages/app-info/app-info.component.ts new file mode 100644 index 0000000..5d8001b --- /dev/null +++ b/ui/src/app/pages/app-info/app-info.component.ts @@ -0,0 +1,488 @@ +import { + Component, + ElementRef, + HostListener, + OnDestroy, + OnInit, + ViewChild, +} from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import mermaid from 'mermaid'; +import { Store } from '@ngxs/store'; +import { ProjectsState } from '../../store/projects/projects.state'; +import { + GetProjectFiles, + UpdateMetadata, +} from '../../store/projects/projects.actions'; +import { + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { getDescriptionFromInput } from '../../utils/common.utils'; +import { Observable, Subject, first, takeUntil } from 'rxjs'; +import { AddBreadcrumbs } from '../../store/breadcrumb/breadcrumb.actions'; +import { MultiUploadComponent } from '../../components/multi-upload/multi-upload.component'; +import { AsyncPipe, NgClass, NgForOf, NgIf } from '@angular/common'; +import { BadgeComponent } from '../../components/core/badge/badge.component'; +import { ButtonComponent } from '../../components/core/button/button.component'; +import { InputFieldComponent } from '../../components/core/input-field/input-field.component'; +import { NgIconComponent } from '@ng-icons/core'; +import { DocumentListingComponent } from '../../components/document-listing/document-listing.component'; +import { APP_MESSAGES, FILTER_STRINGS } from '../../constants/app.constants'; +import { APP_INFO_COMPONENT_ERROR_MESSAGES } from '../../constants/messages.constants'; +import { AccordionComponent } from '../../components/accordion/accordion.component'; +import { ToasterService } from '../../services/toaster/toaster.service'; +import { NGXLogger } from 'ngx-logger'; +import { FileTypeEnum, IconPairingEnum } from '../../model/enum/file-type.enum'; +import { SetChatSettings } from 'src/app/store/chat-settings/chat-settings.action'; +import { ChatSettings } from 'src/app/model/interfaces/ChatSettings'; +import { ChatSettingsState } from 'src/app/store/chat-settings/chat-settings.state'; +import { RequirementTypeEnum } from 'src/app/model/enum/requirement-type.enum'; +import { APP_INTEGRATIONS } from 'src/app/constants/toast.constant'; +import { + getJiraTokenInfo, + storeJiraToken, + resetJiraToken, +} from '../../integrations/jira/jira.utils'; +import { ElectronService } from 'src/app/services/electron/electron.service'; +import { FeatureService } from '../../services/feature/feature.service'; + +@Component({ + selector: 'app-info', + templateUrl: './app-info.component.html', + styleUrls: ['./app-info.component.scss'], + standalone: true, + imports: [ + NgIf, + NgClass, + AsyncPipe, + BadgeComponent, + ButtonComponent, + AccordionComponent, + InputFieldComponent, + ReactiveFormsModule, + NgIconComponent, + DocumentListingComponent, + NgForOf, + ], +}) +export class AppInfoComponent implements OnInit, OnDestroy { + protected readonly APP_MESSAGES = APP_MESSAGES; + @ViewChild(MultiUploadComponent) multiUploadComponent!: MultiUploadComponent; + @ViewChild('mermaidContainer') mermaidContainer!: ElementRef; + fileContent: string = ''; + tasks: any[] = []; + haiFolder = Object.keys(FileTypeEnum).map((key) => ({ + key, + value: FileTypeEnum[key as keyof typeof FileTypeEnum], + })); + haiIcons = Object.keys(IconPairingEnum).map((key) => ({ + key, + value: IconPairingEnum[key as keyof typeof IconPairingEnum], + })); + + useGenAI: any = true; + public loading: boolean = false; + appName: string = ''; + jiraForm!: FormGroup; + bedrockForm!: FormGroup; + editButtonDisabled: boolean = false; + bedrockEditButtonDisabled: boolean = false; + directories$ = this.store.select(ProjectsState.getProjectsFolders); + selectedFolder: any = { title: 'solution', id: '' }; + content = new FormControl<string>(''); + appInfo: any = {}; + projectId = this.route.snapshot.paramMap.get('id'); + destroy$: Subject<boolean> = new Subject<boolean>(); + navigationState: any; + isJiraConnected: boolean = false; + isBedrockConnected: boolean = false; + currentSettings?: ChatSettings; + + accordionState: { [key: string]: boolean } = { + jira: false, + knowledgeBase: false, + }; + + chatSettings$: Observable<ChatSettings> = this.store.select( + ChatSettingsState.getConfig, + ); + + // Predefined order of folders + folderOrder = ['BRD', 'NFR', 'PRD', 'UIR', 'BP']; + + constructor( + private route: ActivatedRoute, + private router: Router, + private store: Store, + private toast: ToasterService, + private electronService: ElectronService, + private featureService: FeatureService, + private logger: NGXLogger, + ) { + const navigation = this.router.getCurrentNavigation(); + this.appInfo = navigation?.extras?.state?.['data']; + this.navigationState = navigation?.extras?.state; + this.appName = this.appInfo?.name; + } + + @HostListener('window:focus') + onFocus() { + this.store.dispatch(new GetProjectFiles(this.projectId as string)); + } + + ngOnInit(): void { + this.store + .select(ProjectsState.getProjects) + .pipe(first()) + .subscribe((projects) => { + const project = projects.find((p) => p.metadata.id === this.projectId); + + if (project) { + this.appInfo = project.metadata; + this.appName = project.project; + + this.store.dispatch(new GetProjectFiles(this.projectId as string)); + + this.store.dispatch( + new AddBreadcrumbs([ + { + label: this.appName.replace(/(^\w{1})|(\s+\w{1})/g, (letter) => + letter.toUpperCase(), + ), + url: `/apps/${this.appInfo.id}`, + }, + ]), + ); + } else { + console.error('Project not found with id:', this.projectId); + } + }); + + this.directories$ + .pipe( + first((directories) => directories && directories.length > 0), + takeUntil(this.destroy$), + ) + .subscribe((directories) => { + if (this.navigationState && this.navigationState['selectedFolder']) { + this.selectedFolder = this.navigationState['selectedFolder']; + } + // Sort directories based on predefined order + directories.sort((a, b) => { + return this.folderOrder.indexOf(a.name) - this.folderOrder.indexOf(b.name); + }); + }); + + // Initialize Mermaid configuration + mermaid.initialize({ + startOnLoad: false, + theme: 'default', + flowchart: { + useMaxWidth: true, + }, + }); + + // Initialize forms + this.bedrockForm = new FormGroup({ + kbId: new FormControl( + this.appInfo.integration?.bedrock?.kbId || '', + Validators.required, + ), + }); + + this.jiraForm = new FormGroup({ + jiraProjectKey: new FormControl( + this.appInfo.integration?.jira?.jiraProjectKey || '', + Validators.required, + ), + clientId: new FormControl( + this.appInfo.integration?.jira?.clientId || '', + Validators.required, + ), + clientSecret: new FormControl( + this.appInfo.integration?.jira?.clientSecret || '', + Validators.required, + ), + redirectUrl: new FormControl( + this.appInfo.integration?.jira?.redirectUrl || '', + Validators.required, + ), + }); + + this.editButtonDisabled = !this.jiraForm.valid; + this.jiraForm.statusChanges.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.editButtonDisabled = !this.jiraForm.valid; + }); + + this.chatSettings$.subscribe((settings) => { + this.currentSettings = settings; + }); + + this.bedrockEditButtonDisabled = !this.bedrockForm.valid; + this.bedrockForm.statusChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.bedrockEditButtonDisabled = !this.bedrockForm.valid; + }); + + this.isJiraConnected = (() => { + const tokenInfo = getJiraTokenInfo(this.projectId as string); + return ( + tokenInfo.projectKey === + this.appInfo.integration?.jira?.jiraProjectKey && + !!tokenInfo.token && + this.isTokenValid() + ); + })(); + + this.handleIntegrationNavState(); + this.isBedrockConnected = !!this.appInfo.integration?.bedrock?.kbId; + this.isJiraConnected && this.jiraForm.disable(); + this.isBedrockConnected && this.bedrockForm.disable(); + } + + isTokenValid(): boolean { + const { token, tokenExpiration } = getJiraTokenInfo( + this.projectId as string, + ); + return ( + !!token && !!tokenExpiration && new Date() < new Date(tokenExpiration) + ); + } + + handleJiraAuthentication(): void { + const { jiraProjectKey, clientId, clientSecret, redirectUrl } = + this.jiraForm.getRawValue(); + + const oauthParams = { + clientId: clientId, + clientSecret: clientSecret, + redirectUri: redirectUrl, + }; + this.electronService + .startJiraOAuth(oauthParams) + .then((authResponse) => { + storeJiraToken(authResponse, jiraProjectKey, this.projectId as string); + console.debug('Token received and stored.', authResponse.accessToken); + this.saveJiraData(); + this.toast.showSuccess(APP_INTEGRATIONS.JIRA.SUCCESS); + }) + .catch((error) => { + console.error('Error during OAuth process:', error); + this.toast.showError(APP_INTEGRATIONS.JIRA.ERROR); + }); + } + + saveJiraData() { + const { jiraProjectKey, clientId, clientSecret, redirectUrl } = + this.jiraForm.getRawValue(); + const tokenInfo = getJiraTokenInfo(this.projectId as string); + + const updatedMetadata = { + ...this.appInfo, + integration: { + ...this.appInfo.integration, + jira: { jiraProjectKey, clientId, clientSecret, redirectUrl }, + }, + }; + + this.store + .dispatch(new UpdateMetadata(this.appInfo.id, updatedMetadata)) + .subscribe(() => { + this.logger.debug('Jira metadata updated successfully'); + this.jiraForm.disable(); + this.isJiraConnected = + tokenInfo.projectKey === jiraProjectKey && !!tokenInfo.token; + this.editButtonDisabled = true; + }); + } + + disconnectJira(): void { + resetJiraToken(this.projectId as string); + this.jiraForm.enable(); + this.isJiraConnected = false; + this.editButtonDisabled = false; + this.toast.showSuccess(APP_INTEGRATIONS.JIRA.DISCONNECT); + } + + saveBedrockData() { + const { kbId } = this.bedrockForm.getRawValue(); + + this.featureService.validateBedrockId(kbId).subscribe({ + next: (isValid) => { + if (isValid) { + const updatedMetadata = { + ...this.appInfo, + integration: { ...this.appInfo.integration, bedrock: { kbId } }, + }; + + this.store.dispatch( + new SetChatSettings({ + ...this.currentSettings, + kb: kbId, + }), + ); + + this.store + .dispatch(new UpdateMetadata(this.appInfo.id, updatedMetadata)) + .subscribe(() => { + this.logger.debug('Bedrock metadata updated successfully'); + this.bedrockForm.disable(); + this.bedrockEditButtonDisabled = true; + this.isBedrockConnected = true; + this.toast.showSuccess(APP_INTEGRATIONS.BEDROCK.SUCCESS); + }); + } else { + this.toast.showError(APP_INTEGRATIONS.BEDROCK.INVALID); + } + }, + error: (err) => { + console.error('Error during Bedrock validation:', err); + this.toast.showError(APP_INTEGRATIONS.BEDROCK.ERROR); + }, + }); + } + + disconnectBedrock(): void { + const updatedMetadata = { + ...this.appInfo, + integration: { ...this.appInfo.integration, bedrock: '' }, + }; + + this.store.dispatch( + new SetChatSettings({ + ...this.currentSettings, + kb: '', + }), + ); + + this.store + .dispatch(new UpdateMetadata(this.appInfo.id, updatedMetadata)) + .subscribe(() => { + this.bedrockForm.enable(); + this.bedrockEditButtonDisabled = false; + this.isBedrockConnected = false; + this.toast.showSuccess(APP_INTEGRATIONS.BEDROCK.DISCONNECT); + }); + } + + selectFolder(folder: any): void { + this.selectedFolder = { + title: folder.name, + id: this.projectId as string, + metadata: this.appInfo, + }; + } + + toggleAccordion(key: string) { + this.accordionState[key] = !this.accordionState[key]; + } + + getDescription(input: string | undefined): string | null { + return getDescriptionFromInput(input); + } + + directoryContainsFolder( + folderName: string, + directories: { name: string; children: string[] }[], + ) { + return directories.some((dir) => dir.name.includes(folderName) && !this.isArchived(dir)); + } + + isArchived(directories: { name: string; children: string[]}) { + if(directories.name === RequirementTypeEnum.PRD) return directories.children.filter((child) => child.includes(FILTER_STRINGS.BASE)).every((child) => child.includes(FILTER_STRINGS.ARCHIVED)); + return directories.children.every((child) => child.includes(FILTER_STRINGS.ARCHIVED)); + } + + navigateToBPAdd(): void { + // Check if any non-archived PRD or BRD exists + this.directories$.pipe(first()).subscribe(directories => { + const prdDir = directories.find(dir => dir.name === 'PRD'); + const brdDir = directories.find(dir => dir.name === 'BRD'); + + // For PRD, only check base files that aren't archived + const hasPRD = prdDir && prdDir.children + .filter(child => child.includes('-base.json')) + .some(child => !child.includes('-archived')); + + // For BRD, only check base files that aren't archived + const hasBRD = brdDir && brdDir.children + .filter(child => child.includes('-base.json')) + .some(child => !child.includes('-archived')); + + if (!hasPRD && !hasBRD) { + this.toast.showWarning(APP_INFO_COMPONENT_ERROR_MESSAGES.REQUIRES_PRD_OR_BRD); + return; + } + + this.router + .navigate(['/bp-add'], { + state: { + data: this.appInfo, + id: this.projectId, + folderName: 'BP', + breadcrumb: { + name: 'Add Document', + link: this.router.url, + icon: 'add', + }, + }, + }) + .then(); + }); + } + + navigateToAdd(folderName: string) { + this.router + .navigate(['/add'], { + state: { + data: this.appInfo, + id: this.projectId, + folderName: folderName, + breadcrumb: { + name: 'Add Document', + link: this.router.url, + icon: 'add', + }, + }, + }) + .then(); + } + + navigateToBPFlow(item: any) { + this.router.navigate(['/bp-flow/view', item.id], { + state: { + data: this.appInfo, + id: item.id, + folderName: item.folderName, + fileName: item.fileName, + req: item.content, + selectedFolder: { + title: item.folderName, + id: this.appInfo.id, + metadata: this.appInfo, + }, + }, + }); + } + + handleIntegrationNavState(): void { + if (this.navigationState && this.navigationState['openAppIntegrations']) { + this.selectFolder({ name: 'app-integrations', children: [] }); + this.toggleAccordion('jira'); + } + } + + getIconName(key: string): string { + const icon = IconPairingEnum[key as keyof typeof IconPairingEnum]; + return icon || 'defaultIcon'; + } + + ngOnDestroy() { + this.destroy$.next(true); + this.destroy$.complete(); + } +} diff --git a/ui/src/app/pages/apps/apps.component.html b/ui/src/app/pages/apps/apps.component.html new file mode 100644 index 0000000..ebaf304 --- /dev/null +++ b/ui/src/app/pages/apps/apps.component.html @@ -0,0 +1,81 @@ +<div class="px-8" *ngIf="projectList$ | async as apps"> + <div class="mb-4 bg-white rounded-lg shadow h-full relative min-h-96"> + <div + class="flex items-center justify-between p-6 sticky top-0 bg-white rounded-t-lg" + > + <h1 class="text-xl font-semibold leading-6 text-gray-900">Solutions</h1> + <app-button + [routerLink]="'/apps/create'" + buttonContent="Create Solution" + size="sm" + /> + </div> + <main> + <!-- Your content --> + <div class="px-6 pb-6" *ngIf="apps.length === 0"> + <button + routerLink="/apps/create" + type="button" + class="relative block w-full rounded-lg border-2 border-dashed border-gray-300 p-12 text-center hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" + > + <svg + class="mx-auto h-12 w-12 text-gray-400" + stroke="currentColor" + fill="none" + viewBox="0 0 48 48" + aria-hidden="true" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + stroke-width="2" + d="M8 14v20c0 4.418 7.163 8 16 8 1.381 0 2.721-.087 4-.252M8 14c0 4.418 7.163 8 16 8s16-3.582 16-8M8 14c0-4.418 7.163-8 16-8s16 3.582 16 8m0 0v14m0-4c0 4.418-7.163 8-16 8S8 28.418 8 24m32 10v6m0 0v6m0-6h6m-6 0h-6" + /> + </svg> + <span class="mt-2 block text-sm font-semibold text-gray-900" + >Create a new solution</span + > + </button> + </div> + + <ul role="list" class="divide-y divide-gray-100 h-full overflow-hidden"> + <li + class="flex items-center justify-between gap-x-6 px-6 py-3 hover:bg-secondary-50" + *ngFor="let app of apps; index as i" + > + <div class="min-w-0"> + <div class="flex items-start gap-x-3"> + <p + class="capitalize text-sm font-semibold leading-6 text-gray-900" + > + {{ app.project }} + </p> + <!-- <p + class="rounded-md whitespace-nowrap mt-0.5 px-1.5 py-0.5 text-xs font-medium ring-1 ring-inset text-green-700 bg-green-50 ring-green-600/20" + > + Complete + </p> --> + </div> + <div + class="mt-1 flex items-center gap-x-2 text-xs leading-5 text-gray-500" + > + <p class="whitespace-nowrap"> + Created + {{ app?.metadata?.createdAt | timezone }} + </p> + </div> + </div> + <div class="flex flex-none items-center gap-x-4"> + <app-button + buttonContent="View Solution" + theme="secondary_outline" + size="sm" + rounded="lg" + (click)="navigateToApp(app?.metadata)" + /> + </div> + </li> + </ul> + </main> + </div> +</div> diff --git a/ui/src/app/pages/apps/apps.component.scss b/ui/src/app/pages/apps/apps.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/pages/apps/apps.component.spec.ts b/ui/src/app/pages/apps/apps.component.spec.ts new file mode 100644 index 0000000..b701a8a --- /dev/null +++ b/ui/src/app/pages/apps/apps.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AppsComponent } from './apps.component'; + +describe('AppsComponent', () => { + let component: AppsComponent; + let fixture: ComponentFixture<AppsComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [AppsComponent], + }); + fixture = TestBed.createComponent(AppsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/pages/apps/apps.component.ts b/ui/src/app/pages/apps/apps.component.ts new file mode 100644 index 0000000..8129062 --- /dev/null +++ b/ui/src/app/pages/apps/apps.component.ts @@ -0,0 +1,50 @@ +import { Component, inject, OnInit } from '@angular/core'; +import { Store } from '@ngxs/store'; +import { ProjectsState } from '../../store/projects/projects.state'; +import { GetProjectListAction } from '../../store/projects/projects.actions'; +import { Router, RouterLink } from '@angular/router'; +import { AddBreadcrumbs } from '../../store/breadcrumb/breadcrumb.actions'; +import { AsyncPipe, NgForOf, NgIf } from '@angular/common'; +import { ButtonComponent } from '../../components/core/button/button.component'; +import { TimeZonePipe } from '../../pipes/timezone-pipe'; + +@Component({ + selector: 'app-apps', + templateUrl: './apps.component.html', + styleUrls: ['./apps.component.scss'], + standalone: true, + imports: [ + NgIf, + ButtonComponent, + RouterLink, + AsyncPipe, + NgForOf, + TimeZonePipe, + ], +}) +export class AppsComponent implements OnInit { + store = inject(Store); + route = inject(Router); + + projectList$ = this.store.select(ProjectsState.getProjects); + + navigateToApp(data: any) { + this.route + .navigate([`apps/${data.id}`], { + state: { + data, + breadcrumb: { + name: data.name, + link: '/', + icon: '', + }, + }, + }) + .then(); + } + + ngOnInit() { + this.store.dispatch(new AddBreadcrumbs([])); + this.store.dispatch(new GetProjectListAction()); + } +} diff --git a/ui/src/app/pages/business-process-flow/business-process-flow.component.html b/ui/src/app/pages/business-process-flow/business-process-flow.component.html new file mode 100644 index 0000000..8b0c7bb --- /dev/null +++ b/ui/src/app/pages/business-process-flow/business-process-flow.component.html @@ -0,0 +1,52 @@ +<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 h-full"> + <div class="grid grid-cols-1 gap-4"> + <div class="bg-white shadow rounded-lg p-6"> + <div> + <h1 class="text-lg font-semibold mb-4"> + {{ requirementId }} - {{ selectedRequirement.title }} + </h1> + <!-- Button to regenerate flow diagram. --> + <div class="flex justify-end space-x-4 mt-4 w-full"> + <div *ngIf="selectedBusinessProcess.regenerate" class="ml-auto"> + <app-button + buttonContent="Regenerate" + theme="primary" + size="sm" + rounded="lg" + (click)="generateProcessFlowDiagram()" + /> + </div> + </div> + </div> + <div class=""> + <!--Placeholder for mermaid flow diagram--> + <div class="mermaid-container my-4 overflow-y-auto flex justify-center"> + <div class="mermaid-wrapper" #mermaidContainer id="mermaidContainer"> + <p *ngIf="errorBlockVisible">{{ errorMessage }}</p> + <pre *ngIf="!errorBlockVisible" class="mermaid"></pre> + </div> + </div> + <!--Display download button and reset button only when image is loaded.--> + <div + *ngIf="selectedBusinessProcess.download" + class="w-full flex space-x-4 ml-auto justify-end" + > + <app-button + buttonContent="Reset" + theme="secondary" + size="sm" + rounded="lg" + (click)="resetZoom()" + /> + <app-button + buttonContent="Download" + theme="primary" + size="sm" + rounded="lg" + (click)="downloadDiagram()" + /> + </div> + </div> + </div> + </div> +</div> diff --git a/ui/src/app/pages/business-process-flow/business-process-flow.component.scss b/ui/src/app/pages/business-process-flow/business-process-flow.component.scss new file mode 100644 index 0000000..8cf79e0 --- /dev/null +++ b/ui/src/app/pages/business-process-flow/business-process-flow.component.scss @@ -0,0 +1,3 @@ +.mermaid-wrapper { + height: calc(100dvh - 370px); +} \ No newline at end of file diff --git a/ui/src/app/pages/business-process-flow/business-process-flow.component.spec.ts b/ui/src/app/pages/business-process-flow/business-process-flow.component.spec.ts new file mode 100644 index 0000000..f748455 --- /dev/null +++ b/ui/src/app/pages/business-process-flow/business-process-flow.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BusinessProcessFlowComponent } from './business-process-flow.component'; + +describe('BusinessProcessFlowComponent', () => { + let component: BusinessProcessFlowComponent; + let fixture: ComponentFixture<BusinessProcessFlowComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [BusinessProcessFlowComponent] + }); + fixture = TestBed.createComponent(BusinessProcessFlowComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/pages/business-process-flow/business-process-flow.component.ts b/ui/src/app/pages/business-process-flow/business-process-flow.component.ts new file mode 100644 index 0000000..6c14a55 --- /dev/null +++ b/ui/src/app/pages/business-process-flow/business-process-flow.component.ts @@ -0,0 +1,317 @@ +import { + AfterViewInit, + Component, + ElementRef, + inject, + OnDestroy, + OnInit, + ViewChild, +} from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { SetCurrentConfig } from '../../store/user-stories/user-stories.actions'; +import { NGXLogger } from 'ngx-logger'; +import { Store } from '@ngxs/store'; + +import { + AddBreadcrumb, + DeleteBreadcrumb, +} from '../../store/breadcrumb/breadcrumb.actions'; +import { FeatureService } from '../../services/feature/feature.service'; +import { IFlowChartRequest } from '../../model/interfaces/IBusinessProcess'; +import { + GetFlowChartAction, + SetFlowChartAction, +} from '../../store/business-process/business-process.actions'; +import { ProjectsState } from '../../store/projects/projects.state'; +import { BusinessProcessState } from '../../store/business-process/business-process.state'; + +import mermaid from 'mermaid'; +import html2canvas from 'html2canvas'; +import panzoom from 'panzoom'; +import { LoadingService } from '../../services/loading.service'; +import { ButtonComponent } from '../../components/core/button/button.component'; +import { NgIf } from '@angular/common'; + +@Component({ + selector: 'app-business-process-flow', + templateUrl: './business-process-flow.component.html', + styleUrls: ['./business-process-flow.component.scss'], + standalone: true, + imports: [ButtonComponent, NgIf], +}) +export class BusinessProcessFlowComponent + implements OnInit, AfterViewInit, OnDestroy +{ + router = inject(Router); + logger = inject(NGXLogger); + store = inject(Store); + activatedRoute = inject(ActivatedRoute); + + @ViewChild('mermaidContainer', { static: false }) + mermaidContainer!: ElementRef; + private panzoomInstance: any; + selectedProject$ = this.store.select(ProjectsState.getSelectedProject); + selectedProcessFlowDiagram$ = this.store.select( + BusinessProcessState.getSelectedFlowChart, + ); + + errorBlockVisible: boolean = false; + errorMessage: string = + "It looks like the description you entered doesn't contain enough details to generate a valid process flow. Please Provide a more relevant description or select relevant PRDs and BRDs to generate the process flow."; + mode: string | null = 'view'; + projectId: string = ''; + projectName: string = ''; + folderName: string = ''; + fileName: string = ''; + selectedRequirement: any = {}; + data: any = {}; + selectedBusinessProcess = { + diagram: '', + download: false, + regenerate: false, + }; + requirementId: string = ''; + currentLabel: string = ''; + + constructor( + private featureService: FeatureService, + private loadingService: LoadingService, + ) { + this.mode = this.activatedRoute.snapshot.paramMap.get('mode'); + const navigation = this.router.getCurrentNavigation(); + this.projectId = navigation?.extras?.state?.['id']; + this.folderName = navigation?.extras?.state?.['folderName']; + this.fileName = navigation?.extras?.state?.['fileName']; + this.selectedRequirement = navigation?.extras?.state?.['req']; + this.data = navigation?.extras?.state?.['data']; + this.requirementId = this.fileName.split('-')[0]; + this.currentLabel = `${this.requirementId ?? ''} - Flow chart`; + // Setting current config in state. + this.store.dispatch( + new SetCurrentConfig({ + projectId: this.projectId, + folderName: this.folderName, + fileName: this.fileName, + reqId: this.requirementId, + featureId: '', + }), + ); + + // Add folder breadcrumb + // Eg: BP + this.store.dispatch( + new AddBreadcrumb({ + label: this.folderName, + url: `/apps/${this.projectId}`, + state: { + data: this.data, + selectedFolder: { + title: this.folderName, + id: this.projectId, + metadata: this.data, + }, + }, + }), + ); + + // Add current BP flow chart breadcrumb + // Eg: BP1 - Flow chart + this.store.dispatch( + new AddBreadcrumb({ + label: `${this.requirementId ?? ''} - Flow chart`, + url: this.router.url, + state: { + data: this.data, + id: this.projectId, + folderName: this.folderName, + }, + }), + ); + } + + ngOnInit() { + this.loadingService.setLoading(true); + // Get current project name. + this.selectedProject$.subscribe((selectedProj) => { + this.projectName = selectedProj; + }); + // Get Flow chart for selected business process. + this.store.dispatch( + new GetFlowChartAction( + `${this.projectName}/${this.folderName}/${this.fileName}`, + ), + ); + // Initialize Mermaid configuration + mermaid.initialize({ + startOnLoad: false, + theme: 'default', // Ensure consistent theme across diagrams + flowchart: { + useMaxWidth: true, // Ensure diagrams respect container width + }, + }); + setTimeout(() => { + this.fetchProcessFlowDiagram(); + }, 1000); + } + + ngAfterViewInit(): void { + this.initializePanZoom(); + } + + async fetchProcessFlowDiagram() { + this.selectedProcessFlowDiagram$.subscribe(async (response) => { + this.selectedBusinessProcess.diagram = response; + }); + if (!this.selectedBusinessProcess.diagram) { + await this.generateProcessFlowDiagram(); + } else { + await this.setFlowDiagram(this.selectedBusinessProcess.diagram); + } + } + + async setFlowDiagram(flowChartSyntax: string) { + const element: any = document.querySelector('.mermaid'); + try { + const { svg } = await mermaid.render('mermaid', `${flowChartSyntax}`); + element!.innerHTML = svg; + this.selectedBusinessProcess.download = true; + this.loadingService.setLoading(false); + this.selectedBusinessProcess.regenerate = true; + mermaid.contentLoaded(); + } catch (error) { + console.error('Syntax error while rendering mermaid diagram:', error); + const element = document.getElementById('dmermaid'); + if (element) { + element.style.display = 'none'; + } + this.errorBlockVisible = true; + this.selectedBusinessProcess.regenerate = true; + this.loadingService.setLoading(false); + this.selectedBusinessProcess.download = false; + } + } + + async generateProcessFlowDiagram() { + this.errorBlockVisible = false; + const request: IFlowChartRequest = { + id: this.requirementId, + title: this.selectedRequirement.title, + description: this.selectedRequirement.requirement, + }; + this.featureService.addFlowChart(request).subscribe({ + next: (response: any) => { + this.store.dispatch( + new SetFlowChartAction( + `${this.projectName}/${this.folderName}/${this.fileName}`, + response, + ), + ); + this.setFlowDiagram(response).then(); + }, + error: (error) => { + console.error('Error from BE while generating flow chart', error); + this.errorBlockVisible = true; + }, + }); + } + + private getSvgWithDimensions() { + const element: any = document.querySelector('.mermaid'); + const svgElement = element?.querySelector('svg'); + + if (!svgElement) { + throw new Error('SVG element not found'); + } + + const viewBox = + svgElement.getAttribute('viewBox')?.split(' ').map(Number) || []; + const width = viewBox[2] || svgElement.width.baseVal.value; + const height = viewBox[3] || svgElement.height.baseVal.value; + + const clonedSvg = svgElement.cloneNode(true) as SVGElement; + clonedSvg.setAttribute('width', width.toString()); + clonedSvg.setAttribute('height', height.toString()); + + return { svg: clonedSvg, width, height }; + } + + private prepareSvgForExport(svg: SVGElement): string { + const svgData = new XMLSerializer().serializeToString(svg); + const svgBase64 = btoa(decodeURIComponent(encodeURIComponent(svgData))); + return `data:image/svg+xml;base64,${svgBase64}`; + } + + private createAndDownloadPng( + img: HTMLImageElement, + width: number, + height: number, + scaleFactor: number, + ): void { + const canvas = document.createElement('canvas'); + canvas.width = width * scaleFactor; + canvas.height = height * scaleFactor; + + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.scale(scaleFactor, scaleFactor); + ctx.drawImage(img, 0, 0); + + const pngData = canvas.toDataURL('image/png'); + const link = document.createElement('a'); + link.download = `${this.projectName}-${this.requirementId}-process-flow.png`; + link.href = pngData; + link.click(); + } + } + + downloadDiagram(): void { + if (!this.mermaidContainer?.nativeElement) return; + + this.loadingService.setLoading(true); + + try { + const { svg, width, height } = this.getSvgWithDimensions(); + const svgDataUrl = this.prepareSvgForExport(svg); + + const scaleFactor = 10; // For image quality + const img = new Image(); + + img.onload = () => { + this.createAndDownloadPng(img, width, height, scaleFactor); + this.loadingService.setLoading(false); + }; + + img.src = svgDataUrl; + } catch (error) { + console.error('Error during diagram export:', error); + this.loadingService.setLoading(false); + } + } + + initializePanZoom(): void { + if (this.mermaidContainer && this.mermaidContainer.nativeElement) { + const element: any = document.querySelector('.mermaid'); + this.panzoomInstance = panzoom(element, { + zoomSpeed: 1, + maxZoom: 5, + minZoom: 0.5, + initialZoom: 1, + bounds: true, + boundsPadding: 0.1, + }); + } + } + + resetZoom(): void { + if (this.panzoomInstance) { + this.panzoomInstance.moveTo(0, 0); + this.panzoomInstance.zoomAbs(0, 0, 1); + } + } + + ngOnDestroy(): void { + this.store.dispatch(new DeleteBreadcrumb(this.currentLabel)); + } +} diff --git a/ui/src/app/pages/business-process/business-process.component.html b/ui/src/app/pages/business-process/business-process.component.html new file mode 100644 index 0000000..459630f --- /dev/null +++ b/ui/src/app/pages/business-process/business-process.component.html @@ -0,0 +1,347 @@ +<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 h-full"> + <div class="grid grid-cols-12 gap-4"> + <div + [formGroup]="businessProcessForm" + class="bg-white shadow rounded-lg p-6 flex flex-col col-span-12 lg:col-span-7 overflow-y-auto" + > + <div class="content-center flex justify-start items-start"> + <div class="flex justify-between w-full items-center mb-4"> + <h1 class="text-lg font-semibold" *ngIf="mode === 'add'"> + {{ folderName | expandDescription }} + </h1> + <h1 class="text-lg font-semibold" *ngIf="mode === 'edit'"> + {{ bpRequirementId }} + </h1> + <div class="flex items-center"> + <app-button + *ngIf="mode === 'edit'" + buttonContent="View BP Flow" + theme="secondary" + size="sm" + rounded="md" + (click)="navigateToBPFlow()" + /> + <div + *ngIf="mode === 'edit'" + class="flex items-center space-x-1 text-3xl ml-3" + > + <app-button + [isIconButton]="true" + icon="heroTrash" + theme="danger" + size="sm" + rounded="md" + (click)="deleteBP()" + ></app-button> + </div> + </div> + </div> + </div> + <div> + <app-input-field + elementPlaceHolder="Title" + elementId="title" + elementName="Title" + formControlName="title" + [required]="true" + /> + <div *ngIf="businessProcessForm.get('title')?.errors?.['required'] && businessProcessForm.get('title')?.touched" class="text-red-500 text-sm mt-1"> + Title is required + </div> + <app-textarea-field + elementPlaceHolder="Description" + elementId="description" + elementName="Description" + formControlName="content" + [required]="true" + [rows]="12" + /> + <div *ngIf="businessProcessForm.get('content')?.errors?.['required'] && businessProcessForm.get('content')?.touched" class="text-red-500 text-sm mt-1"> + Description is required + </div> + + <div class="flex items-center justify-between mt-4"> + <div class="flex-col"> + <div class="flex items-center mb-4"> + <input + type="checkbox" + id="expandAI" + formControlName="expandAI" + class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500 cursor-pointer" + /> + <label for="expandAI" class="ml-2 block text-sm text-gray-900"> + Expand with AI + </label> + </div> + </div> + <div> + <app-button + buttonContent="Update" + theme="primary" + size="sm" + rounded="md" + *ngIf="mode === 'edit'" + (click)="updateBusinessProcess()" + [disabled]="checkFormValidity()" + /> + <app-button + buttonContent="Add" + theme="primary" + size="sm" + rounded="md" + *ngIf="mode === 'add'" + [disabled]="checkFormValidity()" + (click)="addBusinessProcess()" + /> + </div> + </div> + </div> + <div *ngIf="selectedBRDs.length > 0" class="mt-4"> + <h2 class="text-sm font-medium text-gray-900">Included BRDs</h2> + <div class="mt-2 flex flex-wrap"> + <div *ngFor="let brd of selectedBRDs" class="m-1"> + <div class="flex items-center bg-gray-200 px-2 py-1 rounded-md"> + <span class="text-sm font-medium">{{ + brd.fileName.split("-")[0] + }}</span> + <button + class="ml-2 text-gray-600 hover:text-gray-800" + (click)="removeBRD(brd)" + > + × + </button> + </div> + </div> + </div> + </div> + <div class="mt-4" *ngIf="selectedPRDs.length > 0"> + <h2 class="text-sm font-medium text-gray-900">Included PRDs</h2> + <div class="mt-2 flex flex-wrap"> + <div *ngFor="let prd of selectedPRDs" class="m-1"> + <div class="flex items-center bg-gray-200 px-2 py-1 rounded-md"> + <span class="text-sm font-medium">{{ + prd.fileName.split("-")[0] + }}</span> + <button + class="ml-2 text-gray-600 hover:text-gray-800" + (click)="removePRD(prd)" + > + × + </button> + </div> + </div> + </div> + </div> + <!-- Display empty message if no documents are present --> + <div *ngIf="selectedPRDs.length === 0 && selectedBRDs.length === 0" class="py-10"> + <div *ngIf="businessProcessForm.errors?.['noPrdOrBrd']" class="text-red-500 text-center text-xs mt-1"> + Please select at least one PRD or BRD. + </div> + </div> + </div> + + <div class="bg-white shadow rounded-lg col-span-12 lg:col-span-5"> + <div class="flex justify-center rounded-lg bg-gray-100 w-full"> + <div class="tabs w-full max-w-4xl overflow-hidden flex rounded-t-lg"> + <button + *ngIf="mode === 'edit'" + (click)="switchTab('chat')" + class="tab flex-grow text-center py-3 text-sm font-semibold border-r-2 border-gray-300" + [ngClass]="{ + 'bg-gray-100 text-gray-700 hover:bg-gray-200': + activeTab !== 'chat', + 'bg-gray-700 text-white hover:bg-gray-600': activeTab === 'chat', + }" + > + Chat + </button> + <button + (click)="switchTab('includeFiles')" + class="tab flex-grow text-center py-3 text-sm font-semibold" + [ngClass]="{ + 'bg-gray-100 text-gray-700 hover:bg-gray-200': + activeTab !== 'includeFiles', + 'bg-gray-700 text-white hover:bg-gray-600': + activeTab === 'includeFiles', + }" + > + Include BRDs & PRDs + </button> + </div> + </div> + + <div *ngIf="activeTab === 'includeFiles'" class=""> + <div class="space-y-12"> + <div> + <div> + <div class="tabs px-4 pt-4"> + <button + (click)="selectTab(requirementTypes.PRD)" + class="tab px-3 py-2 text-sm font-semibold rounded-t-md border-b-2" + [class.active]="selectedTab === requirementTypes.PRD" + [ngClass]="{ + 'border-indigo-600 text-indigo-600': + selectedTab === requirementTypes.PRD, + 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300': + selectedTab !== requirementTypes.PRD, + }" + > + PRD ({{ selectedPRDs.length }}) + </button> + <button + (click)="selectTab(requirementTypes.BRD)" + class="tab px-3 py-2 text-sm font-semibold rounded-t-md border-b-2" + [class.active]="selectedTab === requirementTypes.BRD" + [ngClass]="{ + 'border-indigo-600 text-indigo-600': + selectedTab === requirementTypes.BRD, + 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300': + selectedTab !== requirementTypes.BRD, + }" + > + BRD ({{ selectedBRDs.length }}) + </button> + </div> + <div class="px-4 pb-4"> + <div class="" *ngIf="originalDocumentList$ | async as list"> + <div + *ngIf="!!list?.length; else noDocuments" + class="grid grid-cols-1 gap-3 sm:gap-3 lg:gap-3 overflow-y-auto mt-4 doc-section-height auto-rows-max" + > + <ng-container *ngFor="let item of list"> + <div *ngIf="(selectedTab === requirementTypes.PRD && item.folderName === requirementTypes.PRD) || (selectedTab === requirementTypes.BRD && item.folderName === requirementTypes.BRD)" class="col-span-1 flex h-fit"> + <div class="flex items-center m-2"> + <input + type="checkbox" + [value]=" + JSON.stringify({ + requirement: item.content.requirement, + fileName: item.fileName, + }) + " + (change)="toggleSelection($event, selectedTab)" + [checked]=" + isSelected( + { + requirement: item?.content?.requirement, + fileName: item.fileName, + }, + selectedTab + ) + " + class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500" + /> + </div> + <div class="flex-1 truncate rounded-md shadow-sm"> + <div + class="flex-1 flex flex-col justify-start rounded-lg border border-gray-200 bg-white" + > + <!-- Adjusted class here --> + <div class="px-3 py-2 text-sm"> + <a class="font-semibold text-secondary-600"> + {{ item.fileName.replace("-base.json", "") }} + </a> + <h1 class="text-base font-medium truncate mt-2"> + {{ item.content.title }} + </h1> + <p + *ngIf="item.folderName !== requirementTypes.PRD" + class="text-gray-500 text-xs text-wrap leading-tight" + > + {{ + item.content.requirement | truncateWithEllipsis + }} + </p> + <div + *ngIf="item.folderName === requirementTypes.PRD" + > + <ng-container + *ngIf="item.content.requirement as requirement" + > + <p class="text-gray-500 text-wrap"> + {{ + item.content.requirement + .split("Screens:")[0] + .split("Personas:")[0] + | truncateWithEllipsis + }} + </p> + <div + *ngIf=" + item.content.requirement?.includes( + 'Screens:' + ) + " + > + <h4 + class="text-sm pt-2 pb-1 font-medium truncate" + > + Screens: + </h4> + <p class="text-gray-500 text-wrap"> + {{ + item.content.requirement + .split("Screens:")[1] + .split("Personas:")[0] + | truncateWithEllipsis + }} + </p> + </div> + <div + *ngIf=" + item.content.requirement?.includes( + 'Personas:' + ) + " + > + <h4 + class="text-sm pt-2 pb-1 font-medium truncate" + > + Personas: + </h4> + <p class="text-gray-500 text-wrap"> + {{ + item.content.requirement + .split("Personas:") + .pop() | truncateWithEllipsis + }} + </p> + </div> + </ng-container> + </div> + </div> + </div> + </div> + </div> + </ng-container> + </div> + </div> + <ng-template #noDocuments> + <div class="py-10"> + <h2 class="text-center text-gray-500"> + No documents available + </h2> + </div> + </ng-template> + </div> + </div> + </div> + </div> + </div> + <div *ngIf="activeTab === 'chat'" class="space-y-4 alter-height"> + <app-chat + class="h-[inherit]" + chatType="requirement" + [name]="name" + [description]="description" + [fileName]="fileName" + [chatHistory]="chatHistory" + [supportsAddFromCode]="false" + [baseContent]="businessProcessForm.getRawValue().content" + (getContent)="appendRequirement($event)" + (updateChatHistory)="updateChatHistory($event)" + /> + </div> + </div> + </div> +</div> diff --git a/ui/src/app/pages/business-process/business-process.component.scss b/ui/src/app/pages/business-process/business-process.component.scss new file mode 100644 index 0000000..8469971 --- /dev/null +++ b/ui/src/app/pages/business-process/business-process.component.scss @@ -0,0 +1,5 @@ +.doc-section-height { + min-height: 535px; + height: calc(100dvh - 285px); + overflow: auto; +} \ No newline at end of file diff --git a/ui/src/app/pages/business-process/business-process.component.spec.ts b/ui/src/app/pages/business-process/business-process.component.spec.ts new file mode 100644 index 0000000..fda2890 --- /dev/null +++ b/ui/src/app/pages/business-process/business-process.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BusinessProcessComponent } from './business-process.component'; + +describe('BusinessProcessComponent', () => { + let component: BusinessProcessComponent; + let fixture: ComponentFixture<BusinessProcessComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [BusinessProcessComponent] + }); + fixture = TestBed.createComponent(BusinessProcessComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/pages/business-process/business-process.component.ts b/ui/src/app/pages/business-process/business-process.component.ts new file mode 100644 index 0000000..553dd0a --- /dev/null +++ b/ui/src/app/pages/business-process/business-process.component.ts @@ -0,0 +1,569 @@ +import { Component, inject, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { ProjectsState } from '../../store/projects/projects.state'; +import { Store } from '@ngxs/store'; +import { firstValueFrom } from 'rxjs'; +import { + BulkReadFiles, + CreateFile, + ArchiveFile, + ReadFile, + UpdateFile, + ClearBRDPRDState, +} from '../../store/projects/projects.actions'; +import { FeatureService } from '../../services/feature/feature.service'; +import { IList } from '../../model/interfaces/IList'; +import { + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { AddBreadcrumb } from '../../store/breadcrumb/breadcrumb.actions'; +import { Observable } from 'rxjs'; +import { + IAddBusinessProcessRequest, + IFlowChartRequest, + IUpdateProcessRequest, +} from '../../model/interfaces/IBusinessProcess'; +import { RequirementTypeEnum } from '../../model/enum/requirement-type.enum'; +import { MatDialog } from '@angular/material/dialog'; +import { LoadingService } from '../../services/loading.service'; +import { ButtonComponent } from '../../components/core/button/button.component'; +import { MatMenuModule } from '@angular/material/menu'; +import { InputFieldComponent } from '../../components/core/input-field/input-field.component'; +import { TextareaFieldComponent } from '../../components/core/textarea-field/textarea-field.component'; +import { ConfirmationDialogComponent } from '../../components/confirmation-dialog/confirmation-dialog.component'; +import { AsyncPipe, NgClass, NgForOf, NgIf } from '@angular/common'; +import { AiChatComponent } from '../../components/ai-chat/ai-chat.component'; +import { ExpandDescriptionPipe } from '../../pipes/expand-description.pipe'; +import { TruncateEllipsisPipe } from '../../pipes/truncate-ellipsis-pipe'; +import { NgIconComponent } from '@ng-icons/core'; +import { NGXLogger } from 'ngx-logger'; +import { + CONFIRMATION_DIALOG, + TOASTER_MESSAGES, +} from '../../constants/app.constants'; +import { ToasterService } from 'src/app/services/toaster/toaster.service'; + +@Component({ + selector: 'app-business-process', + templateUrl: './business-process.component.html', + styleUrls: ['./business-process.component.scss'], + standalone: true, + imports: [ + ButtonComponent, + ReactiveFormsModule, + MatMenuModule, + InputFieldComponent, + TextareaFieldComponent, + NgIf, + NgForOf, + NgClass, + AsyncPipe, + AiChatComponent, + ExpandDescriptionPipe, + TruncateEllipsisPipe, + NgIconComponent, + ], +}) +export class BusinessProcessComponent implements OnInit { + projectId: string = ''; + folderName: string = ''; + fileName: string = ''; + name: string = ''; + description: string = ''; + content: string = ''; + title: string = ''; + mode: 'edit' | 'add' = 'edit'; + data: any = {}; + selectedRequirement: any = {}; + absoluteFilePath: string = ''; + oldContent: string = ''; + existingFlowDiagram: string = ''; + public loading: boolean = false; + selectedBPFileContent$ = this.store.select( + ProjectsState.getSelectedFileContent, + ); + businessProcessForm!: FormGroup; + response: IList = {} as IList; + selectedPRDs: any[] = []; + selectedBRDs: any[] = []; + selectedTab: string = RequirementTypeEnum.PRD; + editLabel: string = ''; + bpRequirementId: string = ''; + requirementTypes: any = RequirementTypeEnum; + readonly dialog = inject(MatDialog); + allowFreeEdit: boolean = false; + activeTab: string = 'includeFiles'; + protected readonly JSON = JSON; + toastService = inject(ToasterService); + + originalDocumentList$: Observable<IList[]> = this.store.select( + ProjectsState.getSelectedFileContents, + ); + + chatHistory: any = []; + + removePRD(prd: { requirement: string; fileName: string }): void { + this.selectedPRDs = this.selectedPRDs.filter( + (item) => + !( + item.requirement === prd.requirement && item.fileName === prd.fileName + ), + ); + this.businessProcessForm.get('selectedPRDs')?.setValue(this.selectedPRDs); + this.updateContentValidators(); + this.updateIncludePRDandBRDValidator(); + } + + removeBRD(brd: { requirement: string; fileName: string }): void { + this.selectedBRDs = this.selectedBRDs.filter( + (item) => + !( + item.requirement === brd.requirement && item.fileName === brd.fileName + ), + ); + this.businessProcessForm.get('selectedBRDs')?.setValue(this.selectedBRDs); + this.updateContentValidators(); + this.updateIncludePRDandBRDValidator(); + } + + constructor( + private store: Store, + private router: Router, + private featureService: FeatureService, + private loadingService: LoadingService, + private loggerService: NGXLogger, + ) { + const url = this.router.url; + this.mode = url.includes('bp-add') ? 'add' : 'edit'; + const navigation = this.router.getCurrentNavigation(); + this.projectId = navigation?.extras?.state?.['id']; + this.folderName = navigation?.extras?.state?.['folderName']; + this.fileName = navigation?.extras?.state?.['fileName']; + this.data = navigation?.extras?.state?.['data']; + this.selectedRequirement = navigation?.extras?.state?.['req']; + this.store.dispatch( + new AddBreadcrumb({ + url: `/apps/${this.projectId}`, + label: this.folderName, + state: { + data: this.data, + selectedFolder: { + title: this.folderName, + id: this.projectId, + metadata: this.data, + }, + }, + }), + ); + this.editLabel = this.mode == 'edit' ? 'Edit' : 'Add'; + this.store.dispatch( + new AddBreadcrumb({ + label: this.editLabel, + url: this.router.url, + state: { + data: this.data, + id: this.projectId, + folderName: this.folderName, + fileName: this.fileName, + req: this.selectedRequirement, + }, + }), + ); + if (this.mode === 'edit') { + this.fileName = navigation?.extras?.state?.['fileName']; + this.absoluteFilePath = `${this.folderName}/${this.fileName}`; + this.name = this.data?.name; + this.description = this.data?.description; + this.bpRequirementId = this.fileName.split('-')[0]; + } + this.initializeBusinessProcessForm(); + } + + private streamSelectedStoring( + dataRequirements: string[], + formRequirements: { requirement: any; fileName: any }[], + ): any[] { + return dataRequirements + .map((requirement: string) => { + const formItem = formRequirements.find( + (item: { requirement: any }) => item.requirement === requirement, + ); + return formItem ? { requirement, fileName: formItem.fileName } : null; + }) + .filter((item) => item !== null); + } + + addBusinessProcess() { + const formValue = this.businessProcessForm.getRawValue(); + + const body: IAddBusinessProcessRequest = { + reqt: this.businessProcessForm.getRawValue().content, + addReqtType: this.folderName, + contentType: 'userContent', + description: this.data.description, + id: this.data.id, + name: this.data.name, + title: this.businessProcessForm.getRawValue().title, + useGenAI: this.businessProcessForm.getRawValue().expandAI, + selectedBRDs: formValue.selectedBRDs.map( + (item: { requirement: any; fileName: any }) => item.requirement, + ), + selectedPRDs: formValue.selectedPRDs.map( + (item: { requirement: any; fileName: any }) => item.requirement, + ), + }; + this.featureService.addBusinessProcess(body).subscribe({ + next: (data) => { + const selectedBRDsWithId = this.streamSelectedStoring( + data.selectedBRDs, + formValue.selectedBRDs, + ); + const selectedPRDsWithId = this.streamSelectedStoring( + data.selectedPRDs, + formValue.selectedPRDs, + ); + + this.store.dispatch( + new CreateFile(`${this.folderName}/`, { + requirement: data.LLMreqt.requirement, + title: data.LLMreqt.title, + selectedBRDs: selectedBRDsWithId, + selectedPRDs: selectedPRDsWithId, + flowChartDiagram: '', + chatHistory: this.chatHistory, + }), + ); + this.allowFreeEdit = true; + this.navigateBackToDocumentList(this.data); + this.toastService.showSuccess( + TOASTER_MESSAGES.ENTITY.ADD.SUCCESS(this.folderName), + ); + }, + error: (error) => { + this.loggerService.error('Error updating requirement:', error); // Handle any errors + this.toastService.showError( + TOASTER_MESSAGES.ENTITY.ADD.FAILURE(this.folderName), + ); + }, + }); + } + + updateBusinessProcess() { + const formValue = this.businessProcessForm.getRawValue(); + this.loadingService.setLoading(true); + const body: IUpdateProcessRequest = { + updatedReqt: this.businessProcessForm.getRawValue().content, + reqId: this.bpRequirementId, + reqDesc: this.oldContent, + contentType: 'userContent', + description: this.description, + id: this.data.id, + name: this.name, + title: this.businessProcessForm.getRawValue().title, + useGenAI: this.businessProcessForm.getRawValue().expandAI, + selectedBRDs: formValue.selectedBRDs.map( + (item: { requirement: any; fileName: any }) => item.requirement, + ), + selectedPRDs: formValue.selectedPRDs.map( + (item: { requirement: any; fileName: any }) => item.requirement, + ), + }; + this.featureService.updateBusinessProcess(body).subscribe({ + next: async (data) => { + const selectedBRDsWithId = this.streamSelectedStoring( + data.selectedBRDs, + formValue.selectedBRDs, + ); + const selectedPRDsWithId = this.streamSelectedStoring( + data.selectedPRDs, + formValue.selectedPRDs, + ); + + const updatedDiagram: string = await this.regenerateProcessFlowDiagram( + this.bpRequirementId, + data.updated.title, + data.updated.requirement, + ); + this.store.dispatch( + new UpdateFile(this.absoluteFilePath, { + requirement: data.updated.requirement, + title: data.updated.title, + selectedBRDs: selectedBRDsWithId, + selectedPRDs: selectedPRDsWithId, + flowChartDiagram: updatedDiagram, + chatHistory: this.chatHistory, + }), + ); + this.loadingService.setLoading(false); + this.allowFreeEdit = true; + this.navigateBackToDocumentList(this.data); + this.toastService.showSuccess( + TOASTER_MESSAGES.ENTITY.UPDATE.SUCCESS( + this.folderName, + this.bpRequirementId, + ), + ); + }, + error: (error) => { + this.loggerService.error('Error updating requirement:', error); + this.loadingService.setLoading(false); + this.toastService.showError( + TOASTER_MESSAGES.ENTITY.UPDATE.FAILURE( + this.folderName, + this.bpRequirementId, + ), + ); + }, + }); + } + + initializeBusinessProcessForm() { + this.businessProcessForm = new FormGroup({ + title: new FormControl('', Validators.compose([Validators.required])), + content: new FormControl('', Validators.compose([Validators.required])), + expandAI: new FormControl(false), + selectedBRDs: new FormControl([]), + selectedPRDs: new FormControl([]), + }); + if (this.mode === 'edit') { + this.store.dispatch(new ReadFile(`${this.folderName}/${this.fileName}`)); + this.selectedBPFileContent$.subscribe((res: any) => { + this.oldContent = res.requirement; + this.selectedPRDs = res.selectedPRDs; + this.selectedBRDs = res.selectedBRDs; + this.businessProcessForm.patchValue({ + title: res.title, + content: res.requirement, + selectedBRDs: res.selectedBRDs, + selectedPRDs: res.selectedPRDs, + }); + this.existingFlowDiagram = res.flowChartDiagram; + this.chatHistory = res.chatHistory || []; + this.updateIncludePRDandBRDValidator(); + }); + } + } + + updateContentValidators() { + const contentControl = this.businessProcessForm.get('content'); + contentControl?.setValidators(Validators.required); + contentControl?.updateValueAndValidity(); + } + + selectTab(tab: string): void { + this.selectedTab = tab; + this.getRequirementFiles(this.selectedTab); + } + + getRequirementFiles(title: string) { + return this.store.dispatch(new BulkReadFiles(title)); + } + + isSelected( + item: { fileName: string; requirement: string | undefined }, + type: string, + ): boolean { + const selectedItems: any = + type === this.requirementTypes.PRD + ? this.selectedPRDs + : this.selectedBRDs; + return selectedItems.some( + (selectedItem: { requirement: string; fileName: string }) => + selectedItem.requirement === item.requirement && + selectedItem.fileName === item.fileName, + ); + } + + toggleSelection(event: any, type: string): void { + const item = JSON.parse(event.target.value); + const checked = event.target.checked; + if (type === this.requirementTypes.PRD) { + this.updateSelection(this.selectedPRDs, item, checked, 'selectedPRDs'); + } else if (type === this.requirementTypes.BRD) { + this.updateSelection(this.selectedBRDs, item, checked, 'selectedBRDs'); + } + } + + updateSelection( + array: any[], + item: { requirement: string; fileName: string }, + checked: boolean, + controlName: string, + ): void { + const newArray = [...array]; + const index = newArray.findIndex( + (x) => x.requirement === item.requirement && x.fileName === item.fileName, + ); + if (checked && index === -1) { + newArray.push(item); + } else if (!checked && index > -1) { + newArray.splice(index, 1); + } + this.businessProcessForm.get(controlName)?.setValue(newArray); + + if (controlName === 'selectedPRDs') { + this.selectedPRDs = newArray; + } else if (controlName === 'selectedBRDs') { + this.selectedBRDs = newArray; + } + this.updateContentValidators(); + this.updateIncludePRDandBRDValidator(); + } + + ngOnInit() { + this.getRequirementFiles(this.selectedTab); + this.store.dispatch(new ClearBRDPRDState()); + } + + navigateBackToDocumentList(data: any) { + this.router + .navigate(['/apps', this.projectId], { + state: { + data, + selectedFolder: { + title: this.folderName, + id: this.projectId, + metadata: data, + }, + }, + }) + .then(); + } + + updateChatHistory(chatHistory: any) { + this.store.dispatch( + new UpdateFile(this.absoluteFilePath, { + requirement: this.businessProcessForm.get('content')?.value, + title: this.businessProcessForm.get('title')?.value, + selectedBRDs: this.businessProcessForm.get('selectedBRDs')?.value, + selectedPRDs: this.businessProcessForm.get('selectedPRDs')?.value, + flowChartDiagram: this.existingFlowDiagram, + chatHistory, + }), + ); + } + + appendRequirement(data: any) { + let { chat, chatHistory } = data; + if (chat.assistant) { + this.businessProcessForm.patchValue({ + content: `${this.businessProcessForm.get('content')?.value} ${chat.assistant}`, + }); + let newArray = chatHistory.map((item: any) => { + if (item.assistant == chat.assistant) return { ...item, isAdded: true }; + else return item; + }); + this.store.dispatch( + new UpdateFile(this.absoluteFilePath, { + requirement: this.businessProcessForm.get('content')?.value, + title: this.businessProcessForm.get('title')?.value, + selectedBRDs: this.businessProcessForm.get('selectedBRDs')?.value, + selectedPRDs: this.businessProcessForm.get('selectedPRDs')?.value, + flowChartDiagram: this.existingFlowDiagram, + chatHistory: newArray, + }), + ); + } + } + + async regenerateProcessFlowDiagram( + id: string, + title: string, + requirement: string, + ): Promise<string> { + const request: IFlowChartRequest = { + id: id, + title, + description: requirement, + }; + try { + return await firstValueFrom(this.featureService.addFlowChart(request)); + } catch (error) { + this.loggerService.error( + 'Error from BE while generating flow chart', + error, + ); + return ''; + } + } + + switchTab(tab: string): void { + this.activeTab = tab; + } + + navigateToBPFlow() { + this.router + .navigate(['/bp-flow/edit', this.bpRequirementId], { + state: { + data: this.data, + id: this.projectId, + folderName: this.folderName, + fileName: this.fileName, + req: { + id: this.bpRequirementId, + title: this.businessProcessForm.get('title')?.value, + requirement: this.businessProcessForm.get('content')?.value, + }, + selectedFolder: { + title: this.folderName, + id: this.projectId, + metadata: this.data, + }, + }, + }) + .then(); + } + + deleteBP() { + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + width: '500px', + data: { + title: CONFIRMATION_DIALOG.DELETION.TITLE, + description: CONFIRMATION_DIALOG.DELETION.DESCRIPTION( + this.bpRequirementId, + ), + cancelButtonText: CONFIRMATION_DIALOG.DELETION.CANCEL_BUTTON_TEXT, + proceedButtonText: CONFIRMATION_DIALOG.DELETION.PROCEED_BUTTON_TEXT, + }, + }); + + dialogRef.afterClosed().subscribe((res) => { + if (!res) { + this.store.dispatch(new ArchiveFile(this.absoluteFilePath)); + this.allowFreeEdit = true; + this.navigateBackToDocumentList(this.data); + this.toastService.showSuccess( + TOASTER_MESSAGES.ENTITY.DELETE.SUCCESS( + this.folderName, + this.bpRequirementId, + ), + ); + } + }); + } + + checkFormValidity(): boolean { + this.updateIncludePRDandBRDValidator(); + return !this.businessProcessForm.valid; + } + + updateIncludePRDandBRDValidator(): void { + if(!(this.selectedPRDs.length > 0 || this.selectedBRDs.length > 0)){ + this.businessProcessForm.setErrors({ noPrdOrBrd: true }); + } + else{ + this.businessProcessForm.setErrors(null); + } + } + + canDeactivate(): boolean { + return ( + !this.allowFreeEdit && + this.businessProcessForm.dirty && + this.businessProcessForm.touched + ); + } +} diff --git a/ui/src/app/pages/create-solution/create-solution.component.html b/ui/src/app/pages/create-solution/create-solution.component.html new file mode 100644 index 0000000..88ebb16 --- /dev/null +++ b/ui/src/app/pages/create-solution/create-solution.component.html @@ -0,0 +1,155 @@ +<ngx-loading + [show]="loading" + [config]="{ backdropBorderRadius: '3px' }" +></ngx-loading> +<div class="w-full px-8"> + <div + class="divide-y divide-gray-200 overflow-hidden rounded-lg bg-white shadow" + > + <div class="px-4 py-5"> + <h1 + class="text-xl font-medium leading-tight tracking-tight text-gray-900" + > + Create Solution + </h1> + </div> + <div class="px-4 py-5 sm:p-6 edit-container-height"> + <form [formGroup]="solutionForm"> + <div class="space-y-12 sm:space-y-16"> + <div> + <h2 class="text-sm font-medium leading-7 text-gray-900"> + Solution Information + </h2> + <p class="mt-1 max-w-2xl text-xs leading-6 text-gray-600"> + Give little bit information about the app which you are planning + to build + </p> + + <div + class="mt-4 space-y-8 border-b border-gray-900/10 pb-12 sm:space-y-0 sm:divide-y sm:divide-gray-900/10 sm:border-t sm:pb-0" + > + <div + class="sm:grid sm:grid-cols-3 sm:items-start sm:gap-4 sm:py-6" + > + <label + class="block text-sm font-medium leading-6 text-gray-900 sm:pt-1.5" + >Solution Name<span class="text-red-500 text-xs"> + *</span + ></label + > + + <app-input-field + formControlName="name" + elementId="solution-name" + elementName="Solution Name" + elementPlaceHolder="Solution Name" + [showLabel]="false" + [required]="true" + /> + <app-error-message + [errorControl]="solutionForm.get('name')" + /> + </div> + + <div + class="sm:grid sm:grid-cols-3 sm:items-start sm:gap-4 sm:py-6" + > + <label + class="block text-sm font-medium leading-6 text-gray-900 sm:pt-1.5" + >Solution Description<span class="text-red-500 text-xs"> + *</span + ></label + > + <div class="mt-2 sm:col-span-2 sm:mt-0"> + <app-textarea-field + formControlName="description" + elementId="solution-description" + elementName="Solution Description" + elementPlaceHolder="Solution Description" + [showLabel]="false" + [rows]="4" + [required]="true" + /> + <app-error-message + [errorControl]="solutionForm.get('description')" + /> + <p class="text-xs leading-6 text-gray-600"> + Write a few sentences about your idea + </p> + </div> + </div> + + <div + class="sm:grid sm:grid-cols-3 sm:items-start sm:gap-4 sm:py-6" + > + <label + class="block text-sm font-medium leading-6 text-gray-900 sm:pt-1.5" + >Technical Details<span class="text-red-500 text-xs"> + *</span + ></label + > + <div class="mt-2 sm:col-span-2 sm:mt-0"> + <app-textarea-field + formControlName="technicalDetails" + elementId="technical-details" + elementName="Technical Details" + elementPlaceHolder="Technical Details" + [showLabel]="false" + [rows]="4" + [required]="true" + /> + <app-error-message + [errorControl]="solutionForm.get('technicalDetails')" + /> + <p class="text-xs leading-6 text-gray-600"> + Write a few sentences about the technical stack of your + application + </p> + </div> + </div> + + <div + class="sm:grid sm:grid-cols-3 sm:items-center sm:gap-4 sm:py-6" + > + <label + class="block text-sm font-medium leading-6 text-gray-900" + >Is solution built already?<span class="text-red-500 text-xs"> + *</span></label + > + <div class="col-span-2 flex items-center"> + <div class="mt-1.5"> + <app-toggle + [isActive]="solutionForm.get('cleanSolution')?.value" + (toggleChange)="solutionForm.get('cleanSolution')?.setValue($event)" + isPlainToggle="true" + ></app-toggle> + </div> + <p class="text-xs leading-6 text-gray-600 ml-4"> + {{getSolutionToggleDescription()}} + </p> + </div> + </div> + </div> + </div> + </div> + + <div class="mt-6 flex items-center justify-end gap-x-2"> + <a routerLink="/apps"> + <app-button + buttonContent="Cancel" + [disabled]="loading" + size="sm" + theme="secondary" + /> + </a> + <app-button + buttonContent="Create Solution" + [disabled]="loading || solutionForm.invalid" + size="sm" + (click)="createSolution()" + /> + </div> + </form> + </div> + </div> +</div> diff --git a/ui/src/app/pages/create-solution/create-solution.component.scss b/ui/src/app/pages/create-solution/create-solution.component.scss new file mode 100644 index 0000000..a42fc05 --- /dev/null +++ b/ui/src/app/pages/create-solution/create-solution.component.scss @@ -0,0 +1,4 @@ +.edit-container-height { + height: calc(100vh - 263px); + overflow: auto; +} diff --git a/ui/src/app/pages/create-solution/create-solution.component.spec.ts b/ui/src/app/pages/create-solution/create-solution.component.spec.ts new file mode 100644 index 0000000..d037613 --- /dev/null +++ b/ui/src/app/pages/create-solution/create-solution.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CreateSolutionComponent } from './create-solution.component'; + +describe('CreateSolutionComponent', () => { + let component: CreateSolutionComponent; + let fixture: ComponentFixture<CreateSolutionComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [CreateSolutionComponent], + }); + fixture = TestBed.createComponent(CreateSolutionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/pages/create-solution/create-solution.component.ts b/ui/src/app/pages/create-solution/create-solution.component.ts new file mode 100644 index 0000000..a0f790b --- /dev/null +++ b/ui/src/app/pages/create-solution/create-solution.component.ts @@ -0,0 +1,152 @@ +import { Component, inject, OnInit } from '@angular/core'; +import { + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; +import { Store } from '@ngxs/store'; +import { CreateProject } from '../../store/projects/projects.actions'; +import { v4 as uuid } from 'uuid'; +import { AddBreadcrumbs } from '../../store/breadcrumb/breadcrumb.actions'; +import { MatDialog } from '@angular/material/dialog'; +import { NGXLogger } from 'ngx-logger'; +import { AppSystemService } from '../../services/app-system/app-system.service'; +import { ElectronService } from '../../services/electron/electron.service'; +import { ToasterService } from '../../services/toaster/toaster.service'; +import { SelectRootDirectoryComponent } from '../../components/select-root-directory/select-root-directory.component'; +import { NgIf } from '@angular/common'; +import { NgxLoadingModule } from 'ngx-loading'; +import { ButtonComponent } from '../../components/core/button/button.component'; +import { ErrorMessageComponent } from '../../components/core/error-message/error-message.component'; +import { + APP_CONSTANTS, + SOLUTION_CREATION_TOGGLE_MESSAGES, +} from '../../constants/app.constants'; +import { InputFieldComponent } from '../../components/core/input-field/input-field.component'; +import { TextareaFieldComponent } from '../../components/core/textarea-field/textarea-field.component'; +import { ToggleComponent } from '../../components/toggle/toggle.component'; + +@Component({ + selector: 'app-create-solution', + templateUrl: './create-solution.component.html', + styleUrls: ['./create-solution.component.scss'], + standalone: true, + imports: [ + NgIf, + ReactiveFormsModule, + NgxLoadingModule, + ButtonComponent, + ErrorMessageComponent, + InputFieldComponent, + TextareaFieldComponent, + RouterLink, + ToggleComponent, + ], +}) +export class CreateSolutionComponent implements OnInit { + solutionForm!: FormGroup; + loading: boolean = false; + addOrUpdate: boolean = false; + + logger = inject(NGXLogger); + appSystemService = inject(AppSystemService); + electronService = inject(ElectronService); + toast = inject(ToasterService); + readonly dialog = inject(MatDialog); + router = inject(Router); + store = inject(Store); + + ngOnInit() { + this.solutionForm = this.createSolutionForm(); + this.store.dispatch( + new AddBreadcrumbs([ + { + label: 'Create', + url: '/create', + }, + ]), + ); + } + + createSolutionForm() { + return new FormGroup({ + name: new FormControl('', Validators.required), + description: new FormControl('', Validators.required), + technicalDetails: new FormControl('', Validators.required), + createReqt: new FormControl(true), + id: new FormControl(uuid()), + createdAt: new FormControl(new Date().toISOString()), + cleanSolution: new FormControl(false), + }); + } + + async createSolution() { + let isRootDirectorySet = localStorage.getItem(APP_CONSTANTS.WORKING_DIR); + if (isRootDirectorySet === null || isRootDirectorySet === '') { + this.openSelectRootDirectoryModal(); + return; + } + + let isPathValid = await this.appSystemService.fileExists(''); + if (!isPathValid) { + this.toast.showError('Please select a valid root directory.'); + return; + } + + if ( + this.solutionForm.valid && + isRootDirectorySet !== null && + isRootDirectorySet !== '' && + isPathValid + ) { + this.addOrUpdate = true; + const data = this.solutionForm.getRawValue(); + this.store.dispatch(new CreateProject(data.name, data)); + } + } + + openSelectRootDirectoryModal() { + const modalRef = this.dialog.open(SelectRootDirectoryComponent, { + disableClose: true, + }); + modalRef.afterClosed().subscribe((res) => { + if (res === true) { + this.selectRootDirectory().then(); + } + }); + } + + async selectRootDirectory(): Promise<void> { + const response = await this.electronService.openDirectory(); + this.logger.debug(response); + if (response.length > 0) { + localStorage.setItem(APP_CONSTANTS.WORKING_DIR, response[0]); + await this.createSolution(); + } + } + + get isTechnicalDetailsInvalid(): boolean { + const field = this.solutionForm?.get('technicalDetails'); + return !!field?.invalid && (!!field?.dirty || !!field?.touched); + } + + get isTechnicalDetailsRequiredError(): boolean { + return 'required' in this.solutionForm?.get('technicalDetails')?.errors!; + } + + canDeactivate(): boolean { + return ( + this.solutionForm.dirty && this.solutionForm.touched && !this.addOrUpdate + ); + } + + getSolutionToggleDescription(): string { + return this.solutionForm.get('cleanSolution')?.value + ? SOLUTION_CREATION_TOGGLE_MESSAGES.BROWNFIELD_SOLUTION + : SOLUTION_CREATION_TOGGLE_MESSAGES.GREENFIELD_SOLUTION; + } + + protected readonly FormControl = FormControl; +} diff --git a/ui/src/app/pages/edit-solution/edit-solution.component.html b/ui/src/app/pages/edit-solution/edit-solution.component.html new file mode 100644 index 0000000..64e1703 --- /dev/null +++ b/ui/src/app/pages/edit-solution/edit-solution.component.html @@ -0,0 +1,122 @@ +<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 h-full"> + <div + [ngClass]=" + mode === 'add' + ? 'grid grid-cols-1 gap-4' + : 'grid grid-cols-1 lg:grid-cols-3 gap-4' + " + > + <div + [ngClass]=" + mode === 'add' + ? 'bg-white shadow rounded-lg p-6 flex flex-col' + : 'bg-white shadow rounded-lg p-6 flex flex-col lg:col-span-2' + " + > + <div class="flex justify-between items-center"> + <!-- Ensures items are centered vertically in this container --> + <div class="flex items-center space-x-1"> + <h1 class="font-semibold"> + <!-- Removed mb-4 --> + {{ + mode === "edit" + ? fileName.split("-")[0] + : getDescription(folderName) + }} + </h1> + </div> + <div + *ngIf="mode === 'edit'" + class="flex items-center space-x-1 text-3xl" + > + <div class="relative" style="display: inline-block; cursor: pointer"> + <app-button + [isIconButton]="true" + icon="heroTrash" + theme="danger" + size="sm" + rounded="md" + (click)="deleteFile()" + ></app-button> + </div> + </div> + </div> + + <form + [formGroup]="requirementForm" + (ngSubmit)="mode === 'edit' ? updateRequirement() : addRequirement()" + > + <app-input-field + [required]="true" + elementPlaceHolder="Title" + elementId="title" + elementName="Title" + formControlName="title" + /> + <app-error-message [errorControl]="requirementForm.get('title')" /> + + <app-textarea-field + [required]="true" + elementPlaceHolder="Description" + elementId="description" + elementName="Description" + formControlName="content" + /> + <app-error-message [errorControl]="requirementForm.get('content')" /> + + <div class="flex items-center justify-between mt-4"> + <div class="flex-col"> + <div class="flex items-center mb-4"> + <input + type="checkbox" + id="expandAI" + formControlName="expandAI" + class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500 cursor-pointer" + /> + <label for="expandAI" class="ml-2 block text-sm text-gray-900" + >Expand with AI</label + > + </div> + <app-multi-upload + *ngIf="mode === 'add'" + (fileContent)="handleFileContent($event)" + ></app-multi-upload> + </div> + <div class=""> + <app-button + buttonContent="Update" + theme="primary" + size="sm" + rounded="md" + *ngIf="mode === 'edit'" + type="submit" + [disabled]="requirementForm.invalid" + /> + <app-button + buttonContent="Add" + theme="primary" + size="sm" + rounded="md" + *ngIf="mode === 'add'" + type="submit" + [disabled]="requirementForm.invalid" + /> + </div> + </div> + </form> + </div> + <div *ngIf="mode !== 'add'" class="space-y-4 h-full lg:col-span-1"> + <app-chat + chatType="requirement" + class="h-[inherit]" + [name]="name" + [description]="description" + [fileName]="fileName" + [chatHistory]="chatHistory" + [baseContent]="requirementForm.getRawValue().content" + (getContent)="appendRequirement($event)" + (updateChatHistory)="updateChatHistory($event)" + ></app-chat> + </div> + </div> +</div> diff --git a/ui/src/app/pages/edit-solution/edit-solution.component.scss b/ui/src/app/pages/edit-solution/edit-solution.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/pages/edit-solution/edit-solution.component.spec.ts b/ui/src/app/pages/edit-solution/edit-solution.component.spec.ts new file mode 100644 index 0000000..1779347 --- /dev/null +++ b/ui/src/app/pages/edit-solution/edit-solution.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { EditSolutionComponent } from './edit-solution.component'; + +describe('EditSolutionComponent', () => { + let component: EditSolutionComponent; + let fixture: ComponentFixture<EditSolutionComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [EditSolutionComponent] + }); + fixture = TestBed.createComponent(EditSolutionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/pages/edit-solution/edit-solution.component.ts b/ui/src/app/pages/edit-solution/edit-solution.component.ts new file mode 100644 index 0000000..e7a9f82 --- /dev/null +++ b/ui/src/app/pages/edit-solution/edit-solution.component.ts @@ -0,0 +1,358 @@ +import { Component } from '@angular/core'; +import { Router } from '@angular/router'; +import { ProjectsState } from '../../store/projects/projects.state'; +import { Store } from '@ngxs/store'; +import { + CreateFile, + ArchiveFile, + ReadFile, + UpdateFile, +} from '../../store/projects/projects.actions'; +import { getDescriptionFromInput } from '../../utils/common.utils'; +import { + IAddRequirementRequest, + IUpdateRequirementRequest, +} from '../../model/interfaces/IRequirement'; +import { FeatureService } from '../../services/feature/feature.service'; +import { IList } from '../../model/interfaces/IList'; +import { + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { AddBreadcrumb } from '../../store/breadcrumb/breadcrumb.actions'; +import { NgClass, NgIf } from '@angular/common'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatDialog } from '@angular/material/dialog'; +import { InputFieldComponent } from '../../components/core/input-field/input-field.component'; +import { TextareaFieldComponent } from '../../components/core/textarea-field/textarea-field.component'; +import { ButtonComponent } from '../../components/core/button/button.component'; +import { AiChatComponent } from '../../components/ai-chat/ai-chat.component'; +import { MultiUploadComponent } from '../../components/multi-upload/multi-upload.component'; +import { NgIconComponent } from '@ng-icons/core'; +import { ErrorMessageComponent } from '../../components/core/error-message/error-message.component'; +import { ConfirmationDialogComponent } from '../../components/confirmation-dialog/confirmation-dialog.component'; +import { + CONFIRMATION_DIALOG, + TOASTER_MESSAGES, +} from '../../constants/app.constants'; +import { ToasterService } from 'src/app/services/toaster/toaster.service'; + +@Component({ + selector: 'app-edit-solution', + templateUrl: './edit-solution.component.html', + styleUrls: ['./edit-solution.component.scss'], + standalone: true, + imports: [ + NgClass, + NgIf, + MatMenuModule, + ReactiveFormsModule, + InputFieldComponent, + TextareaFieldComponent, + ButtonComponent, + AiChatComponent, + MultiUploadComponent, + NgIconComponent, + ErrorMessageComponent, + ], +}) +export class EditSolutionComponent { + projectId: string = ''; + folderName: string = ''; + fileName: string = ''; + name: string = ''; + description: string = ''; + content: string = ''; + title: string = ''; + uploadedFileContent = ''; + mode: 'edit' | 'add' = 'edit'; + message: string = ''; + data: any = {}; + generateLoader: boolean = false; + initialData: any = {}; + selectedRequirement: any = {}; + absoluteFilePath: string = ''; + oldContent: string = ''; + public loading: boolean = false; + selectedFileContent$ = this.store.select( + ProjectsState.getSelectedFileContent, + ); + requirementForm!: FormGroup; + response: IList = {} as IList; + chatHistory: any = []; + allowFreeRedirection: boolean = false; + + constructor( + private store: Store, + private router: Router, + private featureService: FeatureService, + private dialog: MatDialog, + private toastService: ToasterService, + ) { + const url = this.router.url; + this.mode = url.includes('/add') ? 'add' : 'edit'; + const navigation = this.router.getCurrentNavigation(); + this.projectId = navigation?.extras?.state?.['id']; + this.folderName = navigation?.extras?.state?.['folderName']; + this.initialData = navigation?.extras?.state?.['data']; + this.selectedRequirement = navigation?.extras?.state?.['req']; + this.store.dispatch( + new AddBreadcrumb({ + url: `/apps/${this.projectId}`, + label: this.folderName, + state: { + data: this.initialData, + selectedFolder: { + title: this.folderName, + id: this.projectId, + metadata: this.initialData, + }, + }, + }), + ); + this.store.dispatch( + new AddBreadcrumb({ + label: this.mode === 'edit' ? 'Edit' : 'Add', + }), + ); + if (this.mode === 'edit') { + this.fileName = navigation?.extras?.state?.['fileName']; + this.absoluteFilePath = `${this.folderName}/${this.fileName}`; + this.name = this.initialData?.name; + this.description = this.initialData?.description; + } + this.createRequirementForm(); + } + + updateRequirement() { + if (this.requirementForm.getRawValue().expandAI) { + const body: IUpdateRequirementRequest = { + updatedReqt: this.requirementForm.getRawValue().title, + addReqtType: this.folderName, + fileContent: this.uploadedFileContent, + contentType: this.uploadedFileContent ? 'fileContent' : 'userContent', + id: this.initialData.id, + reqId: this.fileName.replace(/\-base.json$/, ''), + reqDesc: this.requirementForm.getRawValue().content, + name: this.initialData.name, + description: this.initialData.description, + useGenAI: true, + }; + this.featureService.updateRequirement(body).subscribe( + (data) => { + this.store.dispatch( + new UpdateFile(this.absoluteFilePath, { + requirement: data.updated.requirement, + title: data.updated.title, + chatHistory: this.chatHistory, + epicTicketId: this.initialData.epicTicketId, + }), + ); + this.allowFreeRedirection = true; + this.navigateBackToDocumentList(this.initialData); + this.toastService.showSuccess( + TOASTER_MESSAGES.ENTITY.UPDATE.SUCCESS( + body.addReqtType, + data.reqId, + ), + ); + }, + (error) => { + console.error('Error updating requirement:', error); // Handle any errors + this.toastService.showError( + TOASTER_MESSAGES.ENTITY.UPDATE.FAILURE( + this.folderName, + body.reqId, + ), + ); + }, + ); + } else { + this.store.dispatch( + new UpdateFile(this.absoluteFilePath, { + requirement: this.requirementForm.getRawValue().content, + title: this.requirementForm.getRawValue().title, + chatHistory: this.chatHistory, + epicTicketId: this.initialData.epicTicketId, + }), + ); + this.allowFreeRedirection = true; + this.navigateBackToDocumentList(this.initialData); + this.toastService.showSuccess( + TOASTER_MESSAGES.ENTITY.UPDATE.SUCCESS( + this.folderName, + this.fileName.replace(/\-base.json$/, ''), + ), + ); + } + } + + navigateBackToDocumentList(data: any) { + this.router.navigate(['/apps', this.projectId], { + state: { + data, + selectedFolder: { + title: this.folderName, + id: this.projectId, + metadata: data, + }, + }, + }); + } + + addRequirement() { + if ( + this.requirementForm.getRawValue().expandAI || + this.uploadedFileContent.length > 0 + ) { + const body: IAddRequirementRequest = { + reqt: this.requirementForm.getRawValue().content, + addReqtType: this.folderName, + contentType: this.uploadedFileContent ? 'fileContent' : 'userContent', + description: this.initialData.description, + fileContent: this.uploadedFileContent, + id: this.initialData.id, + name: this.initialData.name, + title: this.requirementForm.getRawValue().title, + useGenAI: true, + }; + this.featureService.addRequirement(body).subscribe( + (data) => { + this.store.dispatch( + new CreateFile(`${this.folderName}`, { + requirement: data.LLMreqt.requirement, + title: data.LLMreqt.title, + chatHistory: this.chatHistory, + }), + ); + this.allowFreeRedirection = true; + this.navigateBackToDocumentList(this.initialData); + this.toastService.showSuccess( + TOASTER_MESSAGES.ENTITY.ADD.SUCCESS( + this.folderName, + ), + ); + }, + (error) => { + console.error('Error updating requirement:', error); // Handle any errors + this.toastService.showError( + TOASTER_MESSAGES.ENTITY.ADD.FAILURE( + this.folderName, + ), + ); + }, + ); + } else { + this.store.dispatch( + new CreateFile(`${this.folderName}`, { + requirement: this.requirementForm.getRawValue().content, + title: this.requirementForm.getRawValue().title, + chatHistory: this.chatHistory, + }), + ); + this.allowFreeRedirection = true; + this.navigateBackToDocumentList(this.initialData); + this.toastService.showSuccess( + TOASTER_MESSAGES.ENTITY.ADD.SUCCESS( + this.folderName, + ), + ); + } + } + + appendRequirement(data: any) { + let { chat, chatHistory } = data; + if (chat.assistant) { + this.requirementForm.patchValue({ + content: `${this.requirementForm.get('content')?.value} +${chat.assistant}`, + }); + let newArray = chatHistory.map((item: any) => { + if (item.assistant == chat.assistant) return { ...item, isAdded: true }; + else return item; + }); + this.store.dispatch( + new UpdateFile(this.absoluteFilePath, { + requirement: this.requirementForm.get('content')?.value, + title: this.requirementForm.get('title')?.value, + chatHistory: newArray, + }), + ); + } + } + + updateChatHistory(chatHistory: any) { + this.store.dispatch( + new UpdateFile(this.absoluteFilePath, { + requirement: this.requirementForm.get('content')?.value, + title: this.requirementForm.get('title')?.value, + chatHistory, + }), + ); + } + + createRequirementForm() { + this.requirementForm = new FormGroup({ + title: new FormControl('', Validators.compose([Validators.required])), + content: new FormControl('', Validators.compose([Validators.required])), + expandAI: new FormControl(false), + }); + if (this.mode === 'edit') { + this.store.dispatch(new ReadFile(`${this.folderName}/${this.fileName}`)); + this.selectedFileContent$.subscribe((res: any) => { + this.oldContent = res.requirement; + this.requirementForm.patchValue({ + title: res.title, + content: res.requirement, + epicticketid: res.epicTicketId, + }); + this.chatHistory = res.chatHistory || []; + }); + } + } + + deleteFile() { + const reqId = this.fileName.replace(/\-base.json$/, ''); + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + width: '500px', + data: { + title: CONFIRMATION_DIALOG.DELETION.TITLE, + description: CONFIRMATION_DIALOG.DELETION.DESCRIPTION(reqId), + cancelButtonText: CONFIRMATION_DIALOG.DELETION.CANCEL_BUTTON_TEXT, + proceedButtonText: CONFIRMATION_DIALOG.DELETION.PROCEED_BUTTON_TEXT, + }, + }); + + dialogRef.afterClosed().subscribe((res) => { + if (!res) { + this.store.dispatch(new ArchiveFile(this.absoluteFilePath)); + this.allowFreeRedirection = true; + this.navigateBackToDocumentList(this.initialData); + this.toastService.showSuccess( + TOASTER_MESSAGES.ENTITY.DELETE.SUCCESS( + this.folderName, + reqId, + ), + ); + } + }); + } + + handleFileContent(content: string) { + this.uploadedFileContent = content; + } + + getDescription(input: string | undefined): string | null { + return getDescriptionFromInput(input); + } + + canDeactivate(): boolean { + return ( + !this.allowFreeRedirection && + this.requirementForm.dirty && + this.requirementForm.touched + ); + } +} diff --git a/ui/src/app/pages/edit-user-stories/edit-user-stories.component.html b/ui/src/app/pages/edit-user-stories/edit-user-stories.component.html new file mode 100644 index 0000000..be8e358 --- /dev/null +++ b/ui/src/app/pages/edit-user-stories/edit-user-stories.component.html @@ -0,0 +1,137 @@ +<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 h-full"> + <div + [ngClass]=" + mode === 'add' + ? 'grid grid-cols-1 gap-4' + : 'grid grid-cols-1 lg:grid-cols-3 gap-4' + " + > + <div + [ngClass]=" + mode === 'add' + ? 'bg-white shadow rounded-lg p-6 flex flex-col' + : 'bg-white shadow rounded-lg p-6 flex flex-col lg:col-span-2' + " + > + <div class="flex justify-between items-center"> + <div class="flex items-center space-x-1"> + <h1 class="text-normal font-semibold mb-4"> + {{ mode === "add" ? "Add User Story" : existingUserForm.id }} + </h1> + </div> + + <div + *ngIf="mode === 'edit'" + class="flex items-center space-x-1 text-3xl" + > + <div class="relative" style="display: inline-block; cursor: pointer"> + <app-button + [isIconButton]="true" + icon="heroTrash" + theme="danger" + size="sm" + rounded="md" + (click)="deleteUserStory()" + ></app-button> + </div> + </div> + </div> + <form + [formGroup]="userStoryForm" + (ngSubmit)="mode === 'edit' ? updateUserStory() : addUserStory()" + > + <app-input-field + [required]="true" + elementPlaceHolder="Name" + elementId="name" + elementName="Name" + formControlName="name" + /> + <div + *ngIf=" + userStoryForm.get('name')?.invalid && + (userStoryForm.get('name')?.dirty || + userStoryForm.get('name')?.touched) + " + class="text-red-500 text-sm mt-1" + > + <div *ngIf="userStoryForm.get('name')?.errors?.['required']"> + Name is required. + </div> + </div> + <app-textarea-field + [required]="true" + elementPlaceHolder="Description" + elementId="description" + elementName="Description" + formControlName="description" + /> + <div + *ngIf=" + userStoryForm.get('description')?.invalid && + (userStoryForm.get('description')?.dirty || + userStoryForm.get('description')?.touched) + " + class="text-red-500 text-sm mt-1" + > + <div *ngIf="userStoryForm.get('description')?.errors?.['required']"> + Description is required. + </div> + </div> + <div class="flex items-center justify-between mt-4"> + <div class="flex-col"> + <div class="flex items-center mb-4"> + <input + type="checkbox" + id="expandAI" + formControlName="expandAI" + class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500" + /> + <label for="expandAI" class="ml-2 block text-sm text-gray-900" + >Expand story with AI</label + > + </div> + + <app-multi-upload + *ngIf="mode === 'add'" + (fileContent)="handleFileContent($event)" + ></app-multi-upload> + </div> + <div class=""> + <app-button + buttonContent="Update" + theme="primary" + size="sm" + rounded="md" + *ngIf="mode === 'edit'" + type="submit" + [disabled]="userStoryForm.invalid" + /> + <app-button + buttonContent="Add" + theme="primary" + size="sm" + rounded="md" + *ngIf="mode === 'add'" + type="submit" + [disabled]="userStoryForm.invalid" + /> + </div> + </div> + </form> + </div> + <div *ngIf="mode !== 'add'" class="space-y-4 h-full lg:col-span-1"> + <app-chat + chatType="userstory" + class="h-[inherit]" + [name]="projectMetadata?.name" + [description]="projectMetadata?.description" + [baseContent]="description" + [chatHistory]="chatHistory" + [prd]="selectedPRD.requirement" + (getContent)="updateContent($event)" + (updateChatHistory)="updateChatHistory($event)" + ></app-chat> + </div> + </div> +</div> diff --git a/ui/src/app/pages/edit-user-stories/edit-user-stories.component.scss b/ui/src/app/pages/edit-user-stories/edit-user-stories.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/pages/edit-user-stories/edit-user-stories.component.spec.ts b/ui/src/app/pages/edit-user-stories/edit-user-stories.component.spec.ts new file mode 100644 index 0000000..73b9ccc --- /dev/null +++ b/ui/src/app/pages/edit-user-stories/edit-user-stories.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { EditUserStoriesComponent } from './edit-user-stories.component'; + +describe('EditUserStoriesComponent', () => { + let component: EditUserStoriesComponent; + let fixture: ComponentFixture<EditUserStoriesComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [EditUserStoriesComponent] + }); + fixture = TestBed.createComponent(EditUserStoriesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/pages/edit-user-stories/edit-user-stories.component.ts b/ui/src/app/pages/edit-user-stories/edit-user-stories.component.ts new file mode 100644 index 0000000..a8a90e1 --- /dev/null +++ b/ui/src/app/pages/edit-user-stories/edit-user-stories.component.ts @@ -0,0 +1,408 @@ +import { Component, inject, OnDestroy } from '@angular/core'; +import { + IUpdateUserStoryRequest, + IUserStory, +} from '../../model/interfaces/IUserStory'; +import { ProjectsState } from '../../store/projects/projects.state'; +import { IList } from '../../model/interfaces/IList'; + +import { Store } from '@ngxs/store'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FeatureService } from '../../services/feature/feature.service'; +import { + CreateNewUserStory, + EditUserStory, +} from '../../store/user-stories/user-stories.actions'; +import { + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { Subject, takeUntil } from 'rxjs'; +import { NGXLogger } from 'ngx-logger'; +import { AppSystemService } from '../../services/app-system/app-system.service'; +import { + AddBreadcrumb, + DeleteBreadcrumb, +} from '../../store/breadcrumb/breadcrumb.actions'; +import { MatDialog } from '@angular/material/dialog'; +import { NgClass, NgIf } from '@angular/common'; +import { InputFieldComponent } from '../../components/core/input-field/input-field.component'; +import { TextareaFieldComponent } from '../../components/core/textarea-field/textarea-field.component'; +import { ButtonComponent } from '../../components/core/button/button.component'; +import { AiChatComponent } from '../../components/ai-chat/ai-chat.component'; +import { MultiUploadComponent } from '../../components/multi-upload/multi-upload.component'; +import { + CONFIRMATION_DIALOG, + TOASTER_MESSAGES, +} from '../../constants/app.constants'; +import { ToasterService } from 'src/app/services/toaster/toaster.service'; +import { ArchiveUserStory } from '../../store/user-stories/user-stories.actions'; +import { ConfirmationDialogComponent } from 'src/app/components/confirmation-dialog/confirmation-dialog.component'; + +@Component({ + selector: 'app-edit-user-stories', + templateUrl: './edit-user-stories.component.html', + styleUrls: ['./edit-user-stories.component.scss'], + standalone: true, + imports: [ + NgClass, + ReactiveFormsModule, + InputFieldComponent, + TextareaFieldComponent, + NgIf, + ButtonComponent, + AiChatComponent, + MultiUploadComponent, + ], +}) +export class EditUserStoriesComponent implements OnDestroy { + projectId: string = ''; + folderName: string = ''; + fileName: string = ''; + entityType: string = 'STORIES'; + name: string = ''; + description: string = ''; + mode: string | null = 'edit'; + message: string = ''; + data: IUserStory = { description: '', id: '', name: '' }; + absoluteFilePath: string = ''; + userStories: IUserStory = { + description: '', + id: '', + name: '', + chatHistory: [], + storyTicketId: '', + }; + loading: boolean = false; + uploadedFileContent = ''; + + userStoryForm!: FormGroup; + existingUserForm: IUserStory = { description: '', id: '', name: '' }; + response: IList = {} as IList; + fileData: any = {}; + destroy$ = new Subject<boolean>(); + selectedProject!: string; + projectMetadata: any; + chatHistory: any = []; + logger = inject(NGXLogger); + appSystemService = inject(AppSystemService); + activatedRoute = inject(ActivatedRoute); + userStoryId: string | null = ''; + editLabel: string = ''; + selectedProject$ = this.store.select(ProjectsState.getSelectedProject); + selectedPRD: any = {}; + allowFreeRedirection: boolean = false; + readonly dialog = inject(MatDialog); + readonly regex = /\-feature.json$/; + + constructor( + private store: Store, + private featureService: FeatureService, + private router: Router, + private toasterService: ToasterService, + ) { + this.mode = this.activatedRoute.snapshot.paramMap.get('mode'); + const navigation = this.router.getCurrentNavigation(); + this.userStoryId = this.activatedRoute.snapshot.paramMap.get('userStoryId'); + this.folderName = navigation?.extras?.state?.['folderName']; + this.fileName = navigation?.extras?.state?.['fileName']; + this.fileData = navigation?.extras?.state?.['fileData']; + this.absoluteFilePath = `${this.folderName}/${this.fileName}`; + this.selectedPRD = navigation?.extras?.state?.['req']; + if (this.mode === 'edit') { + this.data = navigation?.extras?.state?.['data']; + this.userStories = navigation?.extras?.state?.['data']; + this.name = this.data?.name; + this.description = this.data?.description; + this.chatHistory = this.data?.chatHistory || []; + } + + this.selectedProject$ + .pipe(takeUntil(this.destroy$)) + .pipe(takeUntil(this.destroy$)) + .subscribe((res) => { + this.selectedProject = res; + if (res) { + this.readMetadata(res).then(); + } + }); + this.createUserStoryForm(); + this.editLabel = this.mode == 'edit' ? 'Edit' : 'Add'; + this.store.dispatch( + new AddBreadcrumb({ + label: this.editLabel, + }), + ); + } + + async readMetadata(rootProject: string) { + this.projectMetadata = + await this.appSystemService.readMetadata(rootProject); + console.log(this.projectMetadata, 'projectMetadata'); + } + + updateUserStory() { + if ( + this.userStoryForm.getRawValue().expandAI || + this.uploadedFileContent.length > 0 + ) { + const body: IUpdateUserStoryRequest = { + name: this.projectMetadata.name, + description: this.projectMetadata.description, + appId: this.projectMetadata.appId, + reqId: this.fileName.replace(this.regex, ''), + reqDesc: this.selectedPRD.requirement, + featureId: this.existingUserForm.id, + featureRequest: this.userStoryForm.getRawValue().description, + contentType: '', + fileContent: this.uploadedFileContent, + useGenAI: true, + existingFeatureTitle: this.existingUserForm.name, + existingFeatureDesc: this.existingUserForm.description, + }; + this.featureService.updateUserStory(body).subscribe( + (data) => { + const featuresResponse: any = data; + const matchingFeature = featuresResponse.features.find( + (feature: { id: string }) => feature.id === this.data.id, + ); + + if (matchingFeature) { + const featureName = Object.keys(matchingFeature).find( + (key) => key !== 'id', + ); + const featureDescription = matchingFeature[featureName!]; + this.store.dispatch( + new EditUserStory(this.absoluteFilePath, { + description: featureDescription, + name: featureName!, + id: this.data.id, + storyTicketId: this.data.storyTicketId, + chatHistory: this.chatHistory, + }), + ); + this.allowFreeRedirection = true; + this.navigateBackToUserStories(); + this.toasterService.showSuccess( + TOASTER_MESSAGES.ENTITY.UPDATE.SUCCESS( + this.entityType, + this.existingUserForm.id, + ), + ); + } else { + console.log('No matching feature found for the given ID.'); + } + }, + (error) => { + console.error('Error updating requirement:', error); + this.toasterService.showError( + TOASTER_MESSAGES.ENTITY.UPDATE.FAILURE( + this.entityType, + this.existingUserForm.id, + ), + ); + }, + ); + } else { + this.store.dispatch( + new EditUserStory(this.absoluteFilePath, { + description: this.userStoryForm.getRawValue().description, + name: this.userStoryForm.getRawValue().name, + id: this.data.id, + chatHistory: this.chatHistory, + }), + ); + this.allowFreeRedirection = true; + this.navigateBackToUserStories(); + this.toasterService.showSuccess( + TOASTER_MESSAGES.ENTITY.UPDATE.SUCCESS( + this.entityType, + this.existingUserForm.id, + ), + ); + } + } + + addUserStory() { + if ( + this.userStoryForm.getRawValue().expandAI || + this.uploadedFileContent.length > 0 + ) { + const body: IUpdateUserStoryRequest = { + name: this.projectMetadata.name, + description: this.projectMetadata.description, + appId: this.projectMetadata.appId, + reqId: this.fileName.replace(this.regex, ''), + reqDesc: this.selectedPRD.requirement, + featureId: 'US-NEW', + featureRequest: this.userStoryForm.getRawValue().description, + contentType: '', + fileContent: this.uploadedFileContent, + useGenAI: true, + }; + this.featureService.addUserStory(body).subscribe( + (data) => { + const featuresResponse: any = data; + const matchingFeature = featuresResponse.features.find( + (feature: { id: string }) => feature.id === 'US-NEW', + ); + if (matchingFeature) { + const featureName = Object.keys(matchingFeature).find( + (key) => key !== 'id', + ); + const featureDescription = matchingFeature[featureName!]; + this.store.dispatch( + new CreateNewUserStory( + { + name: featureName!, + description: featureDescription, + }, + this.absoluteFilePath, + ), + ); + this.allowFreeRedirection = true; + this.navigateBackToUserStories(); + this.toasterService.showSuccess( + TOASTER_MESSAGES.ENTITY.ADD.SUCCESS(this.entityType), + ); + } else { + console.log('No matching feature found for the given ID.'); + } + }, + (error) => { + console.error('Error updating requirement:', error); + this.toasterService.showError( + TOASTER_MESSAGES.ENTITY.ADD.FAILURE(this.entityType), + ); + }, + ); + } else { + this.store.dispatch( + new CreateNewUserStory( + { + name: this.userStoryForm.getRawValue().name, + description: this.userStoryForm.getRawValue().description, + }, + this.absoluteFilePath, + ), + ); + this.allowFreeRedirection = true; + this.navigateBackToUserStories(); + this.toasterService.showSuccess( + TOASTER_MESSAGES.ENTITY.ADD.SUCCESS(this.entityType), + ); + } + } + + navigateBackToUserStories() { + this.router.navigate(['/user-stories', this.folderName], { + state: { + id: this.projectId, + folderName: this.folderName, + fileName: this.fileName.replace(this.regex, '-base.json'), + data: this.fileData, + req: this.selectedPRD, + }, + }); + } + + updateContent(data: any) { + let { chat, chatHistory } = data; + if (chat.assistant) { + this.userStoryForm.patchValue({ + description: `${this.userStoryForm.getRawValue().description} ${chat.assistant}`, + }); + let newArray = chatHistory.map((item: any) => { + if (item.assistant == chat.assistant) return { ...item, isAdded: true }; + else return item; + }); + this.store.dispatch( + new EditUserStory(this.absoluteFilePath, { + description: this.userStoryForm.getRawValue().description, + name: this.userStoryForm.getRawValue().name, + id: this.data.id, + chatHistory: newArray, + }), + ); + this.chatHistory = newArray; + } + } + + updateChatHistory(data: any) { + this.store.dispatch( + new EditUserStory(this.absoluteFilePath, { + description: this.userStoryForm.getRawValue().description, + name: this.userStoryForm.getRawValue().name, + id: this.data.id, + chatHistory: data, + }), + ); + } + + createUserStoryForm() { + this.userStoryForm = new FormGroup({ + name: new FormControl('', Validators.compose([Validators.required])), + description: new FormControl( + '', + Validators.compose([Validators.required]), + ), + expandAI: new FormControl(false), + }); + if (this.mode === 'edit') { + this.existingUserForm.description = this.description; + this.existingUserForm.name = this.name; + this.existingUserForm.id = this.data.id; + this.userStoryForm.patchValue({ + name: this.name, + description: this.description, + }); + } + } + + deleteUserStory() { + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + width: '500px', + data: { + title: CONFIRMATION_DIALOG.DELETION.TITLE, + description: CONFIRMATION_DIALOG.DELETION.DESCRIPTION( + this.existingUserForm.id, + ), + cancelButtonText: CONFIRMATION_DIALOG.DELETION.CANCEL_BUTTON_TEXT, + proceedButtonText: CONFIRMATION_DIALOG.DELETION.PROCEED_BUTTON_TEXT, + }, + }); + + dialogRef.afterClosed().subscribe((res) => { + if (!res) { + this.store.dispatch( + new ArchiveUserStory(this.absoluteFilePath, this.existingUserForm.id), + ); + this.navigateBackToUserStories(); + this.toasterService.showSuccess( + TOASTER_MESSAGES.ENTITY.DELETE.SUCCESS( + this.entityType, + this.existingUserForm.id, + ), + ); + } + }); + } + + handleFileContent(content: string) { + this.uploadedFileContent = content; + } + + canDeactivate(): boolean { + return ( + !this.allowFreeRedirection && + this.userStoryForm.dirty && + this.userStoryForm.touched + ); + } + + ngOnDestroy(): void { + this.store.dispatch(new DeleteBreadcrumb(this.editLabel)); + } +} diff --git a/ui/src/app/pages/login/login.component.html b/ui/src/app/pages/login/login.component.html new file mode 100644 index 0000000..f48353e --- /dev/null +++ b/ui/src/app/pages/login/login.component.html @@ -0,0 +1,78 @@ +<div class="bg-secondary-950 font-poppins"> + <div + class="flex flex-col h-screen w-screen overflow-hidden items-center justify-center" + > + <img id="app-logo" [src]="themeConfiguration.appLogo" class="h-[60px] mb-6" alt="App Logo" /> + + <div class="bg-white rounded-lg shadow-lg px-8 py-5 mt-6"> + <div class="sm:mx-auto sm:w-full sm:max-w-sm"> + <h3 + id="app-title" + class="text-center mt-2 font-bold text-lg leading-10 text-gray-900" + >{{ themeConfiguration.appWelcomeTitle }}</h3> + <p + id="app-description" + class="text-center text-sm font-light text-gray-700" + >{{ themeConfiguration.appDescription }}</p> + </div> + <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm"> + <form class="space-y-4" id="login-form" [formGroup]="loginForm"> + <div> + <app-input-field + formControlName="appUrl" + elementPlaceHolder="Your Server URL" + elementName="App URL"/> + <app-error-message [errorControl]="loginForm.get('appUrl')"/> + </div> + <div> + <app-input-field + elementType="password" + elementPlaceHolder="Passcode" + formControlName="passcode" + elementName="App Passcode" + /> + <app-error-message [errorControl]="loginForm.get('passcode')"/> + </div> + <div> + <label + for="directory-path" + class="block text-sm font-medium text-secondary-500 mb-2" + >Choose Destination Folder</label + > + <div class="flex w-full"> + <input + type="text" + id="directory-path" + placeholder="Choose Destination Folder" + formControlName="directoryPath" + class="bg-gray-100 border border-gray-300 text-gray-400 text-sm rounded-l-lg block w-full p-2" + /> + <button + id="browse-files-btn" + type="button" + class="px-4 py-2 bg-primary-600 text-white rounded-r-lg" + (click)="browseFiles()" + > + Browse + </button> + </div> + <p class="text-xs font-light mt-2 text-gray-400"> + Select a folder to store the requirements generated by + <span id="app-name">{{ themeConfiguration.appName }}</span> + </p> + </div> + <div class="flex items-center justify-center py-4"> + <app-button buttonContent="Get Started β†’" (click)="login()"/> + </div> + </form> + </div> + </div> + + <!-- Add the company logo below the form --> + <img *ngIf="themeConfiguration.companyLogo.length > 0" + id="company-logo" + class="mt-6 h-[20px]" + [src]="themeConfiguration.companyLogo" + [alt]="themeConfiguration.companyName"/> + </div> +</div> diff --git a/ui/src/app/pages/login/login.component.scss b/ui/src/app/pages/login/login.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/pages/login/login.component.spec.ts b/ui/src/app/pages/login/login.component.spec.ts new file mode 100644 index 0000000..360f9f2 --- /dev/null +++ b/ui/src/app/pages/login/login.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LoginComponent } from './login.component'; + +describe('LoginComponent', () => { + let component: LoginComponent; + let fixture: ComponentFixture<LoginComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [LoginComponent] + }); + fixture = TestBed.createComponent(LoginComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/pages/login/login.component.ts b/ui/src/app/pages/login/login.component.ts new file mode 100644 index 0000000..261ad93 --- /dev/null +++ b/ui/src/app/pages/login/login.component.ts @@ -0,0 +1,144 @@ +import { Component, inject, OnInit } from '@angular/core'; +import { + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { AuthService } from '../../services/auth/auth.service'; +import { ToasterService } from '../../services/toaster/toaster.service'; +import { Router } from '@angular/router'; +import { ElectronService } from '../../services/electron/electron.service'; +import { NGXLogger } from 'ngx-logger'; +import { APP_CONSTANTS } from '../../constants/app.constants'; +import { InputFieldComponent } from '../../components/core/input-field/input-field.component'; +import { ButtonComponent } from '../../components/core/button/button.component'; +import { ErrorMessageComponent } from '../../components/core/error-message/error-message.component'; +import { environment } from '../../../environments/environment'; +import { NgIf } from '@angular/common'; + +@Component({ + selector: 'app-login', + templateUrl: './login.component.html', + styleUrls: ['./login.component.scss'], + standalone: true, + imports: [ + NgIf, + InputFieldComponent, + ReactiveFormsModule, + ButtonComponent, + ErrorMessageComponent, + ], +}) +export class LoginComponent implements OnInit { + loginForm = new FormGroup({ + appUrl: new FormControl('', Validators.required), + passcode: new FormControl('', Validators.required), + directoryPath: new FormControl( + { value: '', disabled: true }, + Validators.required, + ), + }); + + themeConfiguration = environment.ThemeConfiguration; + + authService = inject(AuthService); + toastService = inject(ToasterService); + routerService = inject(Router); + electronService = inject(ElectronService); + logger = inject(NGXLogger); + + async ngOnInit() { + this.authService.setIsLoggedIn(false); + const config = await this.electronService.getStoreValue("APP_CONFIG") || {}; + + const appUrl = config.appUrl || localStorage.getItem(APP_CONSTANTS.APP_URL); + const passcode = config.password || localStorage.getItem(APP_CONSTANTS.APP_PASSCODE_KEY); + const directoryPath = config.directoryPath || localStorage.getItem(APP_CONSTANTS.WORKING_DIR); + + if (appUrl && passcode) { + this.loginForm.patchValue({ + appUrl: appUrl as string, + passcode: atob(passcode as string), + directoryPath: directoryPath as string, + }); + + // Auto-login if values are present in localStorage + if (localStorage.getItem(APP_CONSTANTS.APP_URL) && localStorage.getItem(APP_CONSTANTS.APP_PASSCODE_KEY)) { + this.login(); + } + } + } + + login() { + this.loginForm.markAllAsTouched(); + if (this.loginForm.valid) { + const { appUrl, passcode } = this.loginForm.getRawValue() as { + appUrl: string; + passcode: string; + }; + const updatedAppUrl = + appUrl[appUrl.length - 1] === '/' + ? appUrl.slice(0, appUrl.length - 1) + : `${appUrl}`; + + const newConfig = { + appUrl: updatedAppUrl, + password: btoa(passcode), + directoryPath: this.loginForm.get('directoryPath')!.value + }; + + this.electronService.setStoreValue("APP_CONFIG", newConfig); + localStorage.setItem(APP_CONSTANTS.APP_URL, updatedAppUrl as string); + localStorage.setItem(APP_CONSTANTS.WORKING_DIR, newConfig.directoryPath as string); + this.logger.debug(updatedAppUrl, passcode); + + this.logger.debug(updatedAppUrl); + this.authService + .login({ + appUrl: `${updatedAppUrl}/`, + passcode: passcode, + }) + .subscribe({ + next: (res: any) => { + this.logger.debug('logging successful', res); + localStorage.setItem( + APP_CONSTANTS.APP_PASSCODE_KEY, + btoa(passcode) as string, + ); + + this.authService.setIsLoggedIn(true); + + this.routerService + .navigate(['/apps']) + .then() + .catch((err) => this.logger.error(err)); + }, + error: (_) => { + this.authService.setIsLoggedIn(false); + localStorage.removeItem(APP_CONSTANTS.APP_URL); + localStorage.removeItem(APP_CONSTANTS.APP_PASSCODE_KEY) + this.toastService.showError( + 'Your Server URL or passcode is incorrect. Please try again', + ); + }, + }); + } + } + + async browseFiles(): Promise<void> { + try { + const response = await this.electronService.openDirectory(); + this.logger.debug('response', response); + if (response.length > 0) { + this.loginForm.get('directoryPath')!.setValue(response[0]); + const currentConfig = await this.electronService.getStoreValue("APP_CONFIG") || {}; + const updatedConfig = { ...currentConfig, directoryPath: response[0] }; + this.electronService.setStoreValue("APP_CONFIG", updatedConfig); + localStorage.setItem(APP_CONSTANTS.WORKING_DIR, response[0]); + } + } catch (error) { + this.logger.error('Error selecting root directory', error); + } + } +} diff --git a/ui/src/app/pages/tasks/add-task/add-task.component.html b/ui/src/app/pages/tasks/add-task/add-task.component.html new file mode 100644 index 0000000..be1ddfc --- /dev/null +++ b/ui/src/app/pages/tasks/add-task/add-task.component.html @@ -0,0 +1,118 @@ +<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 h-full"> + <div + [ngClass]=" + mode === 'add' + ? 'grid grid-cols-1 gap-4' + : 'grid grid-cols-1 lg:grid-cols-3 gap-4' + " + > + <div + [ngClass]=" + mode === 'add' + ? 'bg-white shadow rounded-lg p-6 flex flex-col' + : 'bg-white shadow rounded-lg p-6 flex flex-col lg:col-span-2' + " + > + <div class="flex justify-between items-center"> + <div class="flex items-center space-x-1"> + <h1 class="text-normal font-semibold mb-4"> + {{ mode === "add" ? "Add Task" : existingTask.id }} + </h1> + </div> + + <div + *ngIf="mode === 'edit'" + class="flex items-center space-x-1 text-3xl" + > + <div class="relative" style="display: inline-block; cursor: pointer"> + <app-button + [isIconButton]="true" + icon="heroTrash" + theme="danger" + size="sm" + rounded="md" + (click)="deleteTask()" + ></app-button> + </div> + </div> + </div> + + <form + [formGroup]="taskForm" + (ngSubmit)="mode === 'add' ? addTask() : updateTask()" + > + <app-input-field + [required]="true" + elementPlaceHolder="Task Name" + elementId="add-task-name" + elementName="Task Name" + formControlName="list" + /> + <app-error-message [errorControl]="taskForm.get('list')" /> + <app-textarea-field + [required]="true" + elementPlaceHolder="Task Description" + elementId="add-task-description" + elementName="Task Description" + formControlName="acceptance" + /> + <app-error-message [errorControl]="taskForm.get('acceptance')" /> + + <div class="flex items-center justify-between mt-4"> + <div class="flex items-center"> + <input + id="task-generate-with-ai" + type="checkbox" + formControlName="useGenAI" + class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500" + /> + <label + for="task-generate-with-ai" + class="ml-2 block text-sm text-gray-900" + >Expand task with AI</label + > + </div> + <div class="flex items-center"> + <app-button + buttonContent="Update" + theme="primary" + size="sm" + rounded="md" + *ngIf="mode === 'edit'" + type="submit" + [disabled]="taskForm.invalid" + /> + <app-button + buttonContent="Add" + theme="primary" + size="sm" + rounded="md" + *ngIf="mode === 'add'" + type="submit" + [disabled]="taskForm.invalid" + /> + </div> + </div> + <div class="flex items-center justify-start"> + <app-multi-upload + *ngIf="mode === 'add'" + (fileContent)="handleFileContent($event)" + /> + </div> + </form> + </div> + <div *ngIf="mode !== 'add'" class="space-y-4 h-full lg:col-span-1"> + <app-chat + chatType="task" + [name]="projectMetadata?.name" + [description]="projectMetadata?.description" + [chatHistory]="chatHistory" + [baseContent]="taskForm.getRawValue().acceptance" + [prd]="prd.requirement" + [userStory]="userStory.description" + (getContent)="updateTaskFromChat($event)" + (updateChatHistory)="updateChatHistory($event)" + ></app-chat> + </div> + </div> +</div> diff --git a/ui/src/app/pages/tasks/add-task/add-task.component.scss b/ui/src/app/pages/tasks/add-task/add-task.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/pages/tasks/add-task/add-task.component.spec.ts b/ui/src/app/pages/tasks/add-task/add-task.component.spec.ts new file mode 100644 index 0000000..86cdcf2 --- /dev/null +++ b/ui/src/app/pages/tasks/add-task/add-task.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AddTaskComponent } from './add-task.component'; + +describe('AddTaskComponent', () => { + let component: AddTaskComponent; + let fixture: ComponentFixture<AddTaskComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [AddTaskComponent] + }); + fixture = TestBed.createComponent(AddTaskComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/pages/tasks/add-task/add-task.component.ts b/ui/src/app/pages/tasks/add-task/add-task.component.ts new file mode 100644 index 0000000..32914a5 --- /dev/null +++ b/ui/src/app/pages/tasks/add-task/add-task.component.ts @@ -0,0 +1,480 @@ +import { Component, inject, OnDestroy } from '@angular/core'; +import { + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Store } from '@ngxs/store'; +import { + CreateNewTask, + SetCurrentTaskId, + UpdateTask, +} from '../../../store/user-stories/user-stories.actions'; +import { ProjectsState } from '../../../store/projects/projects.state'; +import { Subject, takeUntil } from 'rxjs'; +import { NGXLogger } from 'ngx-logger'; +import { UserStoriesState } from '../../../store/user-stories/user-stories.state'; +import { FeatureService } from '../../../services/feature/feature.service'; +import { AppSystemService } from '../../../services/app-system/app-system.service'; +import { + IAddTaskRequest, + IEditTaskRequest, +} from '../../../model/interfaces/ITask'; +import { + AddBreadcrumb, + DeleteBreadcrumb, +} from '../../../store/breadcrumb/breadcrumb.actions'; +import { NgClass, NgIf } from '@angular/common'; +import { InputFieldComponent } from '../../../components/core/input-field/input-field.component'; +import { TextareaFieldComponent } from '../../../components/core/textarea-field/textarea-field.component'; +import { ButtonComponent } from '../../../components/core/button/button.component'; +import { AiChatComponent } from '../../../components/ai-chat/ai-chat.component'; +import { MultiUploadComponent } from '../../../components/multi-upload/multi-upload.component'; +import { ErrorMessageComponent } from '../../../components/core/error-message/error-message.component'; +import { ArchiveTask } from '../../../store/user-stories/user-stories.actions'; +import { + CONFIRMATION_DIALOG, + TOASTER_MESSAGES, +} from 'src/app/constants/app.constants'; +import { ConfirmationDialogComponent } from 'src/app/components/confirmation-dialog/confirmation-dialog.component'; +import { MatDialog } from '@angular/material/dialog'; +import { ToasterService } from 'src/app/services/toaster/toaster.service'; + +@Component({ + selector: 'app-add-task', + templateUrl: './add-task.component.html', + styleUrls: ['./add-task.component.scss'], + standalone: true, + imports: [ + NgClass, + ReactiveFormsModule, + InputFieldComponent, + TextareaFieldComponent, + ButtonComponent, + NgIf, + AiChatComponent, + MultiUploadComponent, + ErrorMessageComponent, + ], +}) +export class AddTaskComponent implements OnDestroy { + mode: 'edit' | 'add' = 'edit'; + taskForm!: FormGroup; + selectedProject!: string; + prd: any = {}; + config!: { + projectId: string; + fileName: string; + folderName: string; + reqId: string; + featureId: string; + featureName?: string; + newFileName?: string; + }; + uploadedFileContent: string = ''; + + activatedRoute = inject(ActivatedRoute); + router = inject(Router); + store = inject(Store); + logger = inject(NGXLogger); + appSystemService = inject(AppSystemService); + featureService = inject(FeatureService); + selectedProject$ = this.store.select(ProjectsState.getSelectedProject); + destroy$ = new Subject<boolean>(); + projectMetadata: any; + chatHistory: any = []; + editLabel: string = ''; + userStory: any = {}; + entityType: string = 'TASK'; + totalTaskCount: number = 0; + absoluteFilePath: string = ''; + + existingTask: { + id: string; + acceptance: string; + task: string; + subTaskTicketId: string; + } = { + id: '', + acceptance: '', + task: '', + subTaskTicketId: '', + }; + + constructor( + private dialog: MatDialog, + private toastService: ToasterService, + ) { + this.mode = this.activatedRoute.snapshot.paramMap.get('mode') as + | 'edit' + | 'add'; + this.getConfig(); + const taskId = this.activatedRoute.snapshot.paramMap.get('taskId'); + const userStoryId = + this.activatedRoute.snapshot.paramMap.get('userStoryId'); + this.selectedProject$.pipe(takeUntil(this.destroy$)).subscribe((res) => { + this.selectedProject = res; + if (res) { + this.readMetadata(res).then(); + } + }); + + this.store.select(ProjectsState.getSelectedFileContent).subscribe((res) => { + this.prd = res; + }); + + this.store + .select(UserStoriesState.getSelectedUserStory) + .subscribe((res) => { + this.userStory = res; + this.totalTaskCount = + (res?.tasks?.length || 0) + (res?.archivedTasks?.length || 0); + }); + + this.createTaskForm(taskId); + this.editLabel = this.mode == 'edit' ? 'Edit' : 'Add'; + + this.store.dispatch( + new AddBreadcrumb({ + label: this.config.featureId + ' - Tasks', + tooltipLabel: `Tasks of ${this.config.featureId}: ${this.config.featureName}`, + url: `/task-list/${userStoryId}`, + state: { config: this.config }, + }), + ); + this.store.dispatch( + new AddBreadcrumb({ + label: this.editLabel, + }), + ); + + if (this.mode === 'edit') { + this.absoluteFilePath = `${this.config.folderName}/${this.config.newFileName}`; + } + } + + getConfig() { + this.store.select(UserStoriesState.getCurrentConfig).subscribe((config) => { + if (config) { + this.config = config; + } + this.logger.debug(config, 'config', this.config); + }); + } + + async readMetadata(rootProject: string) { + this.logger.debug('root project: ', rootProject); + this.projectMetadata = + await this.appSystemService.readMetadata(rootProject); + this.logger.debug(this.projectMetadata); + } + + responseFormatter(data: any) { + if (typeof data == 'object') return data.join('. '); + else return data; + } + + createTaskForm(taskId: string | null) { + this.taskForm = new FormGroup({ + list: new FormControl('', Validators.compose([Validators.required])), + acceptance: new FormControl( + '', + Validators.compose([Validators.required]), + ), + id: new FormControl(`TASK${this.totalTaskCount + 1}`), + useGenAI: new FormControl(false), + fileContent: new FormControl(''), + subTaskTicketId: new FormControl(''), + }); + if (taskId) { + this.store.dispatch(new SetCurrentTaskId(taskId)); + this.store.select(UserStoriesState.getSelectedTask).subscribe((task) => { + this.chatHistory = task?.chatHistory || []; + this.existingTask.id = <string>task?.id; + this.existingTask.acceptance = <string>task?.acceptance; + this.existingTask.task = <string>task?.list; + this.existingTask.subTaskTicketId = <string>task?.subTaskTicketId; + this.taskForm.patchValue({ + id: task?.id, + acceptance: task?.acceptance, + list: task?.list, + subTaskTicketId: task?.subTaskTicketId, + }); + }); + } + } + + addTask() { + if (this.taskForm.valid) { + this.logger.debug(this.config); + const newFileName = this.config.fileName.replace('base', 'feature'); + this.logger.debug(this.taskForm.getRawValue()); + const data = this.taskForm.getRawValue(); + if (!data.useGenAI && this.uploadedFileContent.length === 0) { + this.store.dispatch( + new CreateNewTask( + { id: data.id, list: data.list, acceptance: data.acceptance }, + `${this.selectedProject}/${this.config.folderName}/${newFileName}`, + ), + ); + + this.taskForm.markAsUntouched(); + this.taskForm.markAsPristine(); + this.navigateBackToTasks(); + this.toastService.showSuccess( + TOASTER_MESSAGES.ENTITY.ADD.SUCCESS(this.entityType), + ); + } else { + this.addTaskWithAI(data.id, data.list, data.acceptance, newFileName); + } + } + } + + updateTask() { + if (this.taskForm.valid) { + const newFileName = this.config.fileName.replace('base', 'feature'); + this.logger.debug(this.taskForm.getRawValue()); + const data = this.taskForm.getRawValue(); + if (!data.useGenAI && this.uploadedFileContent.length === 0) { + this.store.dispatch( + new UpdateTask( + { + ...this.taskForm.getRawValue(), + chatHistory: this.chatHistory, + subTaskTicketId: this.existingTask.subTaskTicketId, + }, + `${this.selectedProject}/${this.config.folderName}/${newFileName}`, + true, + ), + ); + this.taskForm.markAsUntouched(); + this.taskForm.markAsPristine(); + this.navigateBackToTasks(); + this.toastService.showSuccess( + TOASTER_MESSAGES.ENTITY.UPDATE.SUCCESS( + this.entityType, + this.existingTask.id, + ), + ); + } else { + this.editTaskWithAI(data.id, data.list, data.acceptance, newFileName); + } + } + } + + updateTaskFromChat(data: any) { + let { chat, chatHistory } = data; + if (chat.assistant) { + this.taskForm.patchValue({ + acceptance: `${this.taskForm.getRawValue().acceptance} +${chat.assistant}`, + subTaskTicketId: this.existingTask.subTaskTicketId, + }); + console.log( + this.existingTask.subTaskTicketId, + this.taskForm.getRawValue().subTaskTicketId, + 'subTaskTicketId', + ); + let newArray = chatHistory.map((item: any) => { + if (item.assistant == chat.assistant) return { ...item, isAdded: true }; + else return item; + }); + const newFileName = this.config.fileName.replace('base', 'feature'); + this.store.dispatch( + new UpdateTask( + { + ...this.taskForm.getRawValue(), + chatHistory: newArray, + subTaskTicketId: this.existingTask.subTaskTicketId, + }, + `${this.selectedProject}/${this.config.folderName}/${newFileName}`, + false, + ), + ); + this.chatHistory = newArray; + } + } + + updateChatHistory(data: any) { + const newFileName = this.config.fileName.replace('base', 'feature'); + this.store.dispatch( + new UpdateTask( + { ...this.taskForm.getRawValue(), chatHistory: data }, + `${this.selectedProject}/${this.config.folderName}/${newFileName}`, + false, + ), + ); + } + + addTaskWithAI( + id: string, + list: string, + acceptance: string, + newFileName: string, + ) { + const requestData: IAddTaskRequest = { + taskId: id, + useGenAI: true, + description: this.userStory.description, + name: this.userStory.name, + appId: this.projectMetadata.id, + featureId: this.config.featureId, + contentType: this.uploadedFileContent ? 'fileContent' : '', + fileContent: this.uploadedFileContent, + reqId: this.config.reqId, + reqDesc: acceptance, + usIndex: 0, + taskName: list, + }; + + this.featureService.addTask(requestData).subscribe((res) => { + const taskEntry = res.tasks.find((task) => task.id === id); + + if (taskEntry) { + const taskKey = Object.keys(taskEntry).find((key) => key !== 'id'); + + if (taskKey) { + const dispatchData = { + id: id, + list: taskKey, + acceptance: this.responseFormatter(taskEntry[taskKey]), + }; + this.store.dispatch( + new CreateNewTask( + dispatchData, + `${this.selectedProject}/${this.config.folderName}/${newFileName}`, + ), + ); + this.taskForm.markAsUntouched(); + this.taskForm.markAsPristine(); + this.navigateBackToTasks(); + this.toastService.showSuccess( + TOASTER_MESSAGES.ENTITY.ADD.SUCCESS(this.entityType), + ); + } else { + this.logger.error('No task key found other than "id"'); + } + } else { + this.logger.error('Task with specified ID not found in the response'); + } + }); + } + + navigateBackToTasks() { + this.router + .navigate(['/task-list', this.config.featureId], { + state: { + config: this.config, + }, + }) + .then(); + } + + editTaskWithAI( + id: string, + list: string, + acceptance: string, + newFileName: string, + ) { + const requestBody: IEditTaskRequest = { + name: this.userStory.name, + description: this.userStory.description, + appId: this.projectMetadata.id, + taskId: id, + featureId: this.config.featureId, + reqId: this.config.reqId, + contentType: this.uploadedFileContent ? 'fileContent' : '', + fileContent: this.uploadedFileContent, + reqDesc: acceptance, + useGenAI: true, + usIndex: 0, + existingTaskDesc: this.existingTask.acceptance, + existingTaskTitle: this.existingTask.task, + taskName: list, + }; + + this.featureService.updateTask(requestBody).subscribe((res) => { + const taskEntry = res.tasks.find((task) => task.id === id); + + if (taskEntry) { + const taskKey = Object.keys(taskEntry).find((key) => key !== 'id'); + + if (taskKey) { + const dispatchData = { + id: id, + list: taskKey, + acceptance: this.responseFormatter(taskEntry[taskKey]), + chatHistory: this.chatHistory, + subTaskTicketId: this.existingTask.subTaskTicketId, + }; + this.store.dispatch( + new UpdateTask( + dispatchData, + `${this.selectedProject}/${this.config.folderName}/${newFileName}`, + ), + ); + this.taskForm.markAsUntouched(); + this.taskForm.markAsPristine(); + this.navigateBackToTasks(); + this.toastService.showSuccess( + TOASTER_MESSAGES.ENTITY.UPDATE.SUCCESS(this.entityType, id), + ); + } else { + this.logger.error('No task key found other than "id"'); + } + } else { + this.logger.error('Task with specified ID not found in the response'); + } + }); + } + + handleFileContent(content: string) { + this.logger.debug(content); + this.uploadedFileContent = content; + } + + deleteTask() { + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + width: '500px', + data: { + title: CONFIRMATION_DIALOG.DELETION.TITLE, + description: CONFIRMATION_DIALOG.DELETION.DESCRIPTION( + this.existingTask.id, + ), + cancelButtonText: CONFIRMATION_DIALOG.DELETION.CANCEL_BUTTON_TEXT, + proceedButtonText: CONFIRMATION_DIALOG.DELETION.PROCEED_BUTTON_TEXT, + }, + }); + + dialogRef.afterClosed().subscribe((res) => { + if (!res) { + this.store.dispatch( + new ArchiveTask( + this.absoluteFilePath, + this.config.featureId, + this.existingTask.id, + ), + ); + this.navigateBackToTasks(); + this.toastService.showSuccess( + TOASTER_MESSAGES.ENTITY.DELETE.SUCCESS( + this.entityType, + this.existingTask.id, + ), + ); + } + }); + } + + ngOnDestroy() { + this.store.dispatch(new DeleteBreadcrumb(this.editLabel)); + this.destroy$.next(false); + this.destroy$.complete(); + } + + canDeactivate(): boolean { + return this.taskForm.dirty && this.taskForm.touched; + } +} diff --git a/ui/src/app/pages/tasks/task-list/task-list.component.html b/ui/src/app/pages/tasks/task-list/task-list.component.html new file mode 100644 index 0000000..5cbb11f --- /dev/null +++ b/ui/src/app/pages/tasks/task-list/task-list.component.html @@ -0,0 +1,109 @@ +<div class="container mx-auto px-4 py-2"> + <div + class="grid grid-cols-12 gap-4" + *ngIf="selectedUserStory$ | async as selectedUserStory" + > + <div class="col-span-4 p-4 bg-white rounded-lg shadow"> + <h1 class="text-lg font-semibold"> + {{ selectedUserStory.id }}: + {{ selectedUserStory?.name }} + </h1> + <p class="mt-6 text-sm"> + {{ + selectedUserStory?.description?.includes("Acceptance Criteria:") + ? selectedUserStory.description.split("Acceptance Criteria:")[0] + : selectedUserStory?.description + }} + </p> + <div + *ngIf=" + selectedUserStory?.description && + selectedUserStory?.description?.includes('Acceptance Criteria:') + " + > + <h4 class="text-sm mt-4 font-medium">Acceptance Criteria:</h4> + <p class="text-sm"> + {{ selectedUserStory.description.split("Acceptance Criteria:")[1] }} + </p> + </div> + </div> + <div class="col-span-8 p-4 bg-white rounded-lg shadow"> + <div class="flex items-center justify-between gap-4"> + <div class="flex items-center"> + <h2 class="text-md font-semibold text-gray-600">Tasks</h2> + <app-badge [badgeText]="(taskList$ | async)?.length || 0"></app-badge> + </div> + + <div class="flex justify-between space-x-4"> + <ng-container *ngIf="taskList$ | async as taskList"> + <app-button + [buttonContent]=" + taskList.length > 0 ? 'Regenerate Tasks' : 'Generate Tasks' + " + theme="secondary" + size="sm" + rounded="lg" + (click)="addExtraContext()" + /> + </ng-container> + + <a + routerLink="/task/add/{{ selectedUserStory?.id }}" + [state]="{ config }" + > + <app-button + buttonContent="Add New" + theme="primary" + size="sm" + rounded="lg" + /> + </a> + </div> + </div> + + <app-search-input + *ngIf="taskList$ | async as taskList" + placeholder="Search..." + (searchChange)="onSearch($event)" + ></app-search-input> + + <div class="task-list-section-height"> + <ng-container + class="overflow-y-auto" + *ngIf="filteredTaskList$ | async as taskList" + > + <app-list-item + [payload]="{ + description: task.acceptance, + name: task.list, + id: task.id, + jiraTicketId: task.subTaskTicketId, + }" + [tag]="task.id" + *ngFor="let task of taskList" + (click)="navigateToEditTask(task.id, selectedUserStory?.id)" + > + <div class="absolute top-4 right-4"> + <app-button + theme="secondary_outline" + size="xs" + rounded="lg" + [isIconButton]="true" + icon="heroDocumentDuplicate" + (click)="copyTaskContent($event, task)" + /> + </div> + </app-list-item> + <div + class="flex items-center justify-center" + *ngIf="taskList.length === 0" + > + <h1 class="font-semibold w-full text-center mt-3"> + No Tasks Available. + </h1> + </div> + </ng-container> + </div> + </div> + </div> +</div> diff --git a/ui/src/app/pages/tasks/task-list/task-list.component.scss b/ui/src/app/pages/tasks/task-list/task-list.component.scss new file mode 100644 index 0000000..0a0395c --- /dev/null +++ b/ui/src/app/pages/tasks/task-list/task-list.component.scss @@ -0,0 +1,5 @@ +.task-list-section-height { + height: calc(100vh - 275px); + overflow: auto; + } + \ No newline at end of file diff --git a/ui/src/app/pages/tasks/task-list/task-list.component.spec.ts b/ui/src/app/pages/tasks/task-list/task-list.component.spec.ts new file mode 100644 index 0000000..89d508c --- /dev/null +++ b/ui/src/app/pages/tasks/task-list/task-list.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TaskListComponent } from './task-list.component'; + +describe('TaskListComponent', () => { + let component: TaskListComponent; + let fixture: ComponentFixture<TaskListComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [TaskListComponent] + }); + fixture = TestBed.createComponent(TaskListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/pages/tasks/task-list/task-list.component.ts b/ui/src/app/pages/tasks/task-list/task-list.component.ts new file mode 100644 index 0000000..9dbb51d --- /dev/null +++ b/ui/src/app/pages/tasks/task-list/task-list.component.ts @@ -0,0 +1,236 @@ +import { Component, inject, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { Store } from '@ngxs/store'; +import { + EditUserStory, + GetUserStories, + SetCurrentConfig, + SetSelectedUserStory, +} from '../../../store/user-stories/user-stories.actions'; +import { UserStoriesState } from '../../../store/user-stories/user-stories.state'; +import { NGXLogger } from 'ngx-logger'; +import { IUserStory } from '../../../model/interfaces/IUserStory'; +import { ITaskRequest, ITasksResponse } from '../../../model/interfaces/ITask'; +import { FeatureService } from '../../../services/feature/feature.service'; +import { LoadingService } from '../../../services/loading.service'; +import { + AddBreadcrumb, + DeleteBreadcrumb, +} from '../../../store/breadcrumb/breadcrumb.actions'; +import { ModalDialogCustomComponent } from '../../../components/modal-dialog/modal-dialog.component'; +import { MatDialog } from '@angular/material/dialog'; +import { ProjectsState } from '../../../store/projects/projects.state'; +import { AsyncPipe, NgForOf, NgIf } from '@angular/common'; +import { ButtonComponent } from '../../../components/core/button/button.component'; +import { NgIconComponent } from '@ng-icons/core'; +import { ListItemComponent } from '../../../components/core/list-item/list-item.component'; +import { BadgeComponent } from '../../../components/core/badge/badge.component'; +import { Clipboard } from '@angular/cdk/clipboard'; +import { TOASTER_MESSAGES } from 'src/app/constants/app.constants'; +import { ToasterService } from 'src/app/services/toaster/toaster.service'; +import { SearchInputComponent } from '../../../components/core/search-input/search-input.component'; +import { SearchService } from '../../../services/search/search.service'; +import { BehaviorSubject } from 'rxjs'; + +@Component({ + selector: 'app-task-list', + templateUrl: './task-list.component.html', + styleUrls: ['./task-list.component.scss'], + standalone: true, + imports: [ + NgIf, + AsyncPipe, + ButtonComponent, + RouterLink, + NgForOf, + NgIconComponent, + ListItemComponent, + BadgeComponent, + SearchInputComponent, + ], +}) +export class TaskListComponent implements OnInit, OnDestroy { + activatedRoute = inject(ActivatedRoute); + store = inject(Store); + logger = inject(NGXLogger); + router = inject(Router); + clipboard = inject(Clipboard); + searchService = inject(SearchService); + userStoryId: string | null = ''; + userStories: IUserStory[] = []; + selectedUserStory!: IUserStory; + readonly dialog = inject(MatDialog); + metadata: any = {}; + currentLabel: string = ''; + entityType: string = 'TASK'; + private searchTerm$ = new BehaviorSubject<string>(''); + + config!: { + fileName: string; + folderName: string; + projectId: string; + newFileName: string; + currentProject: string; + i: number; + featureId: string; + featureName: string; + reqId: string; + }; + taskList$ = this.store.select(UserStoriesState.getTaskList); + filteredTaskList$ = this.searchService.filterItems( + this.taskList$, + this.searchTerm$, + (task: any) => [task.id, task.list, task.subTaskTicketId], + ); + selectedUserStory$ = this.store.select(UserStoriesState.getSelectedUserStory); + userStories$ = this.store.select(UserStoriesState.getUserStories); + + onSearch(term: string) { + this.searchTerm$.next(term); + } + + constructor( + private featureService: FeatureService, + private loadingService: LoadingService, + private toastService: ToasterService, + ) { + this.userStoryId = this.activatedRoute.snapshot.paramMap.get('userStoryId'); + this.logger.debug('userStoryId', this.userStoryId); + this.config = this.router.getCurrentNavigation()?.extras?.state?.[ + 'config' + ] as { + fileName: string; + folderName: string; + projectId: string; + newFileName: string; + currentProject: string; + i: number; + featureId: string; + featureName: string; + reqId: string; + }; + this.currentLabel = `${this.config.featureId} - Tasks`; + if (this.userStoryId) { + this.store.dispatch(new SetSelectedUserStory(this.userStoryId)); + } + this.store.dispatch( + new AddBreadcrumb({ + label: this.currentLabel, + tooltipLabel: `Tasks of ${this.config.featureId}: ${this.config.featureName}`, + }), + ); + this.store.select(ProjectsState.getMetadata).subscribe((metadata) => { + this.metadata = metadata; + }); + } + + ngOnDestroy(): void { + this.store.dispatch(new DeleteBreadcrumb(this.currentLabel)); + } + + navigateToEditTask(taskId: string, storyId?: string) { + this.router + .navigate(['task/edit', storyId, taskId], { + state: { + config: this.config, + }, + }) + .then(); + } + + addExtraContext() { + const dialogText = { + title: 'Generate User Story Tasks', + description: + 'Include additional context to generate relevant user story tasks', + placeholder: 'Add additional context for the user story tasks', + }; + + const dialogRef = this.dialog.open(ModalDialogCustomComponent, { + width: '600px', + data: dialogText, + }); + + dialogRef.componentInstance.generate.subscribe((emittedValue) => { + this.refineUserStoryIntoTasks(emittedValue); + }); + } + + refineUserStoryIntoTasks(extraContext: string) { + let request: ITaskRequest = { + appId: this.config.projectId, + reqId: this.config.newFileName.split('-')[0], + featureId: this.selectedUserStory.id, + name: this.selectedUserStory.name, + description: this.selectedUserStory.description, + regenerate: true, + technicalDetails: this.metadata.technicalDetails || '', + extraContext: extraContext, + }; + this.loadingService.setLoading(true); + this.featureService.generateTask(request).subscribe({ + next: (response: ITasksResponse) => { + let tasksResponse = this.featureService.parseTaskResponse(response); + const updatedUserStories = [...this.userStories]; + updatedUserStories[this.config.i] = { + ...updatedUserStories[this.config.i], + tasks: tasksResponse, + }; + this.userStories = updatedUserStories; + this.updateWithUserStories(updatedUserStories[this.config.i]); + }, + error: (error) => { + console.error('There was an error!', error); + this.loadingService.setLoading(false); + }, + }); + this.dialog.closeAll(); + } + + updateWithUserStories(userStories: IUserStory) { + this.store.dispatch( + new EditUserStory( + `${this.config.folderName}/${this.config.newFileName}`, + userStories, + ), + ); + setTimeout(() => { + this.getLatestUserStories(); + this.loadingService.setLoading(false); + }, 2000); + } + + getLatestUserStories() { + this.store.dispatch( + new GetUserStories( + `${this.config.currentProject}/${this.config.folderName}/${this.config.newFileName}`, + ), + ); + + this.userStories$.subscribe((userStories: IUserStory[]) => { + this.userStories = userStories; + }); + + this.selectedUserStory$.subscribe((userStory: any) => { + this.selectedUserStory = userStory; + }); + } + + copyTaskContent(event: Event, task: any) { + event.stopPropagation(); + const taskContent = `${task.id}: ${task.list}\n${task.acceptance || ''}`; + this.clipboard.copy(taskContent); + this.toastService.showSuccess( + TOASTER_MESSAGES.ENTITY.COPY.SUCCESS(this.entityType, task.id), + ); + } + + ngOnInit() { + this.store.dispatch( + new SetCurrentConfig({ + ...this.config, + }), + ); + this.getLatestUserStories(); + } +} diff --git a/ui/src/app/pages/user-stories/user-stories.component.html b/ui/src/app/pages/user-stories/user-stories.component.html new file mode 100644 index 0000000..1012093 --- /dev/null +++ b/ui/src/app/pages/user-stories/user-stories.component.html @@ -0,0 +1,143 @@ +<div class="container mx-auto px-4 py-2 h-full"> + <div + class="bg-white shadow rounded-lg p-6 flex flex-col h-full lg:col-span-2" + *ngIf="userStories$ | async as userStories" + > + <div class="mb-4"> + <div class="flex items-center mt-2 justify-between"> + <div class="flex flex-col gap-2 min-w-0"> + <h1 class="text-lg font-bold text-gray-800 truncate max-w-full pr-8"> + {{ newFileName.split("-")[0] }}: + {{ navigation.selectedRequirement.title }} + </h1> + + <div class="flex items-center"> + <h2 class="text-md font-semibold text-gray-600">User Stories</h2> + <app-badge [badgeText]="userStories.length"></app-badge> + </div> + </div> + + <div class="flex items-center justify-between gap-3"> + <app-button + *ngIf="userStories.length === 0" + buttonContent="Generate User Stories" + theme="secondary" + size="sm" + (click)="addMoreContext(true)" + rounded="lg" + /> + <app-button + *ngIf="userStories.length > 0" + buttonContent="Regenerate User Stories" + theme="secondary" + size="sm" + (click)="addMoreContext(true)" + rounded="lg" + /> + <app-button + [disabled]="userStories.length === 0" + buttonContent="Export" + theme="secondary" + size="sm" + rounded="lg" + [matMenuTriggerFor]="exportOption" + /> + <mat-menu #exportOption="matMenu"> + <button + mat-menu-item + class="rounded px-2 py-1 text-xs font-semibold text-indigo-600 shadow-sm hover:bg-indigo-100 mr-2" + (click)="copyToClipboard()" + > + Copy JSON to Clipboard + </button> + <button + mat-menu-item + class="rounded px-2 py-1 text-xs font-semibold text-indigo-600 shadow-sm hover:bg-indigo-100 mr-2" + (click)="exportToExcel()" + > + Download as Excel(.xlsx) + </button> + <button + mat-menu-item + class="rounded px-2 py-1 text-xs font-semibold text-indigo-600 shadow-sm hover:bg-indigo-100 mr-2" + (click)="syncRequirementWithJira()" + > + Sync with Jira + </button> + <button + mat-menu-item + class="rounded px-2 py-1 text-xs font-semibold text-indigo-600 shadow-sm hover:bg-indigo-100 mr-2" + (click)="exportToCSV()" + > + Download as CSV(.csv) + </button> + </mat-menu> + <app-button + buttonContent="Add New" + theme="primary" + size="sm" + rounded="lg" + (click)="navigateToAddUserStory()" + /> + </div> + </div> + <app-search-input + *ngIf="userStories.length > 0" + placeholder="Search..." + (searchChange)="onSearch($event)" + ></app-search-input> + </div> + + <div class="h-full overflow-y-auto"> + <app-list-item + [payload]="{ + description: userStory.description, + name: userStory.name, + id: userStory.id, + jiraTicketId: userStory.storyTicketId, + }" + *ngFor="let userStory of filteredUserStories$ | async; let i = index" + [tag]="userStory.id" + (click)="navigateToEditUserStory(userStory)" + class="relative" + > + <div class="absolute top-4 right-4 flex gap-2"> + <app-button + routerLink="/task-list/{{ userStory.id }}" + [state]="{ + config: { + folderName: navigation.folderName, + fileName: navigation.fileName, + projectId: navigation.projectId, + newFileName, + currentProject, + i, + featureId: userStory.id, + featureName: userStory.name, + reqId: newFileName.split('-')[0], + }, + }" + buttonContent="View Tasks" + theme="secondary_outline" + size="xs" + rounded="lg" + /> + <app-button + theme="secondary_outline" + size="xs" + rounded="lg" + [isIconButton]="true" + icon="heroDocumentDuplicate" + (click)="copyUserStoryContent($event, userStory)" + /> + </div> + </app-list-item> + <h1 + class="font-semibold text-gray-700 mt-4 text-center" + *ngIf="(filteredUserStories$ | async)?.length === 0" + > + No User Stories Available. + </h1> + </div> + </div> +</div> diff --git a/ui/src/app/pages/user-stories/user-stories.component.scss b/ui/src/app/pages/user-stories/user-stories.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/pages/user-stories/user-stories.component.spec.ts b/ui/src/app/pages/user-stories/user-stories.component.spec.ts new file mode 100644 index 0000000..6f1ca84 --- /dev/null +++ b/ui/src/app/pages/user-stories/user-stories.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UserStoriesComponent } from './user-stories.component'; + +describe('UserStoriesComponent', () => { + let component: UserStoriesComponent; + let fixture: ComponentFixture<UserStoriesComponent>; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [UserStoriesComponent] + }); + fixture = TestBed.createComponent(UserStoriesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/pages/user-stories/user-stories.component.ts b/ui/src/app/pages/user-stories/user-stories.component.ts new file mode 100644 index 0000000..85fba7b --- /dev/null +++ b/ui/src/app/pages/user-stories/user-stories.component.ts @@ -0,0 +1,581 @@ +import { Component, inject, OnInit } from '@angular/core'; +import { Router, RouterLink } from '@angular/router'; +import { NGXLogger } from 'ngx-logger'; +import { Store } from '@ngxs/store'; +import { UserStoriesState } from '../../store/user-stories/user-stories.state'; +import { + EditUserStory, + GetUserStories, + SetCurrentConfig, + SetSelectedProject, +} from '../../store/user-stories/user-stories.actions'; +import { ProjectsState } from '../../store/projects/projects.state'; +import { + IUserStoriesRequest, + IUserStory, +} from '../../model/interfaces/IUserStory'; +import { FeatureService } from '../../services/feature/feature.service'; +import { + CreateFile, + ReadFile, + UpdateFile, +} from '../../store/projects/projects.actions'; +import { ExportService } from '../../services/export.service'; +import { Clipboard } from '@angular/cdk/clipboard'; +import { ITaskRequest, ITasksResponse } from '../../model/interfaces/ITask'; +import { AddBreadcrumb } from '../../store/breadcrumb/breadcrumb.actions'; +import { LoadingService } from '../../services/loading.service'; +import { MatDialog } from '@angular/material/dialog'; +import { ModalDialogCustomComponent } from '../../components/modal-dialog/modal-dialog.component'; +import { + getJiraTokenInfo, + storeJiraToken, +} from '../../integrations/jira/jira.utils'; +import { JiraService } from '../../integrations/jira/jira.service'; +import { ToasterService } from '../../services/toaster/toaster.service'; +import { APP_INTEGRATIONS, JIRA_TOAST } from '../../constants/toast.constant'; +import { ElectronService } from '../../services/electron/electron.service'; +import { getNavigationParams } from '../../utils/common.utils'; +import { ButtonComponent } from '../../components/core/button/button.component'; +import { MatMenuModule } from '@angular/material/menu'; +import { AsyncPipe, NgForOf, NgIf } from '@angular/common'; +import { NgIconComponent } from '@ng-icons/core'; +import { ListItemComponent } from '../../components/core/list-item/list-item.component'; +import { BadgeComponent } from '../../components/core/badge/badge.component'; +import { ConfirmationDialogComponent } from 'src/app/components/confirmation-dialog/confirmation-dialog.component'; +import { CONFIRMATION_DIALOG, TOASTER_MESSAGES } from '../../constants/app.constants'; +import { SearchInputComponent } from '../../components/core/search-input/search-input.component'; +import { SearchService } from '../../services/search/search.service'; +import { BehaviorSubject } from 'rxjs'; + +@Component({ + selector: 'app-user-stories', + templateUrl: './user-stories.component.html', + styleUrls: ['./user-stories.component.scss'], + standalone: true, + imports: [ + ButtonComponent, + RouterLink, + MatMenuModule, + AsyncPipe, + NgIf, + NgIconComponent, + NgForOf, + ListItemComponent, + BadgeComponent, + SearchInputComponent, + ], +}) +export class UserStoriesComponent implements OnInit { + currentProject!: string; + newFileName: string = ''; + entityType: string = 'STORIES'; + selectedRequirement: any = {}; + metadata: any = {}; + private searchTerm$ = new BehaviorSubject<string>(''); + + router = inject(Router); + logger = inject(NGXLogger); + store = inject(Store); + searchService = inject(SearchService); + requirementFile: any = []; + userStories: IUserStory[] = []; + exportData: any = []; + jsonOutput: any = {}; + + isTokenAvailable: boolean = true; + navigation: { + projectId: string; + folderName: string; + fileName: string; + selectedRequirement: any; + data: any; + } = { + projectId: '', + folderName: '', + fileName: '', + selectedRequirement: {}, + data: {}, + }; + + userStories$ = this.store.select(UserStoriesState.getUserStories); + filteredUserStories$ = this.searchService.filterItems( + this.userStories$, + this.searchTerm$, + (story: IUserStory) => [story.id, story.name, story.storyTicketId], + ); + selectedProject$ = this.store.select(ProjectsState.getSelectedProject); + selectedFileContent$ = this.store.select( + ProjectsState.getSelectedFileContent, + ); + + userStoriesInState: IUserStory[] = []; + + + readonly dialog = inject(MatDialog); + + onSearch(term: string) { + this.searchTerm$.next(term); + } + + constructor( + private featureService: FeatureService, + private exportService: ExportService, + private clipboard: Clipboard, + private loadingService: LoadingService, + private jiraService: JiraService, + private electronService: ElectronService, + private toast: ToasterService, + ) { + this.navigation = getNavigationParams(this.router.getCurrentNavigation()); + this.store.dispatch( + new SetCurrentConfig({ + projectId: this.navigation.projectId, + folderName: this.navigation.folderName, + fileName: this.navigation.fileName, + reqId: this.navigation.fileName.split('-')[0], + featureId: '', + }), + ); + + this.store.select(ProjectsState.getMetadata).subscribe((res) => { + this.metadata = res; + }); + + this.store.dispatch( + new AddBreadcrumb({ + label: this.navigation.folderName, + url: `/apps/${this.navigation.projectId}`, + state: { + data: this.navigation.data, + selectedFolder: { + title: this.navigation.folderName, + id: this.navigation.projectId, + metadata: this.navigation.data, + }, + }, + }), + ); + this.store.dispatch( + new AddBreadcrumb({ + label: `${this.navigation.fileName.split('-')[0] ?? ''} - User Stories`, + tooltipLabel: `${this.navigation.fileName.split('-')[0]}: ${this.navigation.selectedRequirement.title} - User Stories`, + url: `/user-stories/${this.navigation.projectId}`, + state: { + data: this.navigation.data, + id: this.navigation.projectId, + folderName: this.navigation.folderName, + fileName: this.navigation.fileName, + req: this.navigation.selectedRequirement, + }, + }), + ); + } + + ngOnInit() { + this.store.select(ProjectsState.getMetadata).subscribe((res) => { + this.metadata = res; + }); + + this.isTokenAvailable = (() => { + const tokenInfo = getJiraTokenInfo(this.navigation.projectId); + return ( + tokenInfo.projectKey === + this.metadata.integration?.jira?.jiraProjectKey && !!tokenInfo.token + ); + })(); + + this.selectedProject$.subscribe((project) => { + this.currentProject = project; + this.store.dispatch(new SetSelectedProject(this.currentProject)); + this.logger.debug(project, 'selected project'); + this.newFileName = this.navigation.fileName.replace('base', 'feature'); + project && this.getLatestUserStories(); + }); + + this.selectedFileContent$.subscribe((res: any) => { + this.requirementFile = res; + }); + + this.userStories$.subscribe((userStories: IUserStory[]) => { + this.userStoriesInState = userStories; + }); + + } + + navigateToAddUserStory() { + this.router + .navigate(['/story', 'add'], { + state: { + folderName: this.navigation.folderName, + fileName: this.newFileName, + fileData: this.navigation.data, + req: this.navigation.selectedRequirement, + }, + }) + .then(); + } + + navigateToEditUserStory(selectedUserStory: IUserStory) { + this.router + .navigate(['/story', 'edit', selectedUserStory.id], { + state: { + data: selectedUserStory, + folderName: this.navigation.folderName, + fileName: this.newFileName, + fileData: this.navigation.data, + req: this.navigation.selectedRequirement, + }, + }) + .then(); + } + + navigateToAppIntegrations() { + this.router.navigate([`/apps/${this.navigation.projectId}`], { + state: { openAppIntegrations: 'true' }, + }); + } + + generateUserStories(regenerate: boolean = false, extraContext: string = '') { + let request: IUserStoriesRequest = { + appId: this.navigation.projectId, + reqId: this.newFileName.split('-')[0], + reqDesc: this.navigation.selectedRequirement.requirement, + regenerate: regenerate, + technicalDetails: this.metadata.technicalDetails || '', + extraContext: extraContext, + }; + + this.loadingService.setLoading(true); + this.featureService.generateUserStories(request).subscribe({ + next: (response) => { + this.userStories = response; + this.generateTasks(regenerate).then(() => { + this.updateWithUserStories(this.userStories); + }); + }, + error: (error) => { + this.loadingService.setLoading(false); + console.error('Error fetching user stories:', error); + }, + }); + this.dialog.closeAll(); + } + + generateTasks(regenerate: boolean): Promise<void[]> { + const requests = this.userStories.map(async (userStory: IUserStory) => { + let request: ITaskRequest = { + appId: this.navigation.projectId, + reqId: this.navigation.fileName.split('-')[0], + featureId: userStory.id, + name: userStory.name, + description: userStory.description, + regenerate: regenerate, + technicalDetails: this.metadata.technicalDetails || '', + extraContext: '', + }; + return this.featureService + .generateTask(request) + .toPromise() + .then((response: ITasksResponse | undefined) => { + userStory.tasks = this.featureService.parseTaskResponse(response); + }) + .catch((error) => { + console.error( + 'Error generating task for user story:', + userStory.id, + error, + ); + }); + }); + return Promise.all(requests); + } + + updateWithUserStories(userStories: IUserStory[]) { + this.store.dispatch( + new CreateFile( + `${this.navigation.folderName}`, + { features: userStories }, + this.navigation.fileName.replace(/\-base.json$/, ''), + ), + ); + + setTimeout(() => { + this.getLatestUserStories(); + this.loadingService.setLoading(false); + }, 2000); + } + + getLatestUserStories() { + this.store.dispatch( + new GetUserStories( + `${this.currentProject}/${this.navigation.folderName}/${this.newFileName}`, + ), + ); + + this.userStories$.subscribe((res) => { + this.exportData = this.prepareExportData(res); + this.jsonOutput = { + userStories: res.map((userStory) => ({ + name: userStory.name, + description: userStory.description, + tasks: + userStory.tasks?.map((task) => ({ + list: task.list, + acceptanceCriteria: task.acceptance, + })) || [], + })), + }; + }); + } + + private prepareExportData(stories: any): any { + const worksheetData = [ + ['User Story', 'Description', 'Task', 'Acceptance Criteria'], + ]; + + stories.forEach((userStory: any) => { + userStory.tasks.forEach((task: any) => { + worksheetData.push([ + userStory.name, + userStory.description, + task.list, + task.acceptance, + ]); + }); + }); + return worksheetData; + } + + copyUserStoryContent(event: Event, userStory: IUserStory) { + event.stopPropagation(); + const userStoryContent = `${userStory.id}: ${userStory.name}\n${userStory.description || ''}`; + this.clipboard.copy(userStoryContent); + this.toast.showSuccess( + TOASTER_MESSAGES.ENTITY.COPY.SUCCESS(this.entityType, userStory.id), + ); + } + + copyToClipboard() { + this.clipboard.copy(JSON.stringify(this.jsonOutput)); + } + + exportToExcel() { + this.exportService.exportToExcel( + this.exportData, + `${this.navigation.data.name}_${this.navigation.fileName.split('-')[0]}`, + ); + } + + exportToCSV() { + this.exportService.exportToCsv( + this.exportData, + `${this.navigation.data.name}_${this.navigation.fileName.split('-')[0]}`, + ); + } + + addMoreContext(regenerate: boolean = false) { + const dialogText = { + title: 'Generate User Story', + description: 'Include additional context to generate relevant user story', + placeholder: 'Add additional context for the user story', + }; + + const dialogRef = this.dialog.open(ModalDialogCustomComponent, { + width: '600px', + data: dialogText, + }); + + dialogRef.componentInstance.generate.subscribe((emittedValue) => { + this.generateUserStories(regenerate, emittedValue); + }); + } + + syncRequirementWithJira(): void { + const { token, tokenExpiration, jiraURL, refreshToken } = getJiraTokenInfo( + this.navigation.projectId, + ); + const isJiraTokenValid = + token && + tokenExpiration && + new Date() < new Date(tokenExpiration) && + this.isTokenAvailable; + + if (isJiraTokenValid) { + console.log('Token exists and is valid, making API call', token); + this.syncJira(token as string, jiraURL as string); + } else if (refreshToken) { + this.electronService + .refreshJiraToken(refreshToken) + .then((authResponse) => { + storeJiraToken( + authResponse, + this.metadata?.integration?.jira?.jiraProjectKey, + this.navigation.projectId, + ); + console.debug( + 'Token refreshed, making API call', + authResponse.accessToken, + ); + this.syncJira(authResponse.accessToken, jiraURL as string); + }) + .catch((error) => { + console.error('Error during token refresh:', error); + this.promptReauthentication(); + }); + } else { + this.promptReauthentication(); + } + } + + promptReauthentication(): void { + const jiraIntegration = this.metadata?.integration?.jira; + + if (!jiraIntegration) { + this.openConfirmationDialog( + CONFIRMATION_DIALOG.JIRA_DETAILS_MISSING, + () => this.navigateToAppIntegrations(), + ); + return; + } + + this.openConfirmationDialog(CONFIRMATION_DIALOG.JIRA_REAUTHENTICATION, () => + this.handleJiraOAuth(jiraIntegration), + ); + } + + private handleJiraOAuth(jiraIntegration: any): void { + const { clientId, clientSecret, redirectUrl } = jiraIntegration; + + const oauthParams = { clientId, clientSecret, redirectUri: redirectUrl }; + this.electronService + .startJiraOAuth(oauthParams) + .then((authResponse) => { + storeJiraToken( + authResponse, + jiraIntegration.jiraProjectKey, + this.navigation.projectId, + ); + console.debug('Token received and stored.', authResponse.accessToken); + this.toast.showSuccess(APP_INTEGRATIONS.JIRA.SUCCESS); + }) + .catch((error) => { + console.error('Error during OAuth process:', error); + this.toast.showError(APP_INTEGRATIONS.JIRA.ERROR); + }); + } + + private openConfirmationDialog( + dialogConfig: any, + onConfirm: () => void, + ): void { + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + width: '500px', + data: { + title: dialogConfig.TITLE, + description: dialogConfig.DESCRIPTION, + cancelButtonText: dialogConfig.CANCEL_BUTTON_TEXT, + proceedButtonText: dialogConfig.PROCEED_BUTTON_TEXT, + }, + }); + + dialogRef.afterClosed().subscribe((res) => { + if (!res) onConfirm(); + }); + } + + syncJira(token: string, jiraUrl: string): void { + const requestPayload: any = { + epicName: '', + epicDescription: '', + epicTicketId: '', + jiraUrl: jiraUrl, + token: token, + projectKey: this.metadata.integration.jira.jiraProjectKey, + features: [], + }; + + this.store.dispatch( + new ReadFile(`${this.navigation.folderName}/${this.navigation.fileName}`), + ); + + + requestPayload.epicName = this.requirementFile.title; + requestPayload.epicDescription = this.requirementFile.requirement; + requestPayload.epicTicketId = this.requirementFile.epicTicketId + ? this.requirementFile.epicTicketId + : ''; + + this.userStories = this.userStoriesInState; + + requestPayload.features = this.userStories.map((story) => { + return { + id: story.id, + name: story.name, + description: story.description, + storyTicketId: story.storyTicketId ? story.storyTicketId : '', + tasks: story?.tasks?.map((task) => { + return { + list: task.list, + acceptance: task.acceptance, + id: task.id, + subTaskTicketId: task.subTaskTicketId ? task.subTaskTicketId : '', + }; + }), + }; + }); + + this.jiraService.createOrUpdateTickets(requestPayload).subscribe({ + next: (response) => { + console.debug('Jira API Response:', response); + + const matchedEpic = response.epicName === this.requirementFile.title; + + if (matchedEpic) { + this.requirementFile.epicTicketId = response.epicTicketId; + } + + const updatedFeatures = this.userStories.map((existingFeature: any) => { + const matchedFeature = response.features.find( + (responseFeature: any) => + responseFeature.storyName === existingFeature.name, + ); + + if (matchedFeature) { + existingFeature.storyTicketId = matchedFeature.storyTicketId; + existingFeature.tasks.forEach((existingTask: any) => { + const matchedTask = matchedFeature.tasks.find( + (responseTask: any) => + responseTask.subTaskName === existingTask.list, + ); + + if (matchedTask) { + existingTask.subTaskTicketId = matchedTask.subTaskTicketId; + } + }); + } + + return existingFeature; + }); + + this.store.dispatch( + new UpdateFile( + `${this.navigation.folderName}/${this.navigation.fileName}`, + this.requirementFile, + ), + ); + + this.store.dispatch( + new EditUserStory( + `${this.navigation.folderName}/${this.navigation.fileName.replace(/\-base.json$/, '-feature.json')}`, + updatedFeatures, + ), + ); + this.toast.showSuccess(JIRA_TOAST.SUCCESS); + }, + error: (error) => { + console.error('Error updating feature.json:', error); + }, + }); + } +} diff --git a/ui/src/app/pipes/expand-description.pipe.ts b/ui/src/app/pipes/expand-description.pipe.ts new file mode 100644 index 0000000..4127f9f --- /dev/null +++ b/ui/src/app/pipes/expand-description.pipe.ts @@ -0,0 +1,17 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { FileTypeEnum } from '../model/enum/file-type.enum'; + +@Pipe({ + name: 'expandDescription', + standalone: true, +}) +export class ExpandDescriptionPipe implements PipeTransform { + constructor() {} + + transform(input: string | undefined): string | null { + const enumKey = Object.keys(FileTypeEnum).find( + (key) => key === input?.toUpperCase().replace(/\s+/g, ''), + ); + return enumKey ? FileTypeEnum[enumKey as keyof typeof FileTypeEnum] : null; + } +} diff --git a/ui/src/app/pipes/expand-requirement.pipe.ts b/ui/src/app/pipes/expand-requirement.pipe.ts new file mode 100644 index 0000000..97ff5fe --- /dev/null +++ b/ui/src/app/pipes/expand-requirement.pipe.ts @@ -0,0 +1,14 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { UtilityService } from '../services/utility.service'; + +@Pipe({ + name: 'expandRequirementName', + standalone: true, +}) +export class ExpandRequirementNamePipe implements PipeTransform { + constructor(private utilityService: UtilityService) {} + + transform(name: string): string { + return this.utilityService.expandRequirementName(name); + } +} diff --git a/ui/src/app/pipes/timezone-pipe.ts b/ui/src/app/pipes/timezone-pipe.ts new file mode 100644 index 0000000..279d25b --- /dev/null +++ b/ui/src/app/pipes/timezone-pipe.ts @@ -0,0 +1,56 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { TimeZoneEnum } from '../model/enum/timezone.enum'; + +@Pipe({ + name: 'timezone', + standalone: true, +}) +export class TimeZonePipe implements PipeTransform { + constructor() {} + + transform(dateString: string | undefined): string { + if (!dateString) { + return ''; + } + return this.formatDateTime(dateString); + } + + /** + * Method to format date time in MM/DD/YYYY HH:MM TZ + * @param dateString + * @returns + */ + formatDateTime(dateString: string): string { + const date = new Date(dateString); + // Format the date in MM/DD/YYYY + const formattedDate = `${(date.getMonth() + 1).toString().padStart(2, '0')}/${date.getDate().toString().padStart(2, '0')}/${date.getFullYear()}`; + // Format the time in HH:MM + const formattedTime = `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`; + return formattedDate + ' ' + formattedTime + ' ' + this.getTimeZone(); + } + + /** + * Get user's current time zone. + * @returns + */ + getTimeZone(): string { + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const date = new Date(); + const timeZoneOffset = -date.getTimezoneOffset() / 60; // offset in hours + + switch (timeZoneOffset) { + case 5.5: + return TimeZoneEnum.IST; + case 5.0: + return TimeZoneEnum.ET; + case 6.0: + return TimeZoneEnum.CT; + case 7.0: + return TimeZoneEnum.MT; + case 8.0: + return TimeZoneEnum.PT; + default: + return timeZone; // Fallback to the full time zone name if not a common U.S. time zone + } + } +} diff --git a/ui/src/app/pipes/truncate-ellipsis-pipe.ts b/ui/src/app/pipes/truncate-ellipsis-pipe.ts new file mode 100644 index 0000000..1ae66ec --- /dev/null +++ b/ui/src/app/pipes/truncate-ellipsis-pipe.ts @@ -0,0 +1,19 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'truncateWithEllipsis', + standalone: true, +}) +export class TruncateEllipsisPipe implements PipeTransform { + constructor() {} + + transform(text: string | undefined, maxLength: number = 180): string | null { + if (!text) { + return null; + } + if (text.length > maxLength) { + return text.substring(0, maxLength) + '...'; + } + return text; + } +} diff --git a/ui/src/app/resolvers/folder-name.resolver.ts b/ui/src/app/resolvers/folder-name.resolver.ts new file mode 100644 index 0000000..e7931f1 --- /dev/null +++ b/ui/src/app/resolvers/folder-name.resolver.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@angular/core'; +import { Resolve, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; + +@Injectable({ + providedIn: 'root' +}) +export class FolderNameResolver implements Resolve<string> { + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): string { + return route.paramMap.get('folderName') || 'brd'; + } +} diff --git a/ui/src/app/services/alert.service.ts b/ui/src/app/services/alert.service.ts new file mode 100644 index 0000000..e599f41 --- /dev/null +++ b/ui/src/app/services/alert.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; + +@Injectable({ + providedIn: 'root', +}) +export class AlertService { + private alertMessageSource = new BehaviorSubject<string>(''); + private alertVisibilitySource = new BehaviorSubject<boolean>(false); + + currentMessage = this.alertMessageSource.asObservable(); + currentVisibility = this.alertVisibilitySource.asObservable(); + + showAlert(message: string, duration: number = 5000) { + this.alertMessageSource.next(message); + this.alertVisibilitySource.next(true); + // setTimeout(() => { + // this.hideAlert(); + // }, duration); + } + + hideAlert() { + this.alertVisibilitySource.next(false); + } +} diff --git a/ui/src/app/services/app-system/app-system.service.spec.ts b/ui/src/app/services/app-system/app-system.service.spec.ts new file mode 100644 index 0000000..16af730 --- /dev/null +++ b/ui/src/app/services/app-system/app-system.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { AppSystemService } from './app-system.service'; + +describe('AppSystemService', () => { + let service: AppSystemService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(AppSystemService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/ui/src/app/services/app-system/app-system.service.ts b/ui/src/app/services/app-system/app-system.service.ts new file mode 100644 index 0000000..beb6806 --- /dev/null +++ b/ui/src/app/services/app-system/app-system.service.ts @@ -0,0 +1,108 @@ +import { inject, Injectable } from '@angular/core'; +import { APP_CONSTANTS } from '../../constants/app.constants'; +import { ElectronService } from '../electron/electron.service'; +import { NGXLogger } from 'ngx-logger'; +import { IProject } from '../../model/interfaces/projects.interface'; + +@Injectable({ + providedIn: 'root', +}) +export class AppSystemService { + electronService = inject(ElectronService); + logger = inject(NGXLogger); + + async getProjectList(): Promise<Array<IProject>> { + const directory = localStorage.getItem(APP_CONSTANTS.WORKING_DIR); + return this.electronService.invokeFunction('readDirectoryMetadata', { + path: directory, + }); + } + + async createProject(metadata: any, projectName: string) { + const directory = localStorage.getItem(APP_CONSTANTS.WORKING_DIR); + await this.electronService.invokeFunction('createDirectoryWithMetadata', { + metadata, + path: `${directory}/${projectName}`, + }); + } + + async createEmptyFile(relativePathWithFileName: string) { + const directory = localStorage.getItem(APP_CONSTANTS.WORKING_DIR); + await this.electronService.invokeFunction('createEmptyFile', { + path: `${directory}/${relativePathWithFileName}`, + }); + } + + async createFileWithContent(relativePathWithFileName: string, content: any) { + const directory = localStorage.getItem(APP_CONSTANTS.WORKING_DIR); + await this.electronService.invokeFunction('createFileWithContent', { + content, + path: `${directory}/${relativePathWithFileName}`, + }); + } + + async createDirectory(relativePath: string) { + const directory = localStorage.getItem(APP_CONSTANTS.WORKING_DIR); + await this.electronService.invokeFunction('createRequestedDirectory', { + path: `${directory}/${relativePath}`, + }); + } + + async archiveFile(relativePath: string) { + const directory = localStorage.getItem(APP_CONSTANTS.WORKING_DIR); + await this.electronService.invokeFunction('archiveFile', { + path: `${directory}/${relativePath}`, + }); + } + + async createNewFile( + relativePathWithFileName: string, + content: any, + featureFile: string, + ) { + const directory = localStorage.getItem(APP_CONSTANTS.WORKING_DIR); + await this.electronService.invokeFunction('appendFile', { + content, + path: `${directory}/${relativePathWithFileName}`, + featureFile, + }); + } + + async getFolders(relativePath: string, filterString: string) { + const directory = localStorage.getItem(APP_CONSTANTS.WORKING_DIR); + return this.electronService.invokeFunction('getDirectoryList', { + path: `${directory}/${relativePath}`, + constructTree: true, + filterString, + }); + } + + async readFile(relativePath: string) { + const directory = localStorage.getItem(APP_CONSTANTS.WORKING_DIR); + return this.electronService.invokeFunction('readFromFile', { + path: `${directory}/${relativePath}`, + }); + } + + async readPortionOfFile(relativePath: string, filterString: string) { + const directory = localStorage.getItem(APP_CONSTANTS.WORKING_DIR); + return this.electronService.invokeFunction('readFileChunk', { + path: `${directory}/${relativePath}`, + filterString, + }); + } + + async fileExists(relativePath: string) { + const directory = localStorage.getItem(APP_CONSTANTS.WORKING_DIR); + return this.electronService.invokeFunction('fileExists', { + path: `${directory}/${relativePath}`, + }); + } + + async readMetadata(path: string) { + const directory = localStorage.getItem(APP_CONSTANTS.WORKING_DIR); + return this.electronService.invokeFunction('readMetadataFile', { + path: `${directory}/${path}`, + }); + } +} diff --git a/ui/src/app/services/auth/auth-state.service.ts b/ui/src/app/services/auth/auth-state.service.ts new file mode 100644 index 0000000..c580ea6 --- /dev/null +++ b/ui/src/app/services/auth/auth-state.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; +import { BehaviorSubject } from 'rxjs'; +import { ToasterService } from '../toaster/toaster.service'; +import { APP_CONSTANTS } from '../../constants/app.constants'; + +@Injectable({ + providedIn: 'root', +}) +export class AuthStateService { + private loggedInSubject = new BehaviorSubject<boolean>(false); + public isLoggedIn$ = this.loggedInSubject.asObservable(); + + constructor( + private router: Router, + private toast: ToasterService, + ) { + this.checkInitialAuthState(); + } + + private checkInitialAuthState(): void { + const hasCredentials = this.isAuthenticated(); + this.setIsLoggedIn(hasCredentials); + } + + public isAuthenticated(): boolean { + const encodedPasscode = localStorage.getItem(APP_CONSTANTS.APP_PASSCODE_KEY); + const appUrl = localStorage.getItem(APP_CONSTANTS.APP_URL); + return !!encodedPasscode && !!appUrl; + } + + setIsLoggedIn(isLoggedIn: boolean) { + this.loggedInSubject.next(isLoggedIn); + } + + logout(errorMessage?: string) { + localStorage.removeItem(APP_CONSTANTS.APP_PASSCODE_KEY); + localStorage.removeItem(APP_CONSTANTS.APP_URL); + this.setIsLoggedIn(false); + this.router.navigate(['/login']).then(() => { + if (errorMessage) { + this.toast.showError(errorMessage); + } + }); + } +} diff --git a/ui/src/app/services/auth/auth.service.ts b/ui/src/app/services/auth/auth.service.ts new file mode 100644 index 0000000..1362d46 --- /dev/null +++ b/ui/src/app/services/auth/auth.service.ts @@ -0,0 +1,100 @@ +import { Injectable } from '@angular/core'; +import { Router, ActivatedRoute } from '@angular/router'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { AuthStateService } from './auth-state.service'; +import { APP_CONSTANTS } from '../../constants/app.constants'; + +@Injectable({ + providedIn: 'root', +}) +export class AuthService { + constructor( + private router: Router, + private route: ActivatedRoute, + private http: HttpClient, + private authState: AuthStateService + ) { + this.verifyTokenOnInit(); + } + + private verifyTokenOnInit(): void { + if (this.authState.isAuthenticated()) { + const encodedPasscode = localStorage.getItem(APP_CONSTANTS.APP_PASSCODE_KEY)!; + const appUrl = localStorage.getItem(APP_CONSTANTS.APP_URL)!; + + this.verifyAccessToken(encodedPasscode, appUrl).subscribe({ + next: (response) => { + if (response.valid) { + // Get return URL from query params or default to '/apps' + const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/apps'; + this.router.navigateByUrl(returnUrl); + } else { + this.authState.logout('Invalid token. Please log in again.'); + } + }, + error: () => { + this.authState.logout('Error verifying token. Please log in again.'); + }, + }); + } + } + + public encodeAccessCode(accessCode: string): string { + return btoa(accessCode); + } + + private verifyAccessToken(encodedPasscode: string, appUrl: string): Observable<{ valid: boolean }> { + return this.http.post<{ valid: boolean }>(`auth/verify_access_token`, { + accessToken: encodedPasscode, + appUrl: appUrl, + }); + } + + public verifyProviderConfig(provider: string, model: string): Observable<any> { + return this.http.post('model/config-verification', { provider, model }); + } + + login(data: { + passcode: string; + appUrl: string; + }): Observable<{ valid: boolean }> { + const encodedPasscode = this.encodeAccessCode(data.passcode); + return this.http.post<{ + valid: boolean; + }>(`auth/verify_access_token`, { + accessToken: encodedPasscode, + appUrl: data.appUrl, + }).pipe( + tap(response => { + if (response.valid) { + this.authState.setIsLoggedIn(true); + // Get return URL from query params or default to '/apps' + const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/apps'; + this.router.navigateByUrl(returnUrl); + } + }) + ); + } + + // Expose auth state observables + get isLoggedIn$() { + return this.authState.isLoggedIn$; + } + + // Delegate authentication check to state service + isAuthenticated(): boolean { + return this.authState.isAuthenticated(); + } + + // Delegate logout to state service + logout(errorMessage?: string) { + this.authState.logout(errorMessage); + } + + // Add setIsLoggedIn method to match login component usage + setIsLoggedIn(isLoggedIn: boolean) { + this.authState.setIsLoggedIn(isLoggedIn); + } +} diff --git a/ui/src/app/services/chat/chat.service.spec.ts b/ui/src/app/services/chat/chat.service.spec.ts new file mode 100644 index 0000000..4d8abdf --- /dev/null +++ b/ui/src/app/services/chat/chat.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { ChatService } from './chat.service'; + +describe('ChatService', () => { + let service: ChatService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ChatService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/ui/src/app/services/chat/chat.service.ts b/ui/src/app/services/chat/chat.service.ts new file mode 100644 index 0000000..c19ac60 --- /dev/null +++ b/ui/src/app/services/chat/chat.service.ts @@ -0,0 +1,45 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { + suggestionPayload, + conversePayload, +} from '../../model/interfaces/chat.interface'; +import { CHAT_TYPES } from '../../constants/app.constants'; + +@Injectable({ + providedIn: 'root', +}) +export class ChatService { + GET_SUGGESTIONS_URL: string = `chat/get_suggestions`; + CONVERSATION_URL: string = `chat/update_requirement`; + CONVERSATION_USER_STORY_URL: string = `chat/update_user_story_task`; + + constructor(private http: HttpClient) {} + + generateSuggestions(request: suggestionPayload): Observable<any> { + // Skip default loader for chat suggestions. + const headers = new HttpHeaders({ + skipLoader: 'true', + }); + return this.http.post(this.GET_SUGGESTIONS_URL, request, { + headers, + }); + } + + chatWithLLM(type: string, request: conversePayload): Observable<any> { + // Skip default loader for chat with LLM call. + const headers = new HttpHeaders({ + skipLoader: 'true', + }); + if (type === CHAT_TYPES.REQUIREMENT) { + return this.http.post(this.CONVERSATION_URL, request, { + headers, + }); + } else { + return this.http.post(this.CONVERSATION_USER_STORY_URL, request, { + headers, + }); + } + } +} diff --git a/ui/src/app/services/electron/electron.service.spec.ts b/ui/src/app/services/electron/electron.service.spec.ts new file mode 100644 index 0000000..534229c --- /dev/null +++ b/ui/src/app/services/electron/electron.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { ElectronService } from './electron.service'; + +describe('ElectronService', () => { + let service: ElectronService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ElectronService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/ui/src/app/services/electron/electron.service.ts b/ui/src/app/services/electron/electron.service.ts new file mode 100644 index 0000000..0d97814 --- /dev/null +++ b/ui/src/app/services/electron/electron.service.ts @@ -0,0 +1,200 @@ +import { Injectable } from '@angular/core'; +import { NGXLogger } from 'ngx-logger'; +import { IpcRendererEvent } from 'electron'; +import { ToasterService } from '../toaster/toaster.service'; +import { Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; +import { WarningRootModalComponent } from '../../components/warning-root-modal/warning-root-modal.component'; + +@Injectable({ + providedIn: 'root', +}) +export class ElectronService { + electronAPI: ElectronAPI | undefined; + constructor( + private logger: NGXLogger, + private toast: ToasterService, + private router: Router, + private dialog: MatDialog, + ) { + if (this.isElectron()) { + this.electronAPI = window.electronAPI; // Access Electron APIs through preload + } + } + + isElectron(): boolean { + return !!window.electronAPI; + } + + async startJiraOAuth(oauthParams: { + clientId: string; + clientSecret: string; + redirectUri: string; + }): Promise<{ + accessToken: string; + refreshToken: string; + expirationDate: string; + tokenType: string; + cloudId: string; + }> { + if (this.electronAPI) { + return new Promise((resolve, reject) => { + this.electronAPI?.send('start-jira-oauth', oauthParams); + + this.electronAPI?.once( + 'oauth-reply', + ( + _: IpcRendererEvent, + authResponse: { + accessToken: string; + refreshToken: string; + expirationDate: string; + tokenType: string; + cloudId: string; + }, + ) => { + if (authResponse.accessToken) { + resolve(authResponse); + } else { + reject('OAuth process failed. No token received.'); + } + }, + ); + + this.electronAPI?.once('port-error', (_: any, message: any) => { + console.error('Port Error: ', message.message); + this.toast.showError(message); + }); + }); + } else { + throw new Error('Electron is not available'); + } + } + + async listenPort(): Promise<void> { + if (this.electronAPI) { + if (sessionStorage.getItem('serverActive') === 'true') { + console.debug('Server is already running.'); + } else { + this.electronAPI.send('start-server'); + + this.electronAPI.on('port-error', (_: any, message: string) => { + console.error('Port Error: ', message); + this.toast.showError(`Port Error: ${message}`); + }); + this.electronAPI.on('server-started', () => { + sessionStorage.setItem('serverActive', 'true'); + }); + } + } + } + + async refreshJiraToken(refreshToken: string): Promise<{ + accessToken: string; + refreshToken: string; + expirationDate: string; + tokenType: string; + cloudId: string; + }> { + if (this.electronAPI) { + return new Promise((resolve, reject) => { + this.electronAPI?.send('refresh-jira-token', { refreshToken }); + + this.electronAPI?.once( + 'oauth-reply', + ( + _: IpcRendererEvent, + authResponse: { + accessToken: string; + refreshToken: string; + expirationDate: string; + tokenType: string; + cloudId: string; + }, + ) => { + if (authResponse.accessToken) { + resolve(authResponse); + } else { + reject('Token refresh process failed. No token received.'); + } + }, + ); + }); + } else { + throw new Error('Electron is not available'); + } + } + + async openDirectory(): Promise<Array<string>> { + if (this.electronAPI) { + return await this.electronAPI.openDirectory(); + } + return []; + } + + async invokeFunction(functionName: string, params: any): Promise<any> { + if (this.electronAPI) { + this.logger.debug(params); + return await this.electronAPI.invoke('invokeCustomFunction', { + functionName, + params: { ...params }, + }); + } + } + + async reloadApp() { + if (this.electronAPI) { + return await this.electronAPI.invoke('reloadApp', {}); + } + } + + async getStoreValue(key: string): Promise<any> { + if (this.electronAPI) { + return await this.electronAPI.getStoreValue(key); + } + return null; + } + + async setStoreValue(key: string, value: any): Promise<void> { + if (this.electronAPI) { + await this.electronAPI.setStoreValue(key, value); + } + } + + async removeStoreValue(key: string): Promise<void> { + if (this.electronAPI) { + await this.electronAPI.invoke('removeStoreValue', key); + } + } +} + +// Define the ElectronAPI interface within this file +interface ElectronAPI { + openFile: () => Promise<string[]>; + saveFile: (fileContent: any, filePath: string) => Promise<void>; + openDirectory: () => Promise<string[]>; + getStoreValue: (key: string) => Promise<any>; + setStoreValue: (key: string, value: any) => Promise<void>; + getThemeConfiguration: () => Promise<any>; + loadURL: (serverConfig: any) => void; + invoke: (channel: string, ...args: any[]) => Promise<any>; + send: (channel: string, ...args: any[]) => void; + on: ( + channel: string, + listener: (event: IpcRendererEvent, ...args: any[]) => void, + ) => void; + once: ( + channel: string, + listener: (event: IpcRendererEvent, ...args: any[]) => void, + ) => void; + removeListener: (channel: string, listener: (...args: any[]) => void) => void; + getStyleUrl: () => string; + reloadApp: () => void; +} + +// Extend the global Window interface to include electronAPI +declare global { + interface Window { + electronAPI: ElectronAPI; + } +} diff --git a/ui/src/app/services/export.service.ts b/ui/src/app/services/export.service.ts new file mode 100644 index 0000000..b05ff59 --- /dev/null +++ b/ui/src/app/services/export.service.ts @@ -0,0 +1,58 @@ +import { Injectable, inject } from '@angular/core'; +import * as XLSX from 'xlsx'; +import { saveAs } from 'file-saver'; +import { NGXLogger } from 'ngx-logger'; + +const EXCEL_TYPE = + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8'; +const CSV_TYPE = 'text/csv;charset=utf-8;'; + +@Injectable({ + providedIn: 'root', +}) +export class ExportService { + logger = inject(NGXLogger); + + public exportToExcel(data: Array<[]> = [], fileName: string): void { + const worksheet: XLSX.WorkSheet = XLSX.utils.aoa_to_sheet(data); + const workbook: XLSX.WorkBook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet); + + const excelBuffer: any = XLSX.write(workbook, { + bookType: 'xlsx', + type: 'array', + }); + this.saveAsFile(excelBuffer, fileName, EXCEL_TYPE, '.xlsx'); + } + + public exportToCsv(data: Array<[]> = [], fileName: string): void { + this.logger.debug('data', data); + const csvData = this.convertToCsv(data); + this.logger.debug('csv data', csvData); + this.saveAsFile(csvData, fileName, CSV_TYPE, '.csv'); + } + + private convertToCsv(data: Array<[]> = []): string { + return data + .map((row) => row.map(String).map(this.escapeCsvValue).join(',')) + .join('\n'); + } + + private escapeCsvValue(value: string): string { + if (value.includes(',') || value.includes('\n') || value.includes('"')) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; + } + + private saveAsFile( + buffer: any, + fileName: string, + fileType: string, + extension: string, + ): void { + const data: Blob = new Blob([buffer], { type: fileType }); + const savingFileName = `${fileName}_export_${new Date().getTime()}${extension}`; + saveAs(data, savingFileName); + } +} diff --git a/ui/src/app/services/feature/feature.service.spec.ts b/ui/src/app/services/feature/feature.service.spec.ts new file mode 100644 index 0000000..111def4 --- /dev/null +++ b/ui/src/app/services/feature/feature.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { FeatureService } from './feature.service'; + +describe('FeatureService', () => { + let service: FeatureService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(FeatureService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/ui/src/app/services/feature/feature.service.ts b/ui/src/app/services/feature/feature.service.ts new file mode 100644 index 0000000..96cb964 --- /dev/null +++ b/ui/src/app/services/feature/feature.service.ts @@ -0,0 +1,177 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { + IUpdateUserStoryRequest, + IUserStoriesRequest, + IUserStory, + IUserStoryResponse, +} from '../../model/interfaces/IUserStory'; +import { + IAddRequirementRequest, + IUpdateRequirementRequest, +} from '../../model/interfaces/IRequirement'; +import { map } from 'rxjs/operators'; +import { + IAddTaskRequest, + IAddTaskResponse, + IEditTaskResponse, + ITask, + ITaskRequest, + ITasksResponse, +} from '../../model/interfaces/ITask'; +import { + IAddBusinessProcessRequest, + IAddBusinessProcessResponse, + IFlowChartRequest, + IUpdateProcessRequest, + IUpdateProcessResponse, +} from '../../model/interfaces/IBusinessProcess'; + +@Injectable({ + providedIn: 'root', +}) +export class FeatureService { + GENERATE_TASK_URL: string = `solutions/task`; + GENERATE_USER_STORIES_URL: string = `solutions/stories`; + ADD_USER_STORY: string = `solutions/story/add`; + UPDATE_USER_STORY: string = `solutions/story/update`; + ADD_TASK: string = `solutions/task/add`; + UPDATE_TASK: string = `solutions/task/update`; + UPDATE_REQUIREMENT: string = `solutions/update`; + ADD_REQUIREMENT: string = `solutions/add`; + ADD_BUSINESS_PROCESS: string = `solutions/business_process/add`; + UPDATE_BUSINESS_PROCESS: string = `solutions/business_process/update`; + ADD_FLOW_CHART: string = `solutions/flowchart`; + VALIDATE_BEDROCK_ID: string = `solutions/integration/knowledgebase/validation`; + + constructor(private http: HttpClient) {} + + generateUserStories(request: IUserStoriesRequest): Observable<IUserStory[]> { + const headers = new HttpHeaders({ + skipLoader: 'true', + }); + return this.http + .post<IUserStoryResponse>(this.GENERATE_USER_STORIES_URL, request, { + headers, + }) + .pipe( + map((response: IUserStoryResponse) => + this.parseUserStoryResponse(response), + ), + ); + } + + addBusinessProcess( + request: IAddBusinessProcessRequest, + ): Observable<IAddBusinessProcessResponse> { + return this.http.post<IAddBusinessProcessResponse>( + this.ADD_BUSINESS_PROCESS, + request, + ); + } + + updateBusinessProcess( + request: IUpdateProcessRequest, + ): Observable<IUpdateProcessResponse> { + const headers = new HttpHeaders({ + skipLoader: 'true', + }); + return this.http.put<IUpdateProcessResponse>( + this.UPDATE_BUSINESS_PROCESS, + request, + { headers }, + ); + } + + addFlowChart(request: IFlowChartRequest): Observable<string> { + return this.http.post<string>(this.ADD_FLOW_CHART, request); + } + + updateRequirement( + request: IUpdateRequirementRequest, + ): Observable<IEditTaskResponse> { + return this.http.post<any>(this.UPDATE_REQUIREMENT, request); + } + + addRequirement( + request: IAddRequirementRequest, + ): Observable<IAddTaskResponse> { + return this.http.post<any>(this.ADD_REQUIREMENT, request); + } + + generateTask(request: ITaskRequest): Observable<ITasksResponse> { + const headers = new HttpHeaders({ + skipLoader: 'true', + }); + return this.http.post<ITasksResponse>(this.GENERATE_TASK_URL, request, { + headers, + }); + } + + addUserStory( + request: IUpdateUserStoryRequest, + ): Observable<IUserStoryResponse> { + return this.http.post<IUserStoryResponse>(this.ADD_USER_STORY, request); + } + + updateUserStory( + request: IUpdateUserStoryRequest, + ): Observable<IUserStoryResponse> { + return this.http.put<IUserStoryResponse>(this.UPDATE_USER_STORY, request); + } + + addTask(request: IAddTaskRequest): Observable<ITasksResponse> { + return this.http.post<ITasksResponse>(this.ADD_TASK, request); + } + + updateTask(request: IAddTaskRequest): Observable<ITasksResponse> { + return this.http.put<ITasksResponse>(this.UPDATE_TASK, request); + } + + validateBedrockId(bedrockId: string): Observable<boolean> { + return this.http + .post<{ isValid: boolean }>(this.VALIDATE_BEDROCK_ID, { bedrockId }) + .pipe(map((response) => response.isValid)); + } + + parseTaskResponse(response: ITasksResponse | undefined): ITask[] { + const tasksArray: ITask[] = []; + if (!response) { + return tasksArray; + } + response.tasks.forEach((feature: any) => { + const id = feature.id; + for (const [list, acceptance] of Object.entries(feature)) { + if (list !== 'id') { + tasksArray.push({ list, acceptance: acceptance as string, id }); + } + } + }); + return tasksArray; + } + + parseUserStoryResponse( + response: IUserStoryResponse, + existingUserStories?: IUserStory[], + ): IUserStory[] { + const userStoriesArray: IUserStory[] = []; + response.features.forEach((feature: any) => { + const id = feature.id; + const tasks: ITask[] = existingUserStories + ? existingUserStories.find((us) => us.id === id)?.tasks || [] + : []; + for (const [name, description] of Object.entries(feature)) { + if (name !== 'id') { + userStoriesArray.push({ + id, + name, + description: description as string, + tasks, + }); + } + } + }); + return userStoriesArray; + } +} diff --git a/ui/src/app/services/loading.service.ts b/ui/src/app/services/loading.service.ts new file mode 100644 index 0000000..faa957c --- /dev/null +++ b/ui/src/app/services/loading.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class LoadingService { + private loadingSubject = new BehaviorSubject<boolean>(false); + loading$ = this.loadingSubject.asObservable(); + + setLoading(loading: boolean) { + this.loadingSubject.next(loading); + } +} diff --git a/ui/src/app/services/search/search.service.ts b/ui/src/app/services/search/search.service.ts new file mode 100644 index 0000000..6412d85 --- /dev/null +++ b/ui/src/app/services/search/search.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@angular/core'; +import { Observable, combineLatest } from 'rxjs'; +import { map } from 'rxjs/operators'; + +@Injectable({ + providedIn: 'root', +}) +export class SearchService { + /** + * Generic search function that can be used with any type of data + * @param items$ Observable of items to search through + * @param searchTerm$ Observable of search term + * @param searchFields Array of field names to search in + * @returns Observable of filtered items + */ + filterItems<T>( + items$: Observable<T[]>, + searchTerm$: Observable<string>, + searchFields: (item: T) => (string | undefined)[], + ): Observable<T[]> { + return combineLatest([items$, searchTerm$]).pipe( + map(([items, term]) => { + if (!term) return items; + return items.filter((item) => { + const fields = searchFields(item); + return fields.some((field) => + field?.toLowerCase().includes(term.toLowerCase()), + ); + }); + }), + ); + } +} diff --git a/ui/src/app/services/solution-service/solution-service.service.spec.ts b/ui/src/app/services/solution-service/solution-service.service.spec.ts new file mode 100644 index 0000000..123f8ad --- /dev/null +++ b/ui/src/app/services/solution-service/solution-service.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { SolutionServiceService } from './solution-service.service'; + +describe('SolutionServiceService', () => { + let service: SolutionServiceService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(SolutionServiceService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/ui/src/app/services/solution-service/solution-service.service.ts b/ui/src/app/services/solution-service/solution-service.service.ts new file mode 100644 index 0000000..23156d1 --- /dev/null +++ b/ui/src/app/services/solution-service/solution-service.service.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { ISolutionResponse } from '../../model/interfaces/projects.interface'; +import { Observable } from 'rxjs'; +import { environment } from '../../../environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class SolutionService { + constructor(private httpService: HttpClient) {} + + generateDocumentsFromLLM(data: { + createReqt: boolean; + name: string; + description: string; + cleanSolution: boolean; + }): Observable<ISolutionResponse> { + const url = `solutions/create`; + return this.httpService.post<ISolutionResponse>(url, data); + } +} diff --git a/ui/src/app/services/toaster/toaster.service.ts b/ui/src/app/services/toaster/toaster.service.ts new file mode 100644 index 0000000..68644fe --- /dev/null +++ b/ui/src/app/services/toaster/toaster.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@angular/core'; +import { Subject, Observable } from 'rxjs'; + +@Injectable({ + providedIn: 'root', +}) +export class ToasterService { + private toastSubject = new Subject<any>(); + private id = 0; + + constructor() {} + + getToasts(): Observable<any> { + return this.toastSubject.asObservable(); + } + + showToast(type: string, message: string) { + this.id++; // Increment the ID to ensure it's unique for every toast + this.toastSubject.next({ id: this.id, type, message }); + } + + showSuccess(message: string) { + this.showToast('success', message); + } + + showError(message: string) { + this.showToast('error', message); + } + + showInfo(message: string) { + this.showToast('info', message); + } + + showWarning(message: string) { + this.showToast('warning', message); + } +} diff --git a/ui/src/app/services/utility.service.ts b/ui/src/app/services/utility.service.ts new file mode 100644 index 0000000..c9e7cad --- /dev/null +++ b/ui/src/app/services/utility.service.ts @@ -0,0 +1,89 @@ +import { RequirementTypeEnum } from '../model/enum/requirement-type.enum'; + +export class UtilityService { + /** + * Returns the expansion of the requirement type abbreviation. + * @param abbreviation + * @returns + */ + expandRequirementName(abbreviation: string): string { + if (!abbreviation) { + return abbreviation; + } + const match = abbreviation.match(/([A-Z]+)(\d+)/); + if (!match) { + return abbreviation; + } + const [_, prefix, number] = match; + switch (prefix) { + case RequirementTypeEnum.BRD: + return `Business Requirement ${number}`; + case RequirementTypeEnum.PRD: + return `Product Requirement ${number}`; + case RequirementTypeEnum.NFR: + return `Non Functional Requirement ${number}`; + case RequirementTypeEnum.UIR: + return `User Interface Requirement ${number}`; + case RequirementTypeEnum.BP: + return `Business Process ${number}`; + default: + return `${abbreviation}`; + } + } + + getRequirementType(abbreviation: string): string { + const match = abbreviation.match(/([A-Z]+)(\d+)/); + if (!match) { + return abbreviation; + } + const [_, prefix, number] = match; + return prefix; + } + + getProductRequirements( + rd: any, + directContent: boolean = false, + fontWeight: string = 'font-bold', + ): string { + let key = ''; + let fullText = ''; + if (!directContent) { + key = this.getKeys(rd)[0]; + fullText = rd[key]; + } else { + fullText = rd; + } + + let productRequirements = ''; + let screensText = ''; + let personasText = ''; + + if (fullText.includes('Screens:')) { + [productRequirements, fullText] = fullText.split('Screens:'); + } else { + productRequirements = fullText; + } + + if (fullText.includes('Personas:')) { + [screensText, personasText] = fullText.split('Personas:'); + } else { + screensText = fullText; + } + + let details = `${productRequirements.trim()}`; + + if (screensText && screensText.trim() !== productRequirements.trim()) { + details += `<br><br><h3 class="${fontWeight} text-gray-900 hover:text-gray-600">SCREENS</h3>${screensText.trim()}`; + } + + if (personasText && personasText.trim()) { + details += `<br><br><h3 class="${fontWeight} text-gray-900 hover:text-gray-600">PERSONAS</h3>${personasText.trim()}`; + } + + return details; + } + + getKeys(obj: any): string[] { + return Object.keys(obj); + } +} diff --git a/ui/src/app/store/breadcrumb/breadcrumb.actions.ts b/ui/src/app/store/breadcrumb/breadcrumb.actions.ts new file mode 100644 index 0000000..b9d66bd --- /dev/null +++ b/ui/src/app/store/breadcrumb/breadcrumb.actions.ts @@ -0,0 +1,26 @@ +import { IBreadcrumb } from '../../model/interfaces/projects.interface'; + +export class AddBreadcrumb { + static readonly type = '[Breadcrumb] Add item'; + + constructor(readonly payload: IBreadcrumb) {} +} + +export class AddBreadcrumbs { + static readonly type = '[Breadcrumb] Add items'; + + constructor(readonly payload: IBreadcrumb[]) {} +} + +export class SetBreadcrumb { + static readonly type = '[Breadcrumb] Update item'; + + constructor(readonly payload: IBreadcrumb) {} +} + +export class DeleteBreadcrumb { + static readonly type = '[Breadcrumb] Delete item'; + + constructor(readonly payload: string) {} +} + diff --git a/ui/src/app/store/breadcrumb/breadcrumb.state.spec.ts b/ui/src/app/store/breadcrumb/breadcrumb.state.spec.ts new file mode 100644 index 0000000..8addfb0 --- /dev/null +++ b/ui/src/app/store/breadcrumb/breadcrumb.state.spec.ts @@ -0,0 +1,25 @@ +import { TestBed } from '@angular/core/testing'; +import { NgxsModule, Store } from '@ngxs/store'; +import { BreadcrumbState, BreadcrumbStateModel } from './breadcrumb.state'; +import { BreadcrumbAction } from './breadcrumb.actions'; + +describe('Breadcrumb store', () => { + let store: Store; + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [NgxsModule.forRoot([BreadcrumbState])] + }); + + store = TestBed.inject(Store); + }); + + it('should create an action and add an item', () => { + const expected: BreadcrumbStateModel = { + items: ['item-1'] + }; + store.dispatch(new BreadcrumbAction('item-1')); + const actual = store.selectSnapshot(BreadcrumbState.getState); + expect(actual).toEqual(expected); + }); + +}); diff --git a/ui/src/app/store/breadcrumb/breadcrumb.state.ts b/ui/src/app/store/breadcrumb/breadcrumb.state.ts new file mode 100644 index 0000000..9e1e7ab --- /dev/null +++ b/ui/src/app/store/breadcrumb/breadcrumb.state.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@angular/core'; +import { State, Action, Selector, StateContext } from '@ngxs/store'; +import { + AddBreadcrumb, + AddBreadcrumbs, + DeleteBreadcrumb, + SetBreadcrumb, +} from './breadcrumb.actions'; +import { IBreadcrumb } from '../../model/interfaces/projects.interface'; + +export interface BreadcrumbStateModel { + items: IBreadcrumb[]; +} + +@State<BreadcrumbStateModel>({ + name: 'breadcrumb', + defaults: { + items: [], + }, +}) +@Injectable() +export class BreadcrumbState { + @Selector() + static getBreadcrumbs(state: BreadcrumbStateModel) { + return state.items; + } + + @Action(AddBreadcrumb) + addBreadcrumb( + ctx: StateContext<BreadcrumbStateModel>, + { payload }: AddBreadcrumb, + ) { + const state = ctx.getState(); + const breadcrumbExists = state.items.some( + (existingBreadcrumb) => existingBreadcrumb.label === payload.label, + ); + if (!breadcrumbExists) { + ctx.patchState({ + items: [...state.items, payload], + }); + } + } + + @Action(AddBreadcrumbs) + addBreadcrumbs( + ctx: StateContext<BreadcrumbStateModel>, + { payload }: AddBreadcrumbs, + ) { + const state = ctx.getState(); + ctx.patchState({ + items: [...payload], + }); + } + + @Action(SetBreadcrumb) + setBreadcrumb( + ctx: StateContext<BreadcrumbStateModel>, + { payload }: SetBreadcrumb, + ) { + const state = ctx.getState(); + ctx.patchState({ + items: [...state.items, payload], + }); + } + + @Action(DeleteBreadcrumb) + deleteBreadcrumb( + ctx: StateContext<BreadcrumbStateModel>, + { payload }: DeleteBreadcrumb, + ) { + const state = ctx.getState(); + const remainingBreadCrumbs = state.items.filter((breadcrumb) => breadcrumb.label !== payload) + ctx.patchState({ + items: [...remainingBreadCrumbs], + }); + } +} diff --git a/ui/src/app/store/business-process/business-process.actions.ts b/ui/src/app/store/business-process/business-process.actions.ts new file mode 100644 index 0000000..1315840 --- /dev/null +++ b/ui/src/app/store/business-process/business-process.actions.ts @@ -0,0 +1,12 @@ +export class SetFlowChartAction { + static readonly type = '[Business Process] Set flow chart'; + constructor( + public relativePath: string, + public content: string, + ) {} +} + +export class GetFlowChartAction { + static readonly type = '[Business Process] Get flow chart'; + constructor(public relativePath: string) {} +} diff --git a/ui/src/app/store/business-process/business-process.state.ts b/ui/src/app/store/business-process/business-process.state.ts new file mode 100644 index 0000000..f52c228 --- /dev/null +++ b/ui/src/app/store/business-process/business-process.state.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@angular/core'; +import { Action, Selector, State, StateContext } from '@ngxs/store'; +import { + SetFlowChartAction, + GetFlowChartAction, +} from './business-process.actions'; +import { AppSystemService } from '../../services/app-system/app-system.service'; + +export class BusinessProcessStateModel { + selectedFlowChart!: string; +} + +@State<BusinessProcessStateModel>({ + name: 'businessProcess', + defaults: { + selectedFlowChart: '', + }, +}) +@Injectable() +export class BusinessProcessState { + constructor(private appSystemService: AppSystemService) {} + + @Selector() + static getSelectedFlowChart(state: BusinessProcessStateModel) { + return state.selectedFlowChart; + } + + @Action(GetFlowChartAction) + async getFlowChart( + { getState, patchState }: StateContext<BusinessProcessStateModel>, + { relativePath }: GetFlowChartAction, + ) { + const response = await this.appSystemService.readFile(relativePath); + const parsedContent = JSON.parse(response); + const state = getState(); + patchState({ + ...state, + selectedFlowChart: parsedContent.flowChartDiagram, + }); + } + + @Action(SetFlowChartAction) + async setFlowChart( + { getState, patchState }: StateContext<BusinessProcessStateModel>, + { relativePath, content }: SetFlowChartAction, + ) { + const response = await this.appSystemService.readFile(relativePath); + const parsedContent = JSON.parse(response); + parsedContent.flowChartDiagram = content; + await this.appSystemService.createFileWithContent( + relativePath, + JSON.stringify(parsedContent), + ); + const state = getState(); + patchState({ + ...state, + selectedFlowChart: content, + }); + } +} diff --git a/ui/src/app/store/chat-settings/chat-settings.action.ts b/ui/src/app/store/chat-settings/chat-settings.action.ts new file mode 100644 index 0000000..f16f58c --- /dev/null +++ b/ui/src/app/store/chat-settings/chat-settings.action.ts @@ -0,0 +1,6 @@ +import { ChatSettings } from "src/app/model/interfaces/ChatSettings"; + +export class SetChatSettings { + static readonly type = '[SetChatSettings] Set'; + constructor(public payload: ChatSettings) { } +} \ No newline at end of file diff --git a/ui/src/app/store/chat-settings/chat-settings.state.ts b/ui/src/app/store/chat-settings/chat-settings.state.ts new file mode 100644 index 0000000..f59c096 --- /dev/null +++ b/ui/src/app/store/chat-settings/chat-settings.state.ts @@ -0,0 +1,24 @@ +import { State, Action, StateContext, Selector } from '@ngxs/store'; +import { ChatSettings } from 'src/app/model/interfaces/ChatSettings'; +import { SetChatSettings } from './chat-settings.action'; + +@State<ChatSettings>({ + name: 'ChatSettings', + defaults: { + kb: '', + }, +}) +export class ChatSettingsState { + @Selector() + static getConfig(state: ChatSettings) { + return state; + } + + @Action(SetChatSettings) + setChatSettings( + { setState }: StateContext<ChatSettings>, + { payload }: SetChatSettings, + ) { + setState({ ...payload }); + } +} diff --git a/ui/src/app/store/llm-config/llm-config.actions.ts b/ui/src/app/store/llm-config/llm-config.actions.ts new file mode 100644 index 0000000..385c0d8 --- /dev/null +++ b/ui/src/app/store/llm-config/llm-config.actions.ts @@ -0,0 +1,6 @@ +import { LLMConfigModel } from "../..//model/interfaces/ILLMConfig"; + +export class SetLLMConfig { + static readonly type = '[LLMConfig] Set'; + constructor(public payload: LLMConfigModel) { } +} diff --git a/ui/src/app/store/llm-config/llm-config.state.ts b/ui/src/app/store/llm-config/llm-config.state.ts new file mode 100644 index 0000000..f4f1b56 --- /dev/null +++ b/ui/src/app/store/llm-config/llm-config.state.ts @@ -0,0 +1,26 @@ +import { State, Action, StateContext, Selector } from '@ngxs/store'; +import { LLMConfigModel } from "../../model/interfaces/ILLMConfig"; +import { SetLLMConfig } from './llm-config.actions'; +import { DefaultLLMModel, DefaultProvider } from '../../constants/llm.models.constants'; + +@State<LLMConfigModel>({ + name: 'LLMConfig', + defaults: { + apiKey: '', + model: DefaultLLMModel, + provider: DefaultProvider, + apiUrl: '' + } +}) +export class LLMConfigState { + + @Selector() + static getConfig(state: LLMConfigModel) { + return state; + } + + @Action(SetLLMConfig) + setLLMConfig({ setState }: StateContext<LLMConfigModel>, { payload }: SetLLMConfig) { + setState({ ...payload }); + } +} diff --git a/ui/src/app/store/projects/projects.actions.ts b/ui/src/app/store/projects/projects.actions.ts new file mode 100644 index 0000000..764a082 --- /dev/null +++ b/ui/src/app/store/projects/projects.actions.ts @@ -0,0 +1,84 @@ +import { FILTER_STRINGS } from '../../constants/app.constants'; + +export class GetProjectListAction { + static readonly type = '[Projects] Get Project List'; + + constructor() {} +} + +export class CreateProject { + static readonly type = '[Projects] Create Project'; + + constructor( + public projectName: string, + public metadata: any, + ) {} +} + +export class GetProjectFiles { + static readonly type = '[Projects] Get Project Files'; + + constructor( + public projectId: string, + public filterString: string = FILTER_STRINGS.BASE, + ) {} +} + +export class ReadFile { + static readonly type = '[Projects] Read File'; + + constructor(public path: string) {} +} + +export class BulkReadFiles { + static readonly type = '[Projects] Bulk Read Files'; + + constructor( + public key: string, + public filterString: string = FILTER_STRINGS.BASE, + ) {} +} + +export class ArchiveFile { + static readonly type = '[Projects] ArchiveFile File'; + + constructor(public path: string) {} +} + +export class CreateFile { + static readonly type = '[Projects] Create File'; + + constructor( + public path: string, + public content: any, + public featureFile: string = '', + ) {} +} + +export class UpdateFile { + static readonly type = '[Projects] Update File'; + + constructor( + public path: string, + public content: any, + ) {} +} + +export class FileExists { + static readonly type = '[Projects] File Exists'; + + constructor(public path: string) {} +} + +export class UpdateMetadata { + static readonly type = '[Projects] Update Metadata'; + + constructor( + public projectId: string, + public newMetadata: any, + ) {} +} + +export class ClearBRDPRDState { + static readonly type = '[Projects] Clear BRD and PRD State'; +} diff --git a/ui/src/app/store/projects/projects.state.ts b/ui/src/app/store/projects/projects.state.ts new file mode 100644 index 0000000..0f7a364 --- /dev/null +++ b/ui/src/app/store/projects/projects.state.ts @@ -0,0 +1,466 @@ +import { IProject } from '../../model/interfaces/projects.interface'; +import { Injectable } from '@angular/core'; +import { Action, Selector, State, StateContext } from '@ngxs/store'; +import { + BulkReadFiles, + CreateFile, + CreateProject, + GetProjectFiles, + GetProjectListAction, + ReadFile, + UpdateFile, + FileExists, + ArchiveFile, + UpdateMetadata, + ClearBRDPRDState, +} from './projects.actions'; +import { AppSystemService } from '../../services/app-system/app-system.service'; +import { NGXLogger } from 'ngx-logger'; +import { SolutionService } from '../../services/solution-service/solution-service.service'; +import { Router } from '@angular/router'; +import { IList } from '../../model/interfaces/IList'; +import { firstValueFrom } from 'rxjs'; +import { ToasterService } from 'src/app/services/toaster/toaster.service'; + +export class ProjectStateModel { + projects!: IProject[]; + currentProjectFiles!: { name: string; children: string[] }[]; + selectedFileContent!: any; + selectedFileContents!: IList[]; + selectedProject!: string; + metadata!: any; + loadingProjectFiles!: boolean; + fileExists!: boolean; +} + +@State<ProjectStateModel>({ + name: 'projects', + defaults: { + projects: [], + currentProjectFiles: [], + selectedFileContent: {}, + selectedProject: '', + metadata: {}, + selectedFileContents: [], + loadingProjectFiles: false, + fileExists: false, + }, +}) +@Injectable() +export class ProjectsState { + constructor( + private appSystemService: AppSystemService, + private logger: NGXLogger, + private solutionService: SolutionService, + private router: Router, + private toast: ToasterService, + ) {} + + @Selector() + static getProjects(state: ProjectStateModel) { + return state.projects; + } + + @Selector() + static loadingProjectFiles(state: ProjectStateModel) { + return state.loadingProjectFiles; + } + + @Selector() + static getProjectsFolders(state: ProjectStateModel) { + return state.currentProjectFiles; + } + + @Selector() + static getSelectedFileContent(state: ProjectStateModel) { + return state.selectedFileContent; + } + + @Selector() + static getSelectedFileContents(state: ProjectStateModel) { + return state.selectedFileContents; + } + + @Selector() + static getSelectedProject(state: ProjectStateModel) { + return state.selectedProject; + } + + @Selector() + static getFileExists(state: ProjectStateModel) { + return state.fileExists; + } + + @Selector() + static getMetadata(state: ProjectStateModel) { + return state.metadata; + } + + @Action(GetProjectListAction) + async getProjectList({ + getState, + patchState, + }: StateContext<ProjectStateModel>) { + const projectList = await this.appSystemService.getProjectList() || []; + const sortedProjectList = projectList.sort((a, b) => { + return ( + new Date(b.metadata.createdAt).getTime() - + new Date(a.metadata.createdAt).getTime() + ); + }); + const state = getState(); + patchState({ + ...state, + projects: sortedProjectList, + }); + } + + @Action(CreateProject) + async createProject( + { getState, patchState }: StateContext<ProjectStateModel>, + { projectName, metadata }: CreateProject, + ) { + try { + const state = getState(); + const projectExists = await this.appSystemService.fileExists(projectName) + if (projectExists) { + this.toast.showError('Project already exists, please retry with another unique project name'); + return; + } + + const response = await firstValueFrom( + this.solutionService.generateDocumentsFromLLM({ + createReqt: metadata.createReqt, + name: projectName, + description: metadata.description, + cleanSolution: metadata.cleanSolution, + }), + ); + + await this.appSystemService.createProject(metadata, projectName); + + if (response && !metadata.cleanSolution) { + response.brd?.forEach((brd) => + this.generateFiles(brd, projectName, 'BRD'), + ); + response.prd?.forEach((prd) => { + this.generateFiles(prd, projectName, 'PRD'); + this.generatePRDFeatureFiles(projectName, 'PRD', prd['id']); + }); + response.uir?.forEach((uir) => + this.generateFiles(uir, projectName, 'UIR'), + ); + response.nfr?.forEach((nfr) => + this.generateFiles(nfr, projectName, 'NFR'), + ); + } + + let projectList = [ + ...state.projects, + { + project: projectName, + projectKey: metadata.jiraProjectKey, + metadata: { + ...metadata, + }, + }, + ]; + const sortedProjectList = projectList.sort( + (a, b) => + new Date(b.metadata.createdAt).getTime() - + new Date(a.metadata.createdAt).getTime(), + ); + + patchState({ + ...state, + projects: sortedProjectList, + }); + this.router.navigate(['/apps']); + } catch (e) { + this.logger.error('Error creating project', e); + this.toast.showError('Error creating project'); + } + } + + @Action(GetProjectFiles) + async getProjectFiles( + { getState, patchState }: StateContext<ProjectStateModel>, + { projectId, filterString }: GetProjectFiles, + ) { + // Start loading + patchState({ loadingProjectFiles: true }); + + const state = getState(); + + // Filter the projects to find the one with the matching projectId + const project = state.projects.find((p) => p.metadata.id === projectId); + + if (!project) { + this.logger.debug(`Project with id ${projectId} not found.`); + patchState({ loadingProjectFiles: false }); + return; + } + + // Define the folder order + const folderOrder = ['solution', 'BRD', 'PRD', 'NFR', 'UIR', 'BP']; // Update as needed + + try { + const files = await this.appSystemService.getFolders( + project.project, + filterString, + ); + + // Sort the files according to the folder order + const sortedFiles = files.sort( + (a: { name: string }, b: { name: string }) => { + const indexA = folderOrder.indexOf(a.name); + const indexB = folderOrder.indexOf(b.name); + + return ( + (indexA === -1 ? Number.MAX_SAFE_INTEGER : indexA) - + (indexB === -1 ? Number.MAX_SAFE_INTEGER : indexB) + ); + }, + ); + + patchState({ + currentProjectFiles: sortedFiles, + selectedProject: project.project, + metadata: project.metadata, + loadingProjectFiles: false, + }); + } catch (error) { + this.logger.error('Failed to fetch project files:', error); + patchState({ loadingProjectFiles: false }); + } + } + + private generateFiles( + document: { [key in string]: string }, + projectName: string, + folderName: string, + ): void { + const fileNameParts = document['id'].split(folderName); + const content = { ...document }; + delete content['id']; + + const fileName = `${folderName}${fileNameParts[1].padStart(2, '0')}-base.json`; + const fileContent = JSON.stringify(content, null, 2); + + this.appSystemService + .createFileWithContent( + `${projectName}/${folderName}/${fileName}`, + fileContent, + ) + .then(); + } + + private generatePRDFeatureFiles( + projectName: string, + folderName: string, + identifier: string, + ) { + const fileNameParts = identifier.split(folderName); + const path = `${projectName}/${folderName}/${folderName}${fileNameParts[1].padStart(2, '0')}-feature.json`; + this.appSystemService + .createFileWithContent(`${path}`, JSON.stringify({ features: [] })) + .then(); + } + + @Action(ReadFile) + async fetchFile( + { getState, patchState }: StateContext<ProjectStateModel>, + { path }: ReadFile, + ) { + const state = getState(); + const response = await this.appSystemService.readFile( + `${state.selectedProject}/${path}`, + ); + const parsedContent = JSON.parse(response); + this.logger.debug('portion of content read: ', response); + + patchState({ + ...state, + selectedFileContent: parsedContent, + }); + } + + @Action(BulkReadFiles) + async readFilesInBulkManner( + { getState, patchState }: StateContext<ProjectStateModel>, + { key, filterString }: BulkReadFiles, + ) { + const state = getState(); + this.logger.debug('Bulk reading files:', key); + + const folder = state.currentProjectFiles.find( + (f: { name: any }) => f.name === key, + ); + + if (!folder) { + this.logger.error('Folder not found:', key); + return; + } + + const paths = folder.children.map( + (child: any) => `${state.selectedProject}/${folder.name}/${child}`, + ); + + const fileContents = await Promise.all( + paths.map(async (path: string) => { + try { + this.logger.debug('path ==>', path); + const content = await this.appSystemService.readPortionOfFile( + path, + filterString, + ); + const fileName = path.split('/').pop() || ''; + + return { folderName: folder.name, fileName, content: content }; + } catch (error) { + this.logger.error('Error reading file:', path, error); + return null; + } + }), + ); + + const nonNullFileContents = fileContents.filter( + ( + file, + ): file is { + folderName: string; + fileName: string; + content: { requirement: string; title: string; epicTicketId: string }; + } => file !== null, + ); + + patchState({ + selectedFileContents: nonNullFileContents, + }); + } + + @Action(CreateFile) + async createFile( + { getState }: StateContext<ProjectStateModel>, + { path, content, featureFile }: CreateFile, + ) { + const state = getState(); + this.logger.debug('Creating file:', path, content); + const fileContent = JSON.stringify(content); + await this.appSystemService.createNewFile( + `${state.selectedProject}/${path}`, + fileContent, + featureFile, + ); + } + + @Action(UpdateFile) + async updateFile( + { getState, patchState }: StateContext<ProjectStateModel>, + { path, content }: UpdateFile, + ) { + const state = getState(); + + this.logger.debug(content, 'contentraw'); + + const fileContent = JSON.stringify(content, null, 2); + + this.logger.debug(fileContent, 'contentrawJson'); + + await this.appSystemService.createFileWithContent( + `${state.selectedProject}/${path}`, + fileContent, + ); + patchState({ + ...state, + selectedFileContent: content, + }); + } + + @Action(FileExists) + async fileExists( + { getState, patchState }: StateContext<ProjectStateModel>, + { path }: FileExists, + ) { + const state = getState(); + const response = await this.appSystemService.fileExists( + `${state.selectedProject}/${path}`, + ); + this.logger.debug('File exists: ', response); + + patchState({ + ...state, + fileExists: response, + }); + } + + @Action(ArchiveFile) + async archiveFile( + { getState, patchState }: StateContext<ProjectStateModel>, + { path }: ArchiveFile, + ) { + const state = getState(); + await this.appSystemService.archiveFile(`${state.selectedProject}/${path}`); + patchState({ + ...state, + }); + } + + @Action(UpdateMetadata) + async updateMetadata( + { getState, patchState }: StateContext<ProjectStateModel>, + { projectId, newMetadata }: UpdateMetadata, // Assuming newMetadata contains the updates you want to apply + ) { + const state = getState(); + + const project = state.projects.find((p) => p.metadata.id === projectId); + if (!project) { + this.logger.error(`Project with id ${projectId} not found.`); + return; + } + + const metadataFilePath = `${project.project}/.metadata.json`; + + try { + const existingMetadataContent = + await this.appSystemService.readFile(metadataFilePath); + const existingMetadata = JSON.parse(existingMetadataContent); + + const updatedMetadata = { ...existingMetadata, ...newMetadata }; + + const updatedFileContent = JSON.stringify(updatedMetadata, null, 2); + + await this.appSystemService.createFileWithContent( + metadataFilePath, + updatedFileContent, + ); + + const updatedProjects = state.projects.map((p) => { + if (p.metadata.id === projectId) { + return { + ...p, + metadata: updatedMetadata, // Update the project's metadata + }; + } + return p; + }); + + patchState({ + ...state, + projects: updatedProjects, + metadata: updatedMetadata, + }); + + this.logger.debug('Metadata updated successfully in both file and state'); + } catch (error) { + this.logger.error('Failed to update metadata:', error); + } + } + + @Action(ClearBRDPRDState) + clearBRDPRDState(ctx: StateContext<ProjectStateModel>) { + ctx.patchState({ + selectedFileContents: [], + }); + } +} diff --git a/ui/src/app/store/user-stories/user-stories.actions.ts b/ui/src/app/store/user-stories/user-stories.actions.ts new file mode 100644 index 0000000..3498ae5 --- /dev/null +++ b/ui/src/app/store/user-stories/user-stories.actions.ts @@ -0,0 +1,101 @@ +import { ITask } from '../../model/interfaces/IList'; + +import { IUserStory } from '../../model/interfaces/IUserStory'; + +export class GetUserStories { + static readonly type = '[UserStories] Get User Stories'; + + constructor(readonly relativePath: string) {} +} + +export class SetSelectedProject { + static readonly type = '[Projects] Set Selected Project'; + + constructor(readonly projectPath: string) {} +} + +export class SetSelectedFeature { + static readonly type = '[Projects] Set Selected Feature'; + + constructor(readonly projectPath: string) {} +} + +export class SetSelectedUserStory { + static readonly type = '[UserStories] Set Selected User Story'; + + constructor(readonly userStoryId: string) {} +} + +export class EditUserStory { + static readonly type = '[UserStories] Edit User Story'; + + constructor( + readonly filePath: string, + readonly userStory: any, + ) {} +} + +export class ArchiveTask { + static readonly type = '[UserStories] Archive Task'; + constructor( + readonly filePath: string, + readonly userStoryId: string, + readonly taskId: string, + ) {} +} + +export class ArchiveUserStory { + static readonly type = '[UserStories] Archive User Story'; + constructor( + readonly filePath: string, + readonly userStoryId: string, + ) {} +} + +export class CreateNewUserStory { + static readonly type = '[UserStories] Create New User Story'; + + constructor( + readonly userStory: any, + readonly absolutePath: string, + ) {} +} + +export class CreateNewTask { + static readonly type = '[UserStories] Create New Task'; + + constructor( + readonly task: ITask, + readonly relativePath: string, + ) {} +} + +export class UpdateTask { + static readonly type = '[UserStories] Update Task'; + + constructor( + readonly task: ITask, + readonly relativePath: string, + readonly redirect?: boolean + ) {} +} + +export class SetCurrentTaskId { + static readonly type = '[UserStories] Set Current Task Id'; + + constructor(readonly taskId: string) {} +} + +export class SetCurrentConfig { + static readonly type = '[UserStories] Set Current Config'; + + constructor( + readonly config: { + fileName: string; + folderName: string; + projectId: string; + reqId: string; + featureId: string; + }, + ) {} +} diff --git a/ui/src/app/store/user-stories/user-stories.state.spec.ts b/ui/src/app/store/user-stories/user-stories.state.spec.ts new file mode 100644 index 0000000..fd8a476 --- /dev/null +++ b/ui/src/app/store/user-stories/user-stories.state.spec.ts @@ -0,0 +1,24 @@ +import { TestBed } from '@angular/core/testing'; +import { NgxsModule, Store } from '@ngxs/store'; +import { UserStoriesState, UserStoriesStateModel } from './user-stories.state'; +import { UserStoriesAction } from './user-stories.actions'; + +describe('UserStories store', () => { + let store: Store; + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [NgxsModule.forRoot([UserStoriesState])], + }); + + store = TestBed.inject(Store); + }); + + it('should create an action and add an item', () => { + const expected: UserStoriesStateModel = { + items: ['item-1'], + }; + store.dispatch(new UserStoriesAction('item-1')); + const actual = store.selectSnapshot(UserStoriesState.getState); + expect(actual).toEqual(expected); + }); +}); diff --git a/ui/src/app/store/user-stories/user-stories.state.ts b/ui/src/app/store/user-stories/user-stories.state.ts new file mode 100644 index 0000000..5b10d4b --- /dev/null +++ b/ui/src/app/store/user-stories/user-stories.state.ts @@ -0,0 +1,383 @@ +import { Injectable } from '@angular/core'; +import { State, Action, Selector, StateContext } from '@ngxs/store'; +import { + CreateNewTask, + CreateNewUserStory, + EditUserStory, + ArchiveTask, + ArchiveUserStory, + GetUserStories, + SetCurrentConfig, + SetCurrentTaskId, + SetSelectedProject, + SetSelectedUserStory, + UpdateTask, +} from './user-stories.actions'; +import { AppSystemService } from '../../services/app-system/app-system.service'; +import { NGXLogger } from 'ngx-logger'; +import { IUserStory } from '../../model/interfaces/IUserStory'; +import { ITask } from '../../model/interfaces/ITask'; +import { Router } from '@angular/router'; + +export interface UserStoriesStateModel { + userStories: IUserStory[]; + taskMap: { [key in string]: ITask[] }; + selectedUserStoryId: string | null; + selectedProject: string; + selectedTaskId: string | null; + fileContent: string; + currentConfig: { + projectId: string; + fileName: string; + folderName: string; + reqId: string; + featureId: string; + } | null; +} + +@State<UserStoriesStateModel>({ + name: 'userStories', + defaults: { + userStories: [], + taskMap: {}, + selectedUserStoryId: null, + selectedProject: '', + fileContent: '', + selectedTaskId: null, + currentConfig: null, + }, +}) +@Injectable() +export class UserStoriesState { + constructor( + private appSystemService: AppSystemService, + private logger: NGXLogger, + private router: Router, + ) { } + + @Selector() + static getUserStories(state: UserStoriesStateModel) { + return state.userStories; + } + + @Selector() + static getTaskList(state: UserStoriesStateModel) { + console.log( + state.taskMap[state.selectedUserStoryId as string], + state.taskMap, + ); + return state.taskMap[state.selectedUserStoryId as string] || []; + } + + @Selector() + static getSelectedUserStory(state: UserStoriesStateModel) { + return state.userStories.find( + (story) => story.id === state.selectedUserStoryId, + ); + } + + @Selector() + static getSelectedTask(state: UserStoriesStateModel) { + return state.taskMap[state.selectedUserStoryId as string]?.find( + (task) => task.id === state.selectedTaskId, + ); + } + + @Selector() + static getSelectedProject(state: UserStoriesStateModel) { + return state.selectedProject; + } + + @Selector() + static getCurrentConfig(state: UserStoriesStateModel) { + return state.currentConfig; + } + + @Action(GetUserStories) + add( + ctx: StateContext<UserStoriesStateModel>, + { relativePath }: GetUserStories, + ) { + const state = ctx.getState(); + this.appSystemService + .readFile(relativePath) + .then((res) => { + this.logger.debug(res, relativePath); + if (!res) { + ctx.patchState({ + userStories: [] + }); + return; + } + const userStories: Array<IUserStory> = JSON.parse(res).features || []; + const stories: IUserStory[] = []; + const taskMap: { [key in string]: ITask[] } = {}; + this.logger.debug('userStories ==>', userStories); + userStories.forEach((story: IUserStory) => { + stories.push({ + id: story.id, + name: story.name, + description: story.description, + tasks: story.tasks, + archivedTasks: story.archivedTasks, + chatHistory: story.chatHistory, + storyTicketId: story.storyTicketId + }); + this.logger.debug('story ==>', story); + taskMap[story.id] = story.tasks as ITask[]; + }); + ctx.patchState({ + userStories: [...stories], + fileContent: res, + taskMap: { ...state.taskMap, ...taskMap }, + }); + }) + .catch((error) => { + this.logger.error('Error in reading file', error); + }); + } + + @Action(SetSelectedUserStory) + setSelectedUserStory( + ctx: StateContext<UserStoriesStateModel>, + { userStoryId }: SetSelectedUserStory, + ) { + ctx.patchState({ + selectedUserStoryId: userStoryId, + }); + } + + @Action(SetSelectedProject) + setSelectedProject( + ctx: StateContext<UserStoriesStateModel>, + { projectPath }: SetSelectedProject, + ) { + ctx.patchState({ + selectedProject: projectPath, + }); + } + + @Action(EditUserStory) + async editUserStory( + ctx: StateContext<UserStoriesStateModel>, + { filePath, userStory }: EditUserStory, + ) { + const state = ctx.getState(); + + const updatedUserStories = state.userStories.map((us) => { + if (us.id === userStory.id) { + return { ...us, ...userStory }; + } + return us; + }); + + const fileContent = JSON.stringify({ + features: updatedUserStories, + archivedFeatures: JSON.parse(state.fileContent).archivedFeatures || [], + }); + + await this.appSystemService.createFileWithContent( + `${state.selectedProject}/${filePath}`, + fileContent, + ); + + ctx.patchState({ + userStories: updatedUserStories, + }); + } + + @Action(ArchiveTask) + async archiveTask( + ctx: StateContext<UserStoriesStateModel>, + { filePath, userStoryId, taskId }: ArchiveTask, + ) { + const state = ctx.getState(); + + const fileContent = JSON.parse(state.fileContent); + + const storyIndex = fileContent.features.findIndex( + (story: IUserStory) => story.id === userStoryId, + ); + const story = fileContent.features[storyIndex]; + + const taskIndex = story.tasks.findIndex( + (task: ITask) => task.id === taskId, + ); + + const [removedTask] = story.tasks.splice(taskIndex, 1); + story.archivedTasks = [...(story.archivedTasks || []), removedTask]; + + const updatedContent = JSON.stringify(fileContent); + + await this.appSystemService.createFileWithContent( + `${state.selectedProject}/${filePath}`, + updatedContent, + ); + + ctx.patchState({ + fileContent: updatedContent, + userStories: fileContent.features, + taskMap: { + ...state.taskMap, + [userStoryId]: story.tasks, + }, + }); + } + + @Action(ArchiveUserStory) + async archiveUserStory( + ctx: StateContext<UserStoriesStateModel>, + { filePath, userStoryId }: ArchiveUserStory, + ) { + const state = ctx.getState(); + const fileContent = JSON.parse(state.fileContent); + + const storyIndex = fileContent.features.findIndex( + (story: IUserStory) => story.id === userStoryId, + ); + + const [removedStory] = fileContent.features.splice(storyIndex, 1); + fileContent.archivedFeatures = [ + ...(fileContent.archivedFeatures || []), + removedStory, + ]; + + const updatedContent = JSON.stringify(fileContent); + + await this.appSystemService.createFileWithContent( + `${state.selectedProject}/${filePath}`, + updatedContent, + ); + + ctx.patchState({ + fileContent: updatedContent, + userStories: fileContent.features, + }); + } + + @Action(CreateNewUserStory) + async createNewUserStory( + ctx: StateContext<UserStoriesStateModel>, + { userStory, absolutePath }: CreateNewUserStory, + ) { + const state = ctx.getState(); + + const newId = `US${state.userStories.length + 1}`; + + const newUserStory = { id: newId, ...userStory, tasks: [] }; + const updatedUserStories = [...state.userStories, newUserStory]; + + const fileContent = JSON.stringify( + { + features: updatedUserStories, + archivedFeatures: JSON.parse(state.fileContent).archivedFeatures || [], + }, + null, + 2, + ); + + console.log(`${state.selectedProject}/${absolutePath}`, 'absinthe'); + + await this.appSystemService.createFileWithContent( + `${state.selectedProject}/${absolutePath}`, + fileContent, + ); + + ctx.patchState({ + userStories: updatedUserStories, + }); + } + + @Action(CreateNewTask) + createNewTask( + ctx: StateContext<UserStoriesStateModel>, + { task, relativePath }: CreateNewTask, + ) { + const state = ctx.getState(); + const newFileContent = JSON.parse(state.fileContent); + if (state.taskMap.hasOwnProperty(state.selectedUserStoryId as string)) { + const newTaskList = [ + ...(state.taskMap[state.selectedUserStoryId as string] as ITask[]), + ]; + newTaskList?.push(task as ITask); + // state.taskMap.delete(state.selectedUserStoryId as string); + ctx.patchState({ + taskMap: { + ...state.taskMap, + [state.selectedUserStoryId as string]: newTaskList as ITask[], + }, + }); + // state.taskMap.set( + // state.selectedUserStoryId as string, + // newTaskList as ITask[], + // ); + } + newFileContent.features.forEach((story: IUserStory) => { + if (story.id === state.selectedUserStoryId) { + if (!story.tasks) { + story.tasks = []; + } + this.logger.debug('task =>', task); + story.tasks?.push(task as ITask); + } + }); + this.logger.debug('logger =>', newFileContent); + this.appSystemService + .createFileWithContent(relativePath, JSON.stringify(newFileContent)); + } + + @Action(UpdateTask) + updateTask( + ctx: StateContext<UserStoriesStateModel>, + { task, relativePath, redirect }: UpdateTask, + ) { + const state = ctx.getState(); + const taskList = state.taskMap[state.selectedUserStoryId as string]; + const existingTasks = taskList.filter((t) => t.id !== task.id); + existingTasks.push(task as ITask); + ctx.patchState({ + taskMap: { + ...state.taskMap, + [state.selectedUserStoryId as string]: existingTasks, + }, + }); + const newFileContent = JSON.parse(state.fileContent); + newFileContent.features.forEach((story: IUserStory, index: number) => { + if (story.id === state.selectedUserStoryId) { + story.tasks?.forEach((t: ITask, j: number) => { + if (t.id === task.id) { + newFileContent.features[index].tasks[j] = task; + } + }); + } + }); + this.logger.debug(newFileContent); + this.appSystemService + .createFileWithContent(relativePath, JSON.stringify(newFileContent)) + .then(() => { + redirect && this.router + .navigate([`/task-list/${state.selectedUserStoryId}`]) + .then(); + }); + } + + @Action(SetCurrentTaskId) + setCurrentTaskId( + ctx: StateContext<UserStoriesStateModel>, + { taskId }: SetCurrentTaskId, + ) { + ctx.patchState({ + selectedTaskId: taskId, + }); + } + + @Action(SetCurrentConfig) + setCurrentConfig( + ctx: StateContext<UserStoriesStateModel>, + { config }: SetCurrentConfig, + ) { + ctx.patchState({ + currentConfig: config, + }); + } +} diff --git a/ui/src/app/utils/common.utils.ts b/ui/src/app/utils/common.utils.ts new file mode 100644 index 0000000..978bb7f --- /dev/null +++ b/ui/src/app/utils/common.utils.ts @@ -0,0 +1,34 @@ +import { FileTypeEnum } from '../model/enum/file-type.enum'; +import { Navigation } from '@angular/router'; + +export function getDescriptionFromInput( + input: string | undefined, +): string | null { + const enumKey = Object.keys(FileTypeEnum).find( + (key) => key === input?.toUpperCase().replace(/\s+/g, ''), + ); + return enumKey ? FileTypeEnum[enumKey as keyof typeof FileTypeEnum] : null; +} + +export function truncateWithEllipsis( + text: string | undefined, + maxLength: number = 180, +): string | null { + if (!text) { + return null; + } + if (text.length > maxLength) { + return text.substring(0, maxLength) + '...'; + } + return text; +} + +export function getNavigationParams(navigation: Navigation | null) { + return { + projectId: navigation?.extras?.state?.['id'], + folderName: navigation?.extras?.state?.['folderName'], + fileName: navigation?.extras?.state?.['fileName'], + selectedRequirement: navigation?.extras?.state?.['req'], + data: navigation?.extras?.state?.['data'], + }; +} diff --git a/ui/src/assets/.gitkeep b/ui/src/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/assets/img/logo/aws_dark_bg_transparent_logo.svg b/ui/src/assets/img/logo/aws_dark_bg_transparent_logo.svg new file mode 100644 index 0000000..242be71 --- /dev/null +++ b/ui/src/assets/img/logo/aws_dark_bg_transparent_logo.svg @@ -0,0 +1,12 @@ +<svg width="20" height="20" viewBox="0 0 20 13" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_4900_427)"> +<path d="M5.65774 4.79029L5.78932 5.3824C5.78932 5.57976 5.85511 5.71134 5.98669 5.90871V6.04029L5.9209 6.23766L5.46037 6.50082L5.32879 6.56661L5.13142 6.50082L4.86827 6.17187L4.6709 5.77713C4.14458 6.36924 3.48669 6.69819 2.763 6.69819C2.1709 6.69819 1.71037 6.50082 1.44721 6.17187C1.11827 5.90871 0.920898 5.44819 0.920898 4.92187C0.920898 4.39555 1.11827 3.93503 1.513 3.60608C1.90774 3.21134 2.43406 3.07976 3.15774 3.07976C3.64708 3.07709 4.13435 3.14353 4.60511 3.27713V2.81661C4.60511 2.29029 4.47353 1.96134 4.27616 1.76397C4.07879 1.50082 3.74985 1.43503 3.22353 1.43503L2.49985 1.50082C2.18328 1.58164 1.87494 1.69177 1.57879 1.82976H1.44721C1.38142 1.82976 1.31564 1.76397 1.31564 1.6324V1.30345L1.38142 1.10608C1.38142 1.04029 1.44721 0.974501 1.57879 0.974501L2.36827 0.645554L3.4209 0.513975C4.21037 0.513975 4.73669 0.711343 5.13142 1.04029C5.46037 1.43503 5.65774 1.96134 5.65774 2.68503V4.79029ZM3.02616 5.84292L3.68406 5.71134C3.94721 5.64555 4.14458 5.44819 4.34195 5.25082L4.53932 4.85608L4.60511 4.26397V4.00082C4.1948 3.90956 3.77542 3.86542 3.35511 3.86924C2.96037 3.86924 2.63142 3.93503 2.36827 4.1324C2.1709 4.26397 2.10511 4.52713 2.10511 4.85608C2.10511 5.18503 2.1709 5.3824 2.30248 5.57976C2.49985 5.71134 2.69721 5.84292 3.02616 5.84292ZM8.28932 6.50082L8.02616 6.43503L7.89458 6.23766L6.38142 1.10608L6.31563 0.842922L6.44721 0.711343H7.10511L7.36827 0.777133L7.49985 1.04029L8.61827 5.3824L9.60511 1.04029L9.73669 0.777133L9.99985 0.711343H10.5262L10.7893 0.777133L10.9209 1.04029L11.9735 5.44819L13.092 1.04029L13.2235 0.777133L13.4867 0.711343H14.0788C14.2104 0.711343 14.2762 0.777133 14.2762 0.842922V0.974501L14.2104 1.10608L12.6314 6.23766L12.4998 6.50082L12.2367 6.56661H11.6446L11.3814 6.50082L11.3156 6.23766L10.263 1.96134L9.27616 6.17187L9.14458 6.43503L8.88142 6.50082H8.28932ZM16.7762 6.69819C16.1628 6.69354 15.5574 6.55901 14.9998 6.30345L14.8025 6.10608L14.7367 5.9745V5.64555C14.7367 5.51397 14.8025 5.44819 14.8683 5.44819H14.9998L15.1972 5.51397C15.6713 5.73355 16.1879 5.84587 16.7104 5.84292C17.1051 5.84292 17.4341 5.71134 17.6314 5.57976C17.8946 5.44819 17.9604 5.25082 17.9604 4.98766L17.8288 4.52713L17.1709 4.19819L16.1841 3.86924C15.7235 3.73766 15.3288 3.4745 15.1314 3.21134C15.0036 3.04386 14.9106 2.85249 14.8578 2.6485C14.8051 2.44451 14.7937 2.23203 14.8244 2.02358C14.855 1.81513 14.9271 1.61492 15.0363 1.43474C15.1455 1.25457 15.2897 1.09809 15.4604 0.974501L16.1183 0.645554C16.5408 0.500762 16.9912 0.455727 17.4341 0.513975C17.7007 0.564905 17.9643 0.630798 18.2235 0.711343L18.4867 0.842922L18.6841 0.974501L18.7498 1.23766V1.50082C18.7498 1.69819 18.6841 1.76397 18.6183 1.76397L18.3551 1.6324C17.9604 1.50082 17.5656 1.43503 17.1051 1.43503C16.7104 1.43503 16.3814 1.43503 16.1841 1.56661C15.9867 1.69819 15.9209 1.89555 15.9209 2.15871C15.9209 2.35608 15.9867 2.48766 16.1183 2.61924C16.2498 2.75082 16.4472 2.8824 16.8419 3.01397L17.763 3.27713C18.2235 3.4745 18.5525 3.67187 18.7498 3.93503C18.9472 4.19819 19.0788 4.52713 19.0788 4.85608L18.8814 5.64555L18.4209 6.17187C18.2235 6.36924 17.9604 6.50082 17.6972 6.56661L16.7762 6.69819Z" fill="#252F3E"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M18.0262 9.92184C15.3425 11.7143 12.1367 12.5584 8.91827 12.32C5.69984 12.0816 2.6534 10.7743 0.263067 8.60605C-9.04351e-05 8.40869 0.197278 8.21132 0.394646 8.3429C3.00843 9.82355 5.92572 10.688 8.92421 10.8704C11.9227 11.0529 14.9233 10.5484 17.6973 9.39553C18.0262 9.26395 18.3552 9.65869 18.0262 9.92184Z" fill="#FF9900"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M18.8813 8.86924C18.6181 8.5403 17.0392 8.67188 16.3813 8.80345C16.1181 8.80345 16.1181 8.60609 16.3155 8.47451C17.5655 7.61924 19.605 7.8824 19.8023 8.14556C20.0655 8.47451 19.7365 10.514 18.6181 11.5008C18.4208 11.6324 18.2234 11.5666 18.2892 11.3692C18.6181 10.7113 19.1444 9.19819 18.8813 8.86924Z" fill="#FF9900"/> +</g> +<defs> +<clipPath id="clip0_4900_427"> +<rect width="20" height="11.9737" fill="white" transform="translate(0 0.448242)"/> +</clipPath> +</defs> +</svg> diff --git a/ui/src/assets/img/logo/aws_dark_bg_white_logo.svg b/ui/src/assets/img/logo/aws_dark_bg_white_logo.svg new file mode 100644 index 0000000..6a99825 --- /dev/null +++ b/ui/src/assets/img/logo/aws_dark_bg_white_logo.svg @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="40px" height="40px" viewBox="0 0 40 40" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <title>Icon-Architecture-Group/32/aws_dark_bg_white_logo + + diff --git a/ui/src/assets/img/logo/haibuild_dark_applogo.svg b/ui/src/assets/img/logo/haibuild_dark_applogo.svg new file mode 100644 index 0000000..c6e936a --- /dev/null +++ b/ui/src/assets/img/logo/haibuild_dark_applogo.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/assets/img/logo/haibuild_light_applogo.svg b/ui/src/assets/img/logo/haibuild_light_applogo.svg new file mode 100644 index 0000000..34c2595 --- /dev/null +++ b/ui/src/assets/img/logo/haibuild_light_applogo.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/assets/img/logo/haibuild_onlylogo.svg b/ui/src/assets/img/logo/haibuild_onlylogo.svg new file mode 100644 index 0000000..f94b56e --- /dev/null +++ b/ui/src/assets/img/logo/haibuild_onlylogo.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/ui/src/assets/img/logo/mark_gradient_blue_jira.svg b/ui/src/assets/img/logo/mark_gradient_blue_jira.svg new file mode 100644 index 0000000..5943e00 --- /dev/null +++ b/ui/src/assets/img/logo/mark_gradient_blue_jira.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/assets/img/presidio.png b/ui/src/assets/img/presidio.png new file mode 100755 index 0000000..78acd87 Binary files /dev/null and b/ui/src/assets/img/presidio.png differ diff --git a/ui/src/environments/environment.development.ts b/ui/src/environments/environment.development.ts new file mode 100644 index 0000000..69a0083 --- /dev/null +++ b/ui/src/environments/environment.development.ts @@ -0,0 +1,24 @@ +export const environment = { + SENTRY_ENVIRONMENT: 'dev', + SENTRY_DSN: + '', + SENTRY_RELEASE: '', + JIRA_RATE_LIMIT_CONFIG: 9, + ENABLE_SENTRY: false, + DEBUG_MODE: false, + APP_VERSION: '1.9.5', + ThemeConfiguration: { + appLogo: 'assets/img/logo/haibuild_light_applogo.svg', + appLogoDark: 'assets/img/logo/haibuild_dark_applogo.svg', + appName: 'Specif AI', + appWelcomeTitle: 'Welcome to Specif AI', + appDescription: 'Get started to accelerate SDLC Process with HAI BUILD', + companyName: 'Presidio', + companyLogo: 'assets/img/presidio.png', + appIcons: { + mac: 'assets/icons/mac_icon.icns', + win: 'assets/icons/win_icon.ico', + linux: 'assets/icons/linux_icon.png', + }, + }, +}; diff --git a/ui/src/environments/environment.prod.ts b/ui/src/environments/environment.prod.ts new file mode 100644 index 0000000..71a1dae --- /dev/null +++ b/ui/src/environments/environment.prod.ts @@ -0,0 +1,24 @@ +export const environment = { + SENTRY_ENVIRONMENT: '', + SENTRY_DSN: + '', + SENTRY_RELEASE: '', + JIRA_RATE_LIMIT_CONFIG: 9, + ENABLE_SENTRY: false, + DEBUG_MODE: false, + APP_VERSION: '1.9.5', + ThemeConfiguration: { + appLogo: 'assets/img/logo/haibuild_light_applogo.svg', + appLogoDark: 'assets/img/logo/haibuild_dark_applogo.svg', + appName: 'Specif AI', + appWelcomeTitle: 'Welcome to Specif AI', + appDescription: 'Get started to accelerate SDLC Process with HAI BUILD', + companyName: 'Presidio', + companyLogo: 'assets/img/presidio.png', + appIcons: { + mac: 'assets/icons/mac_icon.icns', + win: 'assets/icons/win_icon.ico', + linux: 'assets/icons/linux_icon.png', + }, + }, +}; diff --git a/ui/src/environments/environment.ts b/ui/src/environments/environment.ts new file mode 100644 index 0000000..e04134d --- /dev/null +++ b/ui/src/environments/environment.ts @@ -0,0 +1,23 @@ +export const environment = { + SENTRY_ENVIRONMENT: 'production', + SENTRY_DSN: '', + SENTRY_RELEASE: '', + ENABLE_SENTRY: false, + DEBUG_MODE: true, + JIRA_RATE_LIMIT_CONFIG: 9, + APP_VERSION: '1.9.5', + ThemeConfiguration: { + appLogo: 'assets/img/logo/haibuild_light_applogo.svg', + appLogoDark: 'assets/img/logo/haibuild_dark_applogo.svg', + appName: 'Specif AI', + appWelcomeTitle: 'Welcome to Specif AI', + appDescription: 'Get started to accelerate SDLC Process with HAI BUILD', + companyName: 'Presidio', + companyLogo: 'assets/img/presidio.png', + appIcons: { + mac: 'assets/icons/mac_icon.icns', + win: 'assets/icons/win_icon.ico', + linux: 'assets/icons/linux_icon.png', + }, + }, +}; diff --git a/ui/src/favicon.ico b/ui/src/favicon.ico new file mode 100644 index 0000000..ff67416 Binary files /dev/null and b/ui/src/favicon.ico differ diff --git a/ui/src/index.html b/ui/src/index.html new file mode 100644 index 0000000..bf5fd9b --- /dev/null +++ b/ui/src/index.html @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + diff --git a/ui/src/main.ts b/ui/src/main.ts new file mode 100644 index 0000000..061766d --- /dev/null +++ b/ui/src/main.ts @@ -0,0 +1,36 @@ +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { AppModule } from './app/app.module'; +import * as Sentry from '@sentry/angular'; +import { environment } from './environments/environment'; + +if (environment.ENABLE_SENTRY) { + Sentry.init({ + dsn: environment.SENTRY_DSN, + environment: environment.SENTRY_ENVIRONMENT, + integrations: [ + Sentry.browserTracingIntegration(), + Sentry.breadcrumbsIntegration({ + console: true, + dom: true, + fetch: true, + history: true, + xhr: true, + }), + Sentry.replayIntegration({ + maskAllText: false, + blockAllMedia: false, + }), + ], + release: environment.SENTRY_RELEASE, + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, + tracesSampleRate: 1.0, + }); +} else { + console.log('Disabling sentry based on the environment configuration.'); +} + +platformBrowserDynamic() + .bootstrapModule(AppModule) + .catch((err) => console.error(err)); diff --git a/ui/src/styles.scss b/ui/src/styles.scss new file mode 100644 index 0000000..aab3985 --- /dev/null +++ b/ui/src/styles.scss @@ -0,0 +1,248 @@ +////Temporary fix for tooltip issue - Need to consider later +@import "~@angular/material/prebuilt-themes/indigo-pink.css"; + +@tailwind base; +@tailwind components; +@tailwind utilities; + +// Color of overlay background +$color-overlay: rgba(0, 0, 0, 0.5) !default; + +// Position of dialog +$dialog-position-top: 1.75rem !default; +$dialog-position-bottom: 1.75rem !default; +$dialog-position-left: 1.75rem !default; +$dialog-position-right: 1.75rem !default; + +// Transition time +// !! The same as the hideDelay variable defined in ngx-smart-modal.component.ts +$transition-duration: 500ms !default; + +// Transition effect +// linear | ease | ease-in | ease-out | ease-in-out +$transition-timing-function: ease-in-out !default; + +// Body overflow when a modal is opened. +// Set it to `auto` if you want to unlock the page scroll when a modal is opened +$opened-modal-body-overflow: hidden !default; + +// Body if modal is opened +body.dialog-open { + overflow: $opened-modal-body-overflow; +} + +// Close button in modal +.nsm-dialog-btn-close { + border: 0; + background: none; + position: absolute; + top: 8px; + right: 8px; + font-size: 1.2em; + cursor: pointer; + margin: 10px; +} + +// Overlay +.overlay { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + overflow-x: hidden; + overflow-y: auto; + transition: background-color $transition-duration; + background-color: transparent; + z-index: 999; + + &.nsm-overlay-open { + background-color: $color-overlay; + } + + &.transparent { + background-color: transparent; + } +} + +// Dialog modal +.nsm-dialog { + position: relative; + opacity: 1; + visibility: visible; + min-height: 200px; + width: 100%; + max-width: 520px; + margin: 0 auto; + pointer-events: none; + outline: none; + + // When dialog is closing + &.nsm-dialog-close { + opacity: 0; + } + + &.nsm-centered { + display: flex; + align-items: center; + min-height: calc(100% - (1.75rem * 2)); + } +} + +.nsm-content { + position: relative; + display: flex; + flex-direction: column; + pointer-events: auto; + background-clip: padding-box; + background-color: #fff; + border-radius: 2px; + padding: 1rem; + margin-top: $dialog-position-top; + margin-bottom: $dialog-position-bottom; + margin-left: $dialog-position-left; + margin-right: $dialog-position-right; + box-shadow: 0 7px 8px -4px rgba(0, 0, 0, 0.2), + 0 13px 19px 2px rgba(0, 0, 0, 0.14), + 0 5px 24px 4px rgba(0, 0, 0, 0.12); + outline: 0; + + // For performance purpose + transform: translate3d(0, 0, 0); +} + +.nsm-body { + position: relative; + flex: 1 1 auto; +} + +/* ************************* +* Animations +* *************************/ + +.nsm-dialog[class*="nsm-dialog-animation-"] { + transition: transform $transition-duration $transition-timing-function, + opacity $transition-duration; +} + +// Left to right (ltr) +.nsm-dialog-animation-ltr { + transform: translate3d(-50%, 0, 0); + + &.nsm-dialog-open { + transform: translate3d(0, 0, 0); + } + + &.nsm-dialog-close { + transform: translate3d(-50%, 0, 0); + } +} + +// Right to left (ltr) +.nsm-dialog-animation-rtl { + transform: translate3d(50%, 0, 0); + + &.nsm-dialog-open { + transform: translate3d(0, 0, 0); + } + + &.nsm-dialog-close { + transform: translate3d(50%, 0, 0); + } +} + +// Top to bottom (ttb) +.nsm-dialog-animation-ttb { + transform: translate3d(0, -50%, 0); + + &.nsm-dialog-open { + transform: translate3d(0, 0, 0); + } + + &.nsm-dialog-close { + transform: translate3d(0, -50%, 0); + } +} + +// Bottom to top (btt) +.nsm-dialog-animation-btt { + transform: translate3d(0, 50%, 0); + + &.nsm-dialog-open { + transform: translate3d(0, 0, 0); + } + + &.nsm-dialog-close { + transform: translate3d(0, 50%, 0); + } +} + +.backdrop { + position: fixed !important; +} + +//Temporary fix for snackbar - Need to consider later +.cdk-overlay-container { + z-index: 999; +} + +// svg#mermaid { +// max-width: 800px !important; +// max-height: 450px !important; +// } + +ngx-loading { + .backdrop { + display: flex; + align-items: center; + justify-content: center; + } + + .spinner-three-bounce { + position: fixed !important; + top: 50%; + left: 50%; + inset: none !important; + transform: translate(-50%, -50%); + z-index: 2000; + } +} + +html, +body { + height: 100%; + width: 100%; + margin: 0; + padding: 0; + box-sizing: border-box; +} + +.chat-history { + min-height: 510px; + height: calc(100dvh - 282px); + + .chat-log, .suggestion { + &:first-child { + margin-top: auto; + } + } +} + +.alter-height { + .chat-history { + min-height: 650px; + height: calc(100dvh - 324px); + } +} + +.mat-mdc-menu-content { + padding: 0 !important; +} + +@layer base { + html, body { + font-weight: 300; + font-style: normal; + font-size: 16px; + } +} diff --git a/ui/tailwind.config.js b/ui/tailwind.config.js new file mode 100644 index 0000000..a4a28da --- /dev/null +++ b/ui/tailwind.config.js @@ -0,0 +1,96 @@ +const defaultTheme = require("tailwindcss/defaultTheme"); + +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ["./src/**/*.{html,ts}"], + theme: { + extend: { + fontFamily: { + geist: ["Geist", ...defaultTheme.fontFamily.sans], + }, + colors: { + primary: { + 50: "#eef2ff", + 100: "#e0e7ff", + 200: "#c7d2fe", + 300: "#a5b4fc", + 400: "#818cf8", + 500: "#6366f1", + 600: "#4f46e5", + 700: "#4338ca", + 800: "#3730a3", + 900: "#312e81", + 950: "#1e1b4b", + }, + secondary: { + 50: "#f8fafc", + 100: "#f1f5f9", + 200: "#e2e8f0", + 300: "#cbd5e1", + 400: "#94a3b8", + 500: "#64748b", + 600: "#475569", + 700: "#334155", + 800: "#1e293b", + 900: "#0f172a", + 950: "#020617", + }, + success: { + 50: "#ecfdf5", + 100: "#d1fae5", + 200: "#a7f3d0", + 300: "#6ee7b7", + 400: "#34d399", + 500: "#10b981", + 600: "#059669", + 700: "#047857", + 800: "#065f46", + 900: "#064e3b", + 950: "#022c22", + }, + danger: { + 50: "#fef2f2", + 100: "#fee2e2", + 200: "#fecaca", + 300: "#fca5a5", + 400: "#f87171", + 500: "#ef4444", + 600: "#dc2626", + 700: "#b91c1c", + 800: "#991b1b", + 900: "#7f1d1d", + 950: "#450a0a", + }, + warning: { + 50: "#fffbeb", + 100: "#fef3c7", + 200: "#fde68a", + 300: "#fcd34d", + 400: "#fbbf24", + 500: "#f59e0b", + 600: "#d97706", + 700: "#b45309", + 800: "#92400e", + 900: "#78350f", + 950: "#451a03", + }, + }, + keyframes: { + pulse: { + "0%": { transform: "scale(1)", opacity: "0.6" }, + "50%": { transform: "scale(1.5)", opacity: "1" }, + "100%": { transform: "scale(1)", opacity: "0.6" }, + }, + bounce: { + "0%, 100%": { transform: "translateY(0)" }, + "50%": { transform: "translateY(-1rem)" }, + }, + }, + animation: { + pulse: "pulse 1.5s infinite ease-in-out", + bounce: "bounce 1.5s infinite ease-in-out", + }, + }, + }, + plugins: [], +}; diff --git a/ui/tsconfig.app.json b/ui/tsconfig.app.json new file mode 100644 index 0000000..84f1f99 --- /dev/null +++ b/ui/tsconfig.app.json @@ -0,0 +1,10 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.d.ts"] +} diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..08b9086 --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,31 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "compileOnSave": false, + "compilerOptions": { + "types": ["@types/node", "@types/dompurify", "@types/d3"], + "baseUrl": "./", + "outDir": "./dist/out-tsc", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "sourceMap": true, + "declaration": false, + "downlevelIteration": true, + "experimentalDecorators": true, + "moduleResolution": "node", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "useDefineForClassFields": false, + "lib": ["ES2022", "dom"] + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/ui/tsconfig.spec.json b/ui/tsconfig.spec.json new file mode 100644 index 0000000..47e3dd7 --- /dev/null +++ b/ui/tsconfig.spec.json @@ -0,0 +1,9 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": ["jasmine"] + }, + "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] +}