- Remove ai_blueprint.md from git tracking (already gitignored) - web/app.py: Unify startup reset — all non-terminal states (running, queued, interrupted) are reset to 'failed' with per-job logging - web/routes/project.py: Add active_runs list to view_project() context - templates/project.html: Add Active Jobs card showing all running/queued jobs with status badge, start time, progress bar, and View Details link; Generate button and Stop buttons now driven by active_runs list Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
816 lines
30 KiB
Python
816 lines
30 KiB
Python
import os
|
|
import json
|
|
import shutil
|
|
from datetime import datetime
|
|
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
|
from flask_login import login_required, current_user
|
|
from web.db import db, Project, Run
|
|
from web.helpers import is_project_locked
|
|
from core import config, utils
|
|
from ai import models as ai_models
|
|
from ai import setup as ai_setup
|
|
from story import planner, bible_tracker
|
|
from web.tasks import generate_book_task, refine_bible_task
|
|
|
|
project_bp = Blueprint('project', __name__)
|
|
|
|
|
|
@project_bp.route('/')
|
|
@login_required
|
|
def index():
|
|
projects = Project.query.filter_by(user_id=current_user.id).all()
|
|
return render_template('dashboard.html', projects=projects, user=current_user)
|
|
|
|
|
|
@project_bp.route('/project/setup', methods=['POST'])
|
|
@login_required
|
|
def project_setup_wizard():
|
|
concept = request.form.get('concept')
|
|
|
|
try: ai_setup.init_models()
|
|
except: pass
|
|
|
|
prompt = f"""
|
|
ROLE: Publishing Analyst
|
|
TASK: Suggest metadata for a story concept.
|
|
|
|
CONCEPT: {concept}
|
|
|
|
OUTPUT_FORMAT (JSON):
|
|
{{
|
|
"title": "String",
|
|
"genre": "String",
|
|
"target_audience": "String",
|
|
"tone": "String",
|
|
"length_category": "String (Select code: '01'=Chapter Book, '1'=Flash Fiction, '2'=Short Story, '2b'=Young Adult, '3'=Novella, '4'=Novel, '5'=Epic)",
|
|
"estimated_chapters": Int,
|
|
"estimated_word_count": "String (e.g. '75,000')",
|
|
"include_prologue": Bool,
|
|
"include_epilogue": Bool,
|
|
"tropes": ["String"],
|
|
"pov_style": "String",
|
|
"time_period": "String",
|
|
"spice": "String",
|
|
"violence": "String",
|
|
"is_series": Bool,
|
|
"series_title": "String",
|
|
"narrative_tense": "String",
|
|
"language_style": "String",
|
|
"dialogue_style": "String",
|
|
"page_orientation": "Portrait|Landscape|Square",
|
|
"formatting_rules": ["String (e.g. 'Chapter Headers: Number + Title')"],
|
|
"author_bio": "String"
|
|
}}
|
|
"""
|
|
|
|
_default_suggestions = {
|
|
"title": concept[:60] if concept else "New Project",
|
|
"genre": "Fiction",
|
|
"target_audience": "",
|
|
"tone": "",
|
|
"length_category": "4",
|
|
"estimated_chapters": 20,
|
|
"estimated_word_count": "75,000",
|
|
"include_prologue": False,
|
|
"include_epilogue": False,
|
|
"tropes": [],
|
|
"pov_style": "",
|
|
"time_period": "Modern",
|
|
"spice": "",
|
|
"violence": "",
|
|
"is_series": False,
|
|
"series_title": "",
|
|
"narrative_tense": "",
|
|
"language_style": "",
|
|
"dialogue_style": "",
|
|
"page_orientation": "Portrait",
|
|
"formatting_rules": [],
|
|
"author_bio": ""
|
|
}
|
|
|
|
suggestions = {}
|
|
if not ai_models.model_logic:
|
|
flash("AI models not initialized — fill in the details manually.", "warning")
|
|
suggestions = _default_suggestions
|
|
else:
|
|
try:
|
|
response = ai_models.model_logic.generate_content(prompt)
|
|
suggestions = json.loads(utils.clean_json(response.text))
|
|
# Ensure list fields are always lists
|
|
for list_field in ("tropes", "formatting_rules"):
|
|
if not isinstance(suggestions.get(list_field), list):
|
|
suggestions[list_field] = []
|
|
except Exception as e:
|
|
flash(f"AI Analysis failed — fill in the details manually. ({e})", "warning")
|
|
suggestions = _default_suggestions
|
|
|
|
personas = {}
|
|
if os.path.exists(config.PERSONAS_FILE):
|
|
try:
|
|
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
|
except: pass
|
|
|
|
return render_template('project_setup.html', s=suggestions, concept=concept, personas=personas, lengths=config.LENGTH_DEFINITIONS)
|
|
|
|
|
|
@project_bp.route('/project/setup/refine', methods=['POST'])
|
|
@login_required
|
|
def project_setup_refine():
|
|
concept = request.form.get('concept')
|
|
instruction = request.form.get('refine_instruction')
|
|
|
|
current_state = {
|
|
"title": request.form.get('title'),
|
|
"genre": request.form.get('genre'),
|
|
"target_audience": request.form.get('audience'),
|
|
"tone": request.form.get('tone'),
|
|
}
|
|
|
|
try: ai_setup.init_models()
|
|
except: pass
|
|
|
|
prompt = f"""
|
|
ROLE: Publishing Analyst
|
|
TASK: Refine project metadata based on user instruction.
|
|
|
|
INPUT_DATA:
|
|
- ORIGINAL_CONCEPT: {concept}
|
|
- CURRENT_TITLE: {current_state['title']}
|
|
- INSTRUCTION: {instruction}
|
|
|
|
OUTPUT_FORMAT (JSON): Same structure as the initial analysis (title, genre, length_category, etc). Ensure length_category matches the word count.
|
|
"""
|
|
|
|
suggestions = {}
|
|
try:
|
|
response = ai_models.model_logic.generate_content(prompt)
|
|
suggestions = json.loads(utils.clean_json(response.text))
|
|
except Exception as e:
|
|
flash(f"Refinement failed: {e}")
|
|
return redirect(url_for('project.index'))
|
|
|
|
personas = {}
|
|
if os.path.exists(config.PERSONAS_FILE):
|
|
try:
|
|
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
|
except: pass
|
|
|
|
return render_template('project_setup.html', s=suggestions, concept=concept, personas=personas, lengths=config.LENGTH_DEFINITIONS)
|
|
|
|
|
|
@project_bp.route('/project/create', methods=['POST'])
|
|
@login_required
|
|
def create_project_final():
|
|
title = request.form.get('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, exist_ok=True)
|
|
|
|
length_cat = request.form.get('length_category')
|
|
len_def = config.LENGTH_DEFINITIONS.get(length_cat, config.LENGTH_DEFINITIONS['4']).copy()
|
|
|
|
try: len_def['chapters'] = int(request.form.get('chapters'))
|
|
except: pass
|
|
len_def['words'] = request.form.get('words')
|
|
len_def['include_prologue'] = 'include_prologue' in request.form
|
|
len_def['include_epilogue'] = 'include_epilogue' in request.form
|
|
|
|
is_series = 'is_series' in request.form
|
|
|
|
style = {
|
|
"tone": request.form.get('tone'),
|
|
"pov_style": request.form.get('pov_style'),
|
|
"time_period": request.form.get('time_period'),
|
|
"spice": request.form.get('spice'),
|
|
"violence": request.form.get('violence'),
|
|
"narrative_tense": request.form.get('narrative_tense'),
|
|
"language_style": request.form.get('language_style'),
|
|
"dialogue_style": request.form.get('dialogue_style'),
|
|
"page_orientation": request.form.get('page_orientation'),
|
|
"tropes": [x.strip() for x in request.form.get('tropes', '').split(',') if x.strip()],
|
|
"formatting_rules": [x.strip() for x in request.form.get('formatting_rules', '').split(',') if x.strip()]
|
|
}
|
|
|
|
bible = {
|
|
"project_metadata": {
|
|
"title": title,
|
|
"author": request.form.get('author'),
|
|
"author_bio": request.form.get('author_bio'),
|
|
"genre": request.form.get('genre'),
|
|
"target_audience": request.form.get('audience'),
|
|
"is_series": is_series,
|
|
"length_settings": len_def,
|
|
"style": style
|
|
},
|
|
"books": [],
|
|
"characters": []
|
|
}
|
|
|
|
count = 1
|
|
if is_series:
|
|
try: count = int(request.form.get('series_count', 1))
|
|
except: count = 3
|
|
|
|
concept = request.form.get('concept', '')
|
|
|
|
for i in range(count):
|
|
bible['books'].append({
|
|
"book_number": i+1,
|
|
"title": f"{title} - Book {i+1}" if is_series else title,
|
|
"manual_instruction": concept if i==0 else "",
|
|
"plot_beats": []
|
|
})
|
|
|
|
try:
|
|
ai_setup.init_models()
|
|
# Build a per-book blueprint matching what enrich() expects
|
|
first_book = bible['books'][0] if bible.get('books') else {}
|
|
bp = {
|
|
'manual_instruction': first_book.get('manual_instruction', concept),
|
|
'book_metadata': {
|
|
'title': bible['project_metadata']['title'],
|
|
'genre': bible['project_metadata']['genre'],
|
|
'style': dict(bible['project_metadata'].get('style', {})),
|
|
},
|
|
'length_settings': dict(bible['project_metadata'].get('length_settings', {})),
|
|
'characters': [],
|
|
'plot_beats': [],
|
|
}
|
|
bp = planner.enrich(bp, proj_path)
|
|
# Merge enriched characters and plot_beats back into the bible
|
|
if bp.get('characters'):
|
|
bible['characters'] = bp['characters']
|
|
if bp.get('plot_beats') and bible.get('books'):
|
|
bible['books'][0]['plot_beats'] = bp['plot_beats']
|
|
# Merge enriched style fields back (structure_prompt, content_warnings)
|
|
bm = bp.get('book_metadata', {})
|
|
if bm.get('structure_prompt') and bible.get('books'):
|
|
bible['books'][0]['structure_prompt'] = bm['structure_prompt']
|
|
if bm.get('content_warnings'):
|
|
bible['project_metadata']['content_warnings'] = bm['content_warnings']
|
|
except Exception:
|
|
pass
|
|
|
|
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()
|
|
|
|
return redirect(url_for('project.view_project', id=new_proj.id))
|
|
|
|
|
|
@project_bp.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('project.index'))
|
|
|
|
file = request.files['bible_file']
|
|
if file.filename == '':
|
|
flash('No selected file')
|
|
return redirect(url_for('project.index'))
|
|
|
|
if file:
|
|
try:
|
|
bible = json.load(file)
|
|
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('project.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('project.view_project', id=new_proj.id))
|
|
|
|
except Exception as e:
|
|
flash(f"Import failed: {str(e)}")
|
|
return redirect(url_for('project.index'))
|
|
|
|
|
|
@project_bp.route('/project/<int:id>')
|
|
@login_required
|
|
def view_project(id):
|
|
proj = db.session.get(Project, id)
|
|
if not proj: return "Project not found", 404
|
|
|
|
if proj.user_id != current_user.id: return "Unauthorized", 403
|
|
|
|
bible_path = os.path.join(proj.folder_path, "bible.json")
|
|
bible_data = utils.load_json(bible_path)
|
|
|
|
draft_path = os.path.join(proj.folder_path, "bible_draft.json")
|
|
has_draft = os.path.exists(draft_path)
|
|
is_refining = os.path.exists(os.path.join(proj.folder_path, ".refining"))
|
|
|
|
personas = {}
|
|
if os.path.exists(config.PERSONAS_FILE):
|
|
try:
|
|
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
|
except: pass
|
|
|
|
runs = Run.query.filter_by(project_id=id).order_by(Run.id.desc()).all()
|
|
latest_run = runs[0] if runs else None
|
|
active_runs = [r for r in runs if r.status in ('running', 'queued')]
|
|
|
|
other_projects = Project.query.filter(Project.user_id == current_user.id, Project.id != id).all()
|
|
|
|
artifacts = []
|
|
cover_image = None
|
|
generated_books = {}
|
|
locked = is_project_locked(id)
|
|
|
|
for r in runs:
|
|
if r.status == 'completed':
|
|
run_dir = os.path.join(proj.folder_path, "runs", f"run_{r.id}")
|
|
if os.path.exists(run_dir):
|
|
for d in os.listdir(run_dir):
|
|
if d.startswith("Book_") and os.path.isdir(os.path.join(run_dir, d)):
|
|
if os.path.exists(os.path.join(run_dir, d, "manuscript.json")):
|
|
try:
|
|
parts = d.split('_')
|
|
if len(parts) > 1 and parts[1].isdigit():
|
|
b_num = int(parts[1])
|
|
if b_num not in generated_books:
|
|
book_path = os.path.join(run_dir, d)
|
|
epub_file = next((f for f in os.listdir(book_path) if f.endswith('.epub')), None)
|
|
docx_file = next((f for f in os.listdir(book_path) if f.endswith('.docx')), None)
|
|
generated_books[b_num] = {'status': 'generated', 'run_id': r.id, 'folder': d, 'epub': os.path.join(d, epub_file).replace("\\", "/") if epub_file else None, 'docx': os.path.join(d, docx_file).replace("\\", "/") if docx_file else None}
|
|
except: pass
|
|
|
|
if latest_run:
|
|
run_dir = os.path.join(proj.folder_path, "runs", f"run_{latest_run.id}")
|
|
if os.path.exists(run_dir):
|
|
if os.path.exists(os.path.join(run_dir, "cover.png")):
|
|
cover_image = "cover.png"
|
|
else:
|
|
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("\\", "/")
|
|
break
|
|
|
|
for root, dirs, files in os.walk(run_dir):
|
|
for f in files:
|
|
if f.lower().endswith(('.epub', '.docx')):
|
|
rel_path = os.path.relpath(os.path.join(root, f), run_dir)
|
|
artifacts.append({
|
|
'name': f,
|
|
'path': rel_path.replace("\\", "/"),
|
|
'type': f.split('.')[-1].upper()
|
|
})
|
|
|
|
return render_template('project.html', project=proj, bible=bible_data, runs=runs, active_run=latest_run, active_runs=active_runs, artifacts=artifacts, cover_image=cover_image, personas=personas, generated_books=generated_books, other_projects=other_projects, locked=locked, has_draft=has_draft, is_refining=is_refining)
|
|
|
|
|
|
@project_bp.route('/project/<int:id>/run', methods=['POST'])
|
|
@login_required
|
|
def run_project(id):
|
|
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
|
|
|
new_run = Run(project_id=id, status="queued")
|
|
db.session.add(new_run)
|
|
db.session.commit()
|
|
|
|
bible_path = os.path.join(proj.folder_path, "bible.json")
|
|
generate_book_task(new_run.id, proj.folder_path, bible_path, allow_copy=True)
|
|
|
|
return redirect(url_for('project.view_project', id=id))
|
|
|
|
|
|
@project_bp.route('/project/<int:id>/review')
|
|
@login_required
|
|
def review_project(id):
|
|
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
|
if proj.user_id != current_user.id: return "Unauthorized", 403
|
|
|
|
bible_path = os.path.join(proj.folder_path, "bible.json")
|
|
bible = utils.load_json(bible_path)
|
|
|
|
return render_template('project_review.html', project=proj, bible=bible)
|
|
|
|
|
|
@project_bp.route('/project/<int:id>/update', methods=['POST'])
|
|
@login_required
|
|
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('project.view_project', id=id))
|
|
|
|
new_title = request.form.get('title')
|
|
new_author = request.form.get('author')
|
|
|
|
bible_path = os.path.join(proj.folder_path, "bible.json")
|
|
bible = utils.load_json(bible_path)
|
|
|
|
if bible:
|
|
if new_title:
|
|
bible['project_metadata']['title'] = new_title
|
|
proj.name = new_title
|
|
if new_author:
|
|
bible['project_metadata']['author'] = new_author
|
|
|
|
with open(bible_path, 'w') as f: json.dump(bible, f, indent=2)
|
|
db.session.commit()
|
|
|
|
return redirect(url_for('project.view_project', id=id))
|
|
|
|
|
|
@project_bp.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')
|
|
|
|
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)
|
|
|
|
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
|
|
|
|
if instruction:
|
|
try:
|
|
ai_setup.init_models()
|
|
bible = bible_tracker.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('project.view_project', id=new_proj.id))
|
|
|
|
|
|
@project_bp.route('/project/<int:id>/bible_comparison')
|
|
@login_required
|
|
def bible_comparison(id):
|
|
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
|
if proj.user_id != current_user.id: return "Unauthorized", 403
|
|
|
|
bible_path = os.path.join(proj.folder_path, "bible.json")
|
|
draft_path = os.path.join(proj.folder_path, "bible_draft.json")
|
|
|
|
if not os.path.exists(draft_path):
|
|
flash("No draft found. Please refine the bible first.")
|
|
return redirect(url_for('project.review_project', id=id))
|
|
|
|
original = utils.load_json(bible_path)
|
|
new_draft = utils.load_json(draft_path)
|
|
|
|
if not original or not new_draft:
|
|
flash("Error loading bible data. Draft may be corrupt.")
|
|
return redirect(url_for('project.review_project', id=id))
|
|
|
|
return render_template('bible_comparison.html', project=proj, original=original, new=new_draft)
|
|
|
|
|
|
@project_bp.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('project.view_project', id=id))
|
|
|
|
data = request.json if request.is_json else request.form
|
|
instruction = data.get('instruction')
|
|
|
|
if not instruction:
|
|
return {"error": "Instruction required"}, 400
|
|
|
|
source_type = data.get('source', 'original')
|
|
selected_keys = data.get('selected_keys')
|
|
if isinstance(selected_keys, str):
|
|
try: selected_keys = json.loads(selected_keys) if selected_keys.strip() else []
|
|
except: selected_keys = []
|
|
|
|
task = refine_bible_task(proj.folder_path, instruction, source_type, selected_keys)
|
|
|
|
return {"status": "queued", "task_id": task.id}
|
|
|
|
|
|
@project_bp.route('/project/<int:id>/is_refining')
|
|
@login_required
|
|
def check_refinement_status(id):
|
|
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
|
if proj.user_id != current_user.id: return "Unauthorized", 403
|
|
is_refining = os.path.exists(os.path.join(proj.folder_path, ".refining"))
|
|
return {"is_refining": is_refining}
|
|
|
|
|
|
@project_bp.route('/project/<int:id>/refine_bible/confirm', methods=['POST'])
|
|
@login_required
|
|
def confirm_bible_refinement(id):
|
|
proj = db.session.get(Project, id) or Project.query.get_or_404(id)
|
|
if proj.user_id != current_user.id: return "Unauthorized", 403
|
|
|
|
action = request.form.get('action')
|
|
draft_path = os.path.join(proj.folder_path, "bible_draft.json")
|
|
bible_path = os.path.join(proj.folder_path, "bible.json")
|
|
|
|
if action == 'accept' or action == 'accept_all':
|
|
if os.path.exists(draft_path):
|
|
shutil.move(draft_path, bible_path)
|
|
flash("Bible updated successfully.")
|
|
else:
|
|
flash("Draft expired or missing.")
|
|
|
|
elif action == 'accept_selected':
|
|
if os.path.exists(draft_path) and os.path.exists(bible_path):
|
|
selected_keys_json = request.form.get('selected_keys', '[]')
|
|
try:
|
|
selected_keys = json.loads(selected_keys_json)
|
|
draft = utils.load_json(draft_path)
|
|
original = utils.load_json(bible_path)
|
|
|
|
original = bible_tracker.merge_selected_changes(original, draft, selected_keys)
|
|
|
|
with open(bible_path, 'w') as f: json.dump(original, f, indent=2)
|
|
os.remove(draft_path)
|
|
flash(f"Merged {len(selected_keys)} changes into Bible.")
|
|
|
|
except Exception as e:
|
|
flash(f"Merge failed: {e}")
|
|
else:
|
|
flash("Files missing.")
|
|
|
|
elif action == 'decline':
|
|
if os.path.exists(draft_path):
|
|
os.remove(draft_path)
|
|
flash("Changes discarded.")
|
|
|
|
return redirect(url_for('project.view_project', id=id))
|
|
|
|
|
|
@project_bp.route('/project/<int:id>/add_book', methods=['POST'])
|
|
@login_required
|
|
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('project.view_project', id=id))
|
|
|
|
title = request.form.get('title', 'Untitled')
|
|
instruction = request.form.get('instruction', '')
|
|
|
|
bible_path = os.path.join(proj.folder_path, "bible.json")
|
|
bible = utils.load_json(bible_path)
|
|
|
|
if bible:
|
|
if 'books' not in bible: bible['books'] = []
|
|
next_num = len(bible['books']) + 1
|
|
|
|
new_book = {
|
|
"book_number": next_num,
|
|
"title": title,
|
|
"manual_instruction": instruction,
|
|
"plot_beats": []
|
|
}
|
|
bible['books'].append(new_book)
|
|
|
|
if 'project_metadata' in bible:
|
|
bible['project_metadata']['is_series'] = True
|
|
|
|
with open(bible_path, 'w') as f: json.dump(bible, f, indent=2)
|
|
flash(f"Added Book {next_num}: {title}")
|
|
|
|
return redirect(url_for('project.view_project', id=id))
|
|
|
|
|
|
@project_bp.route('/project/<int:id>/book/<int:book_num>/update', methods=['POST'])
|
|
@login_required
|
|
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('project.view_project', id=id))
|
|
|
|
new_title = request.form.get('title')
|
|
new_instruction = request.form.get('instruction')
|
|
|
|
bible_path = os.path.join(proj.folder_path, "bible.json")
|
|
bible = utils.load_json(bible_path)
|
|
|
|
if bible and 'books' in bible:
|
|
for b in bible['books']:
|
|
if b.get('book_number') == book_num:
|
|
if new_title: b['title'] = new_title
|
|
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.")
|
|
|
|
return redirect(url_for('project.view_project', id=id))
|
|
|
|
|
|
@project_bp.route('/project/<int:id>/delete_book/<int:book_num>', methods=['POST'])
|
|
@login_required
|
|
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('project.view_project', id=id))
|
|
|
|
bible_path = os.path.join(proj.folder_path, "bible.json")
|
|
bible = utils.load_json(bible_path)
|
|
|
|
if bible and 'books' in bible:
|
|
bible['books'] = [b for b in bible['books'] if b.get('book_number') != book_num]
|
|
for i, b in enumerate(bible['books']):
|
|
b['book_number'] = i + 1
|
|
|
|
if 'project_metadata' in bible:
|
|
bible['project_metadata']['is_series'] = (len(bible['books']) > 1)
|
|
|
|
with open(bible_path, 'w') as f: json.dump(bible, f, indent=2)
|
|
flash("Book deleted from plan.")
|
|
|
|
return redirect(url_for('project.view_project', id=id))
|
|
|
|
|
|
@project_bp.route('/project/<int:id>/import_characters', methods=['POST'])
|
|
@login_required
|
|
def import_characters(id):
|
|
target_proj = db.session.get(Project, id)
|
|
source_id = request.form.get('source_project_id')
|
|
source_proj = db.session.get(Project, source_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('project.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"))
|
|
|
|
if target_bible and source_bible:
|
|
existing_names = {c['name'].lower() for c in target_bible.get('characters', [])}
|
|
added_count = 0
|
|
|
|
for char in source_bible.get('characters', []):
|
|
if char['name'].lower() not in existing_names:
|
|
target_bible['characters'].append(char)
|
|
added_count += 1
|
|
|
|
if added_count > 0:
|
|
with open(os.path.join(target_proj.folder_path, "bible.json"), 'w') as f:
|
|
json.dump(target_bible, f, indent=2)
|
|
flash(f"Imported {added_count} characters from {source_proj.name}.")
|
|
else:
|
|
flash("No new characters found to import.")
|
|
|
|
return redirect(url_for('project.view_project', id=id))
|
|
|
|
|
|
@project_bp.route('/project/<int:id>/set_persona', methods=['POST'])
|
|
@login_required
|
|
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('project.view_project', id=id))
|
|
|
|
persona_name = request.form.get('persona_name')
|
|
|
|
bible_path = os.path.join(proj.folder_path, "bible.json")
|
|
bible = utils.load_json(bible_path)
|
|
|
|
if bible:
|
|
personas = {}
|
|
if os.path.exists(config.PERSONAS_FILE):
|
|
try:
|
|
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
|
|
except: pass
|
|
|
|
if persona_name in personas:
|
|
bible['project_metadata']['author_details'] = personas[persona_name]
|
|
with open(bible_path, 'w') as f: json.dump(bible, f, indent=2)
|
|
flash(f"Project voice updated to persona: {persona_name}")
|
|
else:
|
|
flash("Persona not found.")
|
|
|
|
return redirect(url_for('project.view_project', id=id))
|
|
|
|
|
|
@project_bp.route('/run/<int:id>/stop', methods=['POST'])
|
|
@login_required
|
|
def stop_run(id):
|
|
run = db.session.get(Run, id) or Run.query.get_or_404(id)
|
|
if run.project.user_id != current_user.id: return "Unauthorized", 403
|
|
|
|
if run.status in ['queued', 'running']:
|
|
run.status = 'cancelled'
|
|
run.end_time = datetime.utcnow()
|
|
db.session.commit()
|
|
|
|
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('project.view_project', id=run.project_id))
|
|
|
|
|
|
@project_bp.route('/run/<int:id>/restart', methods=['POST'])
|
|
@login_required
|
|
def restart_run(id):
|
|
run = db.session.get(Run, id) or Run.query.get_or_404(id)
|
|
if run.project.user_id != current_user.id: return "Unauthorized", 403
|
|
|
|
new_run = Run(project_id=run.project_id, status="queued")
|
|
db.session.add(new_run)
|
|
db.session.commit()
|
|
|
|
mode = request.form.get('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
|
|
|
|
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('project.view_project', id=run.project_id))
|
|
|
|
|
|
@project_bp.route('/project/<int:run_id>/revise_book/<string:book_folder>', methods=['POST'])
|
|
@login_required
|
|
def revise_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
|
|
|
|
instruction = request.form.get('instruction')
|
|
|
|
new_run = Run(project_id=run.project_id, status="queued")
|
|
db.session.add(new_run)
|
|
db.session.commit()
|
|
|
|
generate_book_task(
|
|
new_run.id,
|
|
run.project.folder_path,
|
|
os.path.join(run.project.folder_path, "bible.json"),
|
|
allow_copy=True,
|
|
feedback=instruction,
|
|
source_run_id=run.id,
|
|
keep_cover=True,
|
|
exclude_folders=[book_folder]
|
|
)
|
|
|
|
flash(f"Started Revision Run #{new_run.id}. Book '{book_folder}' will be regenerated.")
|
|
return redirect(url_for('project.view_project', id=run.project_id))
|