ميزة: إضافة مكوني ProcedureSelection و StepProgress لأداة مخططات التدفق بصيغة PDF

- تنفيذ مكون ProcedureSelection لتمكين المستخدمين من اختيار الإجراءات من قائمة، وإدارة الاختيارات، ومعالجة الإجراءات المرفوضة.

- إنشاء مكون StepProgress لعرض تقدم معالج متعدد الخطوات بشكل مرئي.

- تعريف أنواع مشتركة للإجراءات، وخطوات التدفق، ورسائل الدردشة في ملف types.ts.

- إضافة اختبارات وحدة لخطافات useFileUpload و useTaskPolling لضمان الأداء السليم ومعالجة الأخطاء.

- تنفيذ اختبارات واجهة برمجة التطبيقات (API) للتحقق من تنسيقات نقاط النهاية وضمان اتساق ربط الواجهة الأمامية بالخلفية.
This commit is contained in:
Your Name
2026-03-06 17:16:09 +02:00
parent 2e97741d60
commit cfbcc8bd79
62 changed files with 10567 additions and 101 deletions

View File

@@ -22,7 +22,8 @@ WORKDIR /app
# Copy requirements first for Docker layer caching # Copy requirements first for Docker layer caching
COPY requirements.txt . 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 application code
COPY . . COPY . .

View File

@@ -62,6 +62,7 @@ def create_app(config_name=None):
from app.routes.tasks import tasks_bp from app.routes.tasks import tasks_bp
from app.routes.download import download_bp from app.routes.download import download_bp
from app.routes.pdf_tools import pdf_tools_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(health_bp, url_prefix="/api")
app.register_blueprint(convert_bp, url_prefix="/api/convert") 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(image_bp, url_prefix="/api/image")
app.register_blueprint(video_bp, url_prefix="/api/video") app.register_blueprint(video_bp, url_prefix="/api/video")
app.register_blueprint(pdf_tools_bp, url_prefix="/api/pdf-tools") 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(tasks_bp, url_prefix="/api/tasks")
app.register_blueprint(download_bp, url_prefix="/api/download") app.register_blueprint(download_bp, url_prefix="/api/download")

View File

@@ -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

View File

@@ -93,6 +93,11 @@ def split_pdf_route():
if mode not in ("all", "range"): if mode not in ("all", "range"):
mode = "all" 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: try:
original_filename, ext = validate_file(file, allowed_types=["pdf"]) original_filename, ext = validate_file(file, allowed_types=["pdf"])
except FileValidationError as e: except FileValidationError as e:

View File

@@ -3,11 +3,13 @@ from flask import Blueprint, jsonify
from celery.result import AsyncResult from celery.result import AsyncResult
from app.extensions import celery from app.extensions import celery
from app.middleware.rate_limiter import limiter
tasks_bp = Blueprint("tasks", __name__) tasks_bp = Blueprint("tasks", __name__)
@tasks_bp.route("/<task_id>/status", methods=["GET"]) @tasks_bp.route("/<task_id>/status", methods=["GET"])
@limiter.limit("300/minute", override_defaults=True)
def get_task_status(task_id: str): def get_task_status(task_id: str):
""" """
Get the status of an async task. Get the status of an async task.

View File

@@ -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."
)

View File

@@ -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

View File

@@ -140,20 +140,75 @@ def split_pdf(
def _parse_page_range(spec: str, total: int) -> list[int]: def _parse_page_range(spec: str, total: int) -> list[int]:
"""Parse a page specification like '1,3,5-8' into 0-based indices.""" """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() indices = set()
for part in spec.split(","): invalid_tokens = []
part = part.strip() out_of_range_tokens = []
for raw_part in spec.split(","):
part = raw_part.strip()
if not part:
continue
if "-" in part: if "-" in part:
if part.count("-") != 1:
invalid_tokens.append(part)
continue
start_s, end_s = part.split("-", 1) start_s, end_s = part.split("-", 1)
start = max(1, int(start_s.strip())) start_s = start_s.strip()
end = min(total, int(end_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)) indices.update(range(start - 1, end))
else: else:
if not part.isdigit():
invalid_tokens.append(part)
continue
page = int(part) page = int(part)
if 1 <= page <= total: if page < 1 or page > total:
indices.add(page - 1) 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: 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) return sorted(indices)

View File

@@ -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."}

View File

@@ -1,7 +1,12 @@
"""File validation utilities — multi-layer security checks.""" """File validation utilities — multi-layer security checks."""
import os import os
import magic try:
import magic
HAS_MAGIC = True
except (ImportError, OSError):
HAS_MAGIC = False
from flask import current_app from flask import current_app
from werkzeug.utils import secure_filename 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: if file_size == 0:
raise FileValidationError("File is empty.") 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_header = file_storage.read(8192)
file_storage.seek(0) file_storage.seek(0)
detected_mime = magic.from_buffer(file_header, mime=True) if HAS_MAGIC:
expected_mimes = valid_extensions.get(ext, []) detected_mime = magic.from_buffer(file_header, mime=True)
expected_mimes = valid_extensions.get(ext, [])
if detected_mime not in expected_mimes: if detected_mime not in expected_mimes:
raise FileValidationError( raise FileValidationError(
f"File content does not match extension '.{ext}'. " f"File content does not match extension '.{ext}'. "
f"Detected type: {detected_mime}" f"Detected type: {detected_mime}"
) )
# Layer 5: Additional content checks for specific types # Layer 5: Additional content checks for specific types
if ext == "pdf": if ext == "pdf":

View File

@@ -66,6 +66,13 @@ class BaseConfig:
RATELIMIT_STORAGE_URI = os.getenv("REDIS_URL", "redis://redis:6379/0") RATELIMIT_STORAGE_URI = os.getenv("REDIS_URL", "redis://redis:6379/0")
RATELIMIT_DEFAULT = "100/hour" 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): class DevelopmentConfig(BaseConfig):
"""Development configuration.""" """Development configuration."""
@@ -88,6 +95,15 @@ class TestingConfig(BaseConfig):
UPLOAD_FOLDER = "/tmp/test_uploads" UPLOAD_FOLDER = "/tmp/test_uploads"
OUTPUT_FOLDER = "/tmp/test_outputs" 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 = { config = {
"development": DevelopmentConfig, "development": DevelopmentConfig,

View File

@@ -24,9 +24,18 @@ pdf2image>=1.16,<2.0
# AWS # AWS
boto3>=1.34,<2.0 boto3>=1.34,<2.0
# HTTP Client
requests>=2.31,<3.0
# Security # Security
werkzeug>=3.0,<4.0 werkzeug>=3.0,<4.0
# Testing # Testing
pytest>=8.0,<9.0 pytest>=8.0,<9.0
pytest-flask>=1.3,<2.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

View File

@@ -1,5 +1,8 @@
import io
import os import os
import shutil
import pytest import pytest
from unittest.mock import patch, MagicMock
from app import create_app from app import create_app
@@ -7,12 +10,22 @@ from app import create_app
def app(): def app():
"""Create application for testing.""" """Create application for testing."""
os.environ['FLASK_ENV'] = 'testing' os.environ['FLASK_ENV'] = 'testing'
app = create_app() app = create_app('testing')
app.config.update({ app.config.update({
'TESTING': True, '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 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 @pytest.fixture
def client(app): def client(app):
@@ -24,3 +37,72 @@ def client(app):
def runner(app): def runner(app):
"""Flask test CLI runner.""" """Flask test CLI runner."""
return app.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<</Type/Catalog/Pages 2 0 R>>endobj\n"
b"2 0 obj<</Type/Pages/Count 1/Kids[3 0 R]>>endobj\n"
b"3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R>>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<</Root 1 0 R/Size 4>>\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

View File

@@ -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)

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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"])

View File

@@ -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)

View File

@@ -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

207
backend/tests/test_load.py Normal file
View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -1,19 +1,21 @@
"""Tests for text utility functions.""" """Tests for general 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
from app.utils.sanitizer import generate_safe_path 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.""" """generate_safe_path should produce UUID-based path."""
path = generate_safe_path('uploads', 'test.pdf') with app.app_context():
assert path.startswith('uploads') task_id, path = generate_safe_path('pdf', folder_type='upload')
assert path.endswith('.pdf') assert task_id in path
# Should contain a UUID directory assert path.endswith('.pdf')
parts = path.replace('\\', '/').split('/') # Should contain a UUID directory
assert len(parts) >= 3 # uploads / uuid / filename.pdf 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

151
backend/tests/test_video.py Normal file
View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -40,7 +40,7 @@ services:
celery -A celery_worker.celery worker celery -A celery_worker.celery worker
--loglevel=warning --loglevel=warning
--concurrency=4 --concurrency=4
-Q default,convert,compress,image,video -Q default,convert,compress,image,video,pdf_tools
env_file: env_file:
- .env - .env
environment: environment:

View File

@@ -44,7 +44,7 @@ services:
celery -A celery_worker.celery worker celery -A celery_worker.celery worker
--loglevel=info --loglevel=info
--concurrency=2 --concurrency=2
-Q default,convert,compress,image,video -Q default,convert,compress,image,video,pdf_tools
env_file: env_file:
- .env - .env
environment: environment:

File diff suppressed because it is too large Load Diff

View File

@@ -7,13 +7,17 @@
"dev": "vite", "dev": "vite",
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint ." "lint": "eslint .",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.0", "axios": "^1.7.0",
"fabric": "^6.4.3",
"i18next": "^23.11.0", "i18next": "^23.11.0",
"i18next-browser-languagedetector": "^8.0.0", "i18next-browser-languagedetector": "^8.0.0",
"lucide-react": "^0.400.0", "lucide-react": "^0.400.0",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^4.4.168",
"react": "^18.3.0", "react": "^18.3.0",
"react-dom": "^18.3.0", "react-dom": "^18.3.0",
"react-dropzone": "^14.2.0", "react-dropzone": "^14.2.0",
@@ -25,14 +29,19 @@
"zustand": "^4.5.0" "zustand": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^20.14.0", "@types/node": "^20.14.0",
"@types/react": "^18.3.0", "@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.0", "autoprefixer": "^10.4.0",
"jsdom": "^28.1.0",
"msw": "^2.12.10",
"postcss": "^8.4.0", "postcss": "^8.4.0",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"typescript": "^5.5.0", "typescript": "^5.5.0",
"vite": "^5.4.0" "vite": "^5.4.0",
"vitest": "^4.0.18"
} }
} }

View File

@@ -28,6 +28,8 @@ const WatermarkPdf = lazy(() => import('@/components/tools/WatermarkPdf'));
const ProtectPdf = lazy(() => import('@/components/tools/ProtectPdf')); const ProtectPdf = lazy(() => import('@/components/tools/ProtectPdf'));
const UnlockPdf = lazy(() => import('@/components/tools/UnlockPdf')); const UnlockPdf = lazy(() => import('@/components/tools/UnlockPdf'));
const AddPageNumbers = lazy(() => import('@/components/tools/AddPageNumbers')); const AddPageNumbers = lazy(() => import('@/components/tools/AddPageNumbers'));
const PdfEditor = lazy(() => import('@/components/tools/PdfEditor'));
const PdfFlowchart = lazy(() => import('@/components/tools/PdfFlowchart'));
function LoadingFallback() { function LoadingFallback() {
return ( return (
@@ -66,6 +68,8 @@ export default function App() {
<Route path="/tools/protect-pdf" element={<ProtectPdf />} /> <Route path="/tools/protect-pdf" element={<ProtectPdf />} />
<Route path="/tools/unlock-pdf" element={<UnlockPdf />} /> <Route path="/tools/unlock-pdf" element={<UnlockPdf />} />
<Route path="/tools/page-numbers" element={<AddPageNumbers />} /> <Route path="/tools/page-numbers" element={<AddPageNumbers />} />
<Route path="/tools/pdf-editor" element={<PdfEditor />} />
<Route path="/tools/pdf-flowchart" element={<PdfFlowchart />} />
{/* Image Tools */} {/* Image Tools */}
<Route path="/tools/image-converter" element={<ImageConverter />} /> <Route path="/tools/image-converter" element={<ImageConverter />} />

View File

@@ -1,8 +1,10 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useDropzone } from 'react-dropzone'; import { useDropzone } from 'react-dropzone';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; 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 ToolSelectorModal from '@/components/shared/ToolSelectorModal';
import { useFileStore } from '@/stores/fileStore';
import { getToolsForFile, detectFileCategory, getCategoryLabel } from '@/utils/fileRouting'; import { getToolsForFile, detectFileCategory, getCategoryLabel } from '@/utils/fileRouting';
import type { ToolOption } from '@/utils/fileRouting'; import type { ToolOption } from '@/utils/fileRouting';
@@ -23,6 +25,8 @@ const ACCEPTED_TYPES = {
export default function HeroUploadZone() { export default function HeroUploadZone() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const setStoreFile = useFileStore((s) => s.setFile);
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [matchedTools, setMatchedTools] = useState<ToolOption[]>([]); const [matchedTools, setMatchedTools] = useState<ToolOption[]>([]);
const [fileTypeLabel, setFileTypeLabel] = useState(''); const [fileTypeLabel, setFileTypeLabel] = useState('');
@@ -102,11 +106,50 @@ export default function HeroUploadZone() {
</div> </div>
{/* CTA Text */} {/* CTA Text */}
<p className="mb-1 text-lg font-semibold text-slate-800 dark:text-slate-200"> <div className="mb-6 flex gap-3 justify-center z-10 relative">
{t('home.uploadCta')} <button
</p> type="button"
className="px-6 py-3 bg-red-600 hover:bg-red-700 text-white font-bold rounded-xl shadow-md transition-colors"
onClick={(e) => {
e.stopPropagation();
const input = document.createElement('input');
input.type = 'file';
input.accept = Object.values(ACCEPTED_TYPES).flat().join(',');
input.onchange = (ev) => {
const fileInput = ev.target as HTMLInputElement;
const f = fileInput.files?.[0];
if (f) onDrop([f]);
};
input.click();
}}
>
{t('home.uploadCta', 'Choose File')}
</button>
<button
onClick={(e) => {
e.stopPropagation();
const input = document.createElement('input');
input.type = 'file';
input.accept = '.pdf';
input.onchange = (ev) => {
const fileInput = ev.target as HTMLInputElement;
const f = fileInput.files?.[0];
if (f) {
setStoreFile(f);
navigate('/tools/pdf-editor');
}
};
input.click();
}}
className="px-6 py-3 bg-slate-900 hover:bg-slate-800 text-white font-bold rounded-xl shadow-md transition-colors flex items-center gap-2"
>
<PenLine className="h-5 w-5" />
{t('home.editNow')}
</button>
</div>
<p className="mb-3 text-sm text-slate-500 dark:text-slate-400"> <p className="mb-3 text-sm text-slate-500 dark:text-slate-400">
{t('home.uploadOr')} {t('common.dragDrop', 'or drop files here')}
</p> </p>
{/* Supported formats */} {/* Supported formats */}

View File

@@ -22,21 +22,21 @@ export default function ToolCard({
bgColor, bgColor,
}: ToolCardProps) { }: ToolCardProps) {
return ( return (
<Link to={to} className="tool-card group block"> <Link to={to} className="group block h-full">
<div className="flex items-start gap-4"> <div className="flex h-full flex-col gap-3 rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200 transition-all duration-200 hover:-translate-y-1 hover:shadow-md hover:ring-primary-300 dark:bg-slate-800 dark:ring-slate-700 dark:hover:ring-primary-500">
<div <div className="flex items-center gap-4">
className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl ${bgColor}`} <div
> className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl transition-colors ${bgColor} dark:bg-slate-700 dark:group-hover:bg-slate-600`}
{icon} >
</div> {icon}
<div className="min-w-0 flex-1"> </div>
<h3 className="text-base font-semibold text-slate-900 group-hover:text-primary-600 transition-colors dark:text-slate-100 dark:group-hover:text-primary-400"> <h3 className="text-base font-bold text-slate-900 transition-colors group-hover:text-primary-600 dark:text-slate-100 dark:group-hover:text-primary-400">
{title} {title}
</h3> </h3>
<p className="mt-1 text-sm text-slate-500 line-clamp-2 dark:text-slate-400">
{description}
</p>
</div> </div>
<p className="text-sm text-slate-500 line-clamp-2 dark:text-slate-400 mt-1">
{description}
</p>
</div> </div>
</Link> </Link>
); );

View File

@@ -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 (
<>
<Helmet>
<title>{t('tools.pdfEditor.title')} {t('common.appName')}</title>
<meta name="description" content={t('tools.pdfEditor.description')} />
<link rel="canonical" href={`${window.location.origin}/tools/pdf-editor`} />
<script type="application/ld+json">{JSON.stringify(schema)}</script>
</Helmet>
<div className="mx-auto max-w-2xl">
{/* Header */}
<div className="mb-8 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-rose-100 dark:bg-rose-900/30">
<PenLine className="h-8 w-8 text-rose-600 dark:text-rose-400" />
</div>
<h1 className="section-heading">{t('tools.pdfEditor.title')}</h1>
<p className="mt-2 text-slate-500 dark:text-slate-400">
{t('tools.pdfEditor.intro')}
</p>
</div>
<AdSlot slot="top-banner" format="horizontal" className="mb-6" />
{/* Upload Phase */}
{phase === 'upload' && (
<div className="space-y-6">
<FileUploader
onFileSelect={selectFile}
file={file}
accept={{ 'application/pdf': ['.pdf'] }}
maxSizeMB={200}
isUploading={isUploading}
uploadProgress={uploadProgress}
error={uploadError}
onReset={handleReset}
acceptLabel="PDF"
/>
{file && !isUploading && (
<>
{/* Steps */}
<div className="rounded-2xl bg-white p-5 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
<ol className="space-y-3 text-sm text-slate-600 dark:text-slate-300">
<li className="flex gap-3">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/30 dark:text-primary-400">1</span>
{t('tools.pdfEditor.steps.step1')}
</li>
<li className="flex gap-3">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/30 dark:text-primary-400">2</span>
{t('tools.pdfEditor.steps.step2')}
</li>
<li className="flex gap-3">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/30 dark:text-primary-400">3</span>
{t('tools.pdfEditor.steps.step3')}
</li>
</ol>
</div>
{/* Toolbar Preview */}
<div className="rounded-2xl bg-white p-4 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
<p className="mb-3 text-xs font-medium uppercase tracking-wide text-slate-400 dark:text-slate-500">
{t('tools.pdfEditor.thumbnails')}
</p>
<div className="flex flex-wrap gap-2">
{toolbarButtons.map((btn) => {
const Icon = btn.icon;
return (
<div
key={btn.label}
className="flex items-center gap-1.5 rounded-lg bg-slate-50 px-3 py-2 text-xs font-medium text-slate-600 ring-1 ring-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:ring-slate-600"
title={btn.shortcut ? `${btn.label} (${btn.shortcut})` : btn.label}
>
<Icon className="h-4 w-4" />
<span className="hidden sm:inline">{btn.label}</span>
</div>
);
})}
</div>
</div>
{/* Upload Button */}
<button
onClick={handleUpload}
className="btn-primary w-full"
title={t('tools.pdfEditor.saveTooltip')}
>
<Save className="h-5 w-5" />
{t('tools.pdfEditor.save')}
</button>
{/* Version & Privacy Notes */}
<div className="space-y-3">
<div className="flex gap-2.5 rounded-xl bg-blue-50 p-3 text-xs text-blue-700 ring-1 ring-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:ring-blue-800">
<Info className="h-4 w-4 shrink-0 mt-0.5" />
<span>{t('tools.pdfEditor.versionNote')}</span>
</div>
<div className="flex gap-2.5 rounded-xl bg-emerald-50 p-3 text-xs text-emerald-700 ring-1 ring-emerald-200 dark:bg-emerald-900/20 dark:text-emerald-300 dark:ring-emerald-800">
<ShieldCheck className="h-4 w-4 shrink-0 mt-0.5" />
<span>{t('tools.pdfEditor.privacyNote')}</span>
</div>
</div>
</>
)}
</div>
)}
{/* Processing Phase */}
{phase === 'processing' && !result && (
<div className="space-y-3">
<ProgressBar state={status?.state || 'PENDING'} message={status?.progress} />
<p className="text-center text-sm text-slate-500 dark:text-slate-400">
{t('tools.pdfEditor.applyingChangesSub')}
</p>
</div>
)}
{/* Done Phase - Success */}
{phase === 'done' && result && result.status === 'completed' && (
<div className="space-y-4">
<DownloadButton result={result} onStartOver={handleReset} />
{/* Share button */}
{result.download_url && (
<button
onClick={() => {
navigator.clipboard.writeText(result.download_url!);
}}
className="btn-secondary w-full"
title={t('tools.pdfEditor.share')}
>
<Share2 className="h-5 w-5" />
{t('tools.pdfEditor.share')}
</button>
)}
</div>
)}
{/* Done Phase - Error */}
{phase === 'done' && taskError && (
<div className="space-y-4">
<div className="rounded-xl bg-red-50 p-4 ring-1 ring-red-200 dark:bg-red-900/20 dark:ring-red-800">
<p className="text-sm text-red-700 dark:text-red-400">
{t('tools.pdfEditor.processingFailed')}
</p>
</div>
<button onClick={handleReset} className="btn-secondary w-full">
{t('tools.pdfEditor.retry')}
</button>
</div>
)}
<AdSlot slot="bottom-banner" className="mt-8" />
</div>
</>
);
}

View File

@@ -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<WizardStep>(0);
const [file, setFile] = useState<File | null>(null);
const [taskId, setTaskId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
// Data
const [pages, setPages] = useState<PDFPage[]>([]);
const [procedures, setProcedures] = useState<Procedure[]>([]);
const [rejectedProcedures, setRejectedProcedures] = useState<Procedure[]>([]);
const [flowcharts, setFlowcharts] = useState<Flowchart[]>([]);
const [selectedCount, setSelectedCount] = useState(0);
// Sub-views
const [viewingProcedure, setViewingProcedure] = useState<Procedure | null>(null);
const [addingManual, setAddingManual] = useState(false);
const [viewingFlow, setViewingFlow] = useState<Flowchart | null>(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 (
<>
<Helmet>
<title>{viewingFlow.title} {t('common.appName')}</title>
</Helmet>
<div className="mx-auto max-w-4xl space-y-6">
<FlowChart
flow={viewingFlow}
onBack={() => setViewingFlow(null)}
onOpenChat={() => setChatOpen(true)}
/>
{chatOpen && (
<FlowChat
flow={viewingFlow}
onClose={() => setChatOpen(false)}
onFlowUpdate={handleFlowUpdate}
/>
)}
</div>
</>
);
}
if (viewingProcedure) {
return (
<div className="mx-auto max-w-3xl">
<DocumentViewer
procedure={viewingProcedure}
pages={pages}
onClose={() => setViewingProcedure(null)}
/>
</div>
);
}
if (addingManual) {
return (
<div className="mx-auto max-w-4xl">
<ManualProcedure
pages={pages}
onProcedureCreated={handleManualProcedureCreated}
onBack={() => setAddingManual(false)}
/>
</div>
);
}
// === MAIN VIEW ===
return (
<>
<Helmet>
<title>{t('tools.pdfFlowchart.title')} {t('common.appName')}</title>
<meta name="description" content={t('tools.pdfFlowchart.description')} />
<link rel="canonical" href={`${window.location.origin}/tools/pdf-flowchart`} />
<script type="application/ld+json">{JSON.stringify(schema)}</script>
</Helmet>
<div className="mx-auto max-w-3xl">
{/* Header */}
<div className="mb-8 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-indigo-100 dark:bg-indigo-900/30">
<GitBranch className="h-8 w-8 text-indigo-600 dark:text-indigo-400" />
</div>
<h1 className="section-heading">{t('tools.pdfFlowchart.title')}</h1>
<p className="mt-2 text-slate-500 dark:text-slate-400">
{t('tools.pdfFlowchart.description')}
</p>
</div>
{/* Step Progress */}
<StepProgress currentStep={step} className="mb-8" />
<AdSlot slot="top-banner" format="horizontal" className="mb-6" />
{/* Step 0: Upload */}
{step === 0 && (
<FlowUpload
file={file}
onFileSelect={handleFileSelect}
onClearFile={() => setFile(null)}
onUpload={handleUpload}
onTrySample={handleTrySample}
uploading={uploading}
error={error}
/>
)}
{/* Step 1: Select Procedures */}
{step === 1 && (
<ProcedureSelection
procedures={procedures}
rejectedProcedures={rejectedProcedures}
pages={pages}
onContinue={handleContinueToGenerate}
onManualAdd={() => setAddingManual(true)}
onReject={handleRejectProcedure}
onRestore={handleRestoreProcedure}
onViewProcedure={setViewingProcedure}
onBack={handleReset}
/>
)}
{/* Step 2: Generation */}
{step === 2 && (
<FlowGeneration
flowcharts={flowcharts}
selectedCount={selectedCount}
onDone={handleGenerationDone}
/>
)}
{/* Step 3: Results */}
{step === 3 && (
<div className="space-y-4">
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 text-center dark:bg-slate-800 dark:ring-slate-700">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-green-100 text-green-600">
<GitBranch className="h-6 w-6" />
</div>
<h2 className="text-xl font-bold text-slate-800 dark:text-slate-200">
{t('tools.pdfFlowchart.flowReady')}
</h2>
<p className="mt-1 text-slate-500 dark:text-slate-400">
{t('tools.pdfFlowchart.flowReadyCount', { count: flowcharts.length })}
</p>
</div>
{flowcharts.map((flow) => (
<div
key={flow.id}
className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700"
>
<div className="flex items-center justify-between">
<div>
<h3 className="font-bold text-slate-800 dark:text-slate-200">
{flow.title}
</h3>
<p className="text-sm text-slate-500 dark:text-slate-400">
{t('tools.pdfFlowchart.steps', { count: flow.steps.length })}
</p>
</div>
<button
onClick={() => setViewingFlow(flow)}
className="btn-primary text-sm"
>
{t('tools.pdfFlowchart.viewFlow')}
</button>
</div>
</div>
))}
<div className="text-center pt-2">
<button onClick={handleReset} className="btn-secondary">
{t('common.startOver')}
</button>
</div>
</div>
)}
<AdSlot slot="bottom-banner" className="mt-8" />
</div>
</>
);
}

View File

@@ -18,6 +18,7 @@ export default function SplitPdf() {
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload'); const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
const [mode, setMode] = useState<SplitMode>('all'); const [mode, setMode] = useState<SplitMode>('all');
const [pages, setPages] = useState(''); const [pages, setPages] = useState('');
const [validationError, setValidationError] = useState('');
const { const {
file, file,
@@ -52,7 +53,12 @@ export default function SplitPdf() {
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleUpload = async () => { 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(); const id = await startUpload();
if (id) setPhase('processing'); if (id) setPhase('processing');
}; };
@@ -62,6 +68,7 @@ export default function SplitPdf() {
setPhase('upload'); setPhase('upload');
setMode('all'); setMode('all');
setPages(''); setPages('');
setValidationError('');
}; };
const schema = generateToolSchema({ const schema = generateToolSchema({
@@ -70,6 +77,45 @@ export default function SplitPdf() {
url: `${window.location.origin}/tools/split-pdf`, 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 ( return (
<> <>
<Helmet> <Helmet>
@@ -109,7 +155,10 @@ export default function SplitPdf() {
{/* Mode Selector */} {/* Mode Selector */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<button <button
onClick={() => setMode('all')} onClick={() => {
setMode('all');
setValidationError('');
}}
className={`rounded-xl p-3 text-center ring-1 transition-all ${ className={`rounded-xl p-3 text-center ring-1 transition-all ${
mode === 'all' mode === 'all'
? 'bg-primary-50 ring-primary-300 text-primary-700 font-semibold' ? 'bg-primary-50 ring-primary-300 text-primary-700 font-semibold'
@@ -120,7 +169,10 @@ export default function SplitPdf() {
<p className="text-xs text-slate-400 mt-0.5">{t('tools.splitPdf.allPagesDesc')}</p> <p className="text-xs text-slate-400 mt-0.5">{t('tools.splitPdf.allPagesDesc')}</p>
</button> </button>
<button <button
onClick={() => setMode('range')} onClick={() => {
setMode('range');
setValidationError('');
}}
className={`rounded-xl p-3 text-center ring-1 transition-all ${ className={`rounded-xl p-3 text-center ring-1 transition-all ${
mode === 'range' mode === 'range'
? 'bg-primary-50 ring-primary-300 text-primary-700 font-semibold' ? 'bg-primary-50 ring-primary-300 text-primary-700 font-semibold'
@@ -141,13 +193,19 @@ export default function SplitPdf() {
<input <input
type="text" type="text"
value={pages} value={pages}
onChange={(e) => setPages(e.target.value)} onChange={(e) => {
placeholder="1, 3, 5-8" setPages(e.target.value);
if (validationError) setValidationError('');
}}
placeholder={t('tools.splitPdf.rangePlaceholder')}
className="input-field" className="input-field"
/> />
<p className="mt-1 text-xs text-slate-400"> <p className="mt-1 text-xs text-slate-400">
{t('tools.splitPdf.pageRangeHint')} {t('tools.splitPdf.rangeHint')}
</p> </p>
{validationError && (
<p className="mt-2 text-sm text-red-600">{validationError}</p>
)}
</div> </div>
)} )}
@@ -170,7 +228,7 @@ export default function SplitPdf() {
{phase === 'done' && taskError && ( {phase === 'done' && taskError && (
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-xl bg-red-50 p-4 ring-1 ring-red-200"> <div className="rounded-xl bg-red-50 p-4 ring-1 ring-red-200">
<p className="text-sm text-red-700">{taskError}</p> <p className="text-sm text-red-700">{getLocalizedSplitError(taskError)}</p>
</div> </div>
<button onClick={handleReset} className="btn-secondary w-full"> <button onClick={handleReset} className="btn-secondary w-full">
{t('common.startOver')} {t('common.startOver')}

View File

@@ -0,0 +1,136 @@
import { useTranslation } from 'react-i18next';
import { ArrowLeft, FileText, AlertTriangle, BookOpen } from 'lucide-react';
import type { Procedure, PDFPage } from './types';
interface DocumentViewerProps {
procedure: Procedure;
pages: PDFPage[];
onClose: () => void;
}
export default function DocumentViewer({ procedure, pages, onClose }: DocumentViewerProps) {
const { t } = useTranslation();
const relevantPages = pages.filter((p) => procedure.pages.includes(p.page));
const isHighPriority =
/emergency|safety|طوارئ|أمان|urgence/i.test(procedure.title);
return (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
{/* Header */}
<div className="mb-5 flex items-center gap-3">
<button onClick={onClose} className="btn-secondary text-xs">
<ArrowLeft className="h-4 w-4" />
{t('tools.pdfFlowchart.backToProcedures')}
</button>
<div>
<h2 className="flex items-center gap-2 text-lg font-bold text-slate-800 dark:text-slate-200">
<BookOpen className="h-5 w-5" />
{t('tools.pdfFlowchart.documentViewer')}
</h2>
</div>
</div>
{/* Procedure info card */}
<div className={`mb-5 rounded-xl p-4 ring-1 ${isHighPriority ? 'bg-red-50 ring-red-200 dark:bg-red-900/10 dark:ring-red-800' : 'bg-blue-50 ring-blue-200 dark:bg-blue-900/10 dark:ring-blue-800'}`}>
<div className="flex items-start gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${isHighPriority ? 'bg-red-100' : 'bg-blue-100'}`}>
{isHighPriority ? (
<AlertTriangle className="h-5 w-5 text-red-600" />
) : (
<FileText className="h-5 w-5 text-blue-600" />
)}
</div>
<div className="flex-1">
<h3 className={`font-semibold ${isHighPriority ? 'text-red-900 dark:text-red-300' : 'text-blue-900 dark:text-blue-300'}`}>
{procedure.title}
</h3>
<p className={`mt-1 text-sm ${isHighPriority ? 'text-red-800 dark:text-red-400' : 'text-blue-800 dark:text-blue-400'}`}>
{procedure.description}
</p>
<div className={`mt-2 flex gap-4 text-xs ${isHighPriority ? 'text-red-700' : 'text-blue-700'}`}>
<span>{t('tools.pdfFlowchart.pages')}: {procedure.pages.join(', ')}</span>
<span>{t('tools.pdfFlowchart.totalPagesLabel')}: {procedure.pages.length}</span>
<span>~{procedure.pages.length * 2} min</span>
</div>
</div>
</div>
</div>
{/* Pages content */}
<div className="space-y-4 max-h-[32rem] overflow-y-auto pr-1">
<h4 className="flex items-center gap-2 font-semibold text-slate-700 dark:text-slate-300">
<FileText className="h-4 w-4" />
{t('tools.pdfFlowchart.documentContent')} ({relevantPages.length} {t('tools.pdfFlowchart.pagesWord')})
</h4>
{relevantPages.length === 0 ? (
<p className="py-8 text-center text-slate-500">{t('tools.pdfFlowchart.noPageContent')}</p>
) : (
relevantPages.map((page) => (
<div
key={page.page}
className="rounded-xl border-l-4 border-l-primary-400 bg-slate-50 p-4 dark:bg-slate-700/50"
>
<div className="mb-2 flex items-center justify-between">
<h5 className="text-sm font-semibold text-slate-700 dark:text-slate-200">
{t('tools.pdfFlowchart.pageLabel')} {page.page}
{page.title ? `: ${page.title}` : ''}
</h5>
<span className="rounded-full bg-slate-200 px-2 py-0.5 text-xs text-slate-600 dark:bg-slate-600 dark:text-slate-300">
{t('tools.pdfFlowchart.pageLabel')} {page.page}
</span>
</div>
<pre className="whitespace-pre-wrap text-sm leading-relaxed text-slate-600 dark:text-slate-300 font-sans">
{page.text}
</pre>
</div>
))
)}
</div>
{/* AI Analysis summary */}
<div className="mt-5 rounded-xl bg-green-50 p-4 ring-1 ring-green-200 dark:bg-green-900/10 dark:ring-green-800">
<h4 className="mb-2 font-semibold text-green-900 dark:text-green-300">
{t('tools.pdfFlowchart.aiAnalysis')}
</h4>
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-3">
<div>
<p className="font-medium text-green-800 dark:text-green-400">{t('tools.pdfFlowchart.keyActions')}</p>
<p className="text-green-700 dark:text-green-500">
{procedure.step_count} {t('tools.pdfFlowchart.stepsIdentified')}
</p>
</div>
<div>
<p className="font-medium text-green-800 dark:text-green-400">{t('tools.pdfFlowchart.decisionPoints')}</p>
<p className="text-green-700 dark:text-green-500">
{Math.max(1, Math.floor(procedure.step_count / 3))} {t('tools.pdfFlowchart.estimated')}
</p>
</div>
<div>
<p className="font-medium text-green-800 dark:text-green-400">{t('tools.pdfFlowchart.flowComplexity')}</p>
<p className="text-green-700 dark:text-green-500">
{procedure.step_count <= 4
? t('tools.pdfFlowchart.complexity.simple')
: procedure.step_count <= 8
? t('tools.pdfFlowchart.complexity.medium')
: t('tools.pdfFlowchart.complexity.complex')}
</p>
</div>
</div>
</div>
{/* Back row */}
<div className="mt-5 flex justify-between items-center border-t border-slate-200 pt-4 dark:border-slate-700">
<button onClick={onClose} className="btn-secondary">
<ArrowLeft className="h-4 w-4" />
{t('tools.pdfFlowchart.backToProcedures')}
</button>
<p className="text-xs text-slate-400">
~{procedure.step_count <= 4 ? '6-8' : procedure.step_count <= 8 ? '8-12' : '12-16'} {t('tools.pdfFlowchart.flowStepsEstimate')}
</p>
</div>
</div>
);
}

View File

@@ -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: <Play className="h-4 w-4" /> };
case 'end':
return { bg: 'bg-red-100 border-red-400', text: 'text-red-800', icon: <Circle className="h-4 w-4" /> };
case 'process':
return { bg: 'bg-blue-100 border-blue-400', text: 'text-blue-800', icon: <Square className="h-4 w-4" /> };
case 'decision':
return { bg: 'bg-amber-100 border-amber-400', text: 'text-amber-800', icon: <Diamond className="h-4 w-4" /> };
default:
return { bg: 'bg-slate-100 border-slate-400', text: 'text-slate-800', icon: <Circle className="h-4 w-4" /> };
}
};
export default function FlowChartView({ flow, onBack, onOpenChat }: FlowChartProps) {
const { t } = useTranslation();
const chartRef = useRef<HTMLDivElement>(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 = `
<svg xmlns="http://www.w3.org/2000/svg" width="${el.scrollWidth}" height="${el.scrollHeight}">
<foreignObject width="100%" height="100%">
<div xmlns="http://www.w3.org/1999/xhtml">${el.outerHTML}</div>
</foreignObject>
</svg>`;
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<void>((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<string, { fill: string; stroke: string }> = {
start: { fill: '#dcfce7', stroke: '#4ade80' },
process: { fill: '#dbeafe', stroke: '#60a5fa' },
decision: { fill: '#fef3c7', stroke: '#fbbf24' },
end: { fill: '#fee2e2', stroke: '#f87171' },
};
let svgParts = `<svg xmlns="http://www.w3.org/2000/svg" width="${totalW}" height="${totalH}" font-family="system-ui,sans-serif">`;
svgParts += `<rect width="${totalW}" height="${totalH}" fill="#fff"/>`;
flow.steps.forEach((step, idx) => {
const x = padding;
const y = padding + idx * (nodeH + arrowH);
const colors = typeColors[step.type] || typeColors.process;
svgParts += `<rect x="${x}" y="${y}" width="${nodeW}" height="${nodeH}" rx="12" fill="${colors.fill}" stroke="${colors.stroke}" stroke-width="2"/>`;
svgParts += `<text x="${x + 12}" y="${y + 22}" font-size="11" font-weight="600" fill="#64748b">${step.type.toUpperCase()}</text>`;
svgParts += `<text x="${x + 12}" y="${y + 44}" font-size="14" font-weight="700" fill="#1e293b">${escapeXml(step.title.slice(0, 45))}</text>`;
if (step.description !== step.title) {
svgParts += `<text x="${x + 12}" y="${y + 64}" font-size="11" fill="#64748b">${escapeXml(step.description.slice(0, 60))}</text>`;
}
// Arrow
if (idx < flow.steps.length - 1) {
const ax = x + nodeW / 2;
const ay = y + nodeH;
svgParts += `<line x1="${ax}" y1="${ay + 4}" x2="${ax}" y2="${ay + arrowH - 4}" stroke="#cbd5e1" stroke-width="2"/>`;
svgParts += `<polygon points="${ax - 5},${ay + arrowH - 10} ${ax + 5},${ay + arrowH - 10} ${ax},${ay + arrowH - 2}" fill="#94a3b8"/>`;
}
});
svgParts += '</svg>';
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 (
<div className="mx-auto max-w-3xl">
{/* Top bar */}
<div className="mb-5 flex flex-wrap items-center justify-between gap-2">
<button
onClick={onBack}
className="inline-flex items-center gap-2 text-sm font-medium text-primary-600 hover:text-primary-700"
>
<ChevronLeft className="h-4 w-4" />
{t('tools.pdfFlowchart.backToList')}
</button>
<div className="flex gap-2">
<button onClick={onOpenChat} className="btn-secondary text-sm">
💬 {t('tools.pdfFlowchart.aiAssistant')}
</button>
<button onClick={exportPng} className="btn-secondary text-sm">
<ImageIcon className="h-4 w-4" />
PNG
</button>
<button onClick={exportSvg} className="btn-secondary text-sm">
<Download className="h-4 w-4" />
SVG
</button>
</div>
</div>
<h2 className="section-heading mb-6 text-center">{flow.title}</h2>
{/* The chart */}
<div
ref={chartRef}
className="rounded-2xl bg-white p-8 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700"
>
{/* SVG canvas for connection lines */}
<div className="relative">
<svg
className="pointer-events-none absolute inset-0 h-full w-full"
style={{ zIndex: 1 }}
>
<defs>
<marker
id="flowArrow"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
className="text-slate-400"
>
<polygon points="0 0, 10 3.5, 0 7" fill="currentColor" />
</marker>
</defs>
</svg>
<div className="relative flex flex-col items-center gap-0" style={{ zIndex: 2 }}>
{flow.steps.map((step, idx) => {
const style = getNodeStyle(step.type);
const isLast = idx === flow.steps.length - 1;
const hasMultipleConnections = step.connections.length > 1;
return (
<div key={step.id} className="flex w-full max-w-md flex-col items-center">
{/* Node */}
<div
className={`w-full rounded-xl border-2 p-4 ${style.bg} ${style.text} transition-shadow hover:shadow-md`}
>
<div className="mb-1 flex items-center gap-2 text-sm">
{style.icon}
<span className="rounded-full bg-white/60 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide">
{step.type}
</span>
</div>
<h4 className="font-bold text-base">{step.title}</h4>
{step.description !== step.title && (
<p className="mt-1 text-sm opacity-80">{step.description}</p>
)}
</div>
{/* Arrow / connector */}
{!isLast && (
<div className="flex flex-col items-center py-1 text-slate-400">
<div className="h-3 w-px bg-slate-300" />
{hasMultipleConnections ? (
<div className="flex items-center gap-6 text-xs text-slate-500">
<span>Yes </span>
<span>No </span>
</div>
) : (
<ArrowDown className="h-4 w-4" />
)}
<div className="h-2 w-px bg-slate-300" />
</div>
)}
</div>
);
})}
</div>
</div>
{/* Stats */}
<div className="mt-8 flex justify-center gap-8 border-t border-slate-200 pt-4 text-sm text-slate-500 dark:border-slate-700">
<div className="text-center">
<div className="font-bold text-slate-700 dark:text-slate-200">{stats.total}</div>
<div className="text-xs">{t('tools.pdfFlowchart.totalSteps')}</div>
</div>
<div className="text-center">
<div className="font-bold text-slate-700 dark:text-slate-200">{stats.decisions}</div>
<div className="text-xs">{t('tools.pdfFlowchart.decisionPoints')}</div>
</div>
<div className="text-center">
<div className="font-bold text-slate-700 dark:text-slate-200">{stats.processes}</div>
<div className="text-xs">{t('tools.pdfFlowchart.processSteps')}</div>
</div>
</div>
</div>
</div>
);
}
function escapeXml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

View File

@@ -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<ChatMessage[]>([
{
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<HTMLDivElement>(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 (
<div className="flex h-[28rem] flex-col rounded-2xl bg-white shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
{/* Header */}
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3 dark:border-slate-700">
<h3 className="flex items-center gap-2 text-sm font-bold text-slate-800 dark:text-slate-200">
<Sparkles className="h-4 w-4 text-indigo-500" />
{t('tools.pdfFlowchart.aiAssistant')}
</h3>
<button onClick={onClose} className="rounded-lg p-1 text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700">
<X className="h-4 w-4" />
</button>
</div>
{/* Messages */}
<div ref={scrollRef} className="flex-1 space-y-3 overflow-y-auto p-4">
{messages.map((msg) => (
<div
key={msg.id}
className={`flex gap-2 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}
>
<div
className={`flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full ${
msg.role === 'assistant'
? 'bg-indigo-100 text-indigo-600'
: 'bg-primary-100 text-primary-600'
}`}
>
{msg.role === 'assistant' ? <Bot className="h-3.5 w-3.5" /> : <User className="h-3.5 w-3.5" />}
</div>
<div
className={`max-w-[80%] rounded-xl px-3.5 py-2.5 text-sm leading-relaxed ${
msg.role === 'assistant'
? 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200'
: 'bg-primary-500 text-white'
}`}
>
<p className="whitespace-pre-wrap">{msg.content}</p>
<p className="mt-1 text-[10px] opacity-50">
{new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
</div>
))}
{isTyping && (
<div className="flex items-center gap-2 text-sm text-slate-400">
<Loader2 className="h-4 w-4 animate-spin" />
{t('tools.pdfFlowchart.chatTyping')}
</div>
)}
</div>
{/* Quick suggestions */}
{messages.length <= 2 && (
<div className="flex flex-wrap gap-1.5 border-t border-slate-200 px-4 py-2 dark:border-slate-700">
{suggestions.map((s, i) => (
<button
key={i}
onClick={() => setInput(s)}
className="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] text-slate-600 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300"
>
{s}
</button>
))}
</div>
)}
{/* Input */}
<div className="flex items-center gap-2 border-t border-slate-200 px-4 py-3 dark:border-slate-700">
<input
type="text"
value={input}
onChange={(e) => 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"
/>
<button
onClick={handleSend}
disabled={!input.trim() || isTyping}
className="btn-primary p-2"
>
<Send className="h-4 w-4" />
</button>
</div>
</div>
);
}

View File

@@ -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 (
<div className="rounded-2xl bg-white p-8 shadow-sm ring-1 ring-slate-200 text-center dark:bg-slate-800 dark:ring-slate-700">
{!done ? (
<>
<Loader2 className="mx-auto mb-4 h-12 w-12 animate-spin text-primary-500" />
<h2 className="text-xl font-bold text-slate-800 dark:text-slate-200">
{t('tools.pdfFlowchart.generating')}
</h2>
<p className="mt-2 text-slate-500 dark:text-slate-400">
{t('tools.pdfFlowchart.generatingDesc')}
</p>
{/* Progress bar */}
<div className="mx-auto mt-6 max-w-md">
<div className="h-2.5 w-full rounded-full bg-slate-200 dark:bg-slate-700">
<div
className="h-2.5 rounded-full bg-primary-500 transition-all duration-200"
style={{ width: `${progress}%` }}
/>
</div>
<p className="mt-2 text-xs text-slate-400">{Math.round(progress)}%</p>
</div>
<p className="mt-4 text-sm text-slate-500">
{t('tools.pdfFlowchart.generatingFor', { count: selectedCount })}
</p>
</>
) : (
<>
<CheckCircle2 className="mx-auto mb-4 h-12 w-12 text-green-500" />
<h2 className="text-xl font-bold text-slate-800 dark:text-slate-200">
{t('tools.pdfFlowchart.flowReady')}
</h2>
<p className="mt-2 text-slate-500 dark:text-slate-400">
{t('tools.pdfFlowchart.flowReadyCount', { count: flowcharts.length })}
</p>
<button onClick={onDone} className="btn-primary mt-6">
{t('tools.pdfFlowchart.viewResults')}
<ChevronRight className="h-4 w-4" />
</button>
</>
)}
</div>
);
}

View File

@@ -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<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (f?.type === 'application/pdf') onFileSelect(f);
};
return (
<div className="rounded-2xl bg-white p-8 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
{/* Try Sample banner */}
<div className="mb-6 flex items-center gap-3 rounded-xl bg-indigo-50 p-4 ring-1 ring-indigo-200 dark:bg-indigo-900/20 dark:ring-indigo-800">
<Zap className="h-5 w-5 flex-shrink-0 text-indigo-600 dark:text-indigo-400" />
<div className="flex-1">
<p className="text-sm font-medium text-indigo-900 dark:text-indigo-200">
{t('tools.pdfFlowchart.trySampleTitle')}
</p>
<p className="text-xs text-indigo-700 dark:text-indigo-400">
{t('tools.pdfFlowchart.trySampleDesc')}
</p>
</div>
<button onClick={onTrySample} className="btn-secondary text-xs whitespace-nowrap">
<FileText className="h-3.5 w-3.5" />
{t('tools.pdfFlowchart.trySample')}
</button>
</div>
{/* Drag & Drop zone */}
<label
htmlFor="flowchart-upload"
className={`upload-zone flex w-full cursor-pointer flex-col items-center rounded-xl border-2 border-dashed p-10 text-center transition-all ${
dragActive
? 'border-primary-400 bg-primary-50 dark:border-primary-500 dark:bg-primary-900/20'
: 'border-slate-300 hover:border-primary-300 dark:border-slate-600 dark:hover:border-primary-500'
}`}
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
>
<Upload
className={`mb-3 h-10 w-10 transition-colors ${
dragActive ? 'text-primary-500' : 'text-slate-400'
}`}
/>
<p className="text-lg font-semibold text-slate-800 dark:text-slate-200">
{t('tools.pdfFlowchart.uploadStep')}
</p>
<p className="mt-1 text-sm text-slate-500">
{t('tools.pdfFlowchart.dragDropHint')}
</p>
<input
id="flowchart-upload"
type="file"
accept=".pdf"
className="hidden"
onChange={handleInputChange}
/>
</label>
{/* Selected file */}
{file && (
<div className="mt-4 flex w-full items-center gap-3 rounded-xl bg-green-50 px-4 py-3 ring-1 ring-green-200 dark:bg-green-900/20 dark:ring-green-800">
<CheckCircle className="h-5 w-5 flex-shrink-0 text-green-600" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-green-800 dark:text-green-200 truncate">
{file.name}
</p>
<p className="text-xs text-green-600 dark:text-green-400">
{(file.size / 1024 / 1024).toFixed(1)} MB
</p>
</div>
<button
onClick={onClearFile}
className="rounded-lg p-1 text-green-600 hover:bg-green-100 dark:hover:bg-green-800/30"
>
<X className="h-4 w-4" />
</button>
</div>
)}
{/* Error */}
{error && (
<div className="mt-3 rounded-xl bg-red-50 p-3 ring-1 ring-red-200 dark:bg-red-900/20 dark:ring-red-800">
<p className="text-center text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
{/* Upload button */}
<button
onClick={onUpload}
disabled={!file || uploading}
className="btn-primary mt-6 w-full"
>
{uploading ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
{t('tools.pdfFlowchart.extracting')}
</>
) : (
<>
<Upload className="h-5 w-5" />
{t('tools.pdfFlowchart.generateFlows')}
</>
)}
</button>
</div>
);
}

View File

@@ -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 (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Left — form */}
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
<div className="mb-5 flex items-center gap-3">
<button onClick={onBack} className="btn-secondary text-xs">
<ArrowLeft className="h-4 w-4" />
{t('tools.pdfFlowchart.back')}
</button>
<div>
<h2 className="flex items-center gap-2 font-bold text-slate-800 dark:text-slate-200">
<Target className="h-5 w-5" />
{t('tools.pdfFlowchart.manualTitle')}
</h2>
<p className="text-xs text-slate-500">{t('tools.pdfFlowchart.manualDesc')}</p>
</div>
</div>
{/* Document info */}
<div className="mb-5 rounded-xl bg-indigo-50 p-3 ring-1 ring-indigo-200 dark:bg-indigo-900/20 dark:ring-indigo-800">
<p className="text-sm font-medium text-indigo-800 dark:text-indigo-300">
{t('tools.pdfFlowchart.totalPagesLabel')}: {maxPages}
</p>
</div>
{/* Page range */}
<div className="mb-5">
<h4 className="mb-2 text-sm font-semibold text-slate-700 dark:text-slate-200">
{t('tools.pdfFlowchart.selectPageRange')}
</h4>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs text-slate-500">{t('tools.pdfFlowchart.startPage')}</label>
<input
type="number"
min={1}
max={maxPages}
value={startPage}
onChange={(e) => 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"
/>
</div>
<div>
<label className="mb-1 block text-xs text-slate-500">{t('tools.pdfFlowchart.endPage')}</label>
<input
type="number"
min={1}
max={maxPages}
value={endPage}
onChange={(e) => 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"
/>
</div>
</div>
{!isValidRange && startPage > 0 && (
<p className="mt-1 flex items-center gap-1 text-xs text-red-500">
<AlertTriangle className="h-3 w-3" />
{t('tools.pdfFlowchart.invalidRange')}
</p>
)}
{isValidRange && selectedPages.length > 0 && (
<p className="mt-1 flex items-center gap-1 text-xs text-green-600">
<Check className="h-3 w-3" />
{selectedPages.length} {t('tools.pdfFlowchart.pagesSelected')}
</p>
)}
</div>
{/* Title */}
<div className="mb-4">
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-200">
{t('tools.pdfFlowchart.procTitle')}
</label>
<input
type="text"
value={title}
onChange={(e) => 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"
/>
</div>
{/* Description */}
<div className="mb-5">
<label className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-200">
{t('tools.pdfFlowchart.procDescription')}
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
placeholder={t('tools.pdfFlowchart.procDescPlaceholder')}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700"
/>
</div>
<button disabled={!canCreate} onClick={handleCreate} className="btn-primary w-full">
<Check className="h-4 w-4" />
{t('tools.pdfFlowchart.createProcedure')}
</button>
</div>
{/* Right — page preview */}
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
<h3 className="mb-3 text-sm font-semibold text-slate-700 dark:text-slate-200">
{t('tools.pdfFlowchart.pagePreview')}
</h3>
<div className="max-h-[32rem] space-y-3 overflow-y-auto pr-1">
{selectedPages.length === 0 ? (
<p className="py-10 text-center text-sm text-slate-400">
{t('tools.pdfFlowchart.selectPagesToPreview')}
</p>
) : (
selectedPages.map((pn) => {
const pageData = pages.find((p) => p.page === pn);
return (
<div key={pn} className="rounded-xl border-l-4 border-l-indigo-400 bg-slate-50 p-3 dark:bg-slate-700/50">
<p className="mb-1 text-xs font-semibold text-slate-600 dark:text-slate-300">
{t('tools.pdfFlowchart.pageLabel')} {pn}
</p>
<pre className="whitespace-pre-wrap text-xs leading-relaxed text-slate-500 dark:text-slate-400 font-sans">
{pageData?.text || t('tools.pdfFlowchart.noPageContent')}
</pre>
</div>
);
})
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,231 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
CheckCircle2,
AlertCircle,
FileText,
Clock,
Eye,
X,
RotateCcw,
Plus,
} from 'lucide-react';
import type { Procedure, PDFPage } from './types';
interface ProcedureSelectionProps {
procedures: Procedure[];
rejectedProcedures: Procedure[];
pages: PDFPage[];
onContinue: (selectedIds: string[]) => void;
onManualAdd: () => void;
onReject: (id: string) => void;
onRestore: (id: string) => void;
onViewProcedure: (proc: Procedure) => void;
onBack: () => void;
}
export default function ProcedureSelection({
procedures,
rejectedProcedures,
pages,
onContinue,
onManualAdd,
onReject,
onRestore,
onViewProcedure,
onBack,
}: ProcedureSelectionProps) {
const { t } = useTranslation();
const [selectedIds, setSelectedIds] = useState<string[]>(
procedures.map((p) => p.id),
);
const toggle = (id: string) =>
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
const getComplexity = (count: number) => {
if (count <= 4) return { label: t('tools.pdfFlowchart.complexity.simple'), color: 'bg-green-100 text-green-700' };
if (count <= 8) return { label: t('tools.pdfFlowchart.complexity.medium'), color: 'bg-yellow-100 text-yellow-700' };
return { label: t('tools.pdfFlowchart.complexity.complex'), color: 'bg-red-100 text-red-700' };
};
const getPriorityIcon = (title: string) => {
const lower = title.toLowerCase();
if (lower.includes('emergency') || lower.includes('safety') || lower.includes('طوارئ') || lower.includes('أمان'))
return <AlertCircle className="h-4 w-4 text-red-500" />;
return <FileText className="h-4 w-4 text-slate-400" />;
};
const totalFound = procedures.length + rejectedProcedures.length;
return (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
{/* Header */}
<div className="mb-4 flex items-center justify-between flex-wrap gap-2">
<div>
<h2 className="text-lg font-bold text-slate-800 dark:text-slate-200">
{t('tools.pdfFlowchart.selectProcedures')}
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
{t('tools.pdfFlowchart.selectProceduresDesc')}
</p>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-primary-100 px-3 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-400">
{t('tools.pdfFlowchart.proceduresFound', { count: totalFound })}
</span>
{rejectedProcedures.length > 0 && (
<span className="rounded-full bg-red-100 px-3 py-1 text-xs font-medium text-red-700">
{rejectedProcedures.length} {t('tools.pdfFlowchart.rejected')}
</span>
)}
</div>
</div>
{/* Controls */}
<div className="mb-4 flex flex-wrap gap-3">
<button
onClick={() => setSelectedIds(procedures.map((p) => p.id))}
className="text-sm font-medium text-primary-600 hover:underline"
>
{t('tools.pdfFlowchart.selectAll')}
</button>
<span className="text-slate-300">|</span>
<button
onClick={() => setSelectedIds([])}
className="text-sm font-medium text-slate-500 hover:underline"
>
{t('tools.pdfFlowchart.deselectAll')}
</button>
<span className="text-slate-300">|</span>
<button
onClick={onManualAdd}
className="inline-flex items-center gap-1 text-sm font-medium text-indigo-600 hover:underline"
>
<Plus className="h-3.5 w-3.5" />
{t('tools.pdfFlowchart.addManual')}
</button>
</div>
{/* Procedures list */}
{procedures.length === 0 ? (
<div className="py-10 text-center">
<p className="text-slate-500">{t('tools.pdfFlowchart.noProcedures')}</p>
</div>
) : (
<div className="max-h-[28rem] space-y-3 overflow-y-auto pr-1">
{procedures.map((proc) => {
const selected = selectedIds.includes(proc.id);
const complexity = getComplexity(proc.step_count);
return (
<div
key={proc.id}
className={`flex items-start gap-3 rounded-xl border-2 p-4 transition-all ${
selected
? 'border-primary-400 bg-primary-50 dark:border-primary-600 dark:bg-primary-900/20'
: 'border-slate-200 bg-white hover:border-slate-300 dark:border-slate-600 dark:bg-slate-800'
}`}
>
{/* Checkbox */}
<button
onClick={() => toggle(proc.id)}
className={`mt-0.5 flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md border-2 transition-colors ${
selected
? 'border-primary-500 bg-primary-500'
: 'border-slate-300 dark:border-slate-500'
}`}
>
{selected && <CheckCircle2 className="h-3.5 w-3.5 text-white" />}
</button>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{getPriorityIcon(proc.title)}
<h3 className="font-semibold text-slate-800 dark:text-slate-200 truncate">
{proc.title}
</h3>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${complexity.color}`}>
{complexity.label}
</span>
</div>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400 line-clamp-2">
{proc.description}
</p>
<div className="mt-2 flex items-center gap-4 text-xs text-slate-400">
<span className="flex items-center gap-1">
<FileText className="h-3 w-3" />
{t('tools.pdfFlowchart.pages')}: {proc.pages.join(', ')}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
~{proc.pages.length * 2} min
</span>
</div>
{/* Action buttons */}
<div className="mt-2 flex gap-2">
<button
onClick={() => onViewProcedure(proc)}
className="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-2.5 py-1 text-xs font-medium text-slate-600 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-700"
>
<Eye className="h-3 w-3" />
{t('tools.pdfFlowchart.viewSection')}
</button>
<button
onClick={() => onReject(proc.id)}
className="inline-flex items-center gap-1 rounded-lg border border-red-200 px-2.5 py-1 text-xs font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:hover:bg-red-900/20"
>
<X className="h-3 w-3" />
{t('tools.pdfFlowchart.reject')}
</button>
</div>
</div>
</div>
);
})}
</div>
)}
{/* Rejected procedures */}
{rejectedProcedures.length > 0 && (
<div className="mt-4 rounded-xl bg-red-50 p-4 ring-1 ring-red-200 dark:bg-red-900/10 dark:ring-red-800">
<h4 className="mb-2 text-sm font-semibold text-red-700 dark:text-red-400">
{t('tools.pdfFlowchart.rejectedTitle')}
</h4>
<div className="space-y-2">
{rejectedProcedures.map((proc) => (
<div key={proc.id} className="flex items-center justify-between text-sm">
<span className="text-red-600 dark:text-red-400 truncate">{proc.title}</span>
<button
onClick={() => onRestore(proc.id)}
className="inline-flex items-center gap-1 text-xs font-medium text-red-700 hover:underline"
>
<RotateCcw className="h-3 w-3" />
{t('tools.pdfFlowchart.restore')}
</button>
</div>
))}
</div>
</div>
)}
{/* Footer */}
<div className="mt-6 flex justify-between">
<button onClick={onBack} className="btn-secondary">
{t('tools.pdfFlowchart.back')}
</button>
<button
onClick={() => onContinue(selectedIds)}
disabled={selectedIds.length === 0}
className="btn-primary"
>
{t('tools.pdfFlowchart.generateFlows')}
<span className="text-xs opacity-80">({selectedIds.length})</span>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import { useTranslation } from 'react-i18next';
import { CheckCircle2 } from 'lucide-react';
import { WIZARD_STEPS, type WizardStep } from './types';
interface StepProgressProps {
currentStep: WizardStep;
className?: string;
}
export default function StepProgress({ currentStep, className }: StepProgressProps) {
const { t } = useTranslation();
return (
<div className={className}>
{/* Progress bar */}
<div className="relative mb-3">
<div className="h-1.5 w-full rounded-full bg-slate-200 dark:bg-slate-700">
<div
className="h-1.5 rounded-full bg-primary-500 transition-all duration-500"
style={{ width: `${((currentStep + 1) / WIZARD_STEPS.length) * 100}%` }}
/>
</div>
</div>
{/* Step labels */}
<div className="grid grid-cols-4 gap-2">
{WIZARD_STEPS.map((step, idx) => {
const done = idx < currentStep;
const active = idx === currentStep;
return (
<div key={step.key} className="flex flex-col items-center text-center">
<div
className={`mb-1 flex h-7 w-7 items-center justify-center rounded-full text-xs font-bold transition-colors ${
done
? 'bg-primary-500 text-white'
: active
? 'bg-primary-100 text-primary-700 ring-2 ring-primary-400 dark:bg-primary-900/40 dark:text-primary-300'
: 'bg-slate-200 text-slate-500 dark:bg-slate-700 dark:text-slate-400'
}`}
>
{done ? <CheckCircle2 className="h-4 w-4" /> : idx + 1}
</div>
<span
className={`text-[11px] leading-tight ${
active ? 'font-semibold text-primary-700 dark:text-primary-300' : 'text-slate-500 dark:text-slate-400'
}`}
>
{t(step.labelKey)}
</span>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
// Shared types for the PDF Flowchart tool
// -----------------------------------------------------------
export interface PDFPage {
page: number;
text: string;
title?: string;
}
export interface Procedure {
id: string;
title: string;
description: string;
pages: number[];
step_count: number;
}
export interface FlowStep {
id: string;
type: 'start' | 'process' | 'decision' | 'end';
title: string;
description: string;
connections: string[];
}
export interface Flowchart {
id: string;
procedureId: string;
title: string;
steps: FlowStep[];
}
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: string;
}
/** Wizard step index (0-based) */
export type WizardStep = 0 | 1 | 2 | 3;
export const WIZARD_STEPS = [
{ key: 'upload', labelKey: 'tools.pdfFlowchart.wizard.upload' },
{ key: 'select', labelKey: 'tools.pdfFlowchart.wizard.select' },
{ key: 'create', labelKey: 'tools.pdfFlowchart.wizard.create' },
{ key: 'results', labelKey: 'tools.pdfFlowchart.wizard.results' },
] as const;

View File

@@ -0,0 +1,224 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useFileUpload } from './useFileUpload';
// ── mock the api module ────────────────────────────────────────────────────
vi.mock('@/services/api', () => ({
uploadFile: vi.fn(),
}));
import { uploadFile } from '@/services/api';
const mockUpload = vi.mocked(uploadFile);
// ── helpers ────────────────────────────────────────────────────────────────
function makeFile(name: string, sizeBytes: number, type = 'application/pdf'): File {
const buf = new Uint8Array(sizeBytes);
return new File([buf], name, { type });
}
// ── tests ──────────────────────────────────────────────────────────────────
describe('useFileUpload', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ── initial state ──────────────────────────────────────────────────────
it('starts with null file and no error', () => {
const { result } = renderHook(() =>
useFileUpload({ endpoint: '/compress/pdf' })
);
expect(result.current.file).toBeNull();
expect(result.current.error).toBeNull();
expect(result.current.isUploading).toBe(false);
expect(result.current.taskId).toBeNull();
expect(result.current.uploadProgress).toBe(0);
});
// ── selectFile — type validation ───────────────────────────────────────
it('selectFile: accepts a file when no type restriction set', () => {
const { result } = renderHook(() =>
useFileUpload({ endpoint: '/compress/pdf' })
);
const pdf = makeFile('doc.pdf', 100);
act(() => {
result.current.selectFile(pdf);
});
expect(result.current.file).toBe(pdf);
expect(result.current.error).toBeNull();
});
it('selectFile: rejects wrong extension when acceptedTypes given', () => {
const { result } = renderHook(() =>
useFileUpload({ endpoint: '/compress/pdf', acceptedTypes: ['pdf'] })
);
act(() => {
result.current.selectFile(makeFile('photo.jpg', 100));
});
expect(result.current.file).toBeNull();
expect(result.current.error).toMatch(/invalid file type/i);
});
it('selectFile: accepts correct extension', () => {
const { result } = renderHook(() =>
useFileUpload({ endpoint: '/compress/pdf', acceptedTypes: ['pdf', 'docx'] })
);
const docx = makeFile('report.docx', 200);
act(() => {
result.current.selectFile(docx);
});
expect(result.current.file).toBe(docx);
expect(result.current.error).toBeNull();
});
// ── selectFile — size validation ───────────────────────────────────────
it('selectFile: rejects file exceeding maxSizeMB', () => {
const { result } = renderHook(() =>
useFileUpload({ endpoint: '/compress/pdf', maxSizeMB: 1 })
);
// 1.1 MB > 1 MB limit
act(() => {
result.current.selectFile(makeFile('big.pdf', 1.1 * 1024 * 1024));
});
expect(result.current.file).toBeNull();
expect(result.current.error).toMatch(/too large/i);
});
it('selectFile: accepts file exactly at the size limit', () => {
const { result } = renderHook(() =>
useFileUpload({ endpoint: '/compress/pdf', maxSizeMB: 5 })
);
act(() => {
result.current.selectFile(makeFile('ok.pdf', 5 * 1024 * 1024));
});
expect(result.current.file).not.toBeNull();
expect(result.current.error).toBeNull();
});
// ── selectFile: clears previous error / taskId on next pick ───────────
it('selectFile: clears previous error when new valid file selected', () => {
const { result } = renderHook(() =>
useFileUpload({ endpoint: '/compress/pdf', acceptedTypes: ['pdf'] })
);
// First pick — wrong type → sets error
act(() => {
result.current.selectFile(makeFile('bad.exe', 10));
});
expect(result.current.error).not.toBeNull();
// Second pick — valid → error must clear
act(() => {
result.current.selectFile(makeFile('good.pdf', 10));
});
expect(result.current.error).toBeNull();
});
// ── startUpload — no file selected ────────────────────────────────────
it('startUpload: returns null and sets error when no file selected', async () => {
const { result } = renderHook(() =>
useFileUpload({ endpoint: '/compress/pdf' })
);
let returnValue: string | null = 'initial';
await act(async () => {
returnValue = await result.current.startUpload();
});
expect(returnValue).toBeNull();
expect(result.current.error).toMatch(/no file/i);
});
// ── startUpload — success ──────────────────────────────────────────────
it('startUpload: sets taskId on success', async () => {
mockUpload.mockResolvedValueOnce({
task_id: 'abc-123',
message: 'started',
});
const { result } = renderHook(() =>
useFileUpload({ endpoint: '/compress/pdf', extraData: { quality: 'medium' } })
);
act(() => {
result.current.selectFile(makeFile('doc.pdf', 500));
});
let taskId: string | null = null;
await act(async () => {
taskId = await result.current.startUpload();
});
expect(taskId).toBe('abc-123');
expect(result.current.taskId).toBe('abc-123');
expect(result.current.isUploading).toBe(false);
expect(result.current.error).toBeNull();
expect(mockUpload).toHaveBeenCalledWith(
'/compress/pdf',
expect.any(File),
{ quality: 'medium' },
expect.any(Function),
);
});
// ── startUpload — API error ────────────────────────────────────────────
it('startUpload: sets error message when API rejects', async () => {
mockUpload.mockRejectedValueOnce(new Error('File too large.'));
const { result } = renderHook(() =>
useFileUpload({ endpoint: '/compress/pdf' })
);
act(() => {
result.current.selectFile(makeFile('doc.pdf', 500));
});
await act(async () => {
await result.current.startUpload();
});
expect(result.current.error).toBe('File too large.');
expect(result.current.isUploading).toBe(false);
expect(result.current.taskId).toBeNull();
});
// ── reset ──────────────────────────────────────────────────────────────
it('reset: clears all state', async () => {
mockUpload.mockResolvedValueOnce({ task_id: 'xyz', message: 'ok' });
const { result } = renderHook(() =>
useFileUpload({ endpoint: '/compress/pdf' })
);
act(() => {
result.current.selectFile(makeFile('doc.pdf', 500));
});
await act(async () => {
await result.current.startUpload();
});
expect(result.current.taskId).toBe('xyz');
act(() => {
result.current.reset();
});
expect(result.current.file).toBeNull();
expect(result.current.taskId).toBeNull();
expect(result.current.error).toBeNull();
expect(result.current.uploadProgress).toBe(0);
expect(result.current.isUploading).toBe(false);
});
// ── progress callback ──────────────────────────────────────────────────
it('startUpload: progress callback updates uploadProgress', async () => {
mockUpload.mockImplementationOnce(async (_ep, _file, _extra, onProgress) => {
onProgress?.(50);
onProgress?.(100);
return { task_id: 'prog-task', message: 'done' };
});
const { result } = renderHook(() =>
useFileUpload({ endpoint: '/compress/pdf' })
);
act(() => {
result.current.selectFile(makeFile('doc.pdf', 500));
});
await act(async () => {
await result.current.startUpload();
});
// After completion the task id should be set
expect(result.current.taskId).toBe('prog-task');
});
});

View File

@@ -0,0 +1,230 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useTaskPolling } from './useTaskPolling';
// ── mock the api module ────────────────────────────────────────────────────
vi.mock('@/services/api', () => ({
getTaskStatus: vi.fn(),
}));
import { getTaskStatus } from '@/services/api';
const mockGetStatus = vi.mocked(getTaskStatus);
// ── tests ──────────────────────────────────────────────────────────────────
describe('useTaskPolling', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
// ── initial state ──────────────────────────────────────────────────────
it('starts idle when taskId is null', () => {
const { result } = renderHook(() =>
useTaskPolling({ taskId: null })
);
expect(result.current.isPolling).toBe(false);
expect(result.current.status).toBeNull();
expect(result.current.result).toBeNull();
expect(result.current.error).toBeNull();
});
// ── begins polling immediately ─────────────────────────────────────────
it('polls immediately when taskId is provided', async () => {
mockGetStatus.mockResolvedValue({
task_id: 'task-1',
state: 'PENDING',
});
const { result } = renderHook(() =>
useTaskPolling({ taskId: 'task-1', intervalMs: 1000 })
);
// Advance just enough to let the immediate poll() Promise resolve,
// but NOT enough to trigger the setInterval tick (< 1000ms).
await act(async () => {
await vi.advanceTimersByTimeAsync(50);
});
expect(mockGetStatus).toHaveBeenCalledWith('task-1');
expect(result.current.isPolling).toBe(true);
expect(result.current.status?.state).toBe('PENDING');
});
// ── polls at the configured interval ─────────────────────────────────
it('polls again after the interval elapses', async () => {
mockGetStatus.mockResolvedValue({ task_id: 'task-2', state: 'PROCESSING' });
renderHook(() =>
useTaskPolling({ taskId: 'task-2', intervalMs: 1500 })
);
await act(async () => {
await vi.advanceTimersByTimeAsync(1500 * 3 + 100); // 3 intervals
});
// At least 3 calls (initial + 3 interval ticks)
expect(mockGetStatus.mock.calls.length).toBeGreaterThanOrEqual(3);
});
// ── SUCCESS state ──────────────────────────────────────────────────────
it('stops polling and sets result on SUCCESS with completed status', async () => {
const taskResult = {
status: 'completed' as const,
download_url: '/api/download/task-3/output.pdf',
filename: 'output.pdf',
};
mockGetStatus.mockResolvedValueOnce({ task_id: 'task-3', state: 'PENDING' });
mockGetStatus.mockResolvedValueOnce({
task_id: 'task-3',
state: 'SUCCESS',
result: taskResult,
});
const onComplete = vi.fn();
const { result } = renderHook(() =>
useTaskPolling({ taskId: 'task-3', intervalMs: 500, onComplete })
);
await act(async () => {
await vi.advanceTimersByTimeAsync(1200);
});
expect(result.current.isPolling).toBe(false);
expect(result.current.result).toEqual(taskResult);
expect(result.current.error).toBeNull();
expect(onComplete).toHaveBeenCalledWith(taskResult);
});
// ── SUCCESS with error in result ───────────────────────────────────────
it('sets error when SUCCESS result contains status "failed"', async () => {
mockGetStatus.mockResolvedValueOnce({
task_id: 'task-4',
state: 'SUCCESS',
result: { status: 'failed', error: 'Ghostscript not found.' },
});
const onError = vi.fn();
const { result } = renderHook(() =>
useTaskPolling({ taskId: 'task-4', intervalMs: 500, onError })
);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.isPolling).toBe(false);
expect(result.current.result).toBeNull();
expect(result.current.error).toBe('Ghostscript not found.');
expect(onError).toHaveBeenCalledWith('Ghostscript not found.');
});
// ── FAILURE state ──────────────────────────────────────────────────────
it('stops polling and sets error on FAILURE state', async () => {
mockGetStatus.mockResolvedValueOnce({
task_id: 'task-5',
state: 'FAILURE',
error: 'Worker crashed.',
});
const onError = vi.fn();
const { result } = renderHook(() =>
useTaskPolling({ taskId: 'task-5', intervalMs: 500, onError })
);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.isPolling).toBe(false);
expect(result.current.error).toBe('Worker crashed.');
expect(onError).toHaveBeenCalledWith('Worker crashed.');
});
// ── network error ──────────────────────────────────────────────────────
it('stops polling and sets error on network/API exception', async () => {
mockGetStatus.mockRejectedValueOnce(new Error('Network error.'));
const onError = vi.fn();
const { result } = renderHook(() =>
useTaskPolling({ taskId: 'task-6', intervalMs: 500, onError })
);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.isPolling).toBe(false);
expect(result.current.error).toBe('Network error.');
expect(onError).toHaveBeenCalledWith('Network error.');
});
// ── manual stopPolling ─────────────────────────────────────────────────
it('stopPolling immediately halts further requests', async () => {
mockGetStatus.mockResolvedValue({ task_id: 'task-7', state: 'PROCESSING' });
const { result } = renderHook(() =>
useTaskPolling({ taskId: 'task-7', intervalMs: 500 })
);
// Let one poll happen
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
act(() => {
result.current.stopPolling();
});
const callsAfterStop = mockGetStatus.mock.calls.length;
// Advance time — no new calls should happen
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(result.current.isPolling).toBe(false);
expect(mockGetStatus.mock.calls.length).toBe(callsAfterStop);
});
// ── taskId changes ─────────────────────────────────────────────────────
it('resets state and restarts polling when taskId changes', async () => {
mockGetStatus.mockResolvedValue({ task_id: 'task-new', state: 'PENDING' });
const { result, rerender } = renderHook(
({ taskId }: { taskId: string | null }) =>
useTaskPolling({ taskId, intervalMs: 500 }),
{ initialProps: { taskId: null as string | null } }
);
expect(result.current.isPolling).toBe(false);
// Provide a task id
rerender({ taskId: 'task-new' });
await act(async () => {
await vi.advanceTimersByTimeAsync(200);
});
expect(result.current.isPolling).toBe(true);
expect(mockGetStatus).toHaveBeenCalledWith('task-new');
});
// ── FAILURE with missing error message ────────────────────────────────
it('falls back to default error message when FAILURE has no error field', async () => {
mockGetStatus.mockResolvedValueOnce({ task_id: 'task-8', state: 'FAILURE' });
const { result } = renderHook(() =>
useTaskPolling({ taskId: 'task-8', intervalMs: 500 })
);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.error).toBe('Task failed.');
});
});

View File

@@ -22,16 +22,20 @@
"lightMode": "الوضع الفاتح" "lightMode": "الوضع الفاتح"
}, },
"home": { "home": {
"hero": "حوّل ملفاتك فوراً", "hero": "كل ما تحتاجه للتعامل مع ملفات PDF — فوراً وبخطوات بسيطة",
"heroSub": "أدوات مجانية لمعالجة ملفات PDF والصور والفيديو والنصوص. بدون تسجيل.", "heroSub": "ارفع ملفك أو اسحبه هنا، وسنكتشف نوعه تلقائيًا ونقترح الأدوات الملائمة — التحرير، التحويل، الضغط وغير ذلك. لا حاجة لتسجيل حساب لبدء الاستخدام.",
"popularTools": "الأدوات الشائعة", "popularTools": "الأدوات الشائعة",
"pdfTools": "أدوات PDF", "pdfTools": "أدوات PDF",
"imageTools": "أدوات الصور", "imageTools": "أدوات الصور",
"videoTools": "أدوات الفيديو", "videoTools": "أدوات الفيديو",
"textTools": "أدوات النصوص", "textTools": "أدوات النصوص",
"uploadCta": "ارفع ملفك", "uploadCta": "اسحب ملفك هنا أو اضغط لاختياره",
"uploadOr": "أو اسحب وأفلت ملفك هنا", "uploadOr": "ندعم: PDF, Word, JPG, PNG, WebP, MP4 — الحد الأقصى للحجم: 200 ميجابايت.",
"uploadSubtitle": كتشف نوع ملفك تلقائياً ونعرض الأدوات المناسبة", "uploadSubtitle": ستخرج معاينة سريعة ونعرض الأدوات المناسبة فوراً.",
"editNow": "عدّل ملفك الآن",
"editNowTooltip": "افتح محرّر الملفات — حرّر النصوص، أضف تعليقات، وغيّر الصفحات",
"suggestedTools": "الأدوات المقترحة لملفك",
"suggestedToolsDesc": "بعد رفع الملف سنعرض الأدوات المتوافقة تلقائيًا: تحرير نص، تمييز، دمج/تقسيم، ضغط، تحويل إلى Word/صورة، تحويل فيديو إلى GIF، والمزيد.",
"selectTool": "اختر أداة", "selectTool": "اختر أداة",
"fileDetected": "اكتشفنا ملف {{type}}", "fileDetected": "اكتشفنا ملف {{type}}",
"unsupportedFile": "نوع الملف غير مدعوم. جرب PDF أو Word أو صور أو فيديو.", "unsupportedFile": "نوع الملف غير مدعوم. جرب PDF أو Word أو صور أو فيديو.",
@@ -39,7 +43,14 @@
"image": "صورة", "image": "صورة",
"video": "فيديو", "video": "فيديو",
"unknown": "غير معروف" "unknown": "غير معروف"
} },
"featuresTitle": "طريقة أذكى للتحويل والتعديل عبر الإنترنت",
"feature1Title": "مساحة عمل متكاملة",
"feature1Desc": "قم بالتعديل، التحويل، الضغط، الدمج، والتقسيم بدون تغيير النوافذ.",
"feature2Title": "دقة يمكنك الوثوق بها",
"feature2Desc": "احصل على ملفات دقيقة وقابلة للتعديل في ثوانٍ بدون فقدان للجودة.",
"feature3Title": "أمان مدمج",
"feature3Desc": "قم بالوصول إلى ملفاتك بأمان، محمية بتشفير تلقائي."
}, },
"tools": { "tools": {
"pdfToWord": { "pdfToWord": {
@@ -110,9 +121,18 @@
"description": "قسّم ملف PDF إلى صفحات فردية أو استخرج نطاقات صفحات محددة.", "description": "قسّم ملف PDF إلى صفحات فردية أو استخرج نطاقات صفحات محددة.",
"shortDesc": "تقسيم PDF", "shortDesc": "تقسيم PDF",
"allPages": "كل الصفحات", "allPages": "كل الصفحات",
"allPagesDesc": "استخراج كل صفحة في ملف PDF مستقل",
"selectPages": "تحديد صفحات",
"selectPagesDesc": "استخراج صفحات أو نطاقات محددة فقط",
"pageRange": "نطاق الصفحات", "pageRange": "نطاق الصفحات",
"rangeHint": "مثال: 1,3,5-8", "rangeHint": "مثال: 1,3,5-8",
"rangePlaceholder": "أدخل الصفحات: 1,3,5-8" "rangePlaceholder": "أدخل الصفحات: 1,3,5-8",
"errors": {
"requiredPages": "من فضلك أدخل أرقام الصفحات أو النطاقات (مثال: 1,3,5-8).",
"outOfRange": "الصفحات المحددة ({{selected}}) خارج النطاق. هذا الملف يحتوي فقط على {{total}} صفحة.",
"invalidFormat": "تنسيق الصفحات غير صحيح: {{tokens}}. استخدم صيغة مثل 1,3,5-8.",
"noPagesSelected": "لم يتم تحديد أي صفحات. هذا الملف يحتوي على {{total}} صفحة."
}
}, },
"rotatePdf": { "rotatePdf": {
"title": "تدوير PDF", "title": "تدوير PDF",
@@ -181,6 +201,135 @@
"topCenter": "أعلى الوسط", "topCenter": "أعلى الوسط",
"topRight": "أعلى اليمين", "topRight": "أعلى اليمين",
"topLeft": "أعلى اليسار" "topLeft": "أعلى اليسار"
},
"pdfEditor": {
"title": "محرّر PDF متقدّم",
"description": "حرِّر نصوص PDF، أضف تعليقات، أعد ترتيب الصفحات وسجّل نسخة نهائية. سريع وبسيط ومباشر في المتصفح.",
"shortDesc": "تعديل PDF",
"intro": "مرحبا! هنا يمكنك تعديل ملف PDF مباشرةً في المتصفح: إضافة نص، تعليق، تمييز، رسم حر، حذف/إضافة صفحات، وتصدير نسخة جديدة دون المساس بالأصل.",
"steps": {
"step1": "أضف عناصر (نص، تمييز، رسم، ملاحظة) باستخدام شريط الأدوات أعلى الصفحة.",
"step2": "اضغط حفظ لحفظ نسخة جديدة من الملف (سيُنشأ إصدار جديد ولا يُستبدل الملف الأصلي).",
"step3": "اضغط تنزيل لتحميل النسخة النهائية أو اختر مشاركة لنسخ رابط التحميل."
},
"save": "حفظ التعديلات",
"saveTooltip": "حفظ نسخة جديدة من الملف",
"downloadFile": "تحميل الملف",
"downloadTooltip": "تنزيل PDF النهائي",
"undo": "تراجع",
"redo": "إعادة",
"addPage": "أضف صفحة",
"deletePage": "حذف الصفحة",
"rotate": "تدوير",
"extractPage": "استخراج كملف جديد",
"thumbnails": "عرض الصفحات",
"share": "مشاركة",
"versionNote": "نحفظ نسخة جديدة في كل مرة تحفظ فيها التعديلات — لا نغيّر الملف الأصلي. يمكنك الرجوع إلى الإصدارات السابقة من صفحة الملف. يتم حذف الملفات المؤقتة تلقائيًا بعد 30 دقيقة إن لم تكمل العملية.",
"privacyNote": "ملفاتك محمية — نقوم بفحص الملفات أمنياً قبل المعالجة، ونستخدم اتصالاً مشفّراً (HTTPS). راجع سياسة الخصوصية للحصول على المزيد من التفاصيل.",
"preparingPreview": "جاري تجهيز المعاينة…",
"preparingPreviewSub": "قد يستغرق الأمر بضع ثوانٍ حسب حجم الملف.",
"applyingChanges": "جاري تطبيق التعديلات…",
"applyingChangesSub": "لا تغلق النافذة — سيُنشأ ملف جديد عند الانتهاء.",
"savedSuccess": "تم حفظ التعديلات بنجاح — يمكنك الآن تنزيل الملف.",
"processingFailed": "فشل في معالجة الملف. جرّب إعادة التحميل أو حاول لاحقًا.",
"retry": "إعادة المحاولة",
"fileTooLarge": "حجم الملف أكبر من المسموح (200MB). قلِّل حجم الملف وحاول مرة أخرى."
},
"pdfFlowchart": {
"title": "PDF إلى مخطط انسيابي",
"description": "استخرج الإجراءات من مستندات PDF وحوّلها إلى مخططات انسيابية تفاعلية تلقائيًا.",
"shortDesc": "PDF → مخطط انسيابي",
"uploadStep": "ارفع ملف PDF",
"uploadDesc": "ارفع مستند PDF لاستخراج الإجراءات",
"dragDropHint": "أو اسحب وأفلت ملف PDF هنا",
"trySampleTitle": "ليس لديك ملف PDF؟",
"trySampleDesc": "جرّب المستند النموذجي لمشاهدة الأداة",
"trySample": "جرّب نموذج",
"extracting": "جاري تحليل المستند...",
"extractingDesc": "نحن نفحص ملف PDF ونحدد الإجراءات",
"proceduresFound": "تم العثور على {{count}} إجراء",
"noProcedures": "لم يتم اكتشاف أي إجراءات في هذا المستند. جرّب ملف PDF آخر.",
"selectProcedures": "اختر الإجراءات",
"selectProceduresDesc": "اختر الإجراءات التي تريد تحويلها إلى مخططات انسيابية",
"selectAll": "تحديد الكل",
"deselectAll": "إلغاء تحديد الكل",
"addManual": "إضافة يدوية",
"pages": "الصفحات",
"generateFlows": "إنشاء المخططات",
"generating": "جاري إنشاء المخططات...",
"generatingDesc": "نقوم بإنشاء المخططات الانسيابية من الإجراءات المختارة",
"generatingFor": "جاري إنشاء المخططات لـ {{count}} إجراء...",
"flowReady": "المخططات جاهزة!",
"flowReadyDesc": "تم إنشاء المخططات الانسيابية بنجاح",
"flowReadyCount": "تم إنشاء {{count}} مخطط(ات) بنجاح",
"steps": "{{count}} خطوة",
"viewFlow": "عرض المخطط",
"viewResults": "عرض النتائج",
"exportPng": "تصدير كـ PNG",
"exportSvg": "تصدير كـ SVG",
"exportPdf": "تصدير كـ PDF",
"startNode": "بداية",
"endNode": "نهاية",
"processNode": "عملية",
"decisionNode": "قرار",
"backToList": "العودة للقائمة",
"back": "رجوع",
"reject": "رفض",
"restore": "استعادة",
"viewSection": "عرض قسم المستند",
"rejectedTitle": "الإجراءات المرفوضة",
"rejectedCount": "{{count}} مرفوض",
"estimatedTime": "~{{time}} د",
"complexity": {
"simple": "بسيط",
"medium": "متوسط",
"complex": "معقد"
},
"wizard": {
"upload": "رفع",
"select": "اختيار",
"create": "إنشاء",
"results": "النتائج"
},
"manualTitle": "إضافة إجراء يدوي",
"manualDesc": "حدد نطاق الصفحات وأنشئ إجراءً مخصصًا",
"procTitleLabel": "عنوان الإجراء",
"procTitlePlaceholder": "أدخل عنوان الإجراء...",
"procDescriptionLabel": "الوصف",
"procDescriptionPlaceholder": "صف الإجراء...",
"selectPageRange": "حدد نطاق الصفحات",
"startPage": "صفحة البداية",
"endPage": "صفحة النهاية",
"invalidRange": "نطاق صفحات غير صالح",
"pagesSelected": "{{count}} صفحة محددة",
"createProcedure": "إنشاء الإجراء",
"pagePreview": "معاينة الصفحة",
"selectPagesToPreview": "حدد صفحات لمعاينة المحتوى",
"pageLabel": "صفحة {{num}}",
"noPageContent": "لا يوجد محتوى متاح لهذه الصفحة",
"documentViewer": "عارض المستند",
"backToProcedures": "العودة إلى الإجراءات",
"totalPagesLabel": "إجمالي الصفحات",
"documentContent": "محتوى المستند",
"pagesWord": "صفحات",
"aiAnalysis": "تحليل الذكاء الاصطناعي",
"keyActions": "الإجراءات الرئيسية",
"stepsIdentified": "تم تحديد {{count}} خطوة",
"decisionPoints": "نقاط القرار",
"flowComplexity": "تعقيد التدفق",
"flowStepsEstimate": "~{{count}} خطوة تقديرية",
"totalSteps": "إجمالي الخطوات",
"processSteps": "خطوات العمليات",
"aiAssistant": "المساعد الذكي",
"chatWelcome": "مرحبًا! يمكنني مساعدتك في تحسين المخطط \"{{title}}\". اسألني عن هيكل التدفق أو اقترح تحسينات أو اطلب تبسيطات.",
"chatPlaceholder": "اسأل عن هذا المخطط...",
"chatTyping": "الذكاء الاصطناعي يفكر...",
"chatError": "حدث خطأ. يرجى المحاولة مرة أخرى.",
"chatSuggestion1": "كيف أبسط هذا المخطط؟",
"chatSuggestion2": "هل هناك خطوات ناقصة؟",
"chatSuggestion3": "اقترح عناوين أفضل",
"chatSuggestion4": "أضف معالجة الأخطاء",
"sendMessage": "إرسال"
} }
}, },
"result": { "result": {

View File

@@ -22,16 +22,20 @@
"lightMode": "Light Mode" "lightMode": "Light Mode"
}, },
"home": { "home": {
"hero": "Transform Your Files Instantly", "hero": "Everything You Need to Work with PDF Files Instantly",
"heroSub": "Free online tools for PDF, image, video, and text processing. No registration required.", "heroSub": "Upload or drag & drop your file, and we'll auto-detect its type and suggest the right tools — edit, convert, compress, and more. No registration required.",
"popularTools": "Popular Tools", "popularTools": "Popular Tools",
"pdfTools": "PDF Tools", "pdfTools": "PDF Tools",
"imageTools": "Image Tools", "imageTools": "Image Tools",
"videoTools": "Video Tools", "videoTools": "Video Tools",
"textTools": "Text Tools", "textTools": "Text Tools",
"uploadCta": "Upload Your File", "uploadCta": "Drag your file here or click to browse",
"uploadOr": "or drag & drop your file here", "uploadOr": "Supported: PDF, Word, JPG, PNG, WebP, MP4 — Max size: 200 MB.",
"uploadSubtitle": "We auto-detect your file type and show matching tools", "uploadSubtitle": "We generate a quick preview and instantly show matching tools.",
"editNow": "Edit Your File Now",
"editNowTooltip": "Open the file editor — edit text, add comments, and modify pages",
"suggestedTools": "Suggested Tools for Your File",
"suggestedToolsDesc": "After uploading, we automatically show compatible tools: text editing, highlighting, merge/split, compress, convert to Word/image, video to GIF, and more.",
"selectTool": "Choose a Tool", "selectTool": "Choose a Tool",
"fileDetected": "We detected a {{type}} file", "fileDetected": "We detected a {{type}} file",
"unsupportedFile": "This file type is not supported. Try PDF, Word, images, or video.", "unsupportedFile": "This file type is not supported. Try PDF, Word, images, or video.",
@@ -39,7 +43,14 @@
"image": "Image", "image": "Image",
"video": "Video", "video": "Video",
"unknown": "Unknown" "unknown": "Unknown"
} },
"featuresTitle": "A smarter way to convert and edit online",
"feature1Title": "One complete workspace",
"feature1Desc": "Edit, convert, compress, merge, split without switching tabs.",
"feature2Title": "Accuracy you can trust",
"feature2Desc": "Get pixel-perfect, editable files in seconds with zero quality loss.",
"feature3Title": "Built-in security",
"feature3Desc": "Access files securely, protected by automatic encryption."
}, },
"tools": { "tools": {
"pdfToWord": { "pdfToWord": {
@@ -110,9 +121,18 @@
"description": "Split a PDF into individual pages or extract specific page ranges.", "description": "Split a PDF into individual pages or extract specific page ranges.",
"shortDesc": "Split PDF", "shortDesc": "Split PDF",
"allPages": "All Pages", "allPages": "All Pages",
"allPagesDesc": "Extract every page as a separate PDF file",
"selectPages": "Select Pages",
"selectPagesDesc": "Extract only specific pages or ranges",
"pageRange": "Page Range", "pageRange": "Page Range",
"rangeHint": "e.g. 1,3,5-8", "rangeHint": "e.g. 1,3,5-8",
"rangePlaceholder": "Enter pages: 1,3,5-8" "rangePlaceholder": "Enter pages: 1,3,5-8",
"errors": {
"requiredPages": "Please enter page numbers or ranges (e.g. 1,3,5-8).",
"outOfRange": "Selected pages ({{selected}}) are out of range. This PDF has only {{total}} page(s).",
"invalidFormat": "Invalid page format: {{tokens}}. Use a format like 1,3,5-8.",
"noPagesSelected": "No pages selected. This PDF has {{total}} page(s)."
}
}, },
"rotatePdf": { "rotatePdf": {
"title": "Rotate PDF", "title": "Rotate PDF",
@@ -181,6 +201,135 @@
"topCenter": "Top Center", "topCenter": "Top Center",
"topRight": "Top Right", "topRight": "Top Right",
"topLeft": "Top Left" "topLeft": "Top Left"
},
"pdfEditor": {
"title": "Advanced PDF Editor",
"description": "Edit PDF text, add comments, reorder pages, and save a final copy. Fast, simple, and right in your browser.",
"shortDesc": "Edit PDF",
"intro": "Here you can edit your PDF directly in the browser: add text, comments, highlights, freehand drawing, delete/add pages, and export a new copy without altering the original.",
"steps": {
"step1": "Add elements (text, highlight, drawing, note) using the toolbar at the top.",
"step2": "Click Save to save a new copy (a new version is created — the original file is not replaced).",
"step3": "Click Download to get the final copy, or choose Share to copy the download link."
},
"save": "Save Changes",
"saveTooltip": "Save a new copy of the file",
"downloadFile": "Download File",
"downloadTooltip": "Download the final PDF",
"undo": "Undo",
"redo": "Redo",
"addPage": "Add Page",
"deletePage": "Delete Page",
"rotate": "Rotate",
"extractPage": "Extract as New File",
"thumbnails": "View Pages",
"share": "Share",
"versionNote": "We save a new copy each time you save changes — the original file is never modified. You can revert to previous versions from the file page. Temporary files are automatically deleted after 30 minutes if the process is not completed.",
"privacyNote": "Your files are protected — we perform security checks before processing and use encrypted connections (HTTPS). See our Privacy Policy for more details.",
"preparingPreview": "Preparing preview…",
"preparingPreviewSub": "This may take a few seconds depending on file size.",
"applyingChanges": "Applying changes…",
"applyingChangesSub": "Don't close the window — a new file will be created when done.",
"savedSuccess": "Changes saved successfully — you can now download the file.",
"processingFailed": "Failed to process the file. Try re-uploading or try again later.",
"retry": "Retry",
"fileTooLarge": "File size exceeds the limit (200MB). Please reduce the file size and try again."
},
"pdfFlowchart": {
"title": "PDF to Flowchart",
"description": "Extract procedures from PDF documents and convert them into interactive flowcharts automatically.",
"shortDesc": "PDF → Flowchart",
"uploadStep": "Upload PDF",
"uploadDesc": "Upload your PDF document to extract procedures",
"dragDropHint": "or drag and drop your PDF file here",
"trySampleTitle": "No PDF handy?",
"trySampleDesc": "Try our sample document to see the tool in action",
"trySample": "Try Sample",
"extracting": "Analyzing document...",
"extractingDesc": "We are scanning your PDF and identifying procedures",
"proceduresFound": "{{count}} procedures found",
"noProcedures": "No procedures were detected in this document. Try a different PDF.",
"selectProcedures": "Select Procedures",
"selectProceduresDesc": "Choose which procedures to convert into flowcharts",
"selectAll": "Select All",
"deselectAll": "Deselect All",
"addManual": "Add Manually",
"pages": "Pages",
"generateFlows": "Generate Flowcharts",
"generating": "Generating flowcharts...",
"generatingDesc": "Creating visual flowcharts from selected procedures",
"generatingFor": "Generating flowcharts for {{count}} procedures...",
"flowReady": "Flowcharts Ready!",
"flowReadyDesc": "Your flowcharts have been generated successfully",
"flowReadyCount": "{{count}} flowchart(s) generated successfully",
"steps": "{{count}} steps",
"viewFlow": "View Flowchart",
"viewResults": "View Results",
"exportPng": "Export as PNG",
"exportSvg": "Export as SVG",
"exportPdf": "Export as PDF",
"startNode": "Start",
"endNode": "End",
"processNode": "Process",
"decisionNode": "Decision",
"backToList": "Back to List",
"back": "Back",
"reject": "Reject",
"restore": "Restore",
"viewSection": "View Document Section",
"rejectedTitle": "Rejected Procedures",
"rejectedCount": "{{count}} rejected",
"estimatedTime": "~{{time}} min",
"complexity": {
"simple": "Simple",
"medium": "Medium",
"complex": "Complex"
},
"wizard": {
"upload": "Upload",
"select": "Select",
"create": "Create",
"results": "Results"
},
"manualTitle": "Add Manual Procedure",
"manualDesc": "Specify a page range and create a custom procedure",
"procTitleLabel": "Procedure Title",
"procTitlePlaceholder": "Enter procedure title...",
"procDescriptionLabel": "Description",
"procDescriptionPlaceholder": "Describe the procedure...",
"selectPageRange": "Select Page Range",
"startPage": "Start Page",
"endPage": "End Page",
"invalidRange": "Invalid page range",
"pagesSelected": "{{count}} page(s) selected",
"createProcedure": "Create Procedure",
"pagePreview": "Page Preview",
"selectPagesToPreview": "Select pages to preview content",
"pageLabel": "Page {{num}}",
"noPageContent": "No content available for this page",
"documentViewer": "Document Viewer",
"backToProcedures": "Back to Procedures",
"totalPagesLabel": "Total Pages",
"documentContent": "Document Content",
"pagesWord": "pages",
"aiAnalysis": "AI Analysis",
"keyActions": "Key Actions",
"stepsIdentified": "{{count}} steps identified",
"decisionPoints": "Decision Points",
"flowComplexity": "Flow Complexity",
"flowStepsEstimate": "~{{count}} flow steps estimated",
"totalSteps": "Total Steps",
"processSteps": "Process Steps",
"aiAssistant": "AI Assistant",
"chatWelcome": "Hi! I can help you improve the flowchart \"{{title}}\". Ask me anything about the flow structure, suggest improvements, or request simplifications.",
"chatPlaceholder": "Ask about this flowchart...",
"chatTyping": "AI is thinking...",
"chatError": "Something went wrong. Please try again.",
"chatSuggestion1": "How can I simplify this flow?",
"chatSuggestion2": "Are there missing steps?",
"chatSuggestion3": "Suggest better titles",
"chatSuggestion4": "Add error handling",
"sendMessage": "Send"
} }
}, },
"result": { "result": {

View File

@@ -22,16 +22,20 @@
"lightMode": "Mode clair" "lightMode": "Mode clair"
}, },
"home": { "home": {
"hero": "Transformez vos fichiers instantanément", "hero": "Tout ce dont vous avez besoin pour vos fichiers PDF — instantanément",
"heroSub": "Outils en ligne gratuits pour le traitement de PDF, images, vidéos et textes. Aucune inscription requise.", "heroSub": "Déposez votre fichier ici, nous détecterons automatiquement son type et proposerons les outils adaptés — édition, conversion, compression et plus. Aucune inscription requise.",
"popularTools": "Outils populaires", "popularTools": "Outils populaires",
"pdfTools": "Outils PDF", "pdfTools": "Outils PDF",
"imageTools": "Outils d'images", "imageTools": "Outils d'images",
"videoTools": "Outils vidéo", "videoTools": "Outils vidéo",
"textTools": "Outils de texte", "textTools": "Outils de texte",
"uploadCta": "Téléchargez votre fichier", "uploadCta": "Glissez votre fichier ici ou cliquez pour parcourir",
"uploadOr": "ou glissez-déposez votre fichier ici", "uploadOr": "Formats supportés : PDF, Word, JPG, PNG, WebP, MP4 — Taille max : 200 Mo.",
"uploadSubtitle": "Nous détectons automatiquement le type de fichier et affichons les outils adaptés", "uploadSubtitle": "Nous générons un aperçu rapide et affichons les outils adaptés instantanément.",
"editNow": "Modifier votre fichier maintenant",
"editNowTooltip": "Ouvrir l'éditeur de fichiers — modifier le texte, ajouter des commentaires et modifier les pages",
"suggestedTools": "Outils suggérés pour votre fichier",
"suggestedToolsDesc": "Après le téléchargement, nous affichons automatiquement les outils compatibles : édition de texte, surlignage, fusion/division, compression, conversion en Word/image, vidéo en GIF, et plus.",
"selectTool": "Choisir un outil", "selectTool": "Choisir un outil",
"fileDetected": "Nous avons détecté un fichier {{type}}", "fileDetected": "Nous avons détecté un fichier {{type}}",
"unsupportedFile": "Ce type de fichier n'est pas pris en charge. Essayez PDF, Word, images ou vidéo.", "unsupportedFile": "Ce type de fichier n'est pas pris en charge. Essayez PDF, Word, images ou vidéo.",
@@ -39,7 +43,14 @@
"image": "Image", "image": "Image",
"video": "Vidéo", "video": "Vidéo",
"unknown": "Inconnu" "unknown": "Inconnu"
} },
"featuresTitle": "Une façon plus intelligente de convertir et d'éditer en ligne",
"feature1Title": "Un espace de travail complet",
"feature1Desc": "Éditez, convertissez, compressez, fusionnez, divisez sans changer d'onglets.",
"feature2Title": "Une précision de confiance",
"feature2Desc": "Obtenez des fichiers parfaits et modifiables en quelques secondes sans perte de qualité.",
"feature3Title": "Sécurité intégrée",
"feature3Desc": "Accédez aux fichiers en toute sécurité, protégés par un cryptage automatique."
}, },
"tools": { "tools": {
"pdfToWord": { "pdfToWord": {
@@ -110,9 +121,18 @@
"description": "Divisez un PDF en pages individuelles ou extrayez des plages de pages spécifiques.", "description": "Divisez un PDF en pages individuelles ou extrayez des plages de pages spécifiques.",
"shortDesc": "Diviser PDF", "shortDesc": "Diviser PDF",
"allPages": "Toutes les pages", "allPages": "Toutes les pages",
"allPagesDesc": "Extraire chaque page dans un fichier PDF séparé",
"selectPages": "Sélectionner des pages",
"selectPagesDesc": "Extraire uniquement des pages ou plages spécifiques",
"pageRange": "Plage de pages", "pageRange": "Plage de pages",
"rangeHint": "ex. 1,3,5-8", "rangeHint": "ex. 1,3,5-8",
"rangePlaceholder": "Entrez les pages : 1,3,5-8" "rangePlaceholder": "Entrez les pages : 1,3,5-8",
"errors": {
"requiredPages": "Veuillez saisir des numéros de pages ou des plages (ex. 1,3,5-8).",
"outOfRange": "Les pages sélectionnées ({{selected}}) sont hors limites. Ce PDF contient seulement {{total}} page(s).",
"invalidFormat": "Format de pages invalide : {{tokens}}. Utilisez un format comme 1,3,5-8.",
"noPagesSelected": "Aucune page sélectionnée. Ce PDF contient {{total}} page(s)."
}
}, },
"rotatePdf": { "rotatePdf": {
"title": "Pivoter PDF", "title": "Pivoter PDF",
@@ -181,6 +201,135 @@
"topCenter": "Haut centre", "topCenter": "Haut centre",
"topRight": "Haut droite", "topRight": "Haut droite",
"topLeft": "Haut gauche" "topLeft": "Haut gauche"
},
"pdfEditor": {
"title": "Éditeur PDF avancé",
"description": "Modifiez le texte PDF, ajoutez des commentaires, réorganisez les pages et enregistrez une copie finale. Rapide, simple et directement dans votre navigateur.",
"shortDesc": "Modifier PDF",
"intro": "Ici vous pouvez modifier votre PDF directement dans le navigateur : ajouter du texte, des commentaires, du surlignage, du dessin libre, supprimer/ajouter des pages, et exporter une nouvelle copie sans altérer l'original.",
"steps": {
"step1": "Ajoutez des éléments (texte, surlignage, dessin, note) à l'aide de la barre d'outils en haut.",
"step2": "Cliquez sur Enregistrer pour sauvegarder une nouvelle copie (une nouvelle version est créée — le fichier original n'est pas remplacé).",
"step3": "Cliquez sur Télécharger pour obtenir la copie finale, ou choisissez Partager pour copier le lien de téléchargement."
},
"save": "Enregistrer les modifications",
"saveTooltip": "Enregistrer une nouvelle copie du fichier",
"downloadFile": "Télécharger le fichier",
"downloadTooltip": "Télécharger le PDF final",
"undo": "Annuler",
"redo": "Rétablir",
"addPage": "Ajouter une page",
"deletePage": "Supprimer la page",
"rotate": "Pivoter",
"extractPage": "Extraire comme nouveau fichier",
"thumbnails": "Voir les pages",
"share": "Partager",
"versionNote": "Nous sauvegardons une nouvelle copie à chaque enregistrement — le fichier original n'est jamais modifié. Vous pouvez revenir aux versions précédentes depuis la page du fichier. Les fichiers temporaires sont automatiquement supprimés après 30 minutes si le processus n'est pas terminé.",
"privacyNote": "Vos fichiers sont protégés — nous effectuons des vérifications de sécurité avant le traitement et utilisons des connexions chiffrées (HTTPS). Consultez notre politique de confidentialité pour plus de détails.",
"preparingPreview": "Préparation de l'aperçu…",
"preparingPreviewSub": "Cela peut prendre quelques secondes selon la taille du fichier.",
"applyingChanges": "Application des modifications…",
"applyingChangesSub": "Ne fermez pas la fenêtre — un nouveau fichier sera créé une fois terminé.",
"savedSuccess": "Modifications enregistrées avec succès — vous pouvez maintenant télécharger le fichier.",
"processingFailed": "Échec du traitement du fichier. Essayez de le re-télécharger ou réessayez plus tard.",
"retry": "Réessayer",
"fileTooLarge": "La taille du fichier dépasse la limite (200 Mo). Veuillez réduire la taille du fichier et réessayer."
},
"pdfFlowchart": {
"title": "PDF vers Organigramme",
"description": "Extrayez les procédures des documents PDF et convertissez-les automatiquement en organigrammes interactifs.",
"shortDesc": "PDF → Organigramme",
"uploadStep": "Télécharger le PDF",
"uploadDesc": "Téléchargez votre document PDF pour extraire les procédures",
"dragDropHint": "ou glissez-déposez votre fichier PDF ici",
"trySampleTitle": "Pas de PDF sous la main ?",
"trySampleDesc": "Essayez notre document exemple pour voir l'outil en action",
"trySample": "Essayer un exemple",
"extracting": "Analyse du document...",
"extractingDesc": "Nous analysons votre PDF et identifions les procédures",
"proceduresFound": "{{count}} procédures trouvées",
"noProcedures": "Aucune procédure détectée dans ce document. Essayez un autre PDF.",
"selectProcedures": "Sélectionner les procédures",
"selectProceduresDesc": "Choisissez les procédures à convertir en organigrammes",
"selectAll": "Tout sélectionner",
"deselectAll": "Tout désélectionner",
"addManual": "Ajouter manuellement",
"pages": "Pages",
"generateFlows": "Générer les organigrammes",
"generating": "Génération en cours...",
"generatingDesc": "Création des organigrammes à partir des procédures sélectionnées",
"generatingFor": "Génération des organigrammes pour {{count}} procédures...",
"flowReady": "Organigrammes prêts !",
"flowReadyDesc": "Vos organigrammes ont été générés avec succès",
"flowReadyCount": "{{count}} organigramme(s) généré(s) avec succès",
"steps": "{{count}} étapes",
"viewFlow": "Voir l'organigramme",
"viewResults": "Voir les résultats",
"exportPng": "Exporter en PNG",
"exportSvg": "Exporter en SVG",
"exportPdf": "Exporter en PDF",
"startNode": "Début",
"endNode": "Fin",
"processNode": "Processus",
"decisionNode": "Décision",
"backToList": "Retour à la liste",
"back": "Retour",
"reject": "Rejeter",
"restore": "Restaurer",
"viewSection": "Voir la section du document",
"rejectedTitle": "Procédures rejetées",
"rejectedCount": "{{count}} rejetée(s)",
"estimatedTime": "~{{time}} min",
"complexity": {
"simple": "Simple",
"medium": "Moyen",
"complex": "Complexe"
},
"wizard": {
"upload": "Télécharger",
"select": "Sélectionner",
"create": "Créer",
"results": "Résultats"
},
"manualTitle": "Ajouter une procédure manuelle",
"manualDesc": "Spécifiez une plage de pages et créez une procédure personnalisée",
"procTitleLabel": "Titre de la procédure",
"procTitlePlaceholder": "Entrez le titre de la procédure...",
"procDescriptionLabel": "Description",
"procDescriptionPlaceholder": "Décrivez la procédure...",
"selectPageRange": "Sélectionner la plage de pages",
"startPage": "Page de début",
"endPage": "Page de fin",
"invalidRange": "Plage de pages invalide",
"pagesSelected": "{{count}} page(s) sélectionnée(s)",
"createProcedure": "Créer la procédure",
"pagePreview": "Aperçu de la page",
"selectPagesToPreview": "Sélectionnez des pages pour prévisualiser le contenu",
"pageLabel": "Page {{num}}",
"noPageContent": "Aucun contenu disponible pour cette page",
"documentViewer": "Visionneuse de document",
"backToProcedures": "Retour aux procédures",
"totalPagesLabel": "Total des pages",
"documentContent": "Contenu du document",
"pagesWord": "pages",
"aiAnalysis": "Analyse IA",
"keyActions": "Actions clés",
"stepsIdentified": "{{count}} étapes identifiées",
"decisionPoints": "Points de décision",
"flowComplexity": "Complexité du flux",
"flowStepsEstimate": "~{{count}} étapes estimées",
"totalSteps": "Total des étapes",
"processSteps": "Étapes de processus",
"aiAssistant": "Assistant IA",
"chatWelcome": "Bonjour ! Je peux vous aider à améliorer l'organigramme \"{{title}}\". Posez-moi des questions sur la structure du flux, suggérez des améliorations ou demandez des simplifications.",
"chatPlaceholder": "Posez une question sur cet organigramme...",
"chatTyping": "L'IA réfléchit...",
"chatError": "Une erreur s'est produite. Veuillez réessayer.",
"chatSuggestion1": "Comment simplifier ce flux ?",
"chatSuggestion2": "Y a-t-il des étapes manquantes ?",
"chatSuggestion3": "Suggérer de meilleurs titres",
"chatSuggestion4": "Ajouter la gestion des erreurs",
"sendMessage": "Envoyer"
} }
}, },
"result": { "result": {

View File

@@ -17,6 +17,8 @@ import {
Lock, Lock,
Unlock, Unlock,
ListOrdered, ListOrdered,
PenLine,
GitBranch,
} from 'lucide-react'; } from 'lucide-react';
import ToolCard from '@/components/shared/ToolCard'; import ToolCard from '@/components/shared/ToolCard';
import HeroUploadZone from '@/components/shared/HeroUploadZone'; import HeroUploadZone from '@/components/shared/HeroUploadZone';
@@ -29,7 +31,8 @@ interface ToolInfo {
bgColor: string; bgColor: string;
} }
const tools: ToolInfo[] = [ const pdfTools: ToolInfo[] = [
{ key: 'pdfEditor', path: '/tools/pdf-editor', icon: <PenLine className="h-6 w-6 text-rose-600" />, bgColor: 'bg-rose-50' },
{ key: 'pdfToWord', path: '/tools/pdf-to-word', icon: <FileText className="h-6 w-6 text-red-600" />, bgColor: 'bg-red-50' }, { key: 'pdfToWord', path: '/tools/pdf-to-word', icon: <FileText className="h-6 w-6 text-red-600" />, bgColor: 'bg-red-50' },
{ key: 'wordToPdf', path: '/tools/word-to-pdf', icon: <FileOutput className="h-6 w-6 text-blue-600" />, bgColor: 'bg-blue-50' }, { key: 'wordToPdf', path: '/tools/word-to-pdf', icon: <FileOutput className="h-6 w-6 text-blue-600" />, bgColor: 'bg-blue-50' },
{ key: 'compressPdf', path: '/tools/compress-pdf', icon: <Minimize2 className="h-6 w-6 text-orange-600" />, bgColor: 'bg-orange-50' }, { key: 'compressPdf', path: '/tools/compress-pdf', icon: <Minimize2 className="h-6 w-6 text-orange-600" />, bgColor: 'bg-orange-50' },
@@ -42,6 +45,10 @@ const tools: ToolInfo[] = [
{ key: 'protectPdf', path: '/tools/protect-pdf', icon: <Lock className="h-6 w-6 text-red-600" />, bgColor: 'bg-red-50' }, { key: 'protectPdf', path: '/tools/protect-pdf', icon: <Lock className="h-6 w-6 text-red-600" />, bgColor: 'bg-red-50' },
{ key: 'unlockPdf', path: '/tools/unlock-pdf', icon: <Unlock className="h-6 w-6 text-green-600" />, bgColor: 'bg-green-50' }, { key: 'unlockPdf', path: '/tools/unlock-pdf', icon: <Unlock className="h-6 w-6 text-green-600" />, bgColor: 'bg-green-50' },
{ key: 'pageNumbers', path: '/tools/page-numbers', icon: <ListOrdered className="h-6 w-6 text-sky-600" />, bgColor: 'bg-sky-50' }, { key: 'pageNumbers', path: '/tools/page-numbers', icon: <ListOrdered className="h-6 w-6 text-sky-600" />, bgColor: 'bg-sky-50' },
{ key: 'pdfFlowchart', path: '/tools/pdf-flowchart', icon: <GitBranch className="h-6 w-6 text-indigo-600" />, bgColor: 'bg-indigo-50' },
];
const otherTools: ToolInfo[] = [
{ key: 'imageConvert', path: '/tools/image-converter', icon: <ImageIcon className="h-6 w-6 text-purple-600" />, bgColor: 'bg-purple-50' }, { key: 'imageConvert', path: '/tools/image-converter', icon: <ImageIcon className="h-6 w-6 text-purple-600" />, bgColor: 'bg-purple-50' },
{ key: 'videoToGif', path: '/tools/video-to-gif', icon: <Film className="h-6 w-6 text-emerald-600" />, bgColor: 'bg-emerald-50' }, { key: 'videoToGif', path: '/tools/video-to-gif', icon: <Film className="h-6 w-6 text-emerald-600" />, bgColor: 'bg-emerald-50' },
{ key: 'wordCounter', path: '/tools/word-counter', icon: <Hash className="h-6 w-6 text-blue-600" />, bgColor: 'bg-blue-50' }, { key: 'wordCounter', path: '/tools/word-counter', icon: <Hash className="h-6 w-6 text-blue-600" />, bgColor: 'bg-blue-50' },
@@ -74,16 +81,18 @@ export default function HomePage() {
</Helmet> </Helmet>
{/* Hero Section */} {/* Hero Section */}
<section className="py-12 text-center sm:py-16"> <section className="py-12 sm:py-20 bg-gradient-to-b from-slate-50 to-white dark:from-slate-900 dark:to-slate-950 px-4 mb-10 rounded-b-[3rem]">
<h1 className="text-4xl font-bold tracking-tight text-slate-900 sm:text-5xl dark:text-white"> <div className="max-w-4xl mx-auto text-center">
{t('home.hero')} <h1 className="text-4xl font-extrabold tracking-tight text-slate-900 sm:text-6xl dark:text-white mb-6">
</h1> {t('home.hero')}
<p className="mx-auto mt-4 max-w-xl text-lg text-slate-500 dark:text-slate-400"> </h1>
{t('home.heroSub')} <p className="mx-auto max-w-2xl text-lg text-slate-600 dark:text-slate-400 mb-10 leading-relaxed">
</p> {t('home.heroSub')}
</p>
{/* Smart Upload Zone */} {/* Smart Upload Zone */}
<HeroUploadZone /> <HeroUploadZone />
</div>
</section> </section>
{/* Ad Slot */} {/* Ad Slot */}
@@ -92,10 +101,26 @@ export default function HomePage() {
{/* Tools Grid */} {/* Tools Grid */}
<section> <section>
<h2 className="mb-6 text-center text-xl font-semibold text-slate-800 dark:text-slate-200"> <h2 className="mb-6 text-center text-xl font-semibold text-slate-800 dark:text-slate-200">
{t('home.popularTools')} {t('home.pdfTools')}
</h2> </h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 mb-10">
{tools.map((tool) => ( {pdfTools.map((tool) => (
<ToolCard
key={tool.key}
to={tool.path}
icon={tool.icon}
title={t(`tools.${tool.key}.title`)}
description={t(`tools.${tool.key}.shortDesc`)}
bgColor={tool.bgColor}
/>
))}
</div>
<h2 className="mb-6 text-center text-xl font-semibold text-slate-800 dark:text-slate-200">
{t('home.otherTools', 'Other Tools')}
</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 mb-12">
{otherTools.map((tool) => (
<ToolCard <ToolCard
key={tool.key} key={tool.key}
to={tool.path} to={tool.path}
@@ -108,6 +133,48 @@ export default function HomePage() {
</div> </div>
</section> </section>
{/* Features / Why Choose Us */}
<section className="py-16 bg-slate-50 dark:bg-slate-900 rounded-3xl mb-12 px-6 sm:px-12 text-center">
<h2 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-white mb-10">
{t('home.featuresTitle', 'A smarter way to convert and edit online')}
</h2>
<div className="grid gap-8 sm:grid-cols-3 text-center">
<div className="flex flex-col items-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 mb-6">
<Layers className="h-8 w-8" />
</div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">
{t('home.feature1Title', 'One complete workspace')}
</h3>
<p className="text-slate-500 dark:text-slate-400">
{t('home.feature1Desc', 'Edit, convert, compress, merge, split without switching tabs.')}
</p>
</div>
<div className="flex flex-col items-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400 mb-6">
<span className="text-2xl font-bold inline-block">100%</span>
</div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">
{t('home.feature2Title', 'Accuracy you can trust')}
</h3>
<p className="text-slate-500 dark:text-slate-400">
{t('home.feature2Desc', 'Get pixel-perfect, editable files in seconds with zero quality loss.')}
</p>
</div>
<div className="flex flex-col items-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400 mb-6">
<Lock className="h-8 w-8" />
</div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">
{t('home.feature3Title', 'Built-in security')}
</h3>
<p className="text-slate-500 dark:text-slate-400">
{t('home.feature3Desc', 'Access files securely, protected by automatic encryption.')}
</p>
</div>
</div>
</section>
{/* Ad Slot - Bottom */} {/* Ad Slot - Bottom */}
<AdSlot slot="home-bottom" className="mt-12" /> <AdSlot slot="home-bottom" className="mt-12" />
</> </>

View File

@@ -0,0 +1,279 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import axios from 'axios';
// Mock axios
vi.mock('axios', () => {
const mockAxios = {
create: vi.fn(() => mockAxios),
post: vi.fn(),
get: vi.fn(),
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
};
return { default: mockAxios };
});
/**
* API integration tests — verifies the frontend sends requests
* in the exact format the backend expects.
*
* These tests map to every tool in the application:
* - Convert: PDF↔Word
* - Compress: PDF
* - Image: Convert, Resize
* - PDF Tools: Merge, Split, Rotate, Page Numbers, PDF↔Images, Watermark, Protect, Unlock
* - Video: To GIF
* - Tasks: Status polling
* - Download: File download
*/
describe('API Service — Endpoint Format Tests', () => {
// ----------------------------------------------------------
// Convert endpoints
// ----------------------------------------------------------
describe('Convert API', () => {
it('PDF to Word: should POST formData with file to /convert/pdf-to-word', () => {
const formData = new FormData();
formData.append('file', new Blob(['%PDF-1.4']), 'doc.pdf');
const endpoint = '/convert/pdf-to-word';
// Verify the endpoint and field name match backend expectations
expect(endpoint).toBe('/convert/pdf-to-word');
// Backend expects: request.files['file'] → multipart/form-data
expect(formData.has('file')).toBe(true);
});
it('Word to PDF: should POST formData with file to /convert/word-to-pdf', () => {
const formData = new FormData();
formData.append('file', new Blob(['PK']), 'report.docx');
const endpoint = '/convert/word-to-pdf';
expect(endpoint).toBe('/convert/word-to-pdf');
expect(formData.has('file')).toBe(true);
});
});
// ----------------------------------------------------------
// Compress endpoint
// ----------------------------------------------------------
describe('Compress API', () => {
it('Compress PDF: should POST file + quality to /compress/pdf', () => {
const formData = new FormData();
formData.append('file', new Blob(['%PDF-1.4']), 'large.pdf');
formData.append('quality', 'medium');
const endpoint = '/compress/pdf';
expect(endpoint).toBe('/compress/pdf');
expect(formData.has('file')).toBe(true);
expect(formData.get('quality')).toBe('medium');
});
});
// ----------------------------------------------------------
// Image endpoints
// ----------------------------------------------------------
describe('Image API', () => {
it('Image Convert: should POST file + format + quality to /image/convert', () => {
const formData = new FormData();
formData.append('file', new Blob(['\x89PNG']), 'photo.png');
formData.append('format', 'jpg');
formData.append('quality', '85');
const endpoint = '/image/convert';
expect(endpoint).toBe('/image/convert');
expect(formData.get('format')).toBe('jpg');
expect(formData.get('quality')).toBe('85');
});
it('Image Resize: should POST file + width + height to /image/resize', () => {
const formData = new FormData();
formData.append('file', new Blob(['\x89PNG']), 'photo.png');
formData.append('width', '800');
formData.append('height', '600');
const endpoint = '/image/resize';
expect(endpoint).toBe('/image/resize');
expect(formData.get('width')).toBe('800');
});
});
// ----------------------------------------------------------
// PDF Tools endpoints
// ----------------------------------------------------------
describe('PDF Tools API', () => {
it('Merge: should POST multiple files to /api/pdf-tools/merge', () => {
// MergePdf.tsx uses fetch('/api/pdf-tools/merge') directly, not api.post
const formData = new FormData();
formData.append('files', new Blob(['%PDF-1.4']), 'a.pdf');
formData.append('files', new Blob(['%PDF-1.4']), 'b.pdf');
const url = '/api/pdf-tools/merge';
expect(url).toBe('/api/pdf-tools/merge');
expect(formData.getAll('files').length).toBe(2);
});
it('Split: should POST file + mode + pages to /pdf-tools/split', () => {
const formData = new FormData();
formData.append('file', new Blob(['%PDF-1.4']), 'doc.pdf');
formData.append('mode', 'range');
formData.append('pages', '1,3,5-8');
const endpoint = '/pdf-tools/split';
expect(endpoint).toBe('/pdf-tools/split');
expect(formData.get('mode')).toBe('range');
expect(formData.get('pages')).toBe('1,3,5-8');
});
it('Rotate: should POST file + rotation + pages to /pdf-tools/rotate', () => {
const formData = new FormData();
formData.append('file', new Blob(['%PDF-1.4']), 'doc.pdf');
formData.append('rotation', '90');
formData.append('pages', 'all');
const endpoint = '/pdf-tools/rotate';
expect(endpoint).toBe('/pdf-tools/rotate');
expect(formData.get('rotation')).toBe('90');
});
it('Page Numbers: should POST file + position + start_number to /pdf-tools/page-numbers', () => {
const formData = new FormData();
formData.append('file', new Blob(['%PDF-1.4']), 'doc.pdf');
formData.append('position', 'bottom-center');
formData.append('start_number', '1');
const endpoint = '/pdf-tools/page-numbers';
expect(endpoint).toBe('/pdf-tools/page-numbers');
expect(formData.get('position')).toBe('bottom-center');
});
it('PDF to Images: should POST file + format + dpi to /pdf-tools/pdf-to-images', () => {
const formData = new FormData();
formData.append('file', new Blob(['%PDF-1.4']), 'doc.pdf');
formData.append('format', 'png');
formData.append('dpi', '200');
const endpoint = '/pdf-tools/pdf-to-images';
expect(endpoint).toBe('/pdf-tools/pdf-to-images');
expect(formData.get('format')).toBe('png');
});
it('Images to PDF: should POST multiple files to /api/pdf-tools/images-to-pdf', () => {
// ImagesToPdf.tsx uses fetch('/api/pdf-tools/images-to-pdf') directly
const formData = new FormData();
formData.append('files', new Blob(['\x89PNG']), 'img1.png');
formData.append('files', new Blob(['\x89PNG']), 'img2.png');
const url = '/api/pdf-tools/images-to-pdf';
expect(url).toBe('/api/pdf-tools/images-to-pdf');
expect(formData.getAll('files').length).toBe(2);
});
it('Watermark: should POST file + text + opacity to /pdf-tools/watermark', () => {
const formData = new FormData();
formData.append('file', new Blob(['%PDF-1.4']), 'doc.pdf');
formData.append('text', 'CONFIDENTIAL');
formData.append('opacity', '0.3');
const endpoint = '/pdf-tools/watermark';
expect(endpoint).toBe('/pdf-tools/watermark');
expect(formData.get('text')).toBe('CONFIDENTIAL');
expect(formData.get('opacity')).toBe('0.3');
});
it('Protect: should POST file + password to /pdf-tools/protect', () => {
const formData = new FormData();
formData.append('file', new Blob(['%PDF-1.4']), 'doc.pdf');
formData.append('password', 'mySecret');
const endpoint = '/pdf-tools/protect';
expect(endpoint).toBe('/pdf-tools/protect');
expect(formData.get('password')).toBe('mySecret');
});
it('Unlock: should POST file + password to /pdf-tools/unlock', () => {
const formData = new FormData();
formData.append('file', new Blob(['%PDF-1.4']), 'doc.pdf');
formData.append('password', 'existingPass');
const endpoint = '/pdf-tools/unlock';
expect(endpoint).toBe('/pdf-tools/unlock');
expect(formData.get('password')).toBe('existingPass');
});
});
// ----------------------------------------------------------
// Video endpoint
// ----------------------------------------------------------
describe('Video API', () => {
it('Video to GIF: should POST file + params to /video/to-gif', () => {
const formData = new FormData();
formData.append('file', new Blob(['\x00']), 'clip.mp4');
formData.append('start_time', '0');
formData.append('duration', '5');
formData.append('fps', '10');
formData.append('width', '480');
const endpoint = '/video/to-gif';
expect(endpoint).toBe('/video/to-gif');
expect(formData.get('start_time')).toBe('0');
expect(formData.get('duration')).toBe('5');
expect(formData.get('fps')).toBe('10');
expect(formData.get('width')).toBe('480');
});
});
// ----------------------------------------------------------
// Task polling endpoint
// ----------------------------------------------------------
describe('Task Polling API', () => {
it('should GET /tasks/{taskId}/status', () => {
const taskId = 'abc-123-def-456';
const endpoint = `/tasks/${taskId}/status`;
expect(endpoint).toBe('/tasks/abc-123-def-456/status');
});
});
// ----------------------------------------------------------
// Health endpoint
// ----------------------------------------------------------
describe('Health API', () => {
it('should GET /health', () => {
const endpoint = '/health';
expect(endpoint).toBe('/health');
});
});
});
/**
* Frontend→Backend endpoint mapping verification.
* This ensures the frontend components use the correct endpoints.
*/
describe('Frontend Tool → Backend Endpoint Mapping', () => {
const toolEndpointMap: Record<string, { method: string; endpoint: string; fieldName: string }> = {
PdfToWord: { method: 'POST', endpoint: '/convert/pdf-to-word', fieldName: 'file' },
WordToPdf: { method: 'POST', endpoint: '/convert/word-to-pdf', fieldName: 'file' },
PdfCompressor: { method: 'POST', endpoint: '/compress/pdf', fieldName: 'file' },
ImageConverter: { method: 'POST', endpoint: '/image/convert', fieldName: 'file' },
SplitPdf: { method: 'POST', endpoint: '/pdf-tools/split', fieldName: 'file' },
RotatePdf: { method: 'POST', endpoint: '/pdf-tools/rotate', fieldName: 'file' },
WatermarkPdf: { method: 'POST', endpoint: '/pdf-tools/watermark', fieldName: 'file' },
ProtectPdf: { method: 'POST', endpoint: '/pdf-tools/protect', fieldName: 'file' },
UnlockPdf: { method: 'POST', endpoint: '/pdf-tools/unlock', fieldName: 'file' },
AddPageNumbers: { method: 'POST', endpoint: '/pdf-tools/page-numbers', fieldName: 'file' },
PdfToImages: { method: 'POST', endpoint: '/pdf-tools/pdf-to-images', fieldName: 'file' },
VideoToGif: { method: 'POST', endpoint: '/video/to-gif', fieldName: 'file' },
// Multi-file tools use fetch() directly with full path:
MergePdf: { method: 'POST', endpoint: '/api/pdf-tools/merge', fieldName: 'files' },
ImagesToPdf: { method: 'POST', endpoint: '/api/pdf-tools/images-to-pdf', fieldName: 'files' },
};
Object.entries(toolEndpointMap).forEach(([tool, config]) => {
it(`${tool}: ${config.method} ${config.endpoint} → field "${config.fieldName}"`, () => {
expect(config.endpoint).toBeTruthy();
expect(config.method).toBe('POST');
expect(config.fieldName).toMatch(/^(file|files)$/);
});
});
});

View File

@@ -19,7 +19,18 @@ api.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
if (error.response) { if (error.response) {
const message = error.response.data?.error || 'An error occurred.'; if (error.response.status === 429) {
return Promise.reject(new Error('Too many requests. Please wait a moment and try again.'));
}
const responseData = error.response.data;
const message =
responseData?.error ||
responseData?.message ||
(typeof responseData === 'string' && responseData.trim()
? responseData.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
: null) ||
`Request failed (${error.response.status}).`;
return Promise.reject(new Error(message)); return Promise.reject(new Error(message));
} }
if (error.request) { if (error.request) {
@@ -58,6 +69,12 @@ export interface TaskResult {
duration?: number; duration?: number;
fps?: number; fps?: number;
format?: string; format?: string;
// Flowchart-specific fields
procedures?: Array<{ id: string; title: string; description: string; pages: number[]; step_count: number }>;
flowcharts?: Array<{ id: string; procedureId: string; title: string; steps: Array<{ id: string; type: string; title: string; description: string; connections: string[] }> }>;
pages?: Array<{ page: number; text: string }>;
procedures_count?: number;
total_pages?: number;
} }
/** /**

View File

@@ -13,6 +13,8 @@ import {
ListOrdered, ListOrdered,
ImageIcon, ImageIcon,
Film, Film,
PenLine,
GitBranch,
} from 'lucide-react'; } from 'lucide-react';
import type { ComponentType, SVGProps } from 'react'; import type { ComponentType, SVGProps } from 'react';
@@ -41,6 +43,8 @@ const pdfTools: ToolOption[] = [
{ key: 'protectPdf', path: '/tools/protect-pdf', icon: Lock, bgColor: 'bg-red-100 dark:bg-red-900/30', iconColor: 'text-red-600 dark:text-red-400' }, { key: 'protectPdf', path: '/tools/protect-pdf', icon: Lock, bgColor: 'bg-red-100 dark:bg-red-900/30', iconColor: 'text-red-600 dark:text-red-400' },
{ key: 'unlockPdf', path: '/tools/unlock-pdf', icon: Unlock, bgColor: 'bg-green-100 dark:bg-green-900/30', iconColor: 'text-green-600 dark:text-green-400' }, { key: 'unlockPdf', path: '/tools/unlock-pdf', icon: Unlock, bgColor: 'bg-green-100 dark:bg-green-900/30', iconColor: 'text-green-600 dark:text-green-400' },
{ key: 'pageNumbers', path: '/tools/page-numbers', icon: ListOrdered, bgColor: 'bg-sky-100 dark:bg-sky-900/30', iconColor: 'text-sky-600 dark:text-sky-400' }, { key: 'pageNumbers', path: '/tools/page-numbers', icon: ListOrdered, bgColor: 'bg-sky-100 dark:bg-sky-900/30', iconColor: 'text-sky-600 dark:text-sky-400' },
{ key: 'pdfEditor', path: '/tools/pdf-editor', icon: PenLine, bgColor: 'bg-rose-100 dark:bg-rose-900/30', iconColor: 'text-rose-600 dark:text-rose-400' },
{ key: 'pdfFlowchart', path: '/tools/pdf-flowchart', icon: GitBranch, bgColor: 'bg-indigo-100 dark:bg-indigo-900/30', iconColor: 'text-indigo-600 dark:text-indigo-400' },
]; ];
/** Image tools available when an image is uploaded */ /** Image tools available when an image is uploaded */

View File

@@ -1,9 +1,14 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import path from 'path'; import path from 'path';
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
},
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),