first commit
This commit is contained in:
27
routes/__init__.py
Normal file
27
routes/__init__.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# routes/__init__.py - Blueprint Registration
|
||||
|
||||
from flask import Flask
|
||||
|
||||
|
||||
def register_blueprints(app: Flask):
|
||||
"""Register all blueprints with the Flask app."""
|
||||
|
||||
from routes.auth_routes import auth_bp
|
||||
from routes.admin_routes import admin_bp
|
||||
from routes.main_routes import main_bp
|
||||
from routes.pdf_routes import pdf_bp
|
||||
from routes.docx_routes import docx_bp
|
||||
from routes.project_routes import project_bp
|
||||
from routes.generation_routes import generation_bp
|
||||
from routes.export_routes import export_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(pdf_bp)
|
||||
app.register_blueprint(docx_bp)
|
||||
app.register_blueprint(project_bp)
|
||||
app.register_blueprint(generation_bp)
|
||||
app.register_blueprint(export_bp)
|
||||
|
||||
print("✅ All blueprints registered")
|
||||
175
routes/admin_routes.py
Normal file
175
routes/admin_routes.py
Normal file
@@ -0,0 +1,175 @@
|
||||
# routes/admin_routes.py - Admin Dashboard Routes
|
||||
|
||||
from flask import Blueprint, request, jsonify, session, send_from_directory
|
||||
from db import get_db
|
||||
from auth import admin_required
|
||||
|
||||
admin_bp = Blueprint('admin', __name__)
|
||||
|
||||
|
||||
@admin_bp.route('/admin')
|
||||
@admin_required
|
||||
def admin_page():
|
||||
"""Serve admin dashboard page."""
|
||||
return send_from_directory('templates', 'admin.html')
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/users', methods=['GET'])
|
||||
@admin_required
|
||||
def list_users():
|
||||
"""List all users."""
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT id, username, role, is_active, created_at, last_login
|
||||
FROM users ORDER BY created_at DESC
|
||||
''')
|
||||
|
||||
users = []
|
||||
for row in cursor.fetchall():
|
||||
users.append({
|
||||
'id': row['id'],
|
||||
'username': row['username'],
|
||||
'role': row['role'],
|
||||
'is_active': bool(row['is_active']),
|
||||
'created_at': row['created_at'],
|
||||
'last_login': row['last_login']
|
||||
})
|
||||
|
||||
return jsonify({'users': users})
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/users', methods=['POST'])
|
||||
@admin_required
|
||||
def create_user():
|
||||
"""Create a new user."""
|
||||
data = request.json
|
||||
username = data.get('username', '').strip()
|
||||
password = data.get('password', '')
|
||||
role = data.get('role', 'user')
|
||||
|
||||
if not username or not password:
|
||||
return jsonify({'error': 'Username and password are required'}), 400
|
||||
|
||||
if len(username) < 3:
|
||||
return jsonify({'error': 'Username must be at least 3 characters'}), 400
|
||||
|
||||
if len(password) < 4:
|
||||
return jsonify({'error': 'Password must be at least 4 characters'}), 400
|
||||
|
||||
if role not in ('user', 'admin'):
|
||||
return jsonify({'error': 'Role must be "user" or "admin"'}), 400
|
||||
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute('''
|
||||
INSERT INTO users (username, password, role, is_active)
|
||||
VALUES (?, ?, ?, 1)
|
||||
''', (username, password, role))
|
||||
db.commit()
|
||||
|
||||
print(f"✅ New user created: {username} (role: {role})")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'user_id': cursor.lastrowid,
|
||||
'message': f'User "{username}" created successfully'
|
||||
})
|
||||
except Exception as e:
|
||||
if 'UNIQUE constraint' in str(e):
|
||||
return jsonify({'error': f'Username "{username}" already exists'}), 400
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/users/<int:user_id>', methods=['PUT'])
|
||||
@admin_required
|
||||
def update_user(user_id):
|
||||
"""Update a user."""
|
||||
data = request.json
|
||||
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
cursor.execute('SELECT id, username FROM users WHERE id = ?', (user_id,))
|
||||
user = cursor.fetchone()
|
||||
|
||||
if not user:
|
||||
return jsonify({'error': 'User not found'}), 404
|
||||
|
||||
# Build update query dynamically
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if 'username' in data:
|
||||
username = data['username'].strip()
|
||||
if len(username) < 3:
|
||||
return jsonify({'error': 'Username must be at least 3 characters'}), 400
|
||||
updates.append('username = ?')
|
||||
params.append(username)
|
||||
|
||||
if 'password' in data and data['password']:
|
||||
password = data['password']
|
||||
if len(password) < 4:
|
||||
return jsonify({'error': 'Password must be at least 4 characters'}), 400
|
||||
updates.append('password = ?')
|
||||
params.append(password)
|
||||
|
||||
if 'role' in data:
|
||||
role = data['role']
|
||||
if role not in ('user', 'admin'):
|
||||
return jsonify({'error': 'Role must be "user" or "admin"'}), 400
|
||||
# Prevent demoting self
|
||||
if user_id == session.get('user_id') and role != 'admin':
|
||||
return jsonify({'error': 'Cannot change your own role'}), 400
|
||||
updates.append('role = ?')
|
||||
params.append(role)
|
||||
|
||||
if 'is_active' in data:
|
||||
# Prevent deactivating self
|
||||
if user_id == session.get('user_id') and not data['is_active']:
|
||||
return jsonify({'error': 'Cannot deactivate your own account'}), 400
|
||||
updates.append('is_active = ?')
|
||||
params.append(1 if data['is_active'] else 0)
|
||||
|
||||
if not updates:
|
||||
return jsonify({'error': 'No fields to update'}), 400
|
||||
|
||||
params.append(user_id)
|
||||
|
||||
try:
|
||||
cursor.execute(f"UPDATE users SET {', '.join(updates)} WHERE id = ?", params)
|
||||
db.commit()
|
||||
|
||||
return jsonify({'success': True, 'message': 'User updated successfully'})
|
||||
except Exception as e:
|
||||
if 'UNIQUE constraint' in str(e):
|
||||
return jsonify({'error': 'Username already exists'}), 400
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@admin_bp.route('/api/admin/users/<int:user_id>', methods=['DELETE'])
|
||||
@admin_required
|
||||
def delete_user(user_id):
|
||||
"""Delete a user."""
|
||||
# Prevent deleting self
|
||||
if user_id == session.get('user_id'):
|
||||
return jsonify({'error': 'Cannot delete your own account'}), 400
|
||||
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
cursor.execute('SELECT id, username FROM users WHERE id = ?', (user_id,))
|
||||
user = cursor.fetchone()
|
||||
|
||||
if not user:
|
||||
return jsonify({'error': 'User not found'}), 404
|
||||
|
||||
cursor.execute('DELETE FROM users WHERE id = ?', (user_id,))
|
||||
db.commit()
|
||||
|
||||
print(f"🗑️ User deleted: {user['username']}")
|
||||
|
||||
return jsonify({'success': True, 'message': f'User "{user["username"]}" deleted'})
|
||||
113
routes/auth_routes.py
Normal file
113
routes/auth_routes.py
Normal file
@@ -0,0 +1,113 @@
|
||||
# routes/auth_routes.py - Authentication Routes
|
||||
|
||||
from flask import Blueprint, request, jsonify, session, redirect, url_for, send_from_directory
|
||||
from db import get_db
|
||||
from auth import login_required, admin_required, get_current_user
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@auth_bp.route('/login')
|
||||
def login_page():
|
||||
"""Serve login page."""
|
||||
if 'user_id' in session:
|
||||
return redirect(url_for('main.index'))
|
||||
return send_from_directory('templates', 'login.html')
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/login', methods=['POST'])
|
||||
def login():
|
||||
"""Handle user login."""
|
||||
data = request.json
|
||||
username = data.get('username', '').strip()
|
||||
password = data.get('password', '')
|
||||
|
||||
if not username or not password:
|
||||
return jsonify({'error': 'Username and password are required'}), 400
|
||||
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT id, username, password, role, is_active
|
||||
FROM users WHERE username = ?
|
||||
''', (username,))
|
||||
|
||||
user = cursor.fetchone()
|
||||
|
||||
if not user:
|
||||
return jsonify({'error': 'Invalid username or password'}), 401
|
||||
|
||||
if not user['is_active']:
|
||||
return jsonify({'error': 'Account is disabled. Contact your administrator.'}), 403
|
||||
|
||||
if user['password'] != password:
|
||||
return jsonify({'error': 'Invalid username or password'}), 401
|
||||
|
||||
# Set session
|
||||
session['user_id'] = user['id']
|
||||
session['username'] = user['username']
|
||||
session['user_role'] = user['role']
|
||||
|
||||
# Update last login
|
||||
cursor.execute('''
|
||||
UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?
|
||||
''', (user['id'],))
|
||||
db.commit()
|
||||
|
||||
print(f"✅ User logged in: {username} (role: {user['role']})")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'user': {
|
||||
'id': user['id'],
|
||||
'username': user['username'],
|
||||
'role': user['role']
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/logout', methods=['POST'])
|
||||
def logout():
|
||||
"""Handle user logout."""
|
||||
username = session.get('username', 'Unknown')
|
||||
session.clear()
|
||||
print(f"👋 User logged out: {username}")
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/me', methods=['GET'])
|
||||
@login_required
|
||||
def get_me():
|
||||
"""Get current user info."""
|
||||
user = get_current_user()
|
||||
return jsonify({'user': user})
|
||||
|
||||
|
||||
@auth_bp.route('/api/auth/change-password', methods=['POST'])
|
||||
@login_required
|
||||
def change_password():
|
||||
"""Change current user's password."""
|
||||
data = request.json
|
||||
current_password = data.get('current_password', '')
|
||||
new_password = data.get('new_password', '')
|
||||
|
||||
if not current_password or not new_password:
|
||||
return jsonify({'error': 'Current password and new password are required'}), 400
|
||||
|
||||
if len(new_password) < 4:
|
||||
return jsonify({'error': 'New password must be at least 4 characters'}), 400
|
||||
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
cursor.execute('SELECT password FROM users WHERE id = ?', (session['user_id'],))
|
||||
user = cursor.fetchone()
|
||||
|
||||
if not user or user['password'] != current_password:
|
||||
return jsonify({'error': 'Current password is incorrect'}), 401
|
||||
|
||||
cursor.execute('UPDATE users SET password = ? WHERE id = ?', (new_password, session['user_id']))
|
||||
db.commit()
|
||||
|
||||
return jsonify({'success': True, 'message': 'Password changed successfully'})
|
||||
60
routes/docx_routes.py
Normal file
60
routes/docx_routes.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# routes/docx_routes.py - DOCX/DOC Upload and Processing Routes
|
||||
|
||||
import json
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from db import get_db
|
||||
from docx_processor import process_docx_to_markdown
|
||||
from auth import login_required
|
||||
|
||||
docx_bp = Blueprint('docx', __name__)
|
||||
|
||||
|
||||
@docx_bp.route('/api/upload-docx', methods=['POST'])
|
||||
@login_required
|
||||
def upload_docx():
|
||||
"""Upload and process a DOCX or DOC file."""
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'error': 'No file provided'}), 400
|
||||
|
||||
doc_file = request.files['file']
|
||||
|
||||
if not doc_file or not doc_file.filename:
|
||||
return jsonify({'error': 'Invalid file'}), 400
|
||||
|
||||
filename = doc_file.filename.lower()
|
||||
if not (filename.endswith('.docx') or filename.endswith('.doc')):
|
||||
return jsonify({'error': 'File must be a .docx or .doc file'}), 400
|
||||
|
||||
try:
|
||||
print(f"📄 Processing Word document: {doc_file.filename}")
|
||||
|
||||
file_bytes = doc_file.read()
|
||||
print(f" 📏 File size: {len(file_bytes)} bytes")
|
||||
|
||||
result = process_docx_to_markdown(file_bytes, doc_file.filename)
|
||||
|
||||
blocks = result.get('markdown_blocks', [])
|
||||
block_count = len(blocks)
|
||||
image_count = sum(1 for b in blocks if b.get('type') == 'image')
|
||||
text_count = block_count - image_count
|
||||
|
||||
print(f"✅ Word document processed: {block_count} blocks ({text_count} text, {image_count} images)")
|
||||
|
||||
for i, block in enumerate(blocks):
|
||||
if block.get('type') == 'image':
|
||||
data_len = len(block.get('data', ''))
|
||||
fmt = block.get('format', '?')
|
||||
print(f" 📷 Block {i}: image ({fmt}), data length: {data_len}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'filename': doc_file.filename,
|
||||
'metadata': result.get('metadata', {}),
|
||||
'blocks': blocks
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
174
routes/export_routes.py
Normal file
174
routes/export_routes.py
Normal file
@@ -0,0 +1,174 @@
|
||||
# routes/export_routes.py - Export Routes
|
||||
|
||||
import io
|
||||
import os
|
||||
import json
|
||||
import base64
|
||||
import zipfile
|
||||
from flask import Blueprint, request, jsonify, send_file
|
||||
|
||||
from db import get_db
|
||||
from utils import sanitize_filename, strip_markdown
|
||||
from auth import login_required
|
||||
|
||||
export_bp = Blueprint('export', __name__)
|
||||
|
||||
|
||||
@export_bp.route('/api/export/<int:project_id>', methods=['GET'])
|
||||
@login_required
|
||||
def export_project(project_id):
|
||||
"""Export project as ZIP file. Only includes chapters with audio."""
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
cursor.execute('SELECT * FROM projects WHERE id = ?', (project_id,))
|
||||
project = cursor.fetchone()
|
||||
|
||||
if not project:
|
||||
return jsonify({'error': 'Project not found'}), 404
|
||||
|
||||
project_name = sanitize_filename(project['name'])
|
||||
|
||||
cursor.execute('''
|
||||
SELECT * FROM chapters WHERE project_id = ? ORDER BY chapter_number
|
||||
''', (project_id,))
|
||||
chapters = cursor.fetchall()
|
||||
|
||||
zip_buffer = io.BytesIO()
|
||||
|
||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
manifest = {
|
||||
'title': project['name'],
|
||||
'assets': [],
|
||||
'images': []
|
||||
}
|
||||
|
||||
for chapter in chapters:
|
||||
chapter_num = chapter['chapter_number']
|
||||
|
||||
cursor.execute('''
|
||||
SELECT * FROM markdown_blocks WHERE chapter_id = ? ORDER BY block_order
|
||||
''', (chapter['id'],))
|
||||
blocks = cursor.fetchall()
|
||||
|
||||
chapter_has_audio = False
|
||||
for block in blocks:
|
||||
is_image_block = (
|
||||
(block['content'] and block['content'].strip().startswith('![')) or
|
||||
block['block_type'] == 'image'
|
||||
)
|
||||
if not is_image_block and block['audio_data']:
|
||||
chapter_has_audio = True
|
||||
break
|
||||
|
||||
if not chapter_has_audio:
|
||||
continue
|
||||
|
||||
for block in blocks:
|
||||
block_order = block['block_order']
|
||||
prefix = f"{chapter_num}.{block_order}"
|
||||
content = block['content']
|
||||
|
||||
is_image_block = (
|
||||
(content and content.strip().startswith('![')) or
|
||||
block['block_type'] == 'image'
|
||||
)
|
||||
|
||||
cursor.execute('''
|
||||
SELECT * FROM block_images WHERE block_id = ? ORDER BY id
|
||||
''', (block['id'],))
|
||||
images = cursor.fetchall()
|
||||
|
||||
image_idx = 0
|
||||
for img in images:
|
||||
if img['position'] == 'before':
|
||||
image_filename = f"book/{prefix}_img{image_idx}.{img['image_format']}"
|
||||
image_bytes = base64.b64decode(img['image_data'])
|
||||
zf.writestr(image_filename, image_bytes)
|
||||
manifest['images'].append({
|
||||
'sortKey': prefix,
|
||||
'file': image_filename
|
||||
})
|
||||
image_idx += 1
|
||||
|
||||
if is_image_block:
|
||||
for img in images:
|
||||
if img['position'] == 'after':
|
||||
next_prefix = f"{chapter_num}.{block_order + 1}"
|
||||
image_filename = f"book/{next_prefix}_img{image_idx}.{img['image_format']}"
|
||||
image_bytes = base64.b64decode(img['image_data'])
|
||||
zf.writestr(image_filename, image_bytes)
|
||||
manifest['images'].append({
|
||||
'sortKey': next_prefix,
|
||||
'file': image_filename
|
||||
})
|
||||
image_idx += 1
|
||||
continue
|
||||
|
||||
plain_text = strip_markdown(content)
|
||||
if not plain_text.strip():
|
||||
continue
|
||||
|
||||
if not block['audio_data']:
|
||||
continue
|
||||
|
||||
text_filename = f"book/{prefix}_{project_name}.txt"
|
||||
zf.writestr(text_filename, plain_text)
|
||||
|
||||
asset_entry = {
|
||||
'prefix': f"{prefix}_",
|
||||
'sortKey': prefix,
|
||||
'textFile': text_filename,
|
||||
'audioFile': None,
|
||||
'jsonFile': None
|
||||
}
|
||||
|
||||
audio_filename = f"book/{prefix}_{project_name}.{block['audio_format'] or 'mp3'}"
|
||||
audio_bytes = base64.b64decode(block['audio_data'])
|
||||
zf.writestr(audio_filename, audio_bytes)
|
||||
asset_entry['audioFile'] = audio_filename
|
||||
|
||||
if block['transcription']:
|
||||
json_filename = f"book/{prefix}_{project_name}.json"
|
||||
zf.writestr(json_filename, block['transcription'])
|
||||
asset_entry['jsonFile'] = json_filename
|
||||
|
||||
manifest['assets'].append(asset_entry)
|
||||
|
||||
for img in images:
|
||||
if img['position'] == 'after':
|
||||
next_prefix = f"{chapter_num}.{block_order + 1}"
|
||||
image_filename = f"book/{next_prefix}_img{image_idx}.{img['image_format']}"
|
||||
image_bytes = base64.b64decode(img['image_data'])
|
||||
zf.writestr(image_filename, image_bytes)
|
||||
manifest['images'].append({
|
||||
'sortKey': next_prefix,
|
||||
'file': image_filename
|
||||
})
|
||||
image_idx += 1
|
||||
|
||||
zf.writestr('manifest.json', json.dumps(manifest, indent=2))
|
||||
|
||||
reader_templates_dir = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
'reader_templates'
|
||||
)
|
||||
|
||||
index_path = os.path.join(reader_templates_dir, 'index.html')
|
||||
if os.path.exists(index_path):
|
||||
with open(index_path, 'r', encoding='utf-8') as f:
|
||||
zf.writestr('index.html', f.read())
|
||||
|
||||
reader_path = os.path.join(reader_templates_dir, 'Reader.html')
|
||||
if os.path.exists(reader_path):
|
||||
with open(reader_path, 'r', encoding='utf-8') as f:
|
||||
zf.writestr('Reader.html', f.read())
|
||||
|
||||
zip_buffer.seek(0)
|
||||
|
||||
return send_file(
|
||||
zip_buffer,
|
||||
mimetype='application/zip',
|
||||
as_attachment=True,
|
||||
download_name=f"{project_name}.zip"
|
||||
)
|
||||
225
routes/generation_routes.py
Normal file
225
routes/generation_routes.py
Normal file
@@ -0,0 +1,225 @@
|
||||
# routes/generation_routes.py - TTS Audio Generation Routes
|
||||
|
||||
import json
|
||||
import base64
|
||||
import requests
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from db import get_db
|
||||
from config import TTS_API_URL, get_api_headers, get_api_headers_json
|
||||
from utils import convert_to_mp3, strip_markdown
|
||||
from auth import login_required
|
||||
|
||||
generation_bp = Blueprint('generation', __name__)
|
||||
|
||||
|
||||
@generation_bp.route('/api/generate', methods=['POST'])
|
||||
@login_required
|
||||
def generate_audio():
|
||||
"""Generate audio for a single block."""
|
||||
data = request.json
|
||||
text = data.get('text', '')
|
||||
voice = data.get('voice', 'af_heart')
|
||||
block_id = data.get('block_id')
|
||||
|
||||
if not text:
|
||||
return jsonify({'error': 'No text provided'}), 400
|
||||
|
||||
stripped = text.strip()
|
||||
if stripped.startswith(''):
|
||||
return jsonify({'error': 'Cannot generate audio for image content'}), 400
|
||||
|
||||
clean_text = strip_markdown(text)
|
||||
|
||||
if not clean_text.strip():
|
||||
return jsonify({'error': 'No speakable text content'}), 400
|
||||
|
||||
try:
|
||||
print(f"🔊 Generating audio: voice={voice}, text length={len(clean_text)}")
|
||||
print(f" Text preview: {clean_text[:100]}...")
|
||||
|
||||
response = requests.post(
|
||||
f"{TTS_API_URL}/generate-audio",
|
||||
headers=get_api_headers_json(),
|
||||
json={'text': clean_text, 'voice': voice, 'speed': 1.0},
|
||||
timeout=180
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
try:
|
||||
error_detail = response.json().get('error', 'Unknown error')
|
||||
except Exception:
|
||||
error_detail = f'HTTP {response.status_code}'
|
||||
print(f"❌ TTS API Error: {error_detail}")
|
||||
return jsonify({'error': f'TTS API Error: {error_detail}'}), response.status_code
|
||||
|
||||
result = response.json()
|
||||
audio_base64 = result.get('audio_base64', '')
|
||||
source_format = result.get('audio_format', 'wav')
|
||||
|
||||
if not audio_base64:
|
||||
return jsonify({'error': 'No audio data received from TTS API'}), 500
|
||||
|
||||
audio_bytes = base64.b64decode(audio_base64)
|
||||
files = {'audio_file': (f'audio.{source_format}', audio_bytes)}
|
||||
ts_data = {'text': clean_text}
|
||||
|
||||
transcription = []
|
||||
try:
|
||||
ts_response = requests.post(
|
||||
f"{TTS_API_URL}/timestamp",
|
||||
headers=get_api_headers(),
|
||||
files=files,
|
||||
data=ts_data,
|
||||
timeout=120
|
||||
)
|
||||
|
||||
if ts_response.status_code == 200:
|
||||
ts_result = ts_response.json()
|
||||
transcription = ts_result.get('timestamps', [])
|
||||
print(f"✅ Got {len(transcription)} word timestamps")
|
||||
else:
|
||||
print(f"⚠️ Timestamp API returned {ts_response.status_code}, continuing without timestamps")
|
||||
except Exception as ts_err:
|
||||
print(f"⚠️ Timestamp generation failed: {ts_err}, continuing without timestamps")
|
||||
|
||||
if source_format != 'mp3':
|
||||
audio_base64 = convert_to_mp3(audio_base64, source_format)
|
||||
|
||||
if block_id:
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
cursor.execute('''
|
||||
UPDATE markdown_blocks
|
||||
SET audio_data = ?, audio_format = 'mp3', transcription = ?
|
||||
WHERE id = ?
|
||||
''', (audio_base64, json.dumps(transcription), block_id))
|
||||
db.commit()
|
||||
|
||||
print(f"✅ Audio generated successfully: {len(audio_base64)} bytes base64")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'audio_data': audio_base64,
|
||||
'audio_format': 'mp3',
|
||||
'transcription': transcription
|
||||
})
|
||||
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
print(f"❌ Cannot connect to TTS API at {TTS_API_URL}: {e}")
|
||||
return jsonify({'error': f'Cannot connect to TTS API server. Is it running at {TTS_API_URL}?'}), 500
|
||||
except requests.exceptions.Timeout as e:
|
||||
print(f"❌ TTS API timeout: {e}")
|
||||
return jsonify({'error': 'TTS API request timed out. Text may be too long.'}), 500
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"❌ TTS API request error: {e}")
|
||||
return jsonify({'error': f'API connection error: {str(e)}'}), 500
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@generation_bp.route('/api/generate-chapter', methods=['POST'])
|
||||
@login_required
|
||||
def generate_chapter_audio():
|
||||
"""Generate audio for all blocks in a chapter."""
|
||||
data = request.json
|
||||
chapter_id = data.get('chapter_id')
|
||||
voice = data.get('voice', 'af_heart')
|
||||
|
||||
if not chapter_id:
|
||||
return jsonify({'error': 'Chapter ID required'}), 400
|
||||
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT id, content, tts_text, block_type FROM markdown_blocks
|
||||
WHERE chapter_id = ? ORDER BY block_order
|
||||
''', (chapter_id,))
|
||||
blocks = cursor.fetchall()
|
||||
|
||||
if not blocks:
|
||||
return jsonify({'error': 'No blocks found in chapter'}), 404
|
||||
|
||||
results = []
|
||||
|
||||
for block in blocks:
|
||||
block_id = block['id']
|
||||
block_type = block['block_type'] if 'block_type' in block.keys() else 'paragraph'
|
||||
content = block['content'] or ''
|
||||
text = block['tts_text'] if block['tts_text'] else content
|
||||
|
||||
if block_type == 'image':
|
||||
results.append({'block_id': block_id, 'success': True, 'skipped': True, 'reason': 'image block'})
|
||||
continue
|
||||
|
||||
stripped = text.strip()
|
||||
if stripped.startswith(''):
|
||||
results.append({'block_id': block_id, 'success': True, 'skipped': True, 'reason': 'image markdown'})
|
||||
continue
|
||||
|
||||
clean_text = strip_markdown(text)
|
||||
if not clean_text.strip():
|
||||
results.append({'block_id': block_id, 'success': True, 'skipped': True, 'reason': 'empty text'})
|
||||
continue
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{TTS_API_URL}/generate-audio",
|
||||
headers=get_api_headers_json(),
|
||||
json={'text': clean_text, 'voice': voice, 'speed': 1.0},
|
||||
timeout=180
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
results.append({'block_id': block_id, 'success': False, 'error': 'TTS generation failed'})
|
||||
continue
|
||||
|
||||
result = response.json()
|
||||
audio_base64 = result.get('audio_base64', '')
|
||||
source_format = result.get('audio_format', 'wav')
|
||||
|
||||
transcription = []
|
||||
try:
|
||||
audio_bytes = base64.b64decode(audio_base64)
|
||||
files = {'audio_file': (f'audio.{source_format}', audio_bytes)}
|
||||
ts_data = {'text': clean_text}
|
||||
|
||||
ts_response = requests.post(
|
||||
f"{TTS_API_URL}/timestamp",
|
||||
headers=get_api_headers(),
|
||||
files=files,
|
||||
data=ts_data,
|
||||
timeout=120
|
||||
)
|
||||
|
||||
if ts_response.status_code == 200:
|
||||
ts_result = ts_response.json()
|
||||
transcription = ts_result.get('timestamps', [])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if source_format != 'mp3':
|
||||
audio_base64 = convert_to_mp3(audio_base64, source_format)
|
||||
|
||||
cursor.execute('''
|
||||
UPDATE markdown_blocks
|
||||
SET audio_data = ?, audio_format = 'mp3', transcription = ?
|
||||
WHERE id = ?
|
||||
''', (audio_base64, json.dumps(transcription), block_id))
|
||||
|
||||
results.append({
|
||||
'block_id': block_id,
|
||||
'success': True,
|
||||
'audio_data': audio_base64,
|
||||
'transcription': transcription
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
results.append({'block_id': block_id, 'success': False, 'error': str(e)})
|
||||
|
||||
db.commit()
|
||||
|
||||
return jsonify({'success': True, 'results': results})
|
||||
61
routes/main_routes.py
Normal file
61
routes/main_routes.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# routes/main_routes.py - Main Application Routes
|
||||
|
||||
import os
|
||||
from flask import Blueprint, jsonify, send_from_directory, session
|
||||
|
||||
from config import DATABASE, VOICES
|
||||
from auth import login_required, get_current_user
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
|
||||
@main_bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
"""Serve main application page."""
|
||||
return send_from_directory('templates', 'index.html')
|
||||
|
||||
|
||||
@main_bp.route('/static/<path:filename>')
|
||||
def serve_static(filename):
|
||||
"""Serve static files."""
|
||||
return send_from_directory('static', filename)
|
||||
|
||||
|
||||
@main_bp.route('/api/voices', methods=['GET'])
|
||||
@login_required
|
||||
def get_voices():
|
||||
"""Get available TTS voices."""
|
||||
return jsonify({'voices': VOICES})
|
||||
|
||||
|
||||
@main_bp.route('/api/stats', methods=['GET'])
|
||||
@login_required
|
||||
def get_stats():
|
||||
"""Get database statistics."""
|
||||
from db import get_db
|
||||
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
cursor.execute('SELECT COUNT(*) as count FROM projects')
|
||||
project_count = cursor.fetchone()['count']
|
||||
|
||||
cursor.execute('SELECT COUNT(*) as count FROM chapters')
|
||||
chapter_count = cursor.fetchone()['count']
|
||||
|
||||
cursor.execute('SELECT COUNT(*) as count FROM markdown_blocks')
|
||||
block_count = cursor.fetchone()['count']
|
||||
|
||||
cursor.execute('SELECT COUNT(*) as count FROM pdf_documents')
|
||||
pdf_count = cursor.fetchone()['count']
|
||||
|
||||
db_size = os.path.getsize(DATABASE) if os.path.exists(DATABASE) else 0
|
||||
|
||||
return jsonify({
|
||||
'projects': project_count,
|
||||
'chapters': chapter_count,
|
||||
'blocks': block_count,
|
||||
'pdf_documents': pdf_count,
|
||||
'database_size_mb': round(db_size / (1024 * 1024), 2)
|
||||
})
|
||||
64
routes/pdf_routes.py
Normal file
64
routes/pdf_routes.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# routes/pdf_routes.py - PDF Upload and Processing Routes
|
||||
|
||||
import json
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from db import get_db
|
||||
from pdf_processor import process_pdf_to_markdown
|
||||
from auth import login_required
|
||||
|
||||
pdf_bp = Blueprint('pdf', __name__)
|
||||
|
||||
|
||||
@pdf_bp.route('/api/upload-pdf', methods=['POST'])
|
||||
@login_required
|
||||
def upload_pdf():
|
||||
"""Upload and process a PDF file."""
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'error': 'No file provided'}), 400
|
||||
|
||||
pdf_file = request.files['file']
|
||||
|
||||
if not pdf_file or not pdf_file.filename:
|
||||
return jsonify({'error': 'Invalid file'}), 400
|
||||
|
||||
if not pdf_file.filename.lower().endswith('.pdf'):
|
||||
return jsonify({'error': 'File must be a PDF'}), 400
|
||||
|
||||
try:
|
||||
print(f"📄 Processing PDF: {pdf_file.filename}")
|
||||
|
||||
pdf_bytes = pdf_file.read()
|
||||
result = process_pdf_to_markdown(pdf_bytes)
|
||||
|
||||
# Save PDF document record
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO pdf_documents (filename, page_count, metadata)
|
||||
VALUES (?, ?, ?)
|
||||
''', (
|
||||
pdf_file.filename,
|
||||
result["page_count"],
|
||||
json.dumps(result["metadata"])
|
||||
))
|
||||
db.commit()
|
||||
|
||||
doc_id = cursor.lastrowid
|
||||
|
||||
print(f"✅ PDF processed: {result['page_count']} pages, {len(result['markdown_blocks'])} blocks")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'document_id': doc_id,
|
||||
'filename': pdf_file.filename,
|
||||
'page_count': result['page_count'],
|
||||
'metadata': result['metadata'],
|
||||
'blocks': result['markdown_blocks']
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
269
routes/project_routes.py
Normal file
269
routes/project_routes.py
Normal file
@@ -0,0 +1,269 @@
|
||||
# routes/project_routes.py - Project Management Routes
|
||||
|
||||
import json
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from db import get_db, vacuum_db
|
||||
from auth import login_required
|
||||
|
||||
project_bp = Blueprint('project', __name__)
|
||||
|
||||
|
||||
@project_bp.route('/api/projects', methods=['GET'])
|
||||
@login_required
|
||||
def list_projects():
|
||||
"""List all projects."""
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT p.id, p.name, p.created_at, p.updated_at,
|
||||
(SELECT COUNT(*) FROM chapters WHERE project_id = p.id) as chapter_count,
|
||||
(SELECT COUNT(*) FROM markdown_blocks mb
|
||||
JOIN chapters c ON mb.chapter_id = c.id
|
||||
WHERE c.project_id = p.id) as block_count
|
||||
FROM projects p
|
||||
ORDER BY p.updated_at DESC
|
||||
''')
|
||||
|
||||
projects = []
|
||||
for row in cursor.fetchall():
|
||||
projects.append({
|
||||
'id': row['id'],
|
||||
'name': row['name'],
|
||||
'created_at': row['created_at'],
|
||||
'updated_at': row['updated_at'],
|
||||
'chapter_count': row['chapter_count'],
|
||||
'block_count': row['block_count']
|
||||
})
|
||||
|
||||
return jsonify({'projects': projects})
|
||||
|
||||
|
||||
@project_bp.route('/api/projects', methods=['POST'])
|
||||
@login_required
|
||||
def create_project():
|
||||
"""Create a new project."""
|
||||
data = request.json
|
||||
name = data.get('name', '').strip()
|
||||
|
||||
if not name:
|
||||
return jsonify({'error': 'Project name is required'}), 400
|
||||
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute('INSERT INTO projects (name) VALUES (?)', (name,))
|
||||
db.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'project_id': cursor.lastrowid,
|
||||
'name': name
|
||||
})
|
||||
except Exception as e:
|
||||
if 'UNIQUE constraint' in str(e):
|
||||
return jsonify({'error': 'Project with this name already exists'}), 400
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@project_bp.route('/api/projects/<int:project_id>', methods=['GET'])
|
||||
@login_required
|
||||
def get_project(project_id):
|
||||
"""Get a project with all its chapters and blocks."""
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
cursor.execute('SELECT * FROM projects WHERE id = ?', (project_id,))
|
||||
project = cursor.fetchone()
|
||||
|
||||
if not project:
|
||||
return jsonify({'error': 'Project not found'}), 404
|
||||
|
||||
cursor.execute('''
|
||||
SELECT * FROM chapters WHERE project_id = ? ORDER BY chapter_number
|
||||
''', (project_id,))
|
||||
chapters = cursor.fetchall()
|
||||
|
||||
chapters_data = []
|
||||
for chapter in chapters:
|
||||
cursor.execute('''
|
||||
SELECT * FROM markdown_blocks WHERE chapter_id = ? ORDER BY block_order
|
||||
''', (chapter['id'],))
|
||||
blocks = cursor.fetchall()
|
||||
|
||||
blocks_data = []
|
||||
for block in blocks:
|
||||
cursor.execute('''
|
||||
SELECT * FROM block_images WHERE block_id = ? ORDER BY id
|
||||
''', (block['id'],))
|
||||
images = cursor.fetchall()
|
||||
|
||||
blocks_data.append({
|
||||
'id': block['id'],
|
||||
'block_order': block['block_order'],
|
||||
'block_type': block['block_type'],
|
||||
'content': block['content'],
|
||||
'tts_text': block['tts_text'],
|
||||
'audio_data': block['audio_data'],
|
||||
'audio_format': block['audio_format'],
|
||||
'transcription': json.loads(block['transcription']) if block['transcription'] else [],
|
||||
'images': [{
|
||||
'id': img['id'],
|
||||
'data': img['image_data'],
|
||||
'format': img['image_format'],
|
||||
'alt_text': img['alt_text'],
|
||||
'position': img['position']
|
||||
} for img in images]
|
||||
})
|
||||
|
||||
chapters_data.append({
|
||||
'id': chapter['id'],
|
||||
'chapter_number': chapter['chapter_number'],
|
||||
'voice': chapter['voice'],
|
||||
'blocks': blocks_data
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'id': project['id'],
|
||||
'name': project['name'],
|
||||
'created_at': project['created_at'],
|
||||
'updated_at': project['updated_at'],
|
||||
'chapters': chapters_data
|
||||
})
|
||||
|
||||
|
||||
@project_bp.route('/api/projects/<int:project_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_project(project_id):
|
||||
"""Update project name."""
|
||||
data = request.json
|
||||
name = data.get('name', '').strip()
|
||||
|
||||
if not name:
|
||||
return jsonify({'error': 'Project name is required'}), 400
|
||||
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
UPDATE projects SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
|
||||
''', (name, project_id))
|
||||
db.commit()
|
||||
|
||||
if cursor.rowcount == 0:
|
||||
return jsonify({'error': 'Project not found'}), 404
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@project_bp.route('/api/projects/<int:project_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_project(project_id):
|
||||
"""Delete a project and all its data."""
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
cursor.execute('SELECT id FROM projects WHERE id = ?', (project_id,))
|
||||
if not cursor.fetchone():
|
||||
return jsonify({'error': 'Project not found'}), 404
|
||||
|
||||
cursor.execute('''
|
||||
DELETE FROM block_images WHERE block_id IN (
|
||||
SELECT mb.id FROM markdown_blocks mb
|
||||
JOIN chapters c ON mb.chapter_id = c.id
|
||||
WHERE c.project_id = ?
|
||||
)
|
||||
''', (project_id,))
|
||||
|
||||
cursor.execute('''
|
||||
DELETE FROM markdown_blocks WHERE chapter_id IN (
|
||||
SELECT id FROM chapters WHERE project_id = ?
|
||||
)
|
||||
''', (project_id,))
|
||||
|
||||
cursor.execute('DELETE FROM chapters WHERE project_id = ?', (project_id,))
|
||||
cursor.execute('DELETE FROM projects WHERE id = ?', (project_id,))
|
||||
|
||||
db.commit()
|
||||
vacuum_db()
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@project_bp.route('/api/projects/<int:project_id>/save', methods=['POST'])
|
||||
@login_required
|
||||
def save_project_content(project_id):
|
||||
"""Save all chapters and blocks for a project."""
|
||||
data = request.json
|
||||
chapters = data.get('chapters', [])
|
||||
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
|
||||
cursor.execute('SELECT id FROM projects WHERE id = ?', (project_id,))
|
||||
if not cursor.fetchone():
|
||||
return jsonify({'error': 'Project not found'}), 404
|
||||
|
||||
cursor.execute('''
|
||||
DELETE FROM block_images WHERE block_id IN (
|
||||
SELECT mb.id FROM markdown_blocks mb
|
||||
JOIN chapters c ON mb.chapter_id = c.id
|
||||
WHERE c.project_id = ?
|
||||
)
|
||||
''', (project_id,))
|
||||
|
||||
cursor.execute('''
|
||||
DELETE FROM markdown_blocks WHERE chapter_id IN (
|
||||
SELECT id FROM chapters WHERE project_id = ?
|
||||
)
|
||||
''', (project_id,))
|
||||
|
||||
cursor.execute('DELETE FROM chapters WHERE project_id = ?', (project_id,))
|
||||
|
||||
for chapter in chapters:
|
||||
cursor.execute('''
|
||||
INSERT INTO chapters (project_id, chapter_number, voice)
|
||||
VALUES (?, ?, ?)
|
||||
''', (project_id, chapter['chapter_number'], chapter.get('voice', 'af_heart')))
|
||||
|
||||
chapter_id = cursor.lastrowid
|
||||
|
||||
for block in chapter.get('blocks', []):
|
||||
cursor.execute('''
|
||||
INSERT INTO markdown_blocks
|
||||
(chapter_id, block_order, block_type, content, tts_text, audio_data, audio_format, transcription)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
chapter_id,
|
||||
block['block_order'],
|
||||
block.get('block_type', 'paragraph'),
|
||||
block['content'],
|
||||
block.get('tts_text'),
|
||||
block.get('audio_data'),
|
||||
block.get('audio_format', 'mp3'),
|
||||
json.dumps(block.get('transcription', []))
|
||||
))
|
||||
|
||||
block_id = cursor.lastrowid
|
||||
|
||||
for img in block.get('images', []):
|
||||
cursor.execute('''
|
||||
INSERT INTO block_images (block_id, image_data, image_format, alt_text, position)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''', (
|
||||
block_id,
|
||||
img['data'],
|
||||
img.get('format', 'png'),
|
||||
img.get('alt_text', ''),
|
||||
img.get('position', 'before')
|
||||
))
|
||||
|
||||
cursor.execute('''
|
||||
UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE id = ?
|
||||
''', (project_id,))
|
||||
|
||||
db.commit()
|
||||
|
||||
return jsonify({'success': True, 'message': 'Project saved successfully'})
|
||||
Reference in New Issue
Block a user