commit 11d715eb856cf3f40fc80a0b7d7110f9e5467225 Author: Ashim Kumar Date: Fri Jan 9 21:06:30 2026 +0600 first commit 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 + + + + + + + + + + + + + + + + + + + +