Final changes and update
This commit is contained in:
@@ -1,20 +1,22 @@
|
||||
import os
|
||||
import json
|
||||
import html
|
||||
import shutil
|
||||
import markdown
|
||||
from functools import wraps
|
||||
from types import SimpleNamespace
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import func
|
||||
from urllib.parse import urlparse, urljoin
|
||||
from sqlalchemy import func, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from flask import Flask, render_template, request, redirect, url_for, flash, send_from_directory, session
|
||||
from flask_login import LoginManager, login_user, login_required, logout_user, current_user
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from .web_db import db, User, Project, Run, LogEntry
|
||||
from .web_tasks import huey, generate_book_task, regenerate_artifacts_task
|
||||
from .web_tasks import huey, generate_book_task, regenerate_artifacts_task, rewrite_chapter_task
|
||||
import config
|
||||
from . import utils
|
||||
from . import ai
|
||||
from . import story
|
||||
from . import export
|
||||
|
||||
# Calculate paths relative to this file (modules/web_app.py)
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
@@ -35,85 +37,9 @@ login_manager.init_app(app)
|
||||
def load_user(user_id):
|
||||
return db.session.get(User, int(user_id))
|
||||
|
||||
def migrate_logs():
|
||||
"""Parses old log files and inserts them into the database."""
|
||||
runs = Run.query.all()
|
||||
migrated = 0
|
||||
files_to_clean = []
|
||||
for run in runs:
|
||||
# Check if DB logs exist
|
||||
has_db_logs = LogEntry.query.filter_by(run_id=run.id).first() is not None
|
||||
|
||||
# Locate Log File
|
||||
log_path = run.log_file
|
||||
if not log_path or not os.path.exists(log_path):
|
||||
# Try common fallback locations
|
||||
candidates = [
|
||||
os.path.join(run.project.folder_path, f"system_log_{run.id}.txt"),
|
||||
os.path.join(run.project.folder_path, "runs", "bible", f"run_{run.id}", "web_console.log")
|
||||
]
|
||||
for c in candidates:
|
||||
if os.path.exists(c):
|
||||
log_path = c
|
||||
break
|
||||
|
||||
if log_path and os.path.exists(log_path):
|
||||
if has_db_logs:
|
||||
# Logs are already in DB (New Run or previous migration). Mark file for cleanup.
|
||||
files_to_clean.append(log_path)
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(log_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
entries = []
|
||||
for line in lines:
|
||||
# Parse standard log format: [HH:MM:SS] PHASE | Message
|
||||
if '|' in line and line.strip().startswith('['):
|
||||
try:
|
||||
parts = line.split('|', 1)
|
||||
meta = parts[0].strip()
|
||||
msg = parts[1].strip()
|
||||
|
||||
if ']' in meta:
|
||||
ts_str = meta[1:meta.find(']')]
|
||||
phase = meta[meta.find(']')+1:].strip()
|
||||
|
||||
# Reconstruct datetime
|
||||
base_date = run.start_time.date() if run.start_time else datetime.utcnow().date()
|
||||
t_time = datetime.strptime(ts_str, "%H:%M:%S").time()
|
||||
dt = datetime.combine(base_date, t_time)
|
||||
|
||||
entries.append(LogEntry(run_id=run.id, timestamp=dt, phase=phase, message=msg))
|
||||
except: continue
|
||||
|
||||
if entries:
|
||||
db.session.add_all(entries)
|
||||
migrated += 1
|
||||
files_to_clean.append(log_path)
|
||||
except Exception as e:
|
||||
print(f"Migration failed for Run {run.id}: {e}")
|
||||
|
||||
if migrated > 0:
|
||||
db.session.commit()
|
||||
print(f"✅ Migrated logs for {migrated} runs to Database.")
|
||||
|
||||
# Cleanup files (even if no new migrations happened)
|
||||
if files_to_clean:
|
||||
count = 0
|
||||
for fpath in files_to_clean:
|
||||
try:
|
||||
os.remove(fpath)
|
||||
count += 1
|
||||
except: pass
|
||||
if count > 0:
|
||||
print(f"🧹 Cleaned up {count} redundant log files.")
|
||||
|
||||
# --- SETUP ---
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
migrate_logs()
|
||||
|
||||
# Auto-create Admin from Environment Variables (Docker/Portainer Setup)
|
||||
if config.ADMIN_USER and config.ADMIN_PASSWORD:
|
||||
@@ -126,6 +52,26 @@ with app.app_context():
|
||||
elif not admin.is_admin:
|
||||
admin.is_admin = True
|
||||
db.session.commit()
|
||||
|
||||
# Migration: Add 'progress' column if missing
|
||||
try:
|
||||
with db.engine.connect() as conn:
|
||||
conn.execute(text("ALTER TABLE run ADD COLUMN progress INTEGER DEFAULT 0"))
|
||||
conn.commit()
|
||||
print("✅ System: Added 'progress' column to Run table.")
|
||||
except: pass
|
||||
|
||||
# Reset stuck runs on startup
|
||||
try:
|
||||
stuck_runs = Run.query.filter_by(status='running').all()
|
||||
if stuck_runs:
|
||||
print(f"⚠️ System: Found {len(stuck_runs)} stuck runs. Resetting to 'failed'.")
|
||||
for r in stuck_runs:
|
||||
r.status = 'failed'
|
||||
r.end_time = datetime.utcnow()
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
print(f"⚠️ System: Failed to clean up stuck runs: {e}")
|
||||
|
||||
# --- DECORATORS ---
|
||||
def admin_required(f):
|
||||
@@ -137,6 +83,16 @@ def admin_required(f):
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
def is_project_locked(project_id):
|
||||
"""Returns True if the project has any completed runs (Book 1 written)."""
|
||||
return Run.query.filter_by(project_id=project_id, status='completed').count() > 0
|
||||
|
||||
def is_safe_url(target):
|
||||
ref_url = urlparse(request.host_url)
|
||||
test_url = urlparse(urljoin(request.host_url, target))
|
||||
return test_url.scheme in ('http', 'https') and \
|
||||
ref_url.netloc == test_url.netloc
|
||||
|
||||
# --- ROUTES ---
|
||||
|
||||
@app.route('/')
|
||||
@@ -153,7 +109,10 @@ def login():
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user and check_password_hash(user.password, password):
|
||||
login_user(user)
|
||||
return redirect(url_for('index'))
|
||||
next_page = request.args.get('next')
|
||||
if not next_page or not is_safe_url(next_page):
|
||||
next_page = url_for('index')
|
||||
return redirect(next_page)
|
||||
flash('Invalid credentials')
|
||||
return render_template('login.html')
|
||||
|
||||
@@ -167,10 +126,18 @@ def register():
|
||||
return redirect(url_for('register'))
|
||||
|
||||
new_user = User(username=username, password=generate_password_hash(password, method='pbkdf2:sha256'))
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
login_user(new_user)
|
||||
return redirect(url_for('index'))
|
||||
# Auto-promote if matches env var
|
||||
if config.ADMIN_USER and username == config.ADMIN_USER:
|
||||
new_user.is_admin = True
|
||||
try:
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
login_user(new_user)
|
||||
return redirect(url_for('index'))
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
flash('Username exists')
|
||||
return redirect(url_for('register'))
|
||||
return render_template('register.html')
|
||||
|
||||
@app.route('/project/setup', methods=['POST'])
|
||||
@@ -280,16 +247,16 @@ def project_setup_refine():
|
||||
@login_required
|
||||
def create_project_final():
|
||||
title = request.form.get('title')
|
||||
safe_title = "".join([c for c in title if c.isalnum() or c=='_']).replace(" ", "_")
|
||||
safe_title = utils.sanitize_filename(title)
|
||||
|
||||
user_dir = os.path.join(config.DATA_DIR, "users", str(current_user.id))
|
||||
if not os.path.exists(user_dir): os.makedirs(user_dir)
|
||||
os.makedirs(user_dir, exist_ok=True)
|
||||
|
||||
proj_path = os.path.join(user_dir, safe_title)
|
||||
if os.path.exists(proj_path):
|
||||
safe_title += f"_{int(datetime.utcnow().timestamp())}"
|
||||
proj_path = os.path.join(user_dir, safe_title)
|
||||
os.makedirs(proj_path)
|
||||
os.makedirs(proj_path, exist_ok=True)
|
||||
|
||||
# Construct Bible from Form Data
|
||||
length_cat = request.form.get('length_category')
|
||||
@@ -349,12 +316,11 @@ def create_project_final():
|
||||
"plot_beats": []
|
||||
})
|
||||
|
||||
# Enrich via AI immediately if concept exists
|
||||
if concept:
|
||||
try:
|
||||
ai.init_models()
|
||||
bible = story.enrich(bible, proj_path)
|
||||
except: pass
|
||||
# Enrich via AI immediately (Always, to ensure Bible is full)
|
||||
try:
|
||||
ai.init_models()
|
||||
bible = story.enrich(bible, proj_path)
|
||||
except: pass
|
||||
|
||||
with open(os.path.join(proj_path, "bible.json"), 'w') as f:
|
||||
json.dump(bible, f, indent=2)
|
||||
@@ -365,6 +331,52 @@ def create_project_final():
|
||||
|
||||
return redirect(url_for('view_project', id=new_proj.id))
|
||||
|
||||
@app.route('/project/import', methods=['POST'])
|
||||
@login_required
|
||||
def import_project():
|
||||
if 'bible_file' not in request.files:
|
||||
flash('No file part')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
file = request.files['bible_file']
|
||||
if file.filename == '':
|
||||
flash('No selected file')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
if file:
|
||||
try:
|
||||
bible = json.load(file)
|
||||
# Basic validation
|
||||
if 'project_metadata' not in bible or 'title' not in bible['project_metadata']:
|
||||
flash("Invalid Bible format: Missing project_metadata or title.")
|
||||
return redirect(url_for('index'))
|
||||
|
||||
title = bible['project_metadata']['title']
|
||||
safe_title = utils.sanitize_filename(title)
|
||||
|
||||
user_dir = os.path.join(config.DATA_DIR, "users", str(current_user.id))
|
||||
os.makedirs(user_dir, exist_ok=True)
|
||||
|
||||
proj_path = os.path.join(user_dir, safe_title)
|
||||
if os.path.exists(proj_path):
|
||||
safe_title += f"_{int(datetime.utcnow().timestamp())}"
|
||||
proj_path = os.path.join(user_dir, safe_title)
|
||||
os.makedirs(proj_path)
|
||||
|
||||
with open(os.path.join(proj_path, "bible.json"), 'w') as f:
|
||||
json.dump(bible, f, indent=2)
|
||||
|
||||
new_proj = Project(user_id=current_user.id, name=title, folder_path=proj_path)
|
||||
db.session.add(new_proj)
|
||||
db.session.commit()
|
||||
|
||||
flash(f"Project '{title}' imported successfully.")
|
||||
return redirect(url_for('view_project', id=new_proj.id))
|
||||
|
||||
except Exception as e:
|
||||
flash(f"Import failed: {str(e)}")
|
||||
return redirect(url_for('index'))
|
||||
|
||||
@app.route('/project/<int:id>')
|
||||
@login_required
|
||||
def view_project(id):
|
||||
@@ -393,11 +405,12 @@ def view_project(id):
|
||||
artifacts = []
|
||||
cover_image = None
|
||||
generated_books = {} # Map book_number -> {status: 'generated', run_id: int, folder: str}
|
||||
locked = is_project_locked(id)
|
||||
|
||||
# Scan ALL completed runs to find the latest status of each book
|
||||
for r in runs:
|
||||
if r.status == 'completed':
|
||||
run_dir = os.path.join(proj.folder_path, "runs", "bible", f"run_{r.id}")
|
||||
run_dir = os.path.join(proj.folder_path, "runs", f"run_{r.id}")
|
||||
if os.path.exists(run_dir):
|
||||
# 1. Scan for Generated Books
|
||||
for d in os.listdir(run_dir):
|
||||
@@ -420,13 +433,13 @@ def view_project(id):
|
||||
|
||||
# Collect Artifacts from Latest Run
|
||||
if latest_run:
|
||||
run_dir = os.path.join(proj.folder_path, "runs", "bible", f"run_{latest_run.id}")
|
||||
run_dir = os.path.join(proj.folder_path, "runs", f"run_{latest_run.id}")
|
||||
if os.path.exists(run_dir):
|
||||
# Find Cover Image (Root or First Book)
|
||||
if os.path.exists(os.path.join(run_dir, "cover.png")):
|
||||
cover_image = "cover.png"
|
||||
else:
|
||||
subdirs = sorted([d for d in os.listdir(run_dir) if os.path.isdir(os.path.join(run_dir, d)) and d.startswith("Book_")])
|
||||
subdirs = utils.get_sorted_book_folders(run_dir)
|
||||
for d in subdirs:
|
||||
if os.path.exists(os.path.join(run_dir, d, "cover.png")):
|
||||
cover_image = os.path.join(d, "cover.png").replace("\\", "/")
|
||||
@@ -442,7 +455,7 @@ def view_project(id):
|
||||
'type': f.split('.')[-1].upper()
|
||||
})
|
||||
|
||||
return render_template('project.html', project=proj, bible=bible_data, runs=runs, active_run=latest_run, artifacts=artifacts, cover_image=cover_image, personas=personas, generated_books=generated_books, other_projects=other_projects)
|
||||
return render_template('project.html', project=proj, bible=bible_data, runs=runs, active_run=latest_run, artifacts=artifacts, cover_image=cover_image, personas=personas, generated_books=generated_books, other_projects=other_projects, locked=locked)
|
||||
|
||||
@app.route('/project/<int:id>/run', methods=['POST'])
|
||||
@login_required
|
||||
@@ -477,6 +490,10 @@ def update_project_metadata(id):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if is_project_locked(id):
|
||||
flash("Project is locked. Clone it to make changes.")
|
||||
return redirect(url_for('view_project', id=id))
|
||||
|
||||
new_title = request.form.get('title')
|
||||
new_author = request.form.get('author')
|
||||
|
||||
@@ -495,12 +512,56 @@ def update_project_metadata(id):
|
||||
|
||||
return redirect(url_for('view_project', id=id))
|
||||
|
||||
@app.route('/project/<int:id>/clone', methods=['POST'])
|
||||
@login_required
|
||||
def clone_project(id):
|
||||
source_proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if source_proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
new_name = request.form.get('new_name')
|
||||
instruction = request.form.get('instruction')
|
||||
|
||||
# Create New Project
|
||||
safe_title = utils.sanitize_filename(new_name)
|
||||
user_dir = os.path.join(config.DATA_DIR, "users", str(current_user.id))
|
||||
new_path = os.path.join(user_dir, safe_title)
|
||||
if os.path.exists(new_path):
|
||||
safe_title += f"_{int(datetime.utcnow().timestamp())}"
|
||||
new_path = os.path.join(user_dir, safe_title)
|
||||
os.makedirs(new_path)
|
||||
|
||||
# Copy Bible
|
||||
source_bible_path = os.path.join(source_proj.folder_path, "bible.json")
|
||||
if os.path.exists(source_bible_path):
|
||||
bible = utils.load_json(source_bible_path)
|
||||
bible['project_metadata']['title'] = new_name
|
||||
|
||||
# Apply AI Instruction if provided
|
||||
if instruction:
|
||||
try:
|
||||
ai.init_models()
|
||||
bible = story.refine_bible(bible, instruction, new_path) or bible
|
||||
except: pass
|
||||
|
||||
with open(os.path.join(new_path, "bible.json"), 'w') as f: json.dump(bible, f, indent=2)
|
||||
|
||||
new_proj = Project(user_id=current_user.id, name=new_name, folder_path=new_path)
|
||||
db.session.add(new_proj)
|
||||
db.session.commit()
|
||||
|
||||
flash(f"Project cloned as '{new_name}'.")
|
||||
return redirect(url_for('view_project', id=new_proj.id))
|
||||
|
||||
@app.route('/project/<int:id>/refine_bible', methods=['POST'])
|
||||
@login_required
|
||||
def refine_bible_route(id):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if is_project_locked(id):
|
||||
flash("Project is locked. Clone it to make changes.")
|
||||
return redirect(url_for('view_project', id=id))
|
||||
|
||||
instruction = request.form.get('instruction')
|
||||
if not instruction:
|
||||
flash("Instruction required.")
|
||||
@@ -533,6 +594,10 @@ def add_book(id):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if is_project_locked(id):
|
||||
flash("Project is locked. Clone it to make changes.")
|
||||
return redirect(url_for('view_project', id=id))
|
||||
|
||||
title = request.form.get('title', 'Untitled')
|
||||
instruction = request.form.get('instruction', '')
|
||||
|
||||
@@ -566,6 +631,10 @@ def update_book_details(id, book_num):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if is_project_locked(id):
|
||||
flash("Project is locked. Clone it to make changes.")
|
||||
return redirect(url_for('view_project', id=id))
|
||||
|
||||
new_title = request.form.get('title')
|
||||
new_instruction = request.form.get('instruction')
|
||||
|
||||
@@ -576,7 +645,7 @@ def update_book_details(id, book_num):
|
||||
for b in bible['books']:
|
||||
if b.get('book_number') == book_num:
|
||||
if new_title: b['title'] = new_title
|
||||
if new_instruction: b['manual_instruction'] = new_instruction
|
||||
if new_instruction is not None: b['manual_instruction'] = new_instruction
|
||||
break
|
||||
with open(bible_path, 'w') as f: json.dump(bible, f, indent=2)
|
||||
flash(f"Book {book_num} updated.")
|
||||
@@ -589,6 +658,10 @@ def delete_book(id, book_num):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if is_project_locked(id):
|
||||
flash("Project is locked. Clone it to make changes.")
|
||||
return redirect(url_for('view_project', id=id))
|
||||
|
||||
bible_path = os.path.join(proj.folder_path, "bible.json")
|
||||
bible = utils.load_json(bible_path)
|
||||
|
||||
@@ -617,6 +690,10 @@ def import_characters(id):
|
||||
if not target_proj or not source_proj: return "Project not found", 404
|
||||
if target_proj.user_id != current_user.id or source_proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if is_project_locked(id):
|
||||
flash("Project is locked. Clone it to make changes.")
|
||||
return redirect(url_for('view_project', id=id))
|
||||
|
||||
target_bible = utils.load_json(os.path.join(target_proj.folder_path, "bible.json"))
|
||||
source_bible = utils.load_json(os.path.join(source_proj.folder_path, "bible.json"))
|
||||
|
||||
@@ -644,6 +721,10 @@ def set_project_persona(id):
|
||||
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
||||
if proj.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if is_project_locked(id):
|
||||
flash("Project is locked. Clone it to make changes.")
|
||||
return redirect(url_for('view_project', id=id))
|
||||
|
||||
persona_name = request.form.get('persona_name')
|
||||
|
||||
bible_path = os.path.join(proj.folder_path, "bible.json")
|
||||
@@ -671,11 +752,14 @@ def regenerate_artifacts(run_id):
|
||||
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
|
||||
if run.project.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if run.status == 'running':
|
||||
flash("Run is already active. Please wait for it to finish.")
|
||||
return redirect(url_for('view_run', id=run_id))
|
||||
|
||||
feedback = request.form.get('feedback')
|
||||
|
||||
# Reset state immediately so UI polls correctly
|
||||
run.status = 'queued'
|
||||
LogEntry.query.filter_by(run_id=run_id).delete()
|
||||
db.session.commit()
|
||||
|
||||
regenerate_artifacts_task(run_id, run.project.folder_path, feedback=feedback)
|
||||
@@ -692,6 +776,12 @@ def stop_run(id):
|
||||
run.status = 'cancelled'
|
||||
run.end_time = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
# Signal the backend process to stop by creating a .stop file
|
||||
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
|
||||
if os.path.exists(run_dir):
|
||||
with open(os.path.join(run_dir, ".stop"), 'w') as f: f.write("stop")
|
||||
|
||||
flash(f"Run {id} marked as cancelled.")
|
||||
|
||||
return redirect(url_for('view_project', id=run.project_id))
|
||||
@@ -709,10 +799,14 @@ def restart_run(id):
|
||||
|
||||
# Check mode: 'resume' (default) vs 'restart'
|
||||
mode = request.form.get('mode', 'resume')
|
||||
allow_copy = (mode == 'resume')
|
||||
feedback = request.form.get('feedback')
|
||||
keep_cover = 'keep_cover' in request.form
|
||||
force_regen = 'force_regenerate' in request.form
|
||||
allow_copy = (mode == 'resume' and not force_regen)
|
||||
if feedback: allow_copy = False # Force regeneration if feedback provided to ensure changes are applied
|
||||
|
||||
task = generate_book_task(new_run.id, run.project.folder_path, os.path.join(run.project.folder_path, "bible.json"), allow_copy=allow_copy)
|
||||
flash(f"Started new Run #{new_run.id}.")
|
||||
task = generate_book_task(new_run.id, run.project.folder_path, os.path.join(run.project.folder_path, "bible.json"), allow_copy=allow_copy, feedback=feedback, source_run_id=id if feedback else None, keep_cover=keep_cover)
|
||||
flash(f"Started new Run #{new_run.id}" + (" with modifications." if feedback else "."))
|
||||
return redirect(url_for('view_project', id=run.project_id))
|
||||
|
||||
@app.route('/run/<int:id>')
|
||||
@@ -732,13 +826,12 @@ def view_run(id):
|
||||
with open(run.log_file, 'r') as f: log_content = f.read()
|
||||
|
||||
# Fetch Artifacts for Display
|
||||
run_dir = os.path.join(run.project.folder_path, "runs", "bible", f"run_{run.id}")
|
||||
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
|
||||
|
||||
# Detect Books in Run (Series Support)
|
||||
books_data = []
|
||||
if os.path.exists(run_dir):
|
||||
subdirs = sorted([d for d in os.listdir(run_dir) if os.path.isdir(os.path.join(run_dir, d)) and d.startswith("Book_")])
|
||||
if not subdirs: subdirs = ["."] # Handle legacy/flat runs
|
||||
subdirs = utils.get_sorted_book_folders(run_dir)
|
||||
|
||||
for d in subdirs:
|
||||
b_path = os.path.join(run_dir, d)
|
||||
@@ -766,8 +859,8 @@ def view_run(id):
|
||||
|
||||
# Load Tracking Data for Run Details
|
||||
tracking = {"events": [], "characters": {}, "content_warnings": []}
|
||||
# We load tracking from the first book found to populate the general stats
|
||||
book_dir = os.path.join(run_dir, books_data[0]['folder']) if books_data else run_dir
|
||||
# We load tracking from the LAST book found to populate the general stats (most up-to-date)
|
||||
book_dir = os.path.join(run_dir, books_data[-1]['folder']) if books_data else run_dir
|
||||
if os.path.exists(book_dir):
|
||||
t_ev = os.path.join(book_dir, "tracking_events.json")
|
||||
t_ch = os.path.join(book_dir, "tracking_characters.json")
|
||||
@@ -789,11 +882,13 @@ def run_status(id):
|
||||
# Check status from DB or fallback to log file
|
||||
|
||||
log_content = ""
|
||||
last_log = None
|
||||
|
||||
# 1. Try Database Logs (Fastest & Best)
|
||||
logs = LogEntry.query.filter_by(run_id=id).order_by(LogEntry.timestamp).all()
|
||||
if logs:
|
||||
log_content = "\n".join([f"[{l.timestamp.strftime('%H:%M:%S')}] {l.phase:<15} | {l.message}" for l in logs])
|
||||
last_log = logs[-1]
|
||||
|
||||
# 2. Fallback to File (For old runs or if DB logging fails)
|
||||
if not log_content:
|
||||
@@ -804,7 +899,15 @@ def run_status(id):
|
||||
if os.path.exists(temp_log):
|
||||
with open(temp_log, 'r') as f: log_content = f.read()
|
||||
|
||||
return {"status": run.status, "log": log_content, "cost": run.cost}
|
||||
response = {"status": run.status, "log": log_content, "cost": run.cost, "percent": run.progress}
|
||||
|
||||
if last_log:
|
||||
response["progress"] = {
|
||||
"phase": last_log.phase,
|
||||
"message": last_log.message,
|
||||
"timestamp": last_log.timestamp.timestamp()
|
||||
}
|
||||
return response
|
||||
|
||||
@app.route('/project/<int:run_id>/download')
|
||||
@login_required
|
||||
@@ -813,25 +916,223 @@ def download_artifact(run_id):
|
||||
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
|
||||
if run.project.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
run_dir = os.path.join(run.project.folder_path, "runs", "bible", f"run_{run.id}")
|
||||
if not filename: return "Missing filename", 400
|
||||
|
||||
# Security Check: Prevent path traversal
|
||||
# Combined check using normpath to ensure it stays within root and catches basic traversal chars
|
||||
if os.path.isabs(filename) or ".." in os.path.normpath(filename) or ":" in filename:
|
||||
return "Invalid filename", 400
|
||||
|
||||
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
|
||||
|
||||
# If file not found in root, check subfolders (Series Support)
|
||||
if not os.path.exists(os.path.join(run_dir, filename)) and os.path.exists(run_dir):
|
||||
subdirs = sorted([d for d in os.listdir(run_dir) if os.path.isdir(os.path.join(run_dir, d)) and d.startswith("Book_")])
|
||||
if subdirs:
|
||||
# Try the first book folder
|
||||
possible_path = os.path.join(subdirs[0], filename)
|
||||
subdirs = utils.get_sorted_book_folders(run_dir)
|
||||
# Scan all book folders
|
||||
for d in subdirs:
|
||||
possible_path = os.path.join(d, filename)
|
||||
if os.path.exists(os.path.join(run_dir, possible_path)):
|
||||
filename = possible_path
|
||||
break
|
||||
|
||||
return send_from_directory(run_dir, filename, as_attachment=True)
|
||||
|
||||
@app.route('/project/<int:run_id>/read/<string:book_folder>')
|
||||
@login_required
|
||||
def read_book(run_id, book_folder):
|
||||
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
|
||||
if run.project.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
# Security Check: Prevent path traversal in book_folder
|
||||
if not book_folder or "/" in book_folder or "\\" in book_folder or ".." in book_folder: return "Invalid book folder", 400
|
||||
|
||||
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
|
||||
book_path = os.path.join(run_dir, book_folder)
|
||||
ms_path = os.path.join(book_path, "manuscript.json")
|
||||
|
||||
if not os.path.exists(ms_path):
|
||||
flash("Manuscript not found.")
|
||||
return redirect(url_for('view_run', id=run_id))
|
||||
|
||||
manuscript = utils.load_json(ms_path)
|
||||
|
||||
# Sort by chapter number (Handle Prologue/Epilogue)
|
||||
manuscript.sort(key=utils.chapter_sort_key)
|
||||
|
||||
# Render Markdown for display
|
||||
for ch in manuscript:
|
||||
ch['html_content'] = markdown.markdown(ch.get('content', ''))
|
||||
|
||||
return render_template('read_book.html', run=run, book_folder=book_folder, manuscript=manuscript)
|
||||
|
||||
@app.route('/project/<int:run_id>/save_chapter', methods=['POST'])
|
||||
@login_required
|
||||
def save_chapter(run_id):
|
||||
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
|
||||
if run.project.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if run.status == 'running':
|
||||
return "Cannot edit chapter while run is active.", 409
|
||||
|
||||
book_folder = request.form.get('book_folder')
|
||||
chap_num_raw = request.form.get('chapter_num')
|
||||
try: chap_num = int(chap_num_raw)
|
||||
except: chap_num = chap_num_raw
|
||||
new_content = request.form.get('content')
|
||||
|
||||
# Security Check
|
||||
if not book_folder or "/" in book_folder or "\\" in book_folder or ".." in book_folder: return "Invalid book folder", 400
|
||||
|
||||
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
|
||||
ms_path = os.path.join(run_dir, book_folder, "manuscript.json")
|
||||
|
||||
if os.path.exists(ms_path):
|
||||
ms = utils.load_json(ms_path)
|
||||
for ch in ms:
|
||||
if ch.get('num') == chap_num:
|
||||
ch['content'] = new_content
|
||||
break
|
||||
with open(ms_path, 'w') as f: json.dump(ms, f, indent=2)
|
||||
|
||||
# Regenerate Artifacts (EPUB/DOCX) to reflect manual edits
|
||||
book_path = os.path.join(run_dir, book_folder)
|
||||
bp_path = os.path.join(book_path, "final_blueprint.json")
|
||||
if os.path.exists(bp_path):
|
||||
bp = utils.load_json(bp_path)
|
||||
export.compile_files(bp, ms, book_path)
|
||||
|
||||
return "Saved", 200
|
||||
return "Error", 500
|
||||
|
||||
@app.route('/project/<int:run_id>/check_consistency/<string:book_folder>')
|
||||
@login_required
|
||||
def check_consistency(run_id, book_folder):
|
||||
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
|
||||
|
||||
# Security Check
|
||||
if not book_folder or "/" in book_folder or "\\" in book_folder or ".." in book_folder: return "Invalid book folder", 400
|
||||
|
||||
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
|
||||
book_path = os.path.join(run_dir, book_folder)
|
||||
|
||||
bp = utils.load_json(os.path.join(book_path, "final_blueprint.json"))
|
||||
ms = utils.load_json(os.path.join(book_path, "manuscript.json"))
|
||||
|
||||
if not bp or not ms:
|
||||
return "Data files missing or corrupt.", 404
|
||||
|
||||
try: ai.init_models()
|
||||
except: pass
|
||||
|
||||
report = story.analyze_consistency(bp, ms, book_path)
|
||||
return render_template('consistency_report.html', report=report, run=run, book_folder=book_folder)
|
||||
|
||||
@app.route('/project/<int:run_id>/sync_book/<string:book_folder>', methods=['POST'])
|
||||
@login_required
|
||||
def sync_book_metadata(run_id, book_folder):
|
||||
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
|
||||
if run.project.user_id != current_user.id: return "Unauthorized", 403
|
||||
|
||||
if run.status == 'running':
|
||||
flash("Cannot sync metadata while run is active.")
|
||||
return redirect(url_for('read_book', run_id=run_id, book_folder=book_folder))
|
||||
|
||||
# Security Check
|
||||
if not book_folder or "/" in book_folder or "\\" in book_folder or ".." in book_folder: return "Invalid book folder", 400
|
||||
|
||||
run_dir = os.path.join(run.project.folder_path, "runs", f"run_{run.id}")
|
||||
book_path = os.path.join(run_dir, book_folder)
|
||||
|
||||
ms_path = os.path.join(book_path, "manuscript.json")
|
||||
bp_path = os.path.join(book_path, "final_blueprint.json")
|
||||
|
||||
if os.path.exists(ms_path) and os.path.exists(bp_path):
|
||||
ms = utils.load_json(ms_path)
|
||||
bp = utils.load_json(bp_path)
|
||||
|
||||
if not ms or not bp:
|
||||
flash("Data files corrupt.")
|
||||
return redirect(url_for('read_book', run_id=run_id, book_folder=book_folder))
|
||||
|
||||
try: ai.init_models()
|
||||
except: pass
|
||||
|
||||
# 1. Harvest new characters/info from the EDITED text (Updates BP)
|
||||
bp = story.harvest_metadata(bp, book_path, ms)
|
||||
|
||||
# 2. Sync Tracking (Ensure new characters exist in tracking file)
|
||||
tracking_path = os.path.join(book_path, "tracking_characters.json")
|
||||
if os.path.exists(tracking_path):
|
||||
tracking_chars = utils.load_json(tracking_path) or {}
|
||||
updated_tracking = False
|
||||
for c in bp.get('characters', []):
|
||||
if c.get('name') and c['name'] not in tracking_chars:
|
||||
tracking_chars[c['name']] = {"descriptors": [c.get('description', '')], "likes_dislikes": [], "last_worn": "Unknown"}
|
||||
updated_tracking = True
|
||||
if updated_tracking:
|
||||
with open(tracking_path, 'w') as f: json.dump(tracking_chars, f, indent=2)
|
||||
|
||||
# 3. Update Persona (Style might have changed during manual edits)
|
||||
story.update_persona_sample(bp, book_path)
|
||||
|
||||
with open(bp_path, 'w') as f: json.dump(bp, f, indent=2)
|
||||
flash("Metadata synced. Future generations will respect your edits.")
|
||||
else:
|
||||
flash("Files not found.")
|
||||
|
||||
return redirect(url_for('read_book', run_id=run_id, book_folder=book_folder))
|
||||
|
||||
@app.route('/project/<int:run_id>/rewrite_chapter', methods=['POST'])
|
||||
@login_required
|
||||
def rewrite_chapter(run_id):
|
||||
run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id)
|
||||
if run.project.user_id != current_user.id:
|
||||
return {"error": "Unauthorized"}, 403
|
||||
|
||||
if run.status == 'running':
|
||||
return {"error": "Cannot rewrite while run is active."}, 409
|
||||
|
||||
data = request.json
|
||||
book_folder = data.get('book_folder')
|
||||
chap_num = data.get('chapter_num')
|
||||
instruction = data.get('instruction')
|
||||
|
||||
if not book_folder or chap_num is None or not instruction:
|
||||
return {"error": "Missing parameters"}, 400
|
||||
|
||||
# Security Check
|
||||
if "/" in book_folder or "\\" in book_folder or ".." in book_folder: return {"error": "Invalid book folder"}, 400
|
||||
|
||||
# Try to convert to int, but allow strings (e.g. "Epilogue")
|
||||
try: chap_num = int(chap_num)
|
||||
except: pass
|
||||
|
||||
# Start background task
|
||||
task = rewrite_chapter_task(run.id, run.project.folder_path, book_folder, chap_num, instruction)
|
||||
|
||||
# Store task ID in session to poll for status
|
||||
session['rewrite_task_id'] = task.id
|
||||
|
||||
return {"status": "queued", "task_id": task.id}, 202
|
||||
|
||||
@app.route('/task_status/<string:task_id>')
|
||||
@login_required
|
||||
def get_task_status(task_id):
|
||||
task_result = huey.result(task_id, peek=True)
|
||||
|
||||
if task_result is None:
|
||||
return {"status": "running"}
|
||||
else:
|
||||
return {"status": "completed", "success": task_result}
|
||||
|
||||
@app.route('/logout')
|
||||
def logout():
|
||||
logout_user()
|
||||
return redirect(url_for('login'))
|
||||
|
||||
@app.route('/debug/routes')
|
||||
@login_required
|
||||
@admin_required
|
||||
def debug_routes():
|
||||
output = []
|
||||
for rule in app.url_map.iter_rules():
|
||||
@@ -844,6 +1145,7 @@ def debug_routes():
|
||||
|
||||
@app.route('/system/optimize_models', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def optimize_models():
|
||||
# Force refresh via AI module (safely handles failures)
|
||||
try:
|
||||
@@ -859,12 +1161,6 @@ def optimize_models():
|
||||
|
||||
return redirect(request.referrer or url_for('index'))
|
||||
|
||||
# --- COMPATIBILITY ROUTES (Fix 404s) ---
|
||||
@app.route('/project/<int:project_id>/run/<int:run_id>')
|
||||
@login_required
|
||||
def legacy_run_redirect(project_id, run_id):
|
||||
return redirect(url_for('view_run', id=run_id))
|
||||
|
||||
@app.route('/system/status')
|
||||
@login_required
|
||||
def system_status():
|
||||
@@ -880,11 +1176,7 @@ def system_status():
|
||||
models_info = cache_data.get('models', {})
|
||||
except: pass
|
||||
|
||||
# Create a placeholder run object so the template doesn't crash
|
||||
dummy_project = SimpleNamespace(user_id=current_user.id, name="System", folder_path="")
|
||||
dummy_run = SimpleNamespace(id=0, status="System Status", cost=0.0, log_file=None, start_time=datetime.utcnow(), project=dummy_project, duration=lambda: "N/A")
|
||||
|
||||
return render_template('system_status.html', run=dummy_run, models=models_info, cache=cache_data, datetime=datetime)
|
||||
return render_template('system_status.html', models=models_info, cache=cache_data, datetime=datetime)
|
||||
|
||||
@app.route('/personas')
|
||||
@login_required
|
||||
@@ -1105,7 +1397,7 @@ def admin_spend_report():
|
||||
).join(Project, Project.user_id == User.id)\
|
||||
.join(Run, Run.project_id == Project.id)\
|
||||
.filter(Run.start_time >= start_date)\
|
||||
.group_by(User.id).all()
|
||||
.group_by(User.id, User.username).all()
|
||||
|
||||
report = []
|
||||
total_period_spend = 0.0
|
||||
@@ -1182,7 +1474,7 @@ if __name__ == '__main__':
|
||||
c.run()
|
||||
|
||||
# Configuration
|
||||
debug_mode = True
|
||||
debug_mode = os.environ.get("FLASK_DEBUG", "False").lower() == "true"
|
||||
|
||||
# Run worker if: 1. In reloader child process OR 2. Reloader is disabled (debug=False)
|
||||
if os.environ.get("WERKZEUG_RUN_MAIN") == "true" or not debug_mode:
|
||||
|
||||
Reference in New Issue
Block a user