From 11d715eb856cf3f40fc80a0b7d7110f9e5467225 Mon Sep 17 00:00:00 2001 From: Ashim Kumar Date: Fri, 9 Jan 2026 21:06:30 +0600 Subject: [PATCH] first commit --- .dockerignore | 70 ++ .env.example | 11 + .gitignore | 80 ++ Dockerfile | 51 + app.py | 1778 +++++++++++++++++++++++++++++++ coolify.json | 26 + doc.py | 256 +++++ docker-compose.yml | 30 + reader_templates/Reader.html | 503 +++++++++ reader_templates/index.html | 496 +++++++++ requirements.txt | 10 + static/css/style.css | 760 +++++++++++++ static/js/app.js | 893 ++++++++++++++++ static/js/editor.js | 1070 +++++++++++++++++++ static/js/interactive-reader.js | 270 +++++ static/js/timeline.js | 460 ++++++++ templates/admin.html | 703 ++++++++++++ templates/index.html | 493 +++++++++ templates/login.html | 275 +++++ 19 files changed, 8235 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app.py create mode 100644 coolify.json create mode 100644 doc.py create mode 100644 docker-compose.yml create mode 100755 reader_templates/Reader.html create mode 100755 reader_templates/index.html create mode 100644 requirements.txt create mode 100644 static/css/style.css create mode 100644 static/js/app.js create mode 100644 static/js/editor.js create mode 100644 static/js/interactive-reader.js create mode 100644 static/js/timeline.js create mode 100644 templates/admin.html create mode 100644 templates/index.html create mode 100644 templates/login.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..277e53e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,70 @@ +# Git +.git +.gitignore +.gitattributes + +# Python +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +venv/ +.venv/ +ENV/ +env/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# Local environment +.env +.env.local +*.local + +# Database (will be mounted from volume) +*.db +*.sqlite +*.sqlite3 + +# Logs +*.log +logs/ + +# OS +.DS_Store +Thumbs.db + +# Documentation +README.md +docs/ +*.md + +# Docker +Dockerfile +docker-compose*.yml +.docker/ \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0eb91ef --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Flask Configuration +SECRET_KEY=your-super-secret-key-change-this-in-production +FLASK_ENV=production + +# Database Configuration +# For production with Coolify, this will be mounted as a volume +DATABASE_DIR=/opt/apps/audiobook-studio-pro-v3 + +# TTS API Configuration +TTS_API_URL=http://your-tts-api-server:5010/api/v1 +TTS_API_KEY=your-tts-api-key-here \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df48c28 --- /dev/null +++ b/.gitignore @@ -0,0 +1,80 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +venv/ +.venv/ +ENV/ +env/ +.env + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ +.project +.pydevproject +.settings/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ + +# Database files (will be created at runtime) +*.db +*.sqlite +*.sqlite3 +audio_editor.db + +# Logs +*.log +logs/ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Local configuration +.env +.env.local +*.local +config.local.py + +# Docker +.docker/ + +# Temporary files +tmp/ +temp/ +*.tmp +*.temp \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b5588a3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +# Use Python 3.11 slim image for smaller size +FROM python:3.11-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV FLASK_APP=app.py +ENV FLASK_ENV=production +ENV DATABASE_DIR=/app/data + +# Set working directory +WORKDIR /app + +# Install system dependencies including ffmpeg for pydub +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg \ + libsndfile1 \ + curl \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get clean + +# Copy requirements first for better caching +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create the default database directory inside the container +# This will be used if no volume is mounted +RUN mkdir -p /app/data && chmod 777 /app/data + +# Also create the external mount point directory +RUN mkdir -p /opt/apps/audiobook-studio-pro-v3 && chmod 777 /opt/apps/audiobook-studio-pro-v3 + +# Expose port +EXPOSE 5009 + +# Health check - with longer start period to allow DB initialization +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:5009/health || exit 1 + +# Use entrypoint script to ensure directory exists +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] +CMD ["gunicorn", "--bind", "0.0.0.0:5009", "--workers", "2", "--threads", "4", "--timeout", "300", "--access-logfile", "-", "--error-logfile", "-", "app:app"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..ca47413 --- /dev/null +++ b/app.py @@ -0,0 +1,1778 @@ +import os +import json +import uuid +import base64 +import sqlite3 +import io +import zipfile +from datetime import datetime +from functools import wraps +from contextlib import contextmanager + +# --- ENV SETUP --- +from dotenv import load_dotenv +load_dotenv() + +import requests +from flask import Flask, request, jsonify, send_from_directory, send_file, g, session, redirect, url_for, render_template + +app = Flask(__name__, static_folder='static', static_url_path='/static') + +# --- APP CONFIG --- +# Database path - use environment variable or default to /app/data for container +DATABASE_DIR = os.getenv('DATABASE_DIR', '/app/data') + +# Ensure database directory exists with proper error handling +def ensure_db_directory(): + """Ensure database directory exists and is writable.""" + global DATABASE_DIR + + # Try the configured directory first + try: + os.makedirs(DATABASE_DIR, exist_ok=True) + # Test if we can write + test_file = os.path.join(DATABASE_DIR, '.write_test') + with open(test_file, 'w') as f: + f.write('test') + os.remove(test_file) + print(f"✅ Database directory ready: {DATABASE_DIR}") + return True + except (PermissionError, OSError) as e: + print(f"⚠️ Cannot use {DATABASE_DIR}: {e}") + + # Fallback to /app/data + fallback_dir = '/app/data' + try: + os.makedirs(fallback_dir, exist_ok=True) + test_file = os.path.join(fallback_dir, '.write_test') + with open(test_file, 'w') as f: + f.write('test') + os.remove(test_file) + DATABASE_DIR = fallback_dir + print(f"✅ Using fallback database directory: {DATABASE_DIR}") + return True + except (PermissionError, OSError) as e: + print(f"⚠️ Cannot use fallback {fallback_dir}: {e}") + + # Last resort: current directory + fallback_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data') + try: + os.makedirs(fallback_dir, exist_ok=True) + DATABASE_DIR = fallback_dir + print(f"✅ Using local data directory: {DATABASE_DIR}") + return True + except Exception as e: + print(f"❌ All directory attempts failed: {e}") + return False + +# Initialize database directory +ensure_db_directory() + +DATABASE = os.path.join(DATABASE_DIR, 'audio_editor.db') + +app.secret_key = os.getenv('SECRET_KEY', 'your-secret-key-change-in-production-' + str(uuid.uuid4())) + +# --- API CONFIG --- +TTS_API_URL = os.getenv('TTS_API_URL', 'http://localhost:5010/api/v1') +TTS_API_KEY = os.getenv('TTS_API_KEY', '') + +# --- READER TEMPLATE FILES --- +READER_TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), 'reader_templates') + +def get_api_headers(): + """Get headers for API requests.""" + return { + 'X-API-Key': TTS_API_KEY + } + +# --- AUDIO CONVERSION --- +def convert_to_mp3(audio_base64, source_format='wav'): + """Convert audio from any format to MP3 base64.""" + try: + from pydub import AudioSegment + + audio_bytes = base64.b64decode(audio_base64) + audio_buffer = io.BytesIO(audio_bytes) + audio = AudioSegment.from_file(audio_buffer, format=source_format) + + mp3_buffer = io.BytesIO() + audio.export(mp3_buffer, format='mp3', bitrate='192k') + mp3_buffer.seek(0) + + mp3_base64 = base64.b64encode(mp3_buffer.read()).decode('utf-8') + return mp3_base64 + except ImportError: + print("⚠️ pydub not installed, returning original format") + return audio_base64 + except Exception as e: + print(f"⚠️ MP3 conversion failed: {e}, returning original") + return audio_base64 + +# --- DATABASE SETUP --- +def get_db(): + """Get database connection for current request.""" + if 'db' not in g: + g.db = sqlite3.connect(DATABASE) + g.db.row_factory = sqlite3.Row + return g.db + +@app.teardown_appcontext +def close_db(error): + """Close database connection at end of request.""" + db = g.pop('db', None) + if db is not None: + db.close() + +@contextmanager +def get_db_connection(): + """Context manager for database connections outside request context.""" + conn = sqlite3.connect(DATABASE) + conn.row_factory = sqlite3.Row + try: + yield conn + finally: + conn.close() + +def init_db(): + """Initialize database tables.""" + with get_db_connection() as conn: + cursor = conn.cursor() + + # Users table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + is_admin INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_login TIMESTAMP + ) + ''') + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS uploads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + filename TEXT NOT NULL, + audio_data TEXT NOT NULL, + audio_format TEXT DEFAULT 'mp3', + text_content TEXT NOT NULL, + transcription TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + ''') + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(user_id, name) + ) + ''') + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS project_sections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + chapter INTEGER NOT NULL, + section INTEGER NOT NULL, + audio_data TEXT, + audio_format TEXT DEFAULT 'mp3', + text_content TEXT NOT NULL, + html_content TEXT, + tts_text TEXT, + transcription TEXT, + voice TEXT DEFAULT 'af_heart', + image_data TEXT, + image_format TEXT DEFAULT 'png', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + UNIQUE(project_id, chapter, section) + ) + ''') + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS generations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + name TEXT, + audio_data TEXT NOT NULL, + audio_format TEXT DEFAULT 'mp3', + text_content TEXT NOT NULL, + transcription TEXT, + voice TEXT DEFAULT 'af_heart', + speed REAL DEFAULT 1.0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + ''') + + conn.commit() + + # Create default admin user if not exists + cursor.execute("SELECT id FROM users WHERE username = 'admin'") + if not cursor.fetchone(): + cursor.execute(''' + INSERT INTO users (username, password, is_admin) VALUES (?, ?, ?) + ''', ('admin', 'admin123', 1)) + conn.commit() + print("📦 Created default admin user (admin/admin123)") + + # Migration: Add user_id columns if they don't exist + migrations = [ + ("user_id", "uploads", "ALTER TABLE uploads ADD COLUMN user_id INTEGER DEFAULT 1"), + ("user_id", "projects", "ALTER TABLE projects ADD COLUMN user_id INTEGER DEFAULT 1"), + ("user_id", "generations", "ALTER TABLE generations ADD COLUMN user_id INTEGER DEFAULT 1"), + ("html_content", "project_sections", "ALTER TABLE project_sections ADD COLUMN html_content TEXT"), + ("tts_text", "project_sections", "ALTER TABLE project_sections ADD COLUMN tts_text TEXT"), + ("image_data", "project_sections", "ALTER TABLE project_sections ADD COLUMN image_data TEXT"), + ("image_format", "project_sections", "ALTER TABLE project_sections ADD COLUMN image_format TEXT DEFAULT 'png'"), + ] + + for col_name, table_name, sql in migrations: + try: + cursor.execute(f"SELECT {col_name} FROM {table_name} LIMIT 1") + except sqlite3.OperationalError: + print(f"📦 Adding {col_name} column to {table_name}...") + cursor.execute(sql) + conn.commit() + +def vacuum_db(): + """Run VACUUM to reclaim space after deletions.""" + with get_db_connection() as conn: + conn.execute('VACUUM') + +# Initialize database on startup +print(f"📁 Database directory: {DATABASE_DIR}") +print(f"📁 Database file: {DATABASE}") +init_db() + +# --- AUTHENTICATION DECORATORS --- + +def login_required(f): + """Decorator to require login for a route.""" + @wraps(f) + def decorated_function(*args, **kwargs): + if 'user_id' not in session: + if request.is_json or request.headers.get('Accept') == 'application/json': + return jsonify({'error': 'Authentication required'}), 401 + return redirect(url_for('login_page')) + return f(*args, **kwargs) + return decorated_function + +def admin_required(f): + """Decorator to require admin access for a route.""" + @wraps(f) + def decorated_function(*args, **kwargs): + if 'user_id' not in session: + if request.is_json or request.headers.get('Accept') == 'application/json': + return jsonify({'error': 'Authentication required'}), 401 + return redirect(url_for('login_page')) + if not session.get('is_admin'): + if request.is_json or request.headers.get('Accept') == 'application/json': + return jsonify({'error': 'Admin access required'}), 403 + return redirect(url_for('index')) + return f(*args, **kwargs) + return decorated_function + +def get_current_user_id(): + """Get the current logged-in user's ID.""" + return session.get('user_id') + +# --- AUTH ROUTES --- + +@app.route('/login', methods=['GET']) +def login_page(): + """Render login page.""" + if 'user_id' in session: + return redirect(url_for('index')) + return send_from_directory('templates', 'login.html') + +@app.route('/api/login', methods=['POST']) +def api_login(): + """Handle login API request.""" + data = request.json + username = data.get('username', '').strip() + password = data.get('password', '') + + if not username or not password: + return jsonify({'error': 'Username and password required'}), 400 + + db = get_db() + cursor = db.cursor() + cursor.execute('SELECT id, username, password, is_admin FROM users WHERE username = ?', (username,)) + user = cursor.fetchone() + + if not user or user['password'] != password: + return jsonify({'error': 'Invalid username or password'}), 401 + + # Update last login + cursor.execute('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?', (user['id'],)) + db.commit() + + # Set session + session['user_id'] = user['id'] + session['username'] = user['username'] + session['is_admin'] = bool(user['is_admin']) + + return jsonify({ + 'message': 'Login successful', + 'user': { + 'id': user['id'], + 'username': user['username'], + 'is_admin': bool(user['is_admin']) + } + }) + +@app.route('/api/logout', methods=['POST']) +def api_logout(): + """Handle logout API request.""" + session.clear() + return jsonify({'message': 'Logged out successfully'}) + +@app.route('/logout') +def logout(): + """Logout and redirect to login page.""" + session.clear() + return redirect(url_for('login_page')) + +@app.route('/api/me', methods=['GET']) +@login_required +def api_me(): + """Get current user info.""" + return jsonify({ + 'user': { + 'id': session.get('user_id'), + 'username': session.get('username'), + 'is_admin': session.get('is_admin', False) + } + }) + +# --- ADMIN ROUTES --- + +@app.route('/admin') +@admin_required +def admin_dashboard(): + """Render admin dashboard.""" + return send_from_directory('templates', 'admin.html') + +@app.route('/api/admin/users', methods=['GET']) +@admin_required +def list_users(): + """List all users.""" + db = get_db() + cursor = db.cursor() + cursor.execute(''' + SELECT u.id, u.username, u.is_admin, u.created_at, u.last_login, + (SELECT COUNT(*) FROM projects WHERE user_id = u.id) as project_count + FROM users u + ORDER BY u.created_at DESC + ''') + users = cursor.fetchall() + + return jsonify({ + 'users': [{ + 'id': u['id'], + 'username': u['username'], + 'is_admin': bool(u['is_admin']), + 'created_at': u['created_at'], + 'last_login': u['last_login'], + 'project_count': u['project_count'] + } for u in users] + }) + +@app.route('/api/admin/users', methods=['POST']) +@admin_required +def create_user(): + """Create a new user.""" + data = request.json + username = data.get('username', '').strip() + password = data.get('password', '') + is_admin = data.get('is_admin', False) + + if not username or not password: + return jsonify({'error': 'Username and password required'}), 400 + + if len(username) < 3: + return jsonify({'error': 'Username must be at least 3 characters'}), 400 + + if len(password) < 4: + return jsonify({'error': 'Password must be at least 4 characters'}), 400 + + db = get_db() + cursor = db.cursor() + + try: + cursor.execute(''' + INSERT INTO users (username, password, is_admin) VALUES (?, ?, ?) + ''', (username, password, 1 if is_admin else 0)) + db.commit() + + return jsonify({ + 'message': 'User created successfully', + 'user_id': cursor.lastrowid + }) + except sqlite3.IntegrityError: + return jsonify({'error': 'Username already exists'}), 400 + +@app.route('/api/admin/users/', methods=['PUT']) +@admin_required +def update_user(user_id): + """Update a user.""" + data = request.json + + db = get_db() + cursor = db.cursor() + + # Check if user exists + cursor.execute('SELECT id, username FROM users WHERE id = ?', (user_id,)) + user = cursor.fetchone() + if not user: + return jsonify({'error': 'User not found'}), 404 + + # Prevent modifying own admin status + if user_id == session.get('user_id') and 'is_admin' in data: + return jsonify({'error': 'Cannot modify your own admin status'}), 400 + + updates = [] + params = [] + + if 'username' in data and data['username'].strip(): + updates.append('username = ?') + params.append(data['username'].strip()) + + if 'password' in data and data['password']: + updates.append('password = ?') + params.append(data['password']) + + if 'is_admin' in data: + updates.append('is_admin = ?') + params.append(1 if data['is_admin'] else 0) + + if not updates: + return jsonify({'error': 'No updates provided'}), 400 + + params.append(user_id) + + try: + cursor.execute(f"UPDATE users SET {', '.join(updates)} WHERE id = ?", params) + db.commit() + return jsonify({'message': 'User updated successfully'}) + except sqlite3.IntegrityError: + return jsonify({'error': 'Username already exists'}), 400 + +@app.route('/api/admin/users/', methods=['DELETE']) +@admin_required +def delete_user(user_id): + """Delete a user.""" + # Prevent self-deletion + if user_id == session.get('user_id'): + return jsonify({'error': 'Cannot delete your own account'}), 400 + + db = get_db() + cursor = db.cursor() + + cursor.execute('SELECT id FROM users WHERE id = ?', (user_id,)) + if not cursor.fetchone(): + return jsonify({'error': 'User not found'}), 404 + + # Delete user and all related data (cascade) + cursor.execute('DELETE FROM project_sections WHERE project_id IN (SELECT id FROM projects WHERE user_id = ?)', (user_id,)) + cursor.execute('DELETE FROM projects WHERE user_id = ?', (user_id,)) + cursor.execute('DELETE FROM uploads WHERE user_id = ?', (user_id,)) + cursor.execute('DELETE FROM generations WHERE user_id = ?', (user_id,)) + cursor.execute('DELETE FROM users WHERE id = ?', (user_id,)) + db.commit() + + vacuum_db() + return jsonify({'message': 'User deleted successfully'}) + +@app.route('/api/admin/stats', methods=['GET']) +@admin_required +def admin_stats(): + """Get admin dashboard statistics.""" + db = get_db() + cursor = db.cursor() + + cursor.execute('SELECT COUNT(*) as count FROM users') + user_count = cursor.fetchone()['count'] + + cursor.execute('SELECT COUNT(*) as count FROM projects') + project_count = cursor.fetchone()['count'] + + cursor.execute('SELECT COUNT(*) as count FROM project_sections') + section_count = cursor.fetchone()['count'] + + cursor.execute('SELECT COUNT(*) as count FROM uploads') + upload_count = cursor.fetchone()['count'] + + cursor.execute('SELECT COUNT(*) as count FROM generations') + generation_count = cursor.fetchone()['count'] + + db_size = os.path.getsize(DATABASE) if os.path.exists(DATABASE) else 0 + + return jsonify({ + 'users': user_count, + 'projects': project_count, + 'sections': section_count, + 'uploads': upload_count, + 'generations': generation_count, + 'database_size_mb': round(db_size / (1024 * 1024), 2) + }) + +# --- ROUTES --- + +@app.route('/') +@login_required +def index(): + return send_from_directory('templates', 'index.html') + +@app.route('/static/') +def serve_static(filename): + """Serve static files (CSS, JS).""" + return send_from_directory('static', filename) + +@app.route('/api_status', methods=['GET']) +@login_required +def api_status(): + """Check API server status.""" + try: + response = requests.get(f"{TTS_API_URL}/health", timeout=10) + if response.status_code == 200: + health_data = response.json() + return jsonify({ + 'connected': True, + 'api_url': TTS_API_URL, + 'status': health_data.get('status'), + 'services': health_data.get('services'), + 'device': health_data.get('device') + }) + else: + return jsonify({ + 'connected': False, + 'api_url': TTS_API_URL, + 'error': f'Status code: {response.status_code}' + }) + except Exception as e: + return jsonify({ + 'connected': False, + 'api_url': TTS_API_URL, + 'error': str(e) + }) + +@app.route('/voices', methods=['GET']) +@login_required +def get_voices(): + """Get available voices from the API.""" + try: + response = requests.get( + f"{TTS_API_URL}/voices", + headers=get_api_headers(), + timeout=30 + ) + + if response.status_code == 200: + result = response.json() + return jsonify({'voices': result.get('voices', [])}) + else: + return jsonify({'voices': [ + {'id': 'af_heart', 'name': 'Heart (US Fem)'}, + {'id': 'af_bella', 'name': 'Bella (US Fem)'}, + {'id': 'am_adam', 'name': 'Adam (US Masc)'} + ]}) + except: + return jsonify({'voices': [ + {'id': 'af_heart', 'name': 'Heart (US Fem)'} + ]}) + +# --- UPLOAD HANDLING --- + +@app.route('/upload', methods=['POST']) +@login_required +def upload_files(): + """Upload audio and text files, get timestamps using the API.""" + if 'audioFile' not in request.files or 'txtFile' not in request.files: + return jsonify({'error': 'Missing files'}), 400 + + audio_file = request.files['audioFile'] + txt_file = request.files['txtFile'] + + if not audio_file or not txt_file: + return jsonify({'error': 'Invalid files'}), 400 + + audio_bytes = audio_file.read() + audio_base64 = base64.b64encode(audio_bytes).decode('utf-8') + source_format = audio_file.filename.rsplit('.', 1)[1].lower() if '.' in audio_file.filename else 'wav' + + text_content = txt_file.read().decode('utf-8') + + try: + print("⏳ Calling Timestamp API...") + + files = {'audio_file': (audio_file.filename, audio_bytes)} + data = {'text': text_content} + + response = requests.post( + f"{TTS_API_URL}/timestamp", + headers=get_api_headers(), + files=files, + data=data, + timeout=120 + ) + + if response.status_code != 200: + error_detail = response.json().get('error', 'Unknown error') + return jsonify({'error': f'API Error: {error_detail}'}), response.status_code + + result = response.json() + transcription_data = result.get('timestamps', []) + + if source_format != 'mp3': + print("🔄 Converting to MP3...") + audio_base64 = convert_to_mp3(audio_base64, source_format) + + db = get_db() + cursor = db.cursor() + cursor.execute(''' + INSERT INTO uploads (user_id, filename, audio_data, audio_format, text_content, transcription) + VALUES (?, ?, ?, ?, ?, ?) + ''', ( + get_current_user_id(), + audio_file.filename, + audio_base64, + 'mp3', + text_content, + json.dumps(transcription_data) + )) + db.commit() + upload_id = cursor.lastrowid + + print(f"✅ Upload saved with ID: {upload_id}") + + return jsonify({ + 'message': 'Success', + 'upload_id': upload_id, + 'audio_data': audio_base64, + 'audio_format': 'mp3', + 'text_content': text_content, + 'transcription': transcription_data + }) + + except requests.exceptions.RequestException as e: + return jsonify({'error': f'API connection error: {str(e)}'}), 500 + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'error': str(e)}), 500 + +@app.route('/uploads', methods=['GET']) +@login_required +def list_uploads(): + """List all uploaded files for current user.""" + db = get_db() + cursor = db.cursor() + cursor.execute(''' + SELECT id, filename, audio_format, created_at, + LENGTH(audio_data) as audio_size, + LENGTH(text_content) as text_size + FROM uploads + WHERE user_id = ? + ORDER BY created_at DESC + ''', (get_current_user_id(),)) + rows = cursor.fetchall() + + uploads = [] + for row in rows: + uploads.append({ + 'id': row['id'], + 'filename': row['filename'], + 'audio_format': row['audio_format'], + 'created_at': row['created_at'], + 'audio_size': row['audio_size'], + 'text_size': row['text_size'] + }) + + return jsonify({'uploads': uploads}) + +@app.route('/uploads/', methods=['GET']) +@login_required +def get_upload(upload_id): + """Get a specific upload.""" + db = get_db() + cursor = db.cursor() + cursor.execute('SELECT * FROM uploads WHERE id = ? AND user_id = ?', (upload_id, get_current_user_id())) + row = cursor.fetchone() + + if not row: + return jsonify({'error': 'Upload not found'}), 404 + + return jsonify({ + 'id': row['id'], + 'filename': row['filename'], + 'audio_data': row['audio_data'], + 'audio_format': row['audio_format'], + 'text_content': row['text_content'], + 'transcription': json.loads(row['transcription']) if row['transcription'] else [], + 'created_at': row['created_at'] + }) + +@app.route('/uploads/', methods=['DELETE']) +@login_required +def delete_upload(upload_id): + """Delete an upload.""" + db = get_db() + cursor = db.cursor() + cursor.execute('DELETE FROM uploads WHERE id = ? AND user_id = ?', (upload_id, get_current_user_id())) + db.commit() + + if cursor.rowcount == 0: + return jsonify({'error': 'Upload not found'}), 404 + + vacuum_db() + return jsonify({'message': 'Upload deleted successfully'}) + +@app.route('/uploads//download', methods=['GET']) +@login_required +def download_upload(upload_id): + """Download upload as zip file.""" + db = get_db() + cursor = db.cursor() + cursor.execute('SELECT * FROM uploads WHERE id = ? AND user_id = ?', (upload_id, get_current_user_id())) + row = cursor.fetchone() + + if not row: + return jsonify({'error': 'Upload not found'}), 404 + + zip_buffer = io.BytesIO() + base_name = row['filename'].rsplit('.', 1)[0] if '.' in row['filename'] else row['filename'] + + with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf: + audio_bytes = base64.b64decode(row['audio_data']) + zf.writestr(f"{base_name}.{row['audio_format']}", audio_bytes) + zf.writestr(f"{base_name}.txt", row['text_content']) + if row['transcription']: + zf.writestr(f"{base_name}.json", row['transcription']) + + zip_buffer.seek(0) + return send_file( + zip_buffer, + mimetype='application/zip', + as_attachment=True, + download_name=f"{base_name}.zip" + ) + +# --- GENERATION HANDLING --- + +@app.route('/generate', methods=['POST']) +@login_required +def generate_audio(): + """Generate audio from text using the TTS API, then get timestamps.""" + data = request.json + text_content = data.get('text', '') + voice = data.get('voice', 'af_heart') + speed = data.get('speed', 1.0) + name = data.get('name', '') + save_to_db = data.get('save_to_db', True) + + if not text_content: + return jsonify({'error': 'No text provided'}), 400 + + try: + print(f"🔊 Calling Generate Audio API: voice={voice}, speed={speed}") + + response = requests.post( + f"{TTS_API_URL}/generate-audio", + headers={**get_api_headers(), 'Content-Type': 'application/json'}, + json={'text': text_content, 'voice': voice, 'speed': speed}, + timeout=180 + ) + + if response.status_code != 200: + error_detail = response.json().get('error', 'Unknown error') + return jsonify({'error': f'Generate Audio API Error: {error_detail}'}), response.status_code + + result = response.json() + audio_base64 = result.get('audio_base64', '') + source_format = result.get('audio_format', 'wav') + + if not audio_base64: + return jsonify({'error': 'No audio data received from API'}), 500 + + print(f"💾 Audio generated successfully") + print("⏳ Calling Timestamp API...") + + audio_bytes = base64.b64decode(audio_base64) + files = {'audio_file': (f'audio.{source_format}', audio_bytes)} + ts_data = {'text': text_content} + + ts_response = requests.post( + f"{TTS_API_URL}/timestamp", + headers=get_api_headers(), + files=files, + data=ts_data, + timeout=120 + ) + + if ts_response.status_code != 200: + error_detail = ts_response.json().get('error', 'Unknown error') + return jsonify({'error': f'Timestamp API Error: {error_detail}'}), ts_response.status_code + + ts_result = ts_response.json() + transcription_data = ts_result.get('timestamps', []) + + if source_format != 'mp3': + print("🔄 Converting to MP3...") + audio_base64 = convert_to_mp3(audio_base64, source_format) + + generation_id = None + + if save_to_db: + db = get_db() + cursor = db.cursor() + cursor.execute(''' + INSERT INTO generations (user_id, name, audio_data, audio_format, text_content, transcription, voice, speed) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + get_current_user_id(), + name or f"Generation {datetime.now().strftime('%Y-%m-%d %H:%M')}", + audio_base64, + 'mp3', + text_content, + json.dumps(transcription_data), + voice, + speed + )) + db.commit() + generation_id = cursor.lastrowid + print(f"✅ Generation saved with ID: {generation_id}") + + return jsonify({ + 'message': 'Generated', + 'generation_id': generation_id, + 'audio_data': audio_base64, + 'audio_format': 'mp3', + 'text_content': text_content, + 'transcription': transcription_data, + 'voice': voice, + 'speed': speed + }) + + except requests.exceptions.RequestException as e: + return jsonify({'error': f'API connection error: {str(e)}'}), 500 + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'error': str(e)}), 500 + +@app.route('/generations', methods=['GET']) +@login_required +def list_generations(): + """List all generations for current user.""" + db = get_db() + cursor = db.cursor() + cursor.execute(''' + SELECT id, name, audio_format, voice, speed, created_at, + LENGTH(audio_data) as audio_size, + LENGTH(text_content) as text_size + FROM generations + WHERE user_id = ? + ORDER BY created_at DESC + ''', (get_current_user_id(),)) + rows = cursor.fetchall() + + generations = [] + for row in rows: + generations.append({ + 'id': row['id'], + 'name': row['name'], + 'audio_format': row['audio_format'], + 'voice': row['voice'], + 'speed': row['speed'], + 'created_at': row['created_at'], + 'audio_size': row['audio_size'], + 'text_size': row['text_size'] + }) + + return jsonify({'generations': generations}) + +@app.route('/generations/', methods=['GET']) +@login_required +def get_generation(gen_id): + """Get a specific generation.""" + db = get_db() + cursor = db.cursor() + cursor.execute('SELECT * FROM generations WHERE id = ? AND user_id = ?', (gen_id, get_current_user_id())) + row = cursor.fetchone() + + if not row: + return jsonify({'error': 'Generation not found'}), 404 + + return jsonify({ + 'id': row['id'], + 'name': row['name'], + 'audio_data': row['audio_data'], + 'audio_format': row['audio_format'], + 'text_content': row['text_content'], + 'transcription': json.loads(row['transcription']) if row['transcription'] else [], + 'voice': row['voice'], + 'speed': row['speed'], + 'created_at': row['created_at'] + }) + +@app.route('/generations/', methods=['DELETE']) +@login_required +def delete_generation(gen_id): + """Delete a generation.""" + db = get_db() + cursor = db.cursor() + cursor.execute('DELETE FROM generations WHERE id = ? AND user_id = ?', (gen_id, get_current_user_id())) + db.commit() + + if cursor.rowcount == 0: + return jsonify({'error': 'Generation not found'}), 404 + + vacuum_db() + return jsonify({'message': 'Generation deleted successfully'}) + +@app.route('/generations//download', methods=['GET']) +@login_required +def download_generation(gen_id): + """Download generation as zip file.""" + db = get_db() + cursor = db.cursor() + cursor.execute('SELECT * FROM generations WHERE id = ? AND user_id = ?', (gen_id, get_current_user_id())) + row = cursor.fetchone() + + if not row: + return jsonify({'error': 'Generation not found'}), 404 + + zip_buffer = io.BytesIO() + base_name = row['name'].replace(' ', '_') if row['name'] else f"generation_{gen_id}" + base_name = "".join(c for c in base_name if c.isalnum() or c in ('_', '-')) + + with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf: + audio_bytes = base64.b64decode(row['audio_data']) + zf.writestr(f"{base_name}.{row['audio_format']}", audio_bytes) + zf.writestr(f"{base_name}.txt", row['text_content']) + if row['transcription']: + zf.writestr(f"{base_name}.json", row['transcription']) + + zip_buffer.seek(0) + return send_file( + zip_buffer, + mimetype='application/zip', + as_attachment=True, + download_name=f"{base_name}.zip" + ) + +# --- PROJECT HANDLING (BULK) --- + +@app.route('/projects', methods=['GET']) +@login_required +def list_projects(): + """List all projects for current user.""" + db = get_db() + cursor = db.cursor() + cursor.execute(''' + SELECT p.id, p.name, p.created_at, p.updated_at, + COUNT(ps.id) as section_count + FROM projects p + LEFT JOIN project_sections ps ON p.id = ps.project_id + WHERE p.user_id = ? + GROUP BY p.id + ORDER BY p.updated_at DESC + ''', (get_current_user_id(),)) + rows = cursor.fetchall() + + projects = [] + for row in rows: + projects.append({ + 'id': row['id'], + 'name': row['name'], + 'created_at': row['created_at'], + 'updated_at': row['updated_at'], + 'section_count': row['section_count'] + }) + + return jsonify({'projects': projects}) + +@app.route('/projects', methods=['POST']) +@login_required +def create_project(): + """Create a new project.""" + data = request.json + name = data.get('name', '').strip() + + if not name: + return jsonify({'error': 'Project name is required'}), 400 + + db = get_db() + cursor = db.cursor() + + try: + cursor.execute('INSERT INTO projects (user_id, name) VALUES (?, ?)', (get_current_user_id(), name)) + db.commit() + return jsonify({ + 'message': 'Project created', + 'project_id': cursor.lastrowid, + 'name': name + }) + except sqlite3.IntegrityError: + return jsonify({'error': 'Project with this name already exists'}), 400 + +@app.route('/projects/', methods=['GET']) +@login_required +def get_project(project_id): + """Get a project with all its sections.""" + db = get_db() + cursor = db.cursor() + + cursor.execute('SELECT * FROM projects WHERE id = ? AND user_id = ?', (project_id, get_current_user_id())) + project = cursor.fetchone() + + if not project: + return jsonify({'error': 'Project not found'}), 404 + + cursor.execute(''' + SELECT id, chapter, section, audio_data, audio_format, + text_content, html_content, tts_text, transcription, voice, + image_data, image_format, created_at + FROM project_sections + WHERE project_id = ? + ORDER BY chapter, section + ''', (project_id,)) + sections = cursor.fetchall() + + section_list = [] + for s in sections: + html_content = s['html_content'] if 'html_content' in s.keys() else None + tts_text = s['tts_text'] if 'tts_text' in s.keys() else None + image_data = s['image_data'] if 'image_data' in s.keys() else None + image_format = s['image_format'] if 'image_format' in s.keys() else 'png' + + section_list.append({ + 'id': s['id'], + 'chapter': s['chapter'], + 'section': s['section'], + 'audio_data': s['audio_data'], + 'audio_format': s['audio_format'] or 'mp3', + 'text_content': s['text_content'], + 'html_content': html_content, + 'tts_text': tts_text, + 'transcription': json.loads(s['transcription']) if s['transcription'] else [], + 'voice': s['voice'] or 'af_heart', + 'image_data': image_data, + 'image_format': image_format, + 'created_at': s['created_at'], + 'has_audio': s['audio_data'] is not None and len(s['audio_data'] or '') > 0, + 'has_image': image_data is not None and len(image_data or '') > 0 + }) + + return jsonify({ + 'id': project['id'], + 'name': project['name'], + 'created_at': project['created_at'], + 'updated_at': project['updated_at'], + 'sections': section_list + }) + +@app.route('/projects/', methods=['DELETE']) +@login_required +def delete_project(project_id): + """Delete a project and all its sections.""" + db = get_db() + cursor = db.cursor() + + # Verify ownership + cursor.execute('SELECT id FROM projects WHERE id = ? AND user_id = ?', (project_id, get_current_user_id())) + if not cursor.fetchone(): + return jsonify({'error': 'Project not found'}), 404 + + cursor.execute('DELETE FROM project_sections WHERE project_id = ?', (project_id,)) + cursor.execute('DELETE FROM projects WHERE id = ?', (project_id,)) + db.commit() + + vacuum_db() + return jsonify({'message': 'Project deleted successfully'}) + +@app.route('/projects//sections', methods=['POST']) +@login_required +def add_or_update_section(project_id): + """Add or update a section in a project.""" + data = request.json + chapter = data.get('chapter', 1) + section = data.get('section', 1) + text_content = data.get('text', '') + html_content = data.get('html_content', '') + tts_text = data.get('tts_text', '') + voice = data.get('voice', 'af_heart') + image_data = data.get('image_data', '') + image_format = data.get('image_format', 'png') + generate_audio_flag = data.get('generate_audio', True) + + if not text_content: + return jsonify({'error': 'Text content is required'}), 400 + + db = get_db() + cursor = db.cursor() + + # Verify ownership + cursor.execute('SELECT id FROM projects WHERE id = ? AND user_id = ?', (project_id, get_current_user_id())) + if not cursor.fetchone(): + return jsonify({'error': 'Project not found'}), 404 + + audio_base64 = None + audio_format = 'mp3' + transcription_data = [] + + if generate_audio_flag: + try: + gen_text = tts_text if tts_text else text_content + + print(f"🔊 Generating audio for Ch{chapter}.Sec{section}") + + response = requests.post( + f"{TTS_API_URL}/generate-audio", + headers={**get_api_headers(), 'Content-Type': 'application/json'}, + json={'text': gen_text, 'voice': voice, 'speed': 1.0}, + timeout=180 + ) + + if response.status_code != 200: + error_detail = response.json().get('error', 'Unknown error') + return jsonify({'error': f'Generate Audio API Error: {error_detail}'}), response.status_code + + result = response.json() + audio_base64 = result.get('audio_base64', '') + source_format = result.get('audio_format', 'wav') + + if audio_base64: + audio_bytes = base64.b64decode(audio_base64) + files = {'audio_file': (f'audio.{source_format}', audio_bytes)} + ts_data = {'text': gen_text} + + ts_response = requests.post( + f"{TTS_API_URL}/timestamp", + headers=get_api_headers(), + files=files, + data=ts_data, + timeout=120 + ) + + if ts_response.status_code == 200: + ts_result = ts_response.json() + transcription_data = ts_result.get('timestamps', []) + + if source_format != 'mp3': + print("🔄 Converting to MP3...") + audio_base64 = convert_to_mp3(audio_base64, source_format) + + except Exception as e: + print(f"Error generating audio: {e}") + return jsonify({'error': str(e)}), 500 + + cursor.execute(''' + INSERT INTO project_sections (project_id, chapter, section, audio_data, audio_format, text_content, html_content, tts_text, transcription, voice, image_data, image_format) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(project_id, chapter, section) DO UPDATE SET + audio_data = excluded.audio_data, + audio_format = excluded.audio_format, + text_content = excluded.text_content, + html_content = excluded.html_content, + tts_text = excluded.tts_text, + transcription = excluded.transcription, + voice = excluded.voice, + image_data = excluded.image_data, + image_format = excluded.image_format + ''', ( + project_id, + chapter, + section, + audio_base64, + audio_format, + text_content, + html_content, + tts_text, + json.dumps(transcription_data), + voice, + image_data if image_data else None, + image_format + )) + + cursor.execute('UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', (project_id,)) + db.commit() + + section_id = cursor.lastrowid + + return jsonify({ + 'message': 'Section saved', + 'section_id': section_id, + 'chapter': chapter, + 'section': section, + 'audio_data': audio_base64, + 'audio_format': audio_format, + 'text_content': text_content, + 'html_content': html_content, + 'tts_text': tts_text, + 'transcription': transcription_data, + 'voice': voice, + 'image_data': image_data, + 'image_format': image_format + }) + +@app.route('/projects//sections/save', methods=['POST']) +@login_required +def save_section_directly(project_id): + """Save a section directly without generating audio.""" + data = request.json + chapter = data.get('chapter', 1) + section = data.get('section', 1) + text_content = data.get('text', '') + html_content = data.get('html_content', '') + tts_text = data.get('tts_text', '') + voice = data.get('voice', 'af_heart') + audio_data = data.get('audio_data', '') + audio_format = data.get('audio_format', 'mp3') + transcription = data.get('transcription', []) + image_data = data.get('image_data', '') + image_format = data.get('image_format', 'png') + + db = get_db() + cursor = db.cursor() + + # Verify ownership + cursor.execute('SELECT id FROM projects WHERE id = ? AND user_id = ?', (project_id, get_current_user_id())) + if not cursor.fetchone(): + return jsonify({'error': 'Project not found'}), 404 + + print(f"💾 Direct save: Ch{chapter}.Sec{section} to project {project_id}") + + cursor.execute(''' + INSERT INTO project_sections (project_id, chapter, section, audio_data, audio_format, text_content, html_content, tts_text, transcription, voice, image_data, image_format) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(project_id, chapter, section) DO UPDATE SET + audio_data = CASE WHEN excluded.audio_data IS NOT NULL AND excluded.audio_data != '' THEN excluded.audio_data ELSE project_sections.audio_data END, + audio_format = excluded.audio_format, + text_content = excluded.text_content, + html_content = excluded.html_content, + tts_text = excluded.tts_text, + transcription = CASE WHEN excluded.transcription IS NOT NULL AND excluded.transcription != '[]' THEN excluded.transcription ELSE project_sections.transcription END, + voice = excluded.voice, + image_data = CASE WHEN excluded.image_data IS NOT NULL AND excluded.image_data != '' THEN excluded.image_data ELSE project_sections.image_data END, + image_format = CASE WHEN excluded.image_format IS NOT NULL THEN excluded.image_format ELSE project_sections.image_format END + ''', ( + project_id, + chapter, + section, + audio_data if audio_data else None, + audio_format, + text_content, + html_content, + tts_text, + json.dumps(transcription) if transcription else '[]', + voice, + image_data if image_data else None, + image_format + )) + + cursor.execute('UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', (project_id,)) + db.commit() + + return jsonify({ + 'message': 'Section saved', + 'chapter': chapter, + 'section': section + }) + +@app.route('/projects//save_all', methods=['POST']) +@login_required +def save_all_sections(project_id): + """Save all sections at once.""" + data = request.json + sections = data.get('sections', []) + + db = get_db() + cursor = db.cursor() + + # Verify ownership + cursor.execute('SELECT id FROM projects WHERE id = ? AND user_id = ?', (project_id, get_current_user_id())) + if not cursor.fetchone(): + return jsonify({'error': 'Project not found'}), 404 + + saved_count = 0 + + for sec in sections: + chapter = sec.get('chapter', 1) + section = sec.get('section', 1) + text_content = sec.get('text', '') + html_content = sec.get('htmlContent') or sec.get('html_content', '') + tts_text = sec.get('ttsText') or sec.get('tts_text', '') + voice = sec.get('voice', 'af_heart') + audio_data = sec.get('audioData') or sec.get('audio_data', '') + audio_format = sec.get('audioFormat') or sec.get('audio_format', 'mp3') + transcription = sec.get('transcription', []) + image_data = sec.get('imageData') or sec.get('image_data', '') + image_format = sec.get('imageFormat') or sec.get('image_format', 'png') + + print(f"💾 Saving Ch{chapter}.Sec{section} (image: {len(image_data) if image_data else 0} bytes)") + + cursor.execute(''' + INSERT INTO project_sections (project_id, chapter, section, audio_data, audio_format, text_content, html_content, tts_text, transcription, voice, image_data, image_format) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(project_id, chapter, section) DO UPDATE SET + audio_data = CASE WHEN excluded.audio_data IS NOT NULL AND excluded.audio_data != '' THEN excluded.audio_data ELSE project_sections.audio_data END, + audio_format = excluded.audio_format, + text_content = excluded.text_content, + html_content = excluded.html_content, + tts_text = excluded.tts_text, + transcription = CASE WHEN excluded.transcription IS NOT NULL AND excluded.transcription != '[]' THEN excluded.transcription ELSE project_sections.transcription END, + voice = excluded.voice, + image_data = CASE WHEN excluded.image_data IS NOT NULL AND excluded.image_data != '' THEN excluded.image_data ELSE project_sections.image_data END, + image_format = CASE WHEN excluded.image_format IS NOT NULL AND excluded.image_format != '' THEN excluded.image_format ELSE project_sections.image_format END + ''', ( + project_id, + chapter, + section, + audio_data if audio_data else None, + audio_format, + text_content, + html_content, + tts_text, + json.dumps(transcription) if transcription else '[]', + voice, + image_data if image_data else None, + image_format + )) + saved_count += 1 + + cursor.execute('UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', (project_id,)) + db.commit() + + print(f"✅ Saved {saved_count} sections to project {project_id}") + + return jsonify({ + 'message': f'Saved {saved_count} sections', + 'saved_count': saved_count + }) + +@app.route('/projects//sections/', methods=['GET']) +@login_required +def get_section(project_id, section_id): + """Get a specific section with audio data.""" + db = get_db() + cursor = db.cursor() + + # Verify ownership + cursor.execute('SELECT id FROM projects WHERE id = ? AND user_id = ?', (project_id, get_current_user_id())) + if not cursor.fetchone(): + return jsonify({'error': 'Project not found'}), 404 + + cursor.execute(''' + SELECT * FROM project_sections + WHERE id = ? AND project_id = ? + ''', (section_id, project_id)) + row = cursor.fetchone() + + if not row: + return jsonify({'error': 'Section not found'}), 404 + + html_content = row['html_content'] if 'html_content' in row.keys() else None + tts_text = row['tts_text'] if 'tts_text' in row.keys() else None + image_data = row['image_data'] if 'image_data' in row.keys() else None + image_format = row['image_format'] if 'image_format' in row.keys() else 'png' + + return jsonify({ + 'id': row['id'], + 'chapter': row['chapter'], + 'section': row['section'], + 'audio_data': row['audio_data'], + 'audio_format': row['audio_format'] or 'mp3', + 'text_content': row['text_content'], + 'html_content': html_content, + 'tts_text': tts_text, + 'transcription': json.loads(row['transcription']) if row['transcription'] else [], + 'voice': row['voice'] or 'af_heart', + 'image_data': image_data, + 'image_format': image_format, + 'created_at': row['created_at'] + }) + +@app.route('/projects//sections/', methods=['DELETE']) +@login_required +def delete_section(project_id, section_id): + """Delete a section.""" + db = get_db() + cursor = db.cursor() + + # Verify ownership + cursor.execute('SELECT id FROM projects WHERE id = ? AND user_id = ?', (project_id, get_current_user_id())) + if not cursor.fetchone(): + return jsonify({'error': 'Project not found'}), 404 + + cursor.execute(''' + DELETE FROM project_sections + WHERE id = ? AND project_id = ? + ''', (section_id, project_id)) + db.commit() + + if cursor.rowcount == 0: + return jsonify({'error': 'Section not found'}), 404 + + vacuum_db() + return jsonify({'message': 'Section deleted successfully'}) + +@app.route('/projects//download', methods=['GET']) +@login_required +def download_project(project_id): + """Download entire project as zip file (simple format).""" + db = get_db() + cursor = db.cursor() + + # Verify ownership + cursor.execute('SELECT * FROM projects WHERE id = ? AND user_id = ?', (project_id, get_current_user_id())) + project = cursor.fetchone() + + if not project: + return jsonify({'error': 'Project not found'}), 404 + + cursor.execute(''' + SELECT * FROM project_sections + WHERE project_id = ? + ORDER BY chapter, section + ''', (project_id,)) + sections = cursor.fetchall() + + zip_buffer = io.BytesIO() + project_name = "".join(c for c in project['name'] if c.isalnum() or c in ('_', '-', ' ')) + + with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf: + for s in sections: + file_prefix = f"{s['chapter']}.{s['section']}_{project_name}" + + if s['audio_data']: + audio_bytes = base64.b64decode(s['audio_data']) + zf.writestr(f"{file_prefix}.{s['audio_format'] or 'mp3'}", audio_bytes) + + zf.writestr(f"{file_prefix}.txt", s['text_content'] or '') + + if s['transcription']: + zf.writestr(f"{file_prefix}.json", s['transcription']) + + image_data = s['image_data'] if 'image_data' in s.keys() else None + image_format = s['image_format'] if 'image_format' in s.keys() else 'png' + if image_data: + image_bytes = base64.b64decode(image_data) + zf.writestr(f"{file_prefix}.{image_format}", image_bytes) + + zip_buffer.seek(0) + return send_file( + zip_buffer, + mimetype='application/zip', + as_attachment=True, + download_name=f"{project_name}.zip" + ) + +# --- BULK EXPORT WITH MANIFEST AND READER --- + +def get_reader_template(filename): + """Load reader template file.""" + template_path = os.path.join(READER_TEMPLATES_DIR, filename) + if os.path.exists(template_path): + with open(template_path, 'r', encoding='utf-8') as f: + return f.read() + return None + +@app.route('/export_bulk', methods=['POST']) +@login_required +def export_bulk(): + """Export bulk project files with manifest.json, index.html, and Reader.html.""" + try: + data = request.json + project_name = data.get('projectName', 'Book-1').strip() + files = data.get('files', []) + + print(f"📦 Export bulk: project={project_name}, files={len(files)}") + + if not files: + return jsonify({'error': 'No files to export'}), 400 + + db = get_db() + cursor = db.cursor() + + # Create or get project for current user + cursor.execute('SELECT id FROM projects WHERE name = ? AND user_id = ?', (project_name, get_current_user_id())) + row = cursor.fetchone() + + if row: + project_id = row['id'] + print(f"📁 Using existing project ID: {project_id}") + else: + cursor.execute('INSERT INTO projects (user_id, name) VALUES (?, ?)', (get_current_user_id(), project_name)) + db.commit() + project_id = cursor.lastrowid + print(f"📁 Created new project ID: {project_id}") + + # Save all sections to database + for file_item in files: + chapter = file_item.get('chapter', 0) + section = file_item.get('section', 0) + text = file_item.get('text', '') + html_content = file_item.get('htmlContent') or file_item.get('html_content', '') + tts_text = file_item.get('ttsText') or file_item.get('tts_text', '') + transcription = file_item.get('transcription', []) + audio_data = file_item.get('audioData') or file_item.get('audio_data', '') + audio_format = file_item.get('audioFormat') or file_item.get('audio_format', 'mp3') + voice = file_item.get('voice', 'af_heart') + image_data = file_item.get('imageData') or file_item.get('image_data', '') + image_format = file_item.get('imageFormat') or file_item.get('image_format', 'png') + + print(f" 💾 Saving Ch{chapter}.Sec{section}") + + cursor.execute(''' + INSERT INTO project_sections (project_id, chapter, section, audio_data, audio_format, text_content, html_content, tts_text, transcription, voice, image_data, image_format) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(project_id, chapter, section) DO UPDATE SET + audio_data = CASE WHEN excluded.audio_data IS NOT NULL AND excluded.audio_data != '' THEN excluded.audio_data ELSE project_sections.audio_data END, + audio_format = excluded.audio_format, + text_content = excluded.text_content, + html_content = excluded.html_content, + tts_text = excluded.tts_text, + transcription = CASE WHEN excluded.transcription IS NOT NULL AND excluded.transcription != '[]' THEN excluded.transcription ELSE project_sections.transcription END, + voice = excluded.voice, + image_data = CASE WHEN excluded.image_data IS NOT NULL AND excluded.image_data != '' THEN excluded.image_data ELSE project_sections.image_data END, + image_format = CASE WHEN excluded.image_format IS NOT NULL AND excluded.image_format != '' THEN excluded.image_format ELSE project_sections.image_format END + ''', ( + project_id, + chapter, + section, + audio_data if audio_data else None, + audio_format, + text, + html_content, + tts_text, + json.dumps(transcription) if transcription else '[]', + voice, + image_data if image_data else None, + image_format + )) + + cursor.execute('UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', (project_id,)) + db.commit() + + print(f"✅ Saved {len(files)} sections to project {project_id}") + + # Fetch all sections for export + cursor.execute('SELECT * FROM project_sections WHERE project_id = ? ORDER BY chapter, section', (project_id,)) + sections = cursor.fetchall() + + # Create ZIP with proper structure + zip_buffer = io.BytesIO() + safe_name = "".join(c for c in project_name if c.isalnum() or c in ('_', '-', ' ')) + + # Build manifest assets list + manifest_assets = [] + + with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf: + for s in sections: + chapter = s['chapter'] + section = s['section'] + file_prefix = f"{chapter}.{section}_{safe_name}" + + asset_entry = { + "prefix": f"{chapter}.{section}_", + "textFile": f"book/{file_prefix}.txt", + "audioFile": None, + "jsonFile": None, + "imageFile": None + } + + # Add text file + zf.writestr(f"book/{file_prefix}.txt", s['text_content'] or '') + + # Add audio file + if s['audio_data']: + audio_bytes = base64.b64decode(s['audio_data']) + audio_ext = s['audio_format'] or 'mp3' + zf.writestr(f"book/{file_prefix}.{audio_ext}", audio_bytes) + asset_entry["audioFile"] = f"book/{file_prefix}.{audio_ext}" + + # Add JSON timestamps + if s['transcription'] and s['transcription'] != '[]': + zf.writestr(f"book/{file_prefix}.json", s['transcription']) + asset_entry["jsonFile"] = f"book/{file_prefix}.json" + + # Add image file + image_data = s['image_data'] if 'image_data' in s.keys() else None + image_format = s['image_format'] if 'image_format' in s.keys() else 'png' + if image_data: + image_bytes = base64.b64decode(image_data) + zf.writestr(f"book/{file_prefix}.{image_format}", image_bytes) + asset_entry["imageFile"] = f"book/{file_prefix}.{image_format}" + + manifest_assets.append(asset_entry) + + # Create manifest.json + manifest = { + "title": project_name, + "assets": manifest_assets + } + zf.writestr("manifest.json", json.dumps(manifest, indent=2)) + + # Add index.html + index_html = get_reader_template('index.html') + if index_html: + zf.writestr("index.html", index_html) + else: + print("⚠️ index.html template not found") + + # Add Reader.html + reader_html = get_reader_template('Reader.html') + if reader_html: + zf.writestr("Reader.html", reader_html) + else: + print("⚠️ Reader.html template not found") + + zip_buffer.seek(0) + + print(f"📤 Sending zip: {safe_name}.zip") + + return send_file( + zip_buffer, + mimetype='application/zip', + as_attachment=True, + download_name=f"{safe_name}.zip" + ) + + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'error': str(e)}), 500 + +# --- LEGACY SINGLE EXPORT --- + +@app.route('/export', methods=['POST']) +@login_required +def export_project(): + """Export single project files as zip.""" + try: + data = request.json + base_name = data.get('filename', '').strip() + if not base_name: + base_name = 'task-1' + base_name = "".join(c for c in base_name if c.isalnum() or c in ('_', '-')) + + text = data.get('text', '') + json_data = data.get('transcription', []) + audio_data = data.get('audio_data', '') + audio_format = data.get('audio_format', 'mp3') + + zip_buffer = io.BytesIO() + + with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf: + zf.writestr(f"{base_name}.txt", text) + zf.writestr(f"{base_name}.json", json.dumps(json_data, indent=2)) + + if audio_data: + audio_bytes = base64.b64decode(audio_data) + zf.writestr(f"{base_name}.{audio_format}", audio_bytes) + + zip_buffer.seek(0) + return send_file( + zip_buffer, + mimetype='application/zip', + as_attachment=True, + download_name=f"{base_name}.zip" + ) + + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'error': str(e)}), 500 + +# --- DATABASE STATS --- + +@app.route('/db/stats', methods=['GET']) +@login_required +def db_stats(): + """Get database statistics for current user.""" + db = get_db() + cursor = db.cursor() + user_id = get_current_user_id() + + cursor.execute('SELECT COUNT(*) as count FROM uploads WHERE user_id = ?', (user_id,)) + uploads_count = cursor.fetchone()['count'] + + cursor.execute('SELECT COUNT(*) as count FROM generations WHERE user_id = ?', (user_id,)) + generations_count = cursor.fetchone()['count'] + + cursor.execute('SELECT COUNT(*) as count FROM projects WHERE user_id = ?', (user_id,)) + projects_count = cursor.fetchone()['count'] + + cursor.execute(''' + SELECT COUNT(*) as count FROM project_sections + WHERE project_id IN (SELECT id FROM projects WHERE user_id = ?) + ''', (user_id,)) + sections_count = cursor.fetchone()['count'] + + db_size = os.path.getsize(DATABASE) if os.path.exists(DATABASE) else 0 + + return jsonify({ + 'uploads': uploads_count, + 'generations': generations_count, + 'projects': projects_count, + 'sections': sections_count, + 'database_size_bytes': db_size, + 'database_size_mb': round(db_size / (1024 * 1024), 2) + }) + +# --- HEALTH CHECK ENDPOINT --- + +@app.route('/health', methods=['GET']) +def health_check(): + """Health check endpoint for container orchestration.""" + try: + # Check database connection + with get_db_connection() as conn: + conn.execute('SELECT 1') + return jsonify({ + 'status': 'healthy', + 'database': 'connected', + 'database_path': DATABASE + }), 200 + except Exception as e: + return jsonify({ + 'status': 'unhealthy', + 'error': str(e) + }), 500 + +if __name__ == '__main__': + print("=" * 60) + print("🚀 Audio Transcription Editor Starting...") + print("=" * 60) + print(f"📍 API Server: {TTS_API_URL}") + print(f"📍 API Key: {'✅ Configured' if TTS_API_KEY else '❌ NOT CONFIGURED!'}") + print(f"📍 Database Directory: {DATABASE_DIR}") + print(f"📍 Database File: {DATABASE}") + print(f"📍 Static Files: {app.static_folder}") + print(f"📍 Reader Templates: {READER_TEMPLATES_DIR}") + print("-" * 60) + print("🔐 Default Admin: admin / admin123") + print("-" * 60) + + # Create reader_templates directory if it doesn't exist + if not os.path.exists(READER_TEMPLATES_DIR): + os.makedirs(READER_TEMPLATES_DIR) + print(f"📁 Created reader_templates directory") + + if not TTS_API_KEY: + print("⚠️ WARNING: TTS_API_KEY not set in .env file!") + + print("=" * 60) + + app.run(debug=True, port=5009, host='0.0.0.0') diff --git a/coolify.json b/coolify.json new file mode 100644 index 0000000..efd2b1c --- /dev/null +++ b/coolify.json @@ -0,0 +1,26 @@ +{ + "name": "audiobook-studio-pro-v3", + "build": { + "dockerfile": "Dockerfile" + }, + "settings": { + "port": 5009, + "health_check_path": "/health", + "health_check_interval": 30, + "health_check_timeout": 10 + }, + "volumes": [ + { + "source": "/opt/apps/audiobook-studio-pro-v3", + "target": "/opt/apps/audiobook-studio-pro-v3", + "type": "bind" + } + ], + "environment": { + "SECRET_KEY": "${SECRET_KEY}", + "DATABASE_DIR": "/opt/apps/audiobook-studio-pro-v3", + "TTS_API_URL": "${TTS_API_URL}", + "TTS_API_KEY": "${TTS_API_KEY}", + "FLASK_ENV": "production" + } +} diff --git a/doc.py b/doc.py new file mode 100644 index 0000000..779799b --- /dev/null +++ b/doc.py @@ -0,0 +1,256 @@ +import os +from pathlib import Path +import mimetypes +import markdown + +# INPUT: Set your Application folder path here +APPLICATION_FOLDER = f'../Audio Transcription Editor' # Replace with your actual folder path + + +# Add these new variables +EXCLUDE_FOLDERS = { + 'node_modules', + + + # 'node_modules', + # 'venv', + # 'env', + # '__pycache__', + # 'dist', + # 'build', + # '.pytest_cache' +} + +EXCLUDE_FILES = { + 'doc.py' + # '.DS_Store', + # 'Thumbs.db', + # 'package-lock.json' +} + +# File extensions to exclude +EXCLUDE_EXTENSIONS = { + # '.pyc', + # '.pyo', + # '.log', + # '.tmp' +} + + + + + +def is_text_file(file_path): + """Check if a file is likely a text/code file based on extension and mime type.""" + # Common code file extensions + code_extensions = { + '.py', '.js', '.html', '.css', '.java', '.cpp', '.c', '.h', '.hpp', + '.cs', '.php', '.rb', '.go', '.rs', '.swift', '.kt', '.ts', '.jsx', + '.tsx', '.vue', '.scss', '.sass', '.less', '.sql', '.json', '.xml', + '.yaml', '.yml', '.toml', '.ini', '.cfg', '.conf', '.sh', '.bat', + '.ps1', '.r', '.R', '.m', '.scala', '.clj', '.hs', '.elm', '.dart', + '.lua', '.pl', '.pm', '.tcl', '.awk', '.sed', '.dockerfile', '.md', + '.txt', '.log', '.gitignore', '.env', '.properties' + } + + file_ext = Path(file_path).suffix.lower() + + # Check by extension first + if file_ext in code_extensions: + return True + + # Check by mime type for files without extension + if not file_ext: + mime_type, _ = mimetypes.guess_type(file_path) + if mime_type and mime_type.startswith('text/'): + return True + + return False + +def generate_tree_structure(root_path, prefix="", is_last=True, max_depth=None, current_depth=0): + """Generate a tree-like directory structure.""" + if max_depth is not None and current_depth > max_depth: + return "" + + root = Path(root_path) + tree_str = "" + + if current_depth == 0: + tree_str += f"{root.name}/\n" + + try: + # Get all items and sort them (directories first, then files) + items = list(root.iterdir()) + dirs = [item for item in items if item.is_dir() and not item.name.startswith('.') and item.name not in EXCLUDE_FOLDERS] + files = [item for item in items if item.is_file() and not item.name.startswith('.') and item.name not in EXCLUDE_FILES and item.suffix not in EXCLUDE_EXTENSIONS] + + all_items = sorted(dirs) + sorted(files) + + for i, item in enumerate(all_items): + is_last_item = i == len(all_items) - 1 + + if item.is_dir(): + tree_str += f"{prefix}{'└── ' if is_last_item else '├── '}{item.name}/\n" + extension = " " if is_last_item else "│ " + tree_str += generate_tree_structure( + item, + prefix + extension, + is_last_item, + max_depth, + current_depth + 1 + ) + else: + tree_str += f"{prefix}{'└── ' if is_last_item else '├── '}{item.name}\n" + + except PermissionError: + tree_str += f"{prefix}[Permission Denied]\n" + + return tree_str + +def generate_bash_command(root_folder): + """Generate a bash command to recreate the directory and file structure.""" + root_path = Path(root_folder) + dirs_to_create = [] + files_to_create = [] + + for root, dirs, files in os.walk(root_folder, topdown=True): + # Skip hidden directories + dirs[:] = [d for d in dirs if not d.startswith('.') and d not in EXCLUDE_FOLDERS] + + for name in dirs: + dir_path = Path(root) / name + relative_dir = dir_path.relative_to(root_path) + dirs_to_create.append(f'"{relative_dir}"') + + # Skip hidden files + files[:] = [f for f in files if not f.startswith('.') and f not in EXCLUDE_FILES and Path(f).suffix not in EXCLUDE_EXTENSIONS] + + for name in files: + file_path = Path(root) / name + relative_file = file_path.relative_to(root_path) + files_to_create.append(f'"{relative_file}"') + + command_parts = [] + if dirs_to_create: + command_parts.append(f"mkdir -p {' '.join(dirs_to_create)}") + + if files_to_create: + command_parts.append(f"touch {' '.join(files_to_create)}") + + if not command_parts: + return "# No directories or files to create." + + return " && ".join(command_parts) + +def read_file_content(file_path): + """Safely read file content with encoding detection.""" + encodings = ['utf-8', 'utf-16', 'latin-1', 'cp1252'] + + for encoding in encodings: + try: + with open(file_path, 'r', encoding=encoding) as file: + return file.read() + except (UnicodeDecodeError, UnicodeError): + continue + except Exception as e: + return f"Error reading file: {str(e)}" + + return "Unable to decode file content" + +def get_language_from_extension(file_path): + """Get the appropriate language identifier for markdown code blocks.""" + ext = Path(file_path).suffix.lower() + language_map = { + '.py': 'python', '.js': 'javascript', '.ts': 'typescript', '.jsx': 'jsx', + '.tsx': 'tsx', '.html': 'html', '.css': 'css', '.scss': 'scss', + '.sass': 'sass', '.java': 'java', '.cpp': 'cpp', '.c': 'c', '.h': 'c', + '.hpp': 'cpp', '.cs': 'csharp', '.php': 'php', '.rb': 'ruby', '.go': 'go', + '.rs': 'rust', '.swift': 'swift', '.kt': 'kotlin', '.sql': 'sql', + '.json': 'json', '.xml': 'xml', '.yaml': 'yaml', '.yml': 'yaml', + '.toml': 'toml', '.sh': 'bash', '.bat': 'batch', '.ps1': 'powershell', + '.dockerfile': 'dockerfile', '.md': 'markdown', '.r': 'r', '.R': 'r', + '.scala': 'scala', '.clj': 'clojure', '.hs': 'haskell', '.lua': 'lua', + '.pl': 'perl', '.tcl': 'tcl', + } + + return language_map.get(ext, 'text') + +def generate_documentation(root_folder): + """Generate complete markdown and HTML documentation for the project.""" + root_path = Path(root_folder) + + if not root_path.exists() or not root_path.is_dir(): + print(f"Error: The folder '{root_folder}' does not exist or is not a directory.") + return + + # Start building markdown content + markdown_content = [f"# {root_path.name} - Project Documentation\n"] + + # Add project structure + markdown_content.append("## 📂 Project Structure\n") + markdown_content.append("```") + markdown_content.append(generate_tree_structure(root_path)) + markdown_content.append("```\n") + + # # Add bash command to recreate structure + # markdown_content.append("## ⚙️ Bash Command to Recreate Structure\n") + # markdown_content.append("```bash") + # markdown_content.append(generate_bash_command(root_path)) + # markdown_content.append("```\n") + + # Add files content + markdown_content.append("## 📄 Files Content\n") + + for root, dirs, files in os.walk(root_folder): + dirs[:] = [d for d in dirs if not d.startswith('.') and d not in EXCLUDE_FOLDERS] + for file in sorted(files): + if file.startswith('.') or file in EXCLUDE_FILES or Path(file).suffix in EXCLUDE_EXTENSIONS : continue + + file_path = Path(root) / file + if is_text_file(file_path): + relative_path = file_path.relative_to(root_path) + markdown_content.append(f"### 📜 `{relative_path}`\n") + content = read_file_content(file_path) + language = get_language_from_extension(file_path) + markdown_content.append(f"```{language}\n{content}\n```\n") + + final_markdown = '\n'.join(markdown_content) + + # Write to markdown file + output_md_file = f"../{root_path.name}_documentation.md" + try: + with open(output_md_file, 'w', encoding='utf-8') as f: + f.write(final_markdown) + print(f"✅ Markdown documentation generated: {Path(output_md_file).resolve()}") + except Exception as e: + print(f"❌ Error writing markdown file: {str(e)}") + + # Generate and write HTML file + html_template = """ + {title} + {content} + """ + html_content = markdown.markdown(final_markdown, extensions=['fenced_code', 'tables']) + final_html = html_template.format(title=f"{root_path.name} Documentation", content=html_content) + + output_html_file = f"../{root_path.name}_documentation.html" + try: + with open(output_html_file, 'w', encoding='utf-8') as f: + f.write(final_html) + print(f"✅ HTML documentation generated: {Path(output_html_file).resolve()}") + except Exception as e: + print(f"❌ Error writing HTML file: {str(e)}") + +# Main execution +if __name__ == "__main__": + if not APPLICATION_FOLDER or APPLICATION_FOLDER == "/path/to/your/Application": + print("⚠️ Please set the APPLICATION_FOLDER variable to your actual folder path.") + else: + print(f"🚀 Generating documentation for: {APPLICATION_FOLDER}") + generate_documentation(APPLICATION_FOLDER) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..24735f8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +version: '3.8' + +services: + audiobook-studio: + build: + context: . + dockerfile: Dockerfile + container_name: audiobook-studio-pro + ports: + - "5009:5009" + environment: + - SECRET_KEY=${SECRET_KEY:-your-secret-key-change-in-production} + - DATABASE_DIR=/opt/apps/audiobook-studio-pro-v3 + - TTS_API_URL=${TTS_API_URL:-http://localhost:5010/api/v1} + - TTS_API_KEY=${TTS_API_KEY:-} + - FLASK_ENV=production + volumes: + # Persist database outside container + - audiobook_data:/opt/apps/audiobook-studio-pro-v3 + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5009/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + +volumes: + audiobook_data: + driver: local diff --git a/reader_templates/Reader.html b/reader_templates/Reader.html new file mode 100755 index 0000000..3e1280c --- /dev/null +++ b/reader_templates/Reader.html @@ -0,0 +1,503 @@ + + + + + + Interactive Reader (Local) + + + + + + + + + + + + + +
+
+
+

Interactive Reader

+

Select your assets folder to begin.

+
+ + + +
+

Please select the folder containing your files (e.g., the 'assets' folder).

+ + + +
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/reader_templates/index.html b/reader_templates/index.html new file mode 100755 index 0000000..b88a4c9 --- /dev/null +++ b/reader_templates/index.html @@ -0,0 +1,496 @@ + + + + + + Interactive Reader (Local) + + + + + + + + + + + + + +
+
+
+

Interactive Reader

+

Select your assets folder to begin.

+
+ + + +
+

Please select the folder containing your files (e.g., the 'assets' folder).

+ + + +
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cbdf4dd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +# Flask and Web Framework +flask==3.1.2 +python-dotenv==1.0.0 +requests==2.31.0 + +# Audio Processing (CPU-only) +pydub==0.25.1 + +# Production WSGI Server +gunicorn==21.2.0 diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..0103f57 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,760 @@ +/* ============================================ + CSS Variables +============================================= */ +:root { + --bg-gradient-start: #f0f4f8; + --bg-gradient-end: #e2e8f0; + --bg-card: #ffffff; + --editor-bg: #1a1f2e; + --audio-track-bg: #242b3d; + --transcript-track-bg: #2d3548; + --track-border: #3d4558; + --pill-bg-gradient-start: #667eea; + --pill-bg-gradient-end: #764ba2; + --pill-text: #ffffff; + --accent-primary: #667eea; + --reader-bg: rgba(255, 255, 255, 0.95); + --reader-text: #1f2937; + --highlight-word: #2563eb; + --highlight-bg: #dbeafe; + --unmatched-color: #ef4444; +} + +/* ============================================ + Base Styles +============================================= */ +* { box-sizing: border-box; } + +body { + background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%); + font-family: 'Inter', sans-serif; + color: #1e293b; + min-height: 100vh; + padding: 20px 0 100px 0; +} + +.main-container { + max-width: 1400px; + margin: 0 auto; + padding: 0 20px; +} + +/* ============================================ + App Header +============================================= */ +.app-header { + background: linear-gradient(135deg, var(--accent-primary) 0%, var(--pill-bg-gradient-end) 100%); + color: white; + padding: 24px 32px; + border-radius: 20px; + box-shadow: 0 20px 40px rgba(102, 126, 234, 0.3); + margin-bottom: 32px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.version-badge { + background: rgba(255, 255, 255, 0.25); + padding: 8px 16px; + border-radius: 50px; + font-weight: 700; + font-size: 14px; + backdrop-filter: blur(10px); +} + +/* ============================================ + Input Card & Tabs +============================================= */ +.input-card { + background: var(--bg-card); + border-radius: 20px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08); + border: 1px solid rgba(0, 0, 0, 0.05); + margin-bottom: 32px; + overflow: hidden; + position: relative; +} + +.nav-tabs .nav-link { + color: #64748b; + font-weight: 600; + border: none; + padding: 1rem 1.5rem; +} + +.nav-tabs .nav-link.active { + color: var(--accent-primary); + border-bottom: 3px solid var(--accent-primary); + background: transparent; +} + +/* ============================================ + Quill Editor Styles +============================================= */ +#quill-editor { + height: 300px; + font-family: 'Lora', serif; + font-size: 18px; + line-height: 1.8; + color: #333; + border: none; +} + +.ql-container.ql-snow { + border: none !important; +} + +.ql-toolbar.ql-snow { + border: none !important; + border-bottom: 1px solid #f0f0f0 !important; + background: #fafafa; +} + +.editor-actions { + background: #fafafa; + border-top: 1px solid #eee; + padding: 15px 20px; + display: flex; + justify-content: space-between; + align-items: center; +} + +/* ============================================ + Bulk Editor (Notion-like) +============================================= */ +.notion-editor-wrapper { + position: relative; + min-height: 500px; + background: #fff; + padding: 40px; + font-family: 'Lora', serif; +} + +#bulk-editor { + outline: none; + font-size: 18px; + line-height: 1.8; + color: #333; + min-height: 60vh; + height: auto; + overflow-y: visible; + padding-bottom: 100px; +} + +#bulk-editor p { margin-bottom: 1em; } +#bulk-editor h1 { + font-size: 2em; + font-weight: bold; + margin: 1em 0 0.5em; + font-family: 'Poppins', sans-serif; +} +#bulk-editor h2 { + font-size: 1.5em; + font-weight: bold; + margin: 1em 0 0.5em; + font-family: 'Poppins', sans-serif; +} +#bulk-editor h3 { + font-size: 1.2em; + font-weight: bold; + margin: 1em 0 0.5em; + font-family: 'Poppins', sans-serif; +} + +/* ============================================ + Floating Action Buttons +============================================= */ +.floating-controls { + position: fixed; + top: 50%; + right: 20px; + transform: translateY(-50%); + display: flex; + flex-direction: column; + gap: 15px; + z-index: 1000; +} + +.floating-btn { + width: 60px; + height: 60px; + border-radius: 50%; + border: none; + color: white; + box-shadow: 0 4px 15px rgba(0,0,0,0.2); + transition: all 0.3s; + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + position: relative; +} + +.floating-btn:hover { transform: scale(1.1); } +.floating-btn.chapter-btn { background: linear-gradient(135deg, #FF6B6B 0%, #EE5253 100%); } +.floating-btn.section-btn { background: linear-gradient(135deg, #4834d4 0%, #686de0 100%); } + +.tooltip-text { + position: absolute; + right: 70px; + background: rgba(0,0,0,0.7); + color: white; + padding: 5px 10px; + border-radius: 5px; + font-size: 12px; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; + white-space: nowrap; +} + +.floating-btn:hover .tooltip-text { opacity: 1; } + +/* ============================================ + Chapter/Section Markers +============================================= */ +.editor-marker { + padding: 15px; + margin: 20px 0; + border-radius: 10px; + user-select: none; + cursor: default; + position: relative; + border: 1px solid rgba(0,0,0,0.1); +} + +.chapter-marker { + background: #fff0f0; + border-left: 5px solid #FF6B6B; +} + +.section-marker { + background: #f0f0ff; + border-left: 5px solid #4834d4; + margin-left: 20px; +} + +.marker-header { + display: flex; + align-items: center; + gap: 15px; + flex-wrap: wrap; +} + +.marker-title { + font-family: 'Poppins', sans-serif; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + font-size: 14px; +} + +.chapter-marker .marker-title { color: #d63031; } +.section-marker .marker-title { color: #4834d4; } + +.marker-controls { + display: flex; + align-items: center; + gap: 10px; +} + +/* ============================================ + Audio Control Panel +============================================= */ +.control-panel { + background: var(--bg-card); + border-radius: 16px; + padding: 15px 25px; + margin-bottom: 24px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08); + display: flex; + gap: 16px; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; +} + +/* ============================================ + Timeline/Waveform Editor +============================================= */ +.timeline-wrapper { + background-color: var(--editor-bg); + border-radius: 20px; + position: relative; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + overflow-x: scroll; + overflow-y: hidden; + margin-bottom: 40px; + padding-bottom: 10px; +} + +.timeline-wrapper::-webkit-scrollbar { + height: 12px; + background: #1a1f2e; +} + +.timeline-wrapper::-webkit-scrollbar-thumb { + background: #4b5563; + border-radius: 10px; + border: 2px solid #1a1f2e; +} + +.timeline-content { + position: relative; + min-width: 100%; +} + +#timeline-ruler { + height: 25px; + background: #1a1f2e; + border-bottom: 1px solid var(--track-border); + position: sticky; + top: 0; + z-index: 50; +} + +.audio-track-container { + background: var(--audio-track-bg); + border-bottom: 3px solid var(--track-border); + height: 120px; + padding: 10px 0; + position: relative; +} + +.transcription-track-container { + background: var(--transcript-track-bg); + height: 120px; + padding: 10px 0; + position: relative; +} + +.track-label { + position: absolute; + left: 20px; + top: 10px; + font-size: 10px; + font-weight: 800; + letter-spacing: 2px; + text-transform: uppercase; + color: rgba(255,255,255,0.5); + background: rgba(0,0,0,0.3); + padding: 4px 10px; + border-radius: 6px; + z-index: 100; + pointer-events: none; +} + +/* ============================================ + Playhead +============================================= */ +#custom-playhead { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + background-color: #ef4444; + z-index: 300; + left: 0; + cursor: ew-resize; + pointer-events: all; +} + +#custom-playhead::after { + content: ''; + position: absolute; + top: 0; + left: -6px; + border-left: 7px solid transparent; + border-right: 7px solid transparent; + border-top: 10px solid #ef4444; +} + +/* ============================================ + Word Pills (Transcription) +============================================= */ +.word-pill { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--pill-bg-gradient-start) 0%, var(--pill-bg-gradient-end) 100%); + color: var(--pill-text); + border-radius: 4px; + font-size: 12px; + font-weight: 600; + cursor: grab; + border: 1px solid rgba(255,255,255,0.2); + box-shadow: 0 2px 5px rgba(0,0,0,0.2); + top: 50%; + transform: translateY(-50%); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + user-select: none; + z-index: 200; + padding: 0 5px; +} + +.word-pill.selected { + border-color: #fcd34d; + box-shadow: 0 0 0 2px rgba(252, 211, 77, 0.4); + z-index: 210; +} + +.resize-handle { + position: absolute; + top: 0; + bottom: 0; + width: 5px; + cursor: ew-resize; + z-index: 220; +} + +.resize-handle-left { left: 0; } +.resize-handle-right { right: 0; } + +/* ============================================ + Interactive Reader +============================================= */ +.reader-section { + background-color: var(--reader-bg); + backdrop-filter: blur(12px); + border-radius: 1rem; + padding: 3rem 4rem; + box-shadow: 0 10px 35px rgba(0, 0, 0, 0.08); + border: 1px solid rgba(255, 255, 255, 0.4); + margin-top: 40px; + animation: fadeIn 0.5s ease-in-out; + min-height: 400px; +} + +.reader-header { + font-family: "Poppins", sans-serif; + font-weight: 700; + font-size: 1.5rem; + color: #111827; + margin-bottom: 1rem; + border-bottom: 2px solid #e2e8f0; + padding-bottom: 1rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +.story-text-container { + font-family: "Lora", serif; + font-size: 24px; + line-height: 2.1; + color: var(--reader-text); + cursor: text; +} + +.story-text-container h1, +.story-text-container h2 { + font-family: "Poppins", sans-serif; + margin-top: 1.5em; + font-weight: 700; +} + +.story-text-container p { margin-bottom: 1.2em; } + +/* ============================================ + Word Highlighting +============================================= */ +.word { + transition: background-color 0.15s; + border-radius: 3px; + padding: 2px 0; + display: inline; + cursor: pointer; + border-bottom: 2px solid transparent; +} + +.word:hover { background-color: #f1f5f9; } + +.show-mismatches .word.unmatched { + color: var(--unmatched-color); + text-decoration: underline wavy var(--unmatched-color); + opacity: 1 !important; +} + +.current-word { + color: var(--highlight-word); + text-decoration: underline; + text-decoration-thickness: 3px; + text-underline-offset: 3px; + font-weight: 700; +} + +.current-sentence-bg { + background-color: var(--highlight-bg); + box-decoration-break: clone; + -webkit-box-decoration-break: clone; + border-radius: 6px; +} + +/* ============================================ + Loading Overlay +============================================= */ +.loading-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(255,255,255,0.9); + z-index: 9999; + flex-direction: column; + justify-content: center; + align-items: center; +} + +/* ============================================ + Library Modal Items +============================================= */ +.library-item { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 15px; + margin-bottom: 10px; + display: flex; + justify-content: space-between; + align-items: center; + transition: all 0.2s; +} + +.library-item:hover { + background: #f1f5f9; + border-color: var(--accent-primary); +} + +.library-item-info { flex: 1; } +.library-item-title { + font-weight: 600; + color: #1e293b; + margin-bottom: 4px; +} +.library-item-meta { + font-size: 12px; + color: #64748b; +} +.library-item-actions { + display: flex; + gap: 8px; +} + +/* ============================================ + Database Stats +============================================= */ +.db-stats { + background: linear-gradient(135deg, #f0f4f8 0%, #e2e8f0 100%); + border-radius: 12px; + padding: 15px 20px; + display: flex; + gap: 30px; + flex-wrap: wrap; +} + +.stat-item { text-align: center; } +.stat-value { + font-size: 24px; + font-weight: 700; + color: var(--accent-primary); +} +.stat-label { + font-size: 12px; + color: #64748b; + text-transform: uppercase; + letter-spacing: 1px; +} + +/* ============================================ + Animations +============================================= */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ============================================ + Image Upload Styles for Section Markers +============================================= */ +.section-image-container { + margin: 10px 0; + padding: 10px; + background: #f8f9fa; + border-radius: 8px; + border: 2px dashed #dee2e6; + transition: all 0.3s ease; +} + +.section-image-container.drag-over { + border-color: #667eea; + background: #f0f4ff; +} + +.section-image-container.has-image { + border-style: solid; + border-color: #28a745; + background: #f8fff8; +} + +.image-drop-zone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 80px; + cursor: pointer; + color: #6c757d; + font-size: 14px; +} + +.image-drop-zone i { + font-size: 24px; + margin-bottom: 8px; + color: #adb5bd; +} + +.image-drop-zone:hover { + color: #495057; +} + +.image-drop-zone:hover i { + color: #667eea; +} + +.image-preview-wrapper { + position: relative; + display: inline-block; + max-width: 100%; +} + +.section-image-preview { + max-width: 200px; + max-height: 150px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + object-fit: contain; +} + +.image-actions { + position: absolute; + top: 5px; + right: 5px; + display: flex; + gap: 5px; +} + +.image-actions button { + width: 28px; + height: 28px; + border-radius: 50%; + border: none; + background: rgba(255,255,255,0.9); + color: #333; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); + transition: all 0.2s; +} + +.image-actions button:hover { + transform: scale(1.1); +} + +.image-actions .btn-remove:hover { + background: #dc3545; + color: white; +} + +.image-info { + font-size: 11px; + color: #6c757d; + margin-top: 5px; + text-align: center; +} + +/* Hidden file input */ +.image-file-input { + display: none; +} + +/* Save Button Styles */ +.save-project-btn { + background: linear-gradient(135deg, #28a745 0%, #20c997 100%); + border: none; + color: white; + font-weight: bold; + padding: 8px 20px; + border-radius: 8px; + transition: all 0.3s ease; +} + +.save-project-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4); + color: white; +} + +.save-project-btn:active { + transform: translateY(0); +} + +/* Section marker with image indicator */ +.section-marker.has-image .marker-title::after { + content: '🖼️'; + margin-left: 8px; + font-size: 12px; +} + +/* Image in editor content area */ +.section-content-image { + display: block; + max-width: 300px; + max-height: 200px; + margin: 10px 0; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); +} + +/* ============================================ + User Menu Styles +============================================= */ +.dropdown-menu { + border-radius: 12px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15); + border: 1px solid rgba(0, 0, 0, 0.05); + padding: 8px; +} + +.dropdown-item { + border-radius: 8px; + padding: 10px 16px; + font-weight: 500; + transition: all 0.2s; +} + +.dropdown-item:hover { + background: linear-gradient(135deg, var(--accent-primary) 0%, var(--pill-bg-gradient-end) 100%); + color: white; +} + +.dropdown-item i { + width: 20px; +} + +.dropdown-divider { + margin: 8px 0; +} diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..52e0d5b --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,893 @@ +/** + * Main Application Module + * Handles app initialization, API calls, database operations, and library management + */ + +// ========================================== +// LOADER FUNCTIONS +// ========================================== + +/** + * Show loading overlay + * @param {string} msg - Main message + * @param {string} subtext - Subtext message + */ +function showLoader(msg, subtext = '') { + document.getElementById('loadingText').textContent = msg; + document.getElementById('loadingSubtext').textContent = subtext || 'Please wait...'; + document.getElementById('loader').style.display = 'flex'; +} + +/** + * Hide loading overlay + */ +function hideLoader() { + document.getElementById('loader').style.display = 'none'; +} + +// ========================================== +// UTILITY FUNCTIONS +// ========================================== + +/** + * Format bytes to human readable string + * @param {number} bytes - Byte count + * @returns {string} Formatted string + */ +function formatBytes(bytes) { + if (!bytes) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +/** + * Format date to human readable string + * @param {string} dateStr - Date string + * @returns {string} Formatted date + */ +function formatDate(dateStr) { + if (!dateStr) return ''; + const d = new Date(dateStr); + return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit' + }); +} + +// ========================================== +// APP INITIALIZATION +// ========================================== + +/** + * Initialize app with data + * @param {Object} data - Audio and transcription data + */ +function initApp(data) { + document.getElementById('editorSection').classList.remove('d-none'); + document.getElementById('editorSection').scrollIntoView({ behavior: 'smooth' }); + transcriptionData = data.transcription; + currentAudioData = data.audio_data; + currentAudioFormat = data.audio_format; + initWaveSurferFromBase64(data.audio_data, data.audio_format); + initReader(data.text_content); + refreshLibraryStats(); +} + +// ========================================== +// UPLOAD FORM HANDLER +// ========================================== + +/** + * Initialize upload form handler + */ +function initUploadForm() { + document.getElementById('uploadForm').addEventListener('submit', async function(e) { + e.preventDefault(); + + const audioInput = document.getElementById('audioFile').files[0]; + const txtInput = document.getElementById('txtFile').files[0]; + + if (!audioInput || !txtInput) return; + + const fd = new FormData(); + fd.append('audioFile', audioInput); + fd.append('txtFile', txtInput); + + showLoader("Uploading...", "Processing audio and text files..."); + + try { + const res = await fetch('/upload', { method: 'POST', body: fd }); + const data = await res.json(); + if (data.error) throw new Error(data.error); + initApp(data); + await refreshLibraryStats(); + } catch (e) { + alert(e.message); + } finally { + hideLoader(); + } + }); +} + +// ========================================== +// PROJECT/DATABASE FUNCTIONS +// ========================================== + +/** + * Get or create project by name + * @returns {Promise} Project ID or null + */ +async function getOrCreateProject() { + const projectName = document.getElementById('bulkProjectName').value.trim() || 'Book-1'; + + try { + const listRes = await fetch('/projects'); + const listData = await listRes.json(); + const existing = listData.projects.find(p => p.name === projectName); + + if (existing) { + currentProjectId = existing.id; + return existing.id; + } + + const createRes = await fetch('/projects', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ name: projectName }) + }); + const createData = await createRes.json(); + + if (createData.error) { + console.error('Error creating project:', createData.error); + return null; + } + + currentProjectId = createData.project_id; + return createData.project_id; + } catch (e) { + console.error('Error getting/creating project:', e); + return null; + } +} + +/** + * Save all sections to database + * @returns {Promise} Success status + */ +async function saveAllSectionsToDatabase() { + const projectId = await getOrCreateProject(); + if (!projectId) { + console.error('Could not get project ID'); + return false; + } + + const sections = collectAllSectionsFromEditor(); + console.log(`💾 Saving ${sections.length} sections to database...`); + + for (const sec of sections) { + try { + await fetch(`/projects/${projectId}/sections/save`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + chapter: sec.chapter, + section: sec.section, + text: sec.text || '', + html_content: sec.htmlContent || '', + tts_text: sec.ttsText || '', + audio_data: sec.audioData || '', + audio_format: sec.audioFormat || 'mp3', + transcription: sec.transcription || [], + voice: sec.voice || 'af_heart', + image_data: sec.imageData || '', + image_format: sec.imageFormat || 'png' + }) + }); + console.log(` ✅ Saved Ch${sec.chapter}.Sec${sec.section}`); + } catch (e) { + console.error(` ❌ Error saving Ch${sec.chapter}.Sec${sec.section}:`, e); + } + } + + await refreshLibraryStats(); + return true; +} + +/** + * Save single section to database + * @param {number} chapterNum - Chapter number + * @param {number} sectionNum - Section number + * @param {Object} data - Section data + * @returns {Promise} Success status + */ +async function saveSectionToDatabase(chapterNum, sectionNum, data) { + const projectId = await getOrCreateProject(); + if (!projectId) { + console.error('Could not get project ID'); + return false; + } + + try { + const res = await fetch(`/projects/${projectId}/sections/save`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + chapter: chapterNum, + section: sectionNum, + text: data.text || '', + html_content: data.htmlContent || '', + tts_text: data.ttsText || '', + audio_data: data.audioData || '', + audio_format: data.audioFormat || 'mp3', + transcription: data.transcription || [], + voice: data.voice || 'af_heart', + image_data: data.imageData || '', + image_format: data.imageFormat || 'png' + }) + }); + + const result = await res.json(); + if (result.error) { + console.error('Error saving section:', result.error); + return false; + } + + console.log(`✅ Saved Ch${chapterNum}.Sec${sectionNum} to database`); + return true; + } catch (e) { + console.error('Error saving section to database:', e); + return false; + } +} + +// ========================================== +// AUDIO GENERATION FUNCTIONS +// ========================================== + +/** + * Generate audio for a marker (section or chapter) + * @param {string} id - Marker ID + */ +async function generateMarkerAudio(id) { + const markerEl = document.getElementById(`marker-${id}`); + if (!markerEl) return; + + const type = markerEl.dataset.type; + const num = markerEl.querySelector('input[type="number"]').value; + const voice = markerEl.querySelector('select').value; + const allMarkers = Array.from(document.querySelectorAll('.editor-marker')); + const myIdx = allMarkers.indexOf(markerEl); + + // Save all sections first + showLoader('Saving all sections...', 'Preserving your content before generation...'); + await saveAllSectionsToDatabase(); + + // --- Generate for single section --- + if (type === 'section') { + const htmlContent = extractHtmlForMarker(markerEl, allMarkers); + const displayText = extractMarkdownForMarker(markerEl, allMarkers); + + if (!displayText || displayText.trim().length < 2) { + hideLoader(); + alert("No text found."); + return; + } + + let genText = markerState[id]?.ttsText || extractPlainTextForMarker(markerEl, allMarkers); + + // Get image data from marker state + const imageData = markerState[id]?.imageData || ''; + const imageFormat = markerState[id]?.imageFormat || 'png'; + + // Find chapter number + let chapterNum = 0; + for (let i = myIdx - 1; i >= 0; i--) { + if (allMarkers[i].classList.contains('chapter-marker')) { + chapterNum = allMarkers[i].querySelector('input[type="number"]').value; + break; + } + } + + const trackId = await processSingleSection(chapterNum, num, genText, displayText, htmlContent, voice, id, true, imageData, imageFormat); + if (trackId) loadTrackFromPlaylist(trackId); + + } + // --- Generate for entire chapter --- + else if (type === 'chapter') { + await generateChapterAudio(markerEl, allMarkers, myIdx, num, voice, id); + } +} + +/** + * Generate audio for entire chapter + * @param {Element} markerEl - Chapter marker element + * @param {Element[]} allMarkers - All marker elements + * @param {number} myIdx - Index of chapter marker + * @param {number} num - Chapter number + * @param {string} voice - Voice ID + * @param {string} id - Marker ID + */ +async function generateChapterAudio(markerEl, allMarkers, myIdx, num, voice, id) { + const sectionsToGenerate = []; + + // Check for implicit content after chapter marker + let hasDirectContent = false; + let nextEl = markerEl.nextSibling; + while (nextEl) { + if (nextEl.nodeType === 1 && nextEl.classList && nextEl.classList.contains('editor-marker')) { + if (nextEl.classList.contains('section-marker')) break; + else if (nextEl.classList.contains('chapter-marker')) break; + } else if (nextEl.nodeType === 1 || (nextEl.nodeType === 3 && nextEl.textContent.trim())) { + hasDirectContent = true; + } + nextEl = nextEl.nextSibling; + } + + // Add implicit section if content exists without section markers + if (hasDirectContent) { + let nextMarkerIdx = myIdx + 1; + let nextMarker = nextMarkerIdx < allMarkers.length ? allMarkers[nextMarkerIdx] : null; + + if (!nextMarker || nextMarker.classList.contains('chapter-marker')) { + const secHtml = extractHtmlForMarker(markerEl, allMarkers); + const secDisplay = extractMarkdownForMarker(markerEl, allMarkers); + const secPlain = extractPlainTextForMarker(markerEl, allMarkers); + + if (secDisplay && secDisplay.trim().length > 1) { + sectionsToGenerate.push({ + id: id + '_implicit_1', + num: 1, + genText: secPlain, + displayText: secDisplay, + htmlContent: secHtml, + voice: voice, + imageData: '', + imageFormat: 'png' + }); + } + } + } + + // Collect explicit section markers in this chapter + for (let i = myIdx + 1; i < allMarkers.length; i++) { + const m = allMarkers[i]; + if (m.classList.contains('chapter-marker')) break; + if (m.classList.contains('section-marker')) { + const secId = m.dataset.markerId; + const secNum = m.querySelector('input[type="number"]').value; + const secVoice = m.querySelector('select').value; + const secHtml = extractHtmlForMarker(m, allMarkers); + const secDisplay = extractMarkdownForMarker(m, allMarkers); + const secPlain = extractPlainTextForMarker(m, allMarkers); + const secGen = markerState[secId]?.ttsText || secPlain; + const imageData = markerState[secId]?.imageData || ''; + const imageFormat = markerState[secId]?.imageFormat || 'png'; + + if (secDisplay && secDisplay.trim().length > 1) { + sectionsToGenerate.push({ + id: secId, + num: secNum, + genText: secGen, + displayText: secDisplay, + htmlContent: secHtml, + voice: secVoice, + imageData: imageData, + imageFormat: imageFormat + }); + } + } + } + + if (sectionsToGenerate.length === 0) { + hideLoader(); + alert("No sections found in this chapter."); + return; + } + + console.log(`📚 Found ${sectionsToGenerate.length} sections in Chapter ${num}`); + + // Generate audio for each section + showLoader(`Generating ${sectionsToGenerate.length} sections...`, 'This may take a while...'); + let firstId = null; + + for (let i = 0; i < sectionsToGenerate.length; i++) { + const sec = sectionsToGenerate[i]; + document.getElementById('loadingText').textContent = `Generating section ${i + 1} of ${sectionsToGenerate.length}...`; + document.getElementById('loadingSubtext').textContent = `Chapter ${num}, Section ${sec.num}`; + + console.log(`🔊 Generating Ch${num}.Sec${sec.num}...`); + + const trackId = await processSingleSection(num, sec.num, sec.genText, sec.displayText, sec.htmlContent, sec.voice, sec.id, false, sec.imageData, sec.imageFormat); + + if (trackId && i === 0) firstId = trackId; + } + + hideLoader(); + await refreshLibraryStats(); + + if (firstId) { + loadTrackFromPlaylist(firstId); + alert(`Chapter ${num} generation complete! Generated ${sectionsToGenerate.length} sections.`); + } +} + +/** + * Process single section generation + * @param {number} cNum - Chapter number + * @param {number} sNum - Section number + * @param {string} genText - Text for TTS generation + * @param {string} displayText - Display text (markdown) + * @param {string} htmlContent - HTML content + * @param {string} voice - Voice ID + * @param {string} markerId - Marker ID + * @param {boolean} autoHide - Whether to auto-hide loader + * @param {string} imageData - Base64 image data + * @param {string} imageFormat - Image format (png, jpg, etc.) + * @returns {Promise} Track ID or null + */ +async function processSingleSection(cNum, sNum, genText, displayText, htmlContent, voice, markerId, autoHide = true, imageData = '', imageFormat = 'png') { + if (autoHide) showLoader(`Generating Section ${cNum}.${sNum}...`, 'Creating audio and timestamps...'); + + try { + const res = await fetch('/generate', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ text: genText, voice, save_to_db: false }) + }); + const data = await res.json(); + if (data.error) throw new Error(data.error); + + const track = { + id: markerId || (Date.now() + '_' + Math.random().toString(36).substr(2, 9)), + chapter: cNum, + section: sNum, + audioData: data.audio_data, + audioFormat: data.audio_format, + transcription: data.transcription, + text: displayText, + htmlContent: htmlContent, + ttsText: genText, + voice: voice, + imageData: imageData, + imageFormat: imageFormat + }; + + // Update playlist + const existingIdx = playlist.findIndex(t => t.chapter == cNum && t.section == sNum); + if (existingIdx !== -1) { + playlist[existingIdx] = track; + } else { + playlist.push(track); + } + + // Sort playlist by chapter then section + playlist.sort((a, b) => { + if (Number(a.chapter) !== Number(b.chapter)) return Number(a.chapter) - Number(b.chapter); + return Number(a.section) - Number(b.section); + }); + + updatePlaylistUI(); + + // Save to database + await saveSectionToDatabase(cNum, sNum, track); + + if (autoHide) { + hideLoader(); + await refreshLibraryStats(); + } + + return track.id; + + } catch (e) { + console.error('Generation error:', e); + if (autoHide) { + hideLoader(); + alert("Error: " + e.message); + } + return null; + } +} + +// ========================================== +// LIBRARY MODAL FUNCTIONS +// ========================================== + +let libraryModal = null; + +/** + * Open library modal + */ +function openLibrary() { + if (!libraryModal) libraryModal = new bootstrap.Modal(document.getElementById('libraryModal')); + loadLibraryData(); + libraryModal.show(); +} + +/** + * Refresh library statistics + */ +async function refreshLibraryStats() { + try { + const res = await fetch('/db/stats'); + const stats = await res.json(); + document.getElementById('statUploads').textContent = stats.uploads; + document.getElementById('statGenerations').textContent = stats.generations; + document.getElementById('statProjects').textContent = stats.projects; + document.getElementById('statSections').textContent = stats.sections; + document.getElementById('statDbSize').textContent = stats.database_size_mb + ' MB'; + console.log('📊 Stats:', stats); + } catch (e) { + console.error('Stats error:', e); + } +} + +/** + * Load all library data + */ +async function loadLibraryData() { + await refreshLibraryStats(); + await Promise.all([loadUploads(), loadGenerations(), loadProjects()]); +} + +/** + * Load uploads list + */ +async function loadUploads() { + const container = document.getElementById('uploadsList'); + try { + const data = await (await fetch('/uploads')).json(); + container.innerHTML = data.uploads.length === 0 + ? '
No uploads yet
' + : data.uploads.map(u => ` +
+
+
${u.filename}
+
${u.audio_format.toUpperCase()} • ${formatBytes(u.audio_size)} • ${formatDate(u.created_at)}
+
+
+ + + +
+
`).join(''); + } catch (e) { + container.innerHTML = '
Failed to load
'; + } +} + +/** + * Load generations list + */ +async function loadGenerations() { + const container = document.getElementById('generationsList'); + try { + const data = await (await fetch('/generations')).json(); + container.innerHTML = data.generations.length === 0 + ? '
No generations yet
' + : data.generations.map(g => ` +
+
+
${g.name}
+
Voice: ${g.voice} • ${formatBytes(g.audio_size)} • ${formatDate(g.created_at)}
+
+
+ + + +
+
`).join(''); + } catch (e) { + container.innerHTML = '
Failed to load
'; + } +} + +/** + * Load projects list + */ +async function loadProjects() { + const container = document.getElementById('projectsList'); + try { + const data = await (await fetch('/projects')).json(); + container.innerHTML = data.projects.length === 0 + ? '
No projects yet
' + : data.projects.map(p => ` +
+
+
${p.name}
+
${p.section_count} sections • ${formatDate(p.updated_at)}
+
+
+ + + +
+
`).join(''); + } catch (e) { + container.innerHTML = '
Failed to load
'; + } +} + +// ========================================== +// LIBRARY ITEM LOADERS +// ========================================== + +/** + * Load upload by ID + * @param {number} id - Upload ID + */ +async function loadUpload(id) { + showLoader('Loading...', 'Retrieving upload data...'); + try { + const data = await (await fetch(`/uploads/${id}`)).json(); + if (data.error) throw new Error(data.error); + if (libraryModal) libraryModal.hide(); + initApp(data); + } catch (e) { + alert(e.message); + } finally { + hideLoader(); + } +} + +/** + * Load generation by ID + * @param {number} id - Generation ID + */ +async function loadGeneration(id) { + showLoader('Loading...', 'Retrieving generation data...'); + try { + const data = await (await fetch(`/generations/${id}`)).json(); + if (data.error) throw new Error(data.error); + if (libraryModal) libraryModal.hide(); + initApp(data); + } catch (e) { + alert(e.message); + } finally { + hideLoader(); + } +} + +/** + * Load project by ID + * @param {number} id - Project ID + */ +async function loadProject(id) { + showLoader('Loading project...', 'Retrieving all sections...'); + try { + const data = await (await fetch(`/projects/${id}`)).json(); + if (data.error) throw new Error(data.error); + + if (libraryModal) libraryModal.hide(); + document.getElementById('bulk-tab').click(); + document.getElementById('bulkProjectName').value = data.name; + currentProjectId = id; + + const editor = document.getElementById('bulk-editor'); + editor.innerHTML = ''; + Object.keys(markerState).forEach(key => delete markerState[key]); + chapterCounter = 1; + sectionCounter = 1; + + // Group sections by chapter + const chapters = {}; + data.sections.forEach(sec => { + if (!chapters[sec.chapter]) chapters[sec.chapter] = []; + chapters[sec.chapter].push(sec); + }); + + // Rebuild editor + const sortedChapterNums = Object.keys(chapters).sort((a, b) => Number(a) - Number(b)); + + sortedChapterNums.forEach(chapterNum => { + const chapterSections = chapters[chapterNum]; + const chapterVoice = chapterSections[0]?.voice || 'af_alloy'; + const chapterMarkerId = `ch_${chapterNum}_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`; + + // Insert chapter marker + const chapterMarkerHtml = createMarkerHTML('chapter', chapterNum, chapterVoice, chapterMarkerId); + editor.insertAdjacentHTML('beforeend', chapterMarkerHtml); + + if (Number(chapterNum) >= chapterCounter) chapterCounter = Number(chapterNum) + 1; + + // Sort and insert sections + chapterSections.sort((a, b) => a.section - b.section); + + chapterSections.forEach(sec => { + const secMarkerId = `sec_${chapterNum}_${sec.section}_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`; + const secMarkerHtml = createMarkerHTML('section', sec.section, sec.voice, secMarkerId); + editor.insertAdjacentHTML('beforeend', secMarkerHtml); + + const marker = document.getElementById(`marker-${secMarkerId}`); + if (marker) { + let content = sec.html_content; + if (!content || content.trim() === '') { + content = sec.text_content ? `

${sec.text_content}

` : '


'; + } + + // Remove placeholder paragraph + let nextEl = marker.nextElementSibling; + if (nextEl && nextEl.tagName === 'P' && (nextEl.innerHTML === '
' || nextEl.innerHTML.trim() === '')) { + nextEl.remove(); + } + + // Insert content after marker + const tempContainer = document.createElement('div'); + tempContainer.innerHTML = content; + const insertBeforeElement = marker.nextSibling; + while (tempContainer.firstChild) { + editor.insertBefore(tempContainer.firstChild, insertBeforeElement); + } + + // Ensure at least one paragraph exists after content + if (!marker.nextSibling || (marker.nextSibling.classList && marker.nextSibling.classList.contains('editor-marker'))) { + const emptyP = document.createElement('p'); + emptyP.innerHTML = '
'; + if (insertBeforeElement) { + editor.insertBefore(emptyP, insertBeforeElement); + } else { + editor.appendChild(emptyP); + } + } + } + + // Restore TTS text to marker state + if (sec.tts_text) { + updateMarkerData(secMarkerId, 'ttsText', sec.tts_text); + } + + // Restore image data to marker state and show preview + if (sec.image_data && sec.image_data.length > 0) { + updateMarkerData(secMarkerId, 'imageData', sec.image_data); + updateMarkerData(secMarkerId, 'imageFormat', sec.image_format || 'png'); + + // Show image preview after DOM is ready + setTimeout(() => { + const imgFormat = sec.image_format || 'png'; + const dataUrl = `data:image/${imgFormat};base64,${sec.image_data}`; + showImagePreview(secMarkerId, dataUrl, 'Loaded image', 0, imgFormat); + }, 100); + } + + if (Number(sec.section) >= sectionCounter) sectionCounter = Number(sec.section) + 1; + }); + }); + + // Initialize image handlers for newly created markers + setTimeout(() => { + initializeImageHandlers(); + }, 300); + + // Build playlist from sections with audio + playlist = data.sections + .filter(s => s.audio_data && s.audio_data.length > 0) + .map(sec => ({ + id: `sec_${sec.chapter}_${sec.section}_loaded`, + chapter: sec.chapter, + section: sec.section, + audioData: sec.audio_data, + audioFormat: sec.audio_format || 'mp3', + transcription: sec.transcription || [], + text: sec.text_content, + htmlContent: sec.html_content, + ttsText: sec.tts_text, + voice: sec.voice, + imageData: sec.image_data || '', + imageFormat: sec.image_format || 'png' + })) + .sort((a, b) => { + if (Number(a.chapter) !== Number(b.chapter)) return Number(a.chapter) - Number(b.chapter); + return Number(a.section) - Number(b.section); + }); + + updatePlaylistUI(); + document.getElementById('editorSection').classList.remove('d-none'); + + // Load first track if available + if (playlist.length > 0) { + loadTrackFromPlaylist(playlist[0].id); + } + + console.log(`✅ Loaded project: ${data.name} with ${data.sections.length} sections, ${playlist.length} with audio`); + + } catch (e) { + alert(e.message); + console.error('Load project error:', e); + } finally { + hideLoader(); + } +} + +// ========================================== +// DOWNLOAD FUNCTIONS +// ========================================== + +/** + * Download upload by ID + * @param {number} id - Upload ID + */ +function downloadUpload(id) { + window.location.href = `/uploads/${id}/download`; +} + +/** + * Download generation by ID + * @param {number} id - Generation ID + */ +function downloadGeneration(id) { + window.location.href = `/generations/${id}/download`; +} + +/** + * Download project by ID + * @param {number} id - Project ID + */ +function downloadProject(id) { + window.location.href = `/projects/${id}/download`; +} + +// ========================================== +// DELETE FUNCTIONS +// ========================================== + +/** + * Delete upload by ID + * @param {number} id - Upload ID + */ +async function deleteUpload(id) { + if (!confirm('Delete this upload?')) return; + showLoader('Deleting...', 'Removing upload from database...'); + try { + await fetch(`/uploads/${id}`, { method: 'DELETE' }); + await loadLibraryData(); + } catch (e) { + alert(e.message); + } finally { + hideLoader(); + } +} + +/** + * Delete generation by ID + * @param {number} id - Generation ID + */ +async function deleteGeneration(id) { + if (!confirm('Delete this generation?')) return; + showLoader('Deleting...', 'Removing generation from database...'); + try { + await fetch(`/generations/${id}`, { method: 'DELETE' }); + await loadLibraryData(); + } catch (e) { + alert(e.message); + } finally { + hideLoader(); + } +} + +/** + * Delete project by ID + * @param {number} id - Project ID + */ +async function deleteProject(id) { + if (!confirm('Delete this project and all its sections?')) return; + showLoader('Deleting...', 'Removing project from database...'); + try { + await fetch(`/projects/${id}`, { method: 'DELETE' }); + await loadLibraryData(); + } catch (e) { + alert(e.message); + } finally { + hideLoader(); + } +} + +// ========================================== +// INITIALIZATION +// ========================================== + +// Initialize on DOM ready +document.addEventListener('DOMContentLoaded', function() { + initUploadForm(); + refreshLibraryStats(); +}); diff --git a/static/js/editor.js b/static/js/editor.js new file mode 100644 index 0000000..d311ccb --- /dev/null +++ b/static/js/editor.js @@ -0,0 +1,1070 @@ +/** + * Editor Module + * Handles bulk editor, markers, text formatting, image uploads, and content management + */ + +// ========================================== +// VOICE OPTIONS CONFIGURATION +// ========================================== +const VOICES = [ + {val: 'af_alloy', label: 'Alloy (US Fem)'}, + {val: 'af_aoede', label: 'Aoede (US Fem)'}, + {val: 'af_bella', label: 'Bella (US Fem)'}, + {val: 'af_heart', label: 'Heart (US Fem)'}, + {val: 'af_jessica', label: 'Jessica (US Fem)'}, + {val: 'af_nicole', label: 'Nicole (US Fem)'}, + {val: 'af_nova', label: 'Nova (US Fem)'}, + {val: 'af_river', label: 'River (US Fem)'}, + {val: 'af_sarah', label: 'Sarah (US Fem)'}, + {val: 'af_sky', label: 'Sky (US Fem)'}, + {val: 'am_adam', label: 'Adam (US Masc)'}, + {val: 'am_echo', label: 'Echo (US Masc)'}, + {val: 'am_eric', label: 'Eric (US Masc)'}, + {val: 'am_michael', label: 'Michael (US Masc)'}, + {val: 'bf_emma', label: 'Emma (UK Fem)'}, + {val: 'bf_isabella', label: 'Isabella (UK Fem)'}, + {val: 'bm_daniel', label: 'Daniel (UK Masc)'}, + {val: 'bm_george', label: 'George (UK Masc)'}, +]; + +// ========================================== +// MARKER STATE MANAGEMENT +// ========================================== +let ttsModal = null; +const markerState = {}; +let chapterCounter = 1; +let sectionCounter = 1; + +// ========================================== +// TOGGLE FLOATING CONTROLS +// ========================================== + +/** + * Toggle floating controls visibility + * @param {boolean} show - Whether to show or hide controls + */ +function toggleFloatingControls(show) { + const floatingControls = document.getElementById('floatingControls'); + const plNav = document.getElementById('playlistNavigator'); + const singleExportGroup = document.getElementById('singleExportGroup'); + + if (floatingControls) { + if (show) { + floatingControls.classList.add('visible'); + floatingControls.style.display = 'flex'; + } else { + floatingControls.classList.remove('visible'); + floatingControls.style.display = 'none'; + } + } + + if (show) { + if (singleExportGroup) singleExportGroup.style.display = 'none'; + if (plNav) plNav.style.display = 'block'; + } else { + if (singleExportGroup) singleExportGroup.style.display = 'flex'; + if (plNav) plNav.style.display = 'none'; + } +} + +// Make it globally available +window.toggleFloatingControls = toggleFloatingControls; + +// ========================================== +// VOICE SELECT POPULATION +// ========================================== + +/** + * Populate voice dropdown selects + */ +function populateVoiceSelects() { + const opts = VOICES.map(v => ``).join(''); + const voiceSelect = document.getElementById('voiceSelect'); + if (voiceSelect) { + voiceSelect.innerHTML = opts; + } +} + +// ========================================== +// MARKER CREATION FUNCTIONS +// ========================================== + +/** + * Insert chapter marker at cursor position + */ +function insertChapterMarker() { + sectionCounter = 1; + const marker = createMarkerHTML('chapter', chapterCounter++); + insertHtmlAtCursor(marker); + + // Focus back on editor + const editor = document.getElementById('bulk-editor'); + if (editor) editor.focus(); +} + +/** + * Insert section marker at cursor position + */ +function insertSectionMarker() { + const marker = createMarkerHTML('section', sectionCounter++); + insertHtmlAtCursor(marker); + + // Initialize image handlers after DOM update + setTimeout(() => { + initializeImageHandlers(); + }, 100); + + // Focus back on editor + const editor = document.getElementById('bulk-editor'); + if (editor) editor.focus(); +} + +// Make functions globally available +window.insertChapterMarker = insertChapterMarker; +window.insertSectionMarker = insertSectionMarker; + +/** + * Generate marker HTML + * @param {string} type - 'chapter' or 'section' + * @param {number} num - Marker number + * @param {string} voice - Voice ID + * @param {string} markerId - Optional marker ID + * @returns {string} HTML string + */ +function createMarkerHTML(type, num, voice = 'af_alloy', markerId = null) { + const id = markerId || (Date.now() + '_' + Math.random().toString(36).substr(2, 9)); + const title = type.toUpperCase(); + const btnClass = type === 'chapter' ? 'btn-danger' : 'btn-primary'; + + const voiceOpts = VOICES.map(v => + `` + ).join(''); + + // Section-specific controls including image upload + let extraControls = ''; + let imageSection = ''; + + if (type === 'section') { + extraControls = ` +
+
+ + + + + +
+ + +
+ `; + + imageSection = ` +
+ +
+ + Drop image here, click to browse, or paste from clipboard +
+
+ Section image +
+ +
+
+
+
+ `; + } + + return ` +
+
+ ${title} +
+
+ # + +
+ + ${extraControls} +
+ + +
+
+ ${imageSection} +
+


`; +} + +// Make it globally available +window.createMarkerHTML = createMarkerHTML; + +/** + * Remove marker from editor + * @param {string} id - Marker ID + */ +function removeMarker(id) { + if (confirm("Remove this marker?")) { + const el = document.getElementById(`marker-${id}`); + if (el) el.remove(); + // Clean up marker state + if (markerState[id]) delete markerState[id]; + } +} + +// Make it globally available +window.removeMarker = removeMarker; + +// ========================================== +// IMAGE HANDLING FUNCTIONS +// ========================================== + +/** + * Trigger image file input click + * @param {string} markerId - Marker ID + */ +function triggerImageUpload(markerId) { + const input = document.getElementById(`image-input-${markerId}`); + if (input) input.click(); +} + +// Make it globally available +window.triggerImageUpload = triggerImageUpload; + +/** + * Handle image file selection + * @param {Event} event - File input change event + * @param {string} markerId - Marker ID + */ +function handleImageSelect(event, markerId) { + const file = event.target.files[0]; + if (file) { + processImageFile(file, markerId); + } +} + +// Make it globally available +window.handleImageSelect = handleImageSelect; + +/** + * Process image file and convert to base64 + * @param {File} file - Image file + * @param {string} markerId - Marker ID + */ +function processImageFile(file, markerId) { + // Validate file type + const validTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp']; + if (!validTypes.includes(file.type)) { + alert('Please select a valid image file (PNG, JPG, GIF, or WebP)'); + return; + } + + // Validate file size (max 10MB) + const maxSize = 10 * 1024 * 1024; + if (file.size > maxSize) { + alert('Image file is too large. Maximum size is 10MB.'); + return; + } + + const reader = new FileReader(); + reader.onload = function(e) { + const base64Data = e.target.result.split(',')[1]; // Remove data:image/xxx;base64, prefix + const format = file.type.split('/')[1]; // Get format from MIME type + + // Store in marker state + updateMarkerData(markerId, 'imageData', base64Data); + updateMarkerData(markerId, 'imageFormat', format === 'jpeg' ? 'jpg' : format); + + // Update UI + showImagePreview(markerId, e.target.result, file.name, file.size, format); + }; + reader.readAsDataURL(file); +} + +/** + * Show image preview in the marker + * @param {string} markerId - Marker ID + * @param {string} dataUrl - Image data URL for preview + * @param {string} fileName - Original file name + * @param {number} fileSize - File size in bytes + * @param {string} format - Image format + */ +function showImagePreview(markerId, dataUrl, fileName, fileSize, format) { + const container = document.getElementById(`image-container-${markerId}`); + const dropZone = document.getElementById(`drop-zone-${markerId}`); + const previewWrapper = document.getElementById(`preview-wrapper-${markerId}`); + const preview = document.getElementById(`image-preview-${markerId}`); + const info = document.getElementById(`image-info-${markerId}`); + const marker = document.getElementById(`marker-${markerId}`); + + if (container && dropZone && previewWrapper && preview) { + dropZone.classList.add('d-none'); + previewWrapper.classList.remove('d-none'); + preview.src = dataUrl; + container.classList.add('has-image'); + + if (info) { + const sizeKB = (fileSize / 1024).toFixed(1); + info.textContent = `${fileName || 'Image'} • ${sizeKB > 0 ? sizeKB + ' KB' : 'Loaded'} • ${(format || 'png').toUpperCase()}`; + } + + if (marker) { + marker.classList.add('has-image'); + } + } +} + +// Make it globally available +window.showImagePreview = showImagePreview; + +/** + * Remove image from marker + * @param {string} markerId - Marker ID + */ +function removeImage(markerId) { + const container = document.getElementById(`image-container-${markerId}`); + const dropZone = document.getElementById(`drop-zone-${markerId}`); + const previewWrapper = document.getElementById(`preview-wrapper-${markerId}`); + const input = document.getElementById(`image-input-${markerId}`); + const marker = document.getElementById(`marker-${markerId}`); + + if (container && dropZone && previewWrapper) { + dropZone.classList.remove('d-none'); + previewWrapper.classList.add('d-none'); + container.classList.remove('has-image'); + + if (input) input.value = ''; + + if (marker) { + marker.classList.remove('has-image'); + } + } + + // Clear from marker state + updateMarkerData(markerId, 'imageData', ''); + updateMarkerData(markerId, 'imageFormat', ''); +} + +// Make it globally available +window.removeImage = removeImage; + +/** + * Initialize image drag-drop and paste handlers for all containers + */ +function initializeImageHandlers() { + const containers = document.querySelectorAll('.section-image-container'); + + containers.forEach(container => { + const markerId = container.dataset.markerId; + if (!markerId) return; + + // Skip if already initialized + if (container.dataset.initialized === 'true') return; + container.dataset.initialized = 'true'; + + // Drag and drop handlers + container.addEventListener('dragover', (e) => { + e.preventDefault(); + e.stopPropagation(); + container.classList.add('drag-over'); + }); + + container.addEventListener('dragleave', (e) => { + e.preventDefault(); + e.stopPropagation(); + container.classList.remove('drag-over'); + }); + + container.addEventListener('drop', (e) => { + e.preventDefault(); + e.stopPropagation(); + container.classList.remove('drag-over'); + + const files = e.dataTransfer.files; + if (files.length > 0) { + processImageFile(files[0], markerId); + } + }); + }); +} + +// Make it globally available +window.initializeImageHandlers = initializeImageHandlers; + +/** + * Handle paste event for images + * @param {ClipboardEvent} e - Paste event + */ +function handlePasteImage(e) { + const items = e.clipboardData?.items; + if (!items) return; + + // Check if we're in the bulk editor tab + const bulkPanel = document.getElementById('bulk-panel'); + if (!bulkPanel || !bulkPanel.classList.contains('show') && !bulkPanel.classList.contains('active')) return; + + // Find the focused section marker or the last one + const editor = document.getElementById('bulk-editor'); + let sectionMarker = null; + + // Try to find marker from current selection + const selection = window.getSelection(); + if (selection && selection.anchorNode) { + let currentNode = selection.anchorNode; + + // Walk up the DOM to find a section marker + while (currentNode && currentNode !== editor && currentNode !== document.body) { + if (currentNode.nodeType === 1) { + // Check if this is a section marker + if (currentNode.classList && currentNode.classList.contains('section-marker')) { + sectionMarker = currentNode; + break; + } + + // Check previous siblings + let prevSibling = currentNode.previousElementSibling; + while (prevSibling) { + if (prevSibling.classList && prevSibling.classList.contains('section-marker')) { + sectionMarker = prevSibling; + break; + } + prevSibling = prevSibling.previousElementSibling; + } + + if (sectionMarker) break; + } + currentNode = currentNode.parentNode; + } + } + + // If no section marker found from selection, use the last one + if (!sectionMarker) { + const allSectionMarkers = document.querySelectorAll('.section-marker'); + if (allSectionMarkers.length > 0) { + sectionMarker = allSectionMarkers[allSectionMarkers.length - 1]; + } + } + + if (!sectionMarker) return; + + const markerId = sectionMarker.dataset.markerId; + if (!markerId) return; + + // Check for image in clipboard + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.type.startsWith('image/')) { + e.preventDefault(); + e.stopPropagation(); + + const file = item.getAsFile(); + if (file) { + processImageFile(file, markerId); + } + break; + } + } +} + +// ========================================== +// MARKER DATA MANAGEMENT +// ========================================== + +/** + * Update marker data in state + * @param {string} id - Marker ID + * @param {string} key - Data key + * @param {*} val - Data value + */ +function updateMarkerData(id, key, val) { + if (!markerState[id]) markerState[id] = {}; + markerState[id][key] = val; +} + +// Make it globally available +window.updateMarkerData = updateMarkerData; + +/** + * Get marker data from state + * @param {string} id - Marker ID + * @param {string} key - Data key + * @returns {*} Data value or undefined + */ +function getMarkerData(id, key) { + if (!markerState[id]) return undefined; + return markerState[id][key]; +} + +/** + * Open TTS text editor modal + * @param {string} markerId - Marker ID + */ +function openTTSEditor(markerId) { + if (!ttsModal) ttsModal = new bootstrap.Modal(document.getElementById('ttsEditModal')); + document.getElementById('currentMarkerId').value = markerId; + const marker = document.getElementById(`marker-${markerId}`); + const allMarkers = Array.from(document.querySelectorAll('.editor-marker')); + + if (markerState[markerId] && markerState[markerId].ttsText) { + document.getElementById('ttsTextInput').value = markerState[markerId].ttsText; + } else { + document.getElementById('ttsTextInput').value = extractPlainTextForMarker(marker, allMarkers); + } + ttsModal.show(); +} + +// Make it globally available +window.openTTSEditor = openTTSEditor; + +/** + * Save TTS text from modal + */ +function saveTTSText() { + const markerId = document.getElementById('currentMarkerId').value; + updateMarkerData(markerId, 'ttsText', document.getElementById('ttsTextInput').value); + if (ttsModal) ttsModal.hide(); +} + +// Make it globally available +window.saveTTSText = saveTTSText; + +// ========================================== +// TEXT FORMATTING FUNCTIONS +// ========================================== + +/** + * Apply formatting command + * @param {string} command - execCommand name + */ +function applyFormat(command) { + document.execCommand(command, false, null); +} + +// Make it globally available +window.applyFormat = applyFormat; + +/** + * Format block element + * @param {string} tag - HTML tag name + */ +function formatBlock(tag) { + document.execCommand('formatBlock', false, tag); +} + +// Make it globally available +window.formatBlock = formatBlock; + +/** + * Normalize section text (clean up formatting) + * @param {string} markerId - Marker ID + */ +function normalizeSection(markerId) { + const marker = document.getElementById(`marker-${markerId}`); + if (!marker) return; + + let currentNode = marker.nextSibling; + const nodesToRemove = []; + let collectedText = ""; + + while (currentNode) { + if (currentNode.nodeType === 1 && currentNode.classList && currentNode.classList.contains('editor-marker')) break; + collectedText += currentNode.textContent + " "; + nodesToRemove.push(currentNode); + currentNode = currentNode.nextSibling; + } + + const cleanText = collectedText.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').trim(); + const p = document.createElement('p'); + p.textContent = cleanText || ''; + if (!cleanText) p.innerHTML = '
'; + marker.after(p); + nodesToRemove.forEach(n => n.remove()); +} + +// Make it globally available +window.normalizeSection = normalizeSection; + +/** + * Insert HTML at cursor position + * @param {string} html - HTML string to insert + */ +function insertHtmlAtCursor(html) { + const sel = window.getSelection(); + const editor = document.getElementById('bulk-editor'); + + // Make sure we're focused on the editor + if (!editor.contains(sel.anchorNode)) { + editor.focus(); + // Place cursor at the end if not in editor + const range = document.createRange(); + range.selectNodeContents(editor); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + } + + if (sel.getRangeAt && sel.rangeCount) { + let range = sel.getRangeAt(0); + range.deleteContents(); + const el = document.createElement("div"); + el.innerHTML = html; + let frag = document.createDocumentFragment(), node, lastNode; + while ((node = el.firstChild)) lastNode = frag.appendChild(node); + range.insertNode(frag); + if (lastNode) { + range = range.cloneRange(); + range.setStartAfter(lastNode); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + } + } +} + +// ========================================== +// TEXT EXTRACTION FUNCTIONS +// ========================================== + +/** + * Extract HTML content from marker to next marker + * @param {Element} marker - Marker element + * @param {Element[]} allMarkers - All marker elements + * @returns {string} HTML content + */ +function extractHtmlForMarker(marker, allMarkers = []) { + const editor = document.getElementById('bulk-editor'); + if (allMarkers.length === 0) allMarkers = Array.from(document.querySelectorAll('.editor-marker')); + const myIndex = allMarkers.indexOf(marker); + const nextMarker = (myIndex !== -1 && myIndex < allMarkers.length - 1) ? allMarkers[myIndex + 1] : null; + + const range = document.createRange(); + range.setStartAfter(marker); + if (nextMarker) { + range.setEndBefore(nextMarker); + } else { + range.setEndAfter(editor.lastChild || editor); + } + + const frag = range.cloneContents(); + const tempDiv = document.createElement('div'); + tempDiv.appendChild(frag); + return tempDiv.innerHTML; +} + +// Make it globally available +window.extractHtmlForMarker = extractHtmlForMarker; + +/** + * Extract plain text from marker + * @param {Element} marker - Marker element + * @param {Element[]} allMarkers - All marker elements + * @returns {string} Plain text content + */ +function extractPlainTextForMarker(marker, allMarkers = []) { + const html = extractHtmlForMarker(marker, allMarkers); + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; + return tempDiv.innerText.replace(/\n{3,}/g, '\n\n').trim(); +} + +// Make it globally available +window.extractPlainTextForMarker = extractPlainTextForMarker; + +/** + * Extract markdown from marker + * @param {Element} marker - Marker element + * @param {Element[]} allMarkers - All marker elements + * @returns {string} Markdown content + */ +function extractMarkdownForMarker(marker, allMarkers = []) { + const html = extractHtmlForMarker(marker, allMarkers); + return htmlToMarkdown(html); +} + +// Make it globally available +window.extractMarkdownForMarker = extractMarkdownForMarker; + +/** + * Convert HTML to Markdown + * @param {string} html - HTML string + * @returns {string} Markdown string + */ +function htmlToMarkdown(html) { + const temp = document.createElement('div'); + temp.innerHTML = html; + + temp.querySelectorAll('b, strong').forEach(el => el.replaceWith(`**${el.textContent}**`)); + temp.querySelectorAll('i, em').forEach(el => el.replaceWith(`*${el.textContent}*`)); + temp.querySelectorAll('h1').forEach(el => el.replaceWith(`# ${el.textContent}\n`)); + temp.querySelectorAll('h2').forEach(el => el.replaceWith(`## ${el.textContent}\n`)); + temp.querySelectorAll('h3').forEach(el => el.replaceWith(`### ${el.textContent}\n`)); + + let text = temp.innerHTML; + text = text.replace(//gi, '\n'); + text = text.replace(/<\/p>/gi, '\n\n'); + text = text.replace(/

/gi, ''); + text = text.replace(/<[^>]+>/g, ''); + + const txt = document.createElement("textarea"); + txt.innerHTML = text; + return txt.value.trim(); +} + +// ========================================== +// SECTION COLLECTION FUNCTION +// ========================================== + +/** + * Collect all sections from editor (explicit + implicit) + * @returns {Object[]} Array of section objects + */ +function collectAllSectionsFromEditor() { + const allMarkers = Array.from(document.querySelectorAll('.editor-marker')); + const sections = []; + let currentChapter = 0; + let implicitSectionCounter = {}; + + for (let idx = 0; idx < allMarkers.length; idx++) { + const marker = allMarkers[idx]; + const markerId = marker.dataset.markerId; + + // --- Handle Chapter Markers --- + if (marker.classList.contains('chapter-marker')) { + currentChapter = marker.querySelector('input[type="number"]').value; + const chapterVoice = marker.querySelector('select').value; + + const nextMarker = (idx + 1 < allMarkers.length) ? allMarkers[idx + 1] : null; + + // Check for implicit section (content after chapter without section marker) + if (!nextMarker || nextMarker.classList.contains('chapter-marker')) { + const secHtml = extractHtmlForMarker(marker, allMarkers); + const secDisplay = extractMarkdownForMarker(marker, allMarkers); + const secPlain = extractPlainTextForMarker(marker, allMarkers); + + if (secPlain && secPlain.trim().length > 1) { + if (!implicitSectionCounter[currentChapter]) { + implicitSectionCounter[currentChapter] = 1; + } + + const secNum = implicitSectionCounter[currentChapter]++; + const playlistTrack = typeof playlist !== 'undefined' ? playlist.find(t => t.chapter == currentChapter && t.section == secNum) : null; + + sections.push({ + markerId: markerId + '_implicit_' + secNum, + chapter: currentChapter, + section: secNum, + text: secDisplay, + htmlContent: secHtml, + ttsText: secPlain, + voice: chapterVoice, + audioData: playlistTrack?.audioData || '', + audioFormat: playlistTrack?.audioFormat || 'mp3', + transcription: playlistTrack?.transcription || [], + imageData: '', + imageFormat: 'png', + isImplicit: true + }); + + console.log(`📝 Found implicit section: Ch${currentChapter}.Sec${secNum}`); + } + } + } + // --- Handle Section Markers --- + else if (marker.classList.contains('section-marker')) { + const secId = markerId; + const secNum = marker.querySelector('input[type="number"]').value; + const secVoice = marker.querySelector('select').value; + const secHtml = extractHtmlForMarker(marker, allMarkers); + const secPlain = extractPlainTextForMarker(marker, allMarkers); + const secDisplay = extractMarkdownForMarker(marker, allMarkers); + const secGen = markerState[secId]?.ttsText || secPlain; + + // Get image data from marker state + const imageData = markerState[secId]?.imageData || ''; + const imageFormat = markerState[secId]?.imageFormat || 'png'; + + const playlistTrack = typeof playlist !== 'undefined' ? playlist.find(t => t.chapter == currentChapter && t.section == secNum) : null; + + sections.push({ + markerId: secId, + chapter: currentChapter, + section: secNum, + text: secDisplay, + htmlContent: secHtml, + ttsText: secGen, + voice: secVoice, + audioData: playlistTrack?.audioData || '', + audioFormat: playlistTrack?.audioFormat || 'mp3', + transcription: playlistTrack?.transcription || [], + imageData: imageData, + imageFormat: imageFormat, + isImplicit: false + }); + + console.log(`📝 Found explicit section: Ch${currentChapter}.Sec${secNum} (image: ${imageData ? 'yes' : 'no'})`); + } + } + + return sections; +} + +// Make it globally available +window.collectAllSectionsFromEditor = collectAllSectionsFromEditor; + +// ========================================== +// SAVE PROJECT FUNCTION +// ========================================== + +/** + * Save project without generating audio + */ +async function saveProject() { + const projectName = document.getElementById('bulkProjectName').value.trim() || 'Book-1'; + const sections = collectAllSectionsFromEditor(); + + if (sections.length === 0) { + alert("No sections found. Add chapter and section markers first."); + return; + } + + showLoader("Saving Project...", `Saving ${sections.length} sections to "${projectName}"...`); + + try { + // Get or create project + const projectId = await getOrCreateProject(); + if (!projectId) { + throw new Error('Could not create or get project'); + } + + // Save all sections + const res = await fetch(`/projects/${projectId}/save_all`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ sections: sections }) + }); + + const result = await res.json(); + if (result.error) throw new Error(result.error); + + await refreshLibraryStats(); + + hideLoader(); + alert(`Project "${projectName}" saved successfully!\n${result.saved_count} sections saved.`); + + } catch (e) { + hideLoader(); + alert("Error saving project: " + e.message); + console.error('Save project error:', e); + } +} + +// Make it globally available +window.saveProject = saveProject; + +// ========================================== +// QUILL EDITOR SETUP +// ========================================== + +let quill = null; + +/** + * Initialize Quill editor + */ +function initQuillEditor() { + const quillContainer = document.getElementById('quill-editor'); + if (!quillContainer) return; + + quill = new Quill('#quill-editor', { + theme: 'snow', + modules: { + toolbar: [ + [{ 'header': [1, 2, 3, false] }], + ['bold', 'italic'], + ['clean'] + ] + }, + placeholder: 'Write here...' + }); +} + +/** + * Generate audio from Quill editor content + */ +async function generateAudio() { + if (!quill) return; + + const text = quill.getText().trim(); + const voice = document.getElementById('voiceSelect').value; + + if (text.length < 5) { + alert("Write some text first."); + return; + } + + showLoader("Generating...", "Creating audio and timestamps..."); + + try { + const res = await fetch('/generate', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ text, voice, save_to_db: true }) + }); + const data = await res.json(); + if (data.error) throw new Error(data.error); + initApp(data); + await refreshLibraryStats(); + } catch (e) { + alert(e.message); + } finally { + hideLoader(); + } +} + +// Make it globally available +window.generateAudio = generateAudio; + +// ========================================== +// EXPORT FUNCTIONS +// ========================================== + +/** + * Export all sections as zip + */ +async function exportEverything() { + const projectName = document.getElementById('bulkProjectName').value || 'Book-1'; + + const allSections = collectAllSectionsFromEditor(); + + if (allSections.length === 0) { + alert("No sections found. Add chapter and section markers first."); + return; + } + + showLoader("Exporting...", "Saving all sections and creating zip file..."); + + try { + const res = await fetch('/export_bulk', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ projectName, files: allSections }) + }); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.error || 'Export failed'); + } + + const blob = await res.blob(); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `${projectName}.zip`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + + await refreshLibraryStats(); + } catch (e) { + alert("Export error: " + e.message); + } finally { + hideLoader(); + } +} + +// Make it globally available +window.exportEverything = exportEverything; + +/** + * Export single file as zip + */ +async function exportSingle() { + const filename = document.getElementById('exportFilename').value || '1.1_task-1'; + + if (typeof currentAudioData === 'undefined' || !currentAudioData) { + alert("No project loaded."); + return; + } + + showLoader("Exporting...", "Creating zip file..."); + + try { + const res = await fetch('/export', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + filename, + text: document.getElementById('readerContent').innerText, + transcription: typeof transcriptionData !== 'undefined' ? transcriptionData : [], + audio_data: currentAudioData, + audio_format: typeof currentAudioFormat !== 'undefined' ? currentAudioFormat : 'mp3' + }) + }); + + if (!res.ok) throw new Error('Export failed'); + + const blob = await res.blob(); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `${filename}.zip`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } catch (e) { + alert(e.message); + } finally { + hideLoader(); + } +} + +// Make it globally available +window.exportSingle = exportSingle; + +// ========================================== +// INITIALIZATION +// ========================================== + +// Initialize on DOM ready +document.addEventListener('DOMContentLoaded', function() { + console.log('📝 Editor module initializing...'); + + // Populate voice selects + populateVoiceSelects(); + + // Initialize Quill editor + initQuillEditor(); + + // Initialize paste handler for images + document.addEventListener('paste', handlePasteImage); + + // Initialize image handlers after a short delay + setTimeout(initializeImageHandlers, 500); + + // Handle tab switching for floating controls using Bootstrap events + const bulkTab = document.getElementById('bulk-tab'); + if (bulkTab) { + bulkTab.addEventListener('shown.bs.tab', function() { + console.log('📝 Bulk tab shown, initializing image handlers...'); + setTimeout(initializeImageHandlers, 100); + }); + } + + console.log('✅ Editor module initialized'); +}); diff --git a/static/js/interactive-reader.js b/static/js/interactive-reader.js new file mode 100644 index 0000000..e72530b --- /dev/null +++ b/static/js/interactive-reader.js @@ -0,0 +1,270 @@ +/** + * Interactive Reader Module + * Handles text display, word highlighting, and audio sync + */ + +// ========================================== +// READER STATE VARIABLES +// ========================================== + +let allWordSpans = []; +let wordMap = []; +let sentenceData = []; +let lastHighlightedWordSpan = null; +let lastHighlightedSentenceSpans = []; + +// ========================================== +// READER INITIALIZATION +// ========================================== + +/** + * Initialize reader with markdown text + * @param {string} markdownText - Text content in markdown format + */ +function initReader(markdownText) { + const container = document.getElementById('readerContent'); + container.innerHTML = ''; + allWordSpans = []; + wordMap = []; + + const html = marked.parse(markdownText || '', { breaks: true }); + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; + + // Process nodes to wrap words in spans + processNode(tempDiv); + + while (tempDiv.firstChild) { + container.appendChild(tempDiv.firstChild); + } + + runSmartSync(); +} + +/** + * Process DOM node to wrap words in clickable spans + * @param {Node} node - DOM node to process + */ +function processNode(node) { + if (node.nodeType === Node.TEXT_NODE) { + const words = node.textContent.split(/(\s+|[^\w'])/g); + const fragment = document.createDocumentFragment(); + + words.forEach(part => { + if (part.trim().length > 0) { + const span = document.createElement('span'); + span.className = 'word'; + span.textContent = part; + span.onclick = handleWordClick; + allWordSpans.push(span); + fragment.appendChild(span); + } else { + fragment.appendChild(document.createTextNode(part)); + } + }); + + node.parentNode.replaceChild(fragment, node); + } else if (node.nodeType === Node.ELEMENT_NODE) { + Array.from(node.childNodes).forEach(processNode); + } +} + +/** + * Handle click on a word span + * @param {MouseEvent} e - Click event + */ +function handleWordClick(e) { + e.stopPropagation(); + const spanIndex = allWordSpans.indexOf(e.target); + const aiIdx = wordMap[spanIndex]; + + if (aiIdx !== undefined && transcriptionData[aiIdx] && wavesurfer) { + wavesurfer.setTime(transcriptionData[aiIdx].start); + wavesurfer.play(); + } +} + +// ========================================== +// TEXT-AUDIO SYNC FUNCTIONS +// ========================================== + +/** + * Run smart sync between text words and transcription data + */ +function runSmartSync() { + wordMap = new Array(allWordSpans.length).fill(undefined); + let aiIdx = 0; + let matchCount = 0; + + allWordSpans.forEach((span, i) => { + const clean = span.textContent.toLowerCase().replace(/[^\w]/g, ''); + + if (clean.length === 0) { + span.classList.add('unmatched'); + return; + } + + // Look ahead up to 5 positions for a match + for (let off = 0; off < 5; off++) { + if (aiIdx + off >= transcriptionData.length) break; + + const transcriptWord = transcriptionData[aiIdx + off].word.toLowerCase().replace(/[^\w]/g, ''); + + if (transcriptWord === clean) { + wordMap[i] = aiIdx + off; + aiIdx += off + 1; + span.classList.remove('unmatched'); + matchCount++; + return; + } + } + + span.classList.add('unmatched'); + }); + + mapSentences(); + updateSyncStatus(matchCount); +} + +/** + * Update sync status badge + * @param {number} matchCount - Number of matched words + */ +function updateSyncStatus(matchCount) { + const badge = document.getElementById('syncStatus'); + badge.textContent = `Synced (${matchCount}/${allWordSpans.length})`; + badge.className = matchCount > allWordSpans.length * 0.8 + ? 'badge bg-success' + : 'badge bg-warning text-dark'; +} + +/** + * Map sentences for sentence-level highlighting + */ +function mapSentences() { + sentenceData = []; + let buffer = []; + let startIdx = 0; + + allWordSpans.forEach((span, i) => { + buffer.push(span); + + // Check for sentence-ending punctuation + if (/[.!?]["']?$/.test(span.textContent.trim())) { + let startT = 0; + let endT = 0; + + // Find start time + for (let k = startIdx; k <= i; k++) { + if (wordMap[k] !== undefined) { + startT = transcriptionData[wordMap[k]].start; + break; + } + } + + // Find end time + for (let k = i; k >= startIdx; k--) { + if (wordMap[k] !== undefined) { + endT = transcriptionData[wordMap[k]].end; + break; + } + } + + if (endT > startT) { + sentenceData.push({ + spans: [...buffer], + start: startT, + end: endT + }); + } + + buffer = []; + startIdx = i + 1; + } + }); +} + +/** + * Sync reader highlighting with audio playback time + * @param {number} t - Current playback time in seconds + */ +function syncReader(t) { + // Highlight current word + highlightCurrentWord(t); + + // Highlight current sentence + highlightCurrentSentence(t); +} + +/** + * Highlight the current word based on playback time + * @param {number} t - Current playback time + */ +function highlightCurrentWord(t) { + const aiIdx = transcriptionData.findIndex(d => t >= d.start && t < d.end); + + if (aiIdx !== -1) { + const txtIdx = wordMap.findIndex(i => i === aiIdx); + + if (txtIdx !== -1 && allWordSpans[txtIdx] !== lastHighlightedWordSpan) { + // Remove previous highlight + if (lastHighlightedWordSpan) { + lastHighlightedWordSpan.classList.remove('current-word'); + } + + // Add new highlight + lastHighlightedWordSpan = allWordSpans[txtIdx]; + lastHighlightedWordSpan.classList.add('current-word'); + + // Scroll into view if needed + scrollWordIntoView(lastHighlightedWordSpan); + } + } +} + +/** + * Highlight the current sentence based on playback time + * @param {number} t - Current playback time + */ +function highlightCurrentSentence(t) { + const sent = sentenceData.find(s => t >= s.start && t <= s.end); + + if (sent && sent.spans !== lastHighlightedSentenceSpans) { + // Remove previous highlight + if (lastHighlightedSentenceSpans) { + lastHighlightedSentenceSpans.forEach(s => s.classList.remove('current-sentence-bg')); + } + + // Add new highlight + lastHighlightedSentenceSpans = sent.spans; + lastHighlightedSentenceSpans.forEach(s => s.classList.add('current-sentence-bg')); + } +} + +/** + * Scroll word into view if outside visible area + * @param {Element} wordSpan - Word span element + */ +function scrollWordIntoView(wordSpan) { + const r = wordSpan.getBoundingClientRect(); + const c = document.querySelector('.reader-section').getBoundingClientRect(); + + if (r.top < c.top || r.bottom > c.bottom) { + wordSpan.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }); + } +} + +// ========================================== +// MISMATCH TOGGLE +// ========================================== + +/** + * Toggle display of mismatched words + */ +function toggleMismatches() { + const toggle = document.getElementById('mismatchToggle'); + document.getElementById('readerContent').classList.toggle('show-mismatches', toggle.checked); +} diff --git a/static/js/timeline.js b/static/js/timeline.js new file mode 100644 index 0000000..bf254f9 --- /dev/null +++ b/static/js/timeline.js @@ -0,0 +1,460 @@ +/** + * Timeline Module + * Handles WaveSurfer, audio playback, word pills, and timeline interactions + */ + +// ========================================== +// GLOBAL STATE VARIABLES +// ========================================== + +// --- WaveSurfer Instance --- +let wavesurfer = null; + +// --- Transcription Data --- +let transcriptionData = []; + +// --- Timeline Settings --- +let pixelsPerSecond = 100; +let audioDuration = 0; + +// --- Current Audio --- +let currentAudioData = ""; +let currentAudioFormat = "mp3"; +let currentProjectId = null; + +// --- Playlist --- +let playlist = []; +let currentTrackIndex = -1; + +// --- Drag/Resize State --- +let isDragging = false; +let isResizing = false; +let isScrubbing = false; +let currentPill = null; +let currentIndex = -1; +let selectedPillIndex = -1; +let hasMovedDuringDrag = false; + +// ========================================== +// WAVESURFER INITIALIZATION +// ========================================== + +/** + * Initialize WaveSurfer from base64 audio data + * @param {string} base64Data - Base64 encoded audio + * @param {string} format - Audio format (mp3, wav, etc.) + */ +function initWaveSurferFromBase64(base64Data, format) { + if (wavesurfer) wavesurfer.destroy(); + + const byteArray = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0)); + const blob = new Blob([byteArray], { type: `audio/${format}` }); + const url = URL.createObjectURL(blob); + + wavesurfer = WaveSurfer.create({ + container: '#waveform', + waveColor: '#a5b4fc', + progressColor: '#818cf8', + url, + height: 120, + normalize: true, + minPxPerSec: pixelsPerSecond, + hideScrollbar: true, + plugins: [ + WaveSurfer.Timeline.create({ + container: '#timeline-ruler', + height: 25 + }) + ] + }); + + wavesurfer.on('decode', () => { + audioDuration = wavesurfer.getDuration(); + updateTimelineWidth(); + renderPills(); + }); + + wavesurfer.on('timeupdate', (t) => { + if (!isScrubbing) { + document.getElementById('custom-playhead').style.left = `${t * pixelsPerSecond}px`; + const wrapper = document.getElementById('timelineWrapper'); + if (t * pixelsPerSecond > wrapper.clientWidth / 2) { + wrapper.scrollLeft = t * pixelsPerSecond - wrapper.clientWidth / 2; + } + } + syncReader(t); + }); + + wavesurfer.on('finish', updatePlayBtn); + initScrubber(); +} + +// ========================================== +// PLAYHEAD/SCRUBBER FUNCTIONS +// ========================================== + +/** + * Initialize scrubber interactions + */ +function initScrubber() { + const playhead = document.getElementById('custom-playhead'); + const wrapper = document.getElementById('timelineContent'); + + playhead.onmousedown = (e) => { + isScrubbing = true; + e.preventDefault(); + }; + + document.onmousemove = (e) => { + if (!isScrubbing) return; + const time = Math.max(0, Math.min( + (e.clientX - wrapper.getBoundingClientRect().left) / pixelsPerSecond, + audioDuration + )); + playhead.style.left = `${time * pixelsPerSecond}px`; + wavesurfer.setTime(time); + }; + + document.onmouseup = () => isScrubbing = false; + + wrapper.onclick = (e) => { + if (!isDragging && !e.target.closest('.word-pill')) { + const time = (e.clientX - wrapper.getBoundingClientRect().left) / pixelsPerSecond; + if (time >= 0) wavesurfer.setTime(time); + } + }; +} + +// ========================================== +// TIMELINE WIDTH UPDATE +// ========================================== + +/** + * Update timeline width based on audio duration and zoom + */ +function updateTimelineWidth() { + if (wavesurfer) { + const w = wavesurfer.getDuration() * pixelsPerSecond; + document.getElementById('timelineContent').style.width = w + 'px'; + document.getElementById('waveform').style.width = w + 'px'; + } +} + +// ========================================== +// WORD PILL FUNCTIONS +// ========================================== + +/** + * Insert new pill at playhead position + */ +function insertPillAtPlayhead() { + if (!wavesurfer) return; + const t = wavesurfer.getCurrentTime(); + transcriptionData.push({ word: "New", start: t, end: t + 0.5 }); + transcriptionData.sort((a, b) => a.start - b.start); + renderPills(); + runSmartSync(); +} + +/** + * Delete currently selected pill + */ +function deleteSelectedPill() { + if (selectedPillIndex === -1) return; + transcriptionData.splice(selectedPillIndex, 1); + selectedPillIndex = -1; + renderPills(); + runSmartSync(); + document.getElementById('deleteBtn').disabled = true; +} + +/** + * Select a pill by index + * @param {number} index - Pill index + */ +function selectPill(index) { + document.querySelectorAll('.word-pill').forEach(p => p.classList.remove('selected')); + const pills = document.querySelectorAll('.word-pill'); + if (pills[index]) { + pills[index].classList.add('selected'); + selectedPillIndex = index; + document.getElementById('deleteBtn').disabled = false; + } +} + +/** + * Render all word pills on the timeline + */ +function renderPills() { + const container = document.getElementById('transcription-content'); + container.innerHTML = ''; + + transcriptionData.forEach((item, index) => { + const pill = document.createElement('div'); + pill.className = `word-pill ${index === selectedPillIndex ? 'selected' : ''}`; + pill.textContent = item.word; + pill.dataset.index = index; + pill.style.left = `${item.start * pixelsPerSecond}px`; + pill.style.width = `${(item.end - item.start) * pixelsPerSecond}px`; + + // Resize handles + const lh = document.createElement('div'); + lh.className = 'resize-handle resize-handle-left'; + const rh = document.createElement('div'); + rh.className = 'resize-handle resize-handle-right'; + pill.append(lh, rh); + + // Event handlers + pill.onmousedown = handleDragStart; + lh.onmousedown = (e) => handleResizeStart(e, 'left'); + rh.onmousedown = (e) => handleResizeStart(e, 'right'); + pill.onclick = (e) => { + e.stopPropagation(); + selectPill(index); + }; + pill.ondblclick = (e) => { + e.stopPropagation(); + const input = document.createElement('input'); + input.value = item.word; + input.style.cssText = 'all:unset;width:100%;text-align:center;'; + pill.innerHTML = ''; + pill.appendChild(input); + input.focus(); + input.onblur = () => { + item.word = input.value; + renderPills(); + runSmartSync(); + }; + input.onkeydown = (ev) => { + if (ev.key === 'Enter') input.blur(); + }; + }; + + container.appendChild(pill); + }); +} + +// ========================================== +// PILL DRAG/RESIZE HANDLERS +// ========================================== + +/** + * Handle pill drag start + * @param {MouseEvent} e - Mouse event + */ +function handleDragStart(e) { + if (e.target.classList.contains('resize-handle') || e.target.tagName === 'INPUT') return; + + isDragging = true; + hasMovedDuringDrag = false; + currentPill = e.currentTarget; + currentIndex = parseInt(currentPill.dataset.index); + const startX = e.clientX; + const initialLeft = parseFloat(currentPill.style.left); + selectPill(currentIndex); + + const onMove = (me) => { + hasMovedDuringDrag = true; + const newStart = Math.max(0, (initialLeft + (me.clientX - startX)) / pixelsPerSecond); + const dur = transcriptionData[currentIndex].end - transcriptionData[currentIndex].start; + transcriptionData[currentIndex].start = newStart; + transcriptionData[currentIndex].end = newStart + dur; + currentPill.style.left = `${newStart * pixelsPerSecond}px`; + }; + + const onUp = () => { + isDragging = false; + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + if (hasMovedDuringDrag) { + transcriptionData.sort((a, b) => a.start - b.start); + renderPills(); + } + }; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); +} + +/** + * Handle pill resize start + * @param {MouseEvent} e - Mouse event + * @param {string} side - 'left' or 'right' + */ +function handleResizeStart(e, side) { + e.stopPropagation(); + isResizing = true; + currentPill = e.target.parentElement; + currentIndex = parseInt(currentPill.dataset.index); + selectPill(currentIndex); + + const onMove = (me) => { + const time = (me.clientX - document.getElementById('transcription-content').getBoundingClientRect().left) / pixelsPerSecond; + if (side === 'left') { + transcriptionData[currentIndex].start = Math.max(0, Math.min(time, transcriptionData[currentIndex].end - 0.1)); + } else { + transcriptionData[currentIndex].end = Math.max(time, transcriptionData[currentIndex].start + 0.1); + } + renderPills(); + }; + + const onUp = () => { + isResizing = false; + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); +} + +// ========================================== +// PLAYBACK CONTROL FUNCTIONS +// ========================================== + +/** + * Toggle play/pause + */ +function togglePlayPause() { + if (wavesurfer) wavesurfer.playPause(); + updatePlayBtn(); +} + +/** + * Stop audio playback + */ +function stopAudio() { + if (wavesurfer) wavesurfer.stop(); + updatePlayBtn(); +} + +/** + * Update play button state + */ +function updatePlayBtn() { + const playing = wavesurfer?.isPlaying(); + document.getElementById('playIcon').className = playing + ? 'bi bi-pause-fill text-primary' + : 'bi bi-play-fill text-primary'; + document.getElementById('playText').textContent = playing ? 'Pause' : 'Play'; +} + +// ========================================== +// PLAYLIST FUNCTIONS +// ========================================== + +/** + * Update playlist dropdown UI + */ +function updatePlaylistUI() { + const select = document.getElementById('trackSelect'); + select.innerHTML = playlist.length === 0 + ? '' + : playlist.map(t => + `` + ).join(''); +} + +/** + * Load track from playlist by ID + * @param {string} id - Track ID + */ +function loadTrackFromPlaylist(id) { + const idx = playlist.findIndex(t => t.id == id); + if (idx === -1) return; + + currentTrackIndex = idx; + const track = playlist[idx]; + + document.getElementById('trackSelect').value = track.id; + document.getElementById('editorSection').classList.remove('d-none'); + document.getElementById('editorSection').scrollIntoView({ behavior: 'smooth' }); + document.getElementById('trackInfo').textContent = `Now Playing: Ch ${track.chapter} / Sec ${track.section}`; + + transcriptionData = track.transcription || []; + currentAudioData = track.audioData; + currentAudioFormat = track.audioFormat; + + if (track.audioData) { + initWaveSurferFromBase64(track.audioData, track.audioFormat); + } + initReader(track.text); +} + +/** + * Play next track in playlist + */ +function playNextTrack() { + if (currentTrackIndex < playlist.length - 1) { + loadTrackFromPlaylist(playlist[currentTrackIndex + 1].id); + } +} + +/** + * Play previous track in playlist + */ +function playPrevTrack() { + if (currentTrackIndex > 0) { + loadTrackFromPlaylist(playlist[currentTrackIndex - 1].id); + } +} + +// ========================================== +// SLIDER EVENT HANDLERS +// ========================================== + +/** + * Initialize slider event handlers + */ +function initSliders() { + // Zoom slider + document.getElementById('zoomSlider').oninput = (e) => { + pixelsPerSecond = parseInt(e.target.value); + if (wavesurfer) { + wavesurfer.zoom(pixelsPerSecond); + updateTimelineWidth(); + renderPills(); + } + }; + + // Speed slider + document.getElementById('speedSlider').oninput = (e) => { + const rate = parseFloat(e.target.value); + if (wavesurfer) wavesurfer.setPlaybackRate(rate); + document.getElementById('speedDisplay').textContent = rate.toFixed(1) + 'x'; + }; +} + +// ========================================== +// KEYBOARD SHORTCUTS +// ========================================== + +/** + * Initialize keyboard shortcuts + */ +function initKeyboardShortcuts() { + document.onkeydown = (e) => { + // Ignore if typing in input/textarea or bulk editor + if (['INPUT', 'TEXTAREA'].includes(e.target.tagName) || + document.getElementById('bulk-editor').contains(e.target)) return; + + // Space - Play/Pause + if (e.code === 'Space') { + e.preventDefault(); + togglePlayPause(); + } + + // Delete - Delete selected pill + if (e.code === 'Delete') deleteSelectedPill(); + }; +} + +// ========================================== +// INITIALIZATION +// ========================================== + +// Initialize on DOM ready +document.addEventListener('DOMContentLoaded', function() { + initSliders(); + initKeyboardShortcuts(); +}); diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..c93daf7 --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,703 @@ + + + + + + Admin Dashboard - Audio Transcription Editor + + + + + + + + + + + + + + + +

+
+
Loading...
+
+ + + + + + + +
+ +
+
+

Admin Dashboard

+
+
+ + Back to Editor + + +
+
+ + +
+
+
0
+
Users
+
+
+
0
+
Projects
+
+
+
0
+
Sections
+
+
+
0
+
Uploads
+
+
+
0
+
Generations
+
+
+
0
+
Database (MB)
+
+
+ + +
+
+

User Management

+ +
+
+
+
+
+ +

Loading users...

+
+
+
+
+
+ + + + + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..97f427d --- /dev/null +++ b/templates/index.html @@ -0,0 +1,493 @@ + + + + + + Audio Transcription Editor & Reader + + + + + + + + + + + + + + + + + + + +
+
+

Thinking...

+

Generating audio or aligning text...

+
+ + +
+ + +
+ + + + + + + + +
+ + +
+

Audio Editor & Reader

+
+ + + + + + Kokoro AI Edition +
+
+ + +
+ + + + +
+ + +
+
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
+
+
+
+ + +
+
+ +
+
+ + +
+
+
+


+
+
+
+
+ +
+
+ Project Name + +
+ +
+ + +
+
+ +
+ + + Tips: Use floating buttons (right side) to add Chapter/Section markers. + Each section can have an image (drag & drop, browse, or paste from clipboard). + Click Save Project to save without generating audio, or Export Everything to save and download as ZIP. + +
+
+
+
+ + +
+ + + + + +
+ +
+
+ + +
+ + +
+ + +
+
+ + +
+
+ Filename + + +
+
+ + +
+
+ SPEED + + 1.0x +
+
+ ZOOM + +
+
+
+ + +
+
+
+
+
+
Audio
+
+
+
+
Transcript
+
+
+
+
+ + +
+
+
+ Interactive Reader + Not Loaded +
+
+ + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..a8c3902 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,275 @@ + + + + + + Login - Audio Transcription Editor + + + + + + + + + + + + + + + + + + + +