first commit
This commit is contained in:
16
.dockerignore
Normal file
16
.dockerignore
Normal file
@@ -0,0 +1,16 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.db
|
||||
*.sqlite3
|
||||
.env
|
||||
.git/
|
||||
.gitignore
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
routes/__pycache__/
|
||||
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.db
|
||||
*.sqlite3
|
||||
.env
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
routes/__pycache__/
|
||||
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"python-envs.defaultEnvManager": "ms-python.python:conda",
|
||||
"python-envs.defaultPackageManager": "ms-python.python:conda",
|
||||
"python-envs.pythonProjects": []
|
||||
}
|
||||
51
Dockerfile
Normal file
51
Dockerfile
Normal file
@@ -0,0 +1,51 @@
|
||||
# ============================================
|
||||
# Audiobook Maker Pro v4 - Production Dockerfile
|
||||
# CPU-only build — no CUDA/MPS dependencies
|
||||
# ============================================
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Install system dependencies for pydub (ffmpeg), python-docx, olefile, PyMuPDF
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
libmupdf-dev \
|
||||
libfreetype6 \
|
||||
libharfbuzz0b \
|
||||
libjpeg62-turbo \
|
||||
libopenjp2-7 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy requirements first for better layer caching
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install Python dependencies — CPU only, no CUDA
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create the persistent data directory at build time
|
||||
# (Coolify volume mount will overlay this at runtime)
|
||||
RUN mkdir -p /opt/apps/audiobook-maker-pro-v4
|
||||
|
||||
# Expose the application port
|
||||
EXPOSE 5009
|
||||
|
||||
# Health check — Coolify uses this to determine container readiness
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5009/login')" || exit 1
|
||||
|
||||
# Run with gunicorn for production
|
||||
CMD ["gunicorn", \
|
||||
"--bind", "0.0.0.0:5009", \
|
||||
"--workers", "3", \
|
||||
"--threads", "2", \
|
||||
"--timeout", "300", \
|
||||
"--worker-class", "gthread", \
|
||||
"--access-logfile", "-", \
|
||||
"--error-logfile", "-", \
|
||||
"app:app"]
|
||||
56
app.py
Normal file
56
app.py
Normal file
@@ -0,0 +1,56 @@
|
||||
# app.py - Main Flask Application Entry Point
|
||||
|
||||
import os
|
||||
from flask import Flask
|
||||
|
||||
from config import SECRET_KEY, STATIC_FOLDER, STATIC_URL_PATH, TTS_API_URL, TTS_API_KEY, DATABASE
|
||||
from db import init_app as init_db
|
||||
from routes import register_blueprints
|
||||
|
||||
|
||||
def create_app():
|
||||
"""Application factory function."""
|
||||
app = Flask(__name__, static_folder=STATIC_FOLDER, static_url_path=STATIC_URL_PATH)
|
||||
|
||||
# Configure app
|
||||
app.secret_key = SECRET_KEY
|
||||
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||||
app.config['SESSION_COOKIE_SECURE'] = os.getenv('FLASK_ENV') == 'production'
|
||||
app.config['PERMANENT_SESSION_LIFETIME'] = 86400 # 24 hours
|
||||
app.config['PREFERRED_URL_SCHEME'] = 'https'
|
||||
|
||||
# Trust the reverse proxy headers from Coolify/Traefik
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
|
||||
|
||||
# Initialize database
|
||||
init_db(app)
|
||||
|
||||
# Register all blueprints
|
||||
register_blueprints(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# Create app instance
|
||||
app = create_app()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("=" * 60)
|
||||
print("🎧 Audiobook Maker Pro v4 Starting...")
|
||||
print("=" * 60)
|
||||
print(f"📍 TTS API Server: {TTS_API_URL}")
|
||||
print(f"📍 API Key: {'✅ Configured' if TTS_API_KEY else '❌ NOT CONFIGURED!'}")
|
||||
print(f"📍 Database: {DATABASE}")
|
||||
print(f"📍 Static Files: {STATIC_FOLDER}")
|
||||
print(f"📍 Default Admin: admin / admin123")
|
||||
print("-" * 60)
|
||||
|
||||
if not TTS_API_KEY:
|
||||
print("⚠️ WARNING: TTS_API_KEY not set in .env file!")
|
||||
|
||||
print("=" * 60)
|
||||
|
||||
app.run(debug=True, port=5009)
|
||||
74
auth.py
Normal file
74
auth.py
Normal file
@@ -0,0 +1,74 @@
|
||||
# auth.py - Authentication and User Management
|
||||
|
||||
import functools
|
||||
from flask import session, redirect, url_for, request, jsonify
|
||||
from db import get_db_connection
|
||||
|
||||
|
||||
def init_users_table():
|
||||
"""Create users table and default admin user."""
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# Create default admin if no users exist
|
||||
cursor.execute('SELECT COUNT(*) as count FROM users')
|
||||
if cursor.fetchone()['count'] == 0:
|
||||
cursor.execute('''
|
||||
INSERT INTO users (username, password, role, is_active)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', ('admin', 'admin123', 'admin', 1))
|
||||
print("✅ Default admin user created (username: admin, password: admin123)")
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
def login_required(f):
|
||||
"""Decorator to require login for routes."""
|
||||
@functools.wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'user_id' not in session:
|
||||
# Check if it's an API request
|
||||
if request.path.startswith('/api/'):
|
||||
return jsonify({'error': 'Authentication required'}), 401
|
||||
return redirect(url_for('auth.login_page'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
"""Decorator to require admin role for routes."""
|
||||
@functools.wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'user_id' not in session:
|
||||
if request.path.startswith('/api/'):
|
||||
return jsonify({'error': 'Authentication required'}), 401
|
||||
return redirect(url_for('auth.login_page'))
|
||||
if session.get('user_role') != 'admin':
|
||||
if request.path.startswith('/api/'):
|
||||
return jsonify({'error': 'Admin access required'}), 403
|
||||
return redirect(url_for('main.index'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
def get_current_user():
|
||||
"""Get current logged-in user info from session."""
|
||||
if 'user_id' not in session:
|
||||
return None
|
||||
return {
|
||||
'id': session.get('user_id'),
|
||||
'username': session.get('username'),
|
||||
'role': session.get('user_role')
|
||||
}
|
||||
67
config.py
Normal file
67
config.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# config.py - Application Configuration
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# --- DATABASE ---
|
||||
# In production (Docker), the DATABASE env var points to the persistent volume.
|
||||
# Locally it falls back to the current directory.
|
||||
DATABASE = os.getenv('DATABASE', 'audiobook_maker.db')
|
||||
|
||||
# --- Ensure the database directory exists ---
|
||||
_db_dir = os.path.dirname(DATABASE)
|
||||
if _db_dir and not os.path.exists(_db_dir):
|
||||
os.makedirs(_db_dir, exist_ok=True)
|
||||
print(f"📁 Created database directory: {_db_dir}")
|
||||
|
||||
# --- FLASK SECRET KEY ---
|
||||
SECRET_KEY = os.getenv('SECRET_KEY', 'audiobook-maker-pro-' + str(uuid.uuid4()))
|
||||
|
||||
# --- TTS API CONFIGURATION ---
|
||||
TTS_API_URL = os.getenv('TTS_API_URL', 'http://localhost:5010/api/v1')
|
||||
TTS_API_KEY = os.getenv('TTS_API_KEY', '')
|
||||
|
||||
# --- PATHS ---
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
STATIC_FOLDER = 'static'
|
||||
STATIC_URL_PATH = '/static'
|
||||
|
||||
# --- API HEADERS ---
|
||||
def get_api_headers():
|
||||
"""Get headers for TTS API requests."""
|
||||
return {
|
||||
'X-API-Key': TTS_API_KEY
|
||||
}
|
||||
|
||||
def get_api_headers_json():
|
||||
"""Get headers for TTS API requests with JSON content type."""
|
||||
return {
|
||||
'X-API-Key': TTS_API_KEY,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
# --- VOICE OPTIONS ---
|
||||
VOICES = [
|
||||
{'id': 'af_alloy', 'name': 'Alloy (US Female)'},
|
||||
{'id': 'af_aoede', 'name': 'Aoede (US Female)'},
|
||||
{'id': 'af_bella', 'name': 'Bella (US Female)'},
|
||||
{'id': 'af_heart', 'name': 'Heart (US Female)'},
|
||||
{'id': 'af_jessica', 'name': 'Jessica (US Female)'},
|
||||
{'id': 'af_nicole', 'name': 'Nicole (US Female)'},
|
||||
{'id': 'af_nova', 'name': 'Nova (US Female)'},
|
||||
{'id': 'af_river', 'name': 'River (US Female)'},
|
||||
{'id': 'af_sarah', 'name': 'Sarah (US Female)'},
|
||||
{'id': 'af_sky', 'name': 'Sky (US Female)'},
|
||||
{'id': 'am_adam', 'name': 'Adam (US Male)'},
|
||||
{'id': 'am_echo', 'name': 'Echo (US Male)'},
|
||||
{'id': 'am_eric', 'name': 'Eric (US Male)'},
|
||||
{'id': 'am_michael', 'name': 'Michael (US Male)'},
|
||||
{'id': 'bf_emma', 'name': 'Emma (UK Female)'},
|
||||
{'id': 'bf_isabella', 'name': 'Isabella (UK Female)'},
|
||||
{'id': 'bm_daniel', 'name': 'Daniel (UK Male)'},
|
||||
{'id': 'bm_george', 'name': 'George (UK Male)'},
|
||||
]
|
||||
125
db.py
Normal file
125
db.py
Normal file
@@ -0,0 +1,125 @@
|
||||
# db.py - Database Configuration and Operations
|
||||
|
||||
import sqlite3
|
||||
from flask import g
|
||||
from contextlib import contextmanager
|
||||
|
||||
from config import DATABASE
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def close_db(error=None):
|
||||
"""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()
|
||||
|
||||
# Projects table
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# Chapters table
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS chapters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
chapter_number INTEGER NOT NULL,
|
||||
voice TEXT DEFAULT 'af_heart',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
UNIQUE(project_id, chapter_number)
|
||||
)
|
||||
''')
|
||||
|
||||
# Markdown blocks table
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS markdown_blocks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chapter_id INTEGER NOT NULL,
|
||||
block_order INTEGER NOT NULL,
|
||||
block_type TEXT NOT NULL DEFAULT 'paragraph',
|
||||
content TEXT NOT NULL,
|
||||
tts_text TEXT,
|
||||
audio_data TEXT,
|
||||
audio_format TEXT DEFAULT 'mp3',
|
||||
transcription TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
|
||||
)
|
||||
''')
|
||||
|
||||
# Images table
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS block_images (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
block_id INTEGER NOT NULL,
|
||||
image_data TEXT NOT NULL,
|
||||
image_format TEXT DEFAULT 'png',
|
||||
alt_text TEXT,
|
||||
position TEXT DEFAULT 'before',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (block_id) REFERENCES markdown_blocks(id) ON DELETE CASCADE
|
||||
)
|
||||
''')
|
||||
|
||||
# PDF Documents table
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS pdf_documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER,
|
||||
filename TEXT NOT NULL,
|
||||
page_count INTEGER DEFAULT 0,
|
||||
metadata TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL
|
||||
)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
print("✅ Database initialized")
|
||||
|
||||
|
||||
def vacuum_db():
|
||||
"""Run VACUUM to reclaim space after deletions."""
|
||||
with get_db_connection() as conn:
|
||||
conn.execute('VACUUM')
|
||||
|
||||
|
||||
def init_app(app):
|
||||
"""Initialize database with Flask app."""
|
||||
app.teardown_appcontext(close_db)
|
||||
init_db()
|
||||
|
||||
# Initialize users table
|
||||
from auth import init_users_table
|
||||
init_users_table()
|
||||
256
doc.py
Normal file
256
doc.py
Normal file
@@ -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 offline' # 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 = """
|
||||
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>{title}</title>
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; line-height: 1.6; padding: 2em; max-width: 1024px; margin: 0 auto; color: #333; }}
|
||||
h1, h2, h3 {{ border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }}
|
||||
code {{ font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; background-color: #f6f8fa; padding: 0.2em 0.4em; margin: 0; font-size: 85%; border-radius: 6px; }}
|
||||
pre {{ background-color: #f6f8fa; padding: 16px; overflow: auto; border-radius: 6px; }}
|
||||
pre code {{ padding: 0; margin: 0; font-size: 100%; background-color: transparent; border: none; }}
|
||||
</style></head><body>{content}</body></html>
|
||||
"""
|
||||
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)
|
||||
33
docker-compose.yml
Normal file
33
docker-compose.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
# ============================================
|
||||
# Audiobook Maker Pro v4 - Docker Compose for Coolify
|
||||
# Domain: https://abm.1lab.cloud
|
||||
# ============================================
|
||||
|
||||
services:
|
||||
audiobook-maker:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: audiobook-maker-pro-v4
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- DATABASE=/opt/apps/audiobook-maker-pro-v4/audiobook_maker.db
|
||||
- SECRET_KEY=${SECRET_KEY:-}
|
||||
- TTS_API_URL=${TTS_API_URL:-http://localhost:5010/api/v1}
|
||||
- TTS_API_KEY=${TTS_API_KEY:-}
|
||||
- FLASK_ENV=production
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /opt/apps/Audiobook Maker Pro-v4
|
||||
target: /opt/apps/audiobook-maker-pro-v4
|
||||
is_directory: true
|
||||
expose:
|
||||
- "5009"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5009/login')"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
start_period: 15s
|
||||
retries: 3
|
||||
labels:
|
||||
- coolify.managed=true
|
||||
902
docx_processor.py
Normal file
902
docx_processor.py
Normal file
@@ -0,0 +1,902 @@
|
||||
# docx_processor.py - DOCX/DOC Processing and Content Extraction
|
||||
# FIXED: Split paragraphs that contain soft returns (<w:br/>) into separate blocks
|
||||
# FIXED: Extract text from hyperlinks in DOCX
|
||||
|
||||
import io
|
||||
import re
|
||||
import base64
|
||||
import email
|
||||
import email.policy
|
||||
import quopri
|
||||
from html.parser import HTMLParser
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
|
||||
# ================================================================
|
||||
# FORMAT DETECTION
|
||||
# ================================================================
|
||||
|
||||
def detect_doc_format(file_bytes):
|
||||
if not file_bytes or len(file_bytes) < 4:
|
||||
return 'unknown'
|
||||
if file_bytes[:4] == b'PK\x03\x04':
|
||||
return 'docx'
|
||||
if file_bytes[:8] == b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1':
|
||||
return 'ole2'
|
||||
if file_bytes[:4] == b'\xd0\xcf\x11\xe0':
|
||||
return 'ole2'
|
||||
|
||||
header = file_bytes[:64]
|
||||
for bom in [b'\xef\xbb\xbf', b'\xff\xfe', b'\xfe\xff']:
|
||||
if header.startswith(bom):
|
||||
header = header[len(bom):]
|
||||
break
|
||||
if header.lstrip().startswith(b'{\\rtf'):
|
||||
return 'rtf'
|
||||
|
||||
header_str = header.decode('ascii', errors='ignore').strip()
|
||||
if header_str.upper().startswith('MIME-VERSION'):
|
||||
return 'mhtml'
|
||||
sample_512 = file_bytes[:512].decode('ascii', errors='ignore').lower()
|
||||
if 'mime-version' in sample_512 and 'content-type' in sample_512 and 'boundary' in sample_512:
|
||||
return 'mhtml'
|
||||
|
||||
sample = file_bytes[:4096]
|
||||
sample_str_lower = ''
|
||||
for enc in ['utf-8', 'utf-16-le', 'utf-16-be', 'cp1252', 'latin-1']:
|
||||
try:
|
||||
sample_str_lower = sample.decode(enc, errors='ignore').lower().strip()
|
||||
if sample_str_lower:
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if sample_str_lower:
|
||||
html_markers = [
|
||||
'<html', '<!doctype html', '<head', '<meta ',
|
||||
'xmlns:w="urn:schemas-microsoft-com',
|
||||
'xmlns:o="urn:schemas-microsoft-com',
|
||||
'<o:documentproperties>', 'mso-',
|
||||
]
|
||||
for marker in html_markers:
|
||||
if marker in sample_str_lower:
|
||||
return 'html'
|
||||
return 'unknown'
|
||||
|
||||
|
||||
# ================================================================
|
||||
# IMAGE NORMALIZATION HELPERS
|
||||
# ================================================================
|
||||
|
||||
def _normalize_image_key(raw_location):
|
||||
if not raw_location:
|
||||
return ''
|
||||
loc = raw_location.strip()
|
||||
if loc.lower().startswith('cid:'):
|
||||
loc = loc[4:]
|
||||
for _ in range(3):
|
||||
decoded = unquote(loc)
|
||||
if decoded == loc:
|
||||
break
|
||||
loc = decoded
|
||||
try:
|
||||
parsed = urlparse(loc)
|
||||
path = parsed.path if parsed.path else loc
|
||||
except Exception:
|
||||
path = loc
|
||||
filename = path.replace('\\', '/').rsplit('/', 1)[-1].strip()
|
||||
return filename.lower()
|
||||
|
||||
|
||||
# ================================================================
|
||||
# MHTML PROCESSOR
|
||||
# ================================================================
|
||||
|
||||
class MHTMLProcessor:
|
||||
def __init__(self, file_bytes):
|
||||
self._file_bytes = file_bytes
|
||||
self._embedded_images = {}
|
||||
self._ordered_images = []
|
||||
|
||||
def process(self):
|
||||
html_content = self._extract_html_from_mhtml()
|
||||
if not html_content:
|
||||
html_content = self._fallback_extract()
|
||||
if not html_content:
|
||||
return {
|
||||
'metadata': {'title': '', 'author': '', 'subject': ''},
|
||||
'blocks': [{'type': 'paragraph', 'content': '⚠️ Could not extract content from MHTML file.'}],
|
||||
}
|
||||
|
||||
print(f" 📷 MHTML: {len(self._ordered_images)} image parts")
|
||||
processor = HTMLDocProcessor(
|
||||
html_content,
|
||||
embedded_images=self._embedded_images,
|
||||
ordered_images=self._ordered_images
|
||||
)
|
||||
result = processor.process()
|
||||
img_blocks = sum(1 for b in result.get('blocks', []) if b.get('type') == 'image')
|
||||
print(f"📄 MHTML processed: {len(result.get('blocks', []))} blocks ({img_blocks} images)")
|
||||
return result
|
||||
|
||||
def _store_image(self, payload_bytes, content_type, content_location, content_id):
|
||||
fmt = content_type.split('/')[-1].lower()
|
||||
if fmt in ('x-wmf', 'x-emf', 'wmf', 'emf'):
|
||||
return
|
||||
if fmt == 'jpg':
|
||||
fmt = 'jpeg'
|
||||
b64_data = base64.b64encode(payload_bytes).decode('ascii')
|
||||
self._ordered_images.append((b64_data, fmt, content_location or content_id or ''))
|
||||
if content_location:
|
||||
self._embedded_images[content_location] = (b64_data, fmt)
|
||||
norm = _normalize_image_key(content_location)
|
||||
if norm:
|
||||
self._embedded_images[norm] = (b64_data, fmt)
|
||||
if content_id:
|
||||
self._embedded_images[f'cid:{content_id}'] = (b64_data, fmt)
|
||||
self._embedded_images[content_id] = (b64_data, fmt)
|
||||
|
||||
def _extract_html_from_mhtml(self):
|
||||
try:
|
||||
msg = email.message_from_bytes(self._file_bytes, policy=email.policy.default)
|
||||
html_body = None
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
ct = part.get_content_type()
|
||||
cl = part.get('Content-Location', '').strip()
|
||||
cid = part.get('Content-ID', '').strip('<> ')
|
||||
if ct == 'text/html':
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
cs = part.get_content_charset() or 'utf-8'
|
||||
try: html_body = payload.decode(cs, errors='ignore')
|
||||
except: html_body = payload.decode('utf-8', errors='ignore')
|
||||
elif ct and ct.startswith('image/'):
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload and len(payload) > 100:
|
||||
self._store_image(payload, ct, cl, cid)
|
||||
else:
|
||||
ct = msg.get_content_type()
|
||||
if ct in ('text/html', 'multipart/related'):
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
cs = msg.get_content_charset() or 'utf-8'
|
||||
try: html_body = payload.decode(cs, errors='ignore')
|
||||
except: html_body = payload.decode('utf-8', errors='ignore')
|
||||
return html_body
|
||||
except Exception as e:
|
||||
print(f" ⚠️ MIME parsing failed: {e}")
|
||||
return None
|
||||
|
||||
def _fallback_extract(self):
|
||||
try:
|
||||
text = self._file_bytes.decode('ascii', errors='ignore')
|
||||
bm = re.search(r'boundary="?([^\s";\r\n]+)"?', text, re.IGNORECASE)
|
||||
if not bm: return None
|
||||
boundary = bm.group(1)
|
||||
parts = text.split(f'--{boundary}')
|
||||
html_body = None
|
||||
for part in parts:
|
||||
he = part.find('\r\n\r\n')
|
||||
if he == -1: he = part.find('\n\n')
|
||||
if he == -1: continue
|
||||
hs = part[:he]; body = part[he:].strip()
|
||||
ctm = re.search(r'Content-Type:\s*([^\s;]+)', hs, re.IGNORECASE)
|
||||
ct = ctm.group(1).lower() if ctm else ''
|
||||
is_qp = bool(re.search(r'Content-Transfer-Encoding:\s*quoted-printable', hs, re.IGNORECASE))
|
||||
is_b64 = bool(re.search(r'Content-Transfer-Encoding:\s*base64', hs, re.IGNORECASE))
|
||||
clm = re.search(r'Content-Location:\s*(.+?)[\r\n]', hs, re.IGNORECASE)
|
||||
cl = clm.group(1).strip() if clm else ''
|
||||
cidm = re.search(r'Content-ID:\s*<?([^>\s\r\n]+)>?', hs, re.IGNORECASE)
|
||||
cid = cidm.group(1).strip() if cidm else ''
|
||||
if ct == 'text/html':
|
||||
if is_qp: body = quopri.decodestring(body.encode('ascii', errors='ignore')).decode('utf-8', errors='ignore')
|
||||
elif is_b64:
|
||||
try: body = base64.b64decode(body).decode('utf-8', errors='ignore')
|
||||
except: pass
|
||||
if '<html' in body.lower() or '<body' in body.lower(): html_body = body
|
||||
elif ct.startswith('image/') and is_b64 and body:
|
||||
clean_b64 = re.sub(r'\s+', '', body)
|
||||
try:
|
||||
pb = base64.b64decode(clean_b64)
|
||||
if len(pb) > 100: self._store_image(pb, ct, cl, cid)
|
||||
except: pass
|
||||
return html_body
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Fallback MHTML failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ================================================================
|
||||
# HTML COMMENT CLEANUP
|
||||
# ================================================================
|
||||
|
||||
def _clean_html_comments(html_text):
|
||||
html_text = re.sub(r'<!--\[if\s+!vml\]-->(.*?)<!--\[endif\]-->', r'\1', html_text, flags=re.DOTALL|re.IGNORECASE)
|
||||
html_text = re.sub(r'<!--\[if\s+!mso\]-->(.*?)<!--\[endif\]-->', r'\1', html_text, flags=re.DOTALL|re.IGNORECASE)
|
||||
html_text = re.sub(r'<!--\[if\s[^\]]*\]>.*?<!\[endif\]-->', '', html_text, flags=re.DOTALL|re.IGNORECASE)
|
||||
html_text = re.sub(r'<!--.*?-->', '', html_text, flags=re.DOTALL)
|
||||
return html_text
|
||||
|
||||
|
||||
# ================================================================
|
||||
# HTML DOC PROCESSOR
|
||||
# ================================================================
|
||||
|
||||
class HTMLDocProcessor:
|
||||
def __init__(self, file_bytes, embedded_images=None, ordered_images=None):
|
||||
if isinstance(file_bytes, str):
|
||||
self._html_text = file_bytes
|
||||
self._file_bytes = file_bytes.encode('utf-8', errors='ignore')
|
||||
else:
|
||||
self._file_bytes = file_bytes
|
||||
self._html_text = self._decode_html()
|
||||
self._embedded_images = embedded_images or {}
|
||||
self._ordered_images = ordered_images or []
|
||||
self._used_image_indices = set()
|
||||
|
||||
def _decode_html(self):
|
||||
if self._file_bytes[:3] == b'\xef\xbb\xbf': return self._file_bytes[3:].decode('utf-8', errors='ignore')
|
||||
if self._file_bytes[:2] == b'\xff\xfe': return self._file_bytes[2:].decode('utf-16-le', errors='ignore')
|
||||
if self._file_bytes[:2] == b'\xfe\xff': return self._file_bytes[2:].decode('utf-16-be', errors='ignore')
|
||||
sample = self._file_bytes[:4096]
|
||||
try:
|
||||
st = sample.decode('ascii', errors='ignore')
|
||||
cm = re.search(r'charset[="\s]+([a-zA-Z0-9\-]+)', st, re.IGNORECASE)
|
||||
if cm:
|
||||
try: return self._file_bytes.decode(cm.group(1).strip().strip('"\''), errors='ignore')
|
||||
except: pass
|
||||
except: pass
|
||||
for enc in ['utf-8', 'cp1252', 'latin-1']:
|
||||
try: return self._file_bytes.decode(enc, errors='ignore')
|
||||
except: continue
|
||||
return self._file_bytes.decode('latin-1', errors='replace')
|
||||
|
||||
def process(self):
|
||||
metadata = {'title': '', 'author': '', 'subject': ''}
|
||||
tm = re.search(r'<title[^>]*>(.*?)</title>', self._html_text, re.IGNORECASE|re.DOTALL)
|
||||
if tm: metadata['title'] = self._strip_tags(tm.group(1)).strip()
|
||||
blocks = self._extract_all_blocks()
|
||||
blocks = [b for b in blocks if b.get('content', '').strip() or b.get('data')]
|
||||
if not blocks: blocks = self._simple_extract()
|
||||
img_count = sum(1 for b in blocks if b.get('type') == 'image')
|
||||
print(f"📄 HTML-DOC processed: {len(blocks)} blocks ({img_count} images)")
|
||||
return {'metadata': metadata, 'blocks': blocks}
|
||||
|
||||
def _strip_tags(self, html_str):
|
||||
import html as hm
|
||||
return hm.unescape(re.sub(r'<[^>]+>', '', html_str))
|
||||
|
||||
def _resolve_image_src(self, src):
|
||||
import html as hm
|
||||
if not src: return None, None
|
||||
src = hm.unescape(src).strip()
|
||||
if src.startswith('data:image'):
|
||||
dm = re.match(r'data:image/([^;]+);base64,(.+)', src, re.DOTALL)
|
||||
if dm: return dm.group(2).strip(), dm.group(1)
|
||||
if src in self._embedded_images:
|
||||
self._mark_used(self._embedded_images[src][0]); return self._embedded_images[src]
|
||||
ns = _normalize_image_key(src)
|
||||
if ns and ns in self._embedded_images:
|
||||
self._mark_used(self._embedded_images[ns][0]); return self._embedded_images[ns]
|
||||
if ns and '.' in ns:
|
||||
nne = ns.rsplit('.', 1)[0]
|
||||
if nne and nne in self._embedded_images:
|
||||
self._mark_used(self._embedded_images[nne][0]); return self._embedded_images[nne]
|
||||
if ns:
|
||||
for loc, (data, fmt) in self._embedded_images.items():
|
||||
ln = _normalize_image_key(loc)
|
||||
if ln and ns and ln == ns: self._mark_used(data); return data, fmt
|
||||
return self._get_next_unused()
|
||||
|
||||
def _mark_used(self, data_prefix):
|
||||
p = data_prefix[:60]
|
||||
for i, (b, f, l) in enumerate(self._ordered_images):
|
||||
if i not in self._used_image_indices and b[:60] == p:
|
||||
self._used_image_indices.add(i); return
|
||||
|
||||
def _get_next_unused(self):
|
||||
for i, (b, f, l) in enumerate(self._ordered_images):
|
||||
if i not in self._used_image_indices:
|
||||
self._used_image_indices.add(i); return b, f
|
||||
return None, None
|
||||
|
||||
def _extract_all_blocks(self):
|
||||
import html as hm
|
||||
blocks = []
|
||||
cleaned = re.sub(r'<script[^>]*>.*?</script>', '', self._html_text, flags=re.DOTALL|re.IGNORECASE)
|
||||
cleaned = re.sub(r'<style[^>]*>.*?</style>', '', cleaned, flags=re.DOTALL|re.IGNORECASE)
|
||||
vml_srcs = []
|
||||
for vm in re.finditer(r'<!--\[if\s[^\]]*vml[^\]]*\]>(.*?)<!\[endif\]-->', cleaned, re.DOTALL|re.IGNORECASE):
|
||||
for im in re.finditer(r'<v:imagedata\b[^>]*?\bsrc\s*=\s*["\']([^"\']+)["\']', vm.group(1), re.IGNORECASE|re.DOTALL):
|
||||
vml_srcs.append((hm.unescape(im.group(1)), vm.start()))
|
||||
cleaned = _clean_html_comments(cleaned)
|
||||
cleaned = re.sub(r'</?[ovw]:[^>]+>', '', cleaned, flags=re.IGNORECASE)
|
||||
bm = re.search(r'<body[^>]*>(.*)</body>', cleaned, re.IGNORECASE|re.DOTALL)
|
||||
if bm: cleaned = bm.group(1)
|
||||
|
||||
img_entries = []
|
||||
for m in re.finditer(r'<img\b([^>]*?)/?\s*>', cleaned, re.IGNORECASE|re.DOTALL):
|
||||
sm = re.search(r'\bsrc\s*=\s*["\']([^"\']+)["\']', m.group(1), re.IGNORECASE)
|
||||
if not sm: sm = re.search(r'\bsrc\s*=\s*(\S+)', m.group(1), re.IGNORECASE)
|
||||
if sm: img_entries.append((hm.unescape(sm.group(1)), m.start()))
|
||||
if not img_entries and vml_srcs: img_entries = vml_srcs
|
||||
|
||||
self._used_image_indices = set()
|
||||
for src, pos in img_entries:
|
||||
d, f = self._resolve_image_src(src)
|
||||
if d: blocks.append({'type':'image','content':f"",'data':d,'format':f,'_pos':pos})
|
||||
|
||||
for m in re.finditer(r'<(h[1-6])\b[^>]*>(.*?)</\1\s*>', cleaned, re.IGNORECASE|re.DOTALL):
|
||||
t = re.sub(r'\s+', ' ', self._strip_tags(m.group(2))).strip()
|
||||
if t:
|
||||
tag = m.group(1).lower()
|
||||
p = {'h1':'# ','h2':'## '}.get(tag,'### ')
|
||||
bt = {'h1':'heading1','h2':'heading2'}.get(tag,'heading3')
|
||||
blocks.append({'type':bt,'content':f"{p}{t}",'_pos':m.start()})
|
||||
|
||||
for m in re.finditer(r'<table\b[^>]*>(.*?)</table\s*>', cleaned, re.IGNORECASE|re.DOTALL):
|
||||
md = self._parse_table(m.group(1))
|
||||
if md: blocks.append({'type':'table','content':md,'_pos':m.start()})
|
||||
|
||||
for m in re.finditer(r'<p\b([^>]*)>(.*?)</p\s*>', cleaned, re.IGNORECASE|re.DOTALL):
|
||||
inner = m.group(2); attrs = m.group(1)
|
||||
it = self._strip_tags(inner).strip()
|
||||
hw = not it or all(c in ' \t\n\r\xa0' for c in it)
|
||||
if hw: continue
|
||||
t = re.sub(r'[ \t]+', ' ', re.sub(r'\n\s*\n', '\n', it)).strip()
|
||||
if not t: continue
|
||||
bt = 'paragraph'
|
||||
cm = re.search(r'class\s*=\s*["\']?([^"\'>\s]+)', attrs, re.IGNORECASE)
|
||||
cn = cm.group(1) if cm else ''
|
||||
if 'MsoListParagraph' in cn:
|
||||
t = re.sub(r'^[·•●○◦‣⁃]\s*', '', re.sub(r'^\d+[.)]\s*', '', t)); bt = 'list_item'
|
||||
elif 'MsoTitle' in cn: bt = 'heading1'
|
||||
elif 'MsoSubtitle' in cn: bt = 'heading2'
|
||||
elif 'MsoQuote' in cn or 'MsoIntenseQuote' in cn: bt = 'quote'
|
||||
pm = {'heading1':'# ','heading2':'## ','list_item':'- ','quote':'> '}
|
||||
blocks.append({'type':bt,'content':f"{pm.get(bt,'')}{t}",'_pos':m.start()})
|
||||
|
||||
for m in re.finditer(r'<li\b[^>]*>(.*?)</li\s*>', cleaned, re.IGNORECASE|re.DOTALL):
|
||||
t = re.sub(r'\s+', ' ', self._strip_tags(m.group(1))).strip()
|
||||
if t: blocks.append({'type':'list_item','content':f"- {t}",'_pos':m.start()})
|
||||
|
||||
for m in re.finditer(r'<blockquote\b[^>]*>(.*?)</blockquote\s*>', cleaned, re.IGNORECASE|re.DOTALL):
|
||||
t = re.sub(r'\s+', ' ', self._strip_tags(m.group(1))).strip()
|
||||
if t: blocks.append({'type':'quote','content':f"> {t}",'_pos':m.start()})
|
||||
|
||||
for m in re.finditer(r'<div\b([^>]*)>(.*?)</div\s*>', cleaned, re.IGNORECASE|re.DOTALL):
|
||||
if re.search(r'<(?:p|h[1-6]|table|div|ul|ol)\b', m.group(2), re.IGNORECASE): continue
|
||||
t = re.sub(r'[ \t]+', ' ', self._strip_tags(m.group(2))).strip()
|
||||
if t and len(t) > 1 and not all(c in ' \t\n\r\xa0' for c in t):
|
||||
if not any(t in b.get('content','') for b in blocks):
|
||||
blocks.append({'type':'paragraph','content':t,'_pos':m.start()})
|
||||
|
||||
blocks.sort(key=lambda b: b.get('_pos', 0))
|
||||
seen = set(); deduped = []
|
||||
for b in blocks:
|
||||
b.pop('_pos', None)
|
||||
if b.get('type') == 'image':
|
||||
k = b.get('data','')[:60]
|
||||
if k and k in seen: continue
|
||||
if k: seen.add(k)
|
||||
deduped.append(b)
|
||||
else:
|
||||
c = b.get('content','').strip()
|
||||
if c and c not in seen: seen.add(c); deduped.append(b)
|
||||
return deduped
|
||||
|
||||
def _parse_table(self, html):
|
||||
rows = []
|
||||
for rm in re.finditer(r'<tr\b[^>]*>(.*?)</tr\s*>', html, re.IGNORECASE|re.DOTALL):
|
||||
cells = []
|
||||
for cm in re.finditer(r'<t[dh]\b[^>]*>(.*?)</t[dh]\s*>', rm.group(1), re.IGNORECASE|re.DOTALL):
|
||||
cells.append(re.sub(r'\s+', ' ', self._strip_tags(cm.group(1))).strip().replace('|','\\|'))
|
||||
if cells: rows.append(cells)
|
||||
if not rows: return ''
|
||||
if all(len(r)==1 for r in rows) and len(rows)<=2: return ''
|
||||
lines = []
|
||||
for i, r in enumerate(rows):
|
||||
lines.append('| '+' | '.join(r)+' |')
|
||||
if i == 0: lines.append('| '+' | '.join(['---']*len(r))+' |')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _simple_extract(self):
|
||||
import html as hm
|
||||
blocks = []; t = self._html_text
|
||||
t = re.sub(r'<script[^>]*>.*?</script>', '', t, flags=re.DOTALL|re.IGNORECASE)
|
||||
t = re.sub(r'<style[^>]*>.*?</style>', '', t, flags=re.DOTALL|re.IGNORECASE)
|
||||
t = _clean_html_comments(t)
|
||||
bm = re.search(r'<body[^>]*>(.*)</body>', t, re.IGNORECASE|re.DOTALL)
|
||||
if bm: t = bm.group(1)
|
||||
for tag, repl in [('br', '\n'), ('p', '\n\n'), ('div', '\n\n'), ('li', '\n'), ('tr', '\n'), ('table', '\n\n')]:
|
||||
t = re.sub(rf'</?{tag}[^>]*>', repl, t, flags=re.IGNORECASE)
|
||||
t = hm.unescape(re.sub(r'<[^>]+>', '', t))
|
||||
for p in re.split(r'\n{2,}', t):
|
||||
p = re.sub(r'[ \t]+', ' ', p).strip()
|
||||
if p and len(p) > 1: blocks.append({'type':'paragraph','content':p})
|
||||
return blocks
|
||||
|
||||
|
||||
# ================================================================
|
||||
# RTF DOC PROCESSOR
|
||||
# ================================================================
|
||||
|
||||
class RTFDocProcessor:
|
||||
def __init__(self, file_bytes): self._file_bytes = file_bytes
|
||||
def process(self):
|
||||
blocks = []; metadata = {'title':'','author':'','subject':''}
|
||||
rtf = self._decode_rtf(); metadata.update(self._extract_meta(rtf))
|
||||
pt = self._rtf_to_text(rtf)
|
||||
if pt:
|
||||
for p in re.split(r'\n{2,}', pt):
|
||||
p = p.strip()
|
||||
if not p: continue
|
||||
if len(p) < 80 and p.isupper(): blocks.append({'type':'heading2','content':f"## {p}"})
|
||||
else: blocks.append({'type':'paragraph','content':p})
|
||||
print(f"📄 RTF-DOC processed: {len(blocks)} blocks")
|
||||
return {'metadata': metadata, 'blocks': blocks}
|
||||
def _decode_rtf(self):
|
||||
d = self._file_bytes
|
||||
for b in [b'\xef\xbb\xbf',b'\xff\xfe',b'\xfe\xff']:
|
||||
if d.startswith(b): d = d[len(b):]; break
|
||||
try: return d.decode('ascii', errors='ignore')
|
||||
except: return d.decode('latin-1', errors='replace')
|
||||
def _extract_meta(self, rtf):
|
||||
m = {}
|
||||
for f in ['title','author','subject']:
|
||||
r = re.search(r'\\'+f+r'\s+([^}]+)', rtf)
|
||||
if r: m[f] = r.group(1).strip()
|
||||
return m
|
||||
def _rtf_to_text(self, rtf):
|
||||
try:
|
||||
from striprtf.striprtf import rtf_to_text
|
||||
return rtf_to_text(rtf, errors='ignore')
|
||||
except ImportError: pass
|
||||
except Exception: pass
|
||||
t = rtf
|
||||
for g in ['fonttbl','colortbl','stylesheet','info','header','footer']:
|
||||
t = re.sub(r'\{\\'+re.escape(g)+r'[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', '', t, flags=re.DOTALL)
|
||||
t = re.sub(r'\\par\b\s*','\n',t); t = re.sub(r'\\pard\b\s*','',t)
|
||||
t = re.sub(r'\\line\b\s*','\n',t); t = re.sub(r'\\tab\b\s*','\t',t)
|
||||
def hr(m):
|
||||
try: return bytes([int(m.group(1),16)]).decode('cp1252',errors='ignore')
|
||||
except: return ''
|
||||
t = re.sub(r"\\\'([0-9a-fA-F]{2})", hr, t)
|
||||
def ur(m):
|
||||
try:
|
||||
c = int(m.group(1))
|
||||
if c < 0: c += 65536
|
||||
return chr(c)
|
||||
except: return ''
|
||||
t = re.sub(r'\\u(-?\d+)\??', ur, t)
|
||||
t = re.sub(r'\\[a-zA-Z]+\d*\s?','',t); t = re.sub(r'[{}]','',t)
|
||||
return re.sub(r'\n{3,}','\n\n',re.sub(r' +',' ',t)).strip()
|
||||
|
||||
|
||||
# ================================================================
|
||||
# DOCX PROCESSOR (using python-docx)
|
||||
# ================================================================
|
||||
|
||||
DOCX_NSMAP = {
|
||||
'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
|
||||
'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
|
||||
'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
|
||||
}
|
||||
|
||||
W_NS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
||||
R_NS = '{http://schemas.openxmlformats.org/officeDocument/2006/relationships}'
|
||||
|
||||
|
||||
class DOCXProcessor:
|
||||
"""Process DOCX files.
|
||||
|
||||
CRITICAL FIX: Extracts text from hyperlinks (<w:hyperlink>) which
|
||||
paragraph.runs misses. Also splits paragraphs containing <w:br/>
|
||||
(soft line breaks) into separate blocks when they represent
|
||||
logically distinct paragraphs.
|
||||
"""
|
||||
|
||||
HEADING_PATTERNS = {
|
||||
'Title':'title','Subtitle':'subtitle',
|
||||
'Heading 1':'heading1','Heading 2':'heading2',
|
||||
'Heading 3':'heading3','Heading 4':'heading3',
|
||||
'Heading 5':'heading3','Heading 6':'heading3',
|
||||
'Heading 7':'heading3','Heading 8':'heading3','Heading 9':'heading3',
|
||||
}
|
||||
QUOTE_STYLES = {'Quote','Intense Quote','Block Text'}
|
||||
LIST_BULLET_STYLES = {'List Bullet','List Bullet 2','List Bullet 3'}
|
||||
LIST_NUMBER_STYLES = {'List Number','List Number 2','List Number 3','List Continue'}
|
||||
|
||||
def __init__(self, docx_bytes):
|
||||
import docx as docx_module
|
||||
self.doc = docx_module.Document(io.BytesIO(docx_bytes))
|
||||
self._image_cache = {}
|
||||
self._extract_all_images()
|
||||
|
||||
def _extract_all_images(self):
|
||||
try:
|
||||
for rel_id, rel in self.doc.part.rels.items():
|
||||
if "image" in rel.reltype:
|
||||
try:
|
||||
ip = rel.target_part
|
||||
ib = ip.blob; ct = ip.content_type or ''
|
||||
fmt = 'png'
|
||||
if 'jpeg' in ct or 'jpg' in ct: fmt = 'jpeg'
|
||||
elif 'gif' in ct: fmt = 'gif'
|
||||
elif 'bmp' in ct: fmt = 'bmp'
|
||||
elif 'tiff' in ct: fmt = 'tiff'
|
||||
elif 'webp' in ct: fmt = 'webp'
|
||||
else:
|
||||
pn = str(ip.partname) if hasattr(ip,'partname') else ''
|
||||
if '.jpg' in pn or '.jpeg' in pn: fmt = 'jpeg'
|
||||
elif '.gif' in pn: fmt = 'gif'
|
||||
self._image_cache[rel_id] = (base64.b64encode(ib).decode('utf-8'), fmt)
|
||||
except Exception as e: print(f" ⚠️ Image {rel_id}: {e}")
|
||||
except Exception as e: print(f" ⚠️ Rels error: {e}")
|
||||
|
||||
def _get_paragraph_images(self, paragraph):
|
||||
images = []
|
||||
try:
|
||||
for drawing in paragraph._element.findall('.//w:drawing', DOCX_NSMAP):
|
||||
for blip in drawing.findall('.//a:blip', DOCX_NSMAP):
|
||||
eid = blip.get(f'{R_NS}embed')
|
||||
if eid and eid in self._image_cache:
|
||||
d, f = self._image_cache[eid]
|
||||
images.append({'data': d, 'format': f})
|
||||
except Exception as e: print(f" ⚠️ Para images: {e}")
|
||||
return images
|
||||
|
||||
def _get_paragraph_segments(self, paragraph):
|
||||
"""Extract text from paragraph as a list of SEGMENTS split by <w:br/>.
|
||||
|
||||
Each segment is a list of (text, is_bold, is_italic) tuples.
|
||||
Segments are separated by <w:br/> elements (soft line breaks).
|
||||
|
||||
This allows us to split a single <w:p> containing multiple
|
||||
logical paragraphs (joined by <w:br/>) into separate blocks.
|
||||
"""
|
||||
segments = [[]] # List of segments, each segment is a list of (text, bold, italic)
|
||||
|
||||
# Walk all direct children of the paragraph element in order
|
||||
# Children can be: <w:r> (run), <w:hyperlink>, <w:bookmarkStart>, etc.
|
||||
for child in paragraph._element:
|
||||
tag = child.tag
|
||||
|
||||
if tag == f'{W_NS}r':
|
||||
# Direct run
|
||||
self._process_run_element(child, segments)
|
||||
|
||||
elif tag == f'{W_NS}hyperlink':
|
||||
# Hyperlink — contains <w:r> children
|
||||
for run_elem in child.findall(f'{W_NS}r'):
|
||||
self._process_run_element(run_elem, segments)
|
||||
|
||||
elif tag == f'{W_NS}smartTag':
|
||||
# Smart tag — contains <w:r> children
|
||||
for run_elem in child.findall(f'{W_NS}r'):
|
||||
self._process_run_element(run_elem, segments)
|
||||
|
||||
elif tag == f'{W_NS}sdt':
|
||||
# Structured document tag — may contain runs
|
||||
for run_elem in child.iter(f'{W_NS}r'):
|
||||
self._process_run_element(run_elem, segments)
|
||||
|
||||
return segments
|
||||
|
||||
def _process_run_element(self, run_elem, segments):
|
||||
"""Process a single <w:r> element, adding text to segments.
|
||||
|
||||
If the run contains a <w:br/>, start a new segment.
|
||||
"""
|
||||
# Check for <w:br/> first — it means a line break
|
||||
for elem in run_elem:
|
||||
if elem.tag == f'{W_NS}br':
|
||||
# Start a new segment
|
||||
segments.append([])
|
||||
elif elem.tag == f'{W_NS}t':
|
||||
if elem.text:
|
||||
is_bold, is_italic = self._get_run_formatting(run_elem)
|
||||
segments[-1].append((elem.text, is_bold, is_italic))
|
||||
|
||||
def _get_run_formatting(self, run_elem):
|
||||
"""Check if a <w:r> element has bold/italic formatting."""
|
||||
is_bold = False
|
||||
is_italic = False
|
||||
rpr = run_elem.find(f'{W_NS}rPr')
|
||||
if rpr is not None:
|
||||
b = rpr.find(f'{W_NS}b')
|
||||
if b is not None:
|
||||
v = b.get(f'{W_NS}val')
|
||||
is_bold = v is None or v not in ('0', 'false')
|
||||
i = rpr.find(f'{W_NS}i')
|
||||
if i is not None:
|
||||
v = i.get(f'{W_NS}val')
|
||||
is_italic = v is None or v not in ('0', 'false')
|
||||
return is_bold, is_italic
|
||||
|
||||
def _segments_to_text(self, segment):
|
||||
"""Convert a segment (list of (text, bold, italic) tuples) to markdown string."""
|
||||
parts = []
|
||||
for text, is_bold, is_italic in segment:
|
||||
if is_bold and is_italic: parts.append(f"***{text}***")
|
||||
elif is_bold: parts.append(f"**{text}**")
|
||||
elif is_italic: parts.append(f"*{text}*")
|
||||
else: parts.append(text)
|
||||
return ''.join(parts)
|
||||
|
||||
def _segment_plain_text(self, segment):
|
||||
"""Get plain text from a segment."""
|
||||
return ''.join(text for text, _, _ in segment)
|
||||
|
||||
def _get_full_paragraph_plain_text(self, paragraph):
|
||||
"""Get ALL plain text from paragraph including hyperlinks."""
|
||||
texts = []
|
||||
for t_elem in paragraph._element.iter(f'{W_NS}t'):
|
||||
if t_elem.text:
|
||||
texts.append(t_elem.text)
|
||||
return ''.join(texts).strip()
|
||||
|
||||
def _classify_paragraph(self, paragraph):
|
||||
sn = paragraph.style.name if paragraph.style else ''
|
||||
for p, bt in self.HEADING_PATTERNS.items():
|
||||
if sn == p or sn.startswith(p): return bt
|
||||
if sn in self.QUOTE_STYLES: return 'quote'
|
||||
if sn in self.LIST_BULLET_STYLES: return 'list_item'
|
||||
if sn in self.LIST_NUMBER_STYLES: return 'numbered_list'
|
||||
if sn == 'List Paragraph': return 'list_item'
|
||||
if 'toc' in sn.lower(): return 'list_item'
|
||||
return 'paragraph'
|
||||
|
||||
def _table_to_markdown(self, table):
|
||||
rd = []
|
||||
for r in table.rows:
|
||||
rd.append([c.text.replace('|','\\|').replace('\n',' ').strip() for c in r.cells])
|
||||
if not rd: return ""
|
||||
lines = []
|
||||
for i, r in enumerate(rd):
|
||||
lines.append('| '+' | '.join(r)+' |')
|
||||
if i == 0: lines.append('| '+' | '.join(['---']*len(r))+' |')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _make_block(self, block_type, text):
|
||||
tm = {
|
||||
'title':('heading1','# '),'subtitle':('heading2','## '),
|
||||
'heading1':('heading1','# '),'heading2':('heading2','## '),
|
||||
'heading3':('heading3','### '),'quote':('quote','> '),
|
||||
'list_item':('list_item','- '),'numbered_list':('list_item','1. '),
|
||||
}
|
||||
if block_type in tm:
|
||||
bt, pf = tm[block_type]
|
||||
return {'type': bt, 'content': f"{pf}{text}"}
|
||||
return {'type': 'paragraph', 'content': text}
|
||||
|
||||
def _process_element(self, element, blocks):
|
||||
from docx.table import Table as DocxTable
|
||||
from docx.text.paragraph import Paragraph as DocxParagraph
|
||||
|
||||
if isinstance(element, DocxParagraph):
|
||||
plain_text = self._get_full_paragraph_plain_text(element)
|
||||
|
||||
if not plain_text:
|
||||
# Image-only paragraph
|
||||
for img in self._get_paragraph_images(element):
|
||||
blocks.append({
|
||||
'type': 'image',
|
||||
'content': f"",
|
||||
'data': img['data'], 'format': img['format'],
|
||||
})
|
||||
return
|
||||
|
||||
# Extract images first
|
||||
for img in self._get_paragraph_images(element):
|
||||
blocks.append({
|
||||
'type': 'image',
|
||||
'content': f"",
|
||||
'data': img['data'], 'format': img['format'],
|
||||
})
|
||||
|
||||
block_type = self._classify_paragraph(element)
|
||||
|
||||
# Get text as segments split by <w:br/>
|
||||
segments = self._get_paragraph_segments(element)
|
||||
|
||||
# Filter out empty segments
|
||||
non_empty_segments = [s for s in segments if self._segment_plain_text(s).strip()]
|
||||
|
||||
if len(non_empty_segments) <= 1:
|
||||
# Single segment — normal case, one block
|
||||
text = self._segments_to_text(non_empty_segments[0]) if non_empty_segments else ''
|
||||
if text.strip():
|
||||
blocks.append(self._make_block(block_type, text))
|
||||
else:
|
||||
# Multiple segments — split into separate blocks
|
||||
# First segment gets the paragraph's style classification
|
||||
# Subsequent segments are treated as paragraphs unless they look like headings
|
||||
for idx, seg in enumerate(non_empty_segments):
|
||||
seg_text = self._segments_to_text(seg)
|
||||
seg_plain = self._segment_plain_text(seg).strip()
|
||||
|
||||
if not seg_plain:
|
||||
continue
|
||||
|
||||
if idx == 0:
|
||||
# First segment keeps original type
|
||||
blocks.append(self._make_block(block_type, seg_text))
|
||||
else:
|
||||
# Subsequent segments — detect if they look like headings
|
||||
# (short, all bold, or specific patterns)
|
||||
is_all_bold = all(b for _, b, _ in seg if _)
|
||||
is_short = len(seg_plain) < 100
|
||||
|
||||
if is_all_bold and is_short and not seg_plain.endswith(('.', ':', ',')):
|
||||
# Looks like a sub-heading
|
||||
blocks.append(self._make_block('heading3', seg_text))
|
||||
else:
|
||||
blocks.append(self._make_block('paragraph', seg_text))
|
||||
|
||||
elif isinstance(element, DocxTable):
|
||||
md = self._table_to_markdown(element)
|
||||
if md.strip():
|
||||
blocks.append({'type': 'table', 'content': md})
|
||||
|
||||
def process(self):
|
||||
blocks = []; metadata = {'title':'','author':'','subject':''}
|
||||
try:
|
||||
cp = self.doc.core_properties
|
||||
metadata['title'] = cp.title or ''
|
||||
metadata['author'] = cp.author or ''
|
||||
metadata['subject'] = cp.subject or ''
|
||||
except: pass
|
||||
|
||||
try:
|
||||
for element in self.doc.iter_inner_content():
|
||||
self._process_element(element, blocks)
|
||||
except AttributeError:
|
||||
print(" ⚠️ iter_inner_content() not available, using fallback")
|
||||
for p in self.doc.paragraphs: self._process_element(p, blocks)
|
||||
for t in self.doc.tables: self._process_element(t, blocks)
|
||||
|
||||
img_count = sum(1 for b in blocks if b.get('type') == 'image')
|
||||
print(f"📄 DOCX processed: {len(blocks)} blocks ({img_count} images)")
|
||||
return {'metadata': metadata, 'blocks': blocks}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# OLE2 DOC PROCESSOR
|
||||
# ================================================================
|
||||
|
||||
class DOCProcessor:
|
||||
def __init__(self, doc_bytes): self._doc_bytes = doc_bytes
|
||||
def process(self):
|
||||
blocks = []; metadata = {'title':'','author':'','subject':''}; imgs = []
|
||||
try:
|
||||
import olefile
|
||||
ole = olefile.OleFileIO(io.BytesIO(self._doc_bytes))
|
||||
try:
|
||||
m = ole.get_metadata()
|
||||
for f in ['title','author','subject']:
|
||||
v = getattr(m,f,None)
|
||||
if v: metadata[f] = v.decode('utf-8',errors='ignore') if isinstance(v,bytes) else str(v)
|
||||
except: pass
|
||||
imgs = self._extract_ole_images(ole)
|
||||
if ole.exists('WordDocument'):
|
||||
t = self._extract_text(ole)
|
||||
if t:
|
||||
for p in re.split(r'\r\n|\r|\n', t):
|
||||
p = p.strip()
|
||||
if p: blocks.append({'type':'paragraph','content':p})
|
||||
ole.close()
|
||||
except ImportError:
|
||||
blocks = self._basic_extract(); imgs = self._scan_images(self._doc_bytes)
|
||||
except Exception as e:
|
||||
print(f" ⚠️ OLE failed: {e}")
|
||||
blocks = self._basic_extract(); imgs = self._scan_images(self._doc_bytes)
|
||||
if not blocks: blocks = self._basic_extract()
|
||||
if imgs and blocks:
|
||||
iv = max(1, len(blocks)//(len(imgs)+1)); r = []; ii = 0
|
||||
for i, b in enumerate(blocks):
|
||||
if ii < len(imgs) and i > 0 and i % iv == 0: r.append(imgs[ii]); ii += 1
|
||||
r.append(b)
|
||||
while ii < len(imgs): r.append(imgs[ii]); ii += 1
|
||||
blocks = r
|
||||
elif imgs: blocks = imgs + blocks
|
||||
print(f"📄 DOC (OLE2): {len(blocks)} blocks ({len(imgs)} images)")
|
||||
return {'metadata': metadata, 'blocks': blocks}
|
||||
|
||||
def _extract_ole_images(self, ole):
|
||||
imgs = []
|
||||
try:
|
||||
for sp in ole.listdir():
|
||||
try:
|
||||
d = ole.openstream(sp).read()
|
||||
if len(d) < 100: continue
|
||||
if d[:3] == b'\xff\xd8\xff':
|
||||
imgs.append({'type':'image','content':'','data':base64.b64encode(d).decode(),'format':'jpeg'}); continue
|
||||
if d[:8] == b'\x89PNG\r\n\x1a\n':
|
||||
imgs.append({'type':'image','content':'','data':base64.b64encode(d).decode(),'format':'png'}); continue
|
||||
if len(d) > 2048: imgs.extend(self._scan_images(d))
|
||||
except: continue
|
||||
except: pass
|
||||
seen = set(); return [i for i in imgs if (k:=i.get('data','')[:80]) and k not in seen and not seen.add(k)]
|
||||
|
||||
def _scan_images(self, data):
|
||||
imgs = []; pos = 0
|
||||
while pos < len(data)-3:
|
||||
i = data.find(b'\xff\xd8\xff', pos)
|
||||
if i == -1: break
|
||||
e = data.find(b'\xff\xd9', i+3)
|
||||
if e == -1: break
|
||||
e += 2
|
||||
if 2048 < e-i < 50*1024*1024:
|
||||
imgs.append({'type':'image','content':'','data':base64.b64encode(data[i:e]).decode(),'format':'jpeg'})
|
||||
pos = e
|
||||
pos = 0
|
||||
while pos < len(data)-8:
|
||||
i = data.find(b'\x89PNG\r\n\x1a\n', pos)
|
||||
if i == -1: break
|
||||
e = data.find(b'IEND\xaeB`\x82', i+8)
|
||||
if e == -1: break
|
||||
e += 8
|
||||
if 1024 < e-i < 50*1024*1024:
|
||||
imgs.append({'type':'image','content':'','data':base64.b64encode(data[i:e]).decode(),'format':'png'})
|
||||
pos = e
|
||||
return imgs
|
||||
|
||||
def _extract_text(self, ole):
|
||||
t = ''
|
||||
try:
|
||||
if ole.exists('WordDocument'):
|
||||
s = ole.openstream('WordDocument').read()
|
||||
d = s.decode('utf-16-le',errors='ignore')
|
||||
c = ''.join(ch for ch in d if ch in '\r\n\t' or ch.isprintable())
|
||||
if len(c) > 20: return c.strip()
|
||||
except: pass
|
||||
for sp in ole.listdir():
|
||||
try:
|
||||
d = ole.openstream(sp).read().decode('utf-16-le',errors='ignore')
|
||||
c = ''.join(ch for ch in d if ch.isprintable() or ch in '\r\n\t')
|
||||
if len(c) > len(t): t = c
|
||||
except: pass
|
||||
return t
|
||||
|
||||
def _basic_extract(self):
|
||||
blocks = []
|
||||
try:
|
||||
d = self._doc_bytes.decode('utf-16-le',errors='ignore')
|
||||
c = ''.join(ch for ch in d if ch.isprintable() or ch in '\r\n\t')
|
||||
for p in c.split('\r'):
|
||||
p = p.strip()
|
||||
if len(p) > 3: blocks.append({'type':'paragraph','content':p})
|
||||
except: pass
|
||||
return blocks
|
||||
|
||||
|
||||
# ================================================================
|
||||
# MAIN ENTRY POINT
|
||||
# ================================================================
|
||||
|
||||
def process_docx_to_markdown(file_bytes, filename=''):
|
||||
fmt = detect_doc_format(file_bytes)
|
||||
print(f" 🔍 File: {filename} | Format: {fmt} | Size: {len(file_bytes)}")
|
||||
|
||||
pmap = {'docx':DOCXProcessor,'mhtml':MHTMLProcessor,'html':HTMLDocProcessor,'rtf':RTFDocProcessor,'ole2':DOCProcessor}
|
||||
|
||||
if fmt in pmap:
|
||||
try:
|
||||
r = pmap[fmt](file_bytes).process()
|
||||
if r.get('blocks'):
|
||||
ic = sum(1 for b in r['blocks'] if b.get('type')=='image')
|
||||
print(f" ✅ {len(r['blocks'])} blocks ({ic} images)")
|
||||
return {'metadata':r['metadata'],'markdown_blocks':r['blocks']}
|
||||
except Exception as e:
|
||||
import traceback; print(f" ⚠️ {fmt} failed: {e}"); traceback.print_exc()
|
||||
|
||||
for fn, PC in [('DOCX',DOCXProcessor),('MHTML',MHTMLProcessor),('HTML',HTMLDocProcessor),('RTF',RTFDocProcessor),('OLE2',DOCProcessor)]:
|
||||
try:
|
||||
r = PC(file_bytes).process()
|
||||
if r.get('blocks'):
|
||||
print(f" ✅ Parsed as {fn}")
|
||||
return {'metadata':r['metadata'],'markdown_blocks':r['blocks']}
|
||||
except: continue
|
||||
|
||||
return {'metadata':{'title':'','author':'','subject':''},'markdown_blocks':[{'type':'paragraph','content':'⚠️ Could not extract content. Try saving as .docx.'}]}
|
||||
772
pdf_processor.py
Normal file
772
pdf_processor.py
Normal file
@@ -0,0 +1,772 @@
|
||||
# pdf_processor.py - PDF Processing and Content Extraction
|
||||
|
||||
import base64
|
||||
import re
|
||||
import fitz # PyMuPDF
|
||||
|
||||
|
||||
class PDFProcessor:
|
||||
"""Process PDF files and extract structured content."""
|
||||
|
||||
TITLE_SIZE_THRESHOLD = 24
|
||||
SUBTITLE_SIZE_THRESHOLD = 18
|
||||
HEADING_SIZE_THRESHOLD = 14
|
||||
|
||||
TITLE_RATIO = 1.8
|
||||
SUBTITLE_RATIO = 1.4
|
||||
HEADING_RATIO = 1.2
|
||||
|
||||
LIST_PATTERNS = [
|
||||
r'^\s*[\u2022\u2023\u25E6\u2043\u2219•●○◦‣⁃·∙]\s*',
|
||||
r'^\s*[-–—]\s+',
|
||||
r'^\s*\d+[.)]\s+',
|
||||
r'^\s*[a-zA-Z][.)]\s+',
|
||||
r'^\s*[ivxIVX]+[.)]\s+',
|
||||
]
|
||||
|
||||
BULLET_CHARS = set('•●○◦‣⁃·∙\u2022\u2023\u25E6\u2043\u2219-–—')
|
||||
|
||||
INLINE_BULLET_SPLIT = re.compile(
|
||||
r'\s*[\u2022\u2023\u25E6\u2043\u2219•●○◦‣⁃·∙]\s+'
|
||||
)
|
||||
|
||||
QUOTE_PATTERNS = [
|
||||
r'^[\"\'\u201C\u201D\u2018\u2019].+[\"\'\u201C\u201D\u2018\u2019]$',
|
||||
]
|
||||
|
||||
# Pattern for TOC-style dot leaders: text followed by dots and a page number
|
||||
TOC_LEADER_PATTERN = re.compile(r'[.…·]{3,}\s*\.?\s*\d+\s*$')
|
||||
|
||||
def __init__(self, pdf_bytes):
|
||||
self.doc = fitz.open(stream=pdf_bytes, filetype="pdf")
|
||||
self.elements = []
|
||||
self.font_sizes = []
|
||||
self.median_size = 12
|
||||
self.body_size = 12
|
||||
|
||||
def close(self):
|
||||
if self.doc:
|
||||
self.doc.close()
|
||||
|
||||
def _analyze_font_distribution(self):
|
||||
font_size_counts = {}
|
||||
|
||||
for page in self.doc:
|
||||
blocks = page.get_text("dict", flags=11)["blocks"]
|
||||
for block in blocks:
|
||||
if block.get("type") == 0:
|
||||
for line in block.get("lines", []):
|
||||
for span in line.get("spans", []):
|
||||
size = round(span.get("size", 12), 1)
|
||||
text = span.get("text", "").strip()
|
||||
if text:
|
||||
self.font_sizes.append(size)
|
||||
font_size_counts[size] = font_size_counts.get(size, 0) + len(text)
|
||||
|
||||
if self.font_sizes:
|
||||
self.font_sizes.sort()
|
||||
n = len(self.font_sizes)
|
||||
self.median_size = self.font_sizes[n // 2]
|
||||
|
||||
if font_size_counts:
|
||||
self.body_size = max(font_size_counts.keys(), key=lambda x: font_size_counts[x])
|
||||
else:
|
||||
self.body_size = self.median_size
|
||||
|
||||
def _is_likely_heading(self, text, font_size, flags):
|
||||
text_stripped = text.strip()
|
||||
if not text_stripped:
|
||||
return False, None
|
||||
|
||||
is_bold = bool(flags & 2 ** 4)
|
||||
is_all_caps = text_stripped.isupper() and len(text_stripped) > 3
|
||||
size_ratio = font_size / self.body_size if self.body_size > 0 else 1
|
||||
|
||||
if size_ratio >= self.TITLE_RATIO or font_size >= self.TITLE_SIZE_THRESHOLD:
|
||||
if len(text_stripped) < 200:
|
||||
return True, "title"
|
||||
if size_ratio >= self.SUBTITLE_RATIO or font_size >= self.SUBTITLE_SIZE_THRESHOLD:
|
||||
if len(text_stripped) < 150:
|
||||
return True, "subtitle"
|
||||
if size_ratio >= self.HEADING_RATIO and is_bold:
|
||||
if len(text_stripped) < 100:
|
||||
return True, "heading"
|
||||
if is_all_caps and is_bold and len(text_stripped) < 80:
|
||||
return True, "heading"
|
||||
|
||||
# Short bold text is likely a heading even at body font size
|
||||
if is_bold and len(text_stripped) < 60:
|
||||
return True, "heading"
|
||||
|
||||
return False, None
|
||||
|
||||
def _classify_element(self, text, font_size, flags, is_italic=False, bbox=None):
|
||||
text_stripped = text.strip()
|
||||
if not text_stripped:
|
||||
return None
|
||||
|
||||
is_bold = bool(flags & 2 ** 4)
|
||||
|
||||
# Check headings FIRST (before list patterns)
|
||||
is_heading, heading_type = self._is_likely_heading(text_stripped, font_size, flags)
|
||||
if is_heading:
|
||||
return heading_type
|
||||
|
||||
# Then check list patterns
|
||||
for pattern in self.LIST_PATTERNS:
|
||||
if re.match(pattern, text_stripped):
|
||||
return "list_item"
|
||||
|
||||
# Then check quotes
|
||||
if is_italic and len(text_stripped) > 50:
|
||||
return "quote"
|
||||
for pattern in self.QUOTE_PATTERNS:
|
||||
if re.match(pattern, text_stripped):
|
||||
return "quote"
|
||||
|
||||
return "paragraph"
|
||||
|
||||
def _extract_images(self, page, page_num):
|
||||
images = []
|
||||
image_list = page.get_images(full=True)
|
||||
|
||||
for img_index, img in enumerate(image_list):
|
||||
try:
|
||||
xref = img[0]
|
||||
base_image = self.doc.extract_image(xref)
|
||||
if base_image:
|
||||
image_bytes = base_image["image"]
|
||||
image_ext = base_image["ext"]
|
||||
img_rects = page.get_image_rects(img)
|
||||
bbox = None
|
||||
if img_rects:
|
||||
rect = img_rects[0]
|
||||
bbox = [rect.x0, rect.y0, rect.x1, rect.y1]
|
||||
images.append({
|
||||
"type": "image",
|
||||
"data": base64.b64encode(image_bytes).decode('utf-8'),
|
||||
"format": image_ext,
|
||||
"bbox": bbox,
|
||||
"width": base_image.get("width", 0),
|
||||
"height": base_image.get("height", 0),
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Error extracting image {img_index} from page {page_num}: {e}")
|
||||
|
||||
return images
|
||||
|
||||
def _extract_tables(self, page, page_num):
|
||||
tables = []
|
||||
try:
|
||||
table_finder = page.find_tables()
|
||||
for table_index, table in enumerate(table_finder):
|
||||
try:
|
||||
table_data = table.extract()
|
||||
bbox = list(table.bbox)
|
||||
markdown_table = self._table_to_markdown(table_data)
|
||||
tables.append({
|
||||
"type": "table",
|
||||
"data": table_data,
|
||||
"markdown": markdown_table,
|
||||
"bbox": bbox,
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Error extracting table {table_index} from page {page_num}: {e}")
|
||||
except Exception as e:
|
||||
print(f"Error finding tables on page {page_num}: {e}")
|
||||
return tables
|
||||
|
||||
def _table_to_markdown(self, table_data):
|
||||
if not table_data:
|
||||
return ""
|
||||
lines = []
|
||||
for row_idx, row in enumerate(table_data):
|
||||
cells = [str(cell).replace('|', '\\|').replace('\n', ' ') if cell else '' for cell in row]
|
||||
lines.append('| ' + ' | '.join(cells) + ' |')
|
||||
if row_idx == 0:
|
||||
lines.append('| ' + ' | '.join(['---'] * len(cells)) + ' |')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _get_reading_order(self, elements, page_width):
|
||||
if not elements:
|
||||
return elements
|
||||
mid_x = page_width / 2
|
||||
left_col, right_col, full_width = [], [], []
|
||||
|
||||
for elem in elements:
|
||||
bbox = elem.get("bbox")
|
||||
if not bbox:
|
||||
full_width.append(elem)
|
||||
continue
|
||||
x0, y0, x1, y1 = bbox
|
||||
width = x1 - x0
|
||||
if width > page_width * 0.6:
|
||||
full_width.append(elem)
|
||||
elif x1 < mid_x:
|
||||
left_col.append(elem)
|
||||
elif x0 > mid_x:
|
||||
right_col.append(elem)
|
||||
else:
|
||||
full_width.append(elem)
|
||||
|
||||
sort_by_y = lambda e: e.get("bbox", [0, 0, 0, 0])[1]
|
||||
left_col.sort(key=sort_by_y)
|
||||
right_col.sort(key=sort_by_y)
|
||||
full_width.sort(key=sort_by_y)
|
||||
|
||||
all_elements = [(e, "full") for e in full_width]
|
||||
all_elements += [(e, "left") for e in left_col]
|
||||
all_elements += [(e, "right") for e in right_col]
|
||||
all_elements.sort(key=lambda x: x[0].get("bbox", [0, 0, 0, 0])[1])
|
||||
|
||||
result = [e[0] for e in all_elements]
|
||||
for idx, elem in enumerate(result):
|
||||
elem["reading_order"] = idx
|
||||
return result
|
||||
|
||||
def _bboxes_overlap(self, bbox1, bbox2, threshold=0.5):
|
||||
if not bbox1 or not bbox2:
|
||||
return False
|
||||
x1_min, y1_min, x1_max, y1_max = bbox1
|
||||
x2_min, y2_min, x2_max, y2_max = bbox2
|
||||
x_overlap = max(0, min(x1_max, x2_max) - max(x1_min, x2_min))
|
||||
y_overlap = max(0, min(y1_max, y2_max) - max(y1_min, y2_min))
|
||||
intersection = x_overlap * y_overlap
|
||||
area1 = (x1_max - x1_min) * (y1_max - y1_min)
|
||||
if area1 == 0:
|
||||
return False
|
||||
return intersection / area1 > threshold
|
||||
|
||||
# ================================================================
|
||||
# LINE-LEVEL ANALYSIS
|
||||
# ================================================================
|
||||
|
||||
def _extract_line_info(self, line):
|
||||
"""Extract enriched info from a single dict-mode line."""
|
||||
text = ""
|
||||
total_chars = 0
|
||||
weighted_size = 0.0
|
||||
combined_flags = 0
|
||||
|
||||
for span in line.get("spans", []):
|
||||
span_text = span.get("text", "")
|
||||
span_size = span.get("size", 12)
|
||||
span_flags = span.get("flags", 0)
|
||||
|
||||
if span_text.strip():
|
||||
char_count = len(span_text)
|
||||
text += span_text
|
||||
weighted_size = (
|
||||
(weighted_size * total_chars + span_size * char_count) /
|
||||
(total_chars + char_count)
|
||||
) if (total_chars + char_count) > 0 else span_size
|
||||
total_chars += char_count
|
||||
combined_flags |= span_flags
|
||||
|
||||
stripped = text.strip()
|
||||
|
||||
return {
|
||||
"text": text,
|
||||
"stripped": stripped,
|
||||
"bbox": list(line.get("bbox", [0, 0, 0, 0])),
|
||||
"font_size": round(weighted_size, 1),
|
||||
"flags": combined_flags,
|
||||
"is_bold": bool(combined_flags & (2 ** 4)),
|
||||
"is_italic": bool(combined_flags & (2 ** 1)),
|
||||
"char_count": total_chars,
|
||||
"is_bullet": len(stripped) <= 2 and bool(stripped) and all(c in self.BULLET_CHARS for c in stripped),
|
||||
"is_single_line_entry": False, # Set during analysis
|
||||
}
|
||||
|
||||
def _is_single_line_entry(self, info, page_width):
|
||||
"""
|
||||
Determine if a line is a self-contained single-line entry
|
||||
(like a TOC entry, a short heading, etc.) rather than a wrapped
|
||||
continuation of a multi-line paragraph.
|
||||
|
||||
Signals:
|
||||
- Contains dot leaders (TOC pattern)
|
||||
- Is a single line that doesn't reach near the right margin (not a wrapped line)
|
||||
- Ends with a number (page reference)
|
||||
"""
|
||||
text = info["stripped"]
|
||||
if not text:
|
||||
return False
|
||||
|
||||
# TOC dot leader pattern: "Chapter 1 - Something.............3"
|
||||
if self.TOC_LEADER_PATTERN.search(text):
|
||||
return True
|
||||
|
||||
# Ends with a digit (possible page number) and has dots
|
||||
if re.search(r'\d+\s*$', text) and '…' in text:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# ================================================================
|
||||
# MULTI-SIGNAL PARAGRAPH SPLITTING
|
||||
# ================================================================
|
||||
|
||||
def _should_break_between(self, prev_info, curr_info, median_gap, avg_line_height, page_width):
|
||||
"""Decide whether a paragraph break should be inserted between two lines."""
|
||||
if prev_info["is_bullet"]:
|
||||
return False
|
||||
|
||||
prev_bbox = prev_info["bbox"]
|
||||
curr_bbox = curr_info["bbox"]
|
||||
|
||||
gap = curr_bbox[1] - prev_bbox[3]
|
||||
|
||||
# --- Signal 1: FONT SIZE CHANGE ---
|
||||
size_diff = abs(curr_info["font_size"] - prev_info["font_size"])
|
||||
if size_diff > 1.5:
|
||||
return True
|
||||
|
||||
# --- Signal 2: STYLE CHANGE (bold boundary) ---
|
||||
if prev_info["is_bold"] != curr_info["is_bold"]:
|
||||
# Any bold/non-bold transition = structural break
|
||||
return True
|
||||
|
||||
# --- Signal 3: VERTICAL GAP (relative) ---
|
||||
if median_gap > 0:
|
||||
gap_ratio = gap / median_gap if median_gap > 0 else 1
|
||||
|
||||
if gap_ratio >= 2.0:
|
||||
return True
|
||||
|
||||
if gap_ratio >= 1.5:
|
||||
prev_text = prev_info["stripped"]
|
||||
if prev_text and prev_text[-1] in '.!?:"\u201D\u2019':
|
||||
return True
|
||||
|
||||
# --- Signal 4: ABSOLUTE GAP ---
|
||||
if gap > avg_line_height * 1.0:
|
||||
return True
|
||||
|
||||
# --- Signal 5: INDENTATION CHANGE ---
|
||||
x_diff = abs(curr_bbox[0] - prev_bbox[0])
|
||||
if x_diff > 25:
|
||||
return True
|
||||
|
||||
# --- Signal 6: SINGLE-LINE ENTRIES ---
|
||||
# If previous line is a self-contained entry (e.g. TOC line with dot leaders),
|
||||
# break even if font/style/gap are the same
|
||||
if prev_info.get("is_single_line_entry"):
|
||||
return True
|
||||
|
||||
# --- Signal 7: BOTH BOLD + PREVIOUS IS SHORT ---
|
||||
# When both lines are bold and the previous line is relatively short
|
||||
# (not reaching near the right margin), each is likely a separate heading/entry.
|
||||
# This handles TOC entries, section headings, etc. that are all bold+same size.
|
||||
if prev_info["is_bold"] and curr_info["is_bold"]:
|
||||
prev_line_width = prev_bbox[2] - prev_bbox[0]
|
||||
# Compare against page content width (approximate)
|
||||
if page_width > 0 and prev_line_width < page_width * 0.75:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _merge_bullet_lines(self, line_infos):
|
||||
"""Merge bullet character lines with their following text lines."""
|
||||
if not line_infos:
|
||||
return line_infos
|
||||
|
||||
merged = []
|
||||
i = 0
|
||||
while i < len(line_infos):
|
||||
info = line_infos[i]
|
||||
|
||||
if info["is_bullet"] and i + 1 < len(line_infos):
|
||||
next_info = line_infos[i + 1]
|
||||
bullet_char = info["stripped"]
|
||||
merged_text = bullet_char + " " + next_info["text"]
|
||||
merged_stripped = bullet_char + " " + next_info["stripped"]
|
||||
|
||||
merged_info = {
|
||||
"text": merged_text,
|
||||
"stripped": merged_stripped,
|
||||
"bbox": [
|
||||
min(info["bbox"][0], next_info["bbox"][0]),
|
||||
min(info["bbox"][1], next_info["bbox"][1]),
|
||||
max(info["bbox"][2], next_info["bbox"][2]),
|
||||
max(info["bbox"][3], next_info["bbox"][3]),
|
||||
],
|
||||
"font_size": next_info["font_size"],
|
||||
"flags": next_info["flags"],
|
||||
"is_bold": next_info["is_bold"],
|
||||
"is_italic": next_info["is_italic"],
|
||||
"char_count": info["char_count"] + next_info["char_count"],
|
||||
"is_bullet": False,
|
||||
"is_single_line_entry": False,
|
||||
}
|
||||
merged.append(merged_info)
|
||||
i += 2
|
||||
else:
|
||||
merged.append(info)
|
||||
i += 1
|
||||
|
||||
return merged
|
||||
|
||||
def _split_block_into_paragraphs(self, block, page_width):
|
||||
"""Split a single dict-mode block into paragraph groups."""
|
||||
lines = block.get("lines", [])
|
||||
if not lines:
|
||||
return []
|
||||
|
||||
line_infos = []
|
||||
for line in lines:
|
||||
info = self._extract_line_info(line)
|
||||
if info["stripped"]:
|
||||
line_infos.append(info)
|
||||
|
||||
if not line_infos:
|
||||
return []
|
||||
|
||||
line_infos = self._merge_bullet_lines(line_infos)
|
||||
|
||||
# Mark single-line entries (TOC lines, etc.)
|
||||
for info in line_infos:
|
||||
info["is_single_line_entry"] = self._is_single_line_entry(info, page_width)
|
||||
|
||||
if len(line_infos) == 1:
|
||||
return [line_infos]
|
||||
|
||||
gaps = []
|
||||
line_heights = []
|
||||
for i in range(len(line_infos)):
|
||||
h = line_infos[i]["bbox"][3] - line_infos[i]["bbox"][1]
|
||||
line_heights.append(h)
|
||||
if i > 0:
|
||||
gap = line_infos[i]["bbox"][1] - line_infos[i - 1]["bbox"][3]
|
||||
gaps.append(gap)
|
||||
|
||||
avg_line_height = sum(line_heights) / len(line_heights) if line_heights else 12
|
||||
|
||||
if gaps:
|
||||
sorted_gaps = sorted(gaps)
|
||||
median_gap = sorted_gaps[len(sorted_gaps) // 2]
|
||||
else:
|
||||
median_gap = avg_line_height * 0.3
|
||||
|
||||
paragraphs = []
|
||||
current_group = [line_infos[0]]
|
||||
|
||||
for i in range(1, len(line_infos)):
|
||||
if self._should_break_between(
|
||||
line_infos[i - 1], line_infos[i],
|
||||
median_gap, avg_line_height, page_width
|
||||
):
|
||||
paragraphs.append(current_group)
|
||||
current_group = [line_infos[i]]
|
||||
else:
|
||||
current_group.append(line_infos[i])
|
||||
|
||||
if current_group:
|
||||
paragraphs.append(current_group)
|
||||
|
||||
return paragraphs
|
||||
|
||||
def _group_to_element(self, line_group):
|
||||
"""Convert a group of line-infos into a single page element dict."""
|
||||
text = " ".join(info["stripped"] for info in line_group if info["stripped"])
|
||||
|
||||
if not text.strip():
|
||||
return None
|
||||
|
||||
total_chars = sum(info["char_count"] for info in line_group)
|
||||
if total_chars > 0:
|
||||
font_size = sum(
|
||||
info["font_size"] * info["char_count"] for info in line_group
|
||||
) / total_chars
|
||||
else:
|
||||
font_size = self.body_size
|
||||
|
||||
flags = 0
|
||||
for info in line_group:
|
||||
flags |= info["flags"]
|
||||
|
||||
x0 = min(info["bbox"][0] for info in line_group)
|
||||
y0 = min(info["bbox"][1] for info in line_group)
|
||||
x1 = max(info["bbox"][2] for info in line_group)
|
||||
y1 = max(info["bbox"][3] for info in line_group)
|
||||
|
||||
is_italic = bool(flags & (2 ** 1))
|
||||
|
||||
elem_type = self._classify_element(text, font_size, flags, is_italic, [x0, y0, x1, y1])
|
||||
|
||||
if elem_type:
|
||||
return {
|
||||
"type": elem_type,
|
||||
"text": text.strip(),
|
||||
"bbox": [x0, y0, x1, y1],
|
||||
"font_size": font_size,
|
||||
"flags": flags,
|
||||
}
|
||||
return None
|
||||
|
||||
# ================================================================
|
||||
# POST-PROCESSING
|
||||
# ================================================================
|
||||
|
||||
def _should_merge_elements(self, prev_elem, curr_elem):
|
||||
"""
|
||||
Determine if two consecutive elements should be merged because
|
||||
they are continuations of the same paragraph split across PyMuPDF blocks.
|
||||
"""
|
||||
# Only merge paragraph + paragraph
|
||||
if prev_elem["type"] != "paragraph" or curr_elem["type"] != "paragraph":
|
||||
return False
|
||||
|
||||
# Font size must be similar
|
||||
if abs(prev_elem["font_size"] - curr_elem["font_size"]) > 1.5:
|
||||
return False
|
||||
|
||||
# Don't merge if styles differ
|
||||
prev_bold = bool(prev_elem.get("flags", 0) & (2 ** 4))
|
||||
curr_bold = bool(curr_elem.get("flags", 0) & (2 ** 4))
|
||||
if prev_bold != curr_bold:
|
||||
return False
|
||||
|
||||
prev_text = prev_elem["text"].strip()
|
||||
curr_text = curr_elem["text"].strip()
|
||||
|
||||
if not prev_text or not curr_text:
|
||||
return False
|
||||
|
||||
# Don't merge if prev contains dot leaders (TOC entry)
|
||||
if self.TOC_LEADER_PATTERN.search(prev_text):
|
||||
return False
|
||||
|
||||
last_char = prev_text[-1]
|
||||
|
||||
if last_char in '.!?':
|
||||
if curr_text and curr_text[0].islower():
|
||||
return True
|
||||
return False
|
||||
|
||||
if last_char in '"\u201D\u2019':
|
||||
if len(prev_text) >= 2 and prev_text[-2] in '.!?':
|
||||
if curr_text and curr_text[0].islower():
|
||||
return True
|
||||
return False
|
||||
|
||||
# Doesn't end with sentence-ending punctuation — likely mid-paragraph
|
||||
return True
|
||||
|
||||
def _merge_continuation_paragraphs(self, elements):
|
||||
"""Merge consecutive paragraph elements that are continuations."""
|
||||
if len(elements) <= 1:
|
||||
return elements
|
||||
|
||||
merged = [elements[0]]
|
||||
|
||||
for i in range(1, len(elements)):
|
||||
prev = merged[-1]
|
||||
curr = elements[i]
|
||||
|
||||
if self._should_merge_elements(prev, curr):
|
||||
combined_text = prev["text"].rstrip() + " " + curr["text"].lstrip()
|
||||
|
||||
prev_bbox = prev["bbox"]
|
||||
curr_bbox = curr["bbox"]
|
||||
combined_bbox = [
|
||||
min(prev_bbox[0], curr_bbox[0]),
|
||||
min(prev_bbox[1], curr_bbox[1]),
|
||||
max(prev_bbox[2], curr_bbox[2]),
|
||||
max(prev_bbox[3], curr_bbox[3]),
|
||||
]
|
||||
|
||||
merged[-1] = {
|
||||
"type": "paragraph",
|
||||
"text": combined_text,
|
||||
"bbox": combined_bbox,
|
||||
"font_size": prev["font_size"],
|
||||
"flags": prev.get("flags", 0),
|
||||
}
|
||||
else:
|
||||
merged.append(curr)
|
||||
|
||||
return merged
|
||||
|
||||
def _split_combined_list_items(self, elements):
|
||||
"""Split list_item elements that contain multiple inline bullet items."""
|
||||
result = []
|
||||
|
||||
for elem in elements:
|
||||
if elem["type"] != "list_item":
|
||||
result.append(elem)
|
||||
continue
|
||||
|
||||
text = elem["text"].strip()
|
||||
|
||||
cleaned = text
|
||||
for pattern in self.LIST_PATTERNS:
|
||||
cleaned = re.sub(pattern, '', cleaned, count=1).strip()
|
||||
|
||||
parts = self.INLINE_BULLET_SPLIT.split(cleaned)
|
||||
parts = [p.strip() for p in parts if p.strip()]
|
||||
|
||||
if len(parts) <= 1:
|
||||
result.append(elem)
|
||||
else:
|
||||
bbox = elem["bbox"]
|
||||
total_height = bbox[3] - bbox[1]
|
||||
item_height = total_height / len(parts) if len(parts) > 0 else total_height
|
||||
|
||||
for idx, part in enumerate(parts):
|
||||
item_bbox = [
|
||||
bbox[0],
|
||||
bbox[1] + idx * item_height,
|
||||
bbox[2],
|
||||
bbox[1] + (idx + 1) * item_height,
|
||||
]
|
||||
result.append({
|
||||
"type": "list_item",
|
||||
"text": part.strip(),
|
||||
"bbox": item_bbox,
|
||||
"font_size": elem["font_size"],
|
||||
"flags": elem.get("flags", 0),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def process(self):
|
||||
"""Process the entire PDF and extract all elements."""
|
||||
self._analyze_font_distribution()
|
||||
|
||||
all_pages = []
|
||||
total_images = 0
|
||||
|
||||
for page_num, page in enumerate(self.doc):
|
||||
page_elements = []
|
||||
page_rect = page.rect
|
||||
|
||||
dict_blocks = page.get_text("dict", flags=11)["blocks"]
|
||||
|
||||
tables = self._extract_tables(page, page_num)
|
||||
table_bboxes = [t["bbox"] for t in tables if t.get("bbox")]
|
||||
|
||||
images = self._extract_images(page, page_num)
|
||||
total_images += len(images)
|
||||
|
||||
for block in dict_blocks:
|
||||
if block.get("type") != 0:
|
||||
continue
|
||||
|
||||
block_bbox = block.get("bbox", [0, 0, 0, 0])
|
||||
|
||||
skip_block = False
|
||||
for table_bbox in table_bboxes:
|
||||
if self._bboxes_overlap(block_bbox, table_bbox):
|
||||
skip_block = True
|
||||
break
|
||||
if skip_block:
|
||||
continue
|
||||
|
||||
para_groups = self._split_block_into_paragraphs(block, page_rect.width)
|
||||
|
||||
for group in para_groups:
|
||||
element = self._group_to_element(group)
|
||||
if element:
|
||||
page_elements.append(element)
|
||||
|
||||
page_elements = [e for e in page_elements if e["text"].strip()]
|
||||
page_elements = self._merge_continuation_paragraphs(page_elements)
|
||||
page_elements = self._split_combined_list_items(page_elements)
|
||||
|
||||
page_elements.extend(tables)
|
||||
page_elements.extend(images)
|
||||
page_elements = self._get_reading_order(page_elements, page_rect.width)
|
||||
|
||||
all_pages.append({
|
||||
"page_number": page_num,
|
||||
"width": page_rect.width,
|
||||
"height": page_rect.height,
|
||||
"elements": page_elements
|
||||
})
|
||||
|
||||
print(f"📄 PDF processed: {len(self.doc)} pages, {total_images} images extracted")
|
||||
|
||||
return {
|
||||
"page_count": len(self.doc),
|
||||
"metadata": {
|
||||
"title": self.doc.metadata.get("title", ""),
|
||||
"author": self.doc.metadata.get("author", ""),
|
||||
"subject": self.doc.metadata.get("subject", ""),
|
||||
},
|
||||
"pages": all_pages
|
||||
}
|
||||
|
||||
def to_markdown(self, processed_data):
|
||||
"""Convert processed PDF data to Markdown blocks."""
|
||||
blocks = []
|
||||
|
||||
for page in processed_data.get("pages", []):
|
||||
for elem in page.get("elements", []):
|
||||
elem_type = elem.get("type")
|
||||
|
||||
if elem_type == "title":
|
||||
blocks.append({
|
||||
"type": "heading1",
|
||||
"content": f"# {elem.get('text', '')}"
|
||||
})
|
||||
elif elem_type == "subtitle":
|
||||
blocks.append({
|
||||
"type": "heading2",
|
||||
"content": f"## {elem.get('text', '')}"
|
||||
})
|
||||
elif elem_type == "heading":
|
||||
blocks.append({
|
||||
"type": "heading3",
|
||||
"content": f"### {elem.get('text', '')}"
|
||||
})
|
||||
elif elem_type == "paragraph":
|
||||
blocks.append({
|
||||
"type": "paragraph",
|
||||
"content": elem.get('text', '')
|
||||
})
|
||||
elif elem_type == "list_item":
|
||||
text = elem.get('text', '')
|
||||
for pattern in self.LIST_PATTERNS:
|
||||
text = re.sub(pattern, '', text)
|
||||
blocks.append({
|
||||
"type": "list_item",
|
||||
"content": f"- {text.strip()}"
|
||||
})
|
||||
elif elem_type == "quote":
|
||||
blocks.append({
|
||||
"type": "quote",
|
||||
"content": f"> {elem.get('text', '')}"
|
||||
})
|
||||
elif elem_type == "table":
|
||||
blocks.append({
|
||||
"type": "table",
|
||||
"content": elem.get('markdown', '')
|
||||
})
|
||||
elif elem_type == "image":
|
||||
img_data = elem.get("data", "")
|
||||
img_format = elem.get("format", "png")
|
||||
if img_data:
|
||||
blocks.append({
|
||||
"type": "image",
|
||||
"content": f"",
|
||||
"data": img_data,
|
||||
"format": img_format
|
||||
})
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def process_pdf_to_markdown(pdf_bytes):
|
||||
"""Process PDF bytes and return markdown blocks."""
|
||||
processor = PDFProcessor(pdf_bytes)
|
||||
try:
|
||||
processed_data = processor.process()
|
||||
markdown_blocks = processor.to_markdown(processed_data)
|
||||
return {
|
||||
"page_count": processed_data["page_count"],
|
||||
"metadata": processed_data["metadata"],
|
||||
"markdown_blocks": markdown_blocks
|
||||
}
|
||||
finally:
|
||||
processor.close()
|
||||
612
reader_templates/Reader.html
Executable file
612
reader_templates/Reader.html
Executable file
@@ -0,0 +1,612 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Interactive Audiobook Reader</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400..700;1,400..700&family=Poppins:wght@500;700&display=swap" rel="stylesheet" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<style>
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
background-image: linear-gradient(to top, #f3e7e9 0%, #e3eeff 99%, #e3eeff 100%);
|
||||
color: #1f2937; font-family: "Lora", serif;
|
||||
}
|
||||
.story-title { font-family: "Poppins", sans-serif; font-weight: 700; font-size: 2.5rem; color: #111827; }
|
||||
.story-subtitle { font-family: "Poppins", sans-serif; color: #4b5563; font-weight: 500; font-size: 1.1rem; }
|
||||
.main-content-card {
|
||||
background-color: rgba(255,255,255,0.9); 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.2); max-width: 1400px; margin: 0 auto;
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
#load-folder-btn {
|
||||
background-image: linear-gradient(45deg, #3d4e81 0%, #5753c9 50%, #6e78da 100%);
|
||||
background-size: 200% auto; border: none; transition: all 0.4s ease-in-out !important;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
|
||||
}
|
||||
#load-folder-btn:hover { background-position: right center; transform: translateY(-3px); box-shadow: 0 8px 20px rgba(0,0,0,0.25); }
|
||||
|
||||
.story-text-container { font-size: 36px; line-height: 2.1; color: #1f2937; cursor: pointer; }
|
||||
.story-text-container h1, .story-text-container h2, .story-text-container h3 {
|
||||
font-family: "Poppins", sans-serif; color: #111827; line-height: 1.8; margin-top: 1.5em; margin-bottom: 0.8em;
|
||||
}
|
||||
.story-text-container h1 { font-size: 2.2em; }
|
||||
.story-text-container h2 { font-size: 1.8em; }
|
||||
.story-text-container h3 { font-size: 1.5em; }
|
||||
.story-text-container p { margin-bottom: 1.2em; }
|
||||
.story-text-container img { max-width: 100%; height: auto; border-radius: 8px; margin: 16px auto; display: block; }
|
||||
|
||||
.word { transition: all 0.15s ease; border-radius: 3px; }
|
||||
.word:hover { background-color: #f1f5f9; }
|
||||
.current-sentence-bg {
|
||||
-webkit-box-decoration-break: clone; box-decoration-break: clone;
|
||||
background-color: #e0e7ff; padding: 0.1em 0.25em; margin: 0 -0.2em; border-radius: 8px;
|
||||
}
|
||||
.current-word { color: #3d4e81; text-decoration: underline; text-decoration-thickness: 3px; text-underline-offset: 3px; font-weight: 700; }
|
||||
|
||||
/* Image blocks in reader */
|
||||
.story-image-block { text-align: center; margin: 24px 0; }
|
||||
.story-image-block img { max-width: 100%; height: auto; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
||||
|
||||
/* Floating Button - TOP RIGHT adjacent to content */
|
||||
#floating-player-btn {
|
||||
position: fixed; top: 2rem; height: 60px; min-width: 60px; padding: 0 24px;
|
||||
border-radius: 30px; background-image: linear-gradient(45deg, #3d4e81 0%, #5753c9 50%, #6e78da 100%);
|
||||
background-size: 200% auto; border: none; color: white; box-shadow: 0 8px 25px rgba(0,0,0,0.3);
|
||||
display: none; align-items: center; justify-content: center; cursor: pointer; z-index: 1050;
|
||||
transition: transform 0.2s ease-out, opacity 0.3s ease-out, width 0.3s ease, padding 0.3s ease, border-radius 0.3s ease;
|
||||
opacity: 0; transform: scale(0.8);
|
||||
}
|
||||
#floating-player-btn.visible { display: flex; opacity: 1; transform: scale(1); }
|
||||
#floating-player-btn:hover { transform: scale(1.05); }
|
||||
#floating-player-btn:active { transform: scale(0.95); }
|
||||
#floating-player-btn svg { width: 28px; height: 28px; }
|
||||
#fp-start-text { font-weight: 600; margin-right: 10px; font-family: "Poppins", sans-serif; font-size: 1.1rem; }
|
||||
#floating-player-btn.active-mode { width: 60px; padding: 0; border-radius: 50%; }
|
||||
|
||||
/* Navigation - LEFT SIDEBAR adjacent to content */
|
||||
#story-nav {
|
||||
position: fixed; top: 50%; background-color: rgba(255,255,255,0.85);
|
||||
backdrop-filter: blur(10px); border-radius: 25px; padding: 0.5rem;
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.1); z-index: 1000; max-height: 80vh; overflow-y: auto;
|
||||
opacity: 0; transform: translateY(-50%) translateX(-20px);
|
||||
transition: opacity 0.4s ease-out, transform 0.4s ease-out;
|
||||
}
|
||||
#story-nav.visible { opacity: 1; transform: translateY(-50%) translateX(0); }
|
||||
#story-nav ul { padding-left: 0; margin-bottom: 0; }
|
||||
#story-nav a {
|
||||
display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; margin: 0.5rem 0;
|
||||
border-radius: 50%; text-decoration: none; color: #4b5563; font-family: "Poppins", sans-serif;
|
||||
font-weight: 500; transition: all 0.3s ease;
|
||||
}
|
||||
#story-nav a:hover { background-color: #e0e7ff; color: #3d4e81; }
|
||||
#story-nav a.active { background-image: linear-gradient(45deg, #3d4e81 0%, #5753c9 50%, #6e78da 100%); color: white; transform: scale(1.1); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content-card { padding: 1.5rem; }
|
||||
.story-title { font-size: 2rem; }
|
||||
.story-text-container { font-size: 24px; line-height: 1.9; }
|
||||
#floating-player-btn { top: 1rem; right: 1rem !important; height: 50px; min-width: 50px; }
|
||||
#floating-player-btn.active-mode { width: 50px; }
|
||||
#story-nav { display: none !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav id="story-nav">
|
||||
<ul id="story-nav-list" class="list-unstyled"></ul>
|
||||
</nav>
|
||||
|
||||
<button id="floating-player-btn">
|
||||
<span id="fp-start-text">Start</span>
|
||||
<svg id="fp-pause-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="display:none;"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
||||
<svg id="fp-play-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="display:none;"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="container-fluid my-5 px-md-5">
|
||||
<main id="main-content" class="main-content-card">
|
||||
<header class="text-center mb-5" id="main-header">
|
||||
<h1 class="story-title">Interactive Reader</h1>
|
||||
<p class="story-subtitle">Select your assets folder to begin.</p>
|
||||
</header>
|
||||
|
||||
<div id="resume-alert" class="alert alert-info d-flex justify-content-between align-items-center" style="display:none;">
|
||||
<span>Welcome back! Resume from where you left off?</span>
|
||||
<button id="resume-btn" class="btn btn-primary btn-sm">Resume Playback</button>
|
||||
</div>
|
||||
|
||||
<div class="text-center" id="loader-section">
|
||||
<p>Please select the folder containing your files (e.g., the 'book' folder).</p>
|
||||
<input type="file" id="folder-input" webkitdirectory directory multiple style="display:none;" />
|
||||
<button id="load-folder-btn" class="btn btn-dark btn-lg">Select a Folder</button>
|
||||
<div id="info-alert" class="alert mt-4" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<div id="stories-main-container" class="d-none"></div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const mainContainer = document.getElementById("stories-main-container");
|
||||
const mainContentCard = document.getElementById("main-content");
|
||||
const folderInput = document.getElementById("folder-input");
|
||||
const loadFolderBtn = document.getElementById("load-folder-btn");
|
||||
const floatingPlayerBtn = document.getElementById("floating-player-btn");
|
||||
const fpStartText = document.getElementById("fp-start-text");
|
||||
const fpPauseIcon = document.getElementById("fp-pause-icon");
|
||||
const fpPlayIcon = document.getElementById("fp-play-icon");
|
||||
const storyNav = document.getElementById("story-nav");
|
||||
const storyNavList = document.getElementById("story-nav-list");
|
||||
const resumeAlert = document.getElementById("resume-alert");
|
||||
const resumeBtn = document.getElementById("resume-btn");
|
||||
|
||||
let storyInstances = [];
|
||||
let currentlyPlayingInstance = null;
|
||||
let currentlyPlayingIndex = -1;
|
||||
let hasStarted = false;
|
||||
let navObserver;
|
||||
let currentBookId = null;
|
||||
const PROGRESS_KEY = "interactiveReaderProgress";
|
||||
|
||||
loadFolderBtn.addEventListener("click", () => folderInput.click());
|
||||
folderInput.addEventListener("change", handleFolderSelection);
|
||||
mainContainer.addEventListener("click", handleTextClick);
|
||||
floatingPlayerBtn.addEventListener("click", handleFloatingBtnClick);
|
||||
window.addEventListener("beforeunload", saveCurrentProgress);
|
||||
window.addEventListener("resize", positionUI);
|
||||
window.addEventListener("scroll", positionUI);
|
||||
|
||||
function positionUI() {
|
||||
const rect = mainContentCard.getBoundingClientRect();
|
||||
const btnRight = window.innerWidth - rect.right - 8;
|
||||
floatingPlayerBtn.style.right = Math.max(btnRight, 8) + "px";
|
||||
floatingPlayerBtn.style.left = "auto";
|
||||
const navWidth = storyNav.offsetWidth || 52;
|
||||
const navLeft = rect.left - navWidth - 8;
|
||||
storyNav.style.left = Math.max(navLeft, 8) + "px";
|
||||
}
|
||||
|
||||
function saveCurrentProgress() {
|
||||
if (!currentlyPlayingInstance || !currentBookId) return;
|
||||
const idx = storyInstances.indexOf(currentlyPlayingInstance);
|
||||
saveProgress(currentBookId, idx, currentlyPlayingInstance.getAudioElement().currentTime);
|
||||
}
|
||||
function saveProgress(bookId, idx, timestamp) {
|
||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ bookId, instanceIndex: idx, timestamp, lastUpdate: Date.now() }));
|
||||
}
|
||||
function loadProgress(bookId) {
|
||||
const saved = localStorage.getItem(PROGRESS_KEY);
|
||||
if (!saved) return;
|
||||
const p = JSON.parse(saved);
|
||||
if (p.bookId !== bookId) return;
|
||||
const target = storyInstances[p.instanceIndex];
|
||||
if (!target) return;
|
||||
resumeAlert.style.display = "flex";
|
||||
resumeBtn.onclick = () => {
|
||||
resumeAlert.style.display = "none";
|
||||
hasStarted = true;
|
||||
currentlyPlayingInstance = target;
|
||||
currentlyPlayingIndex = p.instanceIndex;
|
||||
target.playAt(p.timestamp);
|
||||
updateFloatingButton("playing");
|
||||
};
|
||||
}
|
||||
|
||||
function updateFloatingButton(state) {
|
||||
if (hasStarted) {
|
||||
fpStartText.style.display = "none";
|
||||
floatingPlayerBtn.classList.add("active-mode");
|
||||
}
|
||||
if (state === "playing") { fpPauseIcon.style.display = "block"; fpPlayIcon.style.display = "none"; }
|
||||
else { fpPauseIcon.style.display = "none"; fpPlayIcon.style.display = "block"; }
|
||||
}
|
||||
|
||||
function handleFloatingBtnClick() {
|
||||
if (!hasStarted) {
|
||||
hasStarted = true;
|
||||
if (storyInstances.length > 0) {
|
||||
currentlyPlayingInstance = storyInstances[0];
|
||||
currentlyPlayingIndex = 0;
|
||||
currentlyPlayingInstance.playAt(0);
|
||||
updateFloatingButton("playing");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (currentlyPlayingInstance) {
|
||||
const audio = currentlyPlayingInstance.getAudioElement();
|
||||
if (audio.paused) {
|
||||
currentlyPlayingInstance.play();
|
||||
updateFloatingButton("playing");
|
||||
} else {
|
||||
currentlyPlayingInstance.pause();
|
||||
updateFloatingButton("paused");
|
||||
}
|
||||
} else {
|
||||
if (storyInstances.length > 0) {
|
||||
currentlyPlayingInstance = storyInstances[0];
|
||||
currentlyPlayingIndex = 0;
|
||||
currentlyPlayingInstance.playAt(0);
|
||||
updateFloatingButton("playing");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function playNextInstance() {
|
||||
const next = currentlyPlayingIndex + 1;
|
||||
if (next < storyInstances.length) {
|
||||
if (currentlyPlayingInstance) currentlyPlayingInstance.stopAndReset();
|
||||
currentlyPlayingInstance = storyInstances[next];
|
||||
currentlyPlayingIndex = next;
|
||||
currentlyPlayingInstance.playAt(0);
|
||||
} else {
|
||||
updateFloatingButton("paused");
|
||||
currentlyPlayingInstance = null;
|
||||
currentlyPlayingIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTextClick(event) {
|
||||
const wordSpan = event.target.closest(".word");
|
||||
if (!wordSpan) return;
|
||||
const storyBlock = event.target.closest(".story-block");
|
||||
if (!storyBlock) return;
|
||||
const idx = parseInt(storyBlock.dataset.instanceIndex, 10);
|
||||
const target = storyInstances[idx];
|
||||
if (!target) return;
|
||||
const timestamp = target.getTimeForSpan(wordSpan);
|
||||
if (timestamp !== null) {
|
||||
hasStarted = true;
|
||||
if (currentlyPlayingInstance && currentlyPlayingInstance !== target) {
|
||||
currentlyPlayingInstance.stopAndReset();
|
||||
}
|
||||
currentlyPlayingInstance = target;
|
||||
currentlyPlayingIndex = idx;
|
||||
currentlyPlayingInstance.playAt(timestamp);
|
||||
updateFloatingButton("playing");
|
||||
}
|
||||
}
|
||||
|
||||
function handleFolderSelection(event) {
|
||||
const files = event.target.files;
|
||||
if (files.length === 0) return;
|
||||
|
||||
// Categorize all files by their numeric prefix (e.g., "1.1_")
|
||||
const allItemsMap = new Map(); // prefix -> { audioFile, textFile, jsonFile, imageFiles: [] }
|
||||
|
||||
for (const file of files) {
|
||||
const name = file.name;
|
||||
const ext = name.split(".").pop().toLowerCase();
|
||||
|
||||
// Match files like "1.1_something.ext"
|
||||
const prefixMatch = name.match(/^([\d]+\.[\d]+)_/);
|
||||
if (!prefixMatch) continue;
|
||||
|
||||
const sortKey = prefixMatch[1]; // e.g., "1.1"
|
||||
|
||||
if (!allItemsMap.has(sortKey)) {
|
||||
allItemsMap.set(sortKey, { audioFile: null, textFile: null, jsonFile: null, imageFiles: [] });
|
||||
}
|
||||
const entry = allItemsMap.get(sortKey);
|
||||
|
||||
if (["wav", "mp3"].includes(ext)) entry.audioFile = file;
|
||||
else if (ext === "txt") entry.textFile = file;
|
||||
else if (ext === "json") entry.jsonFile = file;
|
||||
else if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) entry.imageFiles.push(file);
|
||||
}
|
||||
|
||||
// Sort all entries by their numeric prefix
|
||||
const sortedEntries = Array.from(allItemsMap.entries())
|
||||
.sort(([a], [b]) => {
|
||||
const [aMaj, aMin] = a.split(".").map(Number);
|
||||
const [bMaj, bMin] = b.split(".").map(Number);
|
||||
return aMaj !== bMaj ? aMaj - bMaj : aMin - bMin;
|
||||
});
|
||||
|
||||
// Build ordered list of items: each is either { type: 'audio', ... } or { type: 'image', ... }
|
||||
const orderedItems = [];
|
||||
for (const [sortKey, entry] of sortedEntries) {
|
||||
if (entry.audioFile && entry.textFile && entry.jsonFile) {
|
||||
// Audio block — may also have associated images
|
||||
orderedItems.push({
|
||||
type: "audio",
|
||||
sortKey,
|
||||
audioFile: entry.audioFile,
|
||||
textFile: entry.textFile,
|
||||
jsonFile: entry.jsonFile,
|
||||
imageFiles: entry.imageFiles
|
||||
});
|
||||
} else if (entry.imageFiles.length > 0) {
|
||||
// Image-only block
|
||||
orderedItems.push({
|
||||
type: "image",
|
||||
sortKey,
|
||||
imageFiles: entry.imageFiles
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const audioItems = orderedItems.filter(i => i.type === "audio");
|
||||
if (audioItems.length === 0) {
|
||||
document.getElementById("info-alert").textContent = "No valid story parts found. Ensure files have matching .txt, .json, and audio files.";
|
||||
document.getElementById("info-alert").style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
currentBookId = audioItems.map(i => i.sortKey).join("|");
|
||||
document.getElementById("loader-section").style.display = "none";
|
||||
document.getElementById("main-header").querySelector(".story-subtitle").textContent = "An interactive reading experience.";
|
||||
mainContainer.classList.remove("d-none");
|
||||
mainContainer.innerHTML = "";
|
||||
storyNavList.innerHTML = "";
|
||||
resumeAlert.style.display = "none";
|
||||
|
||||
// Build DOM blocks — audio blocks get story-block class, image blocks get image-only rendering
|
||||
let audioInstanceIndex = 0;
|
||||
let lastChapter = null;
|
||||
|
||||
orderedItems.forEach((item, globalIdx) => {
|
||||
const chapter = item.sortKey.split(".")[0];
|
||||
|
||||
if (item.type === "audio") {
|
||||
// Add chapter nav entry if new chapter
|
||||
if (chapter !== lastChapter) {
|
||||
const li = document.createElement("li");
|
||||
const a = document.createElement("a");
|
||||
a.href = `#story-block-${audioInstanceIndex}`;
|
||||
a.textContent = chapter;
|
||||
a.dataset.chapter = chapter;
|
||||
li.appendChild(a);
|
||||
storyNavList.appendChild(li);
|
||||
lastChapter = chapter;
|
||||
}
|
||||
|
||||
const html = `<div id="story-block-${audioInstanceIndex}" class="story-block mt-4" data-instance-index="${audioInstanceIndex}" data-sort-key="${item.sortKey}">
|
||||
<div class="image-container text-center mb-4"></div>
|
||||
<div class="loading-indicator text-center p-5"><div class="spinner-border"></div></div>
|
||||
<audio class="audio-player" style="display:none;"></audio>
|
||||
<article class="story-text-container" style="display:none;"></article>
|
||||
</div>`;
|
||||
mainContainer.insertAdjacentHTML("beforeend", html);
|
||||
audioInstanceIndex++;
|
||||
|
||||
} else if (item.type === "image") {
|
||||
// Render image block directly
|
||||
const imgDiv = document.createElement("div");
|
||||
imgDiv.className = "story-image-block mt-4";
|
||||
imgDiv.dataset.sortKey = item.sortKey;
|
||||
for (const imgFile of item.imageFiles) {
|
||||
const img = document.createElement("img");
|
||||
img.src = URL.createObjectURL(imgFile);
|
||||
img.className = "img-fluid rounded shadow-sm";
|
||||
img.style.maxHeight = "70vh";
|
||||
imgDiv.appendChild(img);
|
||||
}
|
||||
mainContainer.appendChild(imgDiv);
|
||||
|
||||
// Chapter nav for image-only items too
|
||||
if (chapter !== lastChapter) {
|
||||
const prevAudioBlock = mainContainer.querySelector(".story-block:last-of-type");
|
||||
if (!prevAudioBlock) {
|
||||
// No audio block yet for this chapter, still add nav
|
||||
const li = document.createElement("li");
|
||||
const a = document.createElement("a");
|
||||
a.href = `#story-block-0`;
|
||||
a.textContent = chapter;
|
||||
a.dataset.chapter = chapter;
|
||||
li.appendChild(a);
|
||||
storyNavList.appendChild(li);
|
||||
}
|
||||
lastChapter = chapter;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
storyNav.classList.add("visible");
|
||||
floatingPlayerBtn.classList.add("visible");
|
||||
|
||||
// Create story player instances only for audio blocks
|
||||
storyInstances = [];
|
||||
let instIdx = 0;
|
||||
for (const item of orderedItems) {
|
||||
if (item.type !== "audio") continue;
|
||||
const block = document.getElementById(`story-block-${instIdx}`);
|
||||
|
||||
// Show associated images inside the audio block's image container
|
||||
if (item.imageFiles && item.imageFiles.length > 0) {
|
||||
const imgContainer = block.querySelector(".image-container");
|
||||
for (const imgFile of item.imageFiles) {
|
||||
const img = document.createElement("img");
|
||||
img.src = URL.createObjectURL(imgFile);
|
||||
img.className = "img-fluid rounded shadow-sm";
|
||||
img.style.maxHeight = "60vh";
|
||||
imgContainer.appendChild(img);
|
||||
}
|
||||
}
|
||||
|
||||
storyInstances.push(createStoryPlayer(block, item, instIdx));
|
||||
instIdx++;
|
||||
}
|
||||
|
||||
Promise.all(storyInstances.map(i => i.isReady())).then(() => {
|
||||
loadProgress(currentBookId);
|
||||
positionUI();
|
||||
});
|
||||
setupNavObserver();
|
||||
setTimeout(positionUI, 100);
|
||||
}
|
||||
|
||||
function setupNavObserver() {
|
||||
if (navObserver) navObserver.disconnect();
|
||||
navObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const sk = entry.target.dataset.sortKey;
|
||||
if (sk) {
|
||||
const ch = sk.split(".")[0];
|
||||
storyNavList.querySelectorAll("a").forEach(l => l.classList.remove("active"));
|
||||
const al = storyNavList.querySelector(`a[data-chapter='${ch}']`);
|
||||
if (al) al.classList.add("active");
|
||||
}
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.4 });
|
||||
document.querySelectorAll(".story-block").forEach(b => navObserver.observe(b));
|
||||
}
|
||||
|
||||
function createStoryPlayer(storyBlock, item, instanceIndex) {
|
||||
const audioPlayer = storyBlock.querySelector(".audio-player");
|
||||
const storyContainer = storyBlock.querySelector(".story-text-container");
|
||||
let wordTimestamps = [];
|
||||
let sentenceData = [];
|
||||
let allWordSpans = [];
|
||||
let wordMap = [];
|
||||
let animationFrameId = null;
|
||||
let lastHighlightedWordSpan = null;
|
||||
let lastHighlightedSentenceSpans = [];
|
||||
|
||||
const readyPromise = new Promise(async (resolve) => {
|
||||
const readFile = (f) => new Promise((res, rej) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => res(r.result);
|
||||
r.onerror = rej;
|
||||
r.readAsText(f);
|
||||
});
|
||||
|
||||
const [text, json] = await Promise.all([readFile(item.textFile), readFile(item.jsonFile)]);
|
||||
wordTimestamps = JSON.parse(json);
|
||||
audioPlayer.src = URL.createObjectURL(item.audioFile);
|
||||
|
||||
renderMarkdown(text);
|
||||
smartSync();
|
||||
|
||||
storyBlock.querySelector(".loading-indicator").style.display = "none";
|
||||
storyContainer.style.display = "block";
|
||||
|
||||
audioPlayer.addEventListener("play", () => { startLoop(); updateFloatingButton("playing"); });
|
||||
audioPlayer.addEventListener("pause", () => { stopLoop(); updateFloatingButton("paused"); saveCurrentProgress(); });
|
||||
audioPlayer.addEventListener("ended", () => { stopLoop(); clearAllHighlights(); playNextInstance(); });
|
||||
|
||||
resolve();
|
||||
});
|
||||
|
||||
function renderMarkdown(text) {
|
||||
storyContainer.innerHTML = "";
|
||||
allWordSpans = [];
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = marked.parse(text, { breaks: true, gfm: true });
|
||||
|
||||
function processNode(node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const words = node.textContent.split(/(\s+)/);
|
||||
const fragment = document.createDocumentFragment();
|
||||
words.forEach(part => {
|
||||
if (part.trim().length > 0) {
|
||||
const span = document.createElement("span");
|
||||
span.className = "word";
|
||||
span.textContent = part;
|
||||
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);
|
||||
}
|
||||
}
|
||||
processNode(div);
|
||||
while (div.firstChild) storyContainer.appendChild(div.firstChild);
|
||||
}
|
||||
|
||||
function smartSync() {
|
||||
wordMap = new Array(allWordSpans.length).fill(undefined);
|
||||
let aiIdx = 0;
|
||||
allWordSpans.forEach((span, i) => {
|
||||
const textWord = span.textContent.toLowerCase().replace(/[^\w]/g, "");
|
||||
for (let off = 0; off < 5; off++) {
|
||||
if (aiIdx + off >= wordTimestamps.length) break;
|
||||
const aiWord = wordTimestamps[aiIdx + off].word.toLowerCase().replace(/[^\w]/g, "");
|
||||
if (textWord === aiWord) { wordMap[i] = aiIdx + off; aiIdx += off + 1; return; }
|
||||
}
|
||||
});
|
||||
|
||||
sentenceData = [];
|
||||
let buffer = [], startIdx = 0;
|
||||
allWordSpans.forEach((span, i) => {
|
||||
buffer.push(span);
|
||||
if (/[.!?]["'\u201D\u2019]?$/.test(span.textContent.trim())) {
|
||||
let startT = 0, endT = 0;
|
||||
for (let k = startIdx; k <= i; k++) if (wordMap[k] !== undefined) { startT = wordTimestamps[wordMap[k]].start; break; }
|
||||
for (let k = i; k >= startIdx; k--) if (wordMap[k] !== undefined) { endT = wordTimestamps[wordMap[k]].end; break; }
|
||||
if (endT > startT) sentenceData.push({ spans: [...buffer], startTime: startT, endTime: endT });
|
||||
buffer = []; startIdx = i + 1;
|
||||
}
|
||||
});
|
||||
if (buffer.length > 0) {
|
||||
let startT = 0, endT = 0;
|
||||
for (let k = startIdx; k < allWordSpans.length; k++) if (wordMap[k] !== undefined) { startT = wordTimestamps[wordMap[k]].start; break; }
|
||||
for (let k = allWordSpans.length - 1; k >= startIdx; k--) if (wordMap[k] !== undefined) { endT = wordTimestamps[wordMap[k]].end; break; }
|
||||
if (endT > startT) sentenceData.push({ spans: [...buffer], startTime: startT, endTime: endT });
|
||||
}
|
||||
}
|
||||
|
||||
function highlightLoop() {
|
||||
if (audioPlayer.paused) return;
|
||||
const t = audioPlayer.currentTime;
|
||||
const aiIdx = wordTimestamps.findIndex(w => t >= w.start && t < w.end);
|
||||
if (aiIdx !== -1) {
|
||||
const tIdx = wordMap.findIndex(i => i === aiIdx);
|
||||
if (tIdx !== -1) {
|
||||
const sp = allWordSpans[tIdx];
|
||||
if (sp !== lastHighlightedWordSpan) {
|
||||
if (lastHighlightedWordSpan) lastHighlightedWordSpan.classList.remove("current-word");
|
||||
sp.classList.add("current-word");
|
||||
const rect = sp.getBoundingClientRect();
|
||||
if (rect.top < window.innerHeight * 0.3 || rect.bottom > window.innerHeight * 0.7) sp.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
lastHighlightedWordSpan = sp;
|
||||
}
|
||||
}
|
||||
}
|
||||
const sent = sentenceData.find(s => t >= s.startTime && t <= s.endTime);
|
||||
if (sent && sent.spans !== lastHighlightedSentenceSpans) {
|
||||
lastHighlightedSentenceSpans.forEach(s => s.classList.remove("current-sentence-bg"));
|
||||
sent.spans.forEach(s => s.classList.add("current-sentence-bg"));
|
||||
lastHighlightedSentenceSpans = sent.spans;
|
||||
}
|
||||
animationFrameId = requestAnimationFrame(highlightLoop);
|
||||
}
|
||||
|
||||
function clearAllHighlights() {
|
||||
if (lastHighlightedWordSpan) lastHighlightedWordSpan.classList.remove("current-word");
|
||||
lastHighlightedSentenceSpans.forEach(s => s.classList.remove("current-sentence-bg"));
|
||||
lastHighlightedWordSpan = null; lastHighlightedSentenceSpans = [];
|
||||
}
|
||||
function startLoop() { cancelAnimationFrame(animationFrameId); animationFrameId = requestAnimationFrame(highlightLoop); }
|
||||
function stopLoop() { cancelAnimationFrame(animationFrameId); }
|
||||
|
||||
return {
|
||||
play: () => audioPlayer.play(),
|
||||
pause: () => audioPlayer.pause(),
|
||||
playAt: (time) => { audioPlayer.currentTime = time; audioPlayer.play(); },
|
||||
stopAndReset: () => { audioPlayer.pause(); audioPlayer.currentTime = 0; clearAllHighlights(); },
|
||||
getAudioElement: () => audioPlayer,
|
||||
getTimeForSpan: (span) => {
|
||||
const idx = allWordSpans.indexOf(span);
|
||||
const aiIdx = wordMap[idx];
|
||||
return aiIdx !== undefined ? wordTimestamps[aiIdx].start : null;
|
||||
},
|
||||
isReady: () => readyPromise
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
496
reader_templates/index.html
Executable file
496
reader_templates/index.html
Executable file
@@ -0,0 +1,496 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Interactive Audiobook Reader</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400..700;1,400..700&family=Poppins:wght@500;700&display=swap" rel="stylesheet" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<style>
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
background-image: linear-gradient(to top, #f3e7e9 0%, #e3eeff 99%, #e3eeff 100%);
|
||||
color: #1f2937; font-family: "Lora", serif;
|
||||
}
|
||||
.story-title { font-family: "Poppins", sans-serif; font-weight: 700; font-size: 2.5rem; color: #111827; }
|
||||
.story-subtitle { font-family: "Poppins", sans-serif; color: #4b5563; font-weight: 500; font-size: 1.1rem; }
|
||||
.main-content-card {
|
||||
background-color: rgba(255,255,255,0.9); 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.2); max-width: 1400px; margin: 0 auto;
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.story-text-container { font-size: 36px; line-height: 2.1; color: #1f2937; cursor: pointer; }
|
||||
.story-text-container h1,.story-text-container h2,.story-text-container h3 {
|
||||
font-family: "Poppins", sans-serif; color: #111827; line-height: 1.8; margin-top: 1.5em; margin-bottom: 0.8em;
|
||||
}
|
||||
.story-text-container h1 { font-size: 2.2em; }
|
||||
.story-text-container h2 { font-size: 1.8em; }
|
||||
.story-text-container h3 { font-size: 1.5em; }
|
||||
.story-text-container p { margin-bottom: 1.2em; }
|
||||
.story-text-container img { max-width: 100%; height: auto; border-radius: 8px; margin: 16px auto; display: block; }
|
||||
|
||||
.word { transition: all 0.15s ease; border-radius: 3px; }
|
||||
.word:hover { background-color: #f1f5f9; }
|
||||
.current-sentence-bg {
|
||||
-webkit-box-decoration-break: clone; box-decoration-break: clone;
|
||||
background-color: #e0e7ff; padding: 0.1em 0.25em; margin: 0 -0.2em; border-radius: 8px;
|
||||
}
|
||||
.current-word { color: #3d4e81; text-decoration: underline; text-decoration-thickness: 3px; text-underline-offset: 3px; font-weight: 700; }
|
||||
|
||||
.story-image-block { text-align: center; margin: 24px 0; }
|
||||
.story-image-block img { max-width: 100%; height: auto; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
||||
|
||||
#floating-player-btn {
|
||||
position: fixed; top: 2rem; height: 60px; min-width: 60px; padding: 0 24px;
|
||||
border-radius: 30px; background-image: linear-gradient(45deg, #3d4e81 0%, #5753c9 50%, #6e78da 100%);
|
||||
background-size: 200% auto; border: none; color: white; box-shadow: 0 8px 25px rgba(0,0,0,0.3);
|
||||
display: none; align-items: center; justify-content: center; cursor: pointer; z-index: 1050;
|
||||
transition: transform 0.2s, opacity 0.3s, width 0.3s, padding 0.3s, border-radius 0.3s;
|
||||
opacity: 0; transform: scale(0.8);
|
||||
}
|
||||
#floating-player-btn.visible { display: flex; opacity: 1; transform: scale(1); }
|
||||
#floating-player-btn:hover { transform: scale(1.05); }
|
||||
#floating-player-btn:active { transform: scale(0.95); }
|
||||
#floating-player-btn svg { width: 28px; height: 28px; }
|
||||
#fp-start-text { font-weight: 600; margin-right: 10px; font-family: "Poppins", sans-serif; font-size: 1.1rem; }
|
||||
#floating-player-btn.active-mode { width: 60px; padding: 0; border-radius: 50%; }
|
||||
|
||||
#story-nav {
|
||||
position: fixed; top: 50%; background-color: rgba(255,255,255,0.85);
|
||||
backdrop-filter: blur(10px); border-radius: 25px; padding: 0.5rem;
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.1); z-index: 1000; max-height: 80vh; overflow-y: auto;
|
||||
opacity: 0; transform: translateY(-50%) translateX(-20px);
|
||||
transition: opacity 0.4s ease-out, transform 0.4s ease-out;
|
||||
}
|
||||
#story-nav.visible { opacity: 1; transform: translateY(-50%) translateX(0); }
|
||||
#story-nav ul { padding-left: 0; margin-bottom: 0; }
|
||||
#story-nav a {
|
||||
display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; margin: 0.5rem 0;
|
||||
border-radius: 50%; text-decoration: none; color: #4b5563; font-family: "Poppins", sans-serif;
|
||||
font-weight: 500; transition: all 0.3s ease;
|
||||
}
|
||||
#story-nav a:hover { background-color: #e0e7ff; color: #3d4e81; }
|
||||
#story-nav a.active { background-image: linear-gradient(45deg, #3d4e81 0%, #5753c9 50%, #6e78da 100%); color: white; transform: scale(1.1); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content-card { padding: 1.5rem; }
|
||||
.story-text-container { font-size: 24px; line-height: 1.9; }
|
||||
#floating-player-btn { top: 1rem; right: 1rem !important; height: 50px; min-width: 50px; }
|
||||
#floating-player-btn.active-mode { width: 50px; }
|
||||
#story-nav { display: none !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav id="story-nav"><ul id="story-nav-list" class="list-unstyled"></ul></nav>
|
||||
|
||||
<button id="floating-player-btn">
|
||||
<span id="fp-start-text">Start</span>
|
||||
<svg id="fp-pause-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="display:none;"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
||||
<svg id="fp-play-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="display:none;"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="container-fluid my-5 px-md-5">
|
||||
<main id="main-content" class="main-content-card">
|
||||
<header class="text-center mb-5" id="main-header">
|
||||
<h1 class="story-title">Interactive Reader</h1>
|
||||
<p class="story-subtitle">Loading book...</p>
|
||||
</header>
|
||||
<div id="resume-alert" class="alert alert-info d-flex justify-content-between align-items-center" style="display:none;">
|
||||
<span>Welcome back! Resume from where you left off?</span>
|
||||
<button id="resume-btn" class="btn btn-primary btn-sm">Resume Playback</button>
|
||||
</div>
|
||||
<div id="stories-main-container"></div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
const mainContainer = document.getElementById("stories-main-container");
|
||||
const mainContentCard = document.getElementById("main-content");
|
||||
const floatingPlayerBtn = document.getElementById("floating-player-btn");
|
||||
const fpStartText = document.getElementById("fp-start-text");
|
||||
const fpPauseIcon = document.getElementById("fp-pause-icon");
|
||||
const fpPlayIcon = document.getElementById("fp-play-icon");
|
||||
const storyNav = document.getElementById("story-nav");
|
||||
const storyNavList = document.getElementById("story-nav-list");
|
||||
const resumeAlert = document.getElementById("resume-alert");
|
||||
const resumeBtn = document.getElementById("resume-btn");
|
||||
|
||||
let storyInstances = [];
|
||||
let currentlyPlayingInstance = null;
|
||||
let currentlyPlayingIndex = -1;
|
||||
let hasStarted = false;
|
||||
let navObserver;
|
||||
let currentBookId = null;
|
||||
let audioAssets = [];
|
||||
const PROGRESS_KEY = "interactiveReaderProgress";
|
||||
|
||||
floatingPlayerBtn.addEventListener("click", handleFloatingBtnClick);
|
||||
mainContainer.addEventListener("click", handleTextClick);
|
||||
window.addEventListener("beforeunload", saveCurrentProgress);
|
||||
window.addEventListener("resize", positionUI);
|
||||
window.addEventListener("scroll", positionUI);
|
||||
|
||||
function positionUI() {
|
||||
const rect = mainContentCard.getBoundingClientRect();
|
||||
const btnRight = window.innerWidth - rect.right - 8;
|
||||
floatingPlayerBtn.style.right = Math.max(btnRight, 8) + "px";
|
||||
floatingPlayerBtn.style.left = "auto";
|
||||
const navWidth = storyNav.offsetWidth || 52;
|
||||
storyNav.style.left = Math.max(rect.left - navWidth - 8, 8) + "px";
|
||||
}
|
||||
|
||||
function saveCurrentProgress() {
|
||||
if (!currentlyPlayingInstance || !currentBookId) return;
|
||||
const idx = storyInstances.indexOf(currentlyPlayingInstance);
|
||||
saveProgress(currentBookId, idx, currentlyPlayingInstance.getAudioElement().currentTime);
|
||||
}
|
||||
function saveProgress(bookId, idx, timestamp) {
|
||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ bookId, instanceIndex: idx, timestamp, lastUpdate: Date.now() }));
|
||||
}
|
||||
function loadProgress(bookId) {
|
||||
const saved = localStorage.getItem(PROGRESS_KEY);
|
||||
if (!saved) return;
|
||||
const p = JSON.parse(saved);
|
||||
if (p.bookId !== bookId) return;
|
||||
const target = storyInstances[p.instanceIndex];
|
||||
if (!target) return;
|
||||
resumeAlert.style.display = "flex";
|
||||
resumeBtn.onclick = () => {
|
||||
resumeAlert.style.display = "none";
|
||||
hasStarted = true;
|
||||
currentlyPlayingInstance = target;
|
||||
currentlyPlayingIndex = p.instanceIndex;
|
||||
target.playAt(p.timestamp);
|
||||
updateFloatingButton("playing");
|
||||
};
|
||||
}
|
||||
|
||||
function updateFloatingButton(state) {
|
||||
if (hasStarted) { fpStartText.style.display = "none"; floatingPlayerBtn.classList.add("active-mode"); }
|
||||
if (state === "playing") { fpPauseIcon.style.display = "block"; fpPlayIcon.style.display = "none"; }
|
||||
else { fpPauseIcon.style.display = "none"; fpPlayIcon.style.display = "block"; }
|
||||
}
|
||||
|
||||
function handleFloatingBtnClick() {
|
||||
if (!hasStarted) {
|
||||
hasStarted = true;
|
||||
if (storyInstances.length > 0) { currentlyPlayingInstance = storyInstances[0]; currentlyPlayingIndex = 0; currentlyPlayingInstance.playAt(0); updateFloatingButton("playing"); }
|
||||
return;
|
||||
}
|
||||
if (currentlyPlayingInstance) {
|
||||
const audio = currentlyPlayingInstance.getAudioElement();
|
||||
if (audio.paused) { currentlyPlayingInstance.play(); updateFloatingButton("playing"); }
|
||||
else { currentlyPlayingInstance.pause(); updateFloatingButton("paused"); }
|
||||
} else {
|
||||
if (storyInstances.length > 0) { currentlyPlayingInstance = storyInstances[0]; currentlyPlayingIndex = 0; currentlyPlayingInstance.playAt(0); updateFloatingButton("playing"); }
|
||||
}
|
||||
}
|
||||
|
||||
function playNextInstance() {
|
||||
const next = currentlyPlayingIndex + 1;
|
||||
if (next < storyInstances.length) {
|
||||
if (currentlyPlayingInstance) currentlyPlayingInstance.stopAndReset();
|
||||
currentlyPlayingInstance = storyInstances[next]; currentlyPlayingIndex = next; currentlyPlayingInstance.playAt(0);
|
||||
} else { updateFloatingButton("paused"); currentlyPlayingInstance = null; currentlyPlayingIndex = -1; }
|
||||
}
|
||||
|
||||
function handleTextClick(event) {
|
||||
const wordSpan = event.target.closest(".word");
|
||||
if (!wordSpan) return;
|
||||
const storyBlock = event.target.closest(".story-block");
|
||||
if (!storyBlock) return;
|
||||
const idx = parseInt(storyBlock.dataset.instanceIndex, 10);
|
||||
const target = storyInstances[idx];
|
||||
if (!target) return;
|
||||
const timestamp = target.getTimeForSpan(wordSpan);
|
||||
if (timestamp !== null) {
|
||||
hasStarted = true;
|
||||
if (currentlyPlayingInstance && currentlyPlayingInstance !== target) currentlyPlayingInstance.stopAndReset();
|
||||
currentlyPlayingInstance = target; currentlyPlayingIndex = idx;
|
||||
currentlyPlayingInstance.playAt(timestamp); updateFloatingButton("playing");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Load manifest and scan for images ──
|
||||
try {
|
||||
const resp = await fetch("manifest.json");
|
||||
const manifest = await resp.json();
|
||||
|
||||
document.getElementById("main-header").querySelector(".story-title").textContent = manifest.title || "Interactive Reader";
|
||||
document.getElementById("main-header").querySelector(".story-subtitle").textContent = "An interactive reading experience.";
|
||||
|
||||
audioAssets = (manifest.assets || []).filter(a => a.textFile && a.audioFile && a.jsonFile);
|
||||
|
||||
if (audioAssets.length === 0) {
|
||||
mainContainer.innerHTML = '<p class="text-center text-muted">No playable content found.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Scan the book/ directory for image files by trying common patterns
|
||||
// We'll extract all known prefixes from audio assets and also look for image-only prefixes
|
||||
const allPrefixes = new Set();
|
||||
const audioPrefixSet = new Set();
|
||||
|
||||
for (const asset of audioAssets) {
|
||||
// prefix is like "1.1_"
|
||||
const match = asset.prefix.match(/^([\d]+\.[\d]+)_/);
|
||||
if (match) {
|
||||
allPrefixes.add(match[1]);
|
||||
audioPrefixSet.add(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Build ordered items: audio assets + try to discover image files
|
||||
// Since we can't list directories via fetch, we'll look for images referenced
|
||||
// in the manifest or try common image patterns
|
||||
|
||||
// For now, build from audio assets and try to fetch potential image files
|
||||
const orderedItems = [];
|
||||
|
||||
// Collect all numeric prefixes we should check for images
|
||||
// Check for image files at each audio prefix position and also between them
|
||||
const sortedAudioPrefixes = Array.from(audioPrefixSet).sort((a, b) => {
|
||||
const [aMaj, aMin] = a.split(".").map(Number);
|
||||
const [bMaj, bMin] = b.split(".").map(Number);
|
||||
return aMaj !== bMaj ? aMaj - bMaj : aMin - bMin;
|
||||
});
|
||||
|
||||
// Determine all possible prefixes (including gaps for images)
|
||||
const allSortKeys = new Set();
|
||||
for (const p of sortedAudioPrefixes) allSortKeys.add(p);
|
||||
|
||||
// Check for images at each prefix — try _img0.jpg, _img0.png
|
||||
const imageMap = new Map();
|
||||
const imgExts = ["jpg", "jpeg", "png", "gif", "webp"];
|
||||
|
||||
// Also check prefixes between/around audio prefixes for image-only blocks
|
||||
if (sortedAudioPrefixes.length > 0) {
|
||||
const chapters = new Set(sortedAudioPrefixes.map(p => parseInt(p.split(".")[0])));
|
||||
for (const ch of chapters) {
|
||||
// Check sub-indices 1 through 50 for this chapter
|
||||
for (let sub = 1; sub <= 50; sub++) {
|
||||
allSortKeys.add(`${ch}.${sub}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to fetch images for all potential sort keys
|
||||
const projectName = manifest.title ? manifest.title.replace(/[^a-zA-Z0-9_\- ]/g, "") : "book";
|
||||
|
||||
for (const sortKey of Array.from(allSortKeys).sort((a, b) => {
|
||||
const [aMaj, aMin] = a.split(".").map(Number);
|
||||
const [bMaj, bMin] = b.split(".").map(Number);
|
||||
return aMaj !== bMaj ? aMaj - bMaj : aMin - bMin;
|
||||
})) {
|
||||
// Try fetching image files: book/{sortKey}_img0.{ext}
|
||||
for (const ext of imgExts) {
|
||||
try {
|
||||
const imgUrl = `book/${sortKey}_img0.${ext}`;
|
||||
const imgResp = await fetch(imgUrl, { method: "HEAD" });
|
||||
if (imgResp.ok) {
|
||||
if (!imageMap.has(sortKey)) imageMap.set(sortKey, []);
|
||||
imageMap.get(sortKey).push(imgUrl);
|
||||
break; // Found one format, skip others
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Now build final ordered list
|
||||
const finalSortKeys = new Set([...audioPrefixSet, ...imageMap.keys()]);
|
||||
const sortedFinalKeys = Array.from(finalSortKeys).sort((a, b) => {
|
||||
const [aMaj, aMin] = a.split(".").map(Number);
|
||||
const [bMaj, bMin] = b.split(".").map(Number);
|
||||
return aMaj !== bMaj ? aMaj - bMaj : aMin - bMin;
|
||||
});
|
||||
|
||||
currentBookId = audioAssets.map(a => a.prefix).join("|");
|
||||
|
||||
let audioInstanceIndex = 0;
|
||||
let lastChapter = null;
|
||||
|
||||
for (const sortKey of sortedFinalKeys) {
|
||||
const chapter = sortKey.split(".")[0];
|
||||
const isAudio = audioPrefixSet.has(sortKey);
|
||||
const hasImages = imageMap.has(sortKey);
|
||||
|
||||
if (isAudio) {
|
||||
// Find matching audio asset
|
||||
const asset = audioAssets.find(a => a.prefix.startsWith(sortKey + "_"));
|
||||
if (!asset) continue;
|
||||
|
||||
if (chapter !== lastChapter) {
|
||||
const li = document.createElement("li");
|
||||
const a = document.createElement("a");
|
||||
a.href = `#story-block-${audioInstanceIndex}`;
|
||||
a.textContent = chapter;
|
||||
a.dataset.chapter = chapter;
|
||||
li.appendChild(a);
|
||||
storyNavList.appendChild(li);
|
||||
lastChapter = chapter;
|
||||
}
|
||||
|
||||
mainContainer.insertAdjacentHTML("beforeend",
|
||||
`<div id="story-block-${audioInstanceIndex}" class="story-block mt-4" data-instance-index="${audioInstanceIndex}" data-sort-key="${sortKey}">
|
||||
<div class="image-container text-center mb-4"></div>
|
||||
<div class="loading-indicator text-center p-5"><div class="spinner-border"></div></div>
|
||||
<audio class="audio-player" style="display:none;"></audio>
|
||||
<article class="story-text-container" style="display:none;"></article>
|
||||
</div>`
|
||||
);
|
||||
|
||||
// Show images for this audio block if any
|
||||
if (hasImages) {
|
||||
const block = document.getElementById(`story-block-${audioInstanceIndex}`);
|
||||
const imgContainer = block.querySelector(".image-container");
|
||||
for (const imgUrl of imageMap.get(sortKey)) {
|
||||
const img = document.createElement("img");
|
||||
img.src = imgUrl;
|
||||
img.className = "img-fluid rounded shadow-sm";
|
||||
img.style.maxHeight = "60vh";
|
||||
imgContainer.appendChild(img);
|
||||
}
|
||||
}
|
||||
|
||||
audioInstanceIndex++;
|
||||
|
||||
} else if (hasImages && !isAudio) {
|
||||
// Image-only block
|
||||
if (chapter !== lastChapter) {
|
||||
const nearestAudioIdx = Math.max(0, audioInstanceIndex - 1);
|
||||
const li = document.createElement("li");
|
||||
const a = document.createElement("a");
|
||||
a.href = `#story-block-${nearestAudioIdx}`;
|
||||
a.textContent = chapter;
|
||||
a.dataset.chapter = chapter;
|
||||
li.appendChild(a);
|
||||
storyNavList.appendChild(li);
|
||||
lastChapter = chapter;
|
||||
}
|
||||
|
||||
const imgDiv = document.createElement("div");
|
||||
imgDiv.className = "story-image-block mt-4";
|
||||
for (const imgUrl of imageMap.get(sortKey)) {
|
||||
const img = document.createElement("img");
|
||||
img.src = imgUrl;
|
||||
img.className = "img-fluid rounded shadow-sm";
|
||||
img.style.maxHeight = "70vh";
|
||||
imgDiv.appendChild(img);
|
||||
}
|
||||
mainContainer.appendChild(imgDiv);
|
||||
}
|
||||
}
|
||||
|
||||
storyNav.classList.add("visible");
|
||||
floatingPlayerBtn.classList.add("visible");
|
||||
|
||||
// Create player instances for audio blocks
|
||||
storyInstances = await Promise.all(
|
||||
audioAssets.map(async (asset, index) => {
|
||||
const block = document.getElementById(`story-block-${index}`);
|
||||
const [text, timestamps] = await Promise.all([
|
||||
fetch(asset.textFile).then(r => r.text()),
|
||||
fetch(asset.jsonFile).then(r => r.json())
|
||||
]);
|
||||
return createStoryPlayer(block, { text, timestamps, audioUrl: asset.audioFile }, index);
|
||||
})
|
||||
);
|
||||
|
||||
loadProgress(currentBookId);
|
||||
setupNavObserver();
|
||||
setTimeout(positionUI, 100);
|
||||
|
||||
} catch (e) {
|
||||
mainContainer.innerHTML = `<p class="text-center text-danger">Error loading: ${e.message}</p>`;
|
||||
}
|
||||
|
||||
function setupNavObserver() {
|
||||
if (navObserver) navObserver.disconnect();
|
||||
navObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const sk = entry.target.dataset.sortKey;
|
||||
if (sk) {
|
||||
const ch = sk.split(".")[0];
|
||||
storyNavList.querySelectorAll("a").forEach(l => l.classList.remove("active"));
|
||||
const al = storyNavList.querySelector(`a[data-chapter='${ch}']`);
|
||||
if (al) al.classList.add("active");
|
||||
}
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.4 });
|
||||
document.querySelectorAll(".story-block").forEach(b => navObserver.observe(b));
|
||||
}
|
||||
|
||||
function createStoryPlayer(storyBlock, data, instanceIndex) {
|
||||
const audioPlayer = storyBlock.querySelector(".audio-player");
|
||||
const storyContainer = storyBlock.querySelector(".story-text-container");
|
||||
let wordTimestamps = data.timestamps, sentenceData = [], allWordSpans = [], wordMap = [];
|
||||
let animationFrameId = null, lastW = null, lastSS = [];
|
||||
|
||||
audioPlayer.src = data.audioUrl;
|
||||
renderMd(data.text);
|
||||
smartSync();
|
||||
storyBlock.querySelector(".loading-indicator").style.display = "none";
|
||||
storyContainer.style.display = "block";
|
||||
|
||||
audioPlayer.addEventListener("play", () => { startLoop(); updateFloatingButton("playing"); });
|
||||
audioPlayer.addEventListener("pause", () => { stopLoop(); updateFloatingButton("paused"); saveCurrentProgress(); });
|
||||
audioPlayer.addEventListener("ended", () => { stopLoop(); clearHL(); playNextInstance(); });
|
||||
|
||||
function renderMd(text) {
|
||||
storyContainer.innerHTML = ""; allWordSpans = [];
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = marked.parse(text, { breaks: true, gfm: true });
|
||||
function proc(n) {
|
||||
if (n.nodeType === Node.TEXT_NODE) {
|
||||
const ws = n.textContent.split(/(\s+)/); const f = document.createDocumentFragment();
|
||||
ws.forEach(p => { if (p.trim().length > 0) { const s = document.createElement("span"); s.className = "word"; s.textContent = p; allWordSpans.push(s); f.appendChild(s); } else f.appendChild(document.createTextNode(p)); });
|
||||
n.parentNode.replaceChild(f, n);
|
||||
} else if (n.nodeType === Node.ELEMENT_NODE) Array.from(n.childNodes).forEach(proc);
|
||||
}
|
||||
proc(div); while (div.firstChild) storyContainer.appendChild(div.firstChild);
|
||||
}
|
||||
|
||||
function smartSync() {
|
||||
wordMap = new Array(allWordSpans.length).fill(undefined); let ai = 0;
|
||||
allWordSpans.forEach((s, i) => { const tw = s.textContent.toLowerCase().replace(/[^\w]/g, ""); for (let o = 0; o < 5; o++) { if (ai + o >= wordTimestamps.length) break; if (tw === wordTimestamps[ai + o].word.toLowerCase().replace(/[^\w]/g, "")) { wordMap[i] = ai + o; ai += o + 1; return; } } });
|
||||
sentenceData = []; let buf = [], si = 0;
|
||||
allWordSpans.forEach((s, i) => { buf.push(s); if (/[.!?]["'\u201D\u2019]?$/.test(s.textContent.trim())) { let sT = 0, eT = 0; for (let k = si; k <= i; k++) if (wordMap[k] !== undefined) { sT = wordTimestamps[wordMap[k]].start; break; } for (let k = i; k >= si; k--) if (wordMap[k] !== undefined) { eT = wordTimestamps[wordMap[k]].end; break; } if (eT > sT) sentenceData.push({ spans: [...buf], startTime: sT, endTime: eT }); buf = []; si = i + 1; } });
|
||||
if (buf.length > 0) { let sT = 0, eT = 0; for (let k = si; k < allWordSpans.length; k++) if (wordMap[k] !== undefined) { sT = wordTimestamps[wordMap[k]].start; break; } for (let k = allWordSpans.length - 1; k >= si; k--) if (wordMap[k] !== undefined) { eT = wordTimestamps[wordMap[k]].end; break; } if (eT > sT) sentenceData.push({ spans: [...buf], startTime: sT, endTime: eT }); }
|
||||
}
|
||||
|
||||
function hlLoop() {
|
||||
if (audioPlayer.paused) return; const t = audioPlayer.currentTime;
|
||||
const ai = wordTimestamps.findIndex(w => t >= w.start && t < w.end);
|
||||
if (ai !== -1) { const ti = wordMap.findIndex(i => i === ai); if (ti !== -1) { const sp = allWordSpans[ti]; if (sp !== lastW) { if (lastW) lastW.classList.remove("current-word"); sp.classList.add("current-word"); const r = sp.getBoundingClientRect(); if (r.top < window.innerHeight * 0.3 || r.bottom > window.innerHeight * 0.7) sp.scrollIntoView({ behavior: "smooth", block: "center" }); lastW = sp; } } }
|
||||
const sent = sentenceData.find(s => t >= s.startTime && t <= s.endTime);
|
||||
if (sent && sent.spans !== lastSS) { lastSS.forEach(s => s.classList.remove("current-sentence-bg")); sent.spans.forEach(s => s.classList.add("current-sentence-bg")); lastSS = sent.spans; }
|
||||
animationFrameId = requestAnimationFrame(hlLoop);
|
||||
}
|
||||
function clearHL() { if (lastW) lastW.classList.remove("current-word"); lastSS.forEach(s => s.classList.remove("current-sentence-bg")); lastW = null; lastSS = []; }
|
||||
function startLoop() { cancelAnimationFrame(animationFrameId); animationFrameId = requestAnimationFrame(hlLoop); }
|
||||
function stopLoop() { cancelAnimationFrame(animationFrameId); }
|
||||
|
||||
return {
|
||||
play: () => audioPlayer.play(), pause: () => audioPlayer.pause(),
|
||||
playAt: (t) => { audioPlayer.currentTime = t; audioPlayer.play(); },
|
||||
stopAndReset: () => { audioPlayer.pause(); audioPlayer.currentTime = 0; clearHL(); },
|
||||
getAudioElement: () => audioPlayer,
|
||||
getTimeForSpan: (span) => { const i = allWordSpans.indexOf(span); const ai = wordMap[i]; return ai !== undefined ? wordTimestamps[ai].start : null; },
|
||||
isReady: () => Promise.resolve()
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
9
requirements.txt
Normal file
9
requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
flask==3.1.2
|
||||
python-dotenv==1.0.0
|
||||
requests==2.31.0
|
||||
pydub==0.25.1
|
||||
python-docx==1.1.2
|
||||
olefile==0.47
|
||||
striprtf==0.0.29
|
||||
PyMuPDF==1.25.3
|
||||
gunicorn==23.0.0
|
||||
27
routes/__init__.py
Normal file
27
routes/__init__.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# routes/__init__.py - Blueprint Registration
|
||||
|
||||
from flask import Flask
|
||||
|
||||
|
||||
def register_blueprints(app: Flask):
|
||||
"""Register all blueprints with the Flask app."""
|
||||
|
||||
from routes.auth_routes import auth_bp
|
||||
from routes.admin_routes import admin_bp
|
||||
from routes.main_routes import main_bp
|
||||
from routes.pdf_routes import pdf_bp
|
||||
from routes.docx_routes import docx_bp
|
||||
from routes.project_routes import project_bp
|
||||
from routes.generation_routes import generation_bp
|
||||
from routes.export_routes import export_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(pdf_bp)
|
||||
app.register_blueprint(docx_bp)
|
||||
app.register_blueprint(project_bp)
|
||||
app.register_blueprint(generation_bp)
|
||||
app.register_blueprint(export_bp)
|
||||
|
||||
print("✅ All blueprints registered")
|
||||
175
routes/admin_routes.py
Normal file
175
routes/admin_routes.py
Normal file
@@ -0,0 +1,175 @@
|
||||
# routes/admin_routes.py - Admin Dashboard Routes
|
||||
|
||||
from flask import Blueprint, request, jsonify, session, send_from_directory
|
||||
from db import get_db
|
||||
from auth import admin_required
|
||||
|
||||
admin_bp = Blueprint('admin', __name__)
|
||||
|
||||
|
||||
@admin_bp.route('/admin')
|
||||
@admin_required
|
||||
def admin_page():
|
||||
"""Serve admin dashboard page."""
|
||||
return send_from_directory('templates', 'admin.html')
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/users', methods=['GET'])
|
||||
@admin_required
|
||||
def list_users():
|
||||
"""List all users."""
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT id, username, role, is_active, created_at, last_login
|
||||
FROM users ORDER BY created_at DESC
|
||||
''')
|
||||
|
||||
users = []
|
||||
for row in cursor.fetchall():
|
||||
users.append({
|
||||
'id': row['id'],
|
||||
'username': row['username'],
|
||||
'role': row['role'],
|
||||
'is_active': bool(row['is_active']),
|
||||
'created_at': row['created_at'],
|
||||
'last_login': row['last_login']
|
||||
})
|
||||
|
||||
return jsonify({'users': users})
|
||||
|
||||
|
||||
@admin_bp.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', '')
|
||||
role = data.get('role', 'user')
|
||||
|
||||
if not username or not password:
|
||||
return jsonify({'error': 'Username and password are 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
|
||||
|
||||
if role not in ('user', 'admin'):
|
||||
return jsonify({'error': 'Role must be "user" or "admin"'}), 400
|
||||
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute('''
|
||||
INSERT INTO users (username, password, role, is_active)
|
||||
VALUES (?, ?, ?, 1)
|
||||
''', (username, password, role))
|
||||
db.commit()
|
||||
|
||||
print(f"✅ New user created: {username} (role: {role})")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'user_id': cursor.lastrowid,
|
||||
'message': f'User "{username}" created successfully'
|
||||
})
|
||||
except Exception as e:
|
||||
if 'UNIQUE constraint' in str(e):
|
||||
return jsonify({'error': f'Username "{username}" already exists'}), 400
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/users/<int:user_id>', methods=['PUT'])
|
||||
@admin_required
|
||||
def update_user(user_id):
|
||||
"""Update a user."""
|
||||
data = request.json
|
||||
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
cursor.execute('SELECT id, username FROM users WHERE id = ?', (user_id,))
|
||||
user = cursor.fetchone()
|
||||
|
||||
if not user:
|
||||
return jsonify({'error': 'User not found'}), 404
|
||||
|
||||
# Build update query dynamically
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if 'username' in data:
|
||||
username = data['username'].strip()
|
||||
if len(username) < 3:
|
||||
return jsonify({'error': 'Username must be at least 3 characters'}), 400
|
||||
updates.append('username = ?')
|
||||
params.append(username)
|
||||
|
||||
if 'password' in data and data['password']:
|
||||
password = data['password']
|
||||
if len(password) < 4:
|
||||
return jsonify({'error': 'Password must be at least 4 characters'}), 400
|
||||
updates.append('password = ?')
|
||||
params.append(password)
|
||||
|
||||
if 'role' in data:
|
||||
role = data['role']
|
||||
if role not in ('user', 'admin'):
|
||||
return jsonify({'error': 'Role must be "user" or "admin"'}), 400
|
||||
# Prevent demoting self
|
||||
if user_id == session.get('user_id') and role != 'admin':
|
||||
return jsonify({'error': 'Cannot change your own role'}), 400
|
||||
updates.append('role = ?')
|
||||
params.append(role)
|
||||
|
||||
if 'is_active' in data:
|
||||
# Prevent deactivating self
|
||||
if user_id == session.get('user_id') and not data['is_active']:
|
||||
return jsonify({'error': 'Cannot deactivate your own account'}), 400
|
||||
updates.append('is_active = ?')
|
||||
params.append(1 if data['is_active'] else 0)
|
||||
|
||||
if not updates:
|
||||
return jsonify({'error': 'No fields to update'}), 400
|
||||
|
||||
params.append(user_id)
|
||||
|
||||
try:
|
||||
cursor.execute(f"UPDATE users SET {', '.join(updates)} WHERE id = ?", params)
|
||||
db.commit()
|
||||
|
||||
return jsonify({'success': True, 'message': 'User updated successfully'})
|
||||
except Exception as e:
|
||||
if 'UNIQUE constraint' in str(e):
|
||||
return jsonify({'error': 'Username already exists'}), 400
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/users/<int:user_id>', methods=['DELETE'])
|
||||
@admin_required
|
||||
def delete_user(user_id):
|
||||
"""Delete a user."""
|
||||
# Prevent deleting self
|
||||
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, username FROM users WHERE id = ?', (user_id,))
|
||||
user = cursor.fetchone()
|
||||
|
||||
if not user:
|
||||
return jsonify({'error': 'User not found'}), 404
|
||||
|
||||
cursor.execute('DELETE FROM users WHERE id = ?', (user_id,))
|
||||
db.commit()
|
||||
|
||||
print(f"🗑️ User deleted: {user['username']}")
|
||||
|
||||
return jsonify({'success': True, 'message': f'User "{user["username"]}" deleted'})
|
||||
113
routes/auth_routes.py
Normal file
113
routes/auth_routes.py
Normal file
@@ -0,0 +1,113 @@
|
||||
# routes/auth_routes.py - Authentication Routes
|
||||
|
||||
from flask import Blueprint, request, jsonify, session, redirect, url_for, send_from_directory
|
||||
from db import get_db
|
||||
from auth import login_required, admin_required, get_current_user
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@auth_bp.route('/login')
|
||||
def login_page():
|
||||
"""Serve login page."""
|
||||
if 'user_id' in session:
|
||||
return redirect(url_for('main.index'))
|
||||
return send_from_directory('templates', 'login.html')
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/login', methods=['POST'])
|
||||
def login():
|
||||
"""Handle user login."""
|
||||
data = request.json
|
||||
username = data.get('username', '').strip()
|
||||
password = data.get('password', '')
|
||||
|
||||
if not username or not password:
|
||||
return jsonify({'error': 'Username and password are required'}), 400
|
||||
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT id, username, password, role, is_active
|
||||
FROM users WHERE username = ?
|
||||
''', (username,))
|
||||
|
||||
user = cursor.fetchone()
|
||||
|
||||
if not user:
|
||||
return jsonify({'error': 'Invalid username or password'}), 401
|
||||
|
||||
if not user['is_active']:
|
||||
return jsonify({'error': 'Account is disabled. Contact your administrator.'}), 403
|
||||
|
||||
if user['password'] != password:
|
||||
return jsonify({'error': 'Invalid username or password'}), 401
|
||||
|
||||
# Set session
|
||||
session['user_id'] = user['id']
|
||||
session['username'] = user['username']
|
||||
session['user_role'] = user['role']
|
||||
|
||||
# Update last login
|
||||
cursor.execute('''
|
||||
UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?
|
||||
''', (user['id'],))
|
||||
db.commit()
|
||||
|
||||
print(f"✅ User logged in: {username} (role: {user['role']})")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'user': {
|
||||
'id': user['id'],
|
||||
'username': user['username'],
|
||||
'role': user['role']
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/logout', methods=['POST'])
|
||||
def logout():
|
||||
"""Handle user logout."""
|
||||
username = session.get('username', 'Unknown')
|
||||
session.clear()
|
||||
print(f"👋 User logged out: {username}")
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/me', methods=['GET'])
|
||||
@login_required
|
||||
def get_me():
|
||||
"""Get current user info."""
|
||||
user = get_current_user()
|
||||
return jsonify({'user': user})
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/change-password', methods=['POST'])
|
||||
@login_required
|
||||
def change_password():
|
||||
"""Change current user's password."""
|
||||
data = request.json
|
||||
current_password = data.get('current_password', '')
|
||||
new_password = data.get('new_password', '')
|
||||
|
||||
if not current_password or not new_password:
|
||||
return jsonify({'error': 'Current password and new password are required'}), 400
|
||||
|
||||
if len(new_password) < 4:
|
||||
return jsonify({'error': 'New password must be at least 4 characters'}), 400
|
||||
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
cursor.execute('SELECT password FROM users WHERE id = ?', (session['user_id'],))
|
||||
user = cursor.fetchone()
|
||||
|
||||
if not user or user['password'] != current_password:
|
||||
return jsonify({'error': 'Current password is incorrect'}), 401
|
||||
|
||||
cursor.execute('UPDATE users SET password = ? WHERE id = ?', (new_password, session['user_id']))
|
||||
db.commit()
|
||||
|
||||
return jsonify({'success': True, 'message': 'Password changed successfully'})
|
||||
60
routes/docx_routes.py
Normal file
60
routes/docx_routes.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# routes/docx_routes.py - DOCX/DOC Upload and Processing Routes
|
||||
|
||||
import json
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from db import get_db
|
||||
from docx_processor import process_docx_to_markdown
|
||||
from auth import login_required
|
||||
|
||||
docx_bp = Blueprint('docx', __name__)
|
||||
|
||||
|
||||
@docx_bp.route('/api/upload-docx', methods=['POST'])
|
||||
@login_required
|
||||
def upload_docx():
|
||||
"""Upload and process a DOCX or DOC file."""
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'error': 'No file provided'}), 400
|
||||
|
||||
doc_file = request.files['file']
|
||||
|
||||
if not doc_file or not doc_file.filename:
|
||||
return jsonify({'error': 'Invalid file'}), 400
|
||||
|
||||
filename = doc_file.filename.lower()
|
||||
if not (filename.endswith('.docx') or filename.endswith('.doc')):
|
||||
return jsonify({'error': 'File must be a .docx or .doc file'}), 400
|
||||
|
||||
try:
|
||||
print(f"📄 Processing Word document: {doc_file.filename}")
|
||||
|
||||
file_bytes = doc_file.read()
|
||||
print(f" 📏 File size: {len(file_bytes)} bytes")
|
||||
|
||||
result = process_docx_to_markdown(file_bytes, doc_file.filename)
|
||||
|
||||
blocks = result.get('markdown_blocks', [])
|
||||
block_count = len(blocks)
|
||||
image_count = sum(1 for b in blocks if b.get('type') == 'image')
|
||||
text_count = block_count - image_count
|
||||
|
||||
print(f"✅ Word document processed: {block_count} blocks ({text_count} text, {image_count} images)")
|
||||
|
||||
for i, block in enumerate(blocks):
|
||||
if block.get('type') == 'image':
|
||||
data_len = len(block.get('data', ''))
|
||||
fmt = block.get('format', '?')
|
||||
print(f" 📷 Block {i}: image ({fmt}), data length: {data_len}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'filename': doc_file.filename,
|
||||
'metadata': result.get('metadata', {}),
|
||||
'blocks': blocks
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
174
routes/export_routes.py
Normal file
174
routes/export_routes.py
Normal file
@@ -0,0 +1,174 @@
|
||||
# routes/export_routes.py - Export Routes
|
||||
|
||||
import io
|
||||
import os
|
||||
import json
|
||||
import base64
|
||||
import zipfile
|
||||
from flask import Blueprint, request, jsonify, send_file
|
||||
|
||||
from db import get_db
|
||||
from utils import sanitize_filename, strip_markdown
|
||||
from auth import login_required
|
||||
|
||||
export_bp = Blueprint('export', __name__)
|
||||
|
||||
|
||||
@export_bp.route('/api/export/<int:project_id>', methods=['GET'])
|
||||
@login_required
|
||||
def export_project(project_id):
|
||||
"""Export project as ZIP file. Only includes chapters with audio."""
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
cursor.execute('SELECT * FROM projects WHERE id = ?', (project_id,))
|
||||
project = cursor.fetchone()
|
||||
|
||||
if not project:
|
||||
return jsonify({'error': 'Project not found'}), 404
|
||||
|
||||
project_name = sanitize_filename(project['name'])
|
||||
|
||||
cursor.execute('''
|
||||
SELECT * FROM chapters WHERE project_id = ? ORDER BY chapter_number
|
||||
''', (project_id,))
|
||||
chapters = cursor.fetchall()
|
||||
|
||||
zip_buffer = io.BytesIO()
|
||||
|
||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
manifest = {
|
||||
'title': project['name'],
|
||||
'assets': [],
|
||||
'images': []
|
||||
}
|
||||
|
||||
for chapter in chapters:
|
||||
chapter_num = chapter['chapter_number']
|
||||
|
||||
cursor.execute('''
|
||||
SELECT * FROM markdown_blocks WHERE chapter_id = ? ORDER BY block_order
|
||||
''', (chapter['id'],))
|
||||
blocks = cursor.fetchall()
|
||||
|
||||
chapter_has_audio = False
|
||||
for block in blocks:
|
||||
is_image_block = (
|
||||
(block['content'] and block['content'].strip().startswith('![')) or
|
||||
block['block_type'] == 'image'
|
||||
)
|
||||
if not is_image_block and block['audio_data']:
|
||||
chapter_has_audio = True
|
||||
break
|
||||
|
||||
if not chapter_has_audio:
|
||||
continue
|
||||
|
||||
for block in blocks:
|
||||
block_order = block['block_order']
|
||||
prefix = f"{chapter_num}.{block_order}"
|
||||
content = block['content']
|
||||
|
||||
is_image_block = (
|
||||
(content and content.strip().startswith('![')) or
|
||||
block['block_type'] == 'image'
|
||||
)
|
||||
|
||||
cursor.execute('''
|
||||
SELECT * FROM block_images WHERE block_id = ? ORDER BY id
|
||||
''', (block['id'],))
|
||||
images = cursor.fetchall()
|
||||
|
||||
image_idx = 0
|
||||
for img in images:
|
||||
if img['position'] == 'before':
|
||||
image_filename = f"book/{prefix}_img{image_idx}.{img['image_format']}"
|
||||
image_bytes = base64.b64decode(img['image_data'])
|
||||
zf.writestr(image_filename, image_bytes)
|
||||
manifest['images'].append({
|
||||
'sortKey': prefix,
|
||||
'file': image_filename
|
||||
})
|
||||
image_idx += 1
|
||||
|
||||
if is_image_block:
|
||||
for img in images:
|
||||
if img['position'] == 'after':
|
||||
next_prefix = f"{chapter_num}.{block_order + 1}"
|
||||
image_filename = f"book/{next_prefix}_img{image_idx}.{img['image_format']}"
|
||||
image_bytes = base64.b64decode(img['image_data'])
|
||||
zf.writestr(image_filename, image_bytes)
|
||||
manifest['images'].append({
|
||||
'sortKey': next_prefix,
|
||||
'file': image_filename
|
||||
})
|
||||
image_idx += 1
|
||||
continue
|
||||
|
||||
plain_text = strip_markdown(content)
|
||||
if not plain_text.strip():
|
||||
continue
|
||||
|
||||
if not block['audio_data']:
|
||||
continue
|
||||
|
||||
text_filename = f"book/{prefix}_{project_name}.txt"
|
||||
zf.writestr(text_filename, plain_text)
|
||||
|
||||
asset_entry = {
|
||||
'prefix': f"{prefix}_",
|
||||
'sortKey': prefix,
|
||||
'textFile': text_filename,
|
||||
'audioFile': None,
|
||||
'jsonFile': None
|
||||
}
|
||||
|
||||
audio_filename = f"book/{prefix}_{project_name}.{block['audio_format'] or 'mp3'}"
|
||||
audio_bytes = base64.b64decode(block['audio_data'])
|
||||
zf.writestr(audio_filename, audio_bytes)
|
||||
asset_entry['audioFile'] = audio_filename
|
||||
|
||||
if block['transcription']:
|
||||
json_filename = f"book/{prefix}_{project_name}.json"
|
||||
zf.writestr(json_filename, block['transcription'])
|
||||
asset_entry['jsonFile'] = json_filename
|
||||
|
||||
manifest['assets'].append(asset_entry)
|
||||
|
||||
for img in images:
|
||||
if img['position'] == 'after':
|
||||
next_prefix = f"{chapter_num}.{block_order + 1}"
|
||||
image_filename = f"book/{next_prefix}_img{image_idx}.{img['image_format']}"
|
||||
image_bytes = base64.b64decode(img['image_data'])
|
||||
zf.writestr(image_filename, image_bytes)
|
||||
manifest['images'].append({
|
||||
'sortKey': next_prefix,
|
||||
'file': image_filename
|
||||
})
|
||||
image_idx += 1
|
||||
|
||||
zf.writestr('manifest.json', json.dumps(manifest, indent=2))
|
||||
|
||||
reader_templates_dir = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
'reader_templates'
|
||||
)
|
||||
|
||||
index_path = os.path.join(reader_templates_dir, 'index.html')
|
||||
if os.path.exists(index_path):
|
||||
with open(index_path, 'r', encoding='utf-8') as f:
|
||||
zf.writestr('index.html', f.read())
|
||||
|
||||
reader_path = os.path.join(reader_templates_dir, 'Reader.html')
|
||||
if os.path.exists(reader_path):
|
||||
with open(reader_path, 'r', encoding='utf-8') as f:
|
||||
zf.writestr('Reader.html', f.read())
|
||||
|
||||
zip_buffer.seek(0)
|
||||
|
||||
return send_file(
|
||||
zip_buffer,
|
||||
mimetype='application/zip',
|
||||
as_attachment=True,
|
||||
download_name=f"{project_name}.zip"
|
||||
)
|
||||
225
routes/generation_routes.py
Normal file
225
routes/generation_routes.py
Normal file
@@ -0,0 +1,225 @@
|
||||
# routes/generation_routes.py - TTS Audio Generation Routes
|
||||
|
||||
import json
|
||||
import base64
|
||||
import requests
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from db import get_db
|
||||
from config import TTS_API_URL, get_api_headers, get_api_headers_json
|
||||
from utils import convert_to_mp3, strip_markdown
|
||||
from auth import login_required
|
||||
|
||||
generation_bp = Blueprint('generation', __name__)
|
||||
|
||||
|
||||
@generation_bp.route('/api/generate', methods=['POST'])
|
||||
@login_required
|
||||
def generate_audio():
|
||||
"""Generate audio for a single block."""
|
||||
data = request.json
|
||||
text = data.get('text', '')
|
||||
voice = data.get('voice', 'af_heart')
|
||||
block_id = data.get('block_id')
|
||||
|
||||
if not text:
|
||||
return jsonify({'error': 'No text provided'}), 400
|
||||
|
||||
stripped = text.strip()
|
||||
if stripped.startswith(''):
|
||||
return jsonify({'error': 'Cannot generate audio for image content'}), 400
|
||||
|
||||
clean_text = strip_markdown(text)
|
||||
|
||||
if not clean_text.strip():
|
||||
return jsonify({'error': 'No speakable text content'}), 400
|
||||
|
||||
try:
|
||||
print(f"🔊 Generating audio: voice={voice}, text length={len(clean_text)}")
|
||||
print(f" Text preview: {clean_text[:100]}...")
|
||||
|
||||
response = requests.post(
|
||||
f"{TTS_API_URL}/generate-audio",
|
||||
headers=get_api_headers_json(),
|
||||
json={'text': clean_text, 'voice': voice, 'speed': 1.0},
|
||||
timeout=180
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
try:
|
||||
error_detail = response.json().get('error', 'Unknown error')
|
||||
except Exception:
|
||||
error_detail = f'HTTP {response.status_code}'
|
||||
print(f"❌ TTS API Error: {error_detail}")
|
||||
return jsonify({'error': f'TTS 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 TTS API'}), 500
|
||||
|
||||
audio_bytes = base64.b64decode(audio_base64)
|
||||
files = {'audio_file': (f'audio.{source_format}', audio_bytes)}
|
||||
ts_data = {'text': clean_text}
|
||||
|
||||
transcription = []
|
||||
try:
|
||||
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 = ts_result.get('timestamps', [])
|
||||
print(f"✅ Got {len(transcription)} word timestamps")
|
||||
else:
|
||||
print(f"⚠️ Timestamp API returned {ts_response.status_code}, continuing without timestamps")
|
||||
except Exception as ts_err:
|
||||
print(f"⚠️ Timestamp generation failed: {ts_err}, continuing without timestamps")
|
||||
|
||||
if source_format != 'mp3':
|
||||
audio_base64 = convert_to_mp3(audio_base64, source_format)
|
||||
|
||||
if block_id:
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
cursor.execute('''
|
||||
UPDATE markdown_blocks
|
||||
SET audio_data = ?, audio_format = 'mp3', transcription = ?
|
||||
WHERE id = ?
|
||||
''', (audio_base64, json.dumps(transcription), block_id))
|
||||
db.commit()
|
||||
|
||||
print(f"✅ Audio generated successfully: {len(audio_base64)} bytes base64")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'audio_data': audio_base64,
|
||||
'audio_format': 'mp3',
|
||||
'transcription': transcription
|
||||
})
|
||||
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
print(f"❌ Cannot connect to TTS API at {TTS_API_URL}: {e}")
|
||||
return jsonify({'error': f'Cannot connect to TTS API server. Is it running at {TTS_API_URL}?'}), 500
|
||||
except requests.exceptions.Timeout as e:
|
||||
print(f"❌ TTS API timeout: {e}")
|
||||
return jsonify({'error': 'TTS API request timed out. Text may be too long.'}), 500
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"❌ TTS API request error: {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
|
||||
|
||||
|
||||
@generation_bp.route('/api/generate-chapter', methods=['POST'])
|
||||
@login_required
|
||||
def generate_chapter_audio():
|
||||
"""Generate audio for all blocks in a chapter."""
|
||||
data = request.json
|
||||
chapter_id = data.get('chapter_id')
|
||||
voice = data.get('voice', 'af_heart')
|
||||
|
||||
if not chapter_id:
|
||||
return jsonify({'error': 'Chapter ID required'}), 400
|
||||
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT id, content, tts_text, block_type FROM markdown_blocks
|
||||
WHERE chapter_id = ? ORDER BY block_order
|
||||
''', (chapter_id,))
|
||||
blocks = cursor.fetchall()
|
||||
|
||||
if not blocks:
|
||||
return jsonify({'error': 'No blocks found in chapter'}), 404
|
||||
|
||||
results = []
|
||||
|
||||
for block in blocks:
|
||||
block_id = block['id']
|
||||
block_type = block['block_type'] if 'block_type' in block.keys() else 'paragraph'
|
||||
content = block['content'] or ''
|
||||
text = block['tts_text'] if block['tts_text'] else content
|
||||
|
||||
if block_type == 'image':
|
||||
results.append({'block_id': block_id, 'success': True, 'skipped': True, 'reason': 'image block'})
|
||||
continue
|
||||
|
||||
stripped = text.strip()
|
||||
if stripped.startswith(''):
|
||||
results.append({'block_id': block_id, 'success': True, 'skipped': True, 'reason': 'image markdown'})
|
||||
continue
|
||||
|
||||
clean_text = strip_markdown(text)
|
||||
if not clean_text.strip():
|
||||
results.append({'block_id': block_id, 'success': True, 'skipped': True, 'reason': 'empty text'})
|
||||
continue
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{TTS_API_URL}/generate-audio",
|
||||
headers=get_api_headers_json(),
|
||||
json={'text': clean_text, 'voice': voice, 'speed': 1.0},
|
||||
timeout=180
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
results.append({'block_id': block_id, 'success': False, 'error': 'TTS generation failed'})
|
||||
continue
|
||||
|
||||
result = response.json()
|
||||
audio_base64 = result.get('audio_base64', '')
|
||||
source_format = result.get('audio_format', 'wav')
|
||||
|
||||
transcription = []
|
||||
try:
|
||||
audio_bytes = base64.b64decode(audio_base64)
|
||||
files = {'audio_file': (f'audio.{source_format}', audio_bytes)}
|
||||
ts_data = {'text': clean_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 = ts_result.get('timestamps', [])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if source_format != 'mp3':
|
||||
audio_base64 = convert_to_mp3(audio_base64, source_format)
|
||||
|
||||
cursor.execute('''
|
||||
UPDATE markdown_blocks
|
||||
SET audio_data = ?, audio_format = 'mp3', transcription = ?
|
||||
WHERE id = ?
|
||||
''', (audio_base64, json.dumps(transcription), block_id))
|
||||
|
||||
results.append({
|
||||
'block_id': block_id,
|
||||
'success': True,
|
||||
'audio_data': audio_base64,
|
||||
'transcription': transcription
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
results.append({'block_id': block_id, 'success': False, 'error': str(e)})
|
||||
|
||||
db.commit()
|
||||
|
||||
return jsonify({'success': True, 'results': results})
|
||||
61
routes/main_routes.py
Normal file
61
routes/main_routes.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# routes/main_routes.py - Main Application Routes
|
||||
|
||||
import os
|
||||
from flask import Blueprint, jsonify, send_from_directory, session
|
||||
|
||||
from config import DATABASE, VOICES
|
||||
from auth import login_required, get_current_user
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
|
||||
@main_bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
"""Serve main application page."""
|
||||
return send_from_directory('templates', 'index.html')
|
||||
|
||||
|
||||
@main_bp.route('/static/<path:filename>')
|
||||
def serve_static(filename):
|
||||
"""Serve static files."""
|
||||
return send_from_directory('static', filename)
|
||||
|
||||
|
||||
@main_bp.route('/api/voices', methods=['GET'])
|
||||
@login_required
|
||||
def get_voices():
|
||||
"""Get available TTS voices."""
|
||||
return jsonify({'voices': VOICES})
|
||||
|
||||
|
||||
@main_bp.route('/api/stats', methods=['GET'])
|
||||
@login_required
|
||||
def get_stats():
|
||||
"""Get database statistics."""
|
||||
from db import get_db
|
||||
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
cursor.execute('SELECT COUNT(*) as count FROM projects')
|
||||
project_count = cursor.fetchone()['count']
|
||||
|
||||
cursor.execute('SELECT COUNT(*) as count FROM chapters')
|
||||
chapter_count = cursor.fetchone()['count']
|
||||
|
||||
cursor.execute('SELECT COUNT(*) as count FROM markdown_blocks')
|
||||
block_count = cursor.fetchone()['count']
|
||||
|
||||
cursor.execute('SELECT COUNT(*) as count FROM pdf_documents')
|
||||
pdf_count = cursor.fetchone()['count']
|
||||
|
||||
db_size = os.path.getsize(DATABASE) if os.path.exists(DATABASE) else 0
|
||||
|
||||
return jsonify({
|
||||
'projects': project_count,
|
||||
'chapters': chapter_count,
|
||||
'blocks': block_count,
|
||||
'pdf_documents': pdf_count,
|
||||
'database_size_mb': round(db_size / (1024 * 1024), 2)
|
||||
})
|
||||
64
routes/pdf_routes.py
Normal file
64
routes/pdf_routes.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# routes/pdf_routes.py - PDF Upload and Processing Routes
|
||||
|
||||
import json
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from db import get_db
|
||||
from pdf_processor import process_pdf_to_markdown
|
||||
from auth import login_required
|
||||
|
||||
pdf_bp = Blueprint('pdf', __name__)
|
||||
|
||||
|
||||
@pdf_bp.route('/api/upload-pdf', methods=['POST'])
|
||||
@login_required
|
||||
def upload_pdf():
|
||||
"""Upload and process a PDF file."""
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'error': 'No file provided'}), 400
|
||||
|
||||
pdf_file = request.files['file']
|
||||
|
||||
if not pdf_file or not pdf_file.filename:
|
||||
return jsonify({'error': 'Invalid file'}), 400
|
||||
|
||||
if not pdf_file.filename.lower().endswith('.pdf'):
|
||||
return jsonify({'error': 'File must be a PDF'}), 400
|
||||
|
||||
try:
|
||||
print(f"📄 Processing PDF: {pdf_file.filename}")
|
||||
|
||||
pdf_bytes = pdf_file.read()
|
||||
result = process_pdf_to_markdown(pdf_bytes)
|
||||
|
||||
# Save PDF document record
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO pdf_documents (filename, page_count, metadata)
|
||||
VALUES (?, ?, ?)
|
||||
''', (
|
||||
pdf_file.filename,
|
||||
result["page_count"],
|
||||
json.dumps(result["metadata"])
|
||||
))
|
||||
db.commit()
|
||||
|
||||
doc_id = cursor.lastrowid
|
||||
|
||||
print(f"✅ PDF processed: {result['page_count']} pages, {len(result['markdown_blocks'])} blocks")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'document_id': doc_id,
|
||||
'filename': pdf_file.filename,
|
||||
'page_count': result['page_count'],
|
||||
'metadata': result['metadata'],
|
||||
'blocks': result['markdown_blocks']
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
269
routes/project_routes.py
Normal file
269
routes/project_routes.py
Normal file
@@ -0,0 +1,269 @@
|
||||
# routes/project_routes.py - Project Management Routes
|
||||
|
||||
import json
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from db import get_db, vacuum_db
|
||||
from auth import login_required
|
||||
|
||||
project_bp = Blueprint('project', __name__)
|
||||
|
||||
|
||||
@project_bp.route('/api/projects', methods=['GET'])
|
||||
@login_required
|
||||
def list_projects():
|
||||
"""List all projects."""
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT p.id, p.name, p.created_at, p.updated_at,
|
||||
(SELECT COUNT(*) FROM chapters WHERE project_id = p.id) as chapter_count,
|
||||
(SELECT COUNT(*) FROM markdown_blocks mb
|
||||
JOIN chapters c ON mb.chapter_id = c.id
|
||||
WHERE c.project_id = p.id) as block_count
|
||||
FROM projects p
|
||||
ORDER BY p.updated_at DESC
|
||||
''')
|
||||
|
||||
projects = []
|
||||
for row in cursor.fetchall():
|
||||
projects.append({
|
||||
'id': row['id'],
|
||||
'name': row['name'],
|
||||
'created_at': row['created_at'],
|
||||
'updated_at': row['updated_at'],
|
||||
'chapter_count': row['chapter_count'],
|
||||
'block_count': row['block_count']
|
||||
})
|
||||
|
||||
return jsonify({'projects': projects})
|
||||
|
||||
|
||||
@project_bp.route('/api/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 (name) VALUES (?)', (name,))
|
||||
db.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'project_id': cursor.lastrowid,
|
||||
'name': name
|
||||
})
|
||||
except Exception as e:
|
||||
if 'UNIQUE constraint' in str(e):
|
||||
return jsonify({'error': 'Project with this name already exists'}), 400
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@project_bp.route('/api/projects/<int:project_id>', methods=['GET'])
|
||||
@login_required
|
||||
def get_project(project_id):
|
||||
"""Get a project with all its chapters and blocks."""
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
cursor.execute('SELECT * FROM projects WHERE id = ?', (project_id,))
|
||||
project = cursor.fetchone()
|
||||
|
||||
if not project:
|
||||
return jsonify({'error': 'Project not found'}), 404
|
||||
|
||||
cursor.execute('''
|
||||
SELECT * FROM chapters WHERE project_id = ? ORDER BY chapter_number
|
||||
''', (project_id,))
|
||||
chapters = cursor.fetchall()
|
||||
|
||||
chapters_data = []
|
||||
for chapter in chapters:
|
||||
cursor.execute('''
|
||||
SELECT * FROM markdown_blocks WHERE chapter_id = ? ORDER BY block_order
|
||||
''', (chapter['id'],))
|
||||
blocks = cursor.fetchall()
|
||||
|
||||
blocks_data = []
|
||||
for block in blocks:
|
||||
cursor.execute('''
|
||||
SELECT * FROM block_images WHERE block_id = ? ORDER BY id
|
||||
''', (block['id'],))
|
||||
images = cursor.fetchall()
|
||||
|
||||
blocks_data.append({
|
||||
'id': block['id'],
|
||||
'block_order': block['block_order'],
|
||||
'block_type': block['block_type'],
|
||||
'content': block['content'],
|
||||
'tts_text': block['tts_text'],
|
||||
'audio_data': block['audio_data'],
|
||||
'audio_format': block['audio_format'],
|
||||
'transcription': json.loads(block['transcription']) if block['transcription'] else [],
|
||||
'images': [{
|
||||
'id': img['id'],
|
||||
'data': img['image_data'],
|
||||
'format': img['image_format'],
|
||||
'alt_text': img['alt_text'],
|
||||
'position': img['position']
|
||||
} for img in images]
|
||||
})
|
||||
|
||||
chapters_data.append({
|
||||
'id': chapter['id'],
|
||||
'chapter_number': chapter['chapter_number'],
|
||||
'voice': chapter['voice'],
|
||||
'blocks': blocks_data
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'id': project['id'],
|
||||
'name': project['name'],
|
||||
'created_at': project['created_at'],
|
||||
'updated_at': project['updated_at'],
|
||||
'chapters': chapters_data
|
||||
})
|
||||
|
||||
|
||||
@project_bp.route('/api/projects/<int:project_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_project(project_id):
|
||||
"""Update project name."""
|
||||
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()
|
||||
|
||||
cursor.execute('''
|
||||
UPDATE projects SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
|
||||
''', (name, project_id))
|
||||
db.commit()
|
||||
|
||||
if cursor.rowcount == 0:
|
||||
return jsonify({'error': 'Project not found'}), 404
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@project_bp.route('/api/projects/<int:project_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_project(project_id):
|
||||
"""Delete a project and all its data."""
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
cursor.execute('SELECT id FROM projects WHERE id = ?', (project_id,))
|
||||
if not cursor.fetchone():
|
||||
return jsonify({'error': 'Project not found'}), 404
|
||||
|
||||
cursor.execute('''
|
||||
DELETE FROM block_images WHERE block_id IN (
|
||||
SELECT mb.id FROM markdown_blocks mb
|
||||
JOIN chapters c ON mb.chapter_id = c.id
|
||||
WHERE c.project_id = ?
|
||||
)
|
||||
''', (project_id,))
|
||||
|
||||
cursor.execute('''
|
||||
DELETE FROM markdown_blocks WHERE chapter_id IN (
|
||||
SELECT id FROM chapters WHERE project_id = ?
|
||||
)
|
||||
''', (project_id,))
|
||||
|
||||
cursor.execute('DELETE FROM chapters WHERE project_id = ?', (project_id,))
|
||||
cursor.execute('DELETE FROM projects WHERE id = ?', (project_id,))
|
||||
|
||||
db.commit()
|
||||
vacuum_db()
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@project_bp.route('/api/projects/<int:project_id>/save', methods=['POST'])
|
||||
@login_required
|
||||
def save_project_content(project_id):
|
||||
"""Save all chapters and blocks for a project."""
|
||||
data = request.json
|
||||
chapters = data.get('chapters', [])
|
||||
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
cursor.execute('SELECT id FROM projects WHERE id = ?', (project_id,))
|
||||
if not cursor.fetchone():
|
||||
return jsonify({'error': 'Project not found'}), 404
|
||||
|
||||
cursor.execute('''
|
||||
DELETE FROM block_images WHERE block_id IN (
|
||||
SELECT mb.id FROM markdown_blocks mb
|
||||
JOIN chapters c ON mb.chapter_id = c.id
|
||||
WHERE c.project_id = ?
|
||||
)
|
||||
''', (project_id,))
|
||||
|
||||
cursor.execute('''
|
||||
DELETE FROM markdown_blocks WHERE chapter_id IN (
|
||||
SELECT id FROM chapters WHERE project_id = ?
|
||||
)
|
||||
''', (project_id,))
|
||||
|
||||
cursor.execute('DELETE FROM chapters WHERE project_id = ?', (project_id,))
|
||||
|
||||
for chapter in chapters:
|
||||
cursor.execute('''
|
||||
INSERT INTO chapters (project_id, chapter_number, voice)
|
||||
VALUES (?, ?, ?)
|
||||
''', (project_id, chapter['chapter_number'], chapter.get('voice', 'af_heart')))
|
||||
|
||||
chapter_id = cursor.lastrowid
|
||||
|
||||
for block in chapter.get('blocks', []):
|
||||
cursor.execute('''
|
||||
INSERT INTO markdown_blocks
|
||||
(chapter_id, block_order, block_type, content, tts_text, audio_data, audio_format, transcription)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
chapter_id,
|
||||
block['block_order'],
|
||||
block.get('block_type', 'paragraph'),
|
||||
block['content'],
|
||||
block.get('tts_text'),
|
||||
block.get('audio_data'),
|
||||
block.get('audio_format', 'mp3'),
|
||||
json.dumps(block.get('transcription', []))
|
||||
))
|
||||
|
||||
block_id = cursor.lastrowid
|
||||
|
||||
for img in block.get('images', []):
|
||||
cursor.execute('''
|
||||
INSERT INTO block_images (block_id, image_data, image_format, alt_text, position)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''', (
|
||||
block_id,
|
||||
img['data'],
|
||||
img.get('format', 'png'),
|
||||
img.get('alt_text', ''),
|
||||
img.get('position', 'before')
|
||||
))
|
||||
|
||||
cursor.execute('''
|
||||
UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE id = ?
|
||||
''', (project_id,))
|
||||
|
||||
db.commit()
|
||||
|
||||
return jsonify({'success': True, 'message': 'Project saved successfully'})
|
||||
465
static/css/markdown-editor.css
Normal file
465
static/css/markdown-editor.css
Normal file
@@ -0,0 +1,465 @@
|
||||
/* ============================================
|
||||
Markdown Editor Styles
|
||||
UPDATED: Removed slash command styles
|
||||
UPDATED: Added image-btn style for new-block-line
|
||||
============================================= */
|
||||
|
||||
/* Chapter Marker */
|
||||
.chapter-marker {
|
||||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
||||
border: 2px solid #f59e0b;
|
||||
border-radius: var(--border-radius);
|
||||
padding: 16px 20px;
|
||||
margin: 24px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chapter-marker-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chapter-label {
|
||||
font-weight: 700;
|
||||
color: #92400e;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.875rem;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.chapter-number-input {
|
||||
width: 80px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
border: 2px solid #f59e0b;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.chapter-voice-select {
|
||||
min-width: 180px;
|
||||
border: 2px solid #f59e0b;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.chapter-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Markdown Block */
|
||||
.md-block {
|
||||
position: relative;
|
||||
margin: 8px 0;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.md-block:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.md-block.editing {
|
||||
background: #eff6ff;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
/* Block Content */
|
||||
.md-block-content {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.8;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.md-block-content h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.md-block-content h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.md-block-content h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.md-block-content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.md-block-content blockquote {
|
||||
border-left: 4px solid var(--primary-color);
|
||||
padding-left: 16px;
|
||||
margin: 0;
|
||||
font-style: italic;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.md-block-content ul,
|
||||
.md-block-content ol {
|
||||
margin: 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.md-block-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--border-radius-sm);
|
||||
margin: 8px auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.md-block-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.md-block-content th,
|
||||
.md-block-content td {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.md-block-content th {
|
||||
background: var(--bg-tertiary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Block Edit Mode */
|
||||
.md-block-edit {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.md-block.editing .md-block-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.md-block.editing .md-block-edit {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.md-block-textarea {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.md-block-textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Block Toolbar */
|
||||
.md-block-toolbar {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 0;
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: 4px;
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.md-block.editing .md-block-toolbar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toolbar-btn.active {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toolbar-divider {
|
||||
width: 1px;
|
||||
background: var(--border-color);
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
/* Block Type Menu */
|
||||
.block-type-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 8px;
|
||||
min-width: 200px;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.block-type-menu.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.block-type-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.block-type-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.block-type-item i {
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-secondary);
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.block-type-item span {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.block-type-item small {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Empty Block Placeholder */
|
||||
.md-block-placeholder {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
New Block Line
|
||||
============================================= */
|
||||
|
||||
.new-block-line {
|
||||
height: 24px;
|
||||
position: relative;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.new-block-line:hover::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
height: 2px;
|
||||
background: var(--primary-color);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.new-block-line:hover .add-line-buttons {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.add-line-buttons {
|
||||
display: none;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
gap: 8px;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.add-line-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.add-line-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Image add button - teal */
|
||||
.add-line-btn.image-btn {
|
||||
background: #06b6d4;
|
||||
}
|
||||
|
||||
.add-line-btn.image-btn:hover {
|
||||
background: #0891b2;
|
||||
}
|
||||
|
||||
/* Chapter add button - amber */
|
||||
.add-line-btn.chapter-btn {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.add-line-btn.chapter-btn:hover {
|
||||
background: #d97706;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Image Block - Centered
|
||||
============================================= */
|
||||
|
||||
.image-block {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--bg-tertiary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.image-block:hover {
|
||||
border-color: var(--primary-color);
|
||||
background: rgba(79, 70, 229, 0.05);
|
||||
}
|
||||
|
||||
.image-block img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--border-radius-sm);
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.image-upload-placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.image-upload-placeholder i {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Audio Indicator
|
||||
============================================= */
|
||||
|
||||
.audio-indicator {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.audio-indicator.has-audio {
|
||||
background: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.audio-indicator.no-audio {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Block Actions Indicator (Edit + Delete)
|
||||
============================================= */
|
||||
|
||||
.block-actions-indicator {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 40px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.md-block:hover .block-actions-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.action-indicator-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.action-indicator-btn.edit-block-btn:hover {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-indicator-btn.delete-block-btn:hover {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Hide the old block-edit-indicator since we replaced it */
|
||||
.block-edit-indicator {
|
||||
display: none !important;
|
||||
}
|
||||
1268
static/css/style.css
Normal file
1268
static/css/style.css
Normal file
File diff suppressed because it is too large
Load Diff
899
static/js/app.js
Normal file
899
static/js/app.js
Normal file
@@ -0,0 +1,899 @@
|
||||
/**
|
||||
* Audiobook Maker Pro v3.1 - Main Application
|
||||
* UPDATED: Dynamic header help button, floating guide panel, no hint bar
|
||||
* UPDATED: Hide guide panel by default when Interactive Reader tab is active
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// Global State
|
||||
// ============================================
|
||||
|
||||
let currentProject = {
|
||||
id: null,
|
||||
name: 'My Audiobook',
|
||||
chapters: []
|
||||
};
|
||||
|
||||
let voices = [];
|
||||
let archiveModal = null;
|
||||
let ttsEditModal = null;
|
||||
let currentWorkflowStage = 'upload';
|
||||
|
||||
// ============================================
|
||||
// Initialization
|
||||
// ============================================
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('🎧 Audiobook Maker Pro v3.1 initializing...');
|
||||
|
||||
archiveModal = new bootstrap.Modal(document.getElementById('archiveModal'));
|
||||
ttsEditModal = new bootstrap.Modal(document.getElementById('ttsEditModal'));
|
||||
|
||||
loadVoices();
|
||||
initPdfHandler();
|
||||
initMarkdownEditor();
|
||||
setupEventListeners();
|
||||
|
||||
// Show welcome overlay for first-time users
|
||||
initWelcomeOverlay();
|
||||
|
||||
// Initialize workflow progress
|
||||
updateWorkflowProgress('upload');
|
||||
|
||||
// Initialize floating guide panel
|
||||
initFloatingGuidePanel();
|
||||
|
||||
// Load current user info
|
||||
loadCurrentUser();
|
||||
|
||||
// Ensure reader UI is hidden on startup
|
||||
if (typeof hideReaderUI === 'function') {
|
||||
hideReaderUI();
|
||||
}
|
||||
|
||||
console.log('✅ Application initialized');
|
||||
});
|
||||
|
||||
function setupEventListeners() {
|
||||
document.getElementById('projectName').addEventListener('change', function() {
|
||||
currentProject.name = this.value;
|
||||
});
|
||||
|
||||
document.getElementById('reader-tab').addEventListener('shown.bs.tab', function() {
|
||||
renderInteractiveReader();
|
||||
if (typeof showReaderUI === 'function') {
|
||||
showReaderUI();
|
||||
}
|
||||
// Hide the guide panel when entering the reader
|
||||
hideGuidePanelForReader();
|
||||
});
|
||||
|
||||
document.getElementById('editor-tab').addEventListener('shown.bs.tab', function() {
|
||||
if (typeof hideReaderUI === 'function') {
|
||||
hideReaderUI();
|
||||
}
|
||||
// Restore the guide panel when returning to the editor
|
||||
restoreGuidePanelForEditor();
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Welcome Overlay
|
||||
// ============================================
|
||||
|
||||
function initWelcomeOverlay() {
|
||||
const dontShow = localStorage.getItem('audiobookMakerHideWelcome');
|
||||
if (dontShow === 'true') {
|
||||
document.getElementById('welcomeOverlay').style.display = 'none';
|
||||
} else {
|
||||
document.getElementById('welcomeOverlay').style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
function dismissWelcome() {
|
||||
const dontShowCheckbox = document.getElementById('welcomeDontShow');
|
||||
if (dontShowCheckbox && dontShowCheckbox.checked) {
|
||||
localStorage.setItem('audiobookMakerHideWelcome', 'true');
|
||||
}
|
||||
document.getElementById('welcomeOverlay').style.display = 'none';
|
||||
}
|
||||
|
||||
function showWelcome() {
|
||||
document.getElementById('welcomeOverlay').style.display = 'flex';
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Dynamic Header Help Button
|
||||
// ============================================
|
||||
|
||||
function handleHeaderHelp() {
|
||||
if (currentWorkflowStage === 'upload') {
|
||||
showWelcome();
|
||||
} else {
|
||||
showGuidePanel();
|
||||
}
|
||||
}
|
||||
|
||||
function updateHeaderHelpButton(stage) {
|
||||
const label = document.getElementById('headerHelpLabel');
|
||||
const btn = document.getElementById('headerHelpBtn');
|
||||
|
||||
if (!label || !btn) return;
|
||||
|
||||
if (stage === 'upload') {
|
||||
label.textContent = 'Quick Start';
|
||||
btn.title = 'Show quick start guide';
|
||||
} else {
|
||||
label.textContent = 'Quick Guide';
|
||||
btn.title = 'Show editor quick guide';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Floating Guide Panel
|
||||
// ============================================
|
||||
|
||||
let guidePanelDragState = {
|
||||
isDragging: false,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
offsetX: 0,
|
||||
offsetY: 0
|
||||
};
|
||||
|
||||
function initFloatingGuidePanel() {
|
||||
const panel = document.getElementById('floatingGuidePanel');
|
||||
const header = document.getElementById('guidePanelHeader');
|
||||
const toggle = document.getElementById('floatingGuideToggle');
|
||||
|
||||
if (!panel || !header) return;
|
||||
|
||||
// Check if user previously hid the panel permanently
|
||||
const hideGuide = localStorage.getItem('audiobookMakerHideGuide');
|
||||
if (hideGuide === 'true') {
|
||||
panel.classList.remove('visible');
|
||||
if (toggle) toggle.classList.add('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user previously collapsed
|
||||
const collapsed = localStorage.getItem('audiobookMakerGuideCollapsed');
|
||||
if (collapsed === 'true') {
|
||||
panel.classList.add('collapsed');
|
||||
const icon = document.getElementById('guideCollapseIcon');
|
||||
if (icon) {
|
||||
icon.classList.remove('bi-chevron-up');
|
||||
icon.classList.add('bi-chevron-down');
|
||||
}
|
||||
}
|
||||
|
||||
// Restore saved position
|
||||
const savedPos = localStorage.getItem('audiobookMakerGuidePos');
|
||||
if (savedPos) {
|
||||
try {
|
||||
const pos = JSON.parse(savedPos);
|
||||
const maxX = window.innerWidth - 100;
|
||||
const maxY = window.innerHeight - 50;
|
||||
if (pos.x >= 0 && pos.x <= maxX && pos.y >= 0 && pos.y <= maxY) {
|
||||
panel.style.right = 'auto';
|
||||
panel.style.left = pos.x + 'px';
|
||||
panel.style.top = pos.y + 'px';
|
||||
}
|
||||
} catch(e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// Setup drag events (mouse)
|
||||
header.addEventListener('mousedown', onGuideDragStart);
|
||||
document.addEventListener('mousemove', onGuideDragMove);
|
||||
document.addEventListener('mouseup', onGuideDragEnd);
|
||||
|
||||
// Touch support
|
||||
header.addEventListener('touchstart', onGuideTouchStart, { passive: false });
|
||||
document.addEventListener('touchmove', onGuideTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', onGuideTouchEnd);
|
||||
}
|
||||
|
||||
function showGuidePanelOnEditor() {
|
||||
const panel = document.getElementById('floatingGuidePanel');
|
||||
const toggle = document.getElementById('floatingGuideToggle');
|
||||
const hideGuide = localStorage.getItem('audiobookMakerHideGuide');
|
||||
|
||||
if (hideGuide === 'true') {
|
||||
if (toggle) toggle.classList.add('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
if (panel) panel.classList.add('visible');
|
||||
if (toggle) toggle.classList.remove('visible');
|
||||
}
|
||||
|
||||
function showGuidePanel() {
|
||||
const panel = document.getElementById('floatingGuidePanel');
|
||||
const toggle = document.getElementById('floatingGuideToggle');
|
||||
|
||||
if (panel) panel.classList.add('visible');
|
||||
if (toggle) toggle.classList.remove('visible');
|
||||
|
||||
localStorage.removeItem('audiobookMakerHideGuide');
|
||||
}
|
||||
|
||||
function hideGuidePanel() {
|
||||
const panel = document.getElementById('floatingGuidePanel');
|
||||
const toggle = document.getElementById('floatingGuideToggle');
|
||||
|
||||
if (panel) panel.classList.remove('visible');
|
||||
if (toggle) toggle.classList.add('visible');
|
||||
}
|
||||
|
||||
function toggleGuideCollapse() {
|
||||
const panel = document.getElementById('floatingGuidePanel');
|
||||
const icon = document.getElementById('guideCollapseIcon');
|
||||
|
||||
if (!panel) return;
|
||||
|
||||
const isCollapsed = panel.classList.toggle('collapsed');
|
||||
|
||||
if (icon) {
|
||||
if (isCollapsed) {
|
||||
icon.classList.remove('bi-chevron-up');
|
||||
icon.classList.add('bi-chevron-down');
|
||||
} else {
|
||||
icon.classList.remove('bi-chevron-down');
|
||||
icon.classList.add('bi-chevron-up');
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem('audiobookMakerGuideCollapsed', isCollapsed ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function handleGuideDontShow() {
|
||||
const checkbox = document.getElementById('guidePanelDontShow');
|
||||
if (checkbox && checkbox.checked) {
|
||||
localStorage.setItem('audiobookMakerHideGuide', 'true');
|
||||
hideGuidePanel();
|
||||
} else {
|
||||
localStorage.removeItem('audiobookMakerHideGuide');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Guide Panel: Reader Tab Visibility
|
||||
// ============================================
|
||||
|
||||
let guidePanelHiddenByReader = false;
|
||||
|
||||
function hideGuidePanelForReader() {
|
||||
const panel = document.getElementById('floatingGuidePanel');
|
||||
const toggle = document.getElementById('floatingGuideToggle');
|
||||
|
||||
// If the panel is currently visible, hide it and remember we did so
|
||||
if (panel && panel.classList.contains('visible')) {
|
||||
panel.classList.remove('visible');
|
||||
guidePanelHiddenByReader = true;
|
||||
} else {
|
||||
guidePanelHiddenByReader = false;
|
||||
}
|
||||
|
||||
// Always show the toggle button so the user CAN show it manually if they want
|
||||
if (toggle) toggle.classList.add('visible');
|
||||
}
|
||||
|
||||
function restoreGuidePanelForEditor() {
|
||||
// Only restore if we were the ones who hid it
|
||||
if (guidePanelHiddenByReader) {
|
||||
const panel = document.getElementById('floatingGuidePanel');
|
||||
const toggle = document.getElementById('floatingGuideToggle');
|
||||
const hideGuide = localStorage.getItem('audiobookMakerHideGuide');
|
||||
|
||||
if (hideGuide !== 'true' && panel) {
|
||||
panel.classList.add('visible');
|
||||
if (toggle) toggle.classList.remove('visible');
|
||||
}
|
||||
guidePanelHiddenByReader = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Drag Logic (Mouse) ---
|
||||
|
||||
function onGuideDragStart(e) {
|
||||
if (e.target.closest('.guide-panel-btn') || e.target.closest('button')) return;
|
||||
|
||||
const panel = document.getElementById('floatingGuidePanel');
|
||||
if (!panel) return;
|
||||
|
||||
guidePanelDragState.isDragging = true;
|
||||
|
||||
const rect = panel.getBoundingClientRect();
|
||||
guidePanelDragState.offsetX = e.clientX - rect.left;
|
||||
guidePanelDragState.offsetY = e.clientY - rect.top;
|
||||
|
||||
panel.style.transition = 'none';
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onGuideDragMove(e) {
|
||||
if (!guidePanelDragState.isDragging) return;
|
||||
|
||||
const panel = document.getElementById('floatingGuidePanel');
|
||||
if (!panel) return;
|
||||
|
||||
let newX = e.clientX - guidePanelDragState.offsetX;
|
||||
let newY = e.clientY - guidePanelDragState.offsetY;
|
||||
|
||||
const panelWidth = panel.offsetWidth;
|
||||
const panelHeight = panel.offsetHeight;
|
||||
newX = Math.max(0, Math.min(newX, window.innerWidth - panelWidth));
|
||||
newY = Math.max(0, Math.min(newY, window.innerHeight - panelHeight));
|
||||
|
||||
panel.style.right = 'auto';
|
||||
panel.style.left = newX + 'px';
|
||||
panel.style.top = newY + 'px';
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onGuideDragEnd(e) {
|
||||
if (!guidePanelDragState.isDragging) return;
|
||||
|
||||
guidePanelDragState.isDragging = false;
|
||||
|
||||
const panel = document.getElementById('floatingGuidePanel');
|
||||
if (panel) {
|
||||
panel.style.transition = '';
|
||||
localStorage.setItem('audiobookMakerGuidePos', JSON.stringify({
|
||||
x: parseInt(panel.style.left) || 0,
|
||||
y: parseInt(panel.style.top) || 0
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Drag Logic (Touch) ---
|
||||
|
||||
function onGuideTouchStart(e) {
|
||||
if (e.target.closest('.guide-panel-btn') || e.target.closest('button')) return;
|
||||
|
||||
const panel = document.getElementById('floatingGuidePanel');
|
||||
if (!panel) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
guidePanelDragState.isDragging = true;
|
||||
|
||||
const rect = panel.getBoundingClientRect();
|
||||
guidePanelDragState.offsetX = touch.clientX - rect.left;
|
||||
guidePanelDragState.offsetY = touch.clientY - rect.top;
|
||||
|
||||
panel.style.transition = 'none';
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onGuideTouchMove(e) {
|
||||
if (!guidePanelDragState.isDragging) return;
|
||||
|
||||
const panel = document.getElementById('floatingGuidePanel');
|
||||
if (!panel) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
let newX = touch.clientX - guidePanelDragState.offsetX;
|
||||
let newY = touch.clientY - guidePanelDragState.offsetY;
|
||||
|
||||
const panelWidth = panel.offsetWidth;
|
||||
const panelHeight = panel.offsetHeight;
|
||||
newX = Math.max(0, Math.min(newX, window.innerWidth - panelWidth));
|
||||
newY = Math.max(0, Math.min(newY, window.innerHeight - panelHeight));
|
||||
|
||||
panel.style.right = 'auto';
|
||||
panel.style.left = newX + 'px';
|
||||
panel.style.top = newY + 'px';
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onGuideTouchEnd(e) {
|
||||
if (!guidePanelDragState.isDragging) return;
|
||||
|
||||
guidePanelDragState.isDragging = false;
|
||||
|
||||
const panel = document.getElementById('floatingGuidePanel');
|
||||
if (panel) {
|
||||
panel.style.transition = '';
|
||||
localStorage.setItem('audiobookMakerGuidePos', JSON.stringify({
|
||||
x: parseInt(panel.style.left) || 0,
|
||||
y: parseInt(panel.style.top) || 0
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Workflow Progress Bar
|
||||
// ============================================
|
||||
|
||||
function updateWorkflowProgress(stage) {
|
||||
const step1 = document.getElementById('wpStep1');
|
||||
const step2 = document.getElementById('wpStep2');
|
||||
const step3 = document.getElementById('wpStep3');
|
||||
const conn1 = document.getElementById('wpConn1');
|
||||
const conn2 = document.getElementById('wpConn2');
|
||||
|
||||
if (!step1) return;
|
||||
|
||||
// Track current stage for help button
|
||||
currentWorkflowStage = stage;
|
||||
updateHeaderHelpButton(stage);
|
||||
|
||||
// Reset all
|
||||
[step1, step2, step3].forEach(s => {
|
||||
s.classList.remove('completed', 'active');
|
||||
});
|
||||
[conn1, conn2].forEach(c => {
|
||||
c.classList.remove('active');
|
||||
});
|
||||
|
||||
switch (stage) {
|
||||
case 'upload':
|
||||
step1.classList.add('active');
|
||||
break;
|
||||
case 'edit':
|
||||
step1.classList.add('completed');
|
||||
conn1.classList.add('active');
|
||||
step2.classList.add('active');
|
||||
// Show the floating guide when entering editor
|
||||
showGuidePanelOnEditor();
|
||||
break;
|
||||
case 'audio-ready':
|
||||
step1.classList.add('completed');
|
||||
conn1.classList.add('active');
|
||||
step2.classList.add('completed');
|
||||
conn2.classList.add('active');
|
||||
step3.classList.add('active');
|
||||
// Show badge on reader tab
|
||||
const badge = document.getElementById('readerTabBadge');
|
||||
if (badge) badge.style.display = 'inline';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Helper: Switch to Editor Tab
|
||||
// ============================================
|
||||
|
||||
function switchToEditorTab() {
|
||||
const editorTab = document.getElementById('editor-tab');
|
||||
if (editorTab) {
|
||||
const tab = new bootstrap.Tab(editorTab);
|
||||
tab.show();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Helper: Start from scratch
|
||||
// ============================================
|
||||
|
||||
function startFromScratch() {
|
||||
document.getElementById('uploadSection').style.display = 'none';
|
||||
document.getElementById('editorSection').style.display = 'block';
|
||||
updateWorkflowProgress('edit');
|
||||
|
||||
// Click the editor to trigger first chapter creation
|
||||
const editor = document.getElementById('markdownEditor');
|
||||
if (editor && editorBlocks.length === 0) {
|
||||
addChapterMarker(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Loading Overlay
|
||||
// ============================================
|
||||
|
||||
function showLoader(text = 'Processing...', subtext = 'Please wait') {
|
||||
const overlay = document.getElementById('loadingOverlay');
|
||||
document.getElementById('loadingText').textContent = text;
|
||||
document.getElementById('loadingSubtext').textContent = subtext;
|
||||
overlay.classList.add('active');
|
||||
}
|
||||
|
||||
function hideLoader() {
|
||||
document.getElementById('loadingOverlay').classList.remove('active');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Voice Management
|
||||
// ============================================
|
||||
|
||||
async function loadVoices() {
|
||||
try {
|
||||
const response = await fetch('/api/voices');
|
||||
const data = await response.json();
|
||||
voices = data.voices || [];
|
||||
console.log(`📢 Loaded ${voices.length} voices`);
|
||||
} catch (error) {
|
||||
console.error('Failed to load voices:', error);
|
||||
voices = [
|
||||
{ id: 'af_heart', name: 'Heart (US Female)' },
|
||||
{ id: 'am_adam', name: 'Adam (US Male)' }
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function getVoiceOptions(selectedVoice = 'af_heart') {
|
||||
return voices.map(v =>
|
||||
`<option value="${v.id}" ${v.id === selectedVoice ? 'selected' : ''}>${v.name}</option>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Project Management
|
||||
// ============================================
|
||||
|
||||
async function saveProject() {
|
||||
const projectName = document.getElementById('projectName').value.trim();
|
||||
|
||||
if (!projectName) {
|
||||
alert('Please enter a project name');
|
||||
return;
|
||||
}
|
||||
|
||||
currentProject.name = projectName;
|
||||
const chapters = collectEditorContent();
|
||||
|
||||
if (chapters.length === 0) {
|
||||
alert('No content to save. Add some chapters and blocks first.');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoader('Saving Project...', 'Please wait');
|
||||
|
||||
try {
|
||||
let projectId = currentProject.id;
|
||||
|
||||
if (!projectId) {
|
||||
const createResponse = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: projectName })
|
||||
});
|
||||
|
||||
const createData = await createResponse.json();
|
||||
|
||||
if (createData.error) {
|
||||
const listResponse = await fetch('/api/projects');
|
||||
const listData = await listResponse.json();
|
||||
const existing = listData.projects.find(p => p.name === projectName);
|
||||
|
||||
if (existing) {
|
||||
projectId = existing.id;
|
||||
} else {
|
||||
throw new Error(createData.error);
|
||||
}
|
||||
} else {
|
||||
projectId = createData.project_id;
|
||||
}
|
||||
|
||||
currentProject.id = projectId;
|
||||
}
|
||||
|
||||
const saveResponse = await fetch(`/api/projects/${projectId}/save`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chapters })
|
||||
});
|
||||
|
||||
const saveData = await saveResponse.json();
|
||||
|
||||
if (saveData.error) {
|
||||
throw new Error(saveData.error);
|
||||
}
|
||||
|
||||
hideLoader();
|
||||
showNotification('Project saved successfully!', 'success');
|
||||
|
||||
} catch (error) {
|
||||
hideLoader();
|
||||
console.error('Save error:', error);
|
||||
alert('Failed to save project: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportProject() {
|
||||
if (!currentProject.id) {
|
||||
await saveProject();
|
||||
if (!currentProject.id) return;
|
||||
}
|
||||
|
||||
showLoader('Exporting...', 'Creating ZIP file');
|
||||
|
||||
try {
|
||||
window.location.href = `/api/export/${currentProject.id}`;
|
||||
setTimeout(() => { hideLoader(); }, 2000);
|
||||
} catch (error) {
|
||||
hideLoader();
|
||||
alert('Export failed: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function openProjectArchive() {
|
||||
showLoader('Loading projects...');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/projects');
|
||||
const data = await response.json();
|
||||
|
||||
const container = document.getElementById('projectList');
|
||||
|
||||
if (data.projects.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-folder2-open" style="font-size: 3rem;"></i>
|
||||
<p class="mt-3">No saved projects yet</p>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
container.innerHTML = data.projects.map(project => `
|
||||
<div class="project-item">
|
||||
<div class="project-info">
|
||||
<h6>${escapeHtml(project.name)}</h6>
|
||||
<div class="project-meta">
|
||||
${project.chapter_count} chapters • ${project.block_count} blocks •
|
||||
Updated ${formatDate(project.updated_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-actions">
|
||||
<button class="btn btn-sm btn-primary" onclick="loadProject(${project.id})">
|
||||
<i class="bi bi-folder2-open"></i> Load
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteProject(${project.id})">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
hideLoader();
|
||||
archiveModal.show();
|
||||
|
||||
} catch (error) {
|
||||
hideLoader();
|
||||
alert('Failed to load projects: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProject(projectId) {
|
||||
showLoader('Loading project...');
|
||||
archiveModal.hide();
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
currentProject = { id: data.id, name: data.name, chapters: data.chapters };
|
||||
document.getElementById('projectName').value = data.name;
|
||||
|
||||
// Hide upload, show editor
|
||||
document.getElementById('uploadSection').style.display = 'none';
|
||||
document.getElementById('editorSection').style.display = 'block';
|
||||
|
||||
renderProjectInEditor(data);
|
||||
|
||||
// Check if project has audio and update workflow
|
||||
let hasAudio = false;
|
||||
for (const ch of data.chapters) {
|
||||
for (const bl of ch.blocks) {
|
||||
if (bl.audio_data && bl.block_type !== 'image') {
|
||||
hasAudio = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasAudio) break;
|
||||
}
|
||||
updateWorkflowProgress(hasAudio ? 'audio-ready' : 'edit');
|
||||
|
||||
hideLoader();
|
||||
showNotification('Project loaded successfully!', 'success');
|
||||
|
||||
} catch (error) {
|
||||
hideLoader();
|
||||
alert('Failed to load project: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProject(projectId) {
|
||||
if (!confirm('Are you sure you want to delete this project? This action cannot be undone.')) return;
|
||||
|
||||
showLoader('Deleting...');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}`, { method: 'DELETE' });
|
||||
const data = await response.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
await openProjectArchive();
|
||||
showNotification('Project deleted', 'success');
|
||||
|
||||
} catch (error) {
|
||||
hideLoader();
|
||||
alert('Failed to delete project: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TTS Text Editing
|
||||
// ============================================
|
||||
|
||||
function openTtsEditor(blockId, currentContent) {
|
||||
const plainText = stripMarkdown(currentContent);
|
||||
document.getElementById('ttsTextInput').value = plainText;
|
||||
document.getElementById('ttsBlockId').value = blockId;
|
||||
ttsEditModal.show();
|
||||
}
|
||||
|
||||
function saveTtsText() {
|
||||
const blockId = document.getElementById('ttsBlockId').value;
|
||||
const ttsText = document.getElementById('ttsTextInput').value;
|
||||
|
||||
const block = document.querySelector(`[data-block-id="${blockId}"]`);
|
||||
if (block) {
|
||||
block.dataset.ttsText = ttsText;
|
||||
}
|
||||
|
||||
ttsEditModal.hide();
|
||||
showNotification('TTS text saved', 'success');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Utility Functions
|
||||
// ============================================
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function stripMarkdown(text) {
|
||||
if (!text) return '';
|
||||
|
||||
let result = text;
|
||||
result = result.replace(/^#{1,6}\s*/gm, '');
|
||||
result = result.replace(/\*\*(.+?)\*\*/g, '$1');
|
||||
result = result.replace(/\*(.+?)\*/g, '$1');
|
||||
result = result.replace(/__(.+?)__/g, '$1');
|
||||
result = result.replace(/_(.+?)_/g, '$1');
|
||||
result = result.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
|
||||
result = result.replace(/!\[([^\]]*)\]\([^)]+\)/g, '');
|
||||
result = result.replace(/^>\s*/gm, '');
|
||||
result = result.replace(/^\s*[-*+]\s+/gm, '');
|
||||
result = result.replace(/^\s*\d+\.\s+/gm, '');
|
||||
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
function showNotification(message, type = 'info') {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `alert alert-${type === 'success' ? 'success' : type === 'error' ? 'danger' : 'info'} notification-toast`;
|
||||
notification.innerHTML = `
|
||||
<i class="bi bi-${type === 'success' ? 'check-circle' : type === 'error' ? 'exclamation-circle' : 'info-circle'} me-2"></i>
|
||||
${message}
|
||||
`;
|
||||
notification.style.cssText = `
|
||||
position: fixed; top: 20px; right: 20px; z-index: 9999;
|
||||
min-width: 250px; animation: slideIn 0.3s ease;
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.animation = 'slideOut 0.3s ease';
|
||||
setTimeout(() => notification.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Authentication
|
||||
// ============================================
|
||||
|
||||
let changePasswordModal = null;
|
||||
|
||||
async function loadCurrentUser() {
|
||||
try {
|
||||
const response = await fetch('/api/auth/me');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.user) {
|
||||
const usernameEl = document.getElementById('headerUsername');
|
||||
if (usernameEl) usernameEl.textContent = data.user.username;
|
||||
|
||||
// Show admin menu item if admin
|
||||
if (data.user.role === 'admin') {
|
||||
const adminItem = document.getElementById('adminMenuItem');
|
||||
const adminDivider = document.getElementById('adminDivider');
|
||||
if (adminItem) adminItem.style.display = 'block';
|
||||
if (adminDivider) adminDivider.style.display = 'block';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load user info:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
} catch(e) { /* ignore */ }
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
function openChangePassword() {
|
||||
if (!changePasswordModal) {
|
||||
changePasswordModal = new bootstrap.Modal(document.getElementById('changePasswordModal'));
|
||||
}
|
||||
document.getElementById('currentPassword').value = '';
|
||||
document.getElementById('newPassword').value = '';
|
||||
document.getElementById('confirmPassword').value = '';
|
||||
document.getElementById('changePasswordError').style.display = 'none';
|
||||
changePasswordModal.show();
|
||||
}
|
||||
|
||||
async function submitChangePassword() {
|
||||
const currentPassword = document.getElementById('currentPassword').value;
|
||||
const newPassword = document.getElementById('newPassword').value;
|
||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||
const errorDiv = document.getElementById('changePasswordError');
|
||||
|
||||
errorDiv.style.display = 'none';
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
errorDiv.textContent = 'All fields are required';
|
||||
errorDiv.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
errorDiv.textContent = 'New passwords do not match';
|
||||
errorDiv.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 4) {
|
||||
errorDiv.textContent = 'New password must be at least 4 characters';
|
||||
errorDiv.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
errorDiv.textContent = data.error;
|
||||
errorDiv.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
changePasswordModal.hide();
|
||||
showNotification('Password changed successfully!', 'success');
|
||||
} catch(e) {
|
||||
errorDiv.textContent = 'Network error. Please try again.';
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const notifStyle = document.createElement('style');
|
||||
notifStyle.textContent = `
|
||||
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
@keyframes slideOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } }
|
||||
`;
|
||||
document.head.appendChild(notifStyle);
|
||||
215
static/js/generation.js
Normal file
215
static/js/generation.js
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Audio Generation Module
|
||||
* UPDATED: Updates workflow progress after successful generation
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// Single Block Generation
|
||||
// ============================================
|
||||
|
||||
async function generateBlockAudio(blockId) {
|
||||
const block = document.getElementById(blockId);
|
||||
if (!block) {
|
||||
console.error('Block not found:', blockId);
|
||||
return;
|
||||
}
|
||||
|
||||
const blockType = block.dataset.blockType || 'paragraph';
|
||||
|
||||
if (blockType === 'image') {
|
||||
alert('Cannot generate audio for image blocks.');
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = block.querySelector('.md-block-textarea');
|
||||
const content = textarea ? textarea.value : '';
|
||||
|
||||
if (!content.trim()) {
|
||||
alert('No text content to generate audio for.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (content.trim().startsWith(' !== -1) {
|
||||
alert('Cannot generate audio for image blocks.');
|
||||
return;
|
||||
}
|
||||
|
||||
const ttsText = (block.dataset.ttsText && block.dataset.ttsText.trim()) ? block.dataset.ttsText : content;
|
||||
|
||||
let voice = 'af_heart';
|
||||
let prevElement = block.previousElementSibling;
|
||||
while (prevElement) {
|
||||
if (prevElement.classList.contains('chapter-marker')) {
|
||||
voice = prevElement.dataset.voice || 'af_heart';
|
||||
break;
|
||||
}
|
||||
prevElement = prevElement.previousElementSibling;
|
||||
}
|
||||
|
||||
showLoader('Generating Audio...', 'Creating speech and timestamps');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: ttsText,
|
||||
voice: voice,
|
||||
block_id: null
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
const blockData = editorBlocks.find(b => b.id === blockId);
|
||||
if (blockData) {
|
||||
blockData.audio_data = data.audio_data;
|
||||
blockData.audio_format = data.audio_format;
|
||||
blockData.transcription = data.transcription;
|
||||
}
|
||||
|
||||
const indicator = block.querySelector('.audio-indicator');
|
||||
if (indicator) {
|
||||
indicator.classList.remove('no-audio');
|
||||
indicator.classList.add('has-audio');
|
||||
indicator.title = 'Audio generated';
|
||||
}
|
||||
|
||||
hideLoader();
|
||||
showNotification('Audio generated successfully!', 'success');
|
||||
|
||||
// Update workflow to show audio is ready
|
||||
updateWorkflowProgress('audio-ready');
|
||||
|
||||
} catch (error) {
|
||||
hideLoader();
|
||||
console.error('Generation error:', error);
|
||||
alert('Failed to generate audio: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Chapter Generation
|
||||
// ============================================
|
||||
|
||||
async function generateChapterAudio(chapterId) {
|
||||
const chapterMarker = document.getElementById(chapterId);
|
||||
if (!chapterMarker) {
|
||||
console.error('Chapter marker not found:', chapterId);
|
||||
return;
|
||||
}
|
||||
|
||||
const voice = chapterMarker.dataset.voice || 'af_heart';
|
||||
|
||||
const blocksToGenerate = [];
|
||||
let nextElement = chapterMarker.nextElementSibling;
|
||||
|
||||
while (nextElement && !nextElement.classList.contains('chapter-marker')) {
|
||||
if (nextElement.classList.contains('md-block')) {
|
||||
const blockType = nextElement.dataset.blockType || 'paragraph';
|
||||
|
||||
if (blockType === 'image') {
|
||||
nextElement = nextElement.nextElementSibling;
|
||||
continue;
|
||||
}
|
||||
|
||||
const textarea = nextElement.querySelector('.md-block-textarea');
|
||||
const content = textarea ? textarea.value : '';
|
||||
|
||||
if (!content.trim()) {
|
||||
nextElement = nextElement.nextElementSibling;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content.trim().startsWith(' !== -1) {
|
||||
nextElement = nextElement.nextElementSibling;
|
||||
continue;
|
||||
}
|
||||
|
||||
const ttsText = (nextElement.dataset.ttsText && nextElement.dataset.ttsText.trim())
|
||||
? nextElement.dataset.ttsText
|
||||
: content;
|
||||
|
||||
blocksToGenerate.push({
|
||||
id: nextElement.id,
|
||||
text: ttsText,
|
||||
element: nextElement
|
||||
});
|
||||
}
|
||||
nextElement = nextElement.nextElementSibling;
|
||||
}
|
||||
|
||||
if (blocksToGenerate.length === 0) {
|
||||
alert('No text blocks found in this chapter to generate audio for.');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoader(`Generating Chapter Audio...`, `Processing ${blocksToGenerate.length} blocks`);
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (let i = 0; i < blocksToGenerate.length; i++) {
|
||||
const blockInfo = blocksToGenerate[i];
|
||||
|
||||
document.getElementById('loadingSubtext').textContent =
|
||||
`Block ${i + 1} of ${blocksToGenerate.length}`;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: blockInfo.text,
|
||||
voice: voice,
|
||||
block_id: null
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
console.error(`Block ${blockInfo.id} error:`, data.error);
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const blockData = editorBlocks.find(b => b.id === blockInfo.id);
|
||||
if (blockData) {
|
||||
blockData.audio_data = data.audio_data;
|
||||
blockData.audio_format = data.audio_format;
|
||||
blockData.transcription = data.transcription;
|
||||
}
|
||||
|
||||
const indicator = blockInfo.element.querySelector('.audio-indicator');
|
||||
if (indicator) {
|
||||
indicator.classList.remove('no-audio');
|
||||
indicator.classList.add('has-audio');
|
||||
indicator.title = 'Audio generated';
|
||||
}
|
||||
|
||||
successCount++;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Block ${blockInfo.id} error:`, error);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
hideLoader();
|
||||
|
||||
if (errorCount > 0) {
|
||||
showNotification(`Generated ${successCount} blocks, ${errorCount} failed`, 'warning');
|
||||
} else {
|
||||
showNotification(`Generated audio for ${successCount} blocks!`, 'success');
|
||||
}
|
||||
|
||||
// Update workflow to show audio is ready
|
||||
if (successCount > 0) {
|
||||
updateWorkflowProgress('audio-ready');
|
||||
}
|
||||
}
|
||||
798
static/js/interactive-reader.js
Normal file
798
static/js/interactive-reader.js
Normal file
@@ -0,0 +1,798 @@
|
||||
/**
|
||||
* Interactive Reader Module - Rewritten
|
||||
* FIXED: Images from manual upload now render correctly using base64 data
|
||||
* Features:
|
||||
* - Single Start button, becomes play/pause after first click
|
||||
* - Auto-advance to next block audio
|
||||
* - Click any word to play from that point
|
||||
* - Word + Sentence highlighting with smart sync
|
||||
* - Left sidebar chapter navigation
|
||||
* - Images rendered from base64 data (both processed and uploaded)
|
||||
* - Pause/Resume works correctly
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// Reader State
|
||||
// ============================================
|
||||
|
||||
let readerInstances = [];
|
||||
let currentReaderInstance = null;
|
||||
let currentReaderIndex = -1;
|
||||
let readerStarted = false;
|
||||
let readerUICreated = false;
|
||||
|
||||
// ============================================
|
||||
// Render Reader
|
||||
// ============================================
|
||||
|
||||
function renderInteractiveReader() {
|
||||
const container = document.getElementById('readerContainer');
|
||||
const chapters = collectEditorContent();
|
||||
|
||||
let hasAudio = false;
|
||||
const chaptersWithAudio = [];
|
||||
|
||||
for (const chapter of chapters) {
|
||||
const chapterBlocks = [];
|
||||
for (const block of chapter.blocks) {
|
||||
// Match editor block by ID lookup
|
||||
const blockData = findEditorBlockForContent(block);
|
||||
|
||||
const isImageBlock = block.block_type === 'image' ||
|
||||
(block.content && block.content.trim().startsWith(');
|
||||
|
||||
chapterBlocks.push({
|
||||
...block,
|
||||
_editorData: blockData || null,
|
||||
_isImage: isImageBlock
|
||||
});
|
||||
|
||||
if (!isImageBlock && blockData && blockData.audio_data) {
|
||||
hasAudio = true;
|
||||
}
|
||||
}
|
||||
chaptersWithAudio.push({
|
||||
...chapter,
|
||||
blocks: chapterBlocks
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasAudio) {
|
||||
container.innerHTML = `
|
||||
<div class="reader-empty-state">
|
||||
<i class="bi bi-book"></i>
|
||||
<p>Generate audio to view the interactive reader</p>
|
||||
<p class="text-muted">Go to the Editor tab and click "Generate" on blocks or chapters</p>
|
||||
</div>
|
||||
`;
|
||||
removeReaderUI();
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
readerInstances = [];
|
||||
let globalBlockIndex = 0;
|
||||
|
||||
for (const chapter of chaptersWithAudio) {
|
||||
html += `<div class="reader-chapter" id="reader-chapter-${chapter.chapter_number}">`;
|
||||
html += `<h2 class="reader-chapter-title">Chapter ${chapter.chapter_number}</h2>`;
|
||||
|
||||
for (const block of chapter.blocks) {
|
||||
const blockData = block._editorData;
|
||||
const isImageBlock = block._isImage;
|
||||
|
||||
const hasBlockAudio = !isImageBlock && blockData && blockData.audio_data;
|
||||
const blockId = blockData ? blockData.id : `reader_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
|
||||
|
||||
html += `<div class="reader-block" data-block-id="${blockId}" data-reader-index="${globalBlockIndex}" data-has-audio="${!!hasBlockAudio}">`;
|
||||
|
||||
if (isImageBlock) {
|
||||
const imageHtml = buildImageHtml(block, blockData);
|
||||
html += `<div class="reader-content reader-image-block">${imageHtml}</div>`;
|
||||
} else {
|
||||
// Render before-position images from the block's images array
|
||||
const blockImages = getBlockImages(block, blockData);
|
||||
for (const img of blockImages) {
|
||||
if (img.position === 'before' && img.data) {
|
||||
html += `<div class="reader-image-block"><img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}"></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
html += `<div class="reader-content" id="reader-content-${globalBlockIndex}"></div>`;
|
||||
|
||||
// Render after-position images
|
||||
for (const img of blockImages) {
|
||||
if (img.position === 'after' && img.data) {
|
||||
html += `<div class="reader-image-block"><img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}"></div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
|
||||
readerInstances.push({
|
||||
index: globalBlockIndex,
|
||||
blockId: blockId,
|
||||
blockData: blockData,
|
||||
content: block.content,
|
||||
hasAudio: !!hasBlockAudio,
|
||||
isImage: isImageBlock,
|
||||
chapterNumber: chapter.chapter_number,
|
||||
wordSpans: [],
|
||||
wordMap: [],
|
||||
sentenceData: [],
|
||||
audio: null,
|
||||
transcription: (!isImageBlock && blockData) ? (blockData.transcription || []) : [],
|
||||
animFrameId: null,
|
||||
lastWordSpan: null,
|
||||
lastSentenceSpans: []
|
||||
});
|
||||
|
||||
globalBlockIndex++;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
for (const inst of readerInstances) {
|
||||
if (inst.isImage || !inst.content) continue;
|
||||
|
||||
const contentEl = document.getElementById(`reader-content-${inst.index}`);
|
||||
if (!contentEl) continue;
|
||||
|
||||
renderWordsIntoContainer(contentEl, inst);
|
||||
|
||||
if (inst.hasAudio && inst.transcription.length > 0) {
|
||||
runReaderSmartSync(inst);
|
||||
}
|
||||
}
|
||||
|
||||
addReaderStyles();
|
||||
setupReaderUI(chaptersWithAudio);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Image Resolution Helpers
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Find the editorBlocks entry that corresponds to a collected block.
|
||||
* Uses multiple strategies: ID match, then content match.
|
||||
*/
|
||||
function findEditorBlockForContent(block) {
|
||||
// Strategy 1: Try matching via DOM element ID
|
||||
for (const eb of editorBlocks) {
|
||||
const el = document.getElementById(eb.id);
|
||||
if (el) {
|
||||
const textarea = el.querySelector('.md-block-textarea');
|
||||
if (textarea && textarea.value === block.content) {
|
||||
return eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Match by content string directly
|
||||
for (const eb of editorBlocks) {
|
||||
if (eb.content === block.content) {
|
||||
return eb;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all images for a block from every available source.
|
||||
*/
|
||||
function getBlockImages(block, blockData) {
|
||||
// Priority 1: block.images from collectEditorContent (most reliable)
|
||||
if (block.images && block.images.length > 0) {
|
||||
const valid = block.images.filter(img => img.data && img.data.length > 0);
|
||||
if (valid.length > 0) return valid;
|
||||
}
|
||||
|
||||
// Priority 2: editorBlocks data
|
||||
if (blockData && blockData.images && blockData.images.length > 0) {
|
||||
const valid = blockData.images.filter(img => img.data && img.data.length > 0);
|
||||
if (valid.length > 0) return valid;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the HTML for an image block in the reader.
|
||||
* Resolves base64 data from multiple sources.
|
||||
*/
|
||||
function buildImageHtml(block, blockData) {
|
||||
// Source 1: block.images array (from collectEditorContent)
|
||||
if (block.images && block.images.length > 0) {
|
||||
let html = '';
|
||||
for (const img of block.images) {
|
||||
if (img.data && img.data.length > 0) {
|
||||
html += `<img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}">`;
|
||||
}
|
||||
}
|
||||
if (html) return html;
|
||||
}
|
||||
|
||||
// Source 2: editorBlocks images array
|
||||
if (blockData && blockData.images && blockData.images.length > 0) {
|
||||
let html = '';
|
||||
for (const img of blockData.images) {
|
||||
if (img.data && img.data.length > 0) {
|
||||
html += `<img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}">`;
|
||||
}
|
||||
}
|
||||
if (html) return html;
|
||||
}
|
||||
|
||||
// Source 3: Extract data URI from markdown content itself
|
||||
if (block.content) {
|
||||
const dataUriMatch = block.content.match(/!\[([^\]]*)\]\((data:image\/[^)]+)\)/);
|
||||
if (dataUriMatch) {
|
||||
return `<img src="${dataUriMatch[2]}" alt="${dataUriMatch[1] || 'Image'}">`;
|
||||
}
|
||||
}
|
||||
|
||||
// Source 4: Grab the rendered image directly from the editor DOM
|
||||
if (blockData && blockData.id) {
|
||||
const editorBlock = document.getElementById(blockData.id);
|
||||
if (editorBlock) {
|
||||
const editorImg = editorBlock.querySelector('.image-block img, .md-block-content img');
|
||||
if (editorImg && editorImg.src && editorImg.src.startsWith('data:image')) {
|
||||
return `<img src="${editorImg.src}" alt="Image">`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Source 5: Scan ALL editorBlocks for matching content to find images
|
||||
if (block.content) {
|
||||
for (const eb of editorBlocks) {
|
||||
if (eb.content === block.content && eb.images && eb.images.length > 0) {
|
||||
let html = '';
|
||||
for (const img of eb.images) {
|
||||
if (img.data && img.data.length > 0) {
|
||||
html += `<img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}">`;
|
||||
}
|
||||
}
|
||||
if (html) return html;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: placeholder
|
||||
return `<div class="reader-image-placeholder">
|
||||
<i class="bi bi-image" style="font-size:2rem;color:#94a3b8;"></i>
|
||||
<p style="color:#94a3b8;margin-top:8px;">Image not available</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Word Rendering & Sync
|
||||
// ============================================
|
||||
|
||||
function renderWordsIntoContainer(container, inst) {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = marked.parse(inst.content, { breaks: true, gfm: true });
|
||||
|
||||
inst.wordSpans = [];
|
||||
|
||||
function processNode(node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const words = node.textContent.split(/(\s+)/);
|
||||
const fragment = document.createDocumentFragment();
|
||||
words.forEach(part => {
|
||||
if (part.trim().length > 0) {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'reader-word';
|
||||
span.textContent = part;
|
||||
span.dataset.readerIndex = inst.index;
|
||||
span.dataset.wordIdx = inst.wordSpans.length;
|
||||
inst.wordSpans.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);
|
||||
}
|
||||
}
|
||||
|
||||
processNode(div);
|
||||
while (div.firstChild) container.appendChild(div.firstChild);
|
||||
}
|
||||
|
||||
function runReaderSmartSync(inst) {
|
||||
const { wordSpans, transcription } = inst;
|
||||
inst.wordMap = new Array(wordSpans.length).fill(undefined);
|
||||
let aiIdx = 0;
|
||||
|
||||
wordSpans.forEach((span, i) => {
|
||||
const textWord = span.textContent.toLowerCase().replace(/[^\w]/g, '');
|
||||
for (let off = 0; off < 5; off++) {
|
||||
if (aiIdx + off >= transcription.length) break;
|
||||
const aiWord = transcription[aiIdx + off].word.toLowerCase().replace(/[^\w]/g, '');
|
||||
if (textWord === aiWord) {
|
||||
inst.wordMap[i] = aiIdx + off;
|
||||
aiIdx += off + 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
inst.sentenceData = [];
|
||||
let buffer = [];
|
||||
let startIdx = 0;
|
||||
|
||||
wordSpans.forEach((span, i) => {
|
||||
buffer.push(span);
|
||||
if (/[.!?]["'\u201D\u2019]?$/.test(span.textContent.trim())) {
|
||||
let startT = 0, endT = 0;
|
||||
for (let k = startIdx; k <= i; k++) {
|
||||
if (inst.wordMap[k] !== undefined) {
|
||||
startT = transcription[inst.wordMap[k]].start;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let k = i; k >= startIdx; k--) {
|
||||
if (inst.wordMap[k] !== undefined) {
|
||||
endT = transcription[inst.wordMap[k]].end;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (endT > startT) {
|
||||
inst.sentenceData.push({ spans: [...buffer], startTime: startT, endTime: endT });
|
||||
}
|
||||
buffer = [];
|
||||
startIdx = i + 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (buffer.length > 0) {
|
||||
let startT = 0, endT = 0;
|
||||
for (let k = startIdx; k < wordSpans.length; k++) {
|
||||
if (inst.wordMap[k] !== undefined) {
|
||||
startT = transcription[inst.wordMap[k]].start;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let k = wordSpans.length - 1; k >= startIdx; k--) {
|
||||
if (inst.wordMap[k] !== undefined) {
|
||||
endT = transcription[inst.wordMap[k]].end;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (endT > startT) {
|
||||
inst.sentenceData.push({ spans: [...buffer], startTime: startT, endTime: endT });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Reader UI
|
||||
// ============================================
|
||||
|
||||
function setupReaderUI(chaptersWithAudio) {
|
||||
removeReaderUI();
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.id = 'reader-floating-btn';
|
||||
btn.innerHTML = `
|
||||
<span id="reader-btn-text">Start</span>
|
||||
<svg id="reader-btn-play" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="display:none;width:24px;height:24px;"><path d="M8 5v14l11-7z"/></svg>
|
||||
<svg id="reader-btn-pause" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" style="display:none;width:24px;height:24px;"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
||||
`;
|
||||
document.body.appendChild(btn);
|
||||
btn.addEventListener('click', handleReaderFloatingClick);
|
||||
|
||||
const nav = document.createElement('nav');
|
||||
nav.id = 'reader-chapter-nav';
|
||||
let navHtml = '<ul>';
|
||||
const seenChapters = new Set();
|
||||
for (const ch of chaptersWithAudio) {
|
||||
if (!seenChapters.has(ch.chapter_number)) {
|
||||
seenChapters.add(ch.chapter_number);
|
||||
navHtml += `<li><a href="#reader-chapter-${ch.chapter_number}" data-chapter="${ch.chapter_number}">${ch.chapter_number}</a></li>`;
|
||||
}
|
||||
}
|
||||
navHtml += '</ul>';
|
||||
nav.innerHTML = navHtml;
|
||||
document.body.appendChild(nav);
|
||||
|
||||
const container = document.getElementById('readerContainer');
|
||||
container.addEventListener('click', handleReaderWordClick);
|
||||
|
||||
setupReaderNavObserver();
|
||||
|
||||
readerStarted = false;
|
||||
currentReaderInstance = null;
|
||||
currentReaderIndex = -1;
|
||||
readerUICreated = true;
|
||||
|
||||
positionReaderUI();
|
||||
window.addEventListener('resize', positionReaderUI);
|
||||
window.addEventListener('scroll', positionReaderUI);
|
||||
}
|
||||
|
||||
function positionReaderUI() {
|
||||
const readerContainer = document.getElementById('readerContainer');
|
||||
const btn = document.getElementById('reader-floating-btn');
|
||||
const nav = document.getElementById('reader-chapter-nav');
|
||||
|
||||
if (!readerContainer || !btn || !nav) return;
|
||||
|
||||
const containerRect = readerContainer.getBoundingClientRect();
|
||||
|
||||
btn.style.position = 'fixed';
|
||||
btn.style.top = '80px';
|
||||
const rightPos = window.innerWidth - (containerRect.right + 8);
|
||||
btn.style.right = Math.max(rightPos, 8) + 'px';
|
||||
btn.style.left = 'auto';
|
||||
|
||||
nav.style.position = 'fixed';
|
||||
nav.style.top = '50%';
|
||||
nav.style.transform = 'translateY(-50%)';
|
||||
const navWidth = nav.offsetWidth || 52;
|
||||
const leftPos = containerRect.left - navWidth - 8;
|
||||
nav.style.left = Math.max(leftPos, 8) + 'px';
|
||||
}
|
||||
|
||||
function removeReaderUI() {
|
||||
const oldBtn = document.getElementById('reader-floating-btn');
|
||||
if (oldBtn) oldBtn.remove();
|
||||
const oldNav = document.getElementById('reader-chapter-nav');
|
||||
if (oldNav) oldNav.remove();
|
||||
readerStarted = false;
|
||||
currentReaderInstance = null;
|
||||
currentReaderIndex = -1;
|
||||
readerUICreated = false;
|
||||
|
||||
window.removeEventListener('resize', positionReaderUI);
|
||||
window.removeEventListener('scroll', positionReaderUI);
|
||||
|
||||
for (const inst of readerInstances) {
|
||||
if (inst.audio) {
|
||||
inst.audio.pause();
|
||||
inst.audio = null;
|
||||
}
|
||||
cancelAnimationFrame(inst.animFrameId);
|
||||
}
|
||||
}
|
||||
|
||||
function showReaderUI() {
|
||||
const btn = document.getElementById('reader-floating-btn');
|
||||
const nav = document.getElementById('reader-chapter-nav');
|
||||
if (btn) btn.style.display = 'flex';
|
||||
if (nav) nav.style.display = 'block';
|
||||
positionReaderUI();
|
||||
}
|
||||
|
||||
function hideReaderUI() {
|
||||
const btn = document.getElementById('reader-floating-btn');
|
||||
const nav = document.getElementById('reader-chapter-nav');
|
||||
if (btn) btn.style.display = 'none';
|
||||
if (nav) nav.style.display = 'none';
|
||||
}
|
||||
|
||||
function setupReaderNavObserver() {
|
||||
const chapters = document.querySelectorAll('.reader-chapter');
|
||||
if (chapters.length === 0) return;
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const chNum = entry.target.id.replace('reader-chapter-', '');
|
||||
const navLinks = document.querySelectorAll('#reader-chapter-nav a');
|
||||
navLinks.forEach(l => l.classList.remove('active'));
|
||||
const activeLink = document.querySelector(`#reader-chapter-nav a[data-chapter="${chNum}"]`);
|
||||
if (activeLink) activeLink.classList.add('active');
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.3 });
|
||||
|
||||
chapters.forEach(ch => observer.observe(ch));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Playback Logic
|
||||
// ============================================
|
||||
|
||||
function handleReaderFloatingClick() {
|
||||
if (!readerStarted) {
|
||||
readerStarted = true;
|
||||
updateReaderButton('playing');
|
||||
playReaderInstanceByIndex(findNextAudioIndex(-1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentReaderInstance && currentReaderInstance.audio) {
|
||||
if (currentReaderInstance.audio.paused) {
|
||||
currentReaderInstance.audio.play();
|
||||
updateReaderButton('playing');
|
||||
} else {
|
||||
currentReaderInstance.audio.pause();
|
||||
updateReaderButton('paused');
|
||||
}
|
||||
} else {
|
||||
playReaderInstanceByIndex(findNextAudioIndex(-1));
|
||||
updateReaderButton('playing');
|
||||
}
|
||||
}
|
||||
|
||||
function handleReaderWordClick(event) {
|
||||
const wordSpan = event.target.closest('.reader-word');
|
||||
if (!wordSpan) return;
|
||||
|
||||
const readerIdx = parseInt(wordSpan.dataset.readerIndex, 10);
|
||||
const wordIdx = parseInt(wordSpan.dataset.wordIdx, 10);
|
||||
const inst = readerInstances[readerIdx];
|
||||
|
||||
if (!inst || !inst.hasAudio) return;
|
||||
|
||||
const aiIdx = inst.wordMap[wordIdx];
|
||||
if (aiIdx === undefined) return;
|
||||
|
||||
const timestamp = inst.transcription[aiIdx].start;
|
||||
|
||||
if (currentReaderInstance && currentReaderInstance !== inst) {
|
||||
stopReaderInstance(currentReaderInstance);
|
||||
}
|
||||
|
||||
readerStarted = true;
|
||||
currentReaderIndex = readerIdx;
|
||||
currentReaderInstance = inst;
|
||||
|
||||
ensureAudioLoaded(inst);
|
||||
inst.audio.currentTime = timestamp;
|
||||
inst.audio.play();
|
||||
updateReaderButton('playing');
|
||||
startReaderHighlightLoop(inst);
|
||||
}
|
||||
|
||||
function findNextAudioIndex(afterIndex) {
|
||||
for (let i = afterIndex + 1; i < readerInstances.length; i++) {
|
||||
if (readerInstances[i].hasAudio) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function playReaderInstanceByIndex(index) {
|
||||
if (index < 0 || index >= readerInstances.length) {
|
||||
updateReaderButton('paused');
|
||||
currentReaderInstance = null;
|
||||
currentReaderIndex = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
const inst = readerInstances[index];
|
||||
if (!inst.hasAudio) {
|
||||
playReaderInstanceByIndex(findNextAudioIndex(index));
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentReaderInstance && currentReaderInstance !== inst) {
|
||||
stopReaderInstance(currentReaderInstance);
|
||||
}
|
||||
|
||||
currentReaderIndex = index;
|
||||
currentReaderInstance = inst;
|
||||
|
||||
ensureAudioLoaded(inst);
|
||||
inst.audio.currentTime = 0;
|
||||
inst.audio.play();
|
||||
updateReaderButton('playing');
|
||||
startReaderHighlightLoop(inst);
|
||||
|
||||
const blockEl = document.querySelector(`.reader-block[data-reader-index="${index}"]`);
|
||||
if (blockEl) {
|
||||
blockEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
function ensureAudioLoaded(inst) {
|
||||
if (!inst.audio) {
|
||||
const blockData = inst.blockData;
|
||||
if (!blockData || !blockData.audio_data) return;
|
||||
|
||||
const audioBlob = base64ToBlob(blockData.audio_data, `audio/${blockData.audio_format || 'mp3'}`);
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
inst.audio = new Audio(audioUrl);
|
||||
|
||||
inst.audio.addEventListener('ended', () => {
|
||||
stopReaderHighlightLoop(inst);
|
||||
clearReaderHighlights(inst);
|
||||
const nextIdx = findNextAudioIndex(inst.index);
|
||||
if (nextIdx >= 0) {
|
||||
playReaderInstanceByIndex(nextIdx);
|
||||
} else {
|
||||
updateReaderButton('paused');
|
||||
currentReaderInstance = null;
|
||||
currentReaderIndex = -1;
|
||||
}
|
||||
});
|
||||
|
||||
inst.audio.addEventListener('pause', () => {
|
||||
stopReaderHighlightLoop(inst);
|
||||
});
|
||||
|
||||
inst.audio.addEventListener('play', () => {
|
||||
startReaderHighlightLoop(inst);
|
||||
updateReaderButton('playing');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function stopReaderInstance(inst) {
|
||||
if (inst.audio) {
|
||||
inst.audio.pause();
|
||||
inst.audio.currentTime = 0;
|
||||
}
|
||||
stopReaderHighlightLoop(inst);
|
||||
clearReaderHighlights(inst);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Highlighting
|
||||
// ============================================
|
||||
|
||||
function startReaderHighlightLoop(inst) {
|
||||
cancelAnimationFrame(inst.animFrameId);
|
||||
|
||||
function loop() {
|
||||
if (!inst.audio || inst.audio.paused) return;
|
||||
const currentTime = inst.audio.currentTime;
|
||||
|
||||
const activeAiIndex = inst.transcription.findIndex(w => currentTime >= w.start && currentTime < w.end);
|
||||
if (activeAiIndex !== -1) {
|
||||
const activeTextIndex = inst.wordMap.findIndex(i => i === activeAiIndex);
|
||||
if (activeTextIndex !== -1) {
|
||||
const activeSpan = inst.wordSpans[activeTextIndex];
|
||||
if (activeSpan !== inst.lastWordSpan) {
|
||||
if (inst.lastWordSpan) inst.lastWordSpan.classList.remove('current-word');
|
||||
activeSpan.classList.add('current-word');
|
||||
|
||||
const rect = activeSpan.getBoundingClientRect();
|
||||
if (rect.top < window.innerHeight * 0.25 || rect.bottom > window.innerHeight * 0.75) {
|
||||
activeSpan.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
inst.lastWordSpan = activeSpan;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const activeSentence = inst.sentenceData.find(s => currentTime >= s.startTime && currentTime <= s.endTime);
|
||||
if (activeSentence && activeSentence.spans !== inst.lastSentenceSpans) {
|
||||
if (inst.lastSentenceSpans && inst.lastSentenceSpans.length) {
|
||||
inst.lastSentenceSpans.forEach(s => s.classList.remove('current-sentence-bg'));
|
||||
}
|
||||
activeSentence.spans.forEach(s => s.classList.add('current-sentence-bg'));
|
||||
inst.lastSentenceSpans = activeSentence.spans;
|
||||
}
|
||||
|
||||
inst.animFrameId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
inst.animFrameId = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
function stopReaderHighlightLoop(inst) {
|
||||
cancelAnimationFrame(inst.animFrameId);
|
||||
}
|
||||
|
||||
function clearReaderHighlights(inst) {
|
||||
if (inst.lastWordSpan) {
|
||||
inst.lastWordSpan.classList.remove('current-word');
|
||||
inst.lastWordSpan = null;
|
||||
}
|
||||
if (inst.lastSentenceSpans && inst.lastSentenceSpans.length) {
|
||||
inst.lastSentenceSpans.forEach(s => s.classList.remove('current-sentence-bg'));
|
||||
inst.lastSentenceSpans = [];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Button State
|
||||
// ============================================
|
||||
|
||||
function updateReaderButton(state) {
|
||||
const btn = document.getElementById('reader-floating-btn');
|
||||
if (!btn) return;
|
||||
|
||||
const textEl = document.getElementById('reader-btn-text');
|
||||
const playIcon = document.getElementById('reader-btn-play');
|
||||
const pauseIcon = document.getElementById('reader-btn-pause');
|
||||
|
||||
if (readerStarted) {
|
||||
if (textEl) textEl.style.display = 'none';
|
||||
btn.classList.add('active-mode');
|
||||
|
||||
if (state === 'playing') {
|
||||
playIcon.style.display = 'none';
|
||||
pauseIcon.style.display = 'block';
|
||||
} else {
|
||||
playIcon.style.display = 'block';
|
||||
pauseIcon.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Utility
|
||||
// ============================================
|
||||
|
||||
function base64ToBlob(base64, mimeType) {
|
||||
const byteCharacters = atob(base64);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
return new Blob([byteArray], { type: mimeType });
|
||||
}
|
||||
|
||||
function addReaderStyles() {
|
||||
if (document.getElementById('readerStyles')) return;
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'readerStyles';
|
||||
style.textContent = `
|
||||
.reader-chapter { margin-bottom: 48px; }
|
||||
.reader-chapter-title {
|
||||
font-family: var(--font-serif); font-size: 1.75rem; font-weight: 700;
|
||||
color: var(--text-primary); margin-bottom: 24px; padding-bottom: 12px;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
.reader-block { position: relative; margin-bottom: 16px; padding: 8px 16px; border-radius: var(--border-radius-sm); transition: background 0.2s; }
|
||||
.reader-content { font-family: var(--font-serif); font-size: 1.125rem; line-height: 1.8; }
|
||||
.reader-content p { margin-bottom: 1em; }
|
||||
.reader-content h1, .reader-content h2, .reader-content h3 { font-family: var(--font-serif); margin-top: 1.5em; margin-bottom: 0.5em; }
|
||||
.reader-image-block { text-align: center; margin: 24px 0; }
|
||||
.reader-image-block img {
|
||||
max-width: 100%; height: auto; border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1); display: block; margin: 0 auto;
|
||||
}
|
||||
.reader-image { max-width: 100%; height: auto; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); display: block; margin: 16px auto; }
|
||||
.reader-image-placeholder { text-align: center; padding: 40px; background: #f8fafc; border: 2px dashed #e2e8f0; border-radius: 12px; }
|
||||
.reader-word { cursor: pointer; padding: 1px 0; border-radius: 3px; transition: background 0.15s, color 0.15s; }
|
||||
.reader-word:hover { background: #e3f2fd; }
|
||||
.reader-word.current-word { color: #3d4e81; text-decoration: underline; text-decoration-thickness: 3px; text-underline-offset: 3px; font-weight: 700; }
|
||||
.current-sentence-bg { -webkit-box-decoration-break: clone; box-decoration-break: clone; background-color: #e0e7ff; padding: 0.1em 0.2em; margin: 0 -0.15em; border-radius: 6px; }
|
||||
#reader-floating-btn {
|
||||
position: fixed; top: 80px; right: 24px; height: 56px; min-width: 56px; padding: 0 20px;
|
||||
border-radius: 28px; background: linear-gradient(135deg, var(--primary-color) 0%, #7c3aed 100%);
|
||||
border: none; color: white; box-shadow: 0 4px 15px rgba(0,0,0,0.25);
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px; cursor: pointer;
|
||||
z-index: 1050; transition: transform 0.2s, box-shadow 0.2s;
|
||||
font-family: var(--font-sans); font-weight: 600; font-size: 1rem;
|
||||
}
|
||||
#reader-floating-btn:hover { transform: scale(1.05); box-shadow: 0 6px 20px rgba(0,0,0,0.3); }
|
||||
#reader-floating-btn:active { transform: scale(0.95); }
|
||||
#reader-floating-btn.active-mode { width: 56px; padding: 0; border-radius: 50%; }
|
||||
#reader-floating-btn.active-mode #reader-btn-text { display: none; }
|
||||
#reader-chapter-nav {
|
||||
position: fixed; top: 50%; transform: translateY(-50%);
|
||||
background: rgba(255,255,255,0.9); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||||
border-radius: 20px; padding: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
z-index: 1000; max-height: 70vh; overflow-y: auto;
|
||||
}
|
||||
#reader-chapter-nav ul { list-style: none; padding: 0; margin: 0; }
|
||||
#reader-chapter-nav a {
|
||||
display: flex; align-items: center; justify-content: center; width: 36px; height: 36px;
|
||||
margin: 4px 0; border-radius: 50%; text-decoration: none; color: var(--text-secondary);
|
||||
font-family: var(--font-sans); font-weight: 600; font-size: 0.85rem; transition: all 0.2s;
|
||||
}
|
||||
#reader-chapter-nav a:hover { background: #e0e7ff; color: var(--primary-color); }
|
||||
#reader-chapter-nav a.active { background: linear-gradient(135deg, var(--primary-color) 0%, #7c3aed 100%); color: white; transform: scale(1.1); }
|
||||
@media (max-width: 768px) {
|
||||
#reader-chapter-nav { display: none !important; }
|
||||
#reader-floating-btn { top: auto; bottom: 20px; right: 20px !important; left: auto !important; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
942
static/js/markdown-editor.js
Normal file
942
static/js/markdown-editor.js
Normal file
@@ -0,0 +1,942 @@
|
||||
/**
|
||||
* Markdown Editor Module - Notion/Obsidian Style
|
||||
* UPDATED: Removed slash command feature (redundant with new-block-line buttons)
|
||||
* UPDATED: Chapter marker no longer auto-creates empty text block
|
||||
* UPDATED: Image upload in new-block-line buttons
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// Editor State
|
||||
// ============================================
|
||||
|
||||
let editorBlocks = [];
|
||||
let activeBlockId = null;
|
||||
let isToolbarClick = false;
|
||||
|
||||
// ============================================
|
||||
// Initialization
|
||||
// ============================================
|
||||
|
||||
function initMarkdownEditor() {
|
||||
const editor = document.getElementById('markdownEditor');
|
||||
|
||||
editor.addEventListener('click', function(e) {
|
||||
if (e.target === editor || e.target.id === 'emptyEditorMessage') {
|
||||
if (editorBlocks.length === 0) {
|
||||
addChapterMarker(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', handleGlobalKeydown);
|
||||
|
||||
document.addEventListener('mousedown', function(e) {
|
||||
if (e.target.closest('.md-block-toolbar') ||
|
||||
e.target.closest('.dropdown-menu') ||
|
||||
e.target.closest('.toolbar-btn')) {
|
||||
isToolbarClick = true;
|
||||
} else {
|
||||
isToolbarClick = false;
|
||||
}
|
||||
});
|
||||
|
||||
console.log('📝 Markdown editor initialized');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// New Block Line Helper
|
||||
// ============================================
|
||||
|
||||
function createNewBlockLine() {
|
||||
const line = document.createElement('div');
|
||||
line.className = 'new-block-line';
|
||||
|
||||
const lineId = 'line_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5);
|
||||
|
||||
line.innerHTML = `
|
||||
<div class="add-line-buttons">
|
||||
<button class="add-line-btn" onclick="addBlockAtLine(this)" title="Add text block">
|
||||
<i class="bi bi-plus"></i>
|
||||
</button>
|
||||
<button class="add-line-btn image-btn" onclick="triggerImageAtLine('${lineId}')" title="Add image">
|
||||
<i class="bi bi-image"></i>
|
||||
</button>
|
||||
<button class="add-line-btn chapter-btn" onclick="addChapterAtLine(this)" title="Add chapter marker">
|
||||
<i class="bi bi-bookmark-star"></i>
|
||||
</button>
|
||||
</div>
|
||||
<input type="file" id="imageLineInput_${lineId}" accept="image/*" hidden
|
||||
onchange="handleImageAtLine(event, this)">
|
||||
`;
|
||||
return line;
|
||||
}
|
||||
|
||||
function triggerImageAtLine(lineId) {
|
||||
const fileInput = document.getElementById(`imageLineInput_${lineId}`);
|
||||
if (fileInput) {
|
||||
fileInput.click();
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageAtLine(event, inputEl) {
|
||||
const file = event.target.files[0];
|
||||
if (!file || !file.type.startsWith('image/')) return;
|
||||
|
||||
const line = inputEl.closest('.new-block-line');
|
||||
if (!line) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = function(e) {
|
||||
const base64Data = e.target.result.split(',')[1];
|
||||
const format = file.type.split('/')[1] === 'jpeg' ? 'jpeg' : file.type.split('/')[1];
|
||||
|
||||
const images = [{
|
||||
data: base64Data,
|
||||
format: format,
|
||||
alt_text: file.name,
|
||||
position: 'before'
|
||||
}];
|
||||
|
||||
const content = ``;
|
||||
|
||||
const blockId = addBlock('image', content, line, images);
|
||||
|
||||
const blockEl = document.getElementById(blockId);
|
||||
if (blockEl) {
|
||||
const contentDiv = blockEl.querySelector('.md-block-content');
|
||||
if (contentDiv) {
|
||||
contentDiv.innerHTML = `
|
||||
<div class="image-block">
|
||||
<img src="${e.target.result}" alt="${file.name}">
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
repairAllNewBlockLines();
|
||||
showNotification('Image added', 'success');
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
event.target.value = '';
|
||||
}
|
||||
|
||||
function ensureNewBlockLineAfter(element) {
|
||||
if (!element) return;
|
||||
const next = element.nextElementSibling;
|
||||
if (next && next.classList.contains('new-block-line')) {
|
||||
return;
|
||||
}
|
||||
element.after(createNewBlockLine());
|
||||
}
|
||||
|
||||
function repairAllNewBlockLines() {
|
||||
const editor = document.getElementById('markdownEditor');
|
||||
if (!editor) return;
|
||||
|
||||
const children = Array.from(editor.children);
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const el = children[i];
|
||||
if (el.classList.contains('new-block-line')) {
|
||||
const prev = el.previousElementSibling;
|
||||
if (!prev || prev.classList.contains('new-block-line')) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updatedChildren = Array.from(editor.children);
|
||||
for (let i = 0; i < updatedChildren.length; i++) {
|
||||
const el = updatedChildren[i];
|
||||
if (el.classList.contains('md-block') || el.classList.contains('chapter-marker')) {
|
||||
ensureNewBlockLineAfter(el);
|
||||
}
|
||||
}
|
||||
|
||||
const finalChildren = Array.from(editor.children);
|
||||
for (let i = 1; i < finalChildren.length; i++) {
|
||||
if (finalChildren[i].classList.contains('new-block-line') &&
|
||||
finalChildren[i-1].classList.contains('new-block-line')) {
|
||||
finalChildren[i].remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Chapter Markers
|
||||
// ============================================
|
||||
|
||||
function addChapterMarker(chapterNumber = 1, voice = 'af_heart', afterElement = null) {
|
||||
const markerId = 'chapter_' + Date.now();
|
||||
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'chapter-marker';
|
||||
marker.id = markerId;
|
||||
marker.dataset.chapterNumber = chapterNumber;
|
||||
marker.dataset.voice = voice;
|
||||
|
||||
marker.innerHTML = `
|
||||
<div class="chapter-marker-left">
|
||||
<span class="chapter-label">Chapter</span>
|
||||
<input type="number" class="form-control chapter-number-input"
|
||||
value="${chapterNumber}" min="1"
|
||||
onchange="updateChapterNumber('${markerId}', this.value)">
|
||||
<select class="form-select chapter-voice-select"
|
||||
onchange="updateChapterVoice('${markerId}', this.value)">
|
||||
${getVoiceOptions(voice)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="chapter-actions">
|
||||
<button class="btn btn-success btn-sm" onclick="generateChapterAudio('${markerId}')">
|
||||
<i class="bi bi-play-fill me-1"></i> Generate All
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm" onclick="deleteChapter('${markerId}')">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const editor = document.getElementById('markdownEditor');
|
||||
const emptyMessage = document.getElementById('emptyEditorMessage');
|
||||
|
||||
if (emptyMessage) {
|
||||
emptyMessage.style.display = 'none';
|
||||
}
|
||||
|
||||
if (afterElement) {
|
||||
afterElement.after(marker);
|
||||
} else {
|
||||
editor.appendChild(marker);
|
||||
}
|
||||
|
||||
ensureNewBlockLineAfter(marker);
|
||||
|
||||
return markerId;
|
||||
}
|
||||
|
||||
function getNextChapterNumber() {
|
||||
const chapterMarkers = document.querySelectorAll('.chapter-marker');
|
||||
let maxChapter = 0;
|
||||
chapterMarkers.forEach(m => {
|
||||
const num = parseInt(m.dataset.chapterNumber) || 0;
|
||||
if (num > maxChapter) maxChapter = num;
|
||||
});
|
||||
return maxChapter + 1;
|
||||
}
|
||||
|
||||
function updateChapterNumber(markerId, value) {
|
||||
const marker = document.getElementById(markerId);
|
||||
if (marker) {
|
||||
marker.dataset.chapterNumber = value;
|
||||
}
|
||||
}
|
||||
|
||||
function updateChapterVoice(markerId, value) {
|
||||
const marker = document.getElementById(markerId);
|
||||
if (marker) {
|
||||
marker.dataset.voice = value;
|
||||
}
|
||||
}
|
||||
|
||||
function deleteChapter(markerId) {
|
||||
const marker = document.getElementById(markerId);
|
||||
if (!marker) return;
|
||||
|
||||
const nextLine = marker.nextElementSibling;
|
||||
if (nextLine && nextLine.classList.contains('new-block-line')) {
|
||||
nextLine.remove();
|
||||
}
|
||||
|
||||
marker.remove();
|
||||
|
||||
repairAllNewBlockLines();
|
||||
checkEmptyEditor();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Block Deletion
|
||||
// ============================================
|
||||
|
||||
function deleteBlock(blockId) {
|
||||
const block = document.getElementById(blockId);
|
||||
if (!block) return;
|
||||
|
||||
if (activeBlockId === blockId) {
|
||||
activeBlockId = null;
|
||||
}
|
||||
|
||||
const nextLine = block.nextElementSibling;
|
||||
if (nextLine && nextLine.classList.contains('new-block-line')) {
|
||||
nextLine.remove();
|
||||
}
|
||||
|
||||
block.remove();
|
||||
editorBlocks = editorBlocks.filter(b => b.id !== blockId);
|
||||
|
||||
repairAllNewBlockLines();
|
||||
checkEmptyEditor();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Block Management
|
||||
// ============================================
|
||||
|
||||
function addBlock(type = 'paragraph', content = '', afterElement = null, images = []) {
|
||||
const blockId = 'block_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5);
|
||||
|
||||
const block = document.createElement('div');
|
||||
block.className = 'md-block';
|
||||
block.id = blockId;
|
||||
block.dataset.blockId = blockId;
|
||||
block.dataset.blockType = type;
|
||||
block.dataset.ttsText = '';
|
||||
|
||||
const renderedContent = renderBlockContent(type, content, blockId, images);
|
||||
const isImageBlock = (type === 'image');
|
||||
|
||||
if (isImageBlock) {
|
||||
block.innerHTML = `
|
||||
<div class="block-actions-indicator">
|
||||
<button class="action-indicator-btn delete-block-btn" onclick="deleteBlock('${blockId}')" title="Delete block">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="md-block-content">${renderedContent}</div>
|
||||
<div class="md-block-edit">
|
||||
<textarea class="md-block-textarea"
|
||||
placeholder="Start typing..."
|
||||
oninput="handleBlockInput(event, '${blockId}')"
|
||||
onkeydown="handleBlockKeydown(event, '${blockId}')"
|
||||
onblur="handleBlockBlur(event, '${blockId}')">${content}</textarea>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
block.innerHTML = `
|
||||
<div class="md-block-toolbar">
|
||||
<button class="toolbar-btn" onmousedown="event.preventDefault(); applyFormat('bold')" title="Bold">
|
||||
<i class="bi bi-type-bold"></i>
|
||||
</button>
|
||||
<button class="toolbar-btn" onmousedown="event.preventDefault(); applyFormat('italic')" title="Italic">
|
||||
<i class="bi bi-type-italic"></i>
|
||||
</button>
|
||||
<div class="toolbar-divider"></div>
|
||||
<div class="dropdown">
|
||||
<button class="toolbar-btn dropdown-toggle" data-bs-toggle="dropdown" title="Change Case" onmousedown="event.preventDefault()">
|
||||
Aa
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="#" onmousedown="event.preventDefault(); changeCase('sentence')">Sentence case</a></li>
|
||||
<li><a class="dropdown-item" href="#" onmousedown="event.preventDefault(); changeCase('lower')">lowercase</a></li>
|
||||
<li><a class="dropdown-item" href="#" onmousedown="event.preventDefault(); changeCase('upper')">UPPERCASE</a></li>
|
||||
<li><a class="dropdown-item" href="#" onmousedown="event.preventDefault(); changeCase('title')">Title Case</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="toolbar-divider"></div>
|
||||
<button class="toolbar-btn" onmousedown="event.preventDefault(); openTtsEditor('${blockId}', getBlockContent('${blockId}'))" title="Edit TTS Text">
|
||||
<i class="bi bi-mic"></i>
|
||||
</button>
|
||||
<button class="toolbar-btn" onmousedown="event.preventDefault(); generateBlockAudio('${blockId}')" title="Generate Audio">
|
||||
<i class="bi bi-soundwave"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="block-actions-indicator">
|
||||
<button class="action-indicator-btn edit-block-btn" onclick="enterEditMode('${blockId}')" title="Click to edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="action-indicator-btn delete-block-btn" onclick="deleteBlock('${blockId}')" title="Delete block">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="audio-indicator no-audio" title="No audio generated">
|
||||
<i class="bi bi-soundwave"></i>
|
||||
</div>
|
||||
|
||||
<div class="md-block-content">${renderedContent}</div>
|
||||
|
||||
<div class="md-block-edit">
|
||||
<textarea class="md-block-textarea"
|
||||
placeholder="Start typing..."
|
||||
oninput="handleBlockInput(event, '${blockId}')"
|
||||
onkeydown="handleBlockKeydown(event, '${blockId}')"
|
||||
onblur="handleBlockBlur(event, '${blockId}')">${content}</textarea>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const editor = document.getElementById('markdownEditor');
|
||||
|
||||
if (afterElement) {
|
||||
if (afterElement.classList.contains('new-block-line')) {
|
||||
afterElement.after(block);
|
||||
} else {
|
||||
const nextSibling = afterElement.nextElementSibling;
|
||||
if (nextSibling && nextSibling.classList.contains('new-block-line')) {
|
||||
nextSibling.after(block);
|
||||
} else {
|
||||
afterElement.after(block);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
editor.appendChild(block);
|
||||
}
|
||||
|
||||
editorBlocks.push({
|
||||
id: blockId,
|
||||
type: type,
|
||||
content: content,
|
||||
images: images
|
||||
});
|
||||
|
||||
if (!content && type !== 'image') {
|
||||
setTimeout(() => enterEditMode(blockId), 100);
|
||||
}
|
||||
|
||||
return blockId;
|
||||
}
|
||||
|
||||
function addBlockAtLine(button) {
|
||||
const line = button.closest('.new-block-line');
|
||||
const blockId = addBlock('paragraph', '', line);
|
||||
repairAllNewBlockLines();
|
||||
}
|
||||
|
||||
function addChapterAtLine(button) {
|
||||
const line = button.closest('.new-block-line');
|
||||
const nextChapterNum = getNextChapterNumber();
|
||||
addChapterMarker(nextChapterNum, 'af_heart', line);
|
||||
repairAllNewBlockLines();
|
||||
}
|
||||
|
||||
function renderBlockContent(type, content, blockId, images) {
|
||||
if (!content) {
|
||||
return `<span class="md-block-placeholder">Click to edit</span>`;
|
||||
}
|
||||
|
||||
if (type === 'image') {
|
||||
if (images && images.length > 0) {
|
||||
const img = images[0];
|
||||
if (img.data) {
|
||||
return `<div class="image-block">
|
||||
<img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}">
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (blockId) {
|
||||
const blockData = editorBlocks.find(b => b.id === blockId);
|
||||
if (blockData && blockData.images && blockData.images.length > 0) {
|
||||
const img = blockData.images[0];
|
||||
if (img.data) {
|
||||
return `<div class="image-block">
|
||||
<img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}">
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dataUriMatch = content.match(/!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)/);
|
||||
if (dataUriMatch) {
|
||||
return `<div class="image-block">
|
||||
<img src="${dataUriMatch[2]}" alt="${dataUriMatch[1] || 'Image'}">
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `<div class="image-block">
|
||||
<div class="image-upload-placeholder">
|
||||
<i class="bi bi-image"></i>
|
||||
<p>Click to upload an image</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
let safeContent = content;
|
||||
if (content.length > 10000 && content.includes('base64,')) {
|
||||
safeContent = content.replace(
|
||||
/!\[([^\]]*)\]\(data:image\/[^;]+;base64,[^)]+\)/g,
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const html = marked.parse(safeContent, { breaks: true, gfm: true });
|
||||
return html;
|
||||
} catch (e) {
|
||||
console.warn('marked.js parse error, rendering as plain text:', e.message);
|
||||
return `<p>${escapeHtml(content)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function getBlockContent(blockId) {
|
||||
const block = document.getElementById(blockId);
|
||||
if (block) {
|
||||
const textarea = block.querySelector('.md-block-textarea');
|
||||
return textarea ? textarea.value : '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Edit Mode
|
||||
// ============================================
|
||||
|
||||
function enterEditMode(blockId) {
|
||||
if (activeBlockId && activeBlockId !== blockId) {
|
||||
exitEditMode(activeBlockId);
|
||||
}
|
||||
|
||||
const block = document.getElementById(blockId);
|
||||
if (!block) return;
|
||||
|
||||
block.classList.add('editing');
|
||||
activeBlockId = blockId;
|
||||
|
||||
const textarea = block.querySelector('.md-block-textarea');
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
||||
autoResizeTextarea(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
function handleBlockBlur(event, blockId) {
|
||||
if (isToolbarClick) {
|
||||
isToolbarClick = false;
|
||||
setTimeout(() => {
|
||||
const block = document.getElementById(blockId);
|
||||
if (block && block.classList.contains('editing')) {
|
||||
const textarea = block.querySelector('.md-block-textarea');
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
}
|
||||
}
|
||||
}, 10);
|
||||
return;
|
||||
}
|
||||
|
||||
exitEditMode(blockId);
|
||||
}
|
||||
|
||||
function exitEditMode(blockId) {
|
||||
const block = document.getElementById(blockId);
|
||||
if (!block) return;
|
||||
|
||||
block.classList.remove('editing');
|
||||
|
||||
const textarea = block.querySelector('.md-block-textarea');
|
||||
const contentDiv = block.querySelector('.md-block-content');
|
||||
|
||||
if (textarea && contentDiv) {
|
||||
const content = textarea.value.trim();
|
||||
const blockType = block.dataset.blockType || 'paragraph';
|
||||
|
||||
const blockData = editorBlocks.find(b => b.id === blockId);
|
||||
const images = blockData ? (blockData.images || []) : [];
|
||||
|
||||
if (content === '') {
|
||||
contentDiv.innerHTML = renderBlockContent(blockType, '', blockId, images);
|
||||
|
||||
if (blockData) {
|
||||
blockData.content = '';
|
||||
}
|
||||
} else {
|
||||
contentDiv.innerHTML = renderBlockContent(blockType, content, blockId, images);
|
||||
|
||||
if (blockData) {
|
||||
blockData.content = content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeBlockId === blockId) {
|
||||
activeBlockId = null;
|
||||
}
|
||||
|
||||
repairAllNewBlockLines();
|
||||
}
|
||||
|
||||
function autoResizeTextarea(textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.max(textarea.scrollHeight, 60) + 'px';
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Input Handling
|
||||
// ============================================
|
||||
|
||||
function handleBlockInput(event, blockId) {
|
||||
const textarea = event.target;
|
||||
autoResizeTextarea(textarea);
|
||||
}
|
||||
|
||||
function handleBlockKeydown(event, blockId) {
|
||||
const textarea = event.target;
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
textarea.blur();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
|
||||
const block = document.getElementById(blockId);
|
||||
|
||||
exitEditMode(blockId);
|
||||
|
||||
addBlock('paragraph', '', block);
|
||||
|
||||
repairAllNewBlockLines();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Backspace' && textarea.value === '') {
|
||||
event.preventDefault();
|
||||
|
||||
const block = document.getElementById(blockId);
|
||||
let prevBlock = block.previousElementSibling;
|
||||
|
||||
while (prevBlock && !prevBlock.classList.contains('md-block')) {
|
||||
prevBlock = prevBlock.previousElementSibling;
|
||||
}
|
||||
|
||||
const nextLine = block.nextElementSibling;
|
||||
if (nextLine && nextLine.classList.contains('new-block-line')) {
|
||||
nextLine.remove();
|
||||
}
|
||||
|
||||
block.remove();
|
||||
editorBlocks = editorBlocks.filter(b => b.id !== blockId);
|
||||
activeBlockId = null;
|
||||
|
||||
repairAllNewBlockLines();
|
||||
|
||||
if (prevBlock) {
|
||||
enterEditMode(prevBlock.id);
|
||||
}
|
||||
|
||||
checkEmptyEditor();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function handleGlobalKeydown(event) {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
||||
event.preventDefault();
|
||||
saveProject();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Text Formatting
|
||||
// ============================================
|
||||
|
||||
function applyFormat(format) {
|
||||
if (!activeBlockId) return;
|
||||
|
||||
const block = document.getElementById(activeBlockId);
|
||||
const textarea = block.querySelector('.md-block-textarea');
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const selectedText = textarea.value.substring(start, end);
|
||||
|
||||
if (start === end) return;
|
||||
|
||||
let wrapper = '';
|
||||
switch (format) {
|
||||
case 'bold': wrapper = '**'; break;
|
||||
case 'italic': wrapper = '*'; break;
|
||||
}
|
||||
|
||||
const newText = textarea.value.substring(0, start) +
|
||||
wrapper + selectedText + wrapper +
|
||||
textarea.value.substring(end);
|
||||
|
||||
textarea.value = newText;
|
||||
textarea.setSelectionRange(start + wrapper.length, end + wrapper.length);
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
function changeCase(caseType) {
|
||||
if (!activeBlockId) return;
|
||||
|
||||
const block = document.getElementById(activeBlockId);
|
||||
const textarea = block.querySelector('.md-block-textarea');
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
let selectedText = textarea.value.substring(start, end);
|
||||
|
||||
if (start === end) return;
|
||||
|
||||
switch (caseType) {
|
||||
case 'lower': selectedText = selectedText.toLowerCase(); break;
|
||||
case 'upper': selectedText = selectedText.toUpperCase(); break;
|
||||
case 'sentence': selectedText = selectedText.toLowerCase().replace(/(^\w|\.\s+\w)/g, c => c.toUpperCase()); break;
|
||||
case 'title': selectedText = selectedText.toLowerCase().replace(/\b\w/g, c => c.toUpperCase()); break;
|
||||
}
|
||||
|
||||
textarea.value = textarea.value.substring(0, start) + selectedText + textarea.value.substring(end);
|
||||
textarea.setSelectionRange(start, start + selectedText.length);
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Image Block Handling
|
||||
// ============================================
|
||||
|
||||
function convertToImageBlock(blockId) {
|
||||
const block = document.getElementById(blockId);
|
||||
if (!block) return;
|
||||
|
||||
block.dataset.blockType = 'image';
|
||||
|
||||
const actionsIndicator = block.querySelector('.block-actions-indicator');
|
||||
if (actionsIndicator) {
|
||||
const editBtn = actionsIndicator.querySelector('.edit-block-btn');
|
||||
if (editBtn) editBtn.remove();
|
||||
}
|
||||
|
||||
const audioIndicator = block.querySelector('.audio-indicator');
|
||||
if (audioIndicator) audioIndicator.remove();
|
||||
const toolbar = block.querySelector('.md-block-toolbar');
|
||||
if (toolbar) toolbar.remove();
|
||||
|
||||
const contentDiv = block.querySelector('.md-block-content');
|
||||
contentDiv.innerHTML = `
|
||||
<div class="image-block" onclick="triggerImageUpload('${blockId}')">
|
||||
<input type="file" id="imageInput_${blockId}" accept="image/*" hidden
|
||||
onchange="handleImageUpload(event, '${blockId}')">
|
||||
<div class="image-upload-placeholder">
|
||||
<i class="bi bi-image"></i>
|
||||
<p>Click to upload or drag & drop an image</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const imageBlock = contentDiv.querySelector('.image-block');
|
||||
|
||||
imageBlock.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
imageBlock.classList.add('drag-over');
|
||||
});
|
||||
|
||||
imageBlock.addEventListener('dragleave', () => {
|
||||
imageBlock.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
imageBlock.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
imageBlock.classList.remove('drag-over');
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0 && files[0].type.startsWith('image/')) {
|
||||
processImageFile(files[0], blockId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function triggerImageUpload(blockId) {
|
||||
document.getElementById(`imageInput_${blockId}`).click();
|
||||
}
|
||||
|
||||
function handleImageUpload(event, blockId) {
|
||||
const file = event.target.files[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
processImageFile(file, blockId);
|
||||
}
|
||||
}
|
||||
|
||||
function processImageFile(file, blockId) {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = function(e) {
|
||||
const base64Data = e.target.result.split(',')[1];
|
||||
const format = file.type.split('/')[1];
|
||||
|
||||
const block = document.getElementById(blockId);
|
||||
const textarea = block.querySelector('.md-block-textarea');
|
||||
const contentDiv = block.querySelector('.md-block-content');
|
||||
|
||||
textarea.value = ``;
|
||||
|
||||
contentDiv.innerHTML = `
|
||||
<div class="image-block">
|
||||
<img src="${e.target.result}" alt="${file.name}">
|
||||
</div>
|
||||
`;
|
||||
|
||||
const blockData = editorBlocks.find(b => b.id === blockId);
|
||||
if (blockData) {
|
||||
blockData.content = textarea.value;
|
||||
blockData.images = [{
|
||||
data: base64Data,
|
||||
format: format,
|
||||
alt_text: file.name,
|
||||
position: 'before'
|
||||
}];
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Utility Functions
|
||||
// ============================================
|
||||
|
||||
function checkEmptyEditor() {
|
||||
const editor = document.getElementById('markdownEditor');
|
||||
const blocks = editor.querySelectorAll('.md-block, .chapter-marker');
|
||||
const emptyMessage = document.getElementById('emptyEditorMessage');
|
||||
|
||||
if (blocks.length === 0) {
|
||||
editor.querySelectorAll('.new-block-line').forEach(line => line.remove());
|
||||
|
||||
if (emptyMessage) {
|
||||
emptyMessage.style.display = 'block';
|
||||
}
|
||||
} else {
|
||||
if (emptyMessage) {
|
||||
emptyMessage.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Content Collection
|
||||
// ============================================
|
||||
|
||||
function collectEditorContent() {
|
||||
const editor = document.getElementById('markdownEditor');
|
||||
const chapters = [];
|
||||
let currentChapter = null;
|
||||
let blockOrder = 0;
|
||||
|
||||
const elements = editor.children;
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const el = elements[i];
|
||||
|
||||
if (el.classList.contains('chapter-marker')) {
|
||||
if (currentChapter) {
|
||||
chapters.push(currentChapter);
|
||||
}
|
||||
|
||||
currentChapter = {
|
||||
chapter_number: parseInt(el.dataset.chapterNumber) || 1,
|
||||
voice: el.dataset.voice || 'af_heart',
|
||||
blocks: []
|
||||
};
|
||||
blockOrder = 0;
|
||||
|
||||
} else if (el.classList.contains('md-block')) {
|
||||
if (!currentChapter) {
|
||||
currentChapter = {
|
||||
chapter_number: 1,
|
||||
voice: 'af_heart',
|
||||
blocks: []
|
||||
};
|
||||
}
|
||||
|
||||
blockOrder++;
|
||||
|
||||
const textarea = el.querySelector('.md-block-textarea');
|
||||
const content = textarea ? textarea.value : '';
|
||||
const blockType = el.dataset.blockType || 'paragraph';
|
||||
const blockData = editorBlocks.find(b => b.id === el.id);
|
||||
const hasImages = blockData && blockData.images && blockData.images.length > 0;
|
||||
|
||||
if (content.trim() || (blockType === 'image' && hasImages)) {
|
||||
currentChapter.blocks.push({
|
||||
block_order: blockOrder,
|
||||
block_type: blockType,
|
||||
content: content,
|
||||
tts_text: el.dataset.ttsText || '',
|
||||
audio_data: blockData?.audio_data || '',
|
||||
audio_format: blockData?.audio_format || 'mp3',
|
||||
transcription: blockData?.transcription || [],
|
||||
images: blockData?.images || []
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentChapter && currentChapter.blocks.length > 0) {
|
||||
chapters.push(currentChapter);
|
||||
}
|
||||
|
||||
return chapters;
|
||||
}
|
||||
|
||||
function renderProjectInEditor(projectData) {
|
||||
const editor = document.getElementById('markdownEditor');
|
||||
editor.innerHTML = '';
|
||||
editorBlocks = [];
|
||||
|
||||
const emptyMessage = document.getElementById('emptyEditorMessage');
|
||||
if (emptyMessage) {
|
||||
emptyMessage.style.display = 'none';
|
||||
}
|
||||
|
||||
for (const chapter of projectData.chapters) {
|
||||
addChapterMarker(chapter.chapter_number, chapter.voice);
|
||||
|
||||
for (const block of chapter.blocks) {
|
||||
const lastChild = editor.lastElementChild;
|
||||
|
||||
const blockId = addBlock(
|
||||
block.block_type,
|
||||
block.content,
|
||||
lastChild,
|
||||
block.images || []
|
||||
);
|
||||
|
||||
const blockData = editorBlocks.find(b => b.id === blockId);
|
||||
if (blockData) {
|
||||
blockData.audio_data = block.audio_data;
|
||||
blockData.audio_format = block.audio_format;
|
||||
blockData.transcription = block.transcription;
|
||||
blockData.tts_text = block.tts_text;
|
||||
}
|
||||
|
||||
const blockEl = document.getElementById(blockId);
|
||||
if (blockEl && block.tts_text) {
|
||||
blockEl.dataset.ttsText = block.tts_text;
|
||||
}
|
||||
|
||||
if (block.block_type === 'image' && block.images && block.images.length > 0) {
|
||||
const img = block.images[0];
|
||||
if (img.data) {
|
||||
const contentDiv = blockEl.querySelector('.md-block-content');
|
||||
if (contentDiv) {
|
||||
contentDiv.innerHTML = `<div class="image-block">
|
||||
<img src="data:image/${img.format || 'png'};base64,${img.data}" alt="${img.alt_text || 'Image'}">
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (block.audio_data && block.block_type !== 'image') {
|
||||
const indicator = blockEl.querySelector('.audio-indicator');
|
||||
if (indicator) {
|
||||
indicator.classList.remove('no-audio');
|
||||
indicator.classList.add('has-audio');
|
||||
indicator.title = 'Audio generated';
|
||||
}
|
||||
}
|
||||
|
||||
ensureNewBlockLineAfter(blockEl);
|
||||
}
|
||||
}
|
||||
|
||||
repairAllNewBlockLines();
|
||||
}
|
||||
228
static/js/pdf-handler.js
Normal file
228
static/js/pdf-handler.js
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Document Handler Module (PDF, DOCX, DOC)
|
||||
* UPDATED: Updates workflow progress after document load
|
||||
*/
|
||||
|
||||
function initPdfHandler() {
|
||||
const uploadZone = document.getElementById('uploadZone');
|
||||
const docInput = document.getElementById('docInput');
|
||||
|
||||
if (!uploadZone || !docInput) return;
|
||||
|
||||
uploadZone.addEventListener('click', (e) => {
|
||||
if (e.target.closest('button')) return;
|
||||
docInput.click();
|
||||
});
|
||||
|
||||
docInput.addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
handleDocumentFile(file);
|
||||
}
|
||||
});
|
||||
|
||||
uploadZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
uploadZone.classList.add('drag-over');
|
||||
});
|
||||
|
||||
uploadZone.addEventListener('dragleave', () => {
|
||||
uploadZone.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
uploadZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
uploadZone.classList.remove('drag-over');
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
const file = files[0];
|
||||
const name = file.name.toLowerCase();
|
||||
|
||||
if (name.endsWith('.pdf') || name.endsWith('.docx') || name.endsWith('.doc')) {
|
||||
handleDocumentFile(file);
|
||||
} else {
|
||||
alert('Please drop a valid PDF, DOCX, or DOC file.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('📄 Document handler initialized (PDF, DOCX, DOC)');
|
||||
}
|
||||
|
||||
function handleDocumentFile(file) {
|
||||
const name = file.name.toLowerCase();
|
||||
|
||||
if (name.endsWith('.pdf')) {
|
||||
handlePdfFile(file);
|
||||
} else if (name.endsWith('.docx') || name.endsWith('.doc')) {
|
||||
handleWordFile(file);
|
||||
} else {
|
||||
alert('Unsupported file type. Please upload a PDF, DOCX, or DOC file.');
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePdfFile(file) {
|
||||
if (!file.name.toLowerCase().endsWith('.pdf')) {
|
||||
alert('Please select a valid PDF file.');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoader('Processing PDF...', `Extracting content from ${file.name}`);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/upload-pdf', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
console.log(`✅ PDF processed: ${data.page_count} pages, ${data.blocks.length} blocks`);
|
||||
|
||||
const projectName = file.name.replace('.pdf', '');
|
||||
document.getElementById('projectName').value = projectName;
|
||||
currentProject.name = projectName;
|
||||
|
||||
renderDocumentBlocks(data.blocks);
|
||||
|
||||
document.getElementById('uploadSection').style.display = 'none';
|
||||
document.getElementById('editorSection').style.display = 'block';
|
||||
|
||||
hideLoader();
|
||||
showNotification(`PDF processed: ${data.blocks.length} blocks extracted`, 'success');
|
||||
updateWorkflowProgress('edit');
|
||||
|
||||
} catch (error) {
|
||||
hideLoader();
|
||||
console.error('PDF processing error:', error);
|
||||
alert('Failed to process PDF: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWordFile(file) {
|
||||
const name = file.name.toLowerCase();
|
||||
if (!name.endsWith('.docx') && !name.endsWith('.doc')) {
|
||||
alert('Please select a valid DOCX or DOC file.');
|
||||
return;
|
||||
}
|
||||
|
||||
const fileType = name.endsWith('.docx') ? 'DOCX' : 'DOC';
|
||||
showLoader(`Processing ${fileType}...`, `Extracting content from ${file.name}`);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/upload-docx', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
console.log(`✅ ${fileType} processed: ${data.blocks.length} blocks`);
|
||||
|
||||
const projectName = file.name.replace(/\.(docx|doc)$/i, '');
|
||||
document.getElementById('projectName').value = projectName;
|
||||
currentProject.name = projectName;
|
||||
|
||||
renderDocumentBlocks(data.blocks);
|
||||
|
||||
document.getElementById('uploadSection').style.display = 'none';
|
||||
document.getElementById('editorSection').style.display = 'block';
|
||||
|
||||
hideLoader();
|
||||
showNotification(`${fileType} processed: ${data.blocks.length} blocks extracted`, 'success');
|
||||
updateWorkflowProgress('edit');
|
||||
|
||||
} catch (error) {
|
||||
hideLoader();
|
||||
console.error(`${fileType} processing error:`, error);
|
||||
alert(`Failed to process ${fileType}: ` + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDocumentBlocks(blocks) {
|
||||
const editor = document.getElementById('markdownEditor');
|
||||
editor.innerHTML = '';
|
||||
editorBlocks = [];
|
||||
|
||||
const emptyMessage = document.getElementById('emptyEditorMessage');
|
||||
if (emptyMessage) {
|
||||
emptyMessage.style.display = 'none';
|
||||
}
|
||||
|
||||
addChapterMarker(1);
|
||||
|
||||
for (const block of blocks) {
|
||||
let type = 'paragraph';
|
||||
let content = block.content || '';
|
||||
|
||||
if (block.type === 'image') {
|
||||
type = 'image';
|
||||
} else if (block.type === 'heading1' || content.startsWith('# ')) {
|
||||
type = 'heading1';
|
||||
} else if (block.type === 'heading2' || content.startsWith('## ')) {
|
||||
type = 'heading2';
|
||||
} else if (block.type === 'heading3' || content.startsWith('### ')) {
|
||||
type = 'heading3';
|
||||
} else if (block.type === 'list_item' || content.startsWith('- ')) {
|
||||
type = 'bulletList';
|
||||
} else if (block.type === 'quote' || content.startsWith('> ')) {
|
||||
type = 'quote';
|
||||
} else if (block.type === 'table') {
|
||||
type = 'table';
|
||||
}
|
||||
|
||||
let images = [];
|
||||
if (block.type === 'image' && block.data) {
|
||||
images = [{
|
||||
data: block.data,
|
||||
format: block.format || 'png',
|
||||
alt_text: 'Document Image',
|
||||
position: 'before'
|
||||
}];
|
||||
content = ``;
|
||||
}
|
||||
|
||||
const lastChild = editor.lastElementChild;
|
||||
const blockId = addBlock(type, content, lastChild, images);
|
||||
|
||||
if (block.type === 'image' && block.data) {
|
||||
const blockEl = document.getElementById(blockId);
|
||||
if (blockEl) {
|
||||
const contentDiv = blockEl.querySelector('.md-block-content');
|
||||
if (contentDiv) {
|
||||
contentDiv.innerHTML = `
|
||||
<div class="image-block">
|
||||
<img src="data:image/${block.format || 'png'};base64,${block.data}" alt="Document Image">
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const blockEl = document.getElementById(blockId);
|
||||
if (blockEl) {
|
||||
ensureNewBlockLineAfter(blockEl);
|
||||
}
|
||||
}
|
||||
|
||||
repairAllNewBlockLines();
|
||||
}
|
||||
|
||||
function renderPdfBlocks(blocks) {
|
||||
renderDocumentBlocks(blocks);
|
||||
}
|
||||
679
templates/admin.html
Normal file
679
templates/admin.html
Normal file
@@ -0,0 +1,679 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin Dashboard - Audiobook Maker Pro</title>
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #4f46e5;
|
||||
--primary-hover: #4338ca;
|
||||
--success-color: #10b981;
|
||||
--danger-color: #ef4444;
|
||||
--warning-color: #f59e0b;
|
||||
--bg-primary: #f8fafc;
|
||||
--text-primary: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--text-muted: #94a3b8;
|
||||
--border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, #7c3aed 100%);
|
||||
color: white;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.admin-header-actions .btn {
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-header-light {
|
||||
background: rgba(255,255,255,0.15);
|
||||
border: 1.5px solid rgba(255,255,255,0.4);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-header-light:hover {
|
||||
background: rgba(255,255,255,0.25);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.admin-container {
|
||||
max-width: 900px;
|
||||
margin: 32px auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-card-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.admin-card-header h2 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-count-badge {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.user-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.user-table th {
|
||||
background: var(--bg-primary);
|
||||
padding: 12px 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.user-table td {
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
font-size: 0.88rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.user-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.user-table tr:hover td {
|
||||
background: #fafbfd;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.role-badge.admin {
|
||||
background: #ede9fe;
|
||||
color: #5b21b6;
|
||||
}
|
||||
|
||||
.role-badge.user {
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge .status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-badge.active .status-dot { background: var(--success-color); }
|
||||
.status-badge.active { color: #065f46; }
|
||||
.status-badge.disabled .status-dot { background: var(--danger-color); }
|
||||
.status-badge.disabled { color: #991b1b; }
|
||||
|
||||
.user-actions .btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 0.78rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.date-text {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Modal tweaks */
|
||||
.modal-content {
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.modal-body { padding: 24px; }
|
||||
.modal-footer { border-top: 1px solid var(--border-color); padding: 16px 24px; }
|
||||
|
||||
.form-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-control, .form-select {
|
||||
padding: 10px 14px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-control:focus, .form-select:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(79,70,229,0.1);
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.admin-card-header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.user-table {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.user-table th, .user-table td {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
<header class="admin-header">
|
||||
<h1>
|
||||
<i class="bi bi-shield-lock"></i>
|
||||
Admin Dashboard
|
||||
</h1>
|
||||
<div class="admin-header-actions">
|
||||
<span class="text-white-50" id="currentUserLabel"></span>
|
||||
<a href="/" class="btn btn-header-light btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i> Back to App
|
||||
</a>
|
||||
<button class="btn btn-header-light btn-sm" onclick="handleLogout()">
|
||||
<i class="bi bi-box-arrow-right me-1"></i> Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="admin-container">
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header">
|
||||
<h2>
|
||||
<i class="bi bi-people"></i>
|
||||
User Management
|
||||
<span class="user-count-badge" id="userCountBadge">0 users</span>
|
||||
</h2>
|
||||
<button class="btn btn-primary btn-sm" onclick="openCreateModal()">
|
||||
<i class="bi bi-person-plus me-1"></i> Create User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="userTableContainer">
|
||||
<div class="empty-state">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
<p class="mt-2">Loading users...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<!-- Create/Edit User Modal -->
|
||||
<div class="modal fade" id="userModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="userModalTitle">
|
||||
<i class="bi bi-person-plus me-2"></i>Create New User
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="editUserId">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="modalUsername" class="form-label">Username</label>
|
||||
<input type="text" class="form-control" id="modalUsername"
|
||||
placeholder="Enter username" minlength="3" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="modalPassword" class="form-label">
|
||||
Password
|
||||
<span class="text-muted fw-normal" id="passwordHint" style="display:none;">(leave blank to keep current)</span>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="modalPassword"
|
||||
placeholder="Enter password" minlength="4">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="modalRole" class="form-label">Role</label>
|
||||
<select class="form-select" id="modalRole">
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" id="activeField" style="display:none;">
|
||||
<label class="form-label">Status</label>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="modalActive" checked>
|
||||
<label class="form-check-label" for="modalActive">Account Active</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-danger" id="modalError" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="modalSaveBtn" onclick="saveUser()">
|
||||
<i class="bi bi-check-lg me-1"></i> Create User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-exclamation-triangle text-danger me-2"></i>Delete User</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong id="deleteUserName"></strong>?</p>
|
||||
<p class="text-muted small mb-0">This action cannot be undone.</p>
|
||||
<input type="hidden" id="deleteUserId">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick="confirmDelete()">
|
||||
<i class="bi bi-trash me-1"></i> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
let userModal, deleteModal;
|
||||
let currentUsers = [];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
userModal = new bootstrap.Modal(document.getElementById('userModal'));
|
||||
deleteModal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
||||
|
||||
loadCurrentUser();
|
||||
loadUsers();
|
||||
});
|
||||
|
||||
async function loadCurrentUser() {
|
||||
try {
|
||||
const resp = await fetch('/api/auth/me');
|
||||
const data = await resp.json();
|
||||
if (data.user) {
|
||||
document.getElementById('currentUserLabel').textContent =
|
||||
`Logged in as ${data.user.username}`;
|
||||
}
|
||||
} catch(e) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const resp = await fetch('/api/admin/users');
|
||||
const data = await resp.json();
|
||||
currentUsers = data.users || [];
|
||||
renderUserTable(currentUsers);
|
||||
} catch(e) {
|
||||
document.getElementById('userTableContainer').innerHTML =
|
||||
'<div class="empty-state"><i class="bi bi-exclamation-circle"></i><p>Failed to load users</p></div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderUserTable(users) {
|
||||
const container = document.getElementById('userTableContainer');
|
||||
const badge = document.getElementById('userCountBadge');
|
||||
badge.textContent = `${users.length} user${users.length !== 1 ? 's' : ''}`;
|
||||
|
||||
if (users.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state"><i class="bi bi-people"></i><p>No users found</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `<table class="user-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Last Login</th>
|
||||
<th>Created</th>
|
||||
<th style="text-align:right;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>`;
|
||||
|
||||
for (const user of users) {
|
||||
const statusClass = user.is_active ? 'active' : 'disabled';
|
||||
const statusText = user.is_active ? 'Active' : 'Disabled';
|
||||
const lastLogin = user.last_login ? formatDate(user.last_login) : '<span class="text-muted">Never</span>';
|
||||
|
||||
html += `<tr>
|
||||
<td>
|
||||
<strong>${escapeHtml(user.username)}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<span class="role-badge ${user.role}">
|
||||
<i class="bi bi-${user.role === 'admin' ? 'shield-check' : 'person'}"></i>
|
||||
${user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge ${statusClass}">
|
||||
<span class="status-dot"></span>
|
||||
${statusText}
|
||||
</span>
|
||||
</td>
|
||||
<td class="date-text">${lastLogin}</td>
|
||||
<td class="date-text">${formatDate(user.created_at)}</td>
|
||||
<td style="text-align:right;">
|
||||
<div class="user-actions d-flex gap-1 justify-content-end">
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="openEditModal(${user.id})" title="Edit user">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm" onclick="openDeleteModal(${user.id}, '${escapeHtml(user.username)}')" title="Delete user">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// --- Create / Edit ---
|
||||
|
||||
function openCreateModal() {
|
||||
document.getElementById('userModalTitle').innerHTML = '<i class="bi bi-person-plus me-2"></i>Create New User';
|
||||
document.getElementById('editUserId').value = '';
|
||||
document.getElementById('modalUsername').value = '';
|
||||
document.getElementById('modalPassword').value = '';
|
||||
document.getElementById('modalPassword').required = true;
|
||||
document.getElementById('modalPassword').placeholder = 'Enter password';
|
||||
document.getElementById('passwordHint').style.display = 'none';
|
||||
document.getElementById('modalRole').value = 'user';
|
||||
document.getElementById('modalActive').checked = true;
|
||||
document.getElementById('activeField').style.display = 'none';
|
||||
document.getElementById('modalSaveBtn').innerHTML = '<i class="bi bi-check-lg me-1"></i> Create User';
|
||||
document.getElementById('modalError').style.display = 'none';
|
||||
userModal.show();
|
||||
}
|
||||
|
||||
function openEditModal(userId) {
|
||||
const user = currentUsers.find(u => u.id === userId);
|
||||
if (!user) return;
|
||||
|
||||
document.getElementById('userModalTitle').innerHTML = '<i class="bi bi-pencil me-2"></i>Edit User';
|
||||
document.getElementById('editUserId').value = userId;
|
||||
document.getElementById('modalUsername').value = user.username;
|
||||
document.getElementById('modalPassword').value = '';
|
||||
document.getElementById('modalPassword').required = false;
|
||||
document.getElementById('modalPassword').placeholder = 'Leave blank to keep current';
|
||||
document.getElementById('passwordHint').style.display = 'inline';
|
||||
document.getElementById('modalRole').value = user.role;
|
||||
document.getElementById('modalActive').checked = user.is_active;
|
||||
document.getElementById('activeField').style.display = 'block';
|
||||
document.getElementById('modalSaveBtn').innerHTML = '<i class="bi bi-check-lg me-1"></i> Save Changes';
|
||||
document.getElementById('modalError').style.display = 'none';
|
||||
userModal.show();
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
const userId = document.getElementById('editUserId').value;
|
||||
const username = document.getElementById('modalUsername').value.trim();
|
||||
const password = document.getElementById('modalPassword').value;
|
||||
const role = document.getElementById('modalRole').value;
|
||||
const isActive = document.getElementById('modalActive').checked;
|
||||
const errorDiv = document.getElementById('modalError');
|
||||
|
||||
errorDiv.style.display = 'none';
|
||||
|
||||
if (!username || username.length < 3) {
|
||||
errorDiv.textContent = 'Username must be at least 3 characters';
|
||||
errorDiv.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!userId && (!password || password.length < 4)) {
|
||||
errorDiv.textContent = 'Password must be at least 4 characters';
|
||||
errorDiv.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
if (userId && password && password.length < 4) {
|
||||
errorDiv.textContent = 'Password must be at least 4 characters';
|
||||
errorDiv.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let url, method, body;
|
||||
|
||||
if (userId) {
|
||||
url = `/api/admin/users/${userId}`;
|
||||
method = 'PUT';
|
||||
body = { username, role, is_active: isActive };
|
||||
if (password) body.password = password;
|
||||
} else {
|
||||
url = '/api/admin/users';
|
||||
method = 'POST';
|
||||
body = { username, password, role };
|
||||
}
|
||||
|
||||
const resp = await fetch(url, {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.error) {
|
||||
errorDiv.textContent = data.error;
|
||||
errorDiv.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
userModal.hide();
|
||||
showToast(data.message || 'User saved successfully', 'success');
|
||||
loadUsers();
|
||||
|
||||
} catch(e) {
|
||||
errorDiv.textContent = 'Network error. Please try again.';
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Delete ---
|
||||
|
||||
function openDeleteModal(userId, username) {
|
||||
document.getElementById('deleteUserId').value = userId;
|
||||
document.getElementById('deleteUserName').textContent = username;
|
||||
deleteModal.show();
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const userId = document.getElementById('deleteUserId').value;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' });
|
||||
const data = await resp.json();
|
||||
|
||||
deleteModal.hide();
|
||||
|
||||
if (data.error) {
|
||||
showToast(data.error, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showToast(data.message || 'User deleted', 'success');
|
||||
loadUsers();
|
||||
|
||||
} catch(e) {
|
||||
deleteModal.hide();
|
||||
showToast('Failed to delete user', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Logout ---
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
} catch(e) { /* ignore */ }
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
// --- Utilities ---
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const colors = { success: 'success', error: 'danger', info: 'primary' };
|
||||
const icons = { success: 'check-circle', error: 'exclamation-circle', info: 'info-circle' };
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `alert alert-${colors[type] || 'info'} d-flex align-items-center gap-2`;
|
||||
toast.style.cssText = 'min-width:280px; animation: slideIn 0.3s ease; margin-bottom: 8px;';
|
||||
toast.innerHTML = `<i class="bi bi-${icons[type] || 'info-circle'}"></i> ${escapeHtml(message)}`;
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'slideOut 0.3s ease';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Inject animation styles
|
||||
const s = document.createElement('style');
|
||||
s.textContent = `
|
||||
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
@keyframes slideOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } }
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
481
templates/index.html
Normal file
481
templates/index.html
Normal file
@@ -0,0 +1,481 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Audiobook Maker Pro v4</title>
|
||||
|
||||
|
||||
<!-- Bootstrap 5 -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||
|
||||
<!-- Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Merriweather:ital,wght@0,400;0,700;1,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/markdown-editor.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Loading Overlay -->
|
||||
<div class="loading-overlay" id="loadingOverlay">
|
||||
<div class="loading-content">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
<h5 id="loadingText">Processing...</h5>
|
||||
<p id="loadingSubtext" class="text-muted">Please wait</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Welcome Overlay (first-time guide) -->
|
||||
<div class="welcome-overlay" id="welcomeOverlay">
|
||||
<div class="welcome-card">
|
||||
<div class="welcome-header">
|
||||
<i class="bi bi-soundwave"></i>
|
||||
<h2>Welcome to Audiobook Maker Pro</h2>
|
||||
<p>Turn any document into an interactive audiobook in 3 simple steps</p>
|
||||
</div>
|
||||
<div class="welcome-steps">
|
||||
<!-- Step 1 -->
|
||||
<div class="welcome-step">
|
||||
<div class="welcome-step-number">Step-1</div>
|
||||
<div class="welcome-step-icon"><i class="bi bi-upload"></i></div>
|
||||
<h4>Upload Document</h4>
|
||||
<div class="welcome-step-details">
|
||||
<p>Drag & drop a <strong>PDF</strong>, <strong>DOCX</strong>, or <strong>DOC</strong> file into the upload area</p>
|
||||
<div class="welcome-step-alt">
|
||||
<span class="welcome-alt-label">or</span>
|
||||
<p>Click <span class="welcome-ui-btn"><i class="bi bi-pencil"></i> Start from scratch</span> to write your own content</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="welcome-step-arrow"><i class="bi bi-arrow-right"></i></div>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<div class="welcome-step">
|
||||
<div class="welcome-step-number">Step-2</div>
|
||||
<div class="welcome-step-icon"><i class="bi bi-soundwave"></i></div>
|
||||
<h4>Edit & Generate Audio</h4>
|
||||
<div class="welcome-step-details">
|
||||
<p>Hover between blocks and click <span class="welcome-ui-dot chapter-dot"><i class="bi bi-bookmark-star"></i></span> to add chapter markers for multi-chapter books</p>
|
||||
<p>Select a <strong>voice</strong> from the chapter dropdown, then click <span class="welcome-ui-btn success-btn"><i class="bi bi-play-fill"></i> Generate All</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="welcome-step-arrow"><i class="bi bi-arrow-right"></i></div>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<div class="welcome-step">
|
||||
<div class="welcome-step-number">Step-3</div>
|
||||
<div class="welcome-step-icon"><i class="bi bi-book"></i></div>
|
||||
<h4>Read & Export</h4>
|
||||
<div class="welcome-step-details">
|
||||
<p>Switch to the <span class="welcome-ui-btn"><i class="bi bi-book"></i> Interactive Reader</span> tab to listen with word-by-word highlighting</p>
|
||||
<p>Click <span class="welcome-ui-btn primary-btn"><i class="bi bi-download"></i> Export</span> in the header to download your audiobook as a ZIP</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="welcome-actions">
|
||||
<button class="btn btn-primary btn-lg" onclick="dismissWelcome()">
|
||||
<i class="bi bi-rocket-takeoff me-2"></i>Get Started
|
||||
</button>
|
||||
<label class="welcome-dismiss-label">
|
||||
<input type="checkbox" id="welcomeDontShow"> Don't show this again
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================
|
||||
FLOATING GUIDE PANEL (Draggable)
|
||||
================================================= -->
|
||||
<div class="floating-guide-panel" id="floatingGuidePanel">
|
||||
<!-- Drag Handle / Header -->
|
||||
<div class="guide-panel-header" id="guidePanelHeader">
|
||||
<div class="guide-panel-title">
|
||||
<i class="bi bi-lightbulb"></i>
|
||||
<span>Quick Guide</span>
|
||||
</div>
|
||||
<div class="guide-panel-controls">
|
||||
<button class="guide-panel-btn" id="guidePanelCollapse" onclick="toggleGuideCollapse()" title="Collapse / Expand">
|
||||
<i class="bi bi-chevron-up" id="guideCollapseIcon"></i>
|
||||
</button>
|
||||
<button class="guide-panel-btn" onclick="hideGuidePanel()" title="Hide guide panel">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Panel Body -->
|
||||
<div class="guide-panel-body" id="guidePanelBody">
|
||||
<!-- Step 2: Edit & Generate -->
|
||||
<div class="guide-section">
|
||||
<div class="guide-section-badge">Step 2 — Edit & Generate</div>
|
||||
|
||||
<div class="guide-instruction">
|
||||
<span class="guide-step-num">1</span>
|
||||
<div class="guide-instruction-text">
|
||||
Hover between blocks and click
|
||||
<span class="guide-ui chapter-marker-ui" title="Add chapter marker"><i class="bi bi-bookmark-star-fill"></i></span>
|
||||
to add <strong>chapter markers</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="guide-instruction">
|
||||
<span class="guide-step-num">2</span>
|
||||
<div class="guide-instruction-text">
|
||||
Select a <strong>voice</strong> from the chapter dropdown
|
||||
<span class="guide-ui voice-dropdown-ui">
|
||||
<i class="bi bi-mic-fill"></i> Voice <i class="bi bi-chevron-down" style="font-size:0.55rem;"></i>
|
||||
</span>,
|
||||
then click
|
||||
<span class="guide-ui generate-btn-ui"><i class="bi bi-play-fill"></i> Generate All</span>
|
||||
to create audio
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="guide-instruction">
|
||||
<span class="guide-step-num">3</span>
|
||||
<div class="guide-instruction-text">
|
||||
Click
|
||||
<span class="guide-ui image-btn-ui"><i class="bi bi-image"></i></span>
|
||||
between blocks to <strong>add an image</strong> (optional)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="guide-instruction">
|
||||
<span class="guide-step-num">4</span>
|
||||
<div class="guide-instruction-text">
|
||||
Click
|
||||
<span class="guide-ui add-block-btn-ui"><i class="bi bi-plus"></i></span>
|
||||
between blocks to <strong>add a new text block</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Read & Export -->
|
||||
<div class="guide-section">
|
||||
<div class="guide-section-badge step3-badge">Step 3 — Read & Export</div>
|
||||
|
||||
<div class="guide-instruction">
|
||||
<span class="guide-step-num">5</span>
|
||||
<div class="guide-instruction-text">
|
||||
Switch to the
|
||||
<span class="guide-ui reader-tab-ui"><i class="bi bi-book"></i> Interactive Reader</span>
|
||||
tab to <strong>listen</strong> with word-by-word highlighting
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="guide-instruction">
|
||||
<span class="guide-step-num">6</span>
|
||||
<div class="guide-instruction-text">
|
||||
Click
|
||||
<span class="guide-ui export-btn-ui"><i class="bi bi-download"></i> Export</span>
|
||||
in the header to <strong>download</strong> your audiobook as a ZIP
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Don't show option -->
|
||||
<div class="guide-panel-footer">
|
||||
<label class="guide-dont-show">
|
||||
<input type="checkbox" id="guidePanelDontShow" onchange="handleGuideDontShow()">
|
||||
Don't show this again
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Guide Toggle Button (when panel is hidden) -->
|
||||
<button class="floating-guide-toggle" id="floatingGuideToggle" onclick="showGuidePanel()" title="Show Quick Guide">
|
||||
<i class="bi bi-lightbulb"></i>
|
||||
</button>
|
||||
|
||||
|
||||
<!-- Main Container -->
|
||||
<div class="app-container">
|
||||
<!-- Header -->
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<h1 class="app-title">
|
||||
<i class="bi bi-soundwave"></i>
|
||||
Audiobook Maker Pro
|
||||
<span class="version-badge">v4.0</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="project-controls">
|
||||
<input type="text" id="projectName" class="form-control project-name-input"
|
||||
placeholder="Project Name" value="My Audiobook">
|
||||
<button class="btn btn-success" id="saveProjectBtn" onclick="saveProject()" title="Save project (Ctrl+S)">
|
||||
<i class="bi bi-save me-1"></i> Save
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick="exportProject()" title="Export as ZIP with audio + reader">
|
||||
<i class="bi bi-download me-1"></i> Export
|
||||
</button>
|
||||
<button class="btn btn-header-archive" onclick="openProjectArchive()" title="Load or manage saved projects">
|
||||
<i class="bi bi-archive me-1"></i> Archive
|
||||
</button>
|
||||
<button class="btn btn-header-help" id="headerHelpBtn" onclick="handleHeaderHelp()" title="Show quick start guide">
|
||||
<i class="bi bi-question-circle me-1"></i>
|
||||
<span id="headerHelpLabel">Quick Start</span>
|
||||
</button>
|
||||
|
||||
<!-- User Menu -->
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-header-user dropdown-toggle" type="button"
|
||||
data-bs-toggle="dropdown" aria-expanded="false" id="userMenuBtn">
|
||||
<i class="bi bi-person-circle me-1"></i>
|
||||
<span id="headerUsername">User</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li id="adminMenuItem" style="display:none;">
|
||||
<a class="dropdown-item" href="/admin">
|
||||
<i class="bi bi-shield-lock me-2"></i>Admin Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li id="adminDivider" style="display:none;"><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" onclick="openChangePassword()">
|
||||
<i class="bi bi-key me-2"></i>Change Password
|
||||
</a>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item text-danger" href="#" onclick="handleLogout()">
|
||||
<i class="bi bi-box-arrow-right me-2"></i>Logout
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
<!-- Workflow Progress Bar -->
|
||||
<div class="workflow-progress" id="workflowProgress">
|
||||
<div class="workflow-step active" id="wpStep1">
|
||||
<div class="workflow-step-dot">1</div>
|
||||
<span>Upload</span>
|
||||
</div>
|
||||
<div class="workflow-connector" id="wpConn1"></div>
|
||||
<div class="workflow-step" id="wpStep2">
|
||||
<div class="workflow-step-dot">2</div>
|
||||
<span>Edit & Generate</span>
|
||||
</div>
|
||||
<div class="workflow-connector" id="wpConn2"></div>
|
||||
<div class="workflow-step" id="wpStep3">
|
||||
<div class="workflow-step-dot">3</div>
|
||||
<span>Read & Export</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="main-content">
|
||||
<!-- Tab Navigation -->
|
||||
<ul class="nav nav-tabs main-tabs" id="mainTabs" role="tablist">
|
||||
<li class="nav-item">
|
||||
<button class="nav-link active" id="editor-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#editorPanel" type="button">
|
||||
<i class="bi bi-pencil-square me-1"></i> Audiobook Maker Editor
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link" id="reader-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#readerPanel" type="button">
|
||||
<i class="bi bi-book me-1"></i> Interactive Reader
|
||||
<span class="reader-tab-badge" id="readerTabBadge" style="display:none;">
|
||||
Ready
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="tab-content" id="mainTabContent">
|
||||
<!-- Editor Panel -->
|
||||
<div class="tab-pane fade show active" id="editorPanel" role="tabpanel">
|
||||
|
||||
<!-- Upload Section: Two-Pathway Layout -->
|
||||
<div class="upload-section" id="uploadSection">
|
||||
<div class="upload-zone-wrapper">
|
||||
|
||||
<!-- Option A: Upload a Document -->
|
||||
<div class="upload-zone" id="uploadZone">
|
||||
<input type="file" id="docInput" accept=".pdf,.doc,.docx" hidden>
|
||||
<div class="upload-content">
|
||||
<i class="bi bi-file-earmark-arrow-up upload-icon"></i>
|
||||
<h4>Upload Document</h4>
|
||||
<p class="text-muted mb-3">
|
||||
Drag & drop a file here, or click the button below to browse
|
||||
</p>
|
||||
<div class="d-flex gap-2 justify-content-center flex-wrap mb-3">
|
||||
<span class="badge bg-danger bg-opacity-10 text-danger px-3 py-2">
|
||||
<i class="bi bi-file-earmark-pdf me-1"></i>PDF
|
||||
</span>
|
||||
<span class="badge bg-primary bg-opacity-10 text-primary px-3 py-2">
|
||||
<i class="bi bi-file-earmark-word me-1"></i>DOCX
|
||||
</span>
|
||||
<span class="badge bg-info bg-opacity-10 text-info px-3 py-2">
|
||||
<i class="bi bi-file-earmark-word me-1"></i>DOC
|
||||
</span>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-lg" onclick="event.stopPropagation(); document.getElementById('docInput').click()">
|
||||
<i class="bi bi-upload me-2"></i>Choose File
|
||||
</button>
|
||||
<p class="upload-hint-text">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Your document will be split into editable blocks automatically
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vertical "or" Divider -->
|
||||
<div class="pathway-divider">
|
||||
<span>or</span>
|
||||
</div>
|
||||
|
||||
<!-- Option B: Start from Scratch -->
|
||||
<div class="scratch-zone" id="scratchZone" onclick="startFromScratch()">
|
||||
<div class="scratch-content">
|
||||
<i class="bi bi-pencil-square scratch-icon"></i>
|
||||
<h4>Start from Scratch</h4>
|
||||
<p class="text-muted mb-3">
|
||||
Write or paste your own content directly into the editor
|
||||
</p>
|
||||
<button class="btn btn-outline-primary btn-lg" onclick="event.stopPropagation(); startFromScratch()">
|
||||
<i class="bi bi-pencil me-2"></i>Start Writing
|
||||
</button>
|
||||
<p class="upload-hint-text">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Add chapters, format text, and insert images manually
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor Section: Hidden by default -->
|
||||
<div class="editor-section" id="editorSection" style="display:none">
|
||||
<div class="editor-container" id="markdownEditor">
|
||||
<div class="empty-editor-message" id="emptyEditorMessage">
|
||||
<i class="bi bi-file-text"></i>
|
||||
<p>Upload a document or start typing to create content</p>
|
||||
<p class="text-muted small">Click anywhere to add a new block</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reader Panel -->
|
||||
<div class="tab-pane fade" id="readerPanel" role="tabpanel">
|
||||
<div class="reader-container" id="readerContainer">
|
||||
<div class="reader-empty-state">
|
||||
<i class="bi bi-book"></i>
|
||||
<p>Generate audio to view the interactive reader</p>
|
||||
<p class="text-muted">Go to the Editor tab, then click <strong>"Generate All"</strong> on a chapter</p>
|
||||
<button class="btn btn-primary mt-3" onclick="switchToEditorTab()">
|
||||
<i class="bi bi-arrow-left me-1"></i> Go to Editor
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Project Archive Modal -->
|
||||
<div class="modal fade" id="archiveModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="bi bi-archive me-2"></i>Project Archive
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="projectList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TTS Edit Modal -->
|
||||
<div class="modal fade" id="ttsEditModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="bi bi-mic me-2"></i>Edit Text for TTS Generation
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small mb-2">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
This text will be used for audio generation. The original markdown content will be preserved.
|
||||
</p>
|
||||
<textarea id="ttsTextInput" class="form-control" rows="8"></textarea>
|
||||
<input type="hidden" id="ttsBlockId">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveTtsText()">
|
||||
<i class="bi bi-check-lg me-1"></i>Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password Modal -->
|
||||
<div class="modal fade" id="changePasswordModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="bi bi-key me-2"></i>Change Password
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="currentPassword" class="form-label">Current Password</label>
|
||||
<input type="password" class="form-control" id="currentPassword" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="newPassword" class="form-label">New Password</label>
|
||||
<input type="password" class="form-control" id="newPassword" required minlength="4">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="confirmPassword" class="form-label">Confirm New Password</label>
|
||||
<input type="password" class="form-control" id="confirmPassword" required>
|
||||
</div>
|
||||
<div class="alert alert-danger" id="changePasswordError" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="submitChangePassword()">
|
||||
<i class="bi bi-check-lg me-1"></i>Change Password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script src="/static/js/markdown-editor.js"></script>
|
||||
<script src="/static/js/pdf-handler.js"></script>
|
||||
<script src="/static/js/generation.js"></script>
|
||||
<script src="/static/js/interactive-reader.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
345
templates/login.html
Normal file
345
templates/login.html
Normal file
@@ -0,0 +1,345 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - Audiobook Maker Pro</title>
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #4f46e5;
|
||||
--primary-hover: #4338ca;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
padding: 48px 40px;
|
||||
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.3);
|
||||
animation: slideUp 0.4s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, #7c3aed 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 16px;
|
||||
font-size: 1.75rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
font-size: 0.95rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
.input-group-text {
|
||||
background: #f8fafc;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-right: none;
|
||||
border-radius: 10px 0 0 10px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.input-group .form-control {
|
||||
border-left: none;
|
||||
border-radius: 0 10px 10px 0;
|
||||
}
|
||||
|
||||
.input-group:focus-within .input-group-text {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, #7c3aed 100%);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-login:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 25px rgba(79, 70, 229, 0.4);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-login:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-login:disabled {
|
||||
opacity: 0.7;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #dc2626;
|
||||
border-radius: 10px;
|
||||
padding: 12px 16px;
|
||||
font-size: 0.85rem;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
animation: shakeError 0.4s ease;
|
||||
}
|
||||
|
||||
.login-error.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@keyframes shakeError {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-8px); }
|
||||
75% { transform: translateX(8px); }
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
color: #94a3b8;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.password-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.password-wrapper .form-control {
|
||||
padding-right: 44px;
|
||||
}
|
||||
|
||||
.spinner-border-sm {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="login-logo">
|
||||
<i class="bi bi-soundwave"></i>
|
||||
</div>
|
||||
<h1>Audiobook Maker Pro</h1>
|
||||
<p>Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<div class="login-error" id="loginError">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||
<span id="loginErrorText"></span>
|
||||
</div>
|
||||
|
||||
<form id="loginForm" onsubmit="handleLogin(event)">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-person"></i></span>
|
||||
<input type="text" class="form-control" id="username"
|
||||
placeholder="Enter your username" required autofocus
|
||||
autocomplete="username">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-lock"></i></span>
|
||||
<div class="password-wrapper" style="flex:1;">
|
||||
<input type="password" class="form-control" id="password"
|
||||
placeholder="Enter your password" required
|
||||
autocomplete="current-password">
|
||||
<button type="button" class="password-toggle" onclick="togglePassword()">
|
||||
<i class="bi bi-eye" id="passwordToggleIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-login" id="loginBtn">
|
||||
<span id="loginBtnText">Sign In</span>
|
||||
<div class="spinner-border spinner-border-sm" id="loginSpinner" style="display:none;" role="status"></div>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="login-footer">
|
||||
<i class="bi bi-shield-lock me-1"></i>
|
||||
Audiobook Maker Pro v3.1
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function togglePassword() {
|
||||
const input = document.getElementById('password');
|
||||
const icon = document.getElementById('passwordToggleIcon');
|
||||
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon.classList.remove('bi-eye');
|
||||
icon.classList.add('bi-eye-slash');
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.classList.remove('bi-eye-slash');
|
||||
icon.classList.add('bi-eye');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogin(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
const errorDiv = document.getElementById('loginError');
|
||||
const errorText = document.getElementById('loginErrorText');
|
||||
const loginBtn = document.getElementById('loginBtn');
|
||||
const btnText = document.getElementById('loginBtnText');
|
||||
const spinner = document.getElementById('loginSpinner');
|
||||
|
||||
// Hide previous error
|
||||
errorDiv.classList.remove('visible');
|
||||
|
||||
// Show loading
|
||||
loginBtn.disabled = true;
|
||||
btnText.textContent = 'Signing in...';
|
||||
spinner.style.display = 'inline-block';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
errorText.textContent = data.error;
|
||||
errorDiv.classList.add('visible');
|
||||
loginBtn.disabled = false;
|
||||
btnText.textContent = 'Sign In';
|
||||
spinner.style.display = 'none';
|
||||
|
||||
// Shake the password field
|
||||
document.getElementById('password').select();
|
||||
return;
|
||||
}
|
||||
|
||||
// Success — redirect
|
||||
btnText.textContent = 'Redirecting...';
|
||||
window.location.href = '/';
|
||||
|
||||
} catch (error) {
|
||||
errorText.textContent = 'Network error. Please try again.';
|
||||
errorDiv.classList.add('visible');
|
||||
loginBtn.disabled = false;
|
||||
btnText.textContent = 'Sign In';
|
||||
spinner.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Enter key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
document.getElementById('loginForm').requestSubmit();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
83
utils.py
Normal file
83
utils.py
Normal file
@@ -0,0 +1,83 @@
|
||||
# utils.py - Utility Functions
|
||||
|
||||
import io
|
||||
import re
|
||||
import base64
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def sanitize_filename(name):
|
||||
"""
|
||||
Sanitize a string for use as a filename.
|
||||
"""
|
||||
if not name:
|
||||
return 'unnamed'
|
||||
return "".join(c for c in name if c.isalnum() or c in ('_', '-', ' '))
|
||||
|
||||
|
||||
def strip_markdown(text):
|
||||
"""
|
||||
Strip markdown formatting from text to get plain text for TTS.
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# Remove image references completely
|
||||
text = re.sub(r'!\[([^\]]*)\]\([^)]+\)', '', text)
|
||||
|
||||
# Remove headings markers
|
||||
text = re.sub(r'^#{1,6}\s*', '', text, flags=re.MULTILINE)
|
||||
|
||||
# Remove bold/italic
|
||||
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
|
||||
text = re.sub(r'\*(.+?)\*', r'\1', text)
|
||||
text = re.sub(r'__(.+?)__', r'\1', text)
|
||||
text = re.sub(r'_(.+?)_', r'\1', text)
|
||||
|
||||
# Remove strikethrough
|
||||
text = re.sub(r'~~(.+?)~~', r'\1', text)
|
||||
|
||||
# Remove inline code
|
||||
text = re.sub(r'`([^`]+)`', r'\1', text)
|
||||
|
||||
# Remove links but keep text
|
||||
text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text)
|
||||
|
||||
# Remove blockquote markers
|
||||
text = re.sub(r'^>\s*', '', text, flags=re.MULTILINE)
|
||||
|
||||
# Remove list markers
|
||||
text = re.sub(r'^\s*[-*+]\s+', '', text, flags=re.MULTILINE)
|
||||
text = re.sub(r'^\s*\d+\.\s+', '', text, flags=re.MULTILINE)
|
||||
|
||||
# Remove horizontal rules
|
||||
text = re.sub(r'^(-{3,}|\*{3,}|_{3,})$', '', text, flags=re.MULTILINE)
|
||||
|
||||
# Clean up whitespace
|
||||
text = re.sub(r'\n{3,}', '\n\n', text)
|
||||
|
||||
return text.strip()
|
||||
Reference in New Issue
Block a user