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

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'})