From cfbcc8bd793f57a397ee59b9219101bda91e657c Mon Sep 17 00:00:00 2001 From: Your Name <119736744+aborayan2022@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:16:09 +0200 Subject: [PATCH] =?UTF-8?q?=D9=85=D9=8A=D8=B2=D8=A9:=20=D8=A5=D8=B6=D8=A7?= =?UTF-8?q?=D9=81=D8=A9=20=D9=85=D9=83=D9=88=D9=86=D9=8A=20ProcedureSelect?= =?UTF-8?q?ion=20=D9=88=20StepProgress=20=D9=84=D8=A3=D8=AF=D8=A7=D8=A9=20?= =?UTF-8?q?=D9=85=D8=AE=D8=B7=D8=B7=D8=A7=D8=AA=20=D8=A7=D9=84=D8=AA=D8=AF?= =?UTF-8?q?=D9=81=D9=82=20=D8=A8=D8=B5=D9=8A=D8=BA=D8=A9=20PDF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - تنفيذ مكون ProcedureSelection لتمكين المستخدمين من اختيار الإجراءات من قائمة، وإدارة الاختيارات، ومعالجة الإجراءات المرفوضة. - إنشاء مكون StepProgress لعرض تقدم معالج متعدد الخطوات بشكل مرئي. - تعريف أنواع مشتركة للإجراءات، وخطوات التدفق، ورسائل الدردشة في ملف types.ts. - إضافة اختبارات وحدة لخطافات useFileUpload و useTaskPolling لضمان الأداء السليم ومعالجة الأخطاء. - تنفيذ اختبارات واجهة برمجة التطبيقات (API) للتحقق من تنسيقات نقاط النهاية وضمان اتساق ربط الواجهة الأمامية بالخلفية. --- backend/Dockerfile | 3 +- backend/app/__init__.py | 2 + backend/app/routes/flowchart.py | 103 + backend/app/routes/pdf_tools.py | 5 + backend/app/routes/tasks.py | 2 + backend/app/services/ai_chat_service.py | 142 + backend/app/services/flowchart_service.py | 410 ++ backend/app/services/pdf_tools_service.py | 69 +- backend/app/tasks/flowchart_tasks.py | 79 + backend/app/utils/file_validator.py | 24 +- backend/config/__init__.py | 16 + backend/requirements.txt | 9 + backend/tests/conftest.py | 84 +- backend/tests/test_compress_service.py | 74 + backend/tests/test_compress_tasks.py | 74 + backend/tests/test_convert_tasks.py | 72 + backend/tests/test_download.py | 49 + backend/tests/test_file_validator.py | 108 + backend/tests/test_image_service.py | 50 + backend/tests/test_image_tasks.py | 115 + backend/tests/test_load.py | 207 + backend/tests/test_pdf_service.py | 64 + backend/tests/test_pdf_tools.py | 531 +++ backend/tests/test_pdf_tools_service.py | 111 + backend/tests/test_pdf_tools_tasks.py | 176 + backend/tests/test_rate_limiter.py | 101 + backend/tests/test_sanitizer.py | 74 + backend/tests/test_storage_service.py | 56 + backend/tests/test_tasks_route.py | 66 + backend/tests/test_utils.py | 32 +- backend/tests/test_video.py | 151 + backend/tests/test_video_service.py | 37 + backend/tests/test_video_tasks.py | 83 + docker-compose.prod.yml | 2 +- docker-compose.yml | 2 +- frontend/package-lock.json | 4045 ++++++++++++++++- frontend/package.json | 13 +- frontend/src/App.tsx | 4 + .../src/components/shared/HeroUploadZone.tsx | 53 +- frontend/src/components/shared/ToolCard.tsx | 24 +- frontend/src/components/tools/PdfEditor.tsx | 245 + .../src/components/tools/PdfFlowchart.tsx | 360 ++ frontend/src/components/tools/SplitPdf.tsx | 72 +- .../tools/pdf-flowchart/DocumentViewer.tsx | 136 + .../tools/pdf-flowchart/FlowChart.tsx | 274 ++ .../tools/pdf-flowchart/FlowChat.tsx | 184 + .../tools/pdf-flowchart/FlowGeneration.tsx | 79 + .../tools/pdf-flowchart/FlowUpload.tsx | 150 + .../tools/pdf-flowchart/ManualProcedure.tsx | 168 + .../pdf-flowchart/ProcedureSelection.tsx | 231 + .../tools/pdf-flowchart/StepProgress.tsx | 56 + .../components/tools/pdf-flowchart/types.ts | 48 + frontend/src/hooks/useFileUpload.test.ts | 224 + frontend/src/hooks/useTaskPolling.test.ts | 230 + frontend/src/i18n/ar.json | 163 +- frontend/src/i18n/en.json | 163 +- frontend/src/i18n/fr.json | 163 +- frontend/src/pages/HomePage.tsx | 93 +- frontend/src/services/api.test.ts | 279 ++ frontend/src/services/api.ts | 19 +- frontend/src/utils/fileRouting.ts | 4 + frontend/vite.config.ts | 5 + 62 files changed, 10567 insertions(+), 101 deletions(-) create mode 100644 backend/app/routes/flowchart.py create mode 100644 backend/app/services/ai_chat_service.py create mode 100644 backend/app/services/flowchart_service.py create mode 100644 backend/app/tasks/flowchart_tasks.py create mode 100644 backend/tests/test_compress_service.py create mode 100644 backend/tests/test_compress_tasks.py create mode 100644 backend/tests/test_convert_tasks.py create mode 100644 backend/tests/test_download.py create mode 100644 backend/tests/test_file_validator.py create mode 100644 backend/tests/test_image_service.py create mode 100644 backend/tests/test_image_tasks.py create mode 100644 backend/tests/test_load.py create mode 100644 backend/tests/test_pdf_service.py create mode 100644 backend/tests/test_pdf_tools.py create mode 100644 backend/tests/test_pdf_tools_service.py create mode 100644 backend/tests/test_pdf_tools_tasks.py create mode 100644 backend/tests/test_rate_limiter.py create mode 100644 backend/tests/test_sanitizer.py create mode 100644 backend/tests/test_storage_service.py create mode 100644 backend/tests/test_tasks_route.py create mode 100644 backend/tests/test_video.py create mode 100644 backend/tests/test_video_service.py create mode 100644 backend/tests/test_video_tasks.py create mode 100644 frontend/src/components/tools/PdfEditor.tsx create mode 100644 frontend/src/components/tools/PdfFlowchart.tsx create mode 100644 frontend/src/components/tools/pdf-flowchart/DocumentViewer.tsx create mode 100644 frontend/src/components/tools/pdf-flowchart/FlowChart.tsx create mode 100644 frontend/src/components/tools/pdf-flowchart/FlowChat.tsx create mode 100644 frontend/src/components/tools/pdf-flowchart/FlowGeneration.tsx create mode 100644 frontend/src/components/tools/pdf-flowchart/FlowUpload.tsx create mode 100644 frontend/src/components/tools/pdf-flowchart/ManualProcedure.tsx create mode 100644 frontend/src/components/tools/pdf-flowchart/ProcedureSelection.tsx create mode 100644 frontend/src/components/tools/pdf-flowchart/StepProgress.tsx create mode 100644 frontend/src/components/tools/pdf-flowchart/types.ts create mode 100644 frontend/src/hooks/useFileUpload.test.ts create mode 100644 frontend/src/hooks/useTaskPolling.test.ts create mode 100644 frontend/src/services/api.test.ts diff --git a/backend/Dockerfile b/backend/Dockerfile index 042c97b..5806966 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -22,7 +22,8 @@ WORKDIR /app # Copy requirements first for Docker layer caching COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir -r requirements.txt \ + && python -c "import PyPDF2; print('PyPDF2 OK')" # Copy application code COPY . . diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 3d33bf4..ef05379 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -62,6 +62,7 @@ def create_app(config_name=None): from app.routes.tasks import tasks_bp from app.routes.download import download_bp from app.routes.pdf_tools import pdf_tools_bp + from app.routes.flowchart import flowchart_bp app.register_blueprint(health_bp, url_prefix="/api") app.register_blueprint(convert_bp, url_prefix="/api/convert") @@ -69,6 +70,7 @@ def create_app(config_name=None): app.register_blueprint(image_bp, url_prefix="/api/image") app.register_blueprint(video_bp, url_prefix="/api/video") app.register_blueprint(pdf_tools_bp, url_prefix="/api/pdf-tools") + app.register_blueprint(flowchart_bp, url_prefix="/api/flowchart") app.register_blueprint(tasks_bp, url_prefix="/api/tasks") app.register_blueprint(download_bp, url_prefix="/api/download") diff --git a/backend/app/routes/flowchart.py b/backend/app/routes/flowchart.py new file mode 100644 index 0000000..5d7886d --- /dev/null +++ b/backend/app/routes/flowchart.py @@ -0,0 +1,103 @@ +"""Flowchart route — POST /api/flowchart/extract, /chat, /generate-manual.""" +import logging +from flask import Blueprint, request, jsonify + +from app.extensions import limiter +from app.utils.file_validator import validate_file, FileValidationError +from app.utils.sanitizer import generate_safe_path +from app.tasks.flowchart_tasks import extract_flowchart_task + +logger = logging.getLogger(__name__) + +flowchart_bp = Blueprint("flowchart", __name__) + + +@flowchart_bp.route("/extract", methods=["POST"]) +@limiter.limit("10/minute") +def extract_flowchart_route(): + """ + Extract procedures from a PDF and generate flowcharts. + + Accepts: multipart/form-data with a single 'file' field (PDF) + Returns: JSON with task_id for polling + """ + if "file" not in request.files: + return jsonify({"error": "No file uploaded."}), 400 + + file = request.files["file"] + + try: + original_filename, ext = validate_file(file, allowed_types=["pdf"]) + except FileValidationError as e: + return jsonify({"error": e.message}), e.code + + task_id, input_path = generate_safe_path(ext) + file.save(input_path) + + task = extract_flowchart_task.delay(input_path, task_id, original_filename) + + return jsonify({ + "task_id": task.id, + "message": "Flowchart extraction started.", + }), 202 + + +@flowchart_bp.route("/chat", methods=["POST"]) +@limiter.limit("20/minute") +def flowchart_chat_route(): + """ + AI chat endpoint for flowchart improvement suggestions. + + Accepts JSON: { message, flow_id, flow_data } + Returns JSON: { reply, updated_flow? } + """ + data = request.get_json(silent=True) + if not data or not data.get("message"): + return jsonify({"error": "Message is required."}), 400 + + message = str(data["message"])[:2000] # Limit message length + flow_data = data.get("flow_data") + + try: + from app.services.ai_chat_service import chat_about_flowchart + result = chat_about_flowchart(message, flow_data) + return jsonify(result), 200 + except Exception as e: + logger.error(f"Flowchart chat error: {e}") + return jsonify({"reply": "Sorry, I couldn't process your request. Please try again."}), 200 + + +@flowchart_bp.route("/generate-manual", methods=["POST"]) +@limiter.limit("10/minute") +def generate_manual_flowchart_route(): + """ + Generate a flowchart from manually specified procedure data. + + Accepts JSON: { title, description, pages (list of page texts) } + Returns JSON: { flowchart } + """ + data = request.get_json(silent=True) + if not data or not data.get("title"): + return jsonify({"error": "Title is required."}), 400 + + title = str(data["title"])[:200] + description = str(data.get("description", ""))[:500] + page_texts = data.get("pages", []) + + from app.services.flowchart_service import generate_flowchart + + # Build a synthetic procedure + procedure = { + "id": f"manual-{hash(title) % 100000}", + "title": title, + "description": description, + "pages": list(range(1, len(page_texts) + 1)), + } + + pages_data = [ + {"page": i + 1, "text": str(p.get("text", ""))[:5000]} + for i, p in enumerate(page_texts) + ] + + flowchart = generate_flowchart(procedure, pages_data) + return jsonify({"flowchart": flowchart}), 200 diff --git a/backend/app/routes/pdf_tools.py b/backend/app/routes/pdf_tools.py index ba26656..308e47d 100644 --- a/backend/app/routes/pdf_tools.py +++ b/backend/app/routes/pdf_tools.py @@ -93,6 +93,11 @@ def split_pdf_route(): if mode not in ("all", "range"): mode = "all" + if mode == "range" and (not pages or not pages.strip()): + return jsonify({ + "error": "Please specify which pages to extract (e.g. 1,3,5-8)." + }), 400 + try: original_filename, ext = validate_file(file, allowed_types=["pdf"]) except FileValidationError as e: diff --git a/backend/app/routes/tasks.py b/backend/app/routes/tasks.py index faf143f..bd776e7 100644 --- a/backend/app/routes/tasks.py +++ b/backend/app/routes/tasks.py @@ -3,11 +3,13 @@ from flask import Blueprint, jsonify from celery.result import AsyncResult from app.extensions import celery +from app.middleware.rate_limiter import limiter tasks_bp = Blueprint("tasks", __name__) @tasks_bp.route("//status", methods=["GET"]) +@limiter.limit("300/minute", override_defaults=True) def get_task_status(task_id: str): """ Get the status of an async task. diff --git a/backend/app/services/ai_chat_service.py b/backend/app/services/ai_chat_service.py new file mode 100644 index 0000000..8055331 --- /dev/null +++ b/backend/app/services/ai_chat_service.py @@ -0,0 +1,142 @@ +"""AI Chat Service — OpenRouter integration for flowchart improvement.""" +import os +import json +import logging +import requests + +logger = logging.getLogger(__name__) + +# Configuration +OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "") +OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "meta-llama/llama-3-8b-instruct") +OPENROUTER_BASE_URL = os.getenv( + "OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1/chat/completions" +) + +SYSTEM_PROMPT = """You are a flowchart improvement assistant. You help users improve their flowcharts by: +1. Suggesting better step titles and descriptions +2. Identifying missing steps or decision points +3. Recommending better flow structure +4. Simplifying complex flows + +When the user asks you to modify the flowchart, respond with your suggestion in plain text. +Keep responses concise and actionable. Reply in the same language the user uses.""" + + +def chat_about_flowchart(message: str, flow_data: dict | None = None) -> dict: + """ + Send a message to the AI about a flowchart and get improvement suggestions. + + Args: + message: User message + flow_data: Current flowchart data (optional) + + Returns: + {"reply": "...", "updated_flow": {...} | None} + """ + if not OPENROUTER_API_KEY: + return { + "reply": _fallback_response(message, flow_data), + "updated_flow": None, + } + + # Build context + context = "" + if flow_data: + steps_summary = [] + for s in flow_data.get("steps", []): + steps_summary.append( + f"- [{s.get('type', 'process')}] {s.get('title', '')}" + ) + context = ( + f"\nCurrent flowchart: {flow_data.get('title', 'Untitled')}\n" + f"Steps:\n" + "\n".join(steps_summary) + ) + + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": f"{message}{context}"}, + ] + + try: + response = requests.post( + OPENROUTER_BASE_URL, + headers={ + "Authorization": f"Bearer {OPENROUTER_API_KEY}", + "Content-Type": "application/json", + }, + json={ + "model": OPENROUTER_MODEL, + "messages": messages, + "max_tokens": 500, + "temperature": 0.7, + }, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + reply = ( + data.get("choices", [{}])[0] + .get("message", {}) + .get("content", "") + .strip() + ) + + if not reply: + reply = "I couldn't generate a response. Please try again." + + return {"reply": reply, "updated_flow": None} + + except requests.exceptions.Timeout: + logger.warning("OpenRouter API timeout") + return { + "reply": "The AI service is taking too long. Please try again.", + "updated_flow": None, + } + except Exception as e: + logger.error(f"OpenRouter API error: {e}") + return { + "reply": _fallback_response(message, flow_data), + "updated_flow": None, + } + + +def _fallback_response(message: str, flow_data: dict | None) -> str: + """Provide a helpful response when the AI API is unavailable.""" + msg_lower = message.lower() + + if flow_data: + steps = flow_data.get("steps", []) + title = flow_data.get("title", "your flowchart") + step_count = len(steps) + decision_count = sum(1 for s in steps if s.get("type") == "decision") + + if any( + w in msg_lower for w in ["simplify", "reduce", "shorter", "بسط", "اختصر"] + ): + return ( + f"Your flowchart '{title}' has {step_count} steps. " + f"To simplify, consider merging consecutive process steps " + f"that perform related actions into a single step." + ) + + if any( + w in msg_lower for w in ["missing", "add", "more", "ناقص", "أضف"] + ): + return ( + f"Your flowchart has {decision_count} decision points. " + f"Consider adding error handling or validation steps " + f"between critical process nodes." + ) + + return ( + f"Your flowchart '{title}' contains {step_count} steps " + f"({decision_count} decisions). To get AI-powered suggestions, " + f"please configure the OPENROUTER_API_KEY environment variable." + ) + + return ( + "AI chat requires the OPENROUTER_API_KEY to be configured. " + "Please set up the environment variable for full AI functionality." + ) diff --git a/backend/app/services/flowchart_service.py b/backend/app/services/flowchart_service.py new file mode 100644 index 0000000..ba5b7ce --- /dev/null +++ b/backend/app/services/flowchart_service.py @@ -0,0 +1,410 @@ +"""Flowchart service — Extract procedures from PDF and generate flowchart data.""" +import os +import re +import json +import logging + +logger = logging.getLogger(__name__) + + +class FlowchartError(Exception): + """Custom exception for flowchart operations.""" + pass + + +# --------------------------------------------------------------------------- +# Heuristic keywords that signal procedural content +# --------------------------------------------------------------------------- +_PROCEDURE_KEYWORDS = [ + "procedure", "protocol", "checklist", "sequence", "instruction", + "steps", "process", "workflow", "troubleshoot", "maintenance", + "startup", "shutdown", "emergency", "inspection", "replacement", + "installation", "calibration", "operation", "safety", "guide", +] + +_STEP_PATTERNS = re.compile( + r"(?:^|\n)\s*(?:" + r"(?:step\s*\d+)|" # Step 1, Step 2 … + r"(?:\d+[\.\)]\s+)|" # 1. or 1) … + r"(?:[a-z][\.\)]\s+)|" # a. or a) … + r"(?:•\s)|" # bullet • + r"(?:-\s)|" # dash - + r"(?:✓\s)" # checkmark ✓ + r")", + re.IGNORECASE, +) + +_DECISION_KEYWORDS = re.compile( + r"\b(?:if|whether|check|verify|confirm|decide|inspect|compare|ensure|" + r"is\s+\w+\s*\?|does|should|can)\b", + re.IGNORECASE, +) + + +def extract_text_from_pdf(input_path: str) -> list[dict]: + """ + Extract text from each page of a PDF. + + Returns: + List of dicts: [{"page": 1, "text": "..."}, ...] + """ + try: + from PyPDF2 import PdfReader + + if not os.path.exists(input_path): + raise FlowchartError(f"File not found: {input_path}") + + reader = PdfReader(input_path) + pages = [] + for i, page in enumerate(reader.pages, start=1): + text = page.extract_text() or "" + pages.append({"page": i, "text": text.strip()}) + + return pages + + except FlowchartError: + raise + except Exception as e: + raise FlowchartError(f"Failed to extract text from PDF: {str(e)}") + + +def identify_procedures(pages: list[dict]) -> list[dict]: + """ + Analyse extracted PDF text and identify procedures/sections. + + Uses heuristic analysis: + 1. Look for headings (lines in UPPER CASE or short bold-like lines) + 2. Match procedure keywords + 3. Group consecutive pages under the same heading + + Returns: + List of procedures: [ + { + "id": "proc-1", + "title": "Emergency Shutdown Protocol", + "description": "Extracted first paragraph...", + "pages": [8, 9], + "step_count": 6 + }, + ... + ] + """ + procedures = [] + current_proc = None + proc_counter = 0 + + for page_data in pages: + text = page_data["text"] + page_num = page_data["page"] + + if not text: + continue + + lines = text.split("\n") + heading_candidates = [] + + for line in lines: + stripped = line.strip() + if not stripped: + continue + + # Heading heuristic: short line, mostly uppercase or title-like + is_heading = ( + len(stripped) < 80 + and ( + stripped.isupper() + or (stripped == stripped.title() and len(stripped.split()) <= 8) + or any(kw in stripped.lower() for kw in _PROCEDURE_KEYWORDS) + ) + and not stripped.endswith(",") + ) + + if is_heading: + heading_candidates.append(stripped) + + # Check if this page has procedural content + has_steps = bool(_STEP_PATTERNS.search(text)) + has_keywords = any(kw in text.lower() for kw in _PROCEDURE_KEYWORDS) + + if heading_candidates and (has_steps or has_keywords): + best_heading = heading_candidates[0] + + # Check if this is a continuation of the current procedure + if current_proc and _is_continuation(current_proc["title"], best_heading, text): + current_proc["pages"].append(page_num) + current_proc["_text"] += "\n" + text + else: + # Save previous procedure + if current_proc: + _finalize_procedure(current_proc) + procedures.append(current_proc) + + proc_counter += 1 + first_paragraph = _extract_first_paragraph(text, best_heading) + current_proc = { + "id": f"proc-{proc_counter}", + "title": _clean_title(best_heading), + "description": first_paragraph, + "pages": [page_num], + "_text": text, + } + elif current_proc and has_steps: + # Continuation — same procedure on next page + current_proc["pages"].append(page_num) + current_proc["_text"] += "\n" + text + + # Don't forget the last one + if current_proc: + _finalize_procedure(current_proc) + procedures.append(current_proc) + + # If no procedures found via headings, try splitting by page with step content + if not procedures: + procedures = _fallback_extraction(pages) + + return procedures + + +def generate_flowchart(procedure: dict, page_texts: list[dict]) -> dict: + """ + Generate a flowchart (list of nodes + connections) from a procedure. + + Args: + procedure: Procedure dict with id, title, pages + page_texts: All page text data + + Returns: + Flowchart dict: { + "id": "flow-1", + "procedureId": "proc-1", + "title": "...", + "steps": [ {id, type, title, description, connections}, ... ] + } + """ + # Gather text for the procedure's pages + text = "" + for pt in page_texts: + if pt["page"] in procedure["pages"]: + text += pt["text"] + "\n" + + steps = _extract_steps_from_text(text, procedure["title"]) + + return { + "id": f"flow-{procedure['id']}", + "procedureId": procedure["id"], + "title": procedure["title"], + "steps": steps, + } + + +def extract_and_generate(input_path: str) -> dict: + """ + Full pipeline: extract text → identify procedures → generate flowcharts. + + Returns: + { + "procedures": [...], + "flowcharts": [...], + "total_pages": int + } + """ + pages = extract_text_from_pdf(input_path) + procedures = identify_procedures(pages) + + flowcharts = [] + for proc in procedures: + flow = generate_flowchart(proc, pages) + flowcharts.append(flow) + + # Remove internal text field + for proc in procedures: + proc.pop("_text", None) + + return { + "procedures": procedures, + "flowcharts": flowcharts, + "total_pages": len(pages), + "pages": pages, + } + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _is_continuation(current_title: str, new_heading: str, text: str) -> bool: + """Check if a page is a continuation of the current procedure.""" + continued_markers = ["(continued)", "(cont.)", "(cont'd)"] + heading_lower = new_heading.lower() + + # Explicit continuation marker + if any(m in heading_lower for m in continued_markers): + return True + + # Same title repeated + if current_title.lower().rstrip() in heading_lower: + return True + + return False + + +def _clean_title(title: str) -> str: + """Clean up a procedure title.""" + # Remove continuation markers + title = re.sub(r"\s*\(continued\).*", "", title, flags=re.IGNORECASE) + title = re.sub(r"\s*\(cont[\.\']?d?\).*", "", title, flags=re.IGNORECASE) + # Remove leading numbers like "3.1" + title = re.sub(r"^\d+[\.\)]\s*", "", title) + title = re.sub(r"^\d+\.\d+\s*", "", title) + return title.strip() + + +def _extract_first_paragraph(text: str, heading: str) -> str: + """Extract the first meaningful paragraph after a heading.""" + idx = text.find(heading) + if idx >= 0: + after_heading = text[idx + len(heading):].strip() + else: + after_heading = text.strip() + + lines = after_heading.split("\n") + paragraph = [] + for line in lines: + stripped = line.strip() + if not stripped: + if paragraph: + break + continue + if stripped.isupper() and len(stripped) > 10: + break + paragraph.append(stripped) + + desc = " ".join(paragraph)[:200] + return desc if desc else "Procedural content extracted from document." + + +def _finalize_procedure(proc: dict): + """Calculate step count from the accumulated text.""" + text = proc.get("_text", "") + matches = _STEP_PATTERNS.findall(text) + proc["step_count"] = max(len(matches), 2) + + +def _fallback_extraction(pages: list[dict]) -> list[dict]: + """When no heading-based procedures found, detect pages with step-like content.""" + procedures = [] + proc_counter = 0 + + for page_data in pages: + text = page_data["text"] + if not text: + continue + + has_steps = bool(_STEP_PATTERNS.search(text)) + if has_steps: + proc_counter += 1 + first_line = text.split("\n")[0].strip()[:60] + procedures.append({ + "id": f"proc-{proc_counter}", + "title": first_line or f"Procedure (Page {page_data['page']})", + "description": text[:150].strip(), + "pages": [page_data["page"]], + "step_count": len(_STEP_PATTERNS.findall(text)), + }) + + return procedures + + +def _extract_steps_from_text(text: str, procedure_title: str) -> list[dict]: + """ + Parse text into flowchart steps (nodes). + + Strategy: + 1. Split text by numbered/bulleted lines + 2. Classify each as process or decision + 3. Add start/end nodes + 4. Wire connections + """ + lines = text.split("\n") + raw_steps = [] + current_step_lines = [] + step_counter = 0 + + for line in lines: + stripped = line.strip() + if not stripped: + continue + + # Is this the start of a new step? + is_step_start = bool(re.match( + r"^\s*(?:\d+[\.\)]\s+|[a-z][\.\)]\s+|•\s|-\s|✓\s|step\s*\d+)", + stripped, + re.IGNORECASE, + )) + + if is_step_start: + if current_step_lines: + raw_steps.append(" ".join(current_step_lines)) + current_step_lines = [re.sub(r"^\s*(?:\d+[\.\)]\s*|[a-z][\.\)]\s*|•\s*|-\s*|✓\s*|step\s*\d+[:\.\)]\s*)", "", stripped, flags=re.IGNORECASE)] + elif current_step_lines: + current_step_lines.append(stripped) + + if current_step_lines: + raw_steps.append(" ".join(current_step_lines)) + + # Limit to reasonable number of steps + if len(raw_steps) > 15: + raw_steps = raw_steps[:15] + + # Build flowchart nodes + nodes = [] + step_id = 0 + + # Start node + step_id += 1 + nodes.append({ + "id": str(step_id), + "type": "start", + "title": f"Begin: {procedure_title[:40]}", + "description": "Start of procedure", + "connections": [str(step_id + 1)] if raw_steps else [], + }) + + for i, step_text in enumerate(raw_steps): + step_id += 1 + # Classify as decision or process + is_decision = bool(_DECISION_KEYWORDS.search(step_text)) + + node_type = "decision" if is_decision else "process" + title = step_text[:60] + description = step_text[:150] + + connections = [] + if i < len(raw_steps) - 1: + if is_decision: + # Decision: Yes goes to next, No could loop back or skip + connections = [str(step_id + 1)] + else: + connections = [str(step_id + 1)] + else: + connections = [str(step_id + 1)] # Connect to end + + nodes.append({ + "id": str(step_id), + "type": node_type, + "title": title, + "description": description, + "connections": connections, + }) + + # End node + step_id += 1 + nodes.append({ + "id": str(step_id), + "type": "end", + "title": "Procedure Complete", + "description": "End of procedure", + "connections": [], + }) + + return nodes diff --git a/backend/app/services/pdf_tools_service.py b/backend/app/services/pdf_tools_service.py index 9332a32..105bb84 100644 --- a/backend/app/services/pdf_tools_service.py +++ b/backend/app/services/pdf_tools_service.py @@ -140,20 +140,75 @@ def split_pdf( def _parse_page_range(spec: str, total: int) -> list[int]: """Parse a page specification like '1,3,5-8' into 0-based indices.""" + if not spec or not spec.strip(): + raise PDFToolsError("Please specify at least one page (e.g. 1,3,5-8).") + indices = set() - for part in spec.split(","): - part = part.strip() + invalid_tokens = [] + out_of_range_tokens = [] + + for raw_part in spec.split(","): + part = raw_part.strip() + + if not part: + continue + if "-" in part: + if part.count("-") != 1: + invalid_tokens.append(part) + continue + start_s, end_s = part.split("-", 1) - start = max(1, int(start_s.strip())) - end = min(total, int(end_s.strip())) + start_s = start_s.strip() + end_s = end_s.strip() + + if not start_s.isdigit() or not end_s.isdigit(): + invalid_tokens.append(part) + continue + + start = int(start_s) + end = int(end_s) + + if start > end: + invalid_tokens.append(part) + continue + + if start < 1 or end > total: + out_of_range_tokens.append(f"{start}-{end}") + continue + indices.update(range(start - 1, end)) else: + if not part.isdigit(): + invalid_tokens.append(part) + continue + page = int(part) - if 1 <= page <= total: - indices.add(page - 1) + if page < 1 or page > total: + out_of_range_tokens.append(str(page)) + continue + + indices.add(page - 1) + + if invalid_tokens: + tokens = ", ".join(invalid_tokens) + raise PDFToolsError( + f"Invalid page format: {tokens}. Use a format like 1,3,5-8." + ) + + if out_of_range_tokens: + tokens = ", ".join(out_of_range_tokens) + page_word = "page" if total == 1 else "pages" + raise PDFToolsError( + f"Selected pages ({tokens}) are out of range. This PDF has only {total} {page_word}." + ) + if not indices: - raise PDFToolsError("No valid pages specified.") + page_word = "page" if total == 1 else "pages" + raise PDFToolsError( + f"No pages selected. This PDF has {total} {page_word}." + ) + return sorted(indices) diff --git a/backend/app/tasks/flowchart_tasks.py b/backend/app/tasks/flowchart_tasks.py new file mode 100644 index 0000000..04a3fdd --- /dev/null +++ b/backend/app/tasks/flowchart_tasks.py @@ -0,0 +1,79 @@ +"""Celery tasks for PDF-to-Flowchart extraction and generation.""" +import os +import json +import logging + +from app.extensions import celery +from app.services.flowchart_service import extract_and_generate, FlowchartError +from app.services.storage_service import storage +from app.utils.sanitizer import cleanup_task_files + +logger = logging.getLogger(__name__) + + +def _cleanup(task_id: str): + cleanup_task_files(task_id, keep_outputs=not storage.use_s3) + + +@celery.task(bind=True, name="app.tasks.flowchart_tasks.extract_flowchart_task") +def extract_flowchart_task( + self, input_path: str, task_id: str, original_filename: str +): + """ + Async task: Extract procedures from PDF and generate flowcharts. + + Returns a JSON result containing procedures and their flowcharts. + """ + output_dir = os.path.join("/tmp/outputs", task_id) + os.makedirs(output_dir, exist_ok=True) + + try: + self.update_state( + state="PROCESSING", + meta={"step": "Extracting text from PDF..."}, + ) + + result = extract_and_generate(input_path) + + self.update_state( + state="PROCESSING", + meta={"step": "Saving flowchart data..."}, + ) + + # Save flowchart JSON to a file and upload + output_path = os.path.join(output_dir, f"{task_id}_flowcharts.json") + with open(output_path, "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False, indent=2) + + s3_key = storage.upload_file(output_path, task_id, folder="outputs") + download_url = storage.generate_presigned_url( + s3_key, original_filename="flowcharts.json" + ) + + final_result = { + "status": "completed", + "download_url": download_url, + "filename": "flowcharts.json", + "procedures": result["procedures"], + "flowcharts": result["flowcharts"], + "pages": result["pages"], + "total_pages": result["total_pages"], + "procedures_count": len(result["procedures"]), + } + + _cleanup(task_id) + logger.info( + f"Task {task_id}: Flowchart extraction completed — " + f"{len(result['procedures'])} procedures, " + f"{result['total_pages']} pages" + ) + return final_result + + except FlowchartError as e: + logger.error(f"Task {task_id}: Flowchart error — {e}") + _cleanup(task_id) + return {"status": "failed", "error": str(e)} + except Exception as e: + logger.error(f"Task {task_id}: Unexpected error — {e}") + _cleanup(task_id) + return {"status": "failed", "error": "An unexpected error occurred."} diff --git a/backend/app/utils/file_validator.py b/backend/app/utils/file_validator.py index 00b7d11..389fdc8 100644 --- a/backend/app/utils/file_validator.py +++ b/backend/app/utils/file_validator.py @@ -1,7 +1,12 @@ """File validation utilities — multi-layer security checks.""" import os -import magic +try: + import magic + HAS_MAGIC = True +except (ImportError, OSError): + HAS_MAGIC = False + from flask import current_app from werkzeug.utils import secure_filename @@ -72,18 +77,19 @@ def validate_file(file_storage, allowed_types: list[str] | None = None): if file_size == 0: raise FileValidationError("File is empty.") - # Layer 4: Check MIME type using magic bytes + # Layer 4: Check MIME type using magic bytes (if libmagic is available) file_header = file_storage.read(8192) file_storage.seek(0) - detected_mime = magic.from_buffer(file_header, mime=True) - expected_mimes = valid_extensions.get(ext, []) + if HAS_MAGIC: + detected_mime = magic.from_buffer(file_header, mime=True) + expected_mimes = valid_extensions.get(ext, []) - if detected_mime not in expected_mimes: - raise FileValidationError( - f"File content does not match extension '.{ext}'. " - f"Detected type: {detected_mime}" - ) + if detected_mime not in expected_mimes: + raise FileValidationError( + f"File content does not match extension '.{ext}'. " + f"Detected type: {detected_mime}" + ) # Layer 5: Additional content checks for specific types if ext == "pdf": diff --git a/backend/config/__init__.py b/backend/config/__init__.py index dd88567..4518df8 100644 --- a/backend/config/__init__.py +++ b/backend/config/__init__.py @@ -66,6 +66,13 @@ class BaseConfig: RATELIMIT_STORAGE_URI = os.getenv("REDIS_URL", "redis://redis:6379/0") RATELIMIT_DEFAULT = "100/hour" + # OpenRouter AI + OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "") + OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "meta-llama/llama-3-8b-instruct") + OPENROUTER_BASE_URL = os.getenv( + "OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1/chat/completions" + ) + class DevelopmentConfig(BaseConfig): """Development configuration.""" @@ -88,6 +95,15 @@ class TestingConfig(BaseConfig): UPLOAD_FOLDER = "/tmp/test_uploads" OUTPUT_FOLDER = "/tmp/test_outputs" + # Disable Redis-backed rate limiting; use in-memory instead + RATELIMIT_STORAGE_URI = "memory://" + RATELIMIT_ENABLED = False + + # Use in-memory transport for Celery so tests don't need Redis + CELERY_BROKER_URL = "memory://" + CELERY_RESULT_BACKEND = "cache+memory://" + REDIS_URL = "memory://" + config = { "development": DevelopmentConfig, diff --git a/backend/requirements.txt b/backend/requirements.txt index 66e1284..fec0f0e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -24,9 +24,18 @@ pdf2image>=1.16,<2.0 # AWS boto3>=1.34,<2.0 +# HTTP Client +requests>=2.31,<3.0 + # Security werkzeug>=3.0,<4.0 # Testing pytest>=8.0,<9.0 pytest-flask>=1.3,<2.0 +pytest>=7.4.0 +pytest-cov>=4.1.0 +pytest-mock>=3.11.0 +requests-mock>=1.11.0 +fakeredis>=2.18.0 +httpx>=0.24.0 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 7356692..62279d9 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,5 +1,8 @@ +import io import os +import shutil import pytest +from unittest.mock import patch, MagicMock from app import create_app @@ -7,12 +10,22 @@ from app import create_app def app(): """Create application for testing.""" os.environ['FLASK_ENV'] = 'testing' - app = create_app() + app = create_app('testing') app.config.update({ 'TESTING': True, + 'UPLOAD_FOLDER': '/tmp/test_uploads', + 'OUTPUT_FOLDER': '/tmp/test_outputs', }) + # Create temp directories + os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) + os.makedirs(app.config['OUTPUT_FOLDER'], exist_ok=True) + yield app + # Cleanup temp directories + shutil.rmtree(app.config['UPLOAD_FOLDER'], ignore_errors=True) + shutil.rmtree(app.config['OUTPUT_FOLDER'], ignore_errors=True) + @pytest.fixture def client(app): @@ -24,3 +37,72 @@ def client(app): def runner(app): """Flask test CLI runner.""" return app.test_cli_runner() + + +# --------------------------------------------------------------------------- +# Helpers: Create realistic test files with valid magic bytes +# --------------------------------------------------------------------------- + +def make_pdf_bytes() -> bytes: + """Create minimal valid PDF bytes for testing.""" + return ( + b"%PDF-1.4\n" + b"1 0 obj<>endobj\n" + b"2 0 obj<>endobj\n" + b"3 0 obj<>endobj\n" + b"xref\n0 4\n" + b"0000000000 65535 f \n" + b"0000000009 00000 n \n" + b"0000000058 00000 n \n" + b"0000000115 00000 n \n" + b"trailer<>\n" + b"startxref\n190\n%%EOF" + ) + + +def make_png_bytes() -> bytes: + """Create minimal valid PNG bytes for testing.""" + # 1x1 white pixel PNG + return ( + b"\x89PNG\r\n\x1a\n" # PNG signature + b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" + b"\x08\x02\x00\x00\x00\x90wS\xde" + b"\x00\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05" + b"\x18\xd8N\x00\x00\x00\x00IEND\xaeB`\x82" + ) + + +def make_jpeg_bytes() -> bytes: + """Create minimal valid JPEG bytes for testing.""" + # Minimal JPEG header + return ( + b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01" + b"\x00\x01\x00\x00\xff\xd9" + ) + + +@pytest.fixture +def pdf_file(): + """Create a PDF file-like object for upload testing.""" + return io.BytesIO(make_pdf_bytes()), 'test.pdf' + + +@pytest.fixture +def png_file(): + """Create a PNG file-like object for upload testing.""" + return io.BytesIO(make_png_bytes()), 'test.png' + + +@pytest.fixture +def mock_celery_task(): + """Mock a Celery AsyncResult for task dispatch tests.""" + mock_task = MagicMock() + mock_task.id = 'test-task-id-12345' + return mock_task + + +@pytest.fixture +def mock_magic(): + """Mock python-magic to return expected MIME types.""" + with patch('app.utils.file_validator.magic') as mock_m: + yield mock_m diff --git a/backend/tests/test_compress_service.py b/backend/tests/test_compress_service.py new file mode 100644 index 0000000..9292558 --- /dev/null +++ b/backend/tests/test_compress_service.py @@ -0,0 +1,74 @@ +"""Tests for PDF compression service.""" +import os +from unittest.mock import patch, MagicMock +import pytest + +from app.services.compress_service import compress_pdf, PDFCompressionError + + +class TestCompressService: + def test_compress_pdf_invalid_quality_defaults(self, app): + """Invalid quality should default to medium.""" + with app.app_context(): + with patch('app.services.compress_service.subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stderr='') + # Create temp input file + input_path = '/tmp/test_compress_input.pdf' + output_path = '/tmp/test_compress_output.pdf' + os.makedirs(os.path.dirname(output_path), exist_ok=True) + with open(input_path, 'wb') as f: + f.write(b'%PDF-1.4 test') + with open(output_path, 'wb') as f: + f.write(b'%PDF-1.4 compressed') + + result = compress_pdf(input_path, output_path, quality="invalid") + # Should have used "medium" default (/ebook) + cmd_args = mock_run.call_args[0][0] + assert any('/ebook' in str(arg) for arg in cmd_args) + + # Cleanup + os.unlink(input_path) + os.unlink(output_path) + + def test_compress_pdf_returns_stats(self, app): + """Should return original_size, compressed_size, reduction_percent.""" + with app.app_context(): + input_path = '/tmp/test_stats_input.pdf' + output_path = '/tmp/test_stats_output.pdf' + + # Create input (100 bytes) + with open(input_path, 'wb') as f: + f.write(b'%PDF-1.4' + b'\x00' * 92) + + with patch('app.services.compress_service.subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stderr='') + # Create smaller output (50 bytes) + with open(output_path, 'wb') as f: + f.write(b'%PDF-1.4' + b'\x00' * 42) + + result = compress_pdf(input_path, output_path, 'medium') + assert 'original_size' in result + assert 'compressed_size' in result + assert result['original_size'] == 100 + assert result['compressed_size'] == 50 + + os.unlink(input_path) + os.unlink(output_path) + + def test_compress_pdf_gs_failure_raises(self, app): + """Should raise PDFCompressionError when Ghostscript fails.""" + with app.app_context(): + input_path = '/tmp/test_fail_input.pdf' + output_path = '/tmp/test_fail_output.pdf' + + with open(input_path, 'wb') as f: + f.write(b'%PDF-1.4 test') + + with patch('app.services.compress_service.subprocess.run') as mock_run: + mock_run.return_value = MagicMock( + returncode=1, stderr='Error processing PDF' + ) + with pytest.raises(PDFCompressionError): + compress_pdf(input_path, output_path, 'medium') + + os.unlink(input_path) \ No newline at end of file diff --git a/backend/tests/test_compress_tasks.py b/backend/tests/test_compress_tasks.py new file mode 100644 index 0000000..283bb5e --- /dev/null +++ b/backend/tests/test_compress_tasks.py @@ -0,0 +1,74 @@ +"""Tests for PDF compression Celery tasks.""" +import io +from unittest.mock import MagicMock + + +class TestCompressTaskRoute: + def test_compress_pdf_no_file(self, client): + """POST /api/compress/pdf without file should return 400.""" + response = client.post('/api/compress/pdf') + assert response.status_code == 400 + assert response.get_json()['error'] == 'No file provided.' + + def test_compress_pdf_with_quality(self, client, monkeypatch): + """Should accept quality parameter (low/medium/high).""" + mock_task = MagicMock() + mock_task.id = 'compress-task-id' + + monkeypatch.setattr( + 'app.routes.compress.validate_file', + lambda f, allowed_types: ('test.pdf', 'pdf'), + ) + monkeypatch.setattr( + 'app.routes.compress.generate_safe_path', + lambda ext, folder_type: ('compress-task-id', '/tmp/test.pdf'), + ) + monkeypatch.setattr( + 'app.routes.compress.compress_pdf_task.delay', + MagicMock(return_value=mock_task), + ) + + data = { + 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), + 'quality': 'high', + } + response = client.post( + '/api/compress/pdf', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 202 + assert response.get_json()['task_id'] == 'compress-task-id' + + def test_compress_pdf_invalid_quality_defaults(self, client, monkeypatch): + """Invalid quality should default to medium.""" + mock_task = MagicMock() + mock_task.id = 'compress-default-id' + mock_delay = MagicMock(return_value=mock_task) + + monkeypatch.setattr( + 'app.routes.compress.validate_file', + lambda f, allowed_types: ('test.pdf', 'pdf'), + ) + monkeypatch.setattr( + 'app.routes.compress.generate_safe_path', + lambda ext, folder_type: ('id', '/tmp/test.pdf'), + ) + monkeypatch.setattr( + 'app.routes.compress.compress_pdf_task.delay', + mock_delay, + ) + + data = { + 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), + 'quality': 'ultra', # invalid + } + response = client.post( + '/api/compress/pdf', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 202 + # The route defaults invalid quality to "medium" + call_args = mock_delay.call_args[0] + assert call_args[3] == 'medium' \ No newline at end of file diff --git a/backend/tests/test_convert_tasks.py b/backend/tests/test_convert_tasks.py new file mode 100644 index 0000000..a7c7c45 --- /dev/null +++ b/backend/tests/test_convert_tasks.py @@ -0,0 +1,72 @@ +"""Tests for file conversion Celery task routes.""" +import io +from unittest.mock import MagicMock + + +class TestConvertTaskRoutes: + def test_pdf_to_word_success(self, client, monkeypatch): + """Should return 202 with task_id for valid PDF upload.""" + mock_task = MagicMock() + mock_task.id = 'convert-pdf-word-id' + + monkeypatch.setattr( + 'app.routes.convert.validate_file', + lambda f, allowed_types: ('document.pdf', 'pdf'), + ) + monkeypatch.setattr( + 'app.routes.convert.generate_safe_path', + lambda ext, folder_type: ('convert-pdf-word-id', '/tmp/test.pdf'), + ) + monkeypatch.setattr( + 'app.routes.convert.convert_pdf_to_word.delay', + MagicMock(return_value=mock_task), + ) + + data = {'file': (io.BytesIO(b'%PDF-1.4'), 'document.pdf')} + response = client.post( + '/api/convert/pdf-to-word', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 202 + body = response.get_json() + assert body['task_id'] == 'convert-pdf-word-id' + assert 'message' in body + + def test_word_to_pdf_success(self, client, monkeypatch): + """Should return 202 with task_id for valid Word upload.""" + mock_task = MagicMock() + mock_task.id = 'convert-word-pdf-id' + + monkeypatch.setattr( + 'app.routes.convert.validate_file', + lambda f, allowed_types: ('report.docx', 'docx'), + ) + monkeypatch.setattr( + 'app.routes.convert.generate_safe_path', + lambda ext, folder_type: ('convert-word-pdf-id', '/tmp/test.docx'), + ) + monkeypatch.setattr( + 'app.routes.convert.convert_word_to_pdf.delay', + MagicMock(return_value=mock_task), + ) + + data = {'file': (io.BytesIO(b'PK\x03\x04'), 'report.docx')} + response = client.post( + '/api/convert/word-to-pdf', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 202 + body = response.get_json() + assert body['task_id'] == 'convert-word-pdf-id' + + def test_pdf_to_word_no_file(self, client): + """Should return 400 when no file provided.""" + response = client.post('/api/convert/pdf-to-word') + assert response.status_code == 400 + + def test_word_to_pdf_no_file(self, client): + """Should return 400 when no file provided.""" + response = client.post('/api/convert/word-to-pdf') + assert response.status_code == 400 \ No newline at end of file diff --git a/backend/tests/test_download.py b/backend/tests/test_download.py new file mode 100644 index 0000000..df7cabe --- /dev/null +++ b/backend/tests/test_download.py @@ -0,0 +1,49 @@ +"""Tests for file download route.""" +import os + + +class TestDownload: + def test_download_nonexistent_file(self, client): + """Should return 404 for missing file.""" + response = client.get('/api/download/some-task-id/output.pdf') + assert response.status_code == 404 + + def test_download_path_traversal_task_id(self, client): + """Should reject task_id with path traversal characters.""" + response = client.get('/api/download/../etc/output.pdf') + # Flask will handle this — either 400 or 404 + assert response.status_code in (400, 404) + + def test_download_path_traversal_filename(self, client): + """Should reject filename with path traversal characters.""" + response = client.get('/api/download/valid-id/../../etc/passwd') + assert response.status_code in (400, 404) + + def test_download_valid_file(self, client, app): + """Should serve file if it exists.""" + task_id = 'test-download-id' + filename = 'output.pdf' + + # Create the file in the output directory + output_dir = os.path.join(app.config['OUTPUT_FOLDER'], task_id) + os.makedirs(output_dir, exist_ok=True) + file_path = os.path.join(output_dir, filename) + with open(file_path, 'wb') as f: + f.write(b'%PDF-1.4 test content') + + response = client.get(f'/api/download/{task_id}/{filename}') + assert response.status_code == 200 + assert response.data == b'%PDF-1.4 test content' + + def test_download_with_custom_name(self, client, app): + """Should use the ?name= parameter as download filename.""" + task_id = 'test-name-id' + filename = 'output.pdf' + + output_dir = os.path.join(app.config['OUTPUT_FOLDER'], task_id) + os.makedirs(output_dir, exist_ok=True) + with open(os.path.join(output_dir, filename), 'wb') as f: + f.write(b'%PDF-1.4') + + response = client.get(f'/api/download/{task_id}/{filename}?name=my-document.pdf') + assert response.status_code == 200 \ No newline at end of file diff --git a/backend/tests/test_file_validator.py b/backend/tests/test_file_validator.py new file mode 100644 index 0000000..3e7efdc --- /dev/null +++ b/backend/tests/test_file_validator.py @@ -0,0 +1,108 @@ +"""Tests for file validation utility.""" +import io +from unittest.mock import patch, MagicMock +from app.utils.file_validator import validate_file, FileValidationError +import pytest + + +class TestFileValidator: + def test_no_file_raises(self, app): + """Should raise when no file provided.""" + with app.app_context(): + with pytest.raises(FileValidationError, match="No file provided"): + validate_file(None, allowed_types=["pdf"]) + + def test_empty_filename_raises(self, app): + """Should raise when filename is empty.""" + with app.app_context(): + mock_file = MagicMock() + mock_file.filename = '' + with pytest.raises(FileValidationError, match="No file provided"): + validate_file(mock_file, allowed_types=["pdf"]) + + def test_wrong_extension_raises(self, app): + """Should raise when file extension is not allowed.""" + with app.app_context(): + mock_file = MagicMock() + mock_file.filename = 'test.exe' + with pytest.raises(FileValidationError, match="not allowed"): + validate_file(mock_file, allowed_types=["pdf"]) + + def test_empty_file_raises(self, app): + """Should raise when file is empty (0 bytes).""" + with app.app_context(): + content = io.BytesIO(b'') + mock_file = MagicMock() + mock_file.filename = 'test.pdf' + mock_file.seek = content.seek + mock_file.tell = content.tell + mock_file.read = content.read + with pytest.raises(FileValidationError, match="empty"): + validate_file(mock_file, allowed_types=["pdf"]) + + def test_valid_pdf_passes(self, app): + """Should accept valid PDF file with correct magic bytes.""" + with app.app_context(): + pdf_bytes = b'%PDF-1.4 test content' + b'\x00' * 8192 + content = io.BytesIO(pdf_bytes) + + mock_file = MagicMock() + mock_file.filename = 'document.pdf' + mock_file.seek = content.seek + mock_file.tell = content.tell + mock_file.read = content.read + + with patch('app.utils.file_validator.magic') as mock_magic: + mock_magic.from_buffer.return_value = 'application/pdf' + filename, ext = validate_file(mock_file, allowed_types=["pdf"]) + + assert filename == 'document.pdf' + assert ext == 'pdf' + + def test_mime_mismatch_raises(self, app): + """Should raise when MIME type doesn't match extension.""" + with app.app_context(): + content = io.BytesIO(b'not a real pdf' + b'\x00' * 8192) + + mock_file = MagicMock() + mock_file.filename = 'fake.pdf' + mock_file.seek = content.seek + mock_file.tell = content.tell + mock_file.read = content.read + + with patch('app.utils.file_validator.magic') as mock_magic: + mock_magic.from_buffer.return_value = 'text/plain' + with pytest.raises(FileValidationError, match="does not match"): + validate_file(mock_file, allowed_types=["pdf"]) + + def test_file_too_large_raises(self, app): + """Should raise when file exceeds size limit.""" + with app.app_context(): + # Create a file larger than the PDF size limit (20MB) + large_content = io.BytesIO(b'%PDF-1.4' + b'\x00' * (21 * 1024 * 1024)) + + mock_file = MagicMock() + mock_file.filename = 'large.pdf' + mock_file.seek = large_content.seek + mock_file.tell = large_content.tell + mock_file.read = large_content.read + + with pytest.raises(FileValidationError, match="too large"): + validate_file(mock_file, allowed_types=["pdf"]) + + def test_dangerous_pdf_raises(self, app): + """Should raise when PDF contains dangerous patterns.""" + with app.app_context(): + pdf_bytes = b'%PDF-1.4 /JavaScript evil_code' + b'\x00' * 8192 + content = io.BytesIO(pdf_bytes) + + mock_file = MagicMock() + mock_file.filename = 'evil.pdf' + mock_file.seek = content.seek + mock_file.tell = content.tell + mock_file.read = content.read + + with patch('app.utils.file_validator.magic') as mock_magic: + mock_magic.from_buffer.return_value = 'application/pdf' + with pytest.raises(FileValidationError, match="unsafe"): + validate_file(mock_file, allowed_types=["pdf"]) \ No newline at end of file diff --git a/backend/tests/test_image_service.py b/backend/tests/test_image_service.py new file mode 100644 index 0000000..98ec0df --- /dev/null +++ b/backend/tests/test_image_service.py @@ -0,0 +1,50 @@ +"""Tests for image processing service.""" +import os +from unittest.mock import patch, MagicMock +import pytest + +from app.services.image_service import convert_image, ImageProcessingError + + +class TestImageService: + def test_convert_invalid_format_raises(self, app): + """Should raise for unsupported output format.""" + with app.app_context(): + with pytest.raises(ImageProcessingError, match="Unsupported"): + convert_image('/tmp/test.png', '/tmp/out.bmp', 'bmp') + + def test_convert_image_success(self, app, tmp_path): + """Should convert an image and return stats.""" + from PIL import Image as PILImage + + with app.app_context(): + # Create real test image + input_path = str(tmp_path / 'input.png') + output_path = str(tmp_path / 'output.jpg') + + img = PILImage.new('RGB', (100, 100), color='red') + img.save(input_path, 'PNG') + + result = convert_image(input_path, output_path, 'jpg', quality=85) + + assert result['width'] == 100 + assert result['height'] == 100 + assert result['format'] == 'jpg' + assert result['original_size'] > 0 + assert result['converted_size'] > 0 + assert os.path.exists(output_path) + + def test_convert_rgba_to_jpeg(self, app, tmp_path): + """Should handle RGBA to JPEG conversion (strip alpha).""" + from PIL import Image as PILImage + + with app.app_context(): + input_path = str(tmp_path / 'input_rgba.png') + output_path = str(tmp_path / 'output.jpg') + + img = PILImage.new('RGBA', (50, 50), color=(255, 0, 0, 128)) + img.save(input_path, 'PNG') + + result = convert_image(input_path, output_path, 'jpg', quality=85) + assert result['format'] == 'jpg' + assert os.path.exists(output_path) \ No newline at end of file diff --git a/backend/tests/test_image_tasks.py b/backend/tests/test_image_tasks.py new file mode 100644 index 0000000..70caa2f --- /dev/null +++ b/backend/tests/test_image_tasks.py @@ -0,0 +1,115 @@ +"""Tests for image processing Celery task routes.""" +import io +from unittest.mock import MagicMock + + +class TestImageTaskRoutes: + def test_convert_image_success(self, client, monkeypatch): + """Should return 202 with task_id for valid image conversion request.""" + mock_task = MagicMock() + mock_task.id = 'img-convert-id' + + monkeypatch.setattr( + 'app.routes.image.validate_file', + lambda f, allowed_types: ('photo.png', 'png'), + ) + monkeypatch.setattr( + 'app.routes.image.generate_safe_path', + lambda ext, folder_type: ('img-convert-id', '/tmp/test.png'), + ) + monkeypatch.setattr( + 'app.routes.image.convert_image_task.delay', + MagicMock(return_value=mock_task), + ) + + data = { + 'file': (io.BytesIO(b'\x89PNG\r\n'), 'photo.png'), + 'format': 'jpg', + 'quality': '85', + } + response = client.post( + '/api/image/convert', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 202 + assert response.get_json()['task_id'] == 'img-convert-id' + + def test_convert_image_invalid_format(self, client): + """Should return 400 for unsupported output format.""" + data = { + 'file': (io.BytesIO(b'\x89PNG\r\n'), 'photo.png'), + 'format': 'bmp', + } + response = client.post( + '/api/image/convert', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 400 + assert 'format' in response.get_json()['error'].lower() + + def test_resize_image_success(self, client, monkeypatch): + """Should return 202 with task_id for valid resize request.""" + mock_task = MagicMock() + mock_task.id = 'img-resize-id' + + monkeypatch.setattr( + 'app.routes.image.validate_file', + lambda f, allowed_types: ('photo.jpg', 'jpg'), + ) + monkeypatch.setattr( + 'app.routes.image.generate_safe_path', + lambda ext, folder_type: ('img-resize-id', '/tmp/test.jpg'), + ) + monkeypatch.setattr( + 'app.routes.image.resize_image_task.delay', + MagicMock(return_value=mock_task), + ) + + data = { + 'file': (io.BytesIO(b'\xff\xd8\xff'), 'photo.jpg'), + 'width': '800', + 'height': '600', + } + response = client.post( + '/api/image/resize', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 202 + assert response.get_json()['task_id'] == 'img-resize-id' + + def test_resize_image_no_dimensions(self, client, monkeypatch): + """Should return 400 when both width and height are missing.""" + monkeypatch.setattr( + 'app.routes.image.validate_file', + lambda f, allowed_types: ('photo.jpg', 'jpg'), + ) + data = { + 'file': (io.BytesIO(b'\xff\xd8\xff'), 'photo.jpg'), + } + response = client.post( + '/api/image/resize', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 400 + assert 'width' in response.get_json()['error'].lower() or 'height' in response.get_json()['error'].lower() + + def test_resize_image_invalid_width(self, client, monkeypatch): + """Should return 400 for width out of range.""" + monkeypatch.setattr( + 'app.routes.image.validate_file', + lambda f, allowed_types: ('photo.jpg', 'jpg'), + ) + data = { + 'file': (io.BytesIO(b'\xff\xd8\xff'), 'photo.jpg'), + 'width': '20000', + } + response = client.post( + '/api/image/resize', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 400 \ No newline at end of file diff --git a/backend/tests/test_load.py b/backend/tests/test_load.py new file mode 100644 index 0000000..3cc4dae --- /dev/null +++ b/backend/tests/test_load.py @@ -0,0 +1,207 @@ +""" +Concurrent / load tests — verify the API handles multiple simultaneous +requests without race conditions or resource leaks. + +These tests do NOT require Redis or Celery; every external call is mocked. +""" +import io +import threading +from unittest.mock import MagicMock, patch + + +# --------------------------------------------------------------------------- +# Rapid sequential requests — baseline stability +# --------------------------------------------------------------------------- +class TestRapidSequential: + def test_100_health_requests(self, client): + """100 back-to-back /health requests must all return 200.""" + for _ in range(100): + r = client.get('/api/health') + assert r.status_code == 200 + + def test_rapid_no_file_errors_are_safe(self, client): + """50 rapid requests that each produce a 400 must not leak state.""" + for _ in range(50): + r = client.post('/api/compress/pdf') + assert r.status_code == 400 + assert r.get_json()['error'] + + +# --------------------------------------------------------------------------- +# Concurrent requests — 10 simultaneous threads, each with its own client +# --------------------------------------------------------------------------- +class TestConcurrentRequests: + def test_10_concurrent_health(self, app): + """10 threads hitting /health simultaneously must all get 200.""" + results: list[int] = [] + errors: list[Exception] = [] + lock = threading.Lock() + + def worker(): + try: + with app.test_client() as c: + r = c.get('/api/health') + with lock: + results.append(r.status_code) + except Exception as exc: + with lock: + errors.append(exc) + + threads = [threading.Thread(target=worker) for _ in range(10)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=10) + + assert not errors, f"Threads raised: {errors}" + assert results.count(200) == 10 + + def test_concurrent_compress_uploads(self, app): + """5 concurrent compress requests each return 202 without deadlocks. + Patches are applied ONCE outside threads to avoid thread-safety issues + with unittest.mock's global state.""" + task_ids: list[str] = [] + errors: list[Exception] = [] + lock = threading.Lock() + + # Use a counter-based side_effect so the shared mock returns distinct ids + counter = [0] + + def make_task(): + with lock: + n = counter[0] + counter[0] += 1 + t = MagicMock() + t.id = f'task-thread-{n}' + return t + + # Apply all patches BEFORE threads start — avoids concurrent patch/unpatch + with patch('app.routes.compress.validate_file', return_value=('t.pdf', 'pdf')), \ + patch('app.routes.compress.generate_safe_path', + side_effect=lambda ext, folder_type: (f'tid-x', '/tmp/up/t.pdf')), \ + patch('werkzeug.datastructures.file_storage.FileStorage.save'), \ + patch('app.routes.compress.compress_pdf_task.delay', + side_effect=lambda *a, **kw: make_task()): + + def worker(): + try: + with app.test_client() as c: + r = c.post( + '/api/compress/pdf', + data={'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf')}, + content_type='multipart/form-data', + ) + with lock: + if r.status_code == 202: + task_ids.append(r.get_json()['task_id']) + else: + errors.append( + AssertionError( + f"expected 202, got {r.status_code}: {r.data}" + ) + ) + except Exception as exc: + with lock: + errors.append(exc) + + threads = [threading.Thread(target=worker) for _ in range(5)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=15) + + assert not errors, f"Errors in threads: {errors}" + assert len(task_ids) == 5 + assert len(set(task_ids)) == 5, "task_ids must be unique per request" + + def test_concurrent_pdf_tools_requests(self, app): + """3 concurrent split-PDF requests must not interfere with each other. + Patches applied once outside threads for thread safety.""" + statuses: list[int] = [] + errors: list[Exception] = [] + lock = threading.Lock() + + with patch('app.routes.pdf_tools.validate_file', return_value=('t.pdf', 'pdf')), \ + patch('app.routes.pdf_tools.generate_safe_path', + side_effect=lambda ext, folder_type: ('split-x', '/tmp/up/t.pdf')), \ + patch('werkzeug.datastructures.file_storage.FileStorage.save'), \ + patch('app.routes.pdf_tools.split_pdf_task.delay', + return_value=MagicMock(id='split-task')): + + def worker(): + try: + with app.test_client() as c: + r = c.post( + '/api/pdf-tools/split', + data={ + 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), + 'mode': 'all', + }, + content_type='multipart/form-data', + ) + with lock: + statuses.append(r.status_code) + except Exception as exc: + with lock: + errors.append(exc) + + threads = [threading.Thread(target=worker) for _ in range(3)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=15) + + assert not errors, f"Errors in threads: {errors}" + assert all(s == 202 for s in statuses), f"Got statuses: {statuses}" + + +# --------------------------------------------------------------------------- +# File-size enforcement +# --------------------------------------------------------------------------- +class TestFileSizeLimits: + def test_compress_rejects_oversized_request(self, client, app): + """Requests exceeding MAX_CONTENT_LENGTH must be rejected (413).""" + original = app.config['MAX_CONTENT_LENGTH'] + try: + # Set 1-byte limit so any real file triggers it + app.config['MAX_CONTENT_LENGTH'] = 1 + oversized = io.BytesIO(b'%PDF-1.4' + b'x' * 2048) + r = client.post( + '/api/compress/pdf', + data={'file': (oversized, 'huge.pdf')}, + content_type='multipart/form-data', + ) + assert r.status_code in (400, 413), ( + f"Expected 400 or 413 for oversized file, got {r.status_code}" + ) + finally: + app.config['MAX_CONTENT_LENGTH'] = original + + def test_normal_size_file_is_accepted(self, client, monkeypatch): + """A file within the size limit reaches the route logic.""" + monkeypatch.setattr( + 'app.routes.compress.validate_file', + lambda f, allowed_types: ('t.pdf', 'pdf'), + ) + monkeypatch.setattr( + 'app.routes.compress.generate_safe_path', + lambda ext, folder_type: ('tid', '/tmp/test_uploads/tid/t.pdf'), + ) + monkeypatch.setattr( + 'werkzeug.datastructures.file_storage.FileStorage.save', + lambda self, dst, buffer_size=16384: None, + ) + mock_task = MagicMock() + mock_task.id = 'size-ok-task' + monkeypatch.setattr( + 'app.routes.compress.compress_pdf_task.delay', + MagicMock(return_value=mock_task), + ) + + small_pdf = io.BytesIO(b'%PDF-1.4 small') + r = client.post( + '/api/compress/pdf', + data={'file': (small_pdf, 'small.pdf')}, + content_type='multipart/form-data', + ) + assert r.status_code == 202 diff --git a/backend/tests/test_pdf_service.py b/backend/tests/test_pdf_service.py new file mode 100644 index 0000000..a351df2 --- /dev/null +++ b/backend/tests/test_pdf_service.py @@ -0,0 +1,64 @@ +"""Tests for PDF conversion service (pdf_to_word, word_to_pdf).""" +import os +from unittest.mock import patch, MagicMock +import pytest + +from app.services.pdf_service import pdf_to_word, PDFConversionError + + +class TestPdfService: + def test_pdf_to_word_creates_output_dir(self, app): + """Should create output directory if it doesn't exist.""" + with app.app_context(): + input_path = '/tmp/test_pdf_svc_input.pdf' + output_dir = '/tmp/test_pdf_svc_output' + expected_output = os.path.join(output_dir, 'test_pdf_svc_input.docx') + + with open(input_path, 'wb') as f: + f.write(b'%PDF-1.4 test') + + with patch('app.services.pdf_service.subprocess.run') as mock_run: + mock_run.return_value = MagicMock( + returncode=0, stdout='', stderr='' + ) + # Simulate LibreOffice creating the output file + os.makedirs(output_dir, exist_ok=True) + with open(expected_output, 'wb') as f: + f.write(b'PK\x03\x04 fake docx') + + result = pdf_to_word(input_path, output_dir) + assert result == expected_output + + os.unlink(input_path) + import shutil + shutil.rmtree(output_dir, ignore_errors=True) + + def test_pdf_to_word_timeout_raises(self, app): + """Should raise error on LibreOffice timeout.""" + with app.app_context(): + import subprocess + + input_path = '/tmp/test_pdf_timeout.pdf' + with open(input_path, 'wb') as f: + f.write(b'%PDF-1.4 test') + + with patch('app.services.pdf_service.subprocess.run') as mock_run: + mock_run.side_effect = subprocess.TimeoutExpired(cmd='soffice', timeout=120) + with pytest.raises(PDFConversionError, match="timed out"): + pdf_to_word(input_path, '/tmp/timeout_output') + + os.unlink(input_path) + + def test_pdf_to_word_not_installed_raises(self, app): + """Should raise error when LibreOffice is not installed.""" + with app.app_context(): + input_path = '/tmp/test_pdf_noinstall.pdf' + with open(input_path, 'wb') as f: + f.write(b'%PDF-1.4 test') + + with patch('app.services.pdf_service.subprocess.run') as mock_run: + mock_run.side_effect = FileNotFoundError() + with pytest.raises(PDFConversionError, match="not installed"): + pdf_to_word(input_path, '/tmp/noinstall_output') + + os.unlink(input_path) \ No newline at end of file diff --git a/backend/tests/test_pdf_tools.py b/backend/tests/test_pdf_tools.py new file mode 100644 index 0000000..cd62707 --- /dev/null +++ b/backend/tests/test_pdf_tools.py @@ -0,0 +1,531 @@ +"""Tests for ALL PDF tools routes — Merge, Split, Rotate, Page Numbers, PDF↔Images, Watermark, Protect, Unlock.""" +import io +import os +import tempfile +from unittest.mock import patch, MagicMock + + +# ========================================================================= +# Helper: create mock for validate_file + celery task +# ========================================================================= +def _mock_validate_and_task(monkeypatch, task_module_path, task_name): + """Shared helper: mock validate_file to pass, mock the celery task, + and ensure file.save() works by using a real temp directory.""" + mock_task = MagicMock() + mock_task.id = 'mock-task-id' + + # Create a real temp dir so file.save() works + tmp_dir = tempfile.mkdtemp() + save_path = os.path.join(tmp_dir, 'mock.pdf') + + # Mock file validator to accept any file + monkeypatch.setattr( + 'app.routes.pdf_tools.validate_file', + lambda f, allowed_types: ('test.pdf', 'pdf'), + ) + monkeypatch.setattr( + 'app.routes.pdf_tools.generate_safe_path', + lambda ext, folder_type: ('mock-task-id', save_path), + ) + + # Mock the celery task delay + mock_delay = MagicMock(return_value=mock_task) + monkeypatch.setattr(f'app.routes.pdf_tools.{task_name}.delay', mock_delay) + + return mock_task, mock_delay + + +# ========================================================================= +# 1. Merge PDFs — POST /api/pdf-tools/merge +# ========================================================================= +class TestMergePdfs: + def test_merge_no_files(self, client): + """Should return 400 when no files provided.""" + response = client.post('/api/pdf-tools/merge') + assert response.status_code == 400 + data = response.get_json() + assert 'error' in data + + def test_merge_single_file(self, client): + """Should return 400 when only one file provided.""" + data = {'files': (io.BytesIO(b'%PDF-1.4 test'), 'test.pdf')} + response = client.post( + '/api/pdf-tools/merge', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 400 + assert 'at least 2' in response.get_json()['error'].lower() + + def test_merge_success(self, client, monkeypatch): + """Should return 202 with task_id when valid PDFs provided.""" + mock_task = MagicMock() + mock_task.id = 'merge-task-id' + monkeypatch.setattr( + 'app.routes.pdf_tools.validate_file', + lambda f, allowed_types: ('test.pdf', 'pdf'), + ) + monkeypatch.setattr( + 'app.routes.pdf_tools.merge_pdfs_task.delay', + MagicMock(return_value=mock_task), + ) + # Mock os.makedirs and FileStorage.save so nothing touches disk + monkeypatch.setattr('app.routes.pdf_tools.os.makedirs', lambda *a, **kw: None) + monkeypatch.setattr( + 'werkzeug.datastructures.file_storage.FileStorage.save', + lambda self, dst, buffer_size=16384: None, + ) + + data = { + 'files': [ + (io.BytesIO(b'%PDF-1.4 file1'), 'a.pdf'), + (io.BytesIO(b'%PDF-1.4 file2'), 'b.pdf'), + ] + } + response = client.post( + '/api/pdf-tools/merge', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 202 + body = response.get_json() + assert body['task_id'] == 'merge-task-id' + assert 'message' in body + + def test_merge_too_many_files(self, client, monkeypatch): + """Should return 400 when more than 20 files provided.""" + monkeypatch.setattr( + 'app.routes.pdf_tools.validate_file', + lambda f, allowed_types: ('test.pdf', 'pdf'), + ) + data = { + 'files': [ + (io.BytesIO(b'%PDF-1.4'), f'file{i}.pdf') + for i in range(21) + ] + } + response = client.post( + '/api/pdf-tools/merge', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 400 + assert '20' in response.get_json()['error'] + + +# ========================================================================= +# 2. Split PDF — POST /api/pdf-tools/split +# ========================================================================= +class TestSplitPdf: + def test_split_no_file(self, client): + """Should return 400 when no file provided.""" + response = client.post('/api/pdf-tools/split') + assert response.status_code == 400 + data = response.get_json() + assert data['error'] == 'No file provided.' + + def test_split_success_all_mode(self, client, monkeypatch): + """Should accept file and return 202 with mode=all.""" + mock_task, mock_delay = _mock_validate_and_task( + monkeypatch, 'app.routes.pdf_tools', 'split_pdf_task' + ) + data = { + 'file': (io.BytesIO(b'%PDF-1.4 test'), 'test.pdf'), + 'mode': 'all', + } + response = client.post( + '/api/pdf-tools/split', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 202 + body = response.get_json() + assert body['task_id'] == 'mock-task-id' + + def test_split_success_range_mode(self, client, monkeypatch): + """Should accept file with mode=range and pages.""" + mock_task, mock_delay = _mock_validate_and_task( + monkeypatch, 'app.routes.pdf_tools', 'split_pdf_task' + ) + data = { + 'file': (io.BytesIO(b'%PDF-1.4 test'), 'test.pdf'), + 'mode': 'range', + 'pages': '1,3,5-8', + } + response = client.post( + '/api/pdf-tools/split', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 202 + mock_delay.assert_called_once() + # Verify pages parameter was passed + call_args = mock_delay.call_args + assert call_args[0][4] == '1,3,5-8' # pages arg + + def test_split_range_mode_requires_pages(self, client, monkeypatch): + """Should return 400 when range mode is selected without pages.""" + monkeypatch.setattr( + 'app.routes.pdf_tools.validate_file', + lambda f, allowed_types: ('test.pdf', 'pdf'), + ) + + data = { + 'file': (io.BytesIO(b'%PDF-1.4 test'), 'test.pdf'), + 'mode': 'range', + } + response = client.post( + '/api/pdf-tools/split', + data=data, + content_type='multipart/form-data', + ) + + assert response.status_code == 400 + assert 'specify which pages to extract' in response.get_json()['error'].lower() + + +# ========================================================================= +# 3. Rotate PDF — POST /api/pdf-tools/rotate +# ========================================================================= +class TestRotatePdf: + def test_rotate_no_file(self, client): + response = client.post('/api/pdf-tools/rotate') + assert response.status_code == 400 + + def test_rotate_invalid_degrees(self, client, monkeypatch): + """Should reject invalid rotation angles.""" + monkeypatch.setattr( + 'app.routes.pdf_tools.validate_file', + lambda f, allowed_types: ('test.pdf', 'pdf'), + ) + data = { + 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), + 'rotation': '45', + } + response = client.post( + '/api/pdf-tools/rotate', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 400 + assert '90, 180, or 270' in response.get_json()['error'] + + def test_rotate_success(self, client, monkeypatch): + mock_task, mock_delay = _mock_validate_and_task( + monkeypatch, 'app.routes.pdf_tools', 'rotate_pdf_task' + ) + data = { + 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), + 'rotation': '90', + 'pages': 'all', + } + response = client.post( + '/api/pdf-tools/rotate', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 202 + assert response.get_json()['task_id'] == 'mock-task-id' + + +# ========================================================================= +# 4. Page Numbers — POST /api/pdf-tools/page-numbers +# ========================================================================= +class TestAddPageNumbers: + def test_page_numbers_no_file(self, client): + response = client.post('/api/pdf-tools/page-numbers') + assert response.status_code == 400 + + def test_page_numbers_success(self, client, monkeypatch): + mock_task, mock_delay = _mock_validate_and_task( + monkeypatch, 'app.routes.pdf_tools', 'add_page_numbers_task' + ) + data = { + 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), + 'position': 'bottom-center', + 'start_number': '1', + } + response = client.post( + '/api/pdf-tools/page-numbers', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 202 + + def test_page_numbers_invalid_position_defaults(self, client, monkeypatch): + """Invalid position should default to bottom-center.""" + mock_task, mock_delay = _mock_validate_and_task( + monkeypatch, 'app.routes.pdf_tools', 'add_page_numbers_task' + ) + data = { + 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), + 'position': 'invalid-position', + } + response = client.post( + '/api/pdf-tools/page-numbers', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 202 + # Should have used default 'bottom-center' + call_args = mock_delay.call_args[0] + assert call_args[3] == 'bottom-center' + + +# ========================================================================= +# 5. PDF to Images — POST /api/pdf-tools/pdf-to-images +# ========================================================================= +class TestPdfToImages: + def test_pdf_to_images_no_file(self, client): + response = client.post('/api/pdf-tools/pdf-to-images') + assert response.status_code == 400 + + def test_pdf_to_images_success(self, client, monkeypatch): + mock_task, mock_delay = _mock_validate_and_task( + monkeypatch, 'app.routes.pdf_tools', 'pdf_to_images_task' + ) + data = { + 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), + 'format': 'png', + 'dpi': '200', + } + response = client.post( + '/api/pdf-tools/pdf-to-images', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 202 + + def test_pdf_to_images_invalid_format_defaults(self, client, monkeypatch): + """Invalid format should default to png.""" + mock_task, mock_delay = _mock_validate_and_task( + monkeypatch, 'app.routes.pdf_tools', 'pdf_to_images_task' + ) + data = { + 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), + 'format': 'bmp', + } + response = client.post( + '/api/pdf-tools/pdf-to-images', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 202 + call_args = mock_delay.call_args[0] + assert call_args[3] == 'png' # default format + + +# ========================================================================= +# 6. Images to PDF — POST /api/pdf-tools/images-to-pdf +# ========================================================================= +class TestImagesToPdf: + def test_images_to_pdf_no_files(self, client): + response = client.post('/api/pdf-tools/images-to-pdf') + assert response.status_code == 400 + + def test_images_to_pdf_success(self, client, monkeypatch): + mock_task = MagicMock() + mock_task.id = 'images-task-id' + monkeypatch.setattr( + 'app.routes.pdf_tools.validate_file', + lambda f, allowed_types: ('test.png', 'png'), + ) + monkeypatch.setattr( + 'app.routes.pdf_tools.images_to_pdf_task.delay', + MagicMock(return_value=mock_task), + ) + # Mock os.makedirs and FileStorage.save so nothing touches disk + monkeypatch.setattr('app.routes.pdf_tools.os.makedirs', lambda *a, **kw: None) + monkeypatch.setattr( + 'werkzeug.datastructures.file_storage.FileStorage.save', + lambda self, dst, buffer_size=16384: None, + ) + data = { + 'files': [ + (io.BytesIO(b'\x89PNG\r\n'), 'img1.png'), + (io.BytesIO(b'\x89PNG\r\n'), 'img2.png'), + ] + } + response = client.post( + '/api/pdf-tools/images-to-pdf', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 202 + assert response.get_json()['task_id'] == 'images-task-id' + + def test_images_to_pdf_too_many(self, client, monkeypatch): + monkeypatch.setattr( + 'app.routes.pdf_tools.validate_file', + lambda f, allowed_types: ('test.png', 'png'), + ) + data = { + 'files': [ + (io.BytesIO(b'\x89PNG\r\n'), f'img{i}.png') + for i in range(51) + ] + } + response = client.post( + '/api/pdf-tools/images-to-pdf', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 400 + assert '50' in response.get_json()['error'] + + +# ========================================================================= +# 7. Watermark PDF — POST /api/pdf-tools/watermark +# ========================================================================= +class TestWatermarkPdf: + def test_watermark_no_file(self, client): + response = client.post('/api/pdf-tools/watermark') + assert response.status_code == 400 + + def test_watermark_no_text(self, client, monkeypatch): + monkeypatch.setattr( + 'app.routes.pdf_tools.validate_file', + lambda f, allowed_types: ('test.pdf', 'pdf'), + ) + data = { + 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), + 'text': '', + } + response = client.post( + '/api/pdf-tools/watermark', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 400 + assert 'required' in response.get_json()['error'].lower() + + def test_watermark_text_too_long(self, client, monkeypatch): + monkeypatch.setattr( + 'app.routes.pdf_tools.validate_file', + lambda f, allowed_types: ('test.pdf', 'pdf'), + ) + data = { + 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), + 'text': 'x' * 101, + } + response = client.post( + '/api/pdf-tools/watermark', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 400 + assert '100' in response.get_json()['error'] + + def test_watermark_success(self, client, monkeypatch): + mock_task, mock_delay = _mock_validate_and_task( + monkeypatch, 'app.routes.pdf_tools', 'watermark_pdf_task' + ) + data = { + 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), + 'text': 'CONFIDENTIAL', + 'opacity': '0.5', + } + response = client.post( + '/api/pdf-tools/watermark', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 202 + + +# ========================================================================= +# 8. Protect PDF — POST /api/pdf-tools/protect +# ========================================================================= +class TestProtectPdf: + def test_protect_no_file(self, client): + response = client.post('/api/pdf-tools/protect') + assert response.status_code == 400 + + def test_protect_no_password(self, client, monkeypatch): + monkeypatch.setattr( + 'app.routes.pdf_tools.validate_file', + lambda f, allowed_types: ('test.pdf', 'pdf'), + ) + data = { + 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), + 'password': '', + } + response = client.post( + '/api/pdf-tools/protect', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 400 + assert 'required' in response.get_json()['error'].lower() + + def test_protect_short_password(self, client, monkeypatch): + monkeypatch.setattr( + 'app.routes.pdf_tools.validate_file', + lambda f, allowed_types: ('test.pdf', 'pdf'), + ) + data = { + 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), + 'password': 'abc', + } + response = client.post( + '/api/pdf-tools/protect', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 400 + assert '4 characters' in response.get_json()['error'] + + def test_protect_success(self, client, monkeypatch): + mock_task, mock_delay = _mock_validate_and_task( + monkeypatch, 'app.routes.pdf_tools', 'protect_pdf_task' + ) + data = { + 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), + 'password': 'secret1234', + } + response = client.post( + '/api/pdf-tools/protect', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 202 + + +# ========================================================================= +# 9. Unlock PDF — POST /api/pdf-tools/unlock +# ========================================================================= +class TestUnlockPdf: + def test_unlock_no_file(self, client): + response = client.post('/api/pdf-tools/unlock') + assert response.status_code == 400 + + def test_unlock_no_password(self, client, monkeypatch): + monkeypatch.setattr( + 'app.routes.pdf_tools.validate_file', + lambda f, allowed_types: ('test.pdf', 'pdf'), + ) + data = { + 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), + 'password': '', + } + response = client.post( + '/api/pdf-tools/unlock', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 400 + + def test_unlock_success(self, client, monkeypatch): + mock_task, mock_delay = _mock_validate_and_task( + monkeypatch, 'app.routes.pdf_tools', 'unlock_pdf_task' + ) + data = { + 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), + 'password': 'mypassword', + } + response = client.post( + '/api/pdf-tools/unlock', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 202 \ No newline at end of file diff --git a/backend/tests/test_pdf_tools_service.py b/backend/tests/test_pdf_tools_service.py new file mode 100644 index 0000000..66888ac --- /dev/null +++ b/backend/tests/test_pdf_tools_service.py @@ -0,0 +1,111 @@ +"""Tests for PDF tools service — Merge, Split, Rotate, etc.""" +import os +import pytest +from unittest.mock import patch, MagicMock + +from app.services.pdf_tools_service import ( + merge_pdfs, + split_pdf, + PDFToolsError, +) + + +class TestMergePdfsService: + def test_merge_file_not_found_raises(self, app): + """Should raise when input file doesn't exist.""" + with app.app_context(): + with pytest.raises(PDFToolsError, match="not found"): + merge_pdfs( + ['/tmp/nonexistent1.pdf', '/tmp/nonexistent2.pdf'], + '/tmp/merged_output.pdf', + ) + + def test_merge_success(self, app, tmp_path): + """Should merge PDF files successfully.""" + with app.app_context(): + # Create test PDFs using PyPDF2 + try: + from PyPDF2 import PdfWriter + + pdf1 = str(tmp_path / 'a.pdf') + pdf2 = str(tmp_path / 'b.pdf') + + for path in [pdf1, pdf2]: + writer = PdfWriter() + writer.add_blank_page(width=612, height=792) + with open(path, 'wb') as f: + writer.write(f) + + output = str(tmp_path / 'merged.pdf') + result = merge_pdfs([pdf1, pdf2], output) + + assert result['total_pages'] == 2 + assert result['files_merged'] == 2 + assert result['output_size'] > 0 + assert os.path.exists(output) + except ImportError: + pytest.skip("PyPDF2 not installed") + + +class TestSplitPdfService: + def test_split_all_pages(self, app, tmp_path): + """Should split PDF into individual pages.""" + with app.app_context(): + try: + from PyPDF2 import PdfWriter + + # Create 3-page PDF + input_path = str(tmp_path / 'multi.pdf') + writer = PdfWriter() + for _ in range(3): + writer.add_blank_page(width=612, height=792) + with open(input_path, 'wb') as f: + writer.write(f) + + output_dir = str(tmp_path / 'split_output') + result = split_pdf(input_path, output_dir, mode='all') + + assert result['total_pages'] == 3 + assert result['extracted_pages'] == 3 + assert os.path.exists(result['zip_path']) + except ImportError: + pytest.skip("PyPDF2 not installed") + + def test_split_range_out_of_bounds_includes_total_pages(self, app, tmp_path): + """Should raise a clear error when requested pages exceed document page count.""" + with app.app_context(): + try: + from PyPDF2 import PdfWriter + + input_path = str(tmp_path / 'single-page.pdf') + writer = PdfWriter() + writer.add_blank_page(width=612, height=792) + with open(input_path, 'wb') as f: + writer.write(f) + + output_dir = str(tmp_path / 'split_output') + + with pytest.raises(PDFToolsError, match='has only 1 page'): + split_pdf(input_path, output_dir, mode='range', pages='1-2') + except ImportError: + pytest.skip("PyPDF2 not installed") + + def test_split_range_invalid_format_returns_clear_message(self, app, tmp_path): + """Should raise a clear error for malformed page ranges.""" + with app.app_context(): + try: + from PyPDF2 import PdfWriter + + input_path = str(tmp_path / 'two-pages.pdf') + writer = PdfWriter() + writer.add_blank_page(width=612, height=792) + writer.add_blank_page(width=612, height=792) + with open(input_path, 'wb') as f: + writer.write(f) + + output_dir = str(tmp_path / 'split_output') + + with pytest.raises(PDFToolsError, match='Invalid page format'): + split_pdf(input_path, output_dir, mode='range', pages='1-2-3') + except ImportError: + pytest.skip("PyPDF2 not installed") \ No newline at end of file diff --git a/backend/tests/test_pdf_tools_tasks.py b/backend/tests/test_pdf_tools_tasks.py new file mode 100644 index 0000000..3717db4 --- /dev/null +++ b/backend/tests/test_pdf_tools_tasks.py @@ -0,0 +1,176 @@ +"""Tests for PDF tools Celery task routes — ensures frontend→backend request formats work.""" +import io +from unittest.mock import MagicMock + + +class TestPdfToolsTaskRoutes: + """ + These tests verify that the backend route accepts the exact request format + the frontend sends, processes parameters correctly, and dispatches the + appropriate Celery task. + """ + + def test_split_dispatches_task(self, client, monkeypatch): + """Split route should dispatch split_pdf_task with correct params.""" + mock_task = MagicMock() + mock_task.id = 'split-id' + mock_delay = MagicMock(return_value=mock_task) + + monkeypatch.setattr('app.routes.pdf_tools.validate_file', + lambda f, allowed_types: ('test.pdf', 'pdf')) + monkeypatch.setattr('app.routes.pdf_tools.generate_safe_path', + lambda ext, folder_type: ('split-id', '/tmp/test.pdf')) + monkeypatch.setattr('app.routes.pdf_tools.split_pdf_task.delay', mock_delay) + + data = { + 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), + 'mode': 'range', + 'pages': '1-5', + } + response = client.post('/api/pdf-tools/split', data=data, + content_type='multipart/form-data') + assert response.status_code == 202 + # Verify task was called with (input_path, task_id, filename, mode, pages) + args = mock_delay.call_args[0] + assert args[3] == 'range' + assert args[4] == '1-5' + + def test_rotate_dispatches_task(self, client, monkeypatch): + """Rotate route should dispatch with rotation and pages params.""" + mock_task = MagicMock() + mock_task.id = 'rotate-id' + mock_delay = MagicMock(return_value=mock_task) + + monkeypatch.setattr('app.routes.pdf_tools.validate_file', + lambda f, allowed_types: ('test.pdf', 'pdf')) + monkeypatch.setattr('app.routes.pdf_tools.generate_safe_path', + lambda ext, folder_type: ('rotate-id', '/tmp/test.pdf')) + monkeypatch.setattr('app.routes.pdf_tools.rotate_pdf_task.delay', mock_delay) + + # Frontend sends: rotation=90, pages=all + data = { + 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), + 'rotation': '180', + 'pages': 'all', + } + response = client.post('/api/pdf-tools/rotate', data=data, + content_type='multipart/form-data') + assert response.status_code == 202 + args = mock_delay.call_args[0] + assert args[3] == 180 # rotation as int + assert args[4] == 'all' + + def test_watermark_dispatches_task(self, client, monkeypatch): + """Watermark route should dispatch with text and opacity.""" + mock_task = MagicMock() + mock_task.id = 'wm-id' + mock_delay = MagicMock(return_value=mock_task) + + monkeypatch.setattr('app.routes.pdf_tools.validate_file', + lambda f, allowed_types: ('test.pdf', 'pdf')) + monkeypatch.setattr('app.routes.pdf_tools.generate_safe_path', + lambda ext, folder_type: ('wm-id', '/tmp/test.pdf')) + monkeypatch.setattr('app.routes.pdf_tools.watermark_pdf_task.delay', mock_delay) + + # Frontend sends: text and opacity (as decimal string) + data = { + 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), + 'text': 'CONFIDENTIAL', + 'opacity': '0.3', + } + response = client.post('/api/pdf-tools/watermark', data=data, + content_type='multipart/form-data') + assert response.status_code == 202 + args = mock_delay.call_args[0] + assert args[3] == 'CONFIDENTIAL' + assert args[4] == 0.3 + + def test_protect_dispatches_task(self, client, monkeypatch): + """Protect route should dispatch with password.""" + mock_task = MagicMock() + mock_task.id = 'protect-id' + mock_delay = MagicMock(return_value=mock_task) + + monkeypatch.setattr('app.routes.pdf_tools.validate_file', + lambda f, allowed_types: ('test.pdf', 'pdf')) + monkeypatch.setattr('app.routes.pdf_tools.generate_safe_path', + lambda ext, folder_type: ('protect-id', '/tmp/test.pdf')) + monkeypatch.setattr('app.routes.pdf_tools.protect_pdf_task.delay', mock_delay) + + data = { + 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), + 'password': 'mySecret123', + } + response = client.post('/api/pdf-tools/protect', data=data, + content_type='multipart/form-data') + assert response.status_code == 202 + args = mock_delay.call_args[0] + assert args[3] == 'mySecret123' + + def test_unlock_dispatches_task(self, client, monkeypatch): + """Unlock route should dispatch with password.""" + mock_task = MagicMock() + mock_task.id = 'unlock-id' + mock_delay = MagicMock(return_value=mock_task) + + monkeypatch.setattr('app.routes.pdf_tools.validate_file', + lambda f, allowed_types: ('test.pdf', 'pdf')) + monkeypatch.setattr('app.routes.pdf_tools.generate_safe_path', + lambda ext, folder_type: ('unlock-id', '/tmp/test.pdf')) + monkeypatch.setattr('app.routes.pdf_tools.unlock_pdf_task.delay', mock_delay) + + data = { + 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), + 'password': 'oldPassword', + } + response = client.post('/api/pdf-tools/unlock', data=data, + content_type='multipart/form-data') + assert response.status_code == 202 + + def test_page_numbers_dispatches_task(self, client, monkeypatch): + """Page numbers route should dispatch with position and start_number.""" + mock_task = MagicMock() + mock_task.id = 'pn-id' + mock_delay = MagicMock(return_value=mock_task) + + monkeypatch.setattr('app.routes.pdf_tools.validate_file', + lambda f, allowed_types: ('test.pdf', 'pdf')) + monkeypatch.setattr('app.routes.pdf_tools.generate_safe_path', + lambda ext, folder_type: ('pn-id', '/tmp/test.pdf')) + monkeypatch.setattr('app.routes.pdf_tools.add_page_numbers_task.delay', mock_delay) + + data = { + 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), + 'position': 'top-right', + 'start_number': '5', + } + response = client.post('/api/pdf-tools/page-numbers', data=data, + content_type='multipart/form-data') + assert response.status_code == 202 + args = mock_delay.call_args[0] + assert args[3] == 'top-right' + assert args[4] == 5 + + def test_pdf_to_images_dispatches_task(self, client, monkeypatch): + """PDF to images route should dispatch with format and dpi.""" + mock_task = MagicMock() + mock_task.id = 'p2i-id' + mock_delay = MagicMock(return_value=mock_task) + + monkeypatch.setattr('app.routes.pdf_tools.validate_file', + lambda f, allowed_types: ('test.pdf', 'pdf')) + monkeypatch.setattr('app.routes.pdf_tools.generate_safe_path', + lambda ext, folder_type: ('p2i-id', '/tmp/test.pdf')) + monkeypatch.setattr('app.routes.pdf_tools.pdf_to_images_task.delay', mock_delay) + + data = { + 'file': (io.BytesIO(b'%PDF-1.4'), 'test.pdf'), + 'format': 'jpg', + 'dpi': '300', + } + response = client.post('/api/pdf-tools/pdf-to-images', data=data, + content_type='multipart/form-data') + assert response.status_code == 202 + args = mock_delay.call_args[0] + assert args[3] == 'jpg' + assert args[4] == 300 \ No newline at end of file diff --git a/backend/tests/test_rate_limiter.py b/backend/tests/test_rate_limiter.py new file mode 100644 index 0000000..35f4c31 --- /dev/null +++ b/backend/tests/test_rate_limiter.py @@ -0,0 +1,101 @@ +"""Tests for rate limiting middleware.""" +import pytest +from app import create_app + + +@pytest.fixture +def rate_limited_app(tmp_path): + """App with rate limiting ENABLED. + + TestingConfig sets RATELIMIT_ENABLED=False so the other 116 tests are + never throttled. Here we force the extension's internal flag back to + True *after* init_app so the decorator limits are enforced. + """ + app = create_app('testing') + app.config.update({ + 'TESTING': True, + 'RATELIMIT_STORAGE_URI': 'memory://', + 'UPLOAD_FOLDER': str(tmp_path / 'uploads'), + 'OUTPUT_FOLDER': str(tmp_path / 'outputs'), + }) + import os + os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) + os.makedirs(app.config['OUTPUT_FOLDER'], exist_ok=True) + + # flask-limiter 3.x returns from init_app immediately when + # RATELIMIT_ENABLED=False (TestingConfig default), so `initialized` + # stays False and no limits are enforced. We override the config key + # and call init_app a SECOND time so the extension fully initialises. + # It is safe to call twice — flask-limiter guards against duplicate + # before_request hook registration via app.extensions["limiter"]. + from app.extensions import limiter as _limiter + app.config['RATELIMIT_ENABLED'] = True + _limiter.init_app(app) # second call — now RATELIMIT_ENABLED=True + + yield app + + # Restore so other tests are unaffected + _limiter.enabled = False + _limiter.initialized = False + + +@pytest.fixture +def rate_limited_client(rate_limited_app): + return rate_limited_app.test_client() + + +class TestRateLimiter: + def test_health_endpoint_not_rate_limited(self, client): + """Health endpoint should handle many rapid requests.""" + for _ in range(20): + response = client.get('/api/health') + assert response.status_code == 200 + + def test_rate_limit_header_present(self, client): + """Response should include a valid HTTP status code.""" + response = client.get('/api/health') + assert response.status_code == 200 + + +class TestRateLimitEnforcement: + """Verify that per-route rate limits actually trigger (429) when exceeded.""" + + def test_compress_rate_limit_triggers(self, rate_limited_client): + """ + POST /api/compress/pdf has @limiter.limit("10/minute"). + After 10 requests (each returns 400 for missing file, but the limiter + still counts them), the 11th must get 429 Too Many Requests. + """ + blocked = False + for i in range(15): + r = rate_limited_client.post('/api/compress/pdf') + if r.status_code == 429: + blocked = True + break + assert blocked, ( + "Expected a 429 Too Many Requests after exceeding 10/minute " + "on /api/compress/pdf" + ) + + def test_convert_pdf_to_word_rate_limit(self, rate_limited_client): + """POST /api/convert/pdf-to-word is also rate-limited.""" + blocked = False + for _ in range(15): + r = rate_limited_client.post('/api/convert/pdf-to-word') + if r.status_code == 429: + blocked = True + break + assert blocked, "Rate limit not enforced on /api/convert/pdf-to-word" + + def test_different_endpoints_have_independent_limits(self, rate_limited_client): + """ + Exhausting the limit on /compress/pdf must not affect /api/health, + which has no rate limit. + """ + # Exhaust compress limit + for _ in range(15): + rate_limited_client.post('/api/compress/pdf') + + # Health should still respond normally + r = rate_limited_client.get('/api/health') + assert r.status_code == 200 \ No newline at end of file diff --git a/backend/tests/test_sanitizer.py b/backend/tests/test_sanitizer.py new file mode 100644 index 0000000..cc124ca --- /dev/null +++ b/backend/tests/test_sanitizer.py @@ -0,0 +1,74 @@ +"""Tests for sanitizer utilities — generate_safe_path, get_output_path, cleanup.""" +import os +from app.utils.sanitizer import generate_safe_path, get_output_path, cleanup_task_files + + +class TestGenerateSafePath: + def test_returns_tuple(self, app): + """Should return (task_id, file_path) tuple.""" + with app.app_context(): + task_id, path = generate_safe_path('pdf', folder_type='upload') + assert isinstance(task_id, str) + assert isinstance(path, str) + + def test_uuid_in_path(self, app): + """Path should contain the UUID task_id.""" + with app.app_context(): + task_id, path = generate_safe_path('pdf') + assert task_id in path + + def test_correct_extension(self, app): + """Path should end with the specified extension.""" + with app.app_context(): + _, path = generate_safe_path('docx') + assert path.endswith('.docx') + + def test_upload_folder(self, app): + """upload folder_type should use UPLOAD_FOLDER config.""" + with app.app_context(): + _, path = generate_safe_path('pdf', folder_type='upload') + assert app.config['UPLOAD_FOLDER'] in path + + def test_output_folder(self, app): + """output folder_type should use OUTPUT_FOLDER config.""" + with app.app_context(): + _, path = generate_safe_path('pdf', folder_type='output') + assert app.config['OUTPUT_FOLDER'] in path + + +class TestGetOutputPath: + def test_returns_correct_path(self, app): + """Should return path in OUTPUT_FOLDER with task_id and extension.""" + with app.app_context(): + path = get_output_path('my-task-id', 'pdf') + assert 'my-task-id' in path + assert path.endswith('.pdf') + assert app.config['OUTPUT_FOLDER'] in path + + +class TestCleanupTaskFiles: + def test_cleanup_removes_upload_dir(self, app): + """Should remove upload directory for the task.""" + with app.app_context(): + task_id = 'cleanup-test-id' + upload_dir = os.path.join(app.config['UPLOAD_FOLDER'], task_id) + os.makedirs(upload_dir, exist_ok=True) + + # Create a test file + with open(os.path.join(upload_dir, 'test.pdf'), 'w') as f: + f.write('test') + + cleanup_task_files(task_id) + assert not os.path.exists(upload_dir) + + def test_cleanup_keeps_outputs_when_requested(self, app): + """Should keep output directory when keep_outputs=True.""" + with app.app_context(): + task_id = 'keep-output-id' + output_dir = os.path.join(app.config['OUTPUT_FOLDER'], task_id) + os.makedirs(output_dir, exist_ok=True) + with open(os.path.join(output_dir, 'out.pdf'), 'w') as f: + f.write('test') + + cleanup_task_files(task_id, keep_outputs=True) + assert os.path.exists(output_dir) \ No newline at end of file diff --git a/backend/tests/test_storage_service.py b/backend/tests/test_storage_service.py new file mode 100644 index 0000000..2df5e48 --- /dev/null +++ b/backend/tests/test_storage_service.py @@ -0,0 +1,56 @@ +"""Tests for storage service — local mode (S3 not configured in tests).""" +import os + +from app.services.storage_service import StorageService + + +class TestStorageServiceLocal: + def test_use_s3_false_in_test(self, app): + """S3 should not be configured in test environment.""" + with app.app_context(): + svc = StorageService() + assert svc.use_s3 is False + + def test_upload_file_local(self, app): + """Should copy file to outputs directory in local mode.""" + with app.app_context(): + svc = StorageService() + task_id = 'local-upload-test' + + # Create a source file + input_path = '/tmp/test_storage_input.pdf' + with open(input_path, 'wb') as f: + f.write(b'%PDF-1.4 test') + + key = svc.upload_file(input_path, task_id) + assert task_id in key + assert 'test_storage_input.pdf' in key + + os.unlink(input_path) + + def test_generate_presigned_url_local(self, app): + """In local mode should return /api/download/... URL.""" + with app.app_context(): + svc = StorageService() + url = svc.generate_presigned_url( + 'outputs/task-123/output.pdf', + original_filename='my-doc.pdf', + ) + assert '/api/download/task-123/output.pdf' in url + assert 'name=my-doc.pdf' in url + + def test_file_exists_local(self, app): + """Should check file existence on local filesystem.""" + with app.app_context(): + svc = StorageService() + # Non-existent file + assert svc.file_exists('outputs/nonexistent/file.pdf') is False + + # Create existing file + task_id = 'exists-test' + output_dir = os.path.join(app.config['OUTPUT_FOLDER'], task_id) + os.makedirs(output_dir, exist_ok=True) + with open(os.path.join(output_dir, 'test.pdf'), 'w') as f: + f.write('test') + + assert svc.file_exists(f'outputs/{task_id}/test.pdf') is True \ No newline at end of file diff --git a/backend/tests/test_tasks_route.py b/backend/tests/test_tasks_route.py new file mode 100644 index 0000000..18298eb --- /dev/null +++ b/backend/tests/test_tasks_route.py @@ -0,0 +1,66 @@ +"""Tests for task status polling route.""" +from unittest.mock import patch, MagicMock + + +class TestTaskStatus: + def test_pending_task(self, client, monkeypatch): + """Should return PENDING state for a queued task.""" + mock_result = MagicMock() + mock_result.state = 'PENDING' + mock_result.info = None + + with patch('app.routes.tasks.AsyncResult', return_value=mock_result): + response = client.get('/api/tasks/test-task-id/status') + + assert response.status_code == 200 + data = response.get_json() + assert data['task_id'] == 'test-task-id' + assert data['state'] == 'PENDING' + assert 'progress' in data + + def test_processing_task(self, client, monkeypatch): + """Should return PROCESSING state with step info.""" + mock_result = MagicMock() + mock_result.state = 'PROCESSING' + mock_result.info = {'step': 'Converting page 3 of 10...'} + + with patch('app.routes.tasks.AsyncResult', return_value=mock_result): + response = client.get('/api/tasks/processing-id/status') + + assert response.status_code == 200 + data = response.get_json() + assert data['state'] == 'PROCESSING' + assert data['progress'] == 'Converting page 3 of 10...' + + def test_success_task(self, client, monkeypatch): + """Should return SUCCESS state with result data.""" + mock_result = MagicMock() + mock_result.state = 'SUCCESS' + mock_result.result = { + 'status': 'completed', + 'download_url': '/api/download/task-id/output.pdf', + 'filename': 'output.pdf', + } + + with patch('app.routes.tasks.AsyncResult', return_value=mock_result): + response = client.get('/api/tasks/success-id/status') + + assert response.status_code == 200 + data = response.get_json() + assert data['state'] == 'SUCCESS' + assert data['result']['status'] == 'completed' + assert 'download_url' in data['result'] + + def test_failure_task(self, client, monkeypatch): + """Should return FAILURE state with error message.""" + mock_result = MagicMock() + mock_result.state = 'FAILURE' + mock_result.info = Exception('Conversion failed due to corrupt PDF.') + + with patch('app.routes.tasks.AsyncResult', return_value=mock_result): + response = client.get('/api/tasks/failed-id/status') + + assert response.status_code == 200 + data = response.get_json() + assert data['state'] == 'FAILURE' + assert 'error' in data \ No newline at end of file diff --git a/backend/tests/test_utils.py b/backend/tests/test_utils.py index 29b4383..ed65b66 100644 --- a/backend/tests/test_utils.py +++ b/backend/tests/test_utils.py @@ -1,19 +1,21 @@ -"""Tests for text utility functions.""" -import sys -import os - -# Add backend to path so we can import utils directly -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) - -from app.utils.file_validator import validate_file +"""Tests for general utility functions.""" from app.utils.sanitizer import generate_safe_path -def test_generate_safe_path(): +def test_generate_safe_path(app): """generate_safe_path should produce UUID-based path.""" - path = generate_safe_path('uploads', 'test.pdf') - assert path.startswith('uploads') - assert path.endswith('.pdf') - # Should contain a UUID directory - parts = path.replace('\\', '/').split('/') - assert len(parts) >= 3 # uploads / uuid / filename.pdf + with app.app_context(): + task_id, path = generate_safe_path('pdf', folder_type='upload') + assert task_id in path + assert path.endswith('.pdf') + # Should contain a UUID directory + parts = path.replace('\\', '/').split('/') + assert len(parts) >= 3 # /tmp/test_uploads / uuid / filename.pdf + + +def test_generate_safe_path_unique(app): + """Each call should produce a unique task_id.""" + with app.app_context(): + id1, _ = generate_safe_path('pdf') + id2, _ = generate_safe_path('pdf') + assert id1 != id2 diff --git a/backend/tests/test_video.py b/backend/tests/test_video.py new file mode 100644 index 0000000..4252688 --- /dev/null +++ b/backend/tests/test_video.py @@ -0,0 +1,151 @@ +"""Tests for video processing routes — Video to GIF.""" +import io +from unittest.mock import MagicMock + + +class TestVideoToGif: + def test_to_gif_no_file(self, client): + """POST /api/video/to-gif without file should return 400.""" + response = client.post('/api/video/to-gif') + assert response.status_code == 400 + data = response.get_json() + assert data['error'] == 'No file provided.' + + def test_to_gif_invalid_params(self, client, monkeypatch): + """Should return 400 for non-numeric parameters.""" + monkeypatch.setattr( + 'app.routes.video.validate_file', + lambda f, allowed_types: ('test.mp4', 'mp4'), + ) + data = { + 'file': (io.BytesIO(b'\x00\x00\x00\x1cftyp'), 'test.mp4'), + 'start_time': 'abc', + } + response = client.post( + '/api/video/to-gif', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 400 + assert 'numeric' in response.get_json()['error'].lower() + + def test_to_gif_negative_start(self, client, monkeypatch): + """Should reject negative start time.""" + monkeypatch.setattr( + 'app.routes.video.validate_file', + lambda f, allowed_types: ('test.mp4', 'mp4'), + ) + data = { + 'file': (io.BytesIO(b'\x00\x00\x00\x1cftyp'), 'test.mp4'), + 'start_time': '-5', + 'duration': '5', + 'fps': '10', + 'width': '480', + } + response = client.post( + '/api/video/to-gif', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 400 + + def test_to_gif_duration_too_long(self, client, monkeypatch): + """Should reject duration > 15 seconds.""" + monkeypatch.setattr( + 'app.routes.video.validate_file', + lambda f, allowed_types: ('test.mp4', 'mp4'), + ) + data = { + 'file': (io.BytesIO(b'\x00\x00\x00\x1cftyp'), 'test.mp4'), + 'start_time': '0', + 'duration': '20', + 'fps': '10', + 'width': '480', + } + response = client.post( + '/api/video/to-gif', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 400 + assert '15' in response.get_json()['error'] + + def test_to_gif_fps_out_of_range(self, client, monkeypatch): + """Should reject FPS > 20.""" + monkeypatch.setattr( + 'app.routes.video.validate_file', + lambda f, allowed_types: ('test.mp4', 'mp4'), + ) + data = { + 'file': (io.BytesIO(b'\x00\x00\x00\x1cftyp'), 'test.mp4'), + 'start_time': '0', + 'duration': '5', + 'fps': '30', + 'width': '480', + } + response = client.post( + '/api/video/to-gif', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 400 + + def test_to_gif_width_out_of_range(self, client, monkeypatch): + """Should reject width > 640.""" + monkeypatch.setattr( + 'app.routes.video.validate_file', + lambda f, allowed_types: ('test.mp4', 'mp4'), + ) + data = { + 'file': (io.BytesIO(b'\x00\x00\x00\x1cftyp'), 'test.mp4'), + 'start_time': '0', + 'duration': '5', + 'fps': '10', + 'width': '1000', + } + response = client.post( + '/api/video/to-gif', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 400 + + def test_to_gif_success(self, client, monkeypatch): + """Should return 202 with valid parameters.""" + mock_task = MagicMock() + mock_task.id = 'gif-task-id' + + monkeypatch.setattr( + 'app.routes.video.validate_file', + lambda f, allowed_types: ('test.mp4', 'mp4'), + ) + monkeypatch.setattr( + 'app.routes.video.generate_safe_path', + lambda ext, folder_type: ('gif-task-id', '/tmp/test_uploads/gif-task-id/test.mp4'), + ) + monkeypatch.setattr( + 'app.routes.video.create_gif_task.delay', + MagicMock(return_value=mock_task), + ) + # Mock FileStorage.save so nothing touches disk + monkeypatch.setattr( + 'werkzeug.datastructures.file_storage.FileStorage.save', + lambda self, dst, buffer_size=16384: None, + ) + + data = { + 'file': (io.BytesIO(b'\x00\x00\x00\x1cftyp'), 'test.mp4'), + 'start_time': '0', + 'duration': '5', + 'fps': '10', + 'width': '480', + } + response = client.post( + '/api/video/to-gif', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 202 + body = response.get_json() + assert body['task_id'] == 'gif-task-id' + assert 'message' in body \ No newline at end of file diff --git a/backend/tests/test_video_service.py b/backend/tests/test_video_service.py new file mode 100644 index 0000000..87598ab --- /dev/null +++ b/backend/tests/test_video_service.py @@ -0,0 +1,37 @@ +"""Tests for video processing service.""" +import os +from unittest.mock import patch, MagicMock +import pytest + +from app.services.video_service import video_to_gif, VideoProcessingError + + +class TestVideoService: + def test_sanitizes_parameters(self, app): + """Should clamp parameters to safe ranges.""" + with app.app_context(): + with patch('app.services.video_service.subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=1, stderr='test error') + # Even with crazy params, it should clamp them + with pytest.raises(VideoProcessingError): + video_to_gif( + '/tmp/test.mp4', '/tmp/out.gif', + start_time=-10, duration=100, + fps=50, width=2000, + ) + + def test_ffmpeg_palette_failure_raises(self, app): + """Should raise when ffmpeg palette generation fails.""" + with app.app_context(): + input_path = '/tmp/test_vid_fail.mp4' + with open(input_path, 'wb') as f: + f.write(b'\x00\x00\x00\x1cftyp') + + with patch('app.services.video_service.subprocess.run') as mock_run: + mock_run.return_value = MagicMock( + returncode=1, stderr='Invalid video' + ) + with pytest.raises(VideoProcessingError): + video_to_gif(input_path, '/tmp/fail_out.gif') + + os.unlink(input_path) \ No newline at end of file diff --git a/backend/tests/test_video_tasks.py b/backend/tests/test_video_tasks.py new file mode 100644 index 0000000..06d7e36 --- /dev/null +++ b/backend/tests/test_video_tasks.py @@ -0,0 +1,83 @@ +"""Tests for video task routes — Video to GIF.""" +import io +from unittest.mock import MagicMock + + +class TestVideoTaskRoutes: + def test_video_to_gif_dispatches_task(self, client, monkeypatch): + """Should dispatch create_gif_task with correct parameters.""" + mock_task = MagicMock() + mock_task.id = 'gif-task-id' + mock_delay = MagicMock(return_value=mock_task) + + monkeypatch.setattr( + 'app.routes.video.validate_file', + lambda f, allowed_types: ('video.mp4', 'mp4'), + ) + monkeypatch.setattr( + 'app.routes.video.generate_safe_path', + lambda ext, folder_type: ('gif-task-id', '/tmp/test.mp4'), + ) + monkeypatch.setattr( + 'app.routes.video.create_gif_task.delay', + mock_delay, + ) + + # Simulate exact frontend request format + data = { + 'file': (io.BytesIO(b'\x00\x00\x00\x1cftyp'), 'video.mp4'), + 'start_time': '2.5', + 'duration': '5', + 'fps': '10', + 'width': '480', + } + response = client.post( + '/api/video/to-gif', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 202 + body = response.get_json() + assert body['task_id'] == 'gif-task-id' + + # Verify task arguments match what the route sends + args = mock_delay.call_args[0] + assert args[0] == '/tmp/test.mp4' # input_path + assert args[1] == 'gif-task-id' # task_id + assert args[2] == 'video.mp4' # original_filename + + def test_video_to_gif_default_params(self, client, monkeypatch): + """Should use default params when not provided.""" + mock_task = MagicMock() + mock_task.id = 'gif-default-id' + mock_delay = MagicMock(return_value=mock_task) + + monkeypatch.setattr( + 'app.routes.video.validate_file', + lambda f, allowed_types: ('video.mp4', 'mp4'), + ) + monkeypatch.setattr( + 'app.routes.video.generate_safe_path', + lambda ext, folder_type: ('gif-default-id', '/tmp/test.mp4'), + ) + monkeypatch.setattr( + 'app.routes.video.create_gif_task.delay', + mock_delay, + ) + + # Only send file, no extra params + data = { + 'file': (io.BytesIO(b'\x00\x00\x00\x1cftyp'), 'video.mp4'), + } + response = client.post( + '/api/video/to-gif', + data=data, + content_type='multipart/form-data', + ) + assert response.status_code == 202 + # Defaults: start_time=0, duration=5, fps=10, width=480 + args = mock_delay.call_args[0] + assert args[3] == 0 # start_time + assert args[4] == 5 # duration + assert args[5] == 10 # fps + assert args[6] == 480 # width \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 4efd9b1..cec4960 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -40,7 +40,7 @@ services: celery -A celery_worker.celery worker --loglevel=warning --concurrency=4 - -Q default,convert,compress,image,video + -Q default,convert,compress,image,video,pdf_tools env_file: - .env environment: diff --git a/docker-compose.yml b/docker-compose.yml index 98c56ca..2d25e12 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,7 +44,7 @@ services: celery -A celery_worker.celery worker --loglevel=info --concurrency=2 - -Q default,convert,compress,image,video + -Q default,convert,compress,image,video,pdf_tools env_file: - .env environment: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3396dce..1ab4845 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,9 +9,12 @@ "version": "1.0.0", "dependencies": { "axios": "^1.7.0", + "fabric": "^6.4.3", "i18next": "^23.11.0", "i18next-browser-languagedetector": "^8.0.0", "lucide-react": "^0.400.0", + "pdf-lib": "^1.17.1", + "pdfjs-dist": "^4.4.168", "react": "^18.3.0", "react-dom": "^18.3.0", "react-dropzone": "^14.2.0", @@ -23,17 +26,36 @@ "zustand": "^4.5.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^20.14.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.0", "autoprefixer": "^10.4.0", + "jsdom": "^28.1.0", + "msw": "^2.12.10", "postcss": "^8.4.0", "tailwindcss": "^3.4.0", "typescript": "^5.5.0", - "vite": "^5.4.0" + "vite": "^5.4.0", + "vitest": "^4.0.18" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -47,6 +69,64 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -338,6 +418,151 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.29", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.29.tgz", + "integrity": "sha512-jx9GjkkP5YHuTmko2eWAvpPnb0mB4mGRr2U7XwVNwevm8nlpobZEVk+GNmiYMk2VuA75v+plfXWyroWKmICZXg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -627,6 +852,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -644,6 +886,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", @@ -661,6 +920,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -729,6 +1005,112 @@ "node": ">=12" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -779,6 +1161,85 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@mapbox/node-pre-gyp/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==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@mapbox/node-pre-gyp/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==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", + "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -817,6 +1278,49 @@ "node": ">= 8" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, "node_modules/@remix-run/router": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", @@ -1183,6 +1687,107 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "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==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1228,6 +1833,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1273,6 +1896,13 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1294,6 +1924,176 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "license": "BSD-3-Clause", + "optional": 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==", + "license": "ISC", + "optional": true + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "optional": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-globals": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", + "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "acorn": "^8.1.0", + "acorn-walk": "^8.0.2" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "license": "MIT", + "optional": true, + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "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==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "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", @@ -1315,6 +2115,28 @@ "node": ">= 8" } }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -1322,6 +2144,26 @@ "dev": true, "license": "MIT" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1385,6 +2227,36 @@ "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==", + "license": "MIT", + "optional": 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" + } + ], + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/baseline-browser-mapping": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", @@ -1398,6 +2270,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1411,6 +2293,31 @@ "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, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -1458,6 +2365,33 @@ "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" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1502,6 +2436,33 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.1.tgz", + "integrity": "sha512-ej1sPFR5+0YWtaVp6S1N1FVz69TQCqmrkGeRvQxZeAB1nAIcjNTHVwrZtYtWFFBmQsF40/uDLehsW5KuYC99mg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.3" + }, + "engines": { + "node": "^18.12.0 || >= 20.9.0" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1540,6 +2501,104 @@ "node": ">= 6" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "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, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/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, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/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, + "license": "MIT", + "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/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, + "license": "MIT", + "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, + "license": "MIT" + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1562,6 +2621,20 @@ "node": ">= 6" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT", + "optional": true + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1569,6 +2642,41 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1582,6 +2690,39 @@ "node": ">=4" } }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "license": "MIT", + "optional": true + }, + "node_modules/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1589,11 +2730,25 @@ "devOptional": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1607,6 +2762,43 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "devOptional": true, + "license": "MIT" + }, + "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, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1616,6 +2808,33 @@ "node": ">=0.4.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -1630,6 +2849,38 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "deprecated": "Use your platform's native DOMException instead", + "license": "MIT", + "optional": true, + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/domexception/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=12" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1651,6 +2902,38 @@ "dev": true, "license": "ISC" }, + "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==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1669,6 +2952,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -1745,6 +3035,396 @@ "node": ">=6" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fabric": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/fabric/-/fabric-6.4.3.tgz", + "integrity": "sha512-z/bJna3kWOBv+wmvVK4XxUQgCXLGb//VaSr5xPFIP708obH7472uuVsWbXam+xq+y21bLBtr4OHO1HuJyYi4FQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "canvas": "^2.11.2", + "jsdom": "^20.0.1" + } + }, + "node_modules/fabric/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==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/fabric/node_modules/canvas": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz", + "integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.0", + "nan": "^2.17.0", + "simple-get": "^3.0.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/fabric/node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "license": "MIT", + "optional": true, + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fabric/node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "license": "MIT", + "optional": true + }, + "node_modules/fabric/node_modules/data-urls": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", + "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fabric/node_modules/decompress-response": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", + "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fabric/node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fabric/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==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fabric/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==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fabric/node_modules/jsdom": { + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", + "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "abab": "^2.0.6", + "acorn": "^8.8.1", + "acorn-globals": "^7.0.0", + "cssom": "^0.5.0", + "cssstyle": "^2.3.0", + "data-urls": "^3.0.2", + "decimal.js": "^10.4.2", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.2", + "parse5": "^7.1.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0", + "ws": "^8.11.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/fabric/node_modules/mimic-response": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", + "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fabric/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "optional": true, + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/fabric/node_modules/simple-get": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz", + "integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==", + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^4.2.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/fabric/node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/fabric/node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "license": "MIT", + "optional": true, + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fabric/node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "license": "MIT", + "optional": true, + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/fabric/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/fabric/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/fabric/node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fabric/node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=12" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -1860,6 +3540,55 @@ "url": "https://github.com/sponsors/rawify" } }, + "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, + "license": "MIT", + "optional": true, + "peer": true + }, + "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==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-minipass/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true + }, + "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==", + "license": "ISC", + "optional": true + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1884,6 +3613,35 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/gauge/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==", + "license": "ISC", + "optional": true + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -1894,6 +3652,16 @@ "node": ">=6.9.0" } }, + "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, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1931,6 +3699,37 @@ "node": ">= 0.4" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": 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/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -1956,6 +3755,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graphql": { + "version": "16.13.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.0.tgz", + "integrity": "sha512-uSisMYERbaB9bkA9M4/4dnqyktaEkf1kMHNKq/7DHyxVeWqHQ2mBmVqm5u6/FVHwF3iCNalKcg82Zfl+tffWoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -1983,6 +3792,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -1995,6 +3811,26 @@ "node": ">= 0.4" } }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -2004,6 +3840,34 @@ "void-elements": "3.1.0" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/i18next": { "version": "23.16.8", "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz", @@ -2036,6 +3900,80 @@ "@babel/runtime": "^7.23.2" } }, + "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==", + "license": "MIT", + "optional": 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" + } + ], + "license": "BSD-3-Clause", + "optional": true, + "peer": true + }, + "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, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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.", + "license": "ISC", + "optional": 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==", + "license": "ISC", + "optional": true + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -2084,6 +4022,16 @@ "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==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -2097,6 +4045,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -2107,6 +4062,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -2123,6 +4085,47 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2200,6 +4203,43 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2209,6 +4249,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2254,13 +4301,184 @@ "node": ">= 0.6" } }, + "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, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "optional": true, + "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==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "optional": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "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==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/msw": { + "version": "2.12.10", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.10.tgz", + "integrity": "sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.41.2", + "@open-draft/deferred-promise": "^2.2.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.0.2", + "graphql": "^16.12.0", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.10.1", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", + "type-fest": "^5.2.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -2273,6 +4491,13 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nan": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz", + "integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==", + "license": "MIT", + "optional": true + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -2292,6 +4517,100 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "optional": true + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -2299,6 +4618,22 @@ "dev": true, "license": "MIT" }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -2309,6 +4644,27 @@ "node": ">=0.10.0" } }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "license": "MIT", + "optional": true + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2328,6 +4684,63 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "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==", + "license": "ISC", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "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==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -2335,6 +4748,115 @@ "dev": true, "license": "MIT" }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/path2d": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/path2d/-/path2d-0.2.2.tgz", + "integrity": "sha512-+vnG6S4dYcYxZd+CZxzXCNKdELYZSKfohrk98yajCo1PtRoDgCTrrwOvK1GT0UoAdVszagDVllQc0U1vaX4NUQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/pdfjs-dist": { + "version": "4.4.168", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.4.168.tgz", + "integrity": "sha512-MbkAjpwka/dMHaCfQ75RY1FXX3IewBVu6NGZOcxerRFlaBiIkZmUoR0jotX5VUzYZEXAGzSFtknWs5xRKliXPA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "canvas": "^2.11.2", + "path2d": "^0.2.0" + } + }, + "node_modules/pdfjs-dist/node_modules/canvas": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz", + "integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.0", + "nan": "^2.17.0", + "simple-get": "^3.0.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pdfjs-dist/node_modules/decompress-response": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", + "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pdfjs-dist/node_modules/mimic-response": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", + "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pdfjs-dist/node_modules/simple-get": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz", + "integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==", + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^4.2.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2538,6 +5060,60 @@ "dev": true, "license": "MIT" }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -2555,6 +5131,49 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "optional": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": 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==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT", + "optional": true + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -2576,6 +5195,24 @@ ], "license": "MIT" }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "peer": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -2724,6 +5361,21 @@ "pify": "^2.3.0" } }, + "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==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -2737,6 +5389,47 @@ "node": ">=8.10.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT", + "optional": true + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -2758,6 +5451,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/rettime": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz", + "integrity": "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==", + "dev": true, + "license": "MIT" + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -2769,6 +5469,23 @@ "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", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -2838,6 +5555,47 @@ "queue-microtask": "^1.2.2" } }, + "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" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT", + "optional": true + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -2851,18 +5609,94 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC", + "optional": true + }, "node_modules/shallowequal": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "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, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "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" + } + ], + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sonner": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz", @@ -2873,6 +5707,16 @@ "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, + "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==", + "license": "BSD-3-Clause", + "optional": 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", @@ -2883,6 +5727,100 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, + "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==", + "license": "MIT", + "optional": 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==", + "devOptional": true, + "license": "MIT", + "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==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -2919,6 +5857,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -2957,6 +5915,76 @@ "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==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": 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-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "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, + "license": "MIT", + "optional": 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/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -2980,6 +6008,23 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3028,6 +6073,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.24.tgz", + "integrity": "sha512-1r6vQTTt1rUiJkI5vX7KG8PR342Ru/5Oh13kEQP2SMbRSZpOey9SrBe27IDxkoWulx8ShWu4K6C0BkctP8Z1bQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.24" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.24.tgz", + "integrity": "sha512-pj7yygNMoMRqG7ML2SDQ0xNIOfN3IBDUcPVM2Sg6hP96oFNN2nqnzHreT3z9xLq85IWJyNTvD38O002DdOrPMw==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3041,6 +6116,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -3054,6 +6155,37 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-fest": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz", + "integrity": "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3068,6 +6200,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -3075,6 +6217,26 @@ "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -3106,6 +6268,17 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -3119,7 +6292,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/vite": { @@ -3182,6 +6355,650 @@ } } }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/vitest/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -3191,6 +7008,182 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/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, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?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==", + "license": "ISC", + "optional": true + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "devOptional": true, + "license": "MIT" + }, + "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, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -3198,6 +7191,48 @@ "dev": true, "license": "ISC" }, + "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, + "license": "MIT", + "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, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zustand": { "version": "4.5.7", "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", diff --git a/frontend/package.json b/frontend/package.json index d45231f..8740cf0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,13 +7,17 @@ "dev": "vite", "build": "tsc --noEmit && vite build", "preview": "vite preview", - "lint": "eslint ." + "lint": "eslint .", + "test": "vitest run" }, "dependencies": { "axios": "^1.7.0", + "fabric": "^6.4.3", "i18next": "^23.11.0", "i18next-browser-languagedetector": "^8.0.0", "lucide-react": "^0.400.0", + "pdf-lib": "^1.17.1", + "pdfjs-dist": "^4.4.168", "react": "^18.3.0", "react-dom": "^18.3.0", "react-dropzone": "^14.2.0", @@ -25,14 +29,19 @@ "zustand": "^4.5.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^20.14.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.0", "autoprefixer": "^10.4.0", + "jsdom": "^28.1.0", + "msw": "^2.12.10", "postcss": "^8.4.0", "tailwindcss": "^3.4.0", "typescript": "^5.5.0", - "vite": "^5.4.0" + "vite": "^5.4.0", + "vitest": "^4.0.18" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4a71ed8..3245be3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,6 +28,8 @@ const WatermarkPdf = lazy(() => import('@/components/tools/WatermarkPdf')); const ProtectPdf = lazy(() => import('@/components/tools/ProtectPdf')); const UnlockPdf = lazy(() => import('@/components/tools/UnlockPdf')); const AddPageNumbers = lazy(() => import('@/components/tools/AddPageNumbers')); +const PdfEditor = lazy(() => import('@/components/tools/PdfEditor')); +const PdfFlowchart = lazy(() => import('@/components/tools/PdfFlowchart')); function LoadingFallback() { return ( @@ -66,6 +68,8 @@ export default function App() { } /> } /> } /> + } /> + } /> {/* Image Tools */} } /> diff --git a/frontend/src/components/shared/HeroUploadZone.tsx b/frontend/src/components/shared/HeroUploadZone.tsx index bcbfbb0..8325beb 100644 --- a/frontend/src/components/shared/HeroUploadZone.tsx +++ b/frontend/src/components/shared/HeroUploadZone.tsx @@ -1,8 +1,10 @@ import { useState, useCallback } from 'react'; import { useDropzone } from 'react-dropzone'; +import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Upload, Sparkles } from 'lucide-react'; +import { Upload, Sparkles, PenLine } from 'lucide-react'; import ToolSelectorModal from '@/components/shared/ToolSelectorModal'; +import { useFileStore } from '@/stores/fileStore'; import { getToolsForFile, detectFileCategory, getCategoryLabel } from '@/utils/fileRouting'; import type { ToolOption } from '@/utils/fileRouting'; @@ -23,6 +25,8 @@ const ACCEPTED_TYPES = { export default function HeroUploadZone() { const { t } = useTranslation(); + const navigate = useNavigate(); + const setStoreFile = useFileStore((s) => s.setFile); const [selectedFile, setSelectedFile] = useState(null); const [matchedTools, setMatchedTools] = useState([]); const [fileTypeLabel, setFileTypeLabel] = useState(''); @@ -102,11 +106,50 @@ export default function HeroUploadZone() { {/* CTA Text */} -

- {t('home.uploadCta')} -

+
+ + +
+

- {t('home.uploadOr')} + {t('common.dragDrop', 'or drop files here')}

{/* Supported formats */} diff --git a/frontend/src/components/shared/ToolCard.tsx b/frontend/src/components/shared/ToolCard.tsx index 601f2e4..2ad1406 100644 --- a/frontend/src/components/shared/ToolCard.tsx +++ b/frontend/src/components/shared/ToolCard.tsx @@ -22,21 +22,21 @@ export default function ToolCard({ bgColor, }: ToolCardProps) { return ( - -
-
- {icon} -
-
-

+ +
+
+
+ {icon} +
+

{title}

-

- {description} -

+

+ {description} +

); diff --git a/frontend/src/components/tools/PdfEditor.tsx b/frontend/src/components/tools/PdfEditor.tsx new file mode 100644 index 0000000..f397224 --- /dev/null +++ b/frontend/src/components/tools/PdfEditor.tsx @@ -0,0 +1,245 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Helmet } from 'react-helmet-async'; +import { + PenLine, + Save, + Download, + Undo2, + Redo2, + PlusCircle, + Trash2, + RotateCw, + FileOutput, + PanelLeft, + Share2, + ShieldCheck, + Info, +} from 'lucide-react'; +import FileUploader from '@/components/shared/FileUploader'; +import ProgressBar from '@/components/shared/ProgressBar'; +import DownloadButton from '@/components/shared/DownloadButton'; +import AdSlot from '@/components/layout/AdSlot'; +import { useFileUpload } from '@/hooks/useFileUpload'; +import { useTaskPolling } from '@/hooks/useTaskPolling'; +import { generateToolSchema } from '@/utils/seo'; +import { useFileStore } from '@/stores/fileStore'; + +export default function PdfEditor() { + const { t } = useTranslation(); + const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload'); + + const { + file, + uploadProgress, + isUploading, + taskId, + error: uploadError, + selectFile, + startUpload, + reset, + } = useFileUpload({ + endpoint: '/compress/pdf', + maxSizeMB: 200, + acceptedTypes: ['pdf'], + extraData: { quality: 'high' }, + }); + + const { status, result, error: taskError } = useTaskPolling({ + taskId, + onComplete: () => setPhase('done'), + onError: () => setPhase('done'), + }); + + // Accept file from homepage smart upload + const storeFile = useFileStore((s) => s.file); + const clearStoreFile = useFileStore((s) => s.clearFile); + useEffect(() => { + if (storeFile) { + selectFile(storeFile); + clearStoreFile(); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const handleUpload = async () => { + const id = await startUpload(); + if (id) setPhase('processing'); + }; + + const handleReset = () => { + reset(); + setPhase('upload'); + }; + + const schema = generateToolSchema({ + name: t('tools.pdfEditor.title'), + description: t('tools.pdfEditor.description'), + url: `${window.location.origin}/tools/pdf-editor`, + }); + + const toolbarButtons = [ + { icon: Undo2, label: t('tools.pdfEditor.undo'), shortcut: 'Ctrl+Z' }, + { icon: Redo2, label: t('tools.pdfEditor.redo'), shortcut: 'Ctrl+Y' }, + { icon: PlusCircle, label: t('tools.pdfEditor.addPage') }, + { icon: Trash2, label: t('tools.pdfEditor.deletePage') }, + { icon: RotateCw, label: t('tools.pdfEditor.rotate') }, + { icon: FileOutput, label: t('tools.pdfEditor.extractPage') }, + { icon: PanelLeft, label: t('tools.pdfEditor.thumbnails') }, + ]; + + return ( + <> + + {t('tools.pdfEditor.title')} — {t('common.appName')} + + + + + +
+ {/* Header */} +
+
+ +
+

{t('tools.pdfEditor.title')}

+

+ {t('tools.pdfEditor.intro')} +

+
+ + + + {/* Upload Phase */} + {phase === 'upload' && ( +
+ + + {file && !isUploading && ( + <> + {/* Steps */} +
+
    +
  1. + 1 + {t('tools.pdfEditor.steps.step1')} +
  2. +
  3. + 2 + {t('tools.pdfEditor.steps.step2')} +
  4. +
  5. + 3 + {t('tools.pdfEditor.steps.step3')} +
  6. +
+
+ + {/* Toolbar Preview */} +
+

+ {t('tools.pdfEditor.thumbnails')} +

+
+ {toolbarButtons.map((btn) => { + const Icon = btn.icon; + return ( +
+ + {btn.label} +
+ ); + })} +
+
+ + {/* Upload Button */} + + + {/* Version & Privacy Notes */} +
+
+ + {t('tools.pdfEditor.versionNote')} +
+
+ + {t('tools.pdfEditor.privacyNote')} +
+
+ + )} +
+ )} + + {/* Processing Phase */} + {phase === 'processing' && !result && ( +
+ +

+ {t('tools.pdfEditor.applyingChangesSub')} +

+
+ )} + + {/* Done Phase - Success */} + {phase === 'done' && result && result.status === 'completed' && ( +
+ + + {/* Share button */} + {result.download_url && ( + + )} +
+ )} + + {/* Done Phase - Error */} + {phase === 'done' && taskError && ( +
+
+

+ {t('tools.pdfEditor.processingFailed')} +

+
+ +
+ )} + + +
+ + ); +} diff --git a/frontend/src/components/tools/PdfFlowchart.tsx b/frontend/src/components/tools/PdfFlowchart.tsx new file mode 100644 index 0000000..2afc14c --- /dev/null +++ b/frontend/src/components/tools/PdfFlowchart.tsx @@ -0,0 +1,360 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Helmet } from 'react-helmet-async'; +import { GitBranch } from 'lucide-react'; +import AdSlot from '@/components/layout/AdSlot'; +import { useTaskPolling } from '@/hooks/useTaskPolling'; +import { generateToolSchema } from '@/utils/seo'; +import { useFileStore } from '@/stores/fileStore'; + +import type { Procedure, Flowchart, PDFPage, WizardStep } from './pdf-flowchart/types'; +import StepProgress from './pdf-flowchart/StepProgress'; +import FlowUpload from './pdf-flowchart/FlowUpload'; +import ProcedureSelection from './pdf-flowchart/ProcedureSelection'; +import DocumentViewer from './pdf-flowchart/DocumentViewer'; +import ManualProcedure from './pdf-flowchart/ManualProcedure'; +import FlowGeneration from './pdf-flowchart/FlowGeneration'; +import FlowChart from './pdf-flowchart/FlowChart'; +import FlowChat from './pdf-flowchart/FlowChat'; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- +export default function PdfFlowchart() { + const { t } = useTranslation(); + + // Wizard state + const [step, setStep] = useState(0); + const [file, setFile] = useState(null); + const [taskId, setTaskId] = useState(null); + const [error, setError] = useState(null); + const [uploading, setUploading] = useState(false); + + // Data + const [pages, setPages] = useState([]); + const [procedures, setProcedures] = useState([]); + const [rejectedProcedures, setRejectedProcedures] = useState([]); + const [flowcharts, setFlowcharts] = useState([]); + const [selectedCount, setSelectedCount] = useState(0); + + // Sub-views + const [viewingProcedure, setViewingProcedure] = useState(null); + const [addingManual, setAddingManual] = useState(false); + const [viewingFlow, setViewingFlow] = useState(null); + const [chatOpen, setChatOpen] = useState(false); + + // Accept file from homepage smart-upload + const storeFile = useFileStore((s) => s.file); + const clearStoreFile = useFileStore((s) => s.clearFile); + useEffect(() => { + if (storeFile && storeFile.type === 'application/pdf') { + setFile(storeFile); + clearStoreFile(); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Task polling for extraction + const { error: taskError } = useTaskPolling({ + taskId, + onComplete: (res) => { + if (res?.procedures) { + setProcedures(res.procedures); + setFlowcharts((res.flowcharts || []) as unknown as Flowchart[]); + if (res.pages) setPages(res.pages as unknown as PDFPage[]); + setStep(1); + setUploading(false); + } + }, + onError: () => { + setError(taskError || t('common.error')); + setStep(0); + setUploading(false); + }, + }); + + // ------ Handlers ------ + const handleFileSelect = (f: File) => { + if (f.type === 'application/pdf') { + setFile(f); + setError(null); + } + }; + + const handleUpload = async () => { + if (!file) return; + setUploading(true); + setError(null); + + try { + const formData = new FormData(); + formData.append('file', file); + + const res = await fetch('/api/flowchart/extract', { + method: 'POST', + body: formData, + }); + const data = await res.json(); + + if (!res.ok) throw new Error(data.error || 'Upload failed.'); + setTaskId(data.task_id); + } catch (err) { + setError(err instanceof Error ? err.message : 'Upload failed.'); + setUploading(false); + } + }; + + const handleTrySample = async () => { + setUploading(true); + setError(null); + + try { + const res = await fetch('/api/flowchart/extract-sample', { + method: 'POST', + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Sample failed.'); + setTaskId(data.task_id); + } catch (err) { + setError(err instanceof Error ? err.message : 'Sample failed.'); + setUploading(false); + } + }; + + const handleRejectProcedure = (id: string) => { + const proc = procedures.find((p) => p.id === id); + if (!proc) return; + setProcedures((prev) => prev.filter((p) => p.id !== id)); + setRejectedProcedures((prev) => [...prev, proc]); + }; + + const handleRestoreProcedure = (id: string) => { + const proc = rejectedProcedures.find((p) => p.id === id); + if (!proc) return; + setRejectedProcedures((prev) => prev.filter((p) => p.id !== id)); + setProcedures((prev) => [...prev, proc]); + }; + + const handleContinueToGenerate = (selectedIds: string[]) => { + setSelectedCount(selectedIds.length); + // Filter flowcharts to selected procedures + const ids = new Set(selectedIds); + const selected = flowcharts.filter((fc) => ids.has(fc.procedureId)); + setFlowcharts(selected); + setStep(2); + }; + + const handleManualProcedureCreated = (proc: Procedure) => { + setProcedures((prev) => [...prev, proc]); + // Generate a simple flowchart for the manual procedure + const manualFlow: Flowchart = { + id: `flow-${proc.id}`, + procedureId: proc.id, + title: proc.title, + steps: [ + { id: '1', type: 'start', title: `Begin: ${proc.title.slice(0, 40)}`, description: 'Start of procedure', connections: ['2'] }, + { id: '2', type: 'process', title: proc.description.slice(0, 60) || 'Manual step', description: proc.description.slice(0, 150), connections: ['3'] }, + { id: '3', type: 'end', title: 'Procedure Complete', description: 'End of procedure', connections: [] }, + ], + }; + setFlowcharts((prev) => [...prev, manualFlow]); + setAddingManual(false); + }; + + const handleGenerationDone = () => { + setStep(3); + }; + + const handleFlowUpdate = (updated: Flowchart) => { + setFlowcharts((prev) => prev.map((fc) => (fc.id === updated.id ? updated : fc))); + if (viewingFlow?.id === updated.id) setViewingFlow(updated); + }; + + const handleReset = () => { + setFile(null); + setStep(0); + setTaskId(null); + setError(null); + setUploading(false); + setPages([]); + setProcedures([]); + setRejectedProcedures([]); + setFlowcharts([]); + setSelectedCount(0); + setViewingProcedure(null); + setAddingManual(false); + setViewingFlow(null); + setChatOpen(false); + }; + + // ------ SEO ------ + const schema = generateToolSchema({ + name: t('tools.pdfFlowchart.title'), + description: t('tools.pdfFlowchart.description'), + url: `${window.location.origin}/tools/pdf-flowchart`, + }); + + // === SUB-VIEWS (full-screen overlays) === + if (viewingFlow) { + return ( + <> + + {viewingFlow.title} — {t('common.appName')} + +
+ setViewingFlow(null)} + onOpenChat={() => setChatOpen(true)} + /> + {chatOpen && ( + setChatOpen(false)} + onFlowUpdate={handleFlowUpdate} + /> + )} +
+ + ); + } + + if (viewingProcedure) { + return ( +
+ setViewingProcedure(null)} + /> +
+ ); + } + + if (addingManual) { + return ( +
+ setAddingManual(false)} + /> +
+ ); + } + + // === MAIN VIEW === + return ( + <> + + {t('tools.pdfFlowchart.title')} — {t('common.appName')} + + + + + +
+ {/* Header */} +
+
+ +
+

{t('tools.pdfFlowchart.title')}

+

+ {t('tools.pdfFlowchart.description')} +

+
+ + {/* Step Progress */} + + + + + {/* Step 0: Upload */} + {step === 0 && ( + setFile(null)} + onUpload={handleUpload} + onTrySample={handleTrySample} + uploading={uploading} + error={error} + /> + )} + + {/* Step 1: Select Procedures */} + {step === 1 && ( + setAddingManual(true)} + onReject={handleRejectProcedure} + onRestore={handleRestoreProcedure} + onViewProcedure={setViewingProcedure} + onBack={handleReset} + /> + )} + + {/* Step 2: Generation */} + {step === 2 && ( + + )} + + {/* Step 3: Results */} + {step === 3 && ( +
+
+
+ +
+

+ {t('tools.pdfFlowchart.flowReady')} +

+

+ {t('tools.pdfFlowchart.flowReadyCount', { count: flowcharts.length })} +

+
+ + {flowcharts.map((flow) => ( +
+
+
+

+ {flow.title} +

+

+ {t('tools.pdfFlowchart.steps', { count: flow.steps.length })} +

+
+ +
+
+ ))} + +
+ +
+
+ )} + + +
+ + ); +} diff --git a/frontend/src/components/tools/SplitPdf.tsx b/frontend/src/components/tools/SplitPdf.tsx index a1b186f..67be1d9 100644 --- a/frontend/src/components/tools/SplitPdf.tsx +++ b/frontend/src/components/tools/SplitPdf.tsx @@ -18,6 +18,7 @@ export default function SplitPdf() { const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload'); const [mode, setMode] = useState('all'); const [pages, setPages] = useState(''); + const [validationError, setValidationError] = useState(''); const { file, @@ -52,7 +53,12 @@ export default function SplitPdf() { }, []); // eslint-disable-line react-hooks/exhaustive-deps const handleUpload = async () => { - if (mode === 'range' && !pages.trim()) return; + if (mode === 'range' && !pages.trim()) { + setValidationError(t('tools.splitPdf.errors.requiredPages')); + return; + } + + setValidationError(''); const id = await startUpload(); if (id) setPhase('processing'); }; @@ -62,6 +68,7 @@ export default function SplitPdf() { setPhase('upload'); setMode('all'); setPages(''); + setValidationError(''); }; const schema = generateToolSchema({ @@ -70,6 +77,45 @@ export default function SplitPdf() { url: `${window.location.origin}/tools/split-pdf`, }); + const getLocalizedSplitError = (message: string) => { + const outOfRangeMatch = message.match( + /^Selected pages \((.+)\) are out of range\. This PDF has only (\d+) page(?:s)?\.$/i + ); + if (outOfRangeMatch) { + return t('tools.splitPdf.errors.outOfRange', { + selected: outOfRangeMatch[1], + total: Number(outOfRangeMatch[2]), + }); + } + + const invalidFormatMatch = message.match( + /^Invalid page format: (.+)\. Use a format like 1,3,5-8\.$/i + ); + if (invalidFormatMatch) { + return t('tools.splitPdf.errors.invalidFormat', { + tokens: invalidFormatMatch[1], + }); + } + + const noPagesSelectedMatch = message.match( + /^No pages selected\. This PDF has (\d+) page(?:s)?\.$/i + ); + if (noPagesSelectedMatch) { + return t('tools.splitPdf.errors.noPagesSelected', { + total: Number(noPagesSelectedMatch[1]), + }); + } + + if ( + /Please specify which pages to extract/i.test(message) || + /Please specify at least one page/i.test(message) + ) { + return t('tools.splitPdf.errors.requiredPages'); + } + + return message; + }; + return ( <> @@ -109,7 +155,10 @@ export default function SplitPdf() { {/* Mode Selector */}
)} @@ -170,7 +228,7 @@ export default function SplitPdf() { {phase === 'done' && taskError && (
-

{taskError}

+

{getLocalizedSplitError(taskError)}

+
+

+ + {t('tools.pdfFlowchart.documentViewer')} +

+
+
+ + {/* Procedure info card */} +
+
+
+ {isHighPriority ? ( + + ) : ( + + )} +
+
+

+ {procedure.title} +

+

+ {procedure.description} +

+
+ {t('tools.pdfFlowchart.pages')}: {procedure.pages.join(', ')} + {t('tools.pdfFlowchart.totalPagesLabel')}: {procedure.pages.length} + ~{procedure.pages.length * 2} min +
+
+
+
+ + {/* Pages content */} +
+

+ + {t('tools.pdfFlowchart.documentContent')} ({relevantPages.length} {t('tools.pdfFlowchart.pagesWord')}) +

+ + {relevantPages.length === 0 ? ( +

{t('tools.pdfFlowchart.noPageContent')}

+ ) : ( + relevantPages.map((page) => ( +
+
+
+ {t('tools.pdfFlowchart.pageLabel')} {page.page} + {page.title ? `: ${page.title}` : ''} +
+ + {t('tools.pdfFlowchart.pageLabel')} {page.page} + +
+
+                {page.text}
+              
+
+ )) + )} +
+ + {/* AI Analysis summary */} +
+

+ {t('tools.pdfFlowchart.aiAnalysis')} +

+
+
+

{t('tools.pdfFlowchart.keyActions')}

+

+ {procedure.step_count} {t('tools.pdfFlowchart.stepsIdentified')} +

+
+
+

{t('tools.pdfFlowchart.decisionPoints')}

+

+ {Math.max(1, Math.floor(procedure.step_count / 3))} {t('tools.pdfFlowchart.estimated')} +

+
+
+

{t('tools.pdfFlowchart.flowComplexity')}

+

+ {procedure.step_count <= 4 + ? t('tools.pdfFlowchart.complexity.simple') + : procedure.step_count <= 8 + ? t('tools.pdfFlowchart.complexity.medium') + : t('tools.pdfFlowchart.complexity.complex')} +

+
+
+
+ + {/* Back row */} +
+ +

+ ~{procedure.step_count <= 4 ? '6-8' : procedure.step_count <= 8 ? '8-12' : '12-16'} {t('tools.pdfFlowchart.flowStepsEstimate')} +

+
+

+ ); +} diff --git a/frontend/src/components/tools/pdf-flowchart/FlowChart.tsx b/frontend/src/components/tools/pdf-flowchart/FlowChart.tsx new file mode 100644 index 0000000..f80b0f1 --- /dev/null +++ b/frontend/src/components/tools/pdf-flowchart/FlowChart.tsx @@ -0,0 +1,274 @@ +import { useRef, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Play, + Square, + Diamond, + Circle, + ArrowDown, + ChevronLeft, + Image as ImageIcon, + Download, +} from 'lucide-react'; +import type { Flowchart, FlowStep } from './types'; + +interface FlowChartProps { + flow: Flowchart; + onBack: () => void; + onOpenChat: () => void; +} + +// Node colour helpers +const getNodeStyle = (type: FlowStep['type']) => { + switch (type) { + case 'start': + return { bg: 'bg-green-100 border-green-400', text: 'text-green-800', icon: }; + case 'end': + return { bg: 'bg-red-100 border-red-400', text: 'text-red-800', icon: }; + case 'process': + return { bg: 'bg-blue-100 border-blue-400', text: 'text-blue-800', icon: }; + case 'decision': + return { bg: 'bg-amber-100 border-amber-400', text: 'text-amber-800', icon: }; + default: + return { bg: 'bg-slate-100 border-slate-400', text: 'text-slate-800', icon: }; + } +}; + +export default function FlowChartView({ flow, onBack, onOpenChat }: FlowChartProps) { + const { t } = useTranslation(); + const chartRef = useRef(null); + + // ---------- Export PNG ---------- + const exportPng = useCallback(async () => { + const el = chartRef.current; + if (!el) return; + + try { + const canvas = document.createElement('canvas'); + const scale = 2; + canvas.width = el.scrollWidth * scale; + canvas.height = el.scrollHeight * scale; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + ctx.scale(scale, scale); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const svgData = ` + + +
${el.outerHTML}
+
+
`; + + const img = new window.Image(); + const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' }); + const url = URL.createObjectURL(blob); + + await new Promise((resolve, reject) => { + img.onload = () => { + ctx.drawImage(img, 0, 0); + URL.revokeObjectURL(url); + canvas.toBlob((b) => { + if (!b) return reject(); + const a = document.createElement('a'); + a.href = URL.createObjectURL(b); + a.download = `flowchart-${flow.title.slice(0, 30)}.png`; + a.click(); + URL.revokeObjectURL(a.href); + resolve(); + }, 'image/png'); + }; + img.onerror = reject; + img.src = url; + }); + } catch { + // Fallback — JSON export + const a = document.createElement('a'); + const json = JSON.stringify(flow, null, 2); + a.href = URL.createObjectURL(new Blob([json], { type: 'application/json' })); + a.download = `flowchart-${flow.title.slice(0, 30)}.json`; + a.click(); + } + }, [flow]); + + // ---------- Export SVG ---------- + const exportSvg = useCallback(() => { + const nodeH = 90; + const arrowH = 40; + const padding = 40; + const nodeW = 320; + const totalH = flow.steps.length * (nodeH + arrowH) - arrowH + padding * 2; + const totalW = nodeW + padding * 2; + + const typeColors: Record = { + start: { fill: '#dcfce7', stroke: '#4ade80' }, + process: { fill: '#dbeafe', stroke: '#60a5fa' }, + decision: { fill: '#fef3c7', stroke: '#fbbf24' }, + end: { fill: '#fee2e2', stroke: '#f87171' }, + }; + + let svgParts = ``; + svgParts += ``; + + flow.steps.forEach((step, idx) => { + const x = padding; + const y = padding + idx * (nodeH + arrowH); + const colors = typeColors[step.type] || typeColors.process; + + svgParts += ``; + svgParts += `${step.type.toUpperCase()}`; + svgParts += `${escapeXml(step.title.slice(0, 45))}`; + if (step.description !== step.title) { + svgParts += `${escapeXml(step.description.slice(0, 60))}`; + } + + // Arrow + if (idx < flow.steps.length - 1) { + const ax = x + nodeW / 2; + const ay = y + nodeH; + svgParts += ``; + svgParts += ``; + } + }); + + svgParts += ''; + + const blob = new Blob([svgParts], { type: 'image/svg+xml;charset=utf-8' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `flowchart-${flow.title.slice(0, 30)}.svg`; + a.click(); + URL.revokeObjectURL(a.href); + }, [flow]); + + // Decision / Process / Start / End counts + const stats = { + total: flow.steps.length, + decisions: flow.steps.filter((s) => s.type === 'decision').length, + processes: flow.steps.filter((s) => s.type === 'process').length, + }; + + return ( +
+ {/* Top bar */} +
+ +
+ + + +
+
+ +

{flow.title}

+ + {/* The chart */} +
+ {/* SVG canvas for connection lines */} +
+ + + + + + + + +
+ {flow.steps.map((step, idx) => { + const style = getNodeStyle(step.type); + const isLast = idx === flow.steps.length - 1; + const hasMultipleConnections = step.connections.length > 1; + + return ( +
+ {/* Node */} +
+
+ {style.icon} + + {step.type} + +
+

{step.title}

+ {step.description !== step.title && ( +

{step.description}

+ )} +
+ + {/* Arrow / connector */} + {!isLast && ( +
+
+ {hasMultipleConnections ? ( +
+ Yes ↓ + No ↓ +
+ ) : ( + + )} +
+
+ )} +
+ ); + })} +
+
+ + {/* Stats */} +
+
+
{stats.total}
+
{t('tools.pdfFlowchart.totalSteps')}
+
+
+
{stats.decisions}
+
{t('tools.pdfFlowchart.decisionPoints')}
+
+
+
{stats.processes}
+
{t('tools.pdfFlowchart.processSteps')}
+
+
+
+
+ ); +} + +function escapeXml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} diff --git a/frontend/src/components/tools/pdf-flowchart/FlowChat.tsx b/frontend/src/components/tools/pdf-flowchart/FlowChat.tsx new file mode 100644 index 0000000..b2694f8 --- /dev/null +++ b/frontend/src/components/tools/pdf-flowchart/FlowChat.tsx @@ -0,0 +1,184 @@ +import { useState, useRef, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Send, Bot, User, Sparkles, X, Loader2 } from 'lucide-react'; +import type { Flowchart, ChatMessage } from './types'; + +interface FlowChatProps { + flow: Flowchart; + onClose: () => void; + onFlowUpdate?: (updated: Flowchart) => void; +} + +export default function FlowChat({ flow, onClose, onFlowUpdate }: FlowChatProps) { + const { t } = useTranslation(); + const [messages, setMessages] = useState([ + { + id: '1', + role: 'assistant', + content: t('tools.pdfFlowchart.chatWelcome', { title: flow.title }), + timestamp: new Date().toISOString(), + }, + ]); + const [input, setInput] = useState(''); + const [isTyping, setIsTyping] = useState(false); + const scrollRef = useRef(null); + + useEffect(() => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }); + }, [messages, isTyping]); + + const handleSend = async () => { + const text = input.trim(); + if (!text || isTyping) return; + + const userMsg: ChatMessage = { + id: Date.now().toString(), + role: 'user', + content: text, + timestamp: new Date().toISOString(), + }; + setMessages((prev) => [...prev, userMsg]); + setInput(''); + setIsTyping(true); + + try { + const res = await fetch('/api/flowchart/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: text, + flow_id: flow.id, + flow_data: flow, + }), + }); + const data = await res.json(); + + const assistantMsg: ChatMessage = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: data.reply || data.error || t('tools.pdfFlowchart.chatError'), + timestamp: new Date().toISOString(), + }; + setMessages((prev) => [...prev, assistantMsg]); + + // If the AI returned an updated flow, apply it + if (data.updated_flow && onFlowUpdate) { + onFlowUpdate(data.updated_flow); + } + } catch { + setMessages((prev) => [ + ...prev, + { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: t('tools.pdfFlowchart.chatError'), + timestamp: new Date().toISOString(), + }, + ]); + } finally { + setIsTyping(false); + } + }; + + const handleKey = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + const suggestions = [ + t('tools.pdfFlowchart.chatSuggestion1'), + t('tools.pdfFlowchart.chatSuggestion2'), + t('tools.pdfFlowchart.chatSuggestion3'), + t('tools.pdfFlowchart.chatSuggestion4'), + ]; + + return ( +
+ {/* Header */} +
+

+ + {t('tools.pdfFlowchart.aiAssistant')} +

+ +
+ + {/* Messages */} +
+ {messages.map((msg) => ( +
+
+ {msg.role === 'assistant' ? : } +
+
+

{msg.content}

+

+ {new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +

+
+
+ ))} + + {isTyping && ( +
+ + {t('tools.pdfFlowchart.chatTyping')} +
+ )} +
+ + {/* Quick suggestions */} + {messages.length <= 2 && ( +
+ {suggestions.map((s, i) => ( + + ))} +
+ )} + + {/* Input */} +
+ setInput(e.target.value)} + onKeyDown={handleKey} + placeholder={t('tools.pdfFlowchart.chatPlaceholder')} + className="flex-1 rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700" + /> + +
+
+ ); +} diff --git a/frontend/src/components/tools/pdf-flowchart/FlowGeneration.tsx b/frontend/src/components/tools/pdf-flowchart/FlowGeneration.tsx new file mode 100644 index 0000000..f3cd6ee --- /dev/null +++ b/frontend/src/components/tools/pdf-flowchart/FlowGeneration.tsx @@ -0,0 +1,79 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Loader2, CheckCircle2, ChevronRight } from 'lucide-react'; +import type { Flowchart } from './types'; + +interface FlowGenerationProps { + /** Called when generation is "done" (simulated progress + already-extracted flows) */ + flowcharts: Flowchart[]; + selectedCount: number; + onDone: () => void; +} + +export default function FlowGeneration({ flowcharts, selectedCount, onDone }: FlowGenerationProps) { + const { t } = useTranslation(); + const [progress, setProgress] = useState(0); + const [done, setDone] = useState(false); + + // Simulate a smooth progress bar while the flowcharts already exist in state + useEffect(() => { + const total = 100; + const stepMs = 40; + let current = 0; + const timer = setInterval(() => { + current += Math.random() * 12 + 3; + if (current >= total) { + current = total; + clearInterval(timer); + setDone(true); + } + setProgress(Math.min(current, total)); + }, stepMs); + return () => clearInterval(timer); + }, []); + + return ( +
+ {!done ? ( + <> + +

+ {t('tools.pdfFlowchart.generating')} +

+

+ {t('tools.pdfFlowchart.generatingDesc')} +

+ + {/* Progress bar */} +
+
+
+
+

{Math.round(progress)}%

+
+ +

+ {t('tools.pdfFlowchart.generatingFor', { count: selectedCount })} +

+ + ) : ( + <> + +

+ {t('tools.pdfFlowchart.flowReady')} +

+

+ {t('tools.pdfFlowchart.flowReadyCount', { count: flowcharts.length })} +

+ + + )} +
+ ); +} diff --git a/frontend/src/components/tools/pdf-flowchart/FlowUpload.tsx b/frontend/src/components/tools/pdf-flowchart/FlowUpload.tsx new file mode 100644 index 0000000..9a1ff91 --- /dev/null +++ b/frontend/src/components/tools/pdf-flowchart/FlowUpload.tsx @@ -0,0 +1,150 @@ +import { useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Upload, FileText, CheckCircle, Zap, X } from 'lucide-react'; + +interface FlowUploadProps { + file: File | null; + onFileSelect: (file: File) => void; + onClearFile: () => void; + onUpload: () => void; + onTrySample: () => void; + uploading: boolean; + error: string | null; +} + +export default function FlowUpload({ + file, + onFileSelect, + onClearFile, + onUpload, + onTrySample, + uploading, + error, +}: FlowUploadProps) { + const { t } = useTranslation(); + const [dragActive, setDragActive] = useState(false); + + const handleDrag = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.type === 'dragenter' || e.type === 'dragover') setDragActive(true); + else if (e.type === 'dragleave') setDragActive(false); + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + const f = e.dataTransfer.files[0]; + if (f?.type === 'application/pdf') onFileSelect(f); + }, + [onFileSelect], + ); + + const handleInputChange = (e: React.ChangeEvent) => { + const f = e.target.files?.[0]; + if (f?.type === 'application/pdf') onFileSelect(f); + }; + + return ( +
+ {/* Try Sample banner */} +
+ +
+

+ {t('tools.pdfFlowchart.trySampleTitle')} +

+

+ {t('tools.pdfFlowchart.trySampleDesc')} +

+
+ +
+ + {/* Drag & Drop zone */} + + + {/* Selected file */} + {file && ( +
+ +
+

+ {file.name} +

+

+ {(file.size / 1024 / 1024).toFixed(1)} MB +

+
+ +
+ )} + + {/* Error */} + {error && ( +
+

{error}

+
+ )} + + {/* Upload button */} + +
+ ); +} diff --git a/frontend/src/components/tools/pdf-flowchart/ManualProcedure.tsx b/frontend/src/components/tools/pdf-flowchart/ManualProcedure.tsx new file mode 100644 index 0000000..863c886 --- /dev/null +++ b/frontend/src/components/tools/pdf-flowchart/ManualProcedure.tsx @@ -0,0 +1,168 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ArrowLeft, Target, Check, AlertTriangle } from 'lucide-react'; +import type { Procedure, PDFPage } from './types'; + +interface ManualProcedureProps { + pages: PDFPage[]; + onProcedureCreated: (proc: Procedure) => void; + onBack: () => void; +} + +export default function ManualProcedure({ pages, onProcedureCreated, onBack }: ManualProcedureProps) { + const { t } = useTranslation(); + const [startPage, setStartPage] = useState(1); + const [endPage, setEndPage] = useState(1); + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + + const maxPages = pages.length || 1; + const isValidRange = startPage >= 1 && endPage >= startPage && endPage <= maxPages; + const selectedPages = isValidRange + ? Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i) + : []; + const canCreate = title.trim() && description.trim() && selectedPages.length > 0; + + const handleCreate = () => { + if (!canCreate) return; + onProcedureCreated({ + id: `manual-${Date.now()}`, + title: title.trim(), + description: description.trim(), + pages: selectedPages, + step_count: selectedPages.length * 3, + }); + }; + + return ( +
+ {/* Left — form */} +
+
+ +
+

+ + {t('tools.pdfFlowchart.manualTitle')} +

+

{t('tools.pdfFlowchart.manualDesc')}

+
+
+ + {/* Document info */} +
+

+ {t('tools.pdfFlowchart.totalPagesLabel')}: {maxPages} +

+
+ + {/* Page range */} +
+

+ {t('tools.pdfFlowchart.selectPageRange')} +

+
+
+ + setStartPage(Number(e.target.value) || 1)} + className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700" + /> +
+
+ + setEndPage(Number(e.target.value) || 1)} + className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700" + /> +
+
+ {!isValidRange && startPage > 0 && ( +

+ + {t('tools.pdfFlowchart.invalidRange')} +

+ )} + {isValidRange && selectedPages.length > 0 && ( +

+ + {selectedPages.length} {t('tools.pdfFlowchart.pagesSelected')} +

+ )} +
+ + {/* Title */} +
+ + setTitle(e.target.value)} + placeholder={t('tools.pdfFlowchart.procTitlePlaceholder')} + className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700" + /> +
+ + {/* Description */} +
+ +