0.5.1
App upload
This commit is contained in:
parent
8080b9de7d
commit
ee77b2989f
22 changed files with 1774 additions and 0 deletions
38
Dockerfile
Normal file
38
Dockerfile
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Stage 1: Build the frontend
|
||||
FROM node:16-alpine as frontend-build
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN npm install
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build the backend
|
||||
FROM python:3.9-slim
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
poppler-utils \
|
||||
tesseract-ocr \
|
||||
ghostscript \
|
||||
libffi-dev \
|
||||
libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install OCRmyPDF
|
||||
RUN pip install --no-cache-dir ocrmypdf
|
||||
|
||||
# Copy backend code
|
||||
COPY backend/ /app/backend/
|
||||
|
||||
# Copy built frontend assets into the backend's static folder
|
||||
COPY --from=frontend-build /app/frontend/build/ /app/backend/static/
|
||||
|
||||
# Set working directory to backend and install Python dependencies
|
||||
WORKDIR /app/backend
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
EXPOSE 5015
|
||||
CMD ["python", "app.py"]
|
||||
81
README.md
Normal file
81
README.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<center>
|
||||
<img src="logo.png" alt="ocrAI Logo" width="150">
|
||||
</center>
|
||||
|
||||
# ocrAI 🤖
|
||||
|
||||
ocrAI is a unified web application that combines Optical Character Recognition (OCR) and Artificial Intelligence (AI) to process and translate documents, offering a simple, intuitive interface with real-time feedback (even with emojis!).
|
||||
|
||||
## Key Features
|
||||
|
||||
- **File Management** 📤
|
||||
- Upload PDF or image files using drag & drop or manual selection.
|
||||
- Files are saved with unique names to avoid overwrites.
|
||||
- The "Delete All Files" button removes all files from both the "uploads" and "outputs" folders.
|
||||
|
||||
- **OCR Processing Modes** 🔍
|
||||
- **OCR (Tesseract Only):**
|
||||
Extracts text with Tesseract and embeds it into the PDF using OCRmyPDF. The TXT file contains the raw OCR output.
|
||||
- **OCR + AI (Tesseract + AI):**
|
||||
Uses Tesseract to extract text and then sends it to an AI model (e.g., Gemini) to correct and format the content. The TXT file shows the corrected and structured text, while the PDF retains the original Tesseract output.
|
||||
- **AI (Full AI OCR):**
|
||||
Leverages the AI model's OCR capabilities to process the document page by page. The TXT file includes clear page markers, making it easy to compare with the original document, and the original PDF is preserved.
|
||||
- All modes display real-time progress updates with emojis (e.g., 📤, ✅, 🤖, 🎉) and run in the background.
|
||||
|
||||
- **Translation** 🌐
|
||||
- Translates PDF or TXT documents page by page.
|
||||
- You can upload a new file or select one from the list of processed files.
|
||||
- Progress updates are displayed, and a TXT file with the final translation (including page markers) is generated.
|
||||
|
||||
- **Configuration** ⚙️
|
||||
- Manage and add new AI models (including the ability to add or delete Gemini models) and languages.
|
||||
- Update or add custom prompts for OCR, correction, and translation functions.
|
||||
- Download or upload the complete configuration (which includes prompts and models).
|
||||
|
||||
## How to Use the Application
|
||||
|
||||
1. **Upload and Process Files:**
|
||||
- Go to the **OCR** tab.
|
||||
- Select your file (PDF or image).
|
||||
- Choose one of the processing modes:
|
||||
- **OCR** (Tesseract Only)
|
||||
- **OCR + AI** (Tesseract + AI for correction)
|
||||
- **AI** (Full AI OCR)
|
||||
- Select the desired prompt.
|
||||
- Click **Upload and process** and watch the real-time progress.
|
||||
|
||||
2. **Translate Documents:**
|
||||
- Go to the **Translation** tab.
|
||||
- Upload a new file or select one from the list of processed files.
|
||||
- Choose the target language and translation prompt.
|
||||
- Click **Translate** and observe the progress as each page is processed.
|
||||
- The result is saved in a TXT file with page markers.
|
||||
|
||||
3. **View Processed Files:**
|
||||
- Go to the **Processed Files** tab.
|
||||
- Download or delete files (with confirmation prompts).
|
||||
|
||||
4. **Configure the Application:**
|
||||
- Go to the **Configurations** tab.
|
||||
- Add, edit, or delete custom prompts.
|
||||
- Manage Gemini models: add new models or delete existing ones.
|
||||
- Configure languages and download or upload the complete configuration.
|
||||
|
||||
## How to Run ocrAI
|
||||
|
||||
### Prerequisites
|
||||
- Docker
|
||||
- Docker Compose
|
||||
|
||||
### Build and Run
|
||||
|
||||
```bash
|
||||
docker-compose up --build
|
||||
Then, open your browser at http://localhost:5015 to start using ocrAI.
|
||||
|
||||
Technologies Used
|
||||
Frontend: React, Axios
|
||||
Backend: Flask, Python
|
||||
OCR: Tesseract, pdf2image, OCRmyPDF
|
||||
AI: OpenAI, Gemini, Mistral APIs
|
||||
Containerization: Docker, Docker Compose
|
||||
BIN
backend/.DS_Store
vendored
Normal file
BIN
backend/.DS_Store
vendored
Normal file
Binary file not shown.
257
backend/app.py
Normal file
257
backend/app.py
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
# backend/app.py
|
||||
import os
|
||||
import uuid
|
||||
import threading
|
||||
import json
|
||||
from flask import Flask, request, jsonify, send_from_directory
|
||||
from flask_cors import CORS
|
||||
from werkzeug.utils import secure_filename
|
||||
from models import get_models, add_model, get_languages, update_prompt, get_prompt, add_language, delete_prompt, custom_prompts, default_prompts
|
||||
from utils import process_file, translate_file_by_pages, convert_txt_to_pdf
|
||||
import time
|
||||
|
||||
app = Flask(__name__, static_folder="static", static_url_path="")
|
||||
CORS(app)
|
||||
|
||||
UPLOAD_FOLDER = "uploads"
|
||||
OUTPUT_FOLDER = "outputs"
|
||||
|
||||
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||
os.makedirs(OUTPUT_FOLDER, exist_ok=True)
|
||||
|
||||
# Global dictionary for background jobs
|
||||
active_jobs = {} # job_id: {"progress": int, "status": str, "cancelled": bool, "result": any}
|
||||
|
||||
def update_progress(job_id, progress, status):
|
||||
active_jobs[job_id]["progress"] = progress
|
||||
active_jobs[job_id]["status"] = status
|
||||
|
||||
def is_cancelled(job_id):
|
||||
return active_jobs[job_id]["cancelled"]
|
||||
|
||||
def run_processing(job_id, file_path, api, model, mode, prompt_key):
|
||||
try:
|
||||
result = process_file(
|
||||
file_path, api, model, mode, prompt_key,
|
||||
update_progress=lambda prog, stat: update_progress(job_id, prog, stat),
|
||||
is_cancelled=lambda: is_cancelled(job_id)
|
||||
)
|
||||
active_jobs[job_id]["result"] = result
|
||||
update_progress(job_id, 100, "🎉 Process completed")
|
||||
except Exception as e:
|
||||
update_progress(job_id, active_jobs[job_id]["progress"], f"❌ Error: {str(e)}")
|
||||
|
||||
def run_translation(job_id, file_path, api, model, target_language, prompt_key):
|
||||
try:
|
||||
result = translate_file_by_pages(
|
||||
file_path, api, model, target_language, prompt_key,
|
||||
update_progress=lambda prog, stat: update_progress(job_id, prog, stat),
|
||||
is_cancelled=lambda: is_cancelled(job_id)
|
||||
)
|
||||
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
translation_file = os.path.join(OUTPUT_FOLDER, base_name + "_translation.txt")
|
||||
with open(translation_file, "w", encoding="utf-8") as f:
|
||||
f.write(result)
|
||||
active_jobs[job_id]["result"] = translation_file
|
||||
update_progress(job_id, 100, "🎉 Process completed")
|
||||
except Exception as e:
|
||||
update_progress(job_id, active_jobs[job_id]["progress"], f"❌ Error: {str(e)}")
|
||||
|
||||
@app.route('/api/upload', methods=['POST'])
|
||||
def upload_file():
|
||||
if 'file' not in request.files:
|
||||
return jsonify({"error": "No file found"}), 400
|
||||
|
||||
file = request.files['file']
|
||||
api = request.form.get('api')
|
||||
model = request.form.get('model')
|
||||
mode = request.form.get('mode') # "OCR", "OCR + AI" or "AI"
|
||||
prompt_key = request.form.get('prompt_key')
|
||||
|
||||
if file.filename == '':
|
||||
return jsonify({"error": "Empty filename"}), 400
|
||||
|
||||
filename = secure_filename(file.filename)
|
||||
if os.path.exists(os.path.join(UPLOAD_FOLDER, filename)):
|
||||
base, ext = os.path.splitext(filename)
|
||||
filename = f"{base}_{uuid.uuid4().hex}{ext}"
|
||||
file_path = os.path.join(UPLOAD_FOLDER, filename)
|
||||
file.save(file_path)
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
active_jobs[job_id] = {"progress": 0, "status": "📤 File uploaded", "cancelled": False, "result": None}
|
||||
|
||||
thread = threading.Thread(target=run_processing, args=(job_id, file_path, api, model, mode, prompt_key))
|
||||
thread.start()
|
||||
|
||||
return jsonify({"message": "File uploaded, processing started", "job_id": job_id})
|
||||
|
||||
@app.route('/api/progress/<job_id>', methods=['GET'])
|
||||
def get_progress(job_id):
|
||||
if job_id in active_jobs:
|
||||
return jsonify({
|
||||
"progress": active_jobs[job_id]["progress"],
|
||||
"status": active_jobs[job_id]["status"],
|
||||
"result": active_jobs[job_id]["result"]
|
||||
})
|
||||
else:
|
||||
return jsonify({"error": "Job not found"}), 404
|
||||
|
||||
@app.route('/api/stop/<job_id>', methods=['POST'])
|
||||
def stop_job(job_id):
|
||||
if job_id in active_jobs:
|
||||
active_jobs[job_id]["cancelled"] = True
|
||||
update_progress(job_id, active_jobs[job_id]["progress"], "⏹️ Cancelled")
|
||||
return jsonify({"message": "Job cancellation requested"})
|
||||
else:
|
||||
return jsonify({"error": "Job not found"}), 404
|
||||
|
||||
@app.route('/api/models', methods=['GET'])
|
||||
def models():
|
||||
api = request.args.get('api')
|
||||
if api:
|
||||
models_list = get_models(api)
|
||||
return jsonify({"models": models_list})
|
||||
else:
|
||||
return jsonify({"error": "Must specify API"}), 400
|
||||
|
||||
@app.route('/api/languages', methods=['GET'])
|
||||
def languages():
|
||||
langs = get_languages()
|
||||
return jsonify({"languages": langs})
|
||||
|
||||
@app.route('/api/add-model', methods=['POST'])
|
||||
def add_new_model():
|
||||
data = request.get_json()
|
||||
api = data.get("api")
|
||||
model = data.get("model")
|
||||
if not api or not model:
|
||||
return jsonify({"error": "Missing api or model"}), 400
|
||||
add_model(api, model)
|
||||
return jsonify({"message": f"Model {model} added for {api}"}), 200
|
||||
|
||||
# Nuevo endpoint para eliminar un modelo
|
||||
@app.route('/api/delete-model', methods=['DELETE'])
|
||||
def delete_model_endpoint():
|
||||
data = request.get_json()
|
||||
api_name = data.get("api")
|
||||
model_name = data.get("model")
|
||||
if not api_name or not model_name:
|
||||
return jsonify({"error": "Missing api or model"}), 400
|
||||
from models import delete_model
|
||||
if delete_model(api_name, model_name):
|
||||
return jsonify({"message": f"Model {model_name} deleted from {api_name}."}), 200
|
||||
else:
|
||||
return jsonify({"error": "Model not found."}), 404
|
||||
|
||||
@app.route('/api/prompts', methods=['GET'])
|
||||
def get_prompts_endpoint():
|
||||
prompts = {}
|
||||
prompts.update(default_prompts)
|
||||
from models import custom_prompts
|
||||
prompts.update(custom_prompts)
|
||||
return jsonify({"prompts": prompts})
|
||||
|
||||
@app.route('/api/prompts', methods=['POST'])
|
||||
def update_prompts_endpoint():
|
||||
data = request.get_json()
|
||||
key = data.get("key")
|
||||
new_prompt = data.get("prompt")
|
||||
if not key or not new_prompt:
|
||||
return jsonify({"error": "Missing key or prompt"}), 400
|
||||
update_prompt(key, new_prompt)
|
||||
return jsonify({"message": f"Prompt for '{key}' updated."})
|
||||
|
||||
@app.route('/api/prompts/<key>', methods=['DELETE'])
|
||||
def delete_prompt_endpoint(key):
|
||||
if delete_prompt(key):
|
||||
return jsonify({"message": f"Prompt '{key}' deleted."})
|
||||
else:
|
||||
return jsonify({"error": "Prompt not found or cannot be deleted."}), 404
|
||||
|
||||
@app.route('/api/files', methods=['GET'])
|
||||
def list_files():
|
||||
files = os.listdir(OUTPUT_FOLDER)
|
||||
return jsonify({"files": files})
|
||||
|
||||
@app.route('/api/files/<filename>', methods=['GET'])
|
||||
def download_file(filename):
|
||||
return send_from_directory(OUTPUT_FOLDER, filename, as_attachment=True)
|
||||
|
||||
@app.route('/api/files/<filename>', methods=['DELETE'])
|
||||
def delete_file(filename):
|
||||
file_path = os.path.join(OUTPUT_FOLDER, filename)
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
return jsonify({"message": "File deleted"}), 200
|
||||
else:
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
|
||||
# Endpoint modificado: borrar todos los archivos tanto de la carpeta outputs como de uploads.
|
||||
@app.route('/api/files/all', methods=['DELETE'])
|
||||
def delete_all_files():
|
||||
try:
|
||||
# Borrar archivos de OUTPUT_FOLDER
|
||||
output_files = os.listdir(OUTPUT_FOLDER)
|
||||
for file in output_files:
|
||||
file_path = os.path.join(OUTPUT_FOLDER, file)
|
||||
os.remove(file_path)
|
||||
# Borrar archivos de UPLOAD_FOLDER
|
||||
upload_files = os.listdir(UPLOAD_FOLDER)
|
||||
for file in upload_files:
|
||||
file_path = os.path.join(UPLOAD_FOLDER, file)
|
||||
os.remove(file_path)
|
||||
return jsonify({"message": "All files in outputs and uploads deleted"}), 200
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/config', methods=['GET'])
|
||||
def download_config():
|
||||
from models import custom_prompts, available_models
|
||||
config = {
|
||||
"custom_prompts": custom_prompts,
|
||||
"available_models": available_models
|
||||
}
|
||||
return jsonify(config)
|
||||
|
||||
@app.route('/api/config', methods=['POST'])
|
||||
def upload_config():
|
||||
if 'config' not in request.files:
|
||||
return jsonify({"error": "No config file provided"}), 400
|
||||
file = request.files['config']
|
||||
try:
|
||||
config_data = json.load(file)
|
||||
from models import custom_prompts, available_models
|
||||
custom_prompts.clear()
|
||||
custom_prompts.update(config_data.get("custom_prompts", {}))
|
||||
available_models.clear()
|
||||
available_models.update(config_data.get("available_models", {}))
|
||||
return jsonify({"message": "Configuration updated successfully"}), 200
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
@app.route('/api/txttopdf', methods=['POST'])
|
||||
def txt_to_pdf_endpoint():
|
||||
data = request.get_json()
|
||||
filename = data.get("filename")
|
||||
if not filename:
|
||||
return jsonify({"error": "Missing filename parameter"}), 400
|
||||
txt_path = os.path.join(OUTPUT_FOLDER, filename)
|
||||
if not os.path.exists(txt_path):
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
try:
|
||||
pdf_path = convert_txt_to_pdf(txt_path)
|
||||
return jsonify({"message": "TXT to PDF conversion completed", "pdf_file": os.path.basename(pdf_path)})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/', defaults={'path': ''})
|
||||
@app.route('/<path:path>')
|
||||
def serve(path):
|
||||
if path != "" and os.path.exists(os.path.join(app.static_folder, path)):
|
||||
return send_from_directory(app.static_folder, path)
|
||||
else:
|
||||
return send_from_directory(app.static_folder, 'index.html')
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=5015)
|
||||
53
backend/models.py
Normal file
53
backend/models.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# backend/models.py
|
||||
default_prompts = {
|
||||
"ocr_correction": "Correct the following text. Begin your answer directly with the corrected text. Format the result as HTML, indicating where each page begins, each paragraph begins, and marking titles with the appropriate HTML tags (e.g., <h1>, <h2>, etc.).",
|
||||
"ocr": "Perform OCR on the following document. Begin your answer directly with the OCR. Format the result as HTML, indicating where each page begins, each paragraph begins, and marking titles with the appropriate HTML tags (e.g., <h1>, <h2>, etc.).",
|
||||
"translation": "Translate the following text to Spanish. Do not add any commentary; only output the translated text. Begin your answer directly with the translation. Format the result as HTML, indicating where each page begins, each paragraph begins, and marking titles with the appropriate HTML tags (e.g., <h1>, <h2>, etc.)."
|
||||
}
|
||||
|
||||
custom_prompts = {}
|
||||
|
||||
def get_prompt(key):
|
||||
return custom_prompts.get(key, default_prompts.get(key, ""))
|
||||
|
||||
def update_prompt(key, new_prompt):
|
||||
custom_prompts[key] = new_prompt
|
||||
|
||||
def delete_prompt(key):
|
||||
if key in custom_prompts:
|
||||
del custom_prompts[key]
|
||||
return True
|
||||
elif key in default_prompts:
|
||||
custom_prompts[key] = ""
|
||||
return True
|
||||
return False
|
||||
|
||||
# Solo se usará Gemini.
|
||||
available_models = {
|
||||
"Gemini": ["gemini-2.0-flash"]
|
||||
}
|
||||
|
||||
def get_models(api_name):
|
||||
return available_models.get(api_name, [])
|
||||
|
||||
def add_model(api_name, model_name):
|
||||
if api_name in available_models:
|
||||
if model_name not in available_models[api_name]:
|
||||
available_models[api_name].append(model_name)
|
||||
else:
|
||||
available_models[api_name] = [model_name]
|
||||
|
||||
def delete_model(api, model):
|
||||
if api in available_models and model in available_models[api]:
|
||||
available_models[api].remove(model)
|
||||
return True
|
||||
return False
|
||||
|
||||
available_languages = ["Spanish", "English", "French", "Italian", "German", "Portuguese"]
|
||||
|
||||
def get_languages():
|
||||
return available_languages
|
||||
|
||||
def add_language(language):
|
||||
if language not in available_languages:
|
||||
available_languages.append(language)
|
||||
9
backend/requirements.txt
Normal file
9
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
Flask
|
||||
flask-cors
|
||||
pytesseract
|
||||
Pillow
|
||||
pdf2image
|
||||
google-genai
|
||||
openai
|
||||
mistralai
|
||||
beautifulsoup4
|
||||
BIN
backend/static/logo.png
Normal file
BIN
backend/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 590 KiB |
358
backend/utils.py
Normal file
358
backend/utils.py
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
# backend/utils.py
|
||||
import os
|
||||
import time
|
||||
import pytesseract
|
||||
from PIL import Image
|
||||
from pdf2image import convert_from_path
|
||||
import shutil
|
||||
import asyncio
|
||||
import uuid
|
||||
import subprocess
|
||||
import base64
|
||||
import re
|
||||
from models import get_prompt
|
||||
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.lib.enums import TA_JUSTIFY, TA_CENTER
|
||||
|
||||
# Se agrega BeautifulSoup para procesar HTML
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
UPLOAD_FOLDER = "uploads"
|
||||
OUTPUT_FOLDER = "outputs"
|
||||
|
||||
def encode_image(file_path):
|
||||
with open(file_path, "rb") as f:
|
||||
return base64.b64encode(f.read()).decode("utf-8")
|
||||
|
||||
def run_tesseract(file_path):
|
||||
"""
|
||||
If the file is a PDF, perform OCR page by page and add a "Page X:" header;
|
||||
otherwise, perform OCR normally.
|
||||
"""
|
||||
extracted_text = ""
|
||||
if file_path.lower().endswith(".pdf"):
|
||||
try:
|
||||
pages = convert_from_path(file_path)
|
||||
for i, page in enumerate(pages, start=1):
|
||||
page_text = pytesseract.image_to_string(page, lang='eng')
|
||||
extracted_text += f"Page {i}:\n{page_text}\n\n"
|
||||
except Exception as e:
|
||||
extracted_text = f"❌ Error processing PDF: {str(e)}"
|
||||
else:
|
||||
image = Image.open(file_path)
|
||||
extracted_text = pytesseract.image_to_string(image, lang='eng')
|
||||
return extracted_text
|
||||
|
||||
def call_api_correction(api, model, text, prompt_key="ocr_correction"):
|
||||
prompt = get_prompt(prompt_key) + text
|
||||
try:
|
||||
from google import genai
|
||||
except ImportError:
|
||||
raise ImportError("Please install 'google-genai' to use Gemini.")
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY"))
|
||||
response = client.models.generate_content(model=model, contents=[prompt])
|
||||
return response.text
|
||||
|
||||
def call_api_ocr(api, model, file_path, prompt_key="ocr"):
|
||||
try:
|
||||
from google import genai
|
||||
except ImportError:
|
||||
raise ImportError("Please install 'google-genai' to use Gemini.")
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY"))
|
||||
file_ref = client.files.upload(file=file_path)
|
||||
prompt = get_prompt(prompt_key)
|
||||
response = client.models.generate_content(model=model, contents=[file_ref, prompt])
|
||||
return response.text
|
||||
|
||||
def call_api_translation(api, model, text, target_language, prompt_key="translation"):
|
||||
prompt_template = get_prompt(prompt_key)
|
||||
prompt = prompt_template.format(target_language=target_language) + text
|
||||
try:
|
||||
from google import genai
|
||||
except ImportError:
|
||||
raise ImportError("Please install 'google-genai' to use Gemini.")
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY"))
|
||||
response = client.models.generate_content(model=model, contents=[prompt])
|
||||
return response.text
|
||||
|
||||
def embed_ocr_in_pdf(input_pdf, output_pdf):
|
||||
try:
|
||||
subprocess.run(["ocrmypdf", input_pdf, output_pdf], check=True)
|
||||
return True
|
||||
except Exception as e:
|
||||
return False
|
||||
|
||||
def ocr_file_by_pages(file_path, api, model, prompt_key, update_progress, is_cancelled):
|
||||
final_text = ""
|
||||
if file_path.lower().endswith(".pdf"):
|
||||
try:
|
||||
pages = convert_from_path(file_path)
|
||||
except Exception as e:
|
||||
return f"❌ Error processing PDF: {str(e)}"
|
||||
total = len(pages)
|
||||
for i, page in enumerate(pages, start=1):
|
||||
if is_cancelled():
|
||||
update_progress(0, "⏹️ Cancelled")
|
||||
return "Process cancelled."
|
||||
temp_filename = os.path.join(OUTPUT_FOLDER, f"temp_page_{uuid.uuid4().hex}.png")
|
||||
page.save(temp_filename, "PNG")
|
||||
page_text = call_api_ocr(api, model, temp_filename, prompt_key)
|
||||
final_text += f"Page {i}:\n{page_text}\n\n"
|
||||
os.remove(temp_filename)
|
||||
progress = int((i / total) * 100)
|
||||
update_progress(progress, f"📄 Processed page {i} of {total}.")
|
||||
time.sleep(1)
|
||||
return final_text
|
||||
else:
|
||||
return call_api_ocr(api, model, file_path, prompt_key)
|
||||
|
||||
def translate_file_by_pages(file_path, api, model, target_language, prompt_key, update_progress, is_cancelled):
|
||||
final_translation = ""
|
||||
if file_path.lower().endswith(".pdf"):
|
||||
try:
|
||||
pages = convert_from_path(file_path)
|
||||
except Exception as e:
|
||||
return f"❌ Error processing PDF: {str(e)}"
|
||||
total = len(pages)
|
||||
for i, page in enumerate(pages, start=1):
|
||||
if is_cancelled():
|
||||
update_progress(0, "⏹️ Cancelled")
|
||||
return "Process cancelled."
|
||||
temp_filename = os.path.join(OUTPUT_FOLDER, f"temp_page_{uuid.uuid4().hex}.png")
|
||||
page.save(temp_filename, "PNG")
|
||||
page_text = pytesseract.image_to_string(page, lang='eng')
|
||||
translated_page = call_api_translation(api, model, page_text, target_language, prompt_key)
|
||||
final_translation += f"Page {i}:\n{translated_page}\n\n"
|
||||
os.remove(temp_filename)
|
||||
progress = int((i / total) * 100)
|
||||
update_progress(progress, f"📄 Processed page {i} of {total}.")
|
||||
time.sleep(1)
|
||||
return final_translation
|
||||
elif file_path.lower().endswith(".txt"):
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
translated = call_api_translation(api, model, text, target_language, prompt_key)
|
||||
update_progress(100, "🎉 Process completed")
|
||||
return f"Page 1:\n{translated}"
|
||||
else:
|
||||
return "Unsupported file type for translation."
|
||||
|
||||
def process_file(file_path, api, model, mode, prompt_key, update_progress, is_cancelled):
|
||||
if is_cancelled():
|
||||
update_progress(0, "⏹️ Cancelled")
|
||||
return "Process cancelled."
|
||||
update_progress(25, "📤 File uploaded.")
|
||||
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
|
||||
if mode == "OCR":
|
||||
# Process using tesseract with page-structure if PDF.
|
||||
processed_text = run_tesseract(file_path)
|
||||
if is_cancelled():
|
||||
update_progress(25, "⏹️ Cancelled")
|
||||
return "Process cancelled."
|
||||
update_progress(50, "✅ Tesseract OCR completed.")
|
||||
# Generate PDF copy as before.
|
||||
pdf_output = os.path.join(OUTPUT_FOLDER, base_name + "_ocr.pdf")
|
||||
if file_path.lower().endswith(".pdf"):
|
||||
if embed_ocr_in_pdf(file_path, pdf_output):
|
||||
update_progress(95, "📄 OCR embedded into PDF.")
|
||||
else:
|
||||
shutil.copy(file_path, pdf_output)
|
||||
update_progress(95, "⚠️ Failed to embed OCR; original PDF copied.")
|
||||
else:
|
||||
shutil.copy(file_path, pdf_output)
|
||||
elif mode == "OCR + AI":
|
||||
processed_text = run_tesseract(file_path)
|
||||
if is_cancelled():
|
||||
update_progress(25, "⏹️ Cancelled")
|
||||
return "Process cancelled."
|
||||
update_progress(50, "✅ Tesseract OCR completed.")
|
||||
processed_text = call_api_correction(api, model, processed_text, prompt_key)
|
||||
pdf_output = os.path.join(OUTPUT_FOLDER, base_name + "_ocr.pdf")
|
||||
if file_path.lower().endswith(".pdf"):
|
||||
if embed_ocr_in_pdf(file_path, pdf_output):
|
||||
update_progress(95, "📄 OCR embedded into PDF.")
|
||||
else:
|
||||
shutil.copy(file_path, pdf_output)
|
||||
update_progress(95, "⚠️ Failed to embed OCR; original PDF copied.")
|
||||
else:
|
||||
shutil.copy(file_path, pdf_output)
|
||||
elif mode == "AI":
|
||||
update_progress(25, "📂 File ready for full AI processing.")
|
||||
if file_path.lower().endswith(".pdf"):
|
||||
processed_text = ocr_file_by_pages(file_path, api, model, prompt_key, update_progress, is_cancelled)
|
||||
else:
|
||||
processed_text = call_api_ocr(api, model, file_path, prompt_key)
|
||||
# In AI mode, do not generate a new PDF.
|
||||
else:
|
||||
processed_text = "Unrecognized processing mode."
|
||||
update_progress(25, "❌ Error: Unrecognized mode.")
|
||||
|
||||
if is_cancelled():
|
||||
update_progress(75, "⏹️ Cancelled")
|
||||
return "Process cancelled."
|
||||
|
||||
update_progress(75, "🤖 API processing completed.")
|
||||
|
||||
# Write the output TXT file (it is already structured by page in OCR and OCR+AI modes)
|
||||
txt_file = os.path.join(OUTPUT_FOLDER, base_name + ".txt")
|
||||
with open(txt_file, "w", encoding="utf-8") as f:
|
||||
f.write(processed_text)
|
||||
update_progress(100, "🎉 Process completed")
|
||||
return processed_text
|
||||
|
||||
def process_text(text):
|
||||
lines = text.splitlines()
|
||||
processed_lines = []
|
||||
buffer = ""
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
if buffer:
|
||||
processed_lines.append(buffer)
|
||||
buffer = ""
|
||||
else:
|
||||
processed_lines.append("")
|
||||
continue
|
||||
if buffer:
|
||||
if buffer.endswith('.'):
|
||||
processed_lines.append(buffer)
|
||||
buffer = stripped
|
||||
else:
|
||||
buffer += " " + stripped
|
||||
else:
|
||||
buffer = stripped
|
||||
if buffer:
|
||||
processed_lines.append(buffer)
|
||||
return "\n".join(processed_lines)
|
||||
|
||||
def convert_txt_to_pdf(txt_file_path):
|
||||
"""
|
||||
Se ha modificado para que, si el contenido del TXT está formateado en HTML,
|
||||
se interpreten los tags que indiquen títulos (<h1>, <h2>, etc.), párrafos (<p>)
|
||||
y saltos de página (<pagebreak> o div con clase "page-break").
|
||||
|
||||
En caso de que el contenido sea texto plano y contenga patrones en el formato
|
||||
[Page X] (entre corchetes), se usará ese separador para dividir las páginas.
|
||||
El encabezado (sin los corchetes) se incluirá en la parte superior de cada página.
|
||||
"""
|
||||
with open(txt_file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
base_name = os.path.splitext(os.path.basename(txt_file_path))[0]
|
||||
output_pdf = os.path.join(OUTPUT_FOLDER, base_name + "_txt.pdf")
|
||||
doc = SimpleDocTemplate(output_pdf, pagesize=A4,
|
||||
rightMargin=40, leftMargin=40,
|
||||
topMargin=40, bottomMargin=40)
|
||||
styles = getSampleStyleSheet()
|
||||
# Estilos para encabezados
|
||||
header_styles = {
|
||||
"h1": ParagraphStyle('Heading1', parent=styles['Heading1'], alignment=TA_CENTER),
|
||||
"h2": ParagraphStyle('Heading2', parent=styles['Heading2'], alignment=TA_CENTER),
|
||||
"h3": ParagraphStyle('Heading3', parent=styles['Heading3'], alignment=TA_CENTER),
|
||||
"h4": ParagraphStyle('Heading4', parent=styles['Heading4'], alignment=TA_CENTER),
|
||||
"h5": ParagraphStyle('Heading5', parent=styles['Heading5'], alignment=TA_CENTER),
|
||||
"h6": ParagraphStyle('Heading6', parent=styles['Heading6'], alignment=TA_CENTER),
|
||||
}
|
||||
normal_style = ParagraphStyle(
|
||||
'Normal',
|
||||
parent=styles['Normal'],
|
||||
alignment=TA_JUSTIFY,
|
||||
leading=15,
|
||||
leftIndent=20 # Sangría al inicio de cada párrafo
|
||||
)
|
||||
|
||||
flowables = []
|
||||
# Detectamos si el contenido es HTML (buscando etiquetas comunes)
|
||||
is_html = any(tag in content.lower() for tag in ["<html", "<p", "<h1", "<h2", "<h3"])
|
||||
|
||||
if is_html:
|
||||
soup = BeautifulSoup(content, "html.parser")
|
||||
body = soup.body if soup.body else soup
|
||||
for element in body.children:
|
||||
if element.name is None:
|
||||
text = element.strip()
|
||||
if text:
|
||||
para = Paragraph(text, normal_style)
|
||||
flowables.append(para)
|
||||
flowables.append(Spacer(1, 12))
|
||||
elif element.name.lower() in ["h1", "h2", "h3", "h4", "h5", "h6"]:
|
||||
tag = element.name.lower()
|
||||
style = header_styles.get(tag, styles['Heading1'])
|
||||
para = Paragraph(element.decode_contents(), style)
|
||||
flowables.append(para)
|
||||
flowables.append(Spacer(1, 12))
|
||||
elif element.name.lower() == "p":
|
||||
para = Paragraph(element.decode_contents(), normal_style)
|
||||
flowables.append(para)
|
||||
flowables.append(Spacer(1, 12))
|
||||
elif element.name.lower() == "pagebreak" or (element.name.lower() == "div" and "page-break" in element.get("class", [])):
|
||||
flowables.append(PageBreak())
|
||||
else:
|
||||
text = element.get_text().strip()
|
||||
if text:
|
||||
para = Paragraph(text, normal_style)
|
||||
flowables.append(para)
|
||||
flowables.append(Spacer(1, 12))
|
||||
else:
|
||||
# Si se detecta el patrón [Page X] en el contenido, se usa para dividir las páginas.
|
||||
if re.search(r'\[Page\s+\d+\]', content, re.IGNORECASE):
|
||||
# Dividir incluyendo el separador (usamos grupo de captura)
|
||||
parts = re.split(r'(\[Page\s+\d+\])', content, flags=re.IGNORECASE)
|
||||
current_header = ""
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
# Si es un encabezado [Page X]
|
||||
if re.match(r'\[Page\s+\d+\]', part, re.IGNORECASE):
|
||||
# Extraemos el número o texto sin los corchetes para usarlo como encabezado
|
||||
current_header = part.strip("[]")
|
||||
header_para = Paragraph(current_header, styles['Heading1'])
|
||||
flowables.append(header_para)
|
||||
flowables.append(Spacer(1, 12))
|
||||
else:
|
||||
# Es el contenido de la página
|
||||
processed = process_text(part)
|
||||
para = Paragraph(processed.replace("\n", "<br/>"), normal_style)
|
||||
flowables.append(para)
|
||||
flowables.append(Spacer(1, 12))
|
||||
flowables.append(PageBreak())
|
||||
else:
|
||||
# Si no se detectan separadores, se usa el método anterior basado en "Page X:" sin corchetes.
|
||||
pages = re.split(r'(?i)Page\s+\d+:\s*', content)
|
||||
if pages and pages[0].strip() == "":
|
||||
pages = pages[1:]
|
||||
if len(pages) <= 1:
|
||||
pages = content.split("\n\n")
|
||||
processed_pages = [process_text(page) for page in pages if page.strip() != ""]
|
||||
for i, page_text in enumerate(processed_pages, start=1):
|
||||
header = Paragraph(f"PAGE {i}", styles['Heading1'])
|
||||
flowables.append(header)
|
||||
flowables.append(Spacer(1, 12))
|
||||
para = Paragraph(page_text.replace("\n", "<br/>"), normal_style)
|
||||
flowables.append(para)
|
||||
if i < len(processed_pages):
|
||||
flowables.append(Spacer(1, 24))
|
||||
flowables.append(PageBreak())
|
||||
|
||||
doc.build(flowables)
|
||||
return output_pdf
|
||||
11
docker-compose.yml
Normal file
11
docker-compose.yml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
services:
|
||||
webapp:
|
||||
build: .
|
||||
container_name: ocrai
|
||||
ports:
|
||||
- "5015:5015"
|
||||
volumes:
|
||||
- ./backend/uploads:/app/backend/uploads
|
||||
- ./backend/outputs:/app/backend/outputs
|
||||
environment:
|
||||
- GEMINI_API_KEY=your_api_key
|
||||
BIN
frontend/.DS_Store
vendored
Normal file
BIN
frontend/.DS_Store
vendored
Normal file
Binary file not shown.
17
frontend/package.json
Normal file
17
frontend/package.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "ocrai-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-scripts": "5.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
}
|
||||
}
|
||||
13
frontend/public/index.html
Normal file
13
frontend/public/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>ocrAI</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
124
frontend/src/App.css
Normal file
124
frontend/src/App.css
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/* frontend/src/App.css */
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Header: Logo and title centered */
|
||||
.app-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-logo {
|
||||
height: 100px; /* Aumenta el tamaño del logo */
|
||||
}
|
||||
|
||||
.app-title {
|
||||
margin-top: 10px;
|
||||
font-size: 2em;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Navigation styling */
|
||||
.app-nav {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tab-processing {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
background-color: #ADD8E6;
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.tab-default {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
background-color: #ddd;
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* Nueva regla para la pestaña TXT to PDF con color distinto (light purple) */
|
||||
.tab-txttopdf {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
background-color: #dda0dd;
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.app-nav button.active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
main {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* Processed Files grouping and text wrapping */
|
||||
.file-group {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.file-group-title {
|
||||
word-wrap: break-word;
|
||||
margin: 0 0 10px 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.file-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.button-download {
|
||||
background-color: green;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button-delete {
|
||||
background-color: pink;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 600px) {
|
||||
.app-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
.app-nav {
|
||||
flex-direction: column;
|
||||
}
|
||||
.file-group {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
66
frontend/src/App.js
Normal file
66
frontend/src/App.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// frontend/src/App.js
|
||||
import React, { useState } from 'react';
|
||||
import FileUpload from './components/FileUpload';
|
||||
import FileList from './components/FileList';
|
||||
import Configurations from './components/Configurations';
|
||||
import TxtToPdf from './components/TxtToPdf';
|
||||
import Notifications from './components/Notifications';
|
||||
|
||||
function App() {
|
||||
const [activeTab, setActiveTab] = useState('ocrAI');
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
|
||||
const handleJobCompleted = (notification) => {
|
||||
setNotifications(prev => [...prev, notification]);
|
||||
};
|
||||
|
||||
const clearNotifications = () => {
|
||||
setNotifications([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<header className="app-header">
|
||||
<div className="logo-container">
|
||||
<img src="/logo.png" alt="Logo" className="app-logo" />
|
||||
<h1 className="app-title">ocrAI</h1>
|
||||
</div>
|
||||
</header>
|
||||
<nav className="app-nav">
|
||||
<button
|
||||
onClick={() => setActiveTab('ocrAI')}
|
||||
className={activeTab === 'ocrAI' ? 'active tab-processing' : 'tab-processing'}
|
||||
>
|
||||
💡 ocrAI
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('files')}
|
||||
className={activeTab === 'files' ? 'active tab-default' : 'tab-default'}
|
||||
>
|
||||
📁 Processed Files
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('configurations')}
|
||||
className={activeTab === 'configurations' ? 'active tab-default' : 'tab-default'}
|
||||
>
|
||||
⚙️ Configurations
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('txttopdf')}
|
||||
className={activeTab === 'txttopdf' ? 'active tab-txttopdf' : 'tab-txttopdf'}
|
||||
>
|
||||
📝 TXT to PDF
|
||||
</button>
|
||||
</nav>
|
||||
<main>
|
||||
{activeTab === 'ocrAI' && <FileUpload onJobCompleted={handleJobCompleted} />}
|
||||
{activeTab === 'files' && <FileList />}
|
||||
{activeTab === 'configurations' && <Configurations />}
|
||||
{activeTab === 'txttopdf' && <TxtToPdf />}
|
||||
</main>
|
||||
<Notifications notifications={notifications} onClear={clearNotifications} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
256
frontend/src/components/Configurations.js
Normal file
256
frontend/src/components/Configurations.js
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
// frontend/src/components/Configurations.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
function Configurations() {
|
||||
// ----- ESTADOS PARA PROMPTS -----
|
||||
const [newPromptKey, setNewPromptKey] = useState('');
|
||||
const [newPromptText, setNewPromptText] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [prompts, setPrompts] = useState({});
|
||||
|
||||
// ----- ESTADOS PARA MODELOS GEMINI -----
|
||||
const [models, setModels] = useState([]); // lista de modelos actuales
|
||||
const [newModel, setNewModel] = useState(''); // nombre del modelo que vamos a añadir
|
||||
|
||||
// ----- ESTADOS PARA SUBIR/DESCARGAR CONFIG -----
|
||||
const [configFile, setConfigFile] = useState(null);
|
||||
|
||||
// ----- FUNCIONALIDAD DE PROMPTS -----
|
||||
const fetchPrompts = () => {
|
||||
axios.get(`${API_URL}/prompts`)
|
||||
.then(response => setPrompts(response.data.prompts))
|
||||
.catch(err => console.error(err));
|
||||
};
|
||||
|
||||
const handleAddPrompt = (e) => {
|
||||
e.preventDefault();
|
||||
if (!newPromptKey || !newPromptText) {
|
||||
setMessage("⚠️ Please fill in both key and prompt text.");
|
||||
return;
|
||||
}
|
||||
axios.post(`${API_URL}/prompts`, { key: newPromptKey, prompt: newPromptText })
|
||||
.then(response => {
|
||||
setMessage(response.data.message);
|
||||
setNewPromptKey('');
|
||||
setNewPromptText('');
|
||||
fetchPrompts();
|
||||
})
|
||||
.catch(err => {
|
||||
setMessage("❌ Error adding prompt.");
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeletePrompt = (key) => {
|
||||
if (window.confirm("Are you sure you want to delete this prompt?")) {
|
||||
axios.delete(`${API_URL}/prompts/${key}`)
|
||||
.then(response => {
|
||||
setMessage(response.data.message);
|
||||
fetchPrompts();
|
||||
})
|
||||
.catch(err => {
|
||||
setMessage("❌ Error deleting prompt.");
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ----- FUNCIONALIDAD DE MODELOS GEMINI -----
|
||||
const fetchModels = () => {
|
||||
axios.get(`${API_URL}/models?api=Gemini`)
|
||||
.then(response => {
|
||||
setModels(response.data.models || []);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("❌ Error fetching models:", err);
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddModel = (e) => {
|
||||
e.preventDefault();
|
||||
if (!newModel.trim()) {
|
||||
setMessage("⚠️ Please enter a model name.");
|
||||
return;
|
||||
}
|
||||
axios.post(`${API_URL}/add-model`, { api: "Gemini", model: newModel.trim() })
|
||||
.then(response => {
|
||||
setMessage(response.data.message);
|
||||
setNewModel('');
|
||||
fetchModels();
|
||||
})
|
||||
.catch(err => {
|
||||
setMessage("❌ Error adding model.");
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteModel = (modelName) => {
|
||||
if (window.confirm(`Are you sure you want to delete model ${modelName}?`)) {
|
||||
axios.delete(`${API_URL}/delete-model`, { data: { api: "Gemini", model: modelName } })
|
||||
.then(response => {
|
||||
setMessage(response.data.message);
|
||||
fetchModels();
|
||||
})
|
||||
.catch(err => {
|
||||
setMessage("❌ Error deleting model.");
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ----- FUNCIONALIDAD DE CONFIGURACIÓN (DOWNLOAD / UPLOAD) -----
|
||||
const handleDownloadConfig = () => {
|
||||
axios.get(`${API_URL}/config`)
|
||||
.then(response => {
|
||||
const data = response.data;
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'config.json';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
})
|
||||
.catch(err => {
|
||||
setMessage("❌ Error downloading config.");
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfigFileChange = (e) => {
|
||||
setConfigFile(e.target.files[0]);
|
||||
};
|
||||
|
||||
const handleUploadConfig = () => {
|
||||
if (!configFile) {
|
||||
setMessage("⚠️ Please select a config file to upload.");
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append("config", configFile);
|
||||
axios.post(`${API_URL}/config`, formData, { headers: { "Content-Type": "multipart/form-data" } })
|
||||
.then(response => {
|
||||
setMessage(response.data.message);
|
||||
fetchPrompts();
|
||||
fetchModels();
|
||||
})
|
||||
.catch(err => {
|
||||
setMessage("❌ Error uploading config.");
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
// ----- useEffect -----
|
||||
useEffect(() => {
|
||||
fetchPrompts();
|
||||
fetchModels();
|
||||
}, []);
|
||||
|
||||
// ----- RENDER -----
|
||||
return (
|
||||
<div style={{ textAlign: 'left', maxWidth: '600px', margin: '0 auto' }}>
|
||||
<h2>Configurations ⚙️</h2>
|
||||
|
||||
{/* Sección: Prompts */}
|
||||
<div style={{ border: '1px solid #ccc', padding: '15px', borderRadius: '8px', marginBottom: '20px' }}>
|
||||
<h3>Add / Edit Prompt</h3>
|
||||
<form onSubmit={handleAddPrompt}>
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<label>
|
||||
Prompt Key:
|
||||
<input
|
||||
type="text"
|
||||
value={newPromptKey}
|
||||
onChange={(e) => setNewPromptKey(e.target.value)}
|
||||
placeholder="e.g., custom_prompt"
|
||||
style={{ marginLeft: '10px', width: '100%' }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<label>
|
||||
Prompt Text:
|
||||
<textarea
|
||||
value={newPromptText}
|
||||
onChange={(e) => setNewPromptText(e.target.value)}
|
||||
placeholder="Enter the prompt text"
|
||||
style={{ marginLeft: '10px', width: '100%' }}
|
||||
rows="4"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit">Save Prompt</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style={{ border: '1px solid #ccc', padding: '15px', borderRadius: '8px', marginBottom: '20px' }}>
|
||||
<h3>Current Prompts</h3>
|
||||
{Object.keys(prompts).length === 0 ? (
|
||||
<p>No prompts available.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||
{Object.entries(prompts).map(([key, text]) => (
|
||||
<li key={key} style={{ marginBottom: '5px', display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span><strong>{key}:</strong> {text}</span>
|
||||
<button onClick={() => handleDeletePrompt(key)} style={{ fontSize: '12px', padding: '2px 5px' }}>
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sección: Modelos Gemini */}
|
||||
<div style={{ border: '1px solid #ccc', padding: '15px', borderRadius: '8px', marginBottom: '20px' }}>
|
||||
<h3>Gemini Models</h3>
|
||||
{models.length === 0 ? (
|
||||
<p>No Gemini models found.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||
{models.map((model, i) => (
|
||||
<li key={i} style={{ marginBottom: '5px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>{model}</span>
|
||||
<button onClick={() => handleDeleteModel(model)} style={{ fontSize: '12px', padding: '2px 5px' }}>
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<form onSubmit={handleAddModel} style={{ marginTop: '10px' }}>
|
||||
<label>
|
||||
New model name:
|
||||
<input
|
||||
type="text"
|
||||
value={newModel}
|
||||
onChange={(e) => setNewModel(e.target.value)}
|
||||
placeholder="Enter new model name"
|
||||
style={{ marginLeft: '10px', width: '60%' }}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" style={{ marginLeft: '10px' }}>Add Model</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Sección: Download / Upload Config */}
|
||||
<div style={{ marginBottom: '20px', border: '1px solid #ccc', padding: '15px', borderRadius: '8px' }}>
|
||||
<h3>Download / Upload Configuration</h3>
|
||||
<button onClick={handleDownloadConfig}>Download Config</button>
|
||||
<div style={{ marginTop: '10px' }}>
|
||||
<input type="file" onChange={handleConfigFileChange} />
|
||||
<button onClick={handleUploadConfig} style={{ marginLeft: '10px' }}>Upload Config</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && <p>{message}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Configurations;
|
||||
94
frontend/src/components/FileList.js
Normal file
94
frontend/src/components/FileList.js
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// frontend/src/components/FileList.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
function FileList() {
|
||||
const [files, setFiles] = useState([]);
|
||||
|
||||
const fetchFiles = () => {
|
||||
axios.get(`${API_URL}/files`)
|
||||
.then(response => setFiles(response.data.files))
|
||||
.catch(err => console.error(err));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchFiles();
|
||||
}, []);
|
||||
|
||||
const handleDownload = (filename) => {
|
||||
window.location.href = `${API_URL}/files/${filename}`;
|
||||
};
|
||||
|
||||
const handleDelete = (filename) => {
|
||||
if (window.confirm("Are you sure you want to delete this file?")) {
|
||||
axios.delete(`${API_URL}/files/${filename}`)
|
||||
.then(response => fetchFiles())
|
||||
.catch(err => console.error(err));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAll = () => {
|
||||
if (window.confirm("Are you sure you want to delete ALL files? This action cannot be undone.")) {
|
||||
axios.delete(`${API_URL}/files/all`)
|
||||
.then(response => fetchFiles())
|
||||
.catch(err => console.error(err));
|
||||
}
|
||||
};
|
||||
|
||||
// Group files by base name (without extension)
|
||||
const groupedFiles = files.reduce((groups, file) => {
|
||||
const base = file.replace(/\.[^.]+$/, '');
|
||||
if (!groups[base]) {
|
||||
groups[base] = [];
|
||||
}
|
||||
groups[base].push(file);
|
||||
return groups;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div style={{ textAlign: 'left', maxWidth: '600px', margin: '0 auto' }}>
|
||||
<h2>Processed Files</h2>
|
||||
{Object.keys(groupedFiles).length === 0 ? (
|
||||
<p>No files available.</p>
|
||||
) : (
|
||||
<>
|
||||
{Object.keys(groupedFiles).map(base => (
|
||||
<div key={base} className="file-group">
|
||||
<h3 className="file-group-title">{base}</h3>
|
||||
<div className="file-buttons">
|
||||
{groupedFiles[base].map((file, index) => (
|
||||
<div key={index}>
|
||||
<button onClick={() => handleDownload(file)} className="button-download">
|
||||
📥
|
||||
</button>
|
||||
<button onClick={() => handleDelete(file)} className="button-delete">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={handleDeleteAll}
|
||||
style={{
|
||||
marginTop: '20px',
|
||||
backgroundColor: 'red',
|
||||
color: 'white',
|
||||
padding: '10px',
|
||||
border: 'none',
|
||||
borderRadius: '5px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Delete All Files
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileList;
|
||||
198
frontend/src/components/FileUpload.js
Normal file
198
frontend/src/components/FileUpload.js
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
// frontend/src/components/FileUpload.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import ProgressBar from './ProgressBar';
|
||||
import ModelSelector from './ModelSelector';
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
function FileUpload({ onJobCompleted }) {
|
||||
// Se establece la API por defecto en "Gemini"
|
||||
const [file, setFile] = useState(null);
|
||||
const [api, setApi] = useState('Gemini');
|
||||
const [model, setModel] = useState('');
|
||||
const [mode, setMode] = useState('OCR + AI'); // Opciones: "OCR", "OCR + AI" o "AI"
|
||||
const [promptKey, setPromptKey] = useState('');
|
||||
const [availablePrompts, setAvailablePrompts] = useState({});
|
||||
const [models, setModels] = useState([]);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [message, setMessage] = useState('');
|
||||
const [jobId, setJobId] = useState(null);
|
||||
|
||||
// No se ofrece opción para seleccionar otra API ya que sólo se usa Gemini.
|
||||
useEffect(() => {
|
||||
axios.get(`${API_URL}/models`, { params: { api } })
|
||||
.then(response => {
|
||||
setModels(response.data.models);
|
||||
if (response.data.models.length > 0) {
|
||||
setModel(response.data.models[0]);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error(err));
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
axios.get(`${API_URL}/prompts`)
|
||||
.then(response => setAvailablePrompts(response.data.prompts))
|
||||
.catch(err => console.error(err));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (jobId) {
|
||||
const interval = setInterval(() => {
|
||||
axios.get(`${API_URL}/progress/${jobId}`)
|
||||
.then(response => {
|
||||
const data = response.data;
|
||||
setUploadProgress(data.progress);
|
||||
setMessage(data.status);
|
||||
if (data.progress === 100 || data.status.includes("Cancelled") || data.status.includes("Error")) {
|
||||
clearInterval(interval);
|
||||
onJobCompleted && onJobCompleted("Processing job completed");
|
||||
setJobId(null);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error(err));
|
||||
}, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [jobId, onJobCompleted]);
|
||||
|
||||
const handleFileChange = (e) => setFile(e.target.files[0]);
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
setFile(e.dataTransfer.files[0]);
|
||||
e.dataTransfer.clearData();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e) => e.preventDefault();
|
||||
|
||||
const handleStop = () => {
|
||||
if (jobId) {
|
||||
axios.post(`${API_URL}/stop/${jobId}`)
|
||||
.then(response => {
|
||||
setMessage("⏹️ Process stopped by user");
|
||||
setJobId(null);
|
||||
})
|
||||
.catch(err => console.error(err));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (!file) {
|
||||
setMessage("⚠️ Please select a file.");
|
||||
return;
|
||||
}
|
||||
if (mode === "OCR") {
|
||||
setPromptKey('');
|
||||
} else if (!promptKey) {
|
||||
setMessage("⚠️ Please select a prompt.");
|
||||
return;
|
||||
}
|
||||
setMessage("🚀 Uploading file...");
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
// La API siempre es "Gemini"
|
||||
formData.append("api", api);
|
||||
formData.append("model", model);
|
||||
formData.append("mode", mode);
|
||||
formData.append("prompt_key", promptKey);
|
||||
|
||||
axios.post(`${API_URL}/upload`, formData, { headers: { "Content-Type": "multipart/form-data" } })
|
||||
.then(response => {
|
||||
setMessage("📤 File uploaded. Processing started.");
|
||||
setJobId(response.data.job_id);
|
||||
})
|
||||
.catch(err => {
|
||||
setMessage("❌ Error uploading file.");
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit} className="upload-form">
|
||||
<div
|
||||
className="drop-zone"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
style={{
|
||||
border: '2px dashed #ccc',
|
||||
padding: '20px',
|
||||
borderRadius: '5px',
|
||||
marginBottom: '10px'
|
||||
}}
|
||||
>
|
||||
{file ? <p>📄 {file.name}</p> : <p>📂 Drag and drop the file here or click to select</p>}
|
||||
<input type="file" onChange={handleFileChange} style={{ display: 'none' }} id="fileInput" />
|
||||
<label htmlFor="fileInput" style={{ cursor: 'pointer', color: 'blue' }}>Select file</label>
|
||||
</div>
|
||||
<div className="selectors" style={{ marginBottom: '10px' }}>
|
||||
{/* No se muestra opción de seleccionar otra API */}
|
||||
<ModelSelector models={models} selectedModel={model} setSelectedModel={setModel} />
|
||||
</div>
|
||||
<div className="mode-selector" style={{ marginBottom: '10px' }}>
|
||||
<p>Processing mode:</p>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
value="OCR"
|
||||
checked={mode === 'OCR'}
|
||||
onChange={(e) => setMode(e.target.value)}
|
||||
/> OCR
|
||||
</label>
|
||||
<label style={{ marginLeft: '20px' }}>
|
||||
<input
|
||||
type="radio"
|
||||
value="OCR + AI"
|
||||
checked={mode === 'OCR + AI'}
|
||||
onChange={(e) => setMode(e.target.value)}
|
||||
/> OCR + AI
|
||||
</label>
|
||||
<label style={{ marginLeft: '20px' }}>
|
||||
<input
|
||||
type="radio"
|
||||
value="AI"
|
||||
checked={mode === 'AI'}
|
||||
onChange={(e) => setMode(e.target.value)}
|
||||
/> AI
|
||||
</label>
|
||||
</div>
|
||||
<div className="prompt-selector" style={{ marginBottom: '10px' }}>
|
||||
<label>
|
||||
{mode === "OCR" ? "Prompt not required for OCR mode" : "Select Prompt:"}
|
||||
<select
|
||||
value={promptKey}
|
||||
onChange={(e) => setPromptKey(e.target.value)}
|
||||
style={{ marginLeft: '10px' }}
|
||||
disabled={mode === "OCR"}
|
||||
>
|
||||
<option value="">-- Select Prompt --</option>
|
||||
{Object.keys(availablePrompts).map(key => (
|
||||
<option key={key} value={key}>{key}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit">Upload and process</button>
|
||||
{jobId && (
|
||||
<button type="button" onClick={handleStop} style={{ marginLeft: '10px' }}>
|
||||
Stop Process
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
{jobId && (
|
||||
<div>
|
||||
<ProgressBar progress={uploadProgress} />
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
)}
|
||||
{!jobId && <p>{message}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileUpload;
|
||||
17
frontend/src/components/ModelSelector.js
Normal file
17
frontend/src/components/ModelSelector.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// frontend/src/components/ModelSelector.js
|
||||
import React from 'react';
|
||||
|
||||
function ModelSelector({ models, selectedModel, setSelectedModel }) {
|
||||
return (
|
||||
<label style={{ marginLeft: '10px' }}>
|
||||
Model:
|
||||
<select value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)} style={{ marginLeft: '10px' }}>
|
||||
{models.map((model, index) => (
|
||||
<option key={index} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModelSelector;
|
||||
66
frontend/src/components/Notifications.js
Normal file
66
frontend/src/components/Notifications.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// frontend/src/components/Notifications.js
|
||||
import React, { useState } from 'react';
|
||||
|
||||
function Notifications({ notifications, onClear }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const toggleOpen = () => setIsOpen(!isOpen);
|
||||
const unseenCount = notifications.length;
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', bottom: '10px', right: '10px' }}>
|
||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<button
|
||||
onClick={toggleOpen}
|
||||
style={{ fontSize: '24px', background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
🔔
|
||||
{unseenCount > 0 && (
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
top: '-5px',
|
||||
right: '-5px',
|
||||
background: 'red',
|
||||
color: 'white',
|
||||
borderRadius: '50%',
|
||||
padding: '2px 6px',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
{unseenCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
bottom: '40px',
|
||||
width: '300px',
|
||||
maxHeight: '400px',
|
||||
overflowY: 'auto',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '8px',
|
||||
padding: '10px',
|
||||
backgroundColor: '#fff',
|
||||
boxShadow: '0 2px 5px rgba(0,0,0,0.3)'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h4 style={{ margin: 0 }}>Notifications</h4>
|
||||
<button onClick={onClear} style={{ fontSize: '12px', padding: '2px 5px' }}>Clear</button>
|
||||
</div>
|
||||
{notifications.length === 0 ? (
|
||||
<p>No notifications.</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||
{notifications.map((note, index) => (
|
||||
<li key={index} style={{ marginBottom: '5px' }}>{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Notifications;
|
||||
37
frontend/src/components/ProgressBar.js
Normal file
37
frontend/src/components/ProgressBar.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// frontend/src/components/ProgressBar.js
|
||||
import React from 'react';
|
||||
|
||||
function ProgressBar({ progress }) {
|
||||
const containerStyle = {
|
||||
height: '20px',
|
||||
width: '100%',
|
||||
backgroundColor: '#e0e0de',
|
||||
borderRadius: '50px',
|
||||
margin: '10px 0'
|
||||
};
|
||||
|
||||
const fillerStyle = {
|
||||
height: '100%',
|
||||
width: `${progress}%`,
|
||||
backgroundColor: progress === 100 ? 'green' : '#76c7c0',
|
||||
borderRadius: 'inherit',
|
||||
textAlign: 'right',
|
||||
transition: 'width 0.5s ease-in-out'
|
||||
};
|
||||
|
||||
const labelStyle = {
|
||||
padding: '5px',
|
||||
color: 'white',
|
||||
fontWeight: 'bold'
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={fillerStyle}>
|
||||
<span style={labelStyle}>{`${progress}%`}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProgressBar;
|
||||
68
frontend/src/components/TxtToPdf.js
Normal file
68
frontend/src/components/TxtToPdf.js
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// frontend/src/components/TxtToPdf.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
function TxtToPdf() {
|
||||
const [txtFiles, setTxtFiles] = useState([]);
|
||||
const [selectedFile, setSelectedFile] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [pdfFile, setPdfFile] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
// Obtener la lista de archivos y filtrar solo los .txt
|
||||
axios.get(`${API_URL}/files`)
|
||||
.then(response => {
|
||||
const files = response.data.files.filter(file => file.toLowerCase().endsWith('.txt'));
|
||||
setTxtFiles(files);
|
||||
})
|
||||
.catch(err => console.error(err));
|
||||
}, []);
|
||||
|
||||
const handleConversion = () => {
|
||||
if (!selectedFile) {
|
||||
setMessage("⚠️ Please select a TXT file.");
|
||||
return;
|
||||
}
|
||||
axios.post(`${API_URL}/txttopdf`, { filename: selectedFile })
|
||||
.then(response => {
|
||||
setMessage(response.data.message);
|
||||
setPdfFile(response.data.pdf_file);
|
||||
})
|
||||
.catch(err => {
|
||||
setMessage("❌ Error converting TXT to PDF.");
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ textAlign: 'left', maxWidth: '600px', margin: '0 auto' }}>
|
||||
<h2>TXT to PDF</h2>
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<label>
|
||||
Select TXT File:
|
||||
<select
|
||||
value={selectedFile}
|
||||
onChange={(e) => setSelectedFile(e.target.value)}
|
||||
style={{ marginLeft: '10px' }}
|
||||
>
|
||||
<option value="">-- Select TXT File --</option>
|
||||
{txtFiles.map((file, index) => (
|
||||
<option key={index} value={file}>{file}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<button onClick={handleConversion}>Convert to PDF</button>
|
||||
{message && <p>{message}</p>}
|
||||
{pdfFile && (
|
||||
<p>
|
||||
Download PDF: <a href={`${API_URL}/files/${pdfFile}`} download>{pdfFile}</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TxtToPdf;
|
||||
11
frontend/src/index.js
Normal file
11
frontend/src/index.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './App.css';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
Loading…
Add table
Reference in a new issue