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 if not ai_models.model_logic: flash("AI models not initialized.") return redirect(url_for('project.index')) 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" }} """ suggestions = {} try: response = ai_models.model_logic.generate_content(prompt) suggestions = json.loads(utils.clean_json(response.text)) except Exception as e: flash(f"AI Analysis failed: {e}") suggestions = {"title": "New Project", "genre": "Fiction"} 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() bible = planner.enrich(bible, proj_path) except: 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/') @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 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, 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//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//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//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//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//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//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//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//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//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//book//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//delete_book/', 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//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//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//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//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//revise_book/', 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))