first commit

This commit is contained in:
Ashim Kumar
2026-02-20 13:53:36 +06:00
commit 8e02b9ad09
35 changed files with 11059 additions and 0 deletions

16
.dockerignore Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"![Image](embedded-image.{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"![Document Image](embedded-image.{img['format']})",
'data': img['data'], 'format': img['format'],
})
return
# Extract images first
for img in self._get_paragraph_images(element):
blocks.append({
'type': 'image',
'content': f"![Document Image](embedded-image.{img['format']})",
'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':'![Image](embedded-image.jpeg)','data':base64.b64encode(d).decode(),'format':'jpeg'}); continue
if d[:8] == b'\x89PNG\r\n\x1a\n':
imgs.append({'type':'image','content':'![Image](embedded-image.png)','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':'![Image](embedded-image.jpeg)','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':'![Image](embedded-image.png)','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
View 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"![PDF Image](embedded-image.{img_format})",
"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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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('![') and '](' in stripped and stripped.endswith(')'):
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('![') and '](' in stripped and stripped.endswith(')'):
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
View 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
View 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
View 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'})

View 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

File diff suppressed because it is too large Load Diff

899
static/js/app.js Normal file
View 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
View 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('![') && content.trim().indexOf('](') !== -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('![') && content.trim().indexOf('](') !== -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');
}
}

View 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('![') && block.content.trim().includes(']('));
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);
}

View 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 = `![${file.name}](embedded-image.${format})`;
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,
'![$1](embedded-image)'
);
}
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 = `![${file.name}](embedded-image.${format})`;
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
View 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 = `![Document Image](embedded-image.${block.format || 'png'})`;
}
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
View 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
View 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
View 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
View 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()