diff --git a/CLAUDE.md b/CLAUDE.md index 35203ba..07ad995 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,12 +24,13 @@ services/ prompts.py # Prompt building + dedup (build_prompt, build_extras_prompt) llm.py # LLM integration + MCP tool calls (call_llm, load_prompt) mcp.py # MCP/Docker server lifecycle (ensure_mcp_server_running) - sync.py # All sync_*() functions + preset resolution helpers + sync.py # Generic _sync_category() + sync_characters/sync_checkpoints + preset resolution helpers job_queue.py # Background job queue (_enqueue_job, _make_finalize, worker thread) file_io.py # LoRA/checkpoint scanning, file helpers generation.py # Shared generation logic (generate_from_preset) routes/ __init__.py # register_routes(app) — imports and calls all route modules + shared.py # Factory functions for common routes (favourite, upload, clone, save_json, etc.) characters.py # Character CRUD + generation + outfit management outfits.py # Outfit routes actions.py # Action routes @@ -48,6 +49,10 @@ routes/ api.py # REST API v1 (preset generation, auth) regenerate.py # Tag regeneration (single + bulk, via LLM queue) search.py # Global search across resources and gallery images +static/js/ + detail-common.js # Shared detail page JS (initDetailPage: preview, generation, batch, endless, JSON editor) + layout-utils.js # Extracted from layout.html (confirmResourceDelete, regenerateTags, initJsonEditor) + library-toolbar.js # Library page toolbar (batch generate, clear covers, missing items) ``` ### Dependency Graph @@ -67,16 +72,19 @@ app.py │ ├── file_io.py ← models, utils │ └── generation.py ← prompts, workflow, job_queue, sync, models └── routes/ - ├── All route modules ← services/*, utils, models - └── (routes never import from other routes) + ├── shared.py ← models, utils, services (lazy-init to avoid circular imports) + ├── All route modules ← services/*, utils, models, shared + └── (routes never import from other routes except shared.py) ``` -**No circular imports**: routes → services → utils/models. Services never import routes. Utils never imports services. +**No circular imports**: routes → services → utils/models. Services never import routes. Utils never imports services. `routes/shared.py` uses `_init_models()` lazy initialization to avoid circular imports with model classes. ### Route Registration Pattern Routes use a `register_routes(app)` closure pattern — each route module defines a function that receives the Flask `app` object and registers routes via `@app.route()` closures. This preserves all existing `url_for()` endpoint names without requiring Blueprint prefixes. Helper functions used only by routes in that module are defined inside `register_routes()` before the routes that reference them. +Common routes (favourite, upload, replace cover, save defaults, clone, save JSON, get missing, clear covers) are registered via factory functions in `routes/shared.py`. Each route module calls `register_common_routes(app, 'category_name')` as the first line of its `register_routes()`. The `CATEGORY_CONFIG` dict in `shared.py` maps each category to its model, URL prefix, endpoint names, and directory paths. + ### Database SQLite at `instance/database.db`, managed by Flask-SQLAlchemy. The DB is a cache of the JSON files on disk — the JSON files are the source of truth. @@ -186,8 +194,9 @@ Two independent queues with separate worker threads: ### `services/sync.py` — Data Synchronization -- **`sync_characters()`, `sync_outfits()`, `sync_actions()`, etc.** — Load JSON files from `data/` directories into SQLite. One function per category. -- **`_sync_nsfw_from_tags(entity, data)`** — Reads `data['tags']['nsfw']` and sets `entity.is_nsfw`. Called in every sync function on both create and update paths. +- **`_sync_category(config_key, model_class, id_field, name_field, extra_fn=None, sync_nsfw=True)`** — Generic sync function handling 80% shared logic. 7 sync functions are one-liner wrappers. +- **`sync_characters()`** / **`sync_checkpoints()`** — Custom sync functions (special ID handling, filesystem scan). +- **`_sync_nsfw_from_tags(entity, data)`** — Reads `data['tags']['nsfw']` and sets `entity.is_nsfw`. Called by `_sync_category` and custom sync functions. - **`_resolve_preset_entity(type, id)`** / **`_resolve_preset_fields(preset_data)`** — Preset resolution helpers. ### `services/file_io.py` — File & DB Helpers @@ -424,7 +433,10 @@ Image retrieval is handled server-side by the `_make_finalize()` callback; there - Navbar with links to all sections - Global default checkpoint selector (saves to session via AJAX) - Resource delete modal (soft/hard) shared across gallery pages - - `initJsonEditor(saveUrl)` — shared JSON editor modal (simple form + raw textarea tabs) +- **Shared JS files** (extracted from inline scripts): + - `static/js/detail-common.js` — `initDetailPage(options)`: favourite toggle, selectPreview, waitForJob, AJAX form submit + polling, addToPreviewGallery, batch generation, endless mode, JSON editor init. All 9 detail templates call this with minimal config. + - `static/js/layout-utils.js` — `confirmResourceDelete(mode)`, `regenerateTags(category, slug)`, `initJsonEditor(saveUrl)` + - `static/js/library-toolbar.js` — Library page toolbar (batch generate, clear covers, missing items) - Context processors inject `all_checkpoints`, `default_checkpoint_path`, and `COMFYUI_WS_URL` into every template. The `random_gen_image(category, slug)` template global returns a random image path from `static/uploads///` for use as a fallback cover when `image_path` is not set. - **No `{% block head %}` exists** in layout.html — do not try to use it. - Generation is async: JS submits the form via AJAX (`X-Requested-With: XMLHttpRequest`), receives a `{"job_id": ...}` response, then polls `/api/queue//status` every ~1.5 seconds until `status == "done"`. The server-side worker handles all ComfyUI polling and image saving via the `_make_finalize()` callback. There are no client-facing finalize HTTP routes. @@ -522,14 +534,15 @@ Absolute paths on disk: To add a new content category (e.g. "Poses" as a separate concept from Actions), the pattern is: 1. **Model** (`models.py`): Add a new SQLAlchemy model with the standard fields. -2. **Sync function** (`services/sync.py`): Add `sync_newcategory()` following the pattern of `sync_outfits()`. +2. **Sync function** (`services/sync.py`): Add `sync_newcategory()` as a one-liner using `_sync_category()` (or custom if needed). 3. **Data directory** (`app.py`): Add `app.config['NEWCATEGORY_DIR'] = 'data/newcategory'`. -4. **Routes** (`routes/newcategory.py`): Create a new route module with a `register_routes(app)` function. Implement index, detail, edit, generate, replace_cover_from_preview, upload, save_defaults, clone, rescan routes. Follow `routes/outfits.py` or `routes/scenes.py` exactly. -5. **Route registration** (`routes/__init__.py`): Import and call `newcategory.register_routes(app)`. -6. **Templates**: Create `templates/newcategory/{index,detail,edit,create}.html` extending `layout.html`. -7. **Nav**: Add link to navbar in `templates/layout.html`. -8. **Startup** (`app.py`): Import and call `sync_newcategory()` in the `with app.app_context()` block. -9. **Generator page**: Add to `routes/generator.py`, `services/prompts.py` `build_extras_prompt()`, and `templates/generator.html` accordion. +4. **Shared routes** (`routes/shared.py`): Add entry to `CATEGORY_CONFIG` dict with model, url_prefix, detail_endpoint, config_dir, category_folder, id_field, name_field, endpoint_prefix. +5. **Routes** (`routes/newcategory.py`): Create a new route module with a `register_routes(app)` function. Call `register_common_routes(app, 'newcategory')` first, then implement category-specific routes (index, detail, edit, generate, rescan). Follow `routes/outfits.py` or `routes/scenes.py` exactly. +6. **Route registration** (`routes/__init__.py`): Import and call `newcategory.register_routes(app)`. +7. **Templates**: Create `templates/newcategory/{index,detail,edit,create}.html` extending `layout.html`. Detail template should call `initDetailPage({...})` from `detail-common.js`. +8. **Nav**: Add link to navbar in `templates/layout.html`. +9. **Startup** (`app.py`): Import and call `sync_newcategory()` in the `with app.app_context()` block. +10. **Generator page**: Add to `routes/generator.py`, `services/prompts.py` `build_extras_prompt()`, and `templates/generator.html` accordion. --- diff --git a/app.py b/app.py index 00a7497..ed95ff8 100644 --- a/app.py +++ b/app.py @@ -66,38 +66,33 @@ if __name__ == '__main__': ensure_character_mcp_server_running() init_queue_worker(app) with app.app_context(): + from sqlalchemy import text + os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) db.create_all() - # Migration: Add active_outfit column if it doesn't exist - try: - from sqlalchemy import text - db.session.execute(text('ALTER TABLE character ADD COLUMN active_outfit VARCHAR(100) DEFAULT \'default\'')) - db.session.commit() - print("Added active_outfit column to character table") - except Exception as e: - if 'duplicate column name' in str(e).lower() or 'already exists' in str(e).lower(): - print("active_outfit column already exists") - else: - print(f"Migration note: {e}") + # --- Helper for safe column additions --- + def _add_column(table, column, col_type): + try: + db.session.execute(text(f'ALTER TABLE {table} ADD COLUMN {column} {col_type}')) + db.session.commit() + logger.info("Added %s.%s column", table, column) + except Exception as e: + db.session.rollback() + if 'duplicate column name' not in str(e).lower() and 'already exists' not in str(e).lower(): + logger.debug("Migration note (%s.%s): %s", table, column, e) - # Migration: Add default_fields column to action table if it doesn't exist - try: - from sqlalchemy import text - db.session.execute(text('ALTER TABLE action ADD COLUMN default_fields JSON')) - db.session.commit() - print("Added default_fields column to action table") - except Exception as e: - if 'duplicate column name' in str(e).lower() or 'already exists' in str(e).lower(): - print("default_fields column already exists in action table") - else: - print(f"Migration action note: {e}") + # --- All migrations (grouped before syncs) --- + _add_column('character', 'active_outfit', "VARCHAR(100) DEFAULT 'default'") + _add_column('action', 'default_fields', 'JSON') + _add_column('checkpoint', 'data', 'JSON') + _add_column('look', 'character_ids', 'JSON') - # Migration: Add new columns to settings table - columns_to_add = [ - ('llm_provider', "VARCHAR(50) DEFAULT 'openrouter'"), - ('local_base_url', "VARCHAR(255)"), - ('local_model', "VARCHAR(100)"), + # Settings columns + for col_name, col_type in [ + ('llm_provider', "VARCHAR(50) DEFAULT 'openrouter'"), + ('local_base_url', 'VARCHAR(255)'), + ('local_model', 'VARCHAR(100)'), ('lora_dir_characters', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Looks'"), ('lora_dir_outfits', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Clothing'"), ('lora_dir_actions', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Poses'"), @@ -105,63 +100,33 @@ if __name__ == '__main__': ('lora_dir_scenes', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Backgrounds'"), ('lora_dir_detailers', "VARCHAR(500) DEFAULT '/ImageModels/lora/Illustrious/Detailers'"), ('checkpoint_dirs', "VARCHAR(1000) DEFAULT '/ImageModels/Stable-diffusion/Illustrious,/ImageModels/Stable-diffusion/Noob'"), - ('default_checkpoint', "VARCHAR(500)"), - ('api_key', "VARCHAR(255)"), - ] - for col_name, col_type in columns_to_add: - try: - db.session.execute(text(f'ALTER TABLE settings ADD COLUMN {col_name} {col_type}')) - db.session.commit() - print(f"Added {col_name} column to settings table") - except Exception as e: - if 'duplicate column name' in str(e).lower() or 'already exists' in str(e).lower(): - pass - else: - print(f"Migration settings note ({col_name}): {e}") + ('default_checkpoint', 'VARCHAR(500)'), + ('api_key', 'VARCHAR(255)'), + ]: + _add_column('settings', col_name, col_type) - # Migration: Add is_favourite and is_nsfw columns to all resource tables - _tag_tables = ['character', 'look', 'outfit', 'action', 'style', 'scene', 'detailer', 'checkpoint'] - for _tbl in _tag_tables: - for _col, _type in [('is_favourite', 'BOOLEAN DEFAULT 0'), ('is_nsfw', 'BOOLEAN DEFAULT 0')]: - try: - db.session.execute(text(f'ALTER TABLE {_tbl} ADD COLUMN {_col} {_type}')) - db.session.commit() - print(f"Added {_col} column to {_tbl} table") - except Exception as e: - if 'duplicate column name' in str(e).lower() or 'already exists' in str(e).lower(): - pass - else: - print(f"Migration note ({_tbl}.{_col}): {e}") + # is_favourite / is_nsfw on all resource tables + for tbl in ['character', 'look', 'outfit', 'action', 'style', 'scene', 'detailer', 'checkpoint']: + _add_column(tbl, 'is_favourite', 'BOOLEAN DEFAULT 0') + _add_column(tbl, 'is_nsfw', 'BOOLEAN DEFAULT 0') # Ensure settings exist if not Settings.query.first(): db.session.add(Settings()) db.session.commit() - print("Created default settings") + logger.info("Created default settings") - # Log the default checkpoint on startup + # Log default checkpoint settings = Settings.query.first() if settings and settings.default_checkpoint: - logger.info("=" * 80) - logger.info("DEFAULT CHECKPOINT loaded from database: %s", settings.default_checkpoint) - logger.info("=" * 80) + logger.info("Default checkpoint: %s", settings.default_checkpoint) else: logger.info("No default checkpoint set in database") + # --- Sync all categories --- sync_characters() sync_outfits() sync_actions() - # Migration: Add data column to checkpoint table - try: - db.session.execute(text('ALTER TABLE checkpoint ADD COLUMN data JSON')) - db.session.commit() - print("Added data column to checkpoint table") - except Exception as e: - if 'duplicate column name' in str(e).lower() or 'already exists' in str(e).lower(): - print("data column already exists in checkpoint table") - else: - print(f"Migration checkpoint note: {e}") - sync_styles() sync_detailers() sync_scenes() @@ -169,20 +134,7 @@ if __name__ == '__main__': sync_checkpoints() sync_presets() - # Migration: Convert look.character_id to look.character_ids - try: - from sqlalchemy import text - # First ensure the column exists - db.session.execute(text("ALTER TABLE look ADD COLUMN character_ids JSON")) - db.session.commit() - print("Added character_ids column to look table") - except Exception as e: - if 'duplicate column name' in str(e).lower() or 'already exists' in str(e).lower(): - pass # Column already exists - else: - print(f"Migration note (character_ids column): {e}") - - # Migrate existing character_id to character_ids list + # --- Post-sync data migration: character_id → character_ids --- try: looks_with_old_field = Look.query.filter(Look.character_id.isnot(None)).all() migrated_count = 0 @@ -194,8 +146,8 @@ if __name__ == '__main__': migrated_count += 1 if migrated_count > 0: db.session.commit() - print(f"Migrated {migrated_count} looks from character_id to character_ids") + logger.info("Migrated %d looks from character_id to character_ids", migrated_count) except Exception as e: - print(f"Migration note (character_ids data): {e}") + logger.debug("Migration note (character_ids data): %s", e) app.run(debug=True, host='0.0.0.0', port=5000) diff --git a/docker-compose.yml b/docker-compose.yml index 0671b1a..f811441 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: danbooru-mcp: - build: https://git.liveaodh.com/aodhan/danbooru-mcp.git + build: ./tools/danbooru-mcp image: danbooru-mcp:latest stdin_open: true restart: unless-stopped diff --git a/models.py b/models.py index 365d0b5..e74c1e3 100644 --- a/models.py +++ b/models.py @@ -65,8 +65,10 @@ class Character(db.Model): # Add assigned outfits from Outfit table if self.assigned_outfit_ids: + assigned = Outfit.query.filter(Outfit.outfit_id.in_(self.assigned_outfit_ids)).all() + outfit_by_id = {o.outfit_id: o for o in assigned} for outfit_id in self.assigned_outfit_ids: - outfit = Outfit.query.filter_by(outfit_id=outfit_id).first() + outfit = outfit_by_id.get(outfit_id) if outfit: outfits.append({ 'outfit_id': outfit.outfit_id, diff --git a/routes/actions.py b/routes/actions.py index d8e9022..a7302b6 100644 --- a/routes/actions.py +++ b/routes/actions.py @@ -16,24 +16,13 @@ from services.sync import sync_actions from services.file_io import get_available_loras from services.llm import load_prompt, call_llm from utils import allowed_file, _LORA_DEFAULTS +from routes.shared import register_common_routes logger = logging.getLogger('gaze') def register_routes(app): - - @app.route('/get_missing_actions') - def get_missing_actions(): - missing = Action.query.filter((Action.image_path == None) | (Action.image_path == '')).order_by(Action.name).all() - return {'missing': [{'slug': a.slug, 'name': a.name} for a in missing]} - - @app.route('/clear_all_action_covers', methods=['POST']) - def clear_all_action_covers(): - actions = Action.query.all() - for action in actions: - action.image_path = None - db.session.commit() - return {'success': True} + register_common_routes(app, 'actions') @app.route('/actions') def actions_index(): @@ -151,40 +140,11 @@ def register_routes(app): return redirect(url_for('action_detail', slug=slug)) except Exception as e: - print(f"Edit error: {e}") + logger.exception("Edit error: %s", e) flash(f"Error saving changes: {str(e)}") return render_template('actions/edit.html', action=action, loras=loras) - @app.route('/action//upload', methods=['POST']) - def upload_action_image(slug): - action = Action.query.filter_by(slug=slug).first_or_404() - - if 'image' not in request.files: - flash('No file part') - return redirect(request.url) - - file = request.files['image'] - if file.filename == '': - flash('No selected file') - return redirect(request.url) - - if file and allowed_file(file.filename): - # Create action subfolder - action_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"actions/{slug}") - os.makedirs(action_folder, exist_ok=True) - - filename = secure_filename(file.filename) - file_path = os.path.join(action_folder, filename) - file.save(file_path) - - # Store relative path in DB - action.image_path = f"actions/{slug}/{filename}" - db.session.commit() - flash('Image uploaded successfully!') - - return redirect(url_for('action_detail', slug=slug)) - @app.route('/action//generate', methods=['POST']) def generate_action_image(slug): action_obj = Action.query.filter_by(slug=slug).first_or_404() @@ -352,7 +312,7 @@ def register_routes(app): # Append to main prompt if extra_parts: prompts["main"] += ", " + ", ".join(extra_parts) - print(f"Added extra character: {extra_char.name}") + logger.debug("Added extra character: %s", extra_char.name) _append_background(prompts, character) @@ -377,33 +337,12 @@ def register_routes(app): return redirect(url_for('action_detail', slug=slug)) except Exception as e: - print(f"Generation error: {e}") + logger.exception("Generation error: %s", e) if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return {'error': str(e)}, 500 flash(f"Error during generation: {str(e)}") return redirect(url_for('action_detail', slug=slug)) - @app.route('/action//replace_cover_from_preview', methods=['POST']) - def replace_action_cover_from_preview(slug): - action = Action.query.filter_by(slug=slug).first_or_404() - preview_path = request.form.get('preview_path') - if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): - action.image_path = preview_path - db.session.commit() - flash('Cover image updated!') - else: - flash('No valid preview image selected.', 'error') - return redirect(url_for('action_detail', slug=slug)) - - @app.route('/action//save_defaults', methods=['POST']) - def save_action_defaults(slug): - action = Action.query.filter_by(slug=slug).first_or_404() - selected_fields = request.form.getlist('include_field') - action.default_fields = selected_fields - db.session.commit() - flash('Default prompt selection saved for this action!') - return redirect(url_for('action_detail', slug=slug)) - @app.route('/actions/bulk_create', methods=['POST']) def bulk_create_actions_from_loras(): _s = Settings.query.first() @@ -556,7 +495,7 @@ def register_routes(app): action_data['action_id'] = safe_slug action_data['action_name'] = name except Exception as e: - print(f"LLM error: {e}") + logger.exception("LLM error: %s", e) flash(f"Failed to generate action profile: {e}") return render_template('actions/create.html', form_data=form_data) else: @@ -587,74 +526,9 @@ def register_routes(app): flash('Action created successfully!') return redirect(url_for('action_detail', slug=safe_slug)) except Exception as e: - print(f"Save error: {e}") + logger.exception("Save error: %s", e) flash(f"Failed to create action: {e}") return render_template('actions/create.html', form_data=form_data) return render_template('actions/create.html', form_data=form_data) - @app.route('/action//clone', methods=['POST']) - def clone_action(slug): - action = Action.query.filter_by(slug=slug).first_or_404() - - # Find the next available number for the clone - base_id = action.action_id - match = re.match(r'^(.+?)_(\d+)$', base_id) - if match: - base_name = match.group(1) - current_num = int(match.group(2)) - else: - base_name = base_id - current_num = 1 - - next_num = current_num + 1 - while True: - new_id = f"{base_name}_{next_num:02d}" - new_filename = f"{new_id}.json" - new_path = os.path.join(app.config['ACTIONS_DIR'], new_filename) - if not os.path.exists(new_path): - break - next_num += 1 - - new_data = action.data.copy() - new_data['action_id'] = new_id - new_data['action_name'] = f"{action.name} (Copy)" - - with open(new_path, 'w') as f: - json.dump(new_data, f, indent=2) - - new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id) - new_action = Action( - action_id=new_id, slug=new_slug, filename=new_filename, - name=new_data['action_name'], data=new_data - ) - db.session.add(new_action) - db.session.commit() - - flash(f'Action cloned as "{new_id}"!') - return redirect(url_for('action_detail', slug=new_slug)) - - @app.route('/action//save_json', methods=['POST']) - def save_action_json(slug): - action = Action.query.filter_by(slug=slug).first_or_404() - try: - new_data = json.loads(request.form.get('json_data', '')) - except (ValueError, TypeError) as e: - return {'success': False, 'error': f'Invalid JSON: {e}'}, 400 - action.data = new_data - flag_modified(action, 'data') - db.session.commit() - if action.filename: - file_path = os.path.join(app.config['ACTIONS_DIR'], action.filename) - with open(file_path, 'w') as f: - json.dump(new_data, f, indent=2) - return {'success': True} - - @app.route('/action//favourite', methods=['POST']) - def toggle_action_favourite(slug): - action = Action.query.filter_by(slug=slug).first_or_404() - action.is_favourite = not action.is_favourite - db.session.commit() - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'success': True, 'is_favourite': action.is_favourite} - return redirect(url_for('action_detail', slug=slug)) diff --git a/routes/characters.py b/routes/characters.py index 191da5b..4fec0dd 100644 --- a/routes/characters.py +++ b/routes/characters.py @@ -5,8 +5,6 @@ import re from flask import flash, jsonify, redirect, render_template, request, session, url_for from sqlalchemy.orm.attributes import flag_modified -from werkzeug.utils import secure_filename - from models import Action, Character, Detailer, Look, Outfit, Scene, Style, db from services.file_io import get_available_loras from services.job_queue import _enqueue_job, _make_finalize @@ -14,12 +12,13 @@ from services.llm import call_character_mcp_tool, call_llm, load_prompt from services.prompts import build_prompt from services.sync import sync_characters from services.workflow import _get_default_checkpoint, _prepare_workflow -from utils import allowed_file +from routes.shared import register_common_routes logger = logging.getLogger('gaze') def register_routes(app): + register_common_routes(app, 'characters') @app.route('/') def index(): @@ -165,7 +164,7 @@ def register_routes(app): new_data[f'{target_type}_name'] = new_name except Exception as e: - print(f"LLM transfer error: {e}") + logger.exception("LLM transfer error: %s", e) flash(f'Failed to generate {target_type} with AI: {e}') return redirect(url_for('transfer_character', slug=slug)) else: @@ -212,7 +211,7 @@ def register_routes(app): return redirect(url_for('detailer_detail', slug=safe_slug)) except Exception as e: - print(f"Transfer save error: {e}") + logger.exception("Transfer save error: %s", e) flash(f'Failed to save transferred {target_type}: {e}') return redirect(url_for('transfer_character', slug=slug)) @@ -356,7 +355,7 @@ Create an outfit JSON with wardrobe fields appropriate for this character.""" logger.info(f"Generated outfit: {outfit_name} for character {name}") except Exception as e: - print(f"Outfit generation error: {e}") + logger.exception("Outfit generation error: %s", e) # Fall back to default default_outfit_id = 'default' @@ -404,7 +403,7 @@ Do NOT include a wardrobe section - the outfit is handled separately.""" char_data.pop('wardrobe', None) except Exception as e: - print(f"LLM error: {e}") + logger.exception("LLM error: %s", e) error_msg = f"Failed to generate character profile: {e}" if is_ajax: return jsonify({'error': error_msg}), 500 @@ -473,7 +472,7 @@ Do NOT include a wardrobe section - the outfit is handled separately.""" return redirect(url_for('detail', slug=safe_slug)) except Exception as e: - print(f"Save error: {e}") + logger.exception("Save error: %s", e) error_msg = f"Failed to create character: {e}" if is_ajax: return jsonify({'error': error_msg}), 500 @@ -565,7 +564,7 @@ Do NOT include a wardrobe section - the outfit is handled separately.""" return redirect(url_for('detail', slug=slug)) except Exception as e: - print(f"Edit error: {e}") + logger.exception("Edit error: %s", e) flash(f"Error saving changes: {str(e)}") return render_template('edit.html', character=character, loras=loras, char_looks=char_looks) @@ -757,60 +756,6 @@ Do NOT include a wardrobe section - the outfit is handled separately.""" return redirect(url_for('edit_character', slug=slug)) - @app.route('/character//upload', methods=['POST']) - def upload_image(slug): - character = Character.query.filter_by(slug=slug).first_or_404() - - if 'image' not in request.files: - flash('No file part') - return redirect(request.url) - - file = request.files['image'] - if file.filename == '': - flash('No selected file') - return redirect(request.url) - - if file and allowed_file(file.filename): - # Create character subfolder - char_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"characters/{slug}") - os.makedirs(char_folder, exist_ok=True) - - filename = secure_filename(file.filename) - file_path = os.path.join(char_folder, filename) - file.save(file_path) - - # Store relative path in DB - character.image_path = f"characters/{slug}/{filename}" - db.session.commit() - flash('Image uploaded successfully!') - - return redirect(url_for('detail', slug=slug)) - - @app.route('/character//replace_cover_from_preview', methods=['POST']) - def replace_cover_from_preview(slug): - character = Character.query.filter_by(slug=slug).first_or_404() - preview_path = request.form.get('preview_path') - if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): - character.image_path = preview_path - db.session.commit() - flash('Cover image updated!') - else: - flash('No valid preview image selected.', 'error') - return redirect(url_for('detail', slug=slug)) - - @app.route('/get_missing_characters') - def get_missing_characters(): - missing = Character.query.filter((Character.image_path == None) | (Character.image_path == '')).order_by(Character.name).all() - return {'missing': [{'slug': c.slug, 'name': c.name} for c in missing]} - - @app.route('/clear_all_covers', methods=['POST']) - def clear_all_covers(): - characters = Character.query.all() - for char in characters: - char.image_path = None - db.session.commit() - return {'success': True} - @app.route('/generate_missing', methods=['POST']) def generate_missing(): missing = Character.query.filter( @@ -834,7 +779,7 @@ Do NOT include a wardrobe section - the outfit is handled separately.""" _enqueue_job(f"{character.name} – cover", workflow, _make_finalize('characters', _slug, Character)) enqueued += 1 except Exception as e: - print(f"Error queuing cover generation for {character.name}: {e}") + logger.exception("Error queuing cover generation for %s: %s", character.name, e) flash(f"Queued {enqueued} cover image generation job{'s' if enqueued != 1 else ''}.") return redirect(url_for('index')) @@ -882,26 +827,9 @@ Do NOT include a wardrobe section - the outfit is handled separately.""" return redirect(url_for('detail', slug=slug)) except Exception as e: - print(f"Generation error: {e}") + logger.exception("Generation error: %s", e) if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return {'error': str(e)}, 500 flash(f"Error during generation: {str(e)}") return redirect(url_for('detail', slug=slug)) - @app.route('/character//save_defaults', methods=['POST']) - def save_defaults(slug): - character = Character.query.filter_by(slug=slug).first_or_404() - selected_fields = request.form.getlist('include_field') - character.default_fields = selected_fields - db.session.commit() - flash('Default prompt selection saved for this character!') - return redirect(url_for('detail', slug=slug)) - - @app.route('/character//favourite', methods=['POST']) - def toggle_character_favourite(slug): - character = Character.query.filter_by(slug=slug).first_or_404() - character.is_favourite = not character.is_favourite - db.session.commit() - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'success': True, 'is_favourite': character.is_favourite} - return redirect(url_for('detail', slug=slug)) diff --git a/routes/checkpoints.py b/routes/checkpoints.py index 6fafd8e..46d26f7 100644 --- a/routes/checkpoints.py +++ b/routes/checkpoints.py @@ -15,11 +15,13 @@ from services.sync import sync_checkpoints, _default_checkpoint_data from services.file_io import get_available_checkpoints from services.llm import load_prompt, call_llm from utils import allowed_file +from routes.shared import register_common_routes logger = logging.getLogger('gaze') def register_routes(app): + register_common_routes(app, 'checkpoints') def _build_checkpoint_workflow(ckpt_obj, character=None, fixed_seed=None, extra_positive=None, extra_negative=None): """Build and return a prepared ComfyUI workflow dict for a checkpoint generation.""" @@ -95,26 +97,6 @@ def register_routes(app): existing_previews=existing_previews, extra_positive=extra_positive, extra_negative=extra_negative) - @app.route('/checkpoint//upload', methods=['POST']) - def upload_checkpoint_image(slug): - ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404() - if 'image' not in request.files: - flash('No file part') - return redirect(url_for('checkpoint_detail', slug=slug)) - file = request.files['image'] - if file.filename == '': - flash('No selected file') - return redirect(url_for('checkpoint_detail', slug=slug)) - if file and allowed_file(file.filename): - folder = os.path.join(app.config['UPLOAD_FOLDER'], f"checkpoints/{slug}") - os.makedirs(folder, exist_ok=True) - filename = secure_filename(file.filename) - file.save(os.path.join(folder, filename)) - ckpt.image_path = f"checkpoints/{slug}/{filename}" - db.session.commit() - flash('Image uploaded successfully!') - return redirect(url_for('checkpoint_detail', slug=slug)) - @app.route('/checkpoint//generate', methods=['POST']) def generate_checkpoint_image(slug): ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404() @@ -145,52 +127,12 @@ def register_routes(app): return {'status': 'queued', 'job_id': job['id']} return redirect(url_for('checkpoint_detail', slug=slug)) except Exception as e: - print(f"Generation error: {e}") + logger.exception("Generation error: %s", e) if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return {'error': str(e)}, 500 flash(f"Error during generation: {str(e)}") return redirect(url_for('checkpoint_detail', slug=slug)) - @app.route('/checkpoint//replace_cover_from_preview', methods=['POST']) - def replace_checkpoint_cover_from_preview(slug): - ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404() - preview_path = request.form.get('preview_path') - if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): - ckpt.image_path = preview_path - db.session.commit() - flash('Cover image updated!') - else: - flash('No valid preview image selected.', 'error') - return redirect(url_for('checkpoint_detail', slug=slug)) - - @app.route('/checkpoint//save_json', methods=['POST']) - def save_checkpoint_json(slug): - ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404() - try: - new_data = json.loads(request.form.get('json_data', '')) - except (ValueError, TypeError) as e: - return {'success': False, 'error': f'Invalid JSON: {e}'}, 400 - ckpt.data = new_data - flag_modified(ckpt, 'data') - db.session.commit() - checkpoints_dir = app.config.get('CHECKPOINTS_DIR', 'data/checkpoints') - file_path = os.path.join(checkpoints_dir, f'{ckpt.slug}.json') - with open(file_path, 'w') as f: - json.dump(new_data, f, indent=2) - return {'success': True} - - @app.route('/get_missing_checkpoints') - def get_missing_checkpoints(): - missing = Checkpoint.query.filter((Checkpoint.image_path == None) | (Checkpoint.image_path == '')).order_by(Checkpoint.name).all() - return {'missing': [{'slug': c.slug, 'name': c.name} for c in missing]} - - @app.route('/clear_all_checkpoint_covers', methods=['POST']) - def clear_all_checkpoint_covers(): - for ckpt in Checkpoint.query.all(): - ckpt.image_path = None - db.session.commit() - return {'success': True} - @app.route('/checkpoints/bulk_create', methods=['POST']) def bulk_create_checkpoints(): checkpoints_dir = app.config.get('CHECKPOINTS_DIR', 'data/checkpoints') @@ -304,11 +246,3 @@ def register_routes(app): flash(f'Queued {len(job_ids)} checkpoint tasks, {written_directly} written directly ({skipped} skipped).') return redirect(url_for('checkpoints_index')) - @app.route('/checkpoint//favourite', methods=['POST']) - def toggle_checkpoint_favourite(slug): - ckpt = Checkpoint.query.filter_by(slug=slug).first_or_404() - ckpt.is_favourite = not ckpt.is_favourite - db.session.commit() - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'success': True, 'is_favourite': ckpt.is_favourite} - return redirect(url_for('checkpoint_detail', slug=slug)) diff --git a/routes/detailers.py b/routes/detailers.py index 8185a85..2627ee0 100644 --- a/routes/detailers.py +++ b/routes/detailers.py @@ -3,23 +3,24 @@ import os import re import logging -from flask import render_template, request, redirect, url_for, flash, session, current_app -from werkzeug.utils import secure_filename +from flask import render_template, request, redirect, url_for, flash, session from sqlalchemy.orm.attributes import flag_modified -from models import db, Character, Detailer, Action, Outfit, Style, Scene, Checkpoint, Settings, Look +from models import db, Character, Detailer, Action, Settings from services.workflow import _prepare_workflow, _get_default_checkpoint from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background from services.sync import sync_detailers from services.file_io import get_available_loras from services.llm import load_prompt, call_llm -from utils import allowed_file, _LORA_DEFAULTS, _WARDROBE_KEYS +from utils import _WARDROBE_KEYS +from routes.shared import register_common_routes logger = logging.getLogger('gaze') def register_routes(app): + register_common_routes(app, 'detailers') def _queue_detailer_generation(detailer_obj, character=None, selected_fields=None, client_id=None, action=None, extra_positive=None, extra_negative=None, fixed_seed=None): if character: @@ -193,40 +194,11 @@ def register_routes(app): return redirect(url_for('detailer_detail', slug=slug)) except Exception as e: - print(f"Edit error: {e}") + logger.exception("Edit error: %s", e) flash(f"Error saving changes: {str(e)}") return render_template('detailers/edit.html', detailer=detailer, loras=loras) - @app.route('/detailer//upload', methods=['POST']) - def upload_detailer_image(slug): - detailer = Detailer.query.filter_by(slug=slug).first_or_404() - - if 'image' not in request.files: - flash('No file part') - return redirect(request.url) - - file = request.files['image'] - if file.filename == '': - flash('No selected file') - return redirect(request.url) - - if file and allowed_file(file.filename): - # Create detailer subfolder - detailer_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"detailers/{slug}") - os.makedirs(detailer_folder, exist_ok=True) - - filename = secure_filename(file.filename) - file_path = os.path.join(detailer_folder, filename) - file.save(file_path) - - # Store relative path in DB - detailer.image_path = f"detailers/{slug}/{filename}" - db.session.commit() - flash('Image uploaded successfully!') - - return redirect(url_for('detailer_detail', slug=slug)) - @app.route('/detailer//generate', methods=['POST']) def generate_detailer_image(slug): detailer_obj = Detailer.query.filter_by(slug=slug).first_or_404() @@ -277,49 +249,12 @@ def register_routes(app): return redirect(url_for('detailer_detail', slug=slug)) except Exception as e: - print(f"Generation error: {e}") + logger.exception("Generation error: %s", e) if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return {'error': str(e)}, 500 flash(f"Error during generation: {str(e)}") return redirect(url_for('detailer_detail', slug=slug)) - @app.route('/detailer//save_defaults', methods=['POST']) - def save_detailer_defaults(slug): - detailer = Detailer.query.filter_by(slug=slug).first_or_404() - selected_fields = request.form.getlist('include_field') - detailer.default_fields = selected_fields - db.session.commit() - flash('Default prompt selection saved for this detailer!') - return redirect(url_for('detailer_detail', slug=slug)) - - @app.route('/detailer//replace_cover_from_preview', methods=['POST']) - def replace_detailer_cover_from_preview(slug): - detailer = Detailer.query.filter_by(slug=slug).first_or_404() - preview_path = request.form.get('preview_path') - if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): - detailer.image_path = preview_path - db.session.commit() - flash('Cover image updated!') - else: - flash('No valid preview image selected.', 'error') - return redirect(url_for('detailer_detail', slug=slug)) - - @app.route('/detailer//save_json', methods=['POST']) - def save_detailer_json(slug): - detailer = Detailer.query.filter_by(slug=slug).first_or_404() - try: - new_data = json.loads(request.form.get('json_data', '')) - except (ValueError, TypeError) as e: - return {'success': False, 'error': f'Invalid JSON: {e}'}, 400 - detailer.data = new_data - flag_modified(detailer, 'data') - db.session.commit() - if detailer.filename: - file_path = os.path.join(app.config['DETAILERS_DIR'], detailer.filename) - with open(file_path, 'w') as f: - json.dump(new_data, f, indent=2) - return {'success': True} - @app.route('/detailers/bulk_create', methods=['POST']) def bulk_create_detailers_from_loras(): _s = Settings.query.first() @@ -463,17 +398,9 @@ def register_routes(app): flash('Detailer created successfully!') return redirect(url_for('detailer_detail', slug=safe_slug)) except Exception as e: - print(f"Save error: {e}") + logger.exception("Save error: %s", e) flash(f"Failed to create detailer: {e}") return render_template('detailers/create.html', form_data=form_data) return render_template('detailers/create.html', form_data=form_data) - @app.route('/detailer//favourite', methods=['POST']) - def toggle_detailer_favourite(slug): - detailer = Detailer.query.filter_by(slug=slug).first_or_404() - detailer.is_favourite = not detailer.is_favourite - db.session.commit() - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'success': True, 'is_favourite': detailer.is_favourite} - return redirect(url_for('detailer_detail', slug=slug)) diff --git a/routes/looks.py b/routes/looks.py index e1011f3..dd3d967 100644 --- a/routes/looks.py +++ b/routes/looks.py @@ -3,18 +3,17 @@ import os import re import logging -from flask import render_template, request, redirect, url_for, flash, session, current_app -from werkzeug.utils import secure_filename +from flask import render_template, request, redirect, url_for, flash, session from sqlalchemy.orm.attributes import flag_modified -from models import db, Character, Look, Action, Checkpoint, Settings, Outfit +from models import db, Character, Look, Settings from services.workflow import _prepare_workflow, _get_default_checkpoint from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task -from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background, _dedup_tags +from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _dedup_tags from services.sync import sync_looks from services.file_io import get_available_loras, _count_look_assignments from services.llm import load_prompt, call_llm -from utils import allowed_file +from routes.shared import register_common_routes logger = logging.getLogger('gaze') @@ -54,6 +53,7 @@ def _fix_look_lora_data(lora_data): def register_routes(app): + register_common_routes(app, 'looks') @app.route('/looks') def looks_index(): @@ -173,23 +173,6 @@ def register_routes(app): return render_template('looks/edit.html', look=look, characters=characters, loras=loras) - @app.route('/look//upload', methods=['POST']) - def upload_look_image(slug): - look = Look.query.filter_by(slug=slug).first_or_404() - if 'image' not in request.files: - flash('No file selected') - return redirect(url_for('look_detail', slug=slug)) - file = request.files['image'] - if file and allowed_file(file.filename): - filename = secure_filename(file.filename) - look_folder = os.path.join(app.config['UPLOAD_FOLDER'], f'looks/{slug}') - os.makedirs(look_folder, exist_ok=True) - file_path = os.path.join(look_folder, filename) - file.save(file_path) - look.image_path = f'looks/{slug}/{filename}' - db.session.commit() - return redirect(url_for('look_detail', slug=slug)) - @app.route('/look//generate', methods=['POST']) def generate_look_image(slug): look = Look.query.filter_by(slug=slug).first_or_404() @@ -275,32 +258,12 @@ def register_routes(app): return redirect(url_for('look_detail', slug=slug)) except Exception as e: - print(f"Generation error: {e}") + logger.exception("Generation error: %s", e) if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return {'error': str(e)}, 500 flash(f"Error during generation: {str(e)}") return redirect(url_for('look_detail', slug=slug)) - @app.route('/look//replace_cover_from_preview', methods=['POST']) - def replace_look_cover_from_preview(slug): - look = Look.query.filter_by(slug=slug).first_or_404() - preview_path = request.form.get('preview_path') - if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): - look.image_path = preview_path - db.session.commit() - flash('Cover image updated!') - else: - flash('No valid preview image selected.', 'error') - return redirect(url_for('look_detail', slug=slug)) - - @app.route('/look//save_defaults', methods=['POST']) - def save_look_defaults(slug): - look = Look.query.filter_by(slug=slug).first_or_404() - look.default_fields = request.form.getlist('include_field') - db.session.commit() - flash('Default prompt selection saved!') - return redirect(url_for('look_detail', slug=slug)) - @app.route('/look//generate_character', methods=['POST']) def generate_character_from_look(slug): """Generate a character JSON using a look as the base.""" @@ -426,23 +389,6 @@ Character ID: {character_slug}""" flash(f'Character "{character_name}" created from look!', 'success') return redirect(url_for('detail', slug=character_slug)) - @app.route('/look//save_json', methods=['POST']) - def save_look_json(slug): - look = Look.query.filter_by(slug=slug).first_or_404() - try: - new_data = json.loads(request.form.get('json_data', '')) - except (ValueError, TypeError) as e: - return {'success': False, 'error': f'Invalid JSON: {e}'}, 400 - look.data = new_data - look.character_id = new_data.get('character_id', look.character_id) - flag_modified(look, 'data') - db.session.commit() - if look.filename: - file_path = os.path.join(app.config['LOOKS_DIR'], look.filename) - with open(file_path, 'w') as f: - json.dump(new_data, f, indent=2) - return {'success': True} - @app.route('/look/create', methods=['GET', 'POST']) def create_look(): characters = Character.query.order_by(Character.name).all() @@ -499,25 +445,12 @@ Character ID: {character_slug}""" flash(f'Look "{name}" created!') return redirect(url_for('look_detail', slug=slug)) except Exception as e: - print(f"Save error: {e}") + logger.exception("Save error: %s", e) flash(f"Failed to create look: {e}") return render_template('looks/create.html', characters=characters, loras=loras, form_data=form_data) return render_template('looks/create.html', characters=characters, loras=loras, form_data=form_data) - @app.route('/get_missing_looks') - def get_missing_looks(): - missing = Look.query.filter((Look.image_path == None) | (Look.image_path == '')).order_by(Look.name).all() - return {'missing': [{'slug': l.slug, 'name': l.name} for l in missing]} - - @app.route('/clear_all_look_covers', methods=['POST']) - def clear_all_look_covers(): - looks = Look.query.all() - for look in looks: - look.image_path = None - db.session.commit() - return {'success': True} - @app.route('/looks/bulk_create', methods=['POST']) def bulk_create_looks_from_loras(): _s = Settings.query.first() @@ -615,11 +548,3 @@ Character ID: {character_slug}""" flash(f'Queued {len(job_ids)} look tasks ({skipped} skipped).') return redirect(url_for('looks_index')) - @app.route('/look//favourite', methods=['POST']) - def toggle_look_favourite(slug): - look = Look.query.filter_by(slug=slug).first_or_404() - look.is_favourite = not look.is_favourite - db.session.commit() - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'success': True, 'is_favourite': look.is_favourite} - return redirect(url_for('look_detail', slug=slug)) \ No newline at end of file diff --git a/routes/outfits.py b/routes/outfits.py index 9cac33a..c07e331 100644 --- a/routes/outfits.py +++ b/routes/outfits.py @@ -15,24 +15,13 @@ from services.sync import sync_outfits from services.file_io import get_available_loras, _count_outfit_lora_assignments from utils import allowed_file, _LORA_DEFAULTS from services.llm import load_prompt, call_llm +from routes.shared import register_common_routes logger = logging.getLogger('gaze') def register_routes(app): - - @app.route('/get_missing_outfits') - def get_missing_outfits(): - missing = Outfit.query.filter((Outfit.image_path == None) | (Outfit.image_path == '')).order_by(Outfit.name).all() - return {'missing': [{'slug': o.slug, 'name': o.name} for o in missing]} - - @app.route('/clear_all_outfit_covers', methods=['POST']) - def clear_all_outfit_covers(): - outfits = Outfit.query.all() - for outfit in outfits: - outfit.image_path = None - db.session.commit() - return {'success': True} + register_common_routes(app, 'outfits') @app.route('/outfits') def outfits_index(): @@ -268,40 +257,11 @@ def register_routes(app): return redirect(url_for('outfit_detail', slug=slug)) except Exception as e: - print(f"Edit error: {e}") + logger.exception("Edit error: %s", e) flash(f"Error saving changes: {str(e)}") return render_template('outfits/edit.html', outfit=outfit, loras=loras) - @app.route('/outfit//upload', methods=['POST']) - def upload_outfit_image(slug): - outfit = Outfit.query.filter_by(slug=slug).first_or_404() - - if 'image' not in request.files: - flash('No file part') - return redirect(request.url) - - file = request.files['image'] - if file.filename == '': - flash('No selected file') - return redirect(request.url) - - if file and allowed_file(file.filename): - # Create outfit subfolder - outfit_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"outfits/{slug}") - os.makedirs(outfit_folder, exist_ok=True) - - filename = secure_filename(file.filename) - file_path = os.path.join(outfit_folder, filename) - file.save(file_path) - - # Store relative path in DB - outfit.image_path = f"outfits/{slug}/{filename}" - db.session.commit() - flash('Image uploaded successfully!') - - return redirect(url_for('outfit_detail', slug=slug)) - @app.route('/outfit//generate', methods=['POST']) def generate_outfit_image(slug): outfit = Outfit.query.filter_by(slug=slug).first_or_404() @@ -406,24 +366,12 @@ def register_routes(app): return redirect(url_for('outfit_detail', slug=slug)) except Exception as e: - print(f"Generation error: {e}") + logger.exception("Generation error: %s", e) if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return {'error': str(e)}, 500 flash(f"Error during generation: {str(e)}") return redirect(url_for('outfit_detail', slug=slug)) - @app.route('/outfit//replace_cover_from_preview', methods=['POST']) - def replace_outfit_cover_from_preview(slug): - outfit = Outfit.query.filter_by(slug=slug).first_or_404() - preview_path = request.form.get('preview_path') - if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): - outfit.image_path = preview_path - db.session.commit() - flash('Cover image updated!') - else: - flash('No valid preview image selected.', 'error') - return redirect(url_for('outfit_detail', slug=slug)) - @app.route('/outfit/create', methods=['GET', 'POST']) def create_outfit(): form_data = {} @@ -496,7 +444,7 @@ def register_routes(app): outfit_data['tags'] = [] except Exception as e: - print(f"LLM error: {e}") + logger.exception("LLM error: %s", e) flash(f"Failed to generate outfit profile: {e}") return render_template('outfits/create.html', form_data=form_data) else: @@ -542,92 +490,10 @@ def register_routes(app): return redirect(url_for('outfit_detail', slug=safe_slug)) except Exception as e: - print(f"Save error: {e}") + logger.exception("Save error: %s", e) flash(f"Failed to create outfit: {e}") return render_template('outfits/create.html', form_data=form_data) return render_template('outfits/create.html', form_data=form_data) - @app.route('/outfit//save_defaults', methods=['POST']) - def save_outfit_defaults(slug): - outfit = Outfit.query.filter_by(slug=slug).first_or_404() - selected_fields = request.form.getlist('include_field') - outfit.default_fields = selected_fields - db.session.commit() - flash('Default prompt selection saved for this outfit!') - return redirect(url_for('outfit_detail', slug=slug)) - @app.route('/outfit//clone', methods=['POST']) - def clone_outfit(slug): - outfit = Outfit.query.filter_by(slug=slug).first_or_404() - - # Find the next available number for the clone - base_id = outfit.outfit_id - # Extract base name without number suffix - import re - match = re.match(r'^(.+?)_(\d+)$', base_id) - if match: - base_name = match.group(1) - current_num = int(match.group(2)) - else: - base_name = base_id - current_num = 1 - - # Find next available number - next_num = current_num + 1 - while True: - new_id = f"{base_name}_{next_num:02d}" - new_filename = f"{new_id}.json" - new_path = os.path.join(app.config['CLOTHING_DIR'], new_filename) - if not os.path.exists(new_path): - break - next_num += 1 - - # Create new outfit data (copy of original) - new_data = outfit.data.copy() - new_data['outfit_id'] = new_id - new_data['outfit_name'] = f"{outfit.name} (Copy)" - - # Save the new JSON file - with open(new_path, 'w') as f: - json.dump(new_data, f, indent=2) - - # Create new outfit in database - new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id) - new_outfit = Outfit( - outfit_id=new_id, - slug=new_slug, - filename=new_filename, - name=new_data['outfit_name'], - data=new_data - ) - db.session.add(new_outfit) - db.session.commit() - - flash(f'Outfit cloned as "{new_id}"!') - return redirect(url_for('outfit_detail', slug=new_slug)) - - @app.route('/outfit//save_json', methods=['POST']) - def save_outfit_json(slug): - outfit = Outfit.query.filter_by(slug=slug).first_or_404() - try: - new_data = json.loads(request.form.get('json_data', '')) - except (ValueError, TypeError) as e: - return {'success': False, 'error': f'Invalid JSON: {e}'}, 400 - outfit.data = new_data - flag_modified(outfit, 'data') - db.session.commit() - if outfit.filename: - file_path = os.path.join(app.config['CLOTHING_DIR'], outfit.filename) - with open(file_path, 'w') as f: - json.dump(new_data, f, indent=2) - return {'success': True} - - @app.route('/outfit//favourite', methods=['POST']) - def toggle_outfit_favourite(slug): - outfit = Outfit.query.filter_by(slug=slug).first_or_404() - outfit.is_favourite = not outfit.is_favourite - db.session.commit() - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'success': True, 'is_favourite': outfit.is_favourite} - return redirect(url_for('outfit_detail', slug=slug)) diff --git a/routes/presets.py b/routes/presets.py index c8522f6..a887ee2 100644 --- a/routes/presets.py +++ b/routes/presets.py @@ -2,23 +2,19 @@ import json import os import re import logging -import random from flask import render_template, request, redirect, url_for, flash, session, current_app -from werkzeug.utils import secure_filename -from models import db, Character, Preset, Outfit, Action, Style, Scene, Detailer, Checkpoint, Look, Settings +from models import db, Character, Preset, Outfit, Action, Style, Scene, Detailer, Checkpoint, Look from sqlalchemy.orm.attributes import flag_modified -from services.prompts import build_prompt, _dedup_tags, _resolve_character, _ensure_character_fields, _append_background -from services.workflow import _prepare_workflow, _get_default_checkpoint -from services.job_queue import _enqueue_job, _make_finalize -from services.sync import sync_presets, _resolve_preset_entity, _resolve_preset_fields, _PRESET_ENTITY_MAP +from services.sync import sync_presets from services.generation import generate_from_preset from services.llm import load_prompt, call_llm -from utils import allowed_file +from routes.shared import register_common_routes logger = logging.getLogger('gaze') def register_routes(app): + register_common_routes(app, 'presets') @app.route('/presets') def presets_index(): @@ -79,37 +75,6 @@ def register_routes(app): flash(f"Error during generation: {str(e)}") return redirect(url_for('preset_detail', slug=slug)) - @app.route('/preset//replace_cover_from_preview', methods=['POST']) - def replace_preset_cover_from_preview(slug): - preset = Preset.query.filter_by(slug=slug).first_or_404() - preview_path = request.form.get('preview_path') - if preview_path and os.path.exists(os.path.join(current_app.config['UPLOAD_FOLDER'], preview_path)): - preset.image_path = preview_path - db.session.commit() - flash('Cover image updated!') - else: - flash('No valid preview image selected.', 'error') - return redirect(url_for('preset_detail', slug=slug)) - - @app.route('/preset//upload', methods=['POST']) - def upload_preset_image(slug): - preset = Preset.query.filter_by(slug=slug).first_or_404() - if 'image' not in request.files: - flash('No file uploaded.') - return redirect(url_for('preset_detail', slug=slug)) - file = request.files['image'] - if file.filename == '': - flash('No file selected.') - return redirect(url_for('preset_detail', slug=slug)) - filename = secure_filename(file.filename) - folder = os.path.join(current_app.config['UPLOAD_FOLDER'], f'presets/{slug}') - os.makedirs(folder, exist_ok=True) - file.save(os.path.join(folder, filename)) - preset.image_path = f'presets/{slug}/{filename}' - db.session.commit() - flash('Image uploaded!') - return redirect(url_for('preset_detail', slug=slug)) - @app.route('/preset//edit', methods=['GET', 'POST']) def edit_preset(slug): preset = Preset.query.filter_by(slug=slug).first_or_404() @@ -196,51 +161,6 @@ def register_routes(app): styles=styles, scenes=scenes, detailers=detailers, looks=looks, checkpoints=checkpoints) - @app.route('/preset//save_json', methods=['POST']) - def save_preset_json(slug): - preset = Preset.query.filter_by(slug=slug).first_or_404() - try: - new_data = json.loads(request.form.get('json_data', '')) - preset.data = new_data - preset.name = new_data.get('preset_name', preset.name) - flag_modified(preset, "data") - db.session.commit() - if preset.filename: - file_path = os.path.join(current_app.config['PRESETS_DIR'], preset.filename) - with open(file_path, 'w') as f: - json.dump(new_data, f, indent=2) - return {'success': True} - except Exception as e: - return {'success': False, 'error': str(e)}, 400 - - @app.route('/preset//clone', methods=['POST']) - def clone_preset(slug): - original = Preset.query.filter_by(slug=slug).first_or_404() - new_data = dict(original.data) - - base_id = f"{original.preset_id}_copy" - new_id = base_id - counter = 1 - while Preset.query.filter_by(preset_id=new_id).first(): - new_id = f"{base_id}_{counter}" - counter += 1 - - new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id) - new_data['preset_id'] = new_id - new_data['preset_name'] = f"{original.name} (Copy)" - new_filename = f"{new_id}.json" - - os.makedirs(current_app.config['PRESETS_DIR'], exist_ok=True) - with open(os.path.join(current_app.config['PRESETS_DIR'], new_filename), 'w') as f: - json.dump(new_data, f, indent=2) - - new_preset = Preset(preset_id=new_id, slug=new_slug, filename=new_filename, - name=new_data['preset_name'], data=new_data) - db.session.add(new_preset) - db.session.commit() - flash(f"Cloned as '{new_data['preset_name']}'") - return redirect(url_for('preset_detail', slug=new_slug)) - @app.route('/presets/rescan', methods=['POST']) def rescan_presets(): sync_presets() @@ -322,7 +242,3 @@ def register_routes(app): return render_template('presets/create.html', form_data=form_data) - @app.route('/get_missing_presets') - def get_missing_presets(): - missing = Preset.query.filter((Preset.image_path == None) | (Preset.image_path == '')).order_by(Preset.filename).all() - return {'missing': [{'slug': p.slug, 'name': p.name} for p in missing]} diff --git a/routes/scenes.py b/routes/scenes.py index 69be138..6c21014 100644 --- a/routes/scenes.py +++ b/routes/scenes.py @@ -3,36 +3,24 @@ import os import re import logging -from flask import render_template, request, redirect, url_for, flash, session, current_app -from werkzeug.utils import secure_filename +from flask import render_template, request, redirect, url_for, flash, session from sqlalchemy.orm.attributes import flag_modified -from models import db, Character, Scene, Outfit, Action, Style, Detailer, Checkpoint, Settings, Look +from models import db, Character, Scene, Settings from services.workflow import _prepare_workflow, _get_default_checkpoint from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task -from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background +from services.prompts import build_prompt, _resolve_character, _ensure_character_fields from services.sync import sync_scenes from services.file_io import get_available_loras from services.llm import load_prompt, call_llm -from utils import allowed_file, _LORA_DEFAULTS, _WARDROBE_KEYS +from routes.shared import register_common_routes +from utils import _WARDROBE_KEYS logger = logging.getLogger('gaze') def register_routes(app): - - @app.route('/get_missing_scenes') - def get_missing_scenes(): - missing = Scene.query.filter((Scene.image_path == None) | (Scene.image_path == '')).order_by(Scene.name).all() - return {'missing': [{'slug': s.slug, 'name': s.name} for s in missing]} - - @app.route('/clear_all_scene_covers', methods=['POST']) - def clear_all_scene_covers(): - scenes = Scene.query.all() - for scene in scenes: - scene.image_path = None - db.session.commit() - return {'success': True} + register_common_routes(app, 'scenes') @app.route('/scenes') def scenes_index(): @@ -147,40 +135,11 @@ def register_routes(app): return redirect(url_for('scene_detail', slug=slug)) except Exception as e: - print(f"Edit error: {e}") + logger.exception("Edit error: %s", e) flash(f"Error saving changes: {str(e)}") return render_template('scenes/edit.html', scene=scene, loras=loras) - @app.route('/scene//upload', methods=['POST']) - def upload_scene_image(slug): - scene = Scene.query.filter_by(slug=slug).first_or_404() - - if 'image' not in request.files: - flash('No file part') - return redirect(request.url) - - file = request.files['image'] - if file.filename == '': - flash('No selected file') - return redirect(request.url) - - if file and allowed_file(file.filename): - # Create scene subfolder - scene_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"scenes/{slug}") - os.makedirs(scene_folder, exist_ok=True) - - filename = secure_filename(file.filename) - file_path = os.path.join(scene_folder, filename) - file.save(file_path) - - # Store relative path in DB - scene.image_path = f"scenes/{slug}/{filename}" - db.session.commit() - flash('Image uploaded successfully!') - - return redirect(url_for('scene_detail', slug=slug)) - def _queue_scene_generation(scene_obj, character=None, selected_fields=None, client_id=None, fixed_seed=None, extra_positive=None, extra_negative=None): if character: combined_data = character.data.copy() @@ -306,33 +265,12 @@ def register_routes(app): return redirect(url_for('scene_detail', slug=slug)) except Exception as e: - print(f"Generation error: {e}") + logger.exception("Generation error: %s", e) if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return {'error': str(e)}, 500 flash(f"Error during generation: {str(e)}") return redirect(url_for('scene_detail', slug=slug)) - @app.route('/scene//save_defaults', methods=['POST']) - def save_scene_defaults(slug): - scene = Scene.query.filter_by(slug=slug).first_or_404() - selected_fields = request.form.getlist('include_field') - scene.default_fields = selected_fields - db.session.commit() - flash('Default prompt selection saved for this scene!') - return redirect(url_for('scene_detail', slug=slug)) - - @app.route('/scene//replace_cover_from_preview', methods=['POST']) - def replace_scene_cover_from_preview(slug): - scene = Scene.query.filter_by(slug=slug).first_or_404() - preview_path = request.form.get('preview_path') - if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): - scene.image_path = preview_path - db.session.commit() - flash('Cover image updated!') - else: - flash('No valid preview image selected.', 'error') - return redirect(url_for('scene_detail', slug=slug)) - @app.route('/scenes/bulk_create', methods=['POST']) def bulk_create_scenes_from_loras(): _s = Settings.query.first() @@ -483,74 +421,9 @@ def register_routes(app): flash('Scene created successfully!') return redirect(url_for('scene_detail', slug=safe_slug)) except Exception as e: - print(f"Save error: {e}") + logger.exception("Save error: %s", e) flash(f"Failed to create scene: {e}") return render_template('scenes/create.html', form_data=form_data) return render_template('scenes/create.html', form_data=form_data) - @app.route('/scene//clone', methods=['POST']) - def clone_scene(slug): - scene = Scene.query.filter_by(slug=slug).first_or_404() - - base_id = scene.scene_id - import re - match = re.match(r'^(.+?)_(\d+)$', base_id) - if match: - base_name = match.group(1) - current_num = int(match.group(2)) - else: - base_name = base_id - current_num = 1 - - next_num = current_num + 1 - while True: - new_id = f"{base_name}_{next_num:02d}" - new_filename = f"{new_id}.json" - new_path = os.path.join(app.config['SCENES_DIR'], new_filename) - if not os.path.exists(new_path): - break - next_num += 1 - - new_data = scene.data.copy() - new_data['scene_id'] = new_id - new_data['scene_name'] = f"{scene.name} (Copy)" - - with open(new_path, 'w') as f: - json.dump(new_data, f, indent=2) - - new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id) - new_scene = Scene( - scene_id=new_id, slug=new_slug, filename=new_filename, - name=new_data['scene_name'], data=new_data - ) - db.session.add(new_scene) - db.session.commit() - - flash(f'Scene cloned as "{new_id}"!') - return redirect(url_for('scene_detail', slug=new_slug)) - - @app.route('/scene//save_json', methods=['POST']) - def save_scene_json(slug): - scene = Scene.query.filter_by(slug=slug).first_or_404() - try: - new_data = json.loads(request.form.get('json_data', '')) - except (ValueError, TypeError) as e: - return {'success': False, 'error': f'Invalid JSON: {e}'}, 400 - scene.data = new_data - flag_modified(scene, 'data') - db.session.commit() - if scene.filename: - file_path = os.path.join(app.config['SCENES_DIR'], scene.filename) - with open(file_path, 'w') as f: - json.dump(new_data, f, indent=2) - return {'success': True} - - @app.route('/scene//favourite', methods=['POST']) - def toggle_scene_favourite(slug): - scene = Scene.query.filter_by(slug=slug).first_or_404() - scene.is_favourite = not scene.is_favourite - db.session.commit() - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'success': True, 'is_favourite': scene.is_favourite} - return redirect(url_for('scene_detail', slug=slug)) diff --git a/routes/shared.py b/routes/shared.py new file mode 100644 index 0000000..8d7de35 --- /dev/null +++ b/routes/shared.py @@ -0,0 +1,422 @@ +""" +Shared route factory functions for common patterns across all resource categories. + +Each factory registers a route on the Flask app using the closure pattern, +preserving endpoint names for url_for() compatibility. +""" + +import json +import logging +import os +import re + +from flask import current_app, flash, redirect, request, url_for +from sqlalchemy.orm.attributes import flag_modified +from werkzeug.utils import secure_filename + +from models import db +from utils import allowed_file + +logger = logging.getLogger('gaze') + + +# --------------------------------------------------------------------------- +# Category configuration registry +# --------------------------------------------------------------------------- +# Each entry maps a category name to its metadata. +# 'url_prefix': the URL segment (e.g. 'outfit' → /outfit//...) +# 'detail_endpoint': the Flask endpoint name for the detail page +# 'config_dir': the app.config key for the JSON data directory +# 'category_folder': subfolder under static/uploads/ +# 'id_field': JSON key for entity ID +# 'name_field': JSON key for display name + +CATEGORY_CONFIG = { + 'characters': { + 'model': None, # Set at import time to avoid circular imports + 'url_prefix': 'character', + 'detail_endpoint': 'detail', + 'config_dir': 'CHARACTERS_DIR', + 'category_folder': 'characters', + 'id_field': 'character_id', + 'name_field': 'character_name', + # Characters use unprefixed endpoint names + 'endpoint_prefix': '', + }, + 'outfits': { + 'model': None, + 'url_prefix': 'outfit', + 'detail_endpoint': 'outfit_detail', + 'config_dir': 'CLOTHING_DIR', + 'category_folder': 'outfits', + 'id_field': 'outfit_id', + 'name_field': 'outfit_name', + 'endpoint_prefix': 'outfit_', + }, + 'actions': { + 'model': None, + 'url_prefix': 'action', + 'detail_endpoint': 'action_detail', + 'config_dir': 'ACTIONS_DIR', + 'category_folder': 'actions', + 'id_field': 'action_id', + 'name_field': 'action_name', + 'endpoint_prefix': 'action_', + }, + 'styles': { + 'model': None, + 'url_prefix': 'style', + 'detail_endpoint': 'style_detail', + 'config_dir': 'STYLES_DIR', + 'category_folder': 'styles', + 'id_field': 'style_id', + 'name_field': 'style_name', + 'endpoint_prefix': 'style_', + }, + 'scenes': { + 'model': None, + 'url_prefix': 'scene', + 'detail_endpoint': 'scene_detail', + 'config_dir': 'SCENES_DIR', + 'category_folder': 'scenes', + 'id_field': 'scene_id', + 'name_field': 'scene_name', + 'endpoint_prefix': 'scene_', + }, + 'detailers': { + 'model': None, + 'url_prefix': 'detailer', + 'detail_endpoint': 'detailer_detail', + 'config_dir': 'DETAILERS_DIR', + 'category_folder': 'detailers', + 'id_field': 'detailer_id', + 'name_field': 'detailer_name', + 'endpoint_prefix': 'detailer_', + }, + 'looks': { + 'model': None, + 'url_prefix': 'look', + 'detail_endpoint': 'look_detail', + 'config_dir': 'LOOKS_DIR', + 'category_folder': 'looks', + 'id_field': 'look_id', + 'name_field': 'look_name', + 'endpoint_prefix': 'look_', + }, + 'checkpoints': { + 'model': None, + 'url_prefix': 'checkpoint', + 'detail_endpoint': 'checkpoint_detail', + 'config_dir': 'CHECKPOINTS_DIR', + 'category_folder': 'checkpoints', + 'id_field': 'checkpoint_path', + 'name_field': 'checkpoint_name', + 'endpoint_prefix': 'checkpoint_', + }, + 'presets': { + 'model': None, + 'url_prefix': 'preset', + 'detail_endpoint': 'preset_detail', + 'config_dir': 'PRESETS_DIR', + 'category_folder': 'presets', + 'id_field': 'preset_id', + 'name_field': 'preset_name', + 'endpoint_prefix': 'preset_', + }, +} + + +def _init_models(): + """Lazily populate model references to avoid circular imports.""" + from models import (Action, Character, Checkpoint, Detailer, Look, + Outfit, Preset, Scene, Style) + CATEGORY_CONFIG['characters']['model'] = Character + CATEGORY_CONFIG['outfits']['model'] = Outfit + CATEGORY_CONFIG['actions']['model'] = Action + CATEGORY_CONFIG['styles']['model'] = Style + CATEGORY_CONFIG['scenes']['model'] = Scene + CATEGORY_CONFIG['detailers']['model'] = Detailer + CATEGORY_CONFIG['looks']['model'] = Look + CATEGORY_CONFIG['checkpoints']['model'] = Checkpoint + CATEGORY_CONFIG['presets']['model'] = Preset + + +def _get_config(category): + """Get config for a category, initializing models if needed.""" + cfg = CATEGORY_CONFIG[category] + if cfg['model'] is None: + _init_models() + return cfg + + +# --------------------------------------------------------------------------- +# Factory functions +# --------------------------------------------------------------------------- + +def _register_favourite_route(app, cfg): + """Register POST ///favourite toggle route.""" + Model = cfg['model'] + prefix = cfg['url_prefix'] + detail_ep = cfg['detail_endpoint'] + ep_prefix = cfg['endpoint_prefix'] + + # Characters use 'toggle_character_favourite', others use 'toggle_{prefix}_favourite' + if ep_prefix == '': + endpoint_name = 'toggle_character_favourite' + else: + endpoint_name = f'toggle_{prefix}_favourite' + + @app.route(f'/{prefix}//favourite', methods=['POST'], + endpoint=endpoint_name) + def favourite_toggle(slug): + entity = Model.query.filter_by(slug=slug).first_or_404() + entity.is_favourite = not entity.is_favourite + db.session.commit() + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return {'success': True, 'is_favourite': entity.is_favourite} + return redirect(url_for(detail_ep, slug=slug)) + + +def _register_upload_route(app, cfg): + """Register POST ///upload image route.""" + Model = cfg['model'] + prefix = cfg['url_prefix'] + detail_ep = cfg['detail_endpoint'] + ep_prefix = cfg['endpoint_prefix'] + folder = cfg['category_folder'] + + if ep_prefix == '': + endpoint_name = 'upload_image' + else: + endpoint_name = f'upload_{prefix}_image' + + @app.route(f'/{prefix}//upload', methods=['POST'], + endpoint=endpoint_name) + def upload_image(slug): + entity = Model.query.filter_by(slug=slug).first_or_404() + + if 'image' not in request.files: + flash('No file part') + return redirect(request.url) + + file = request.files['image'] + if file.filename == '': + flash('No selected file') + return redirect(request.url) + + if file and allowed_file(file.filename): + entity_folder = os.path.join( + current_app.config['UPLOAD_FOLDER'], f"{folder}/{slug}") + os.makedirs(entity_folder, exist_ok=True) + + filename = secure_filename(file.filename) + file_path = os.path.join(entity_folder, filename) + file.save(file_path) + + entity.image_path = f"{folder}/{slug}/{filename}" + db.session.commit() + flash('Image uploaded successfully!') + + return redirect(url_for(detail_ep, slug=slug)) + + +def _register_replace_cover_route(app, cfg): + """Register POST ///replace_cover_from_preview route.""" + Model = cfg['model'] + prefix = cfg['url_prefix'] + detail_ep = cfg['detail_endpoint'] + ep_prefix = cfg['endpoint_prefix'] + + if ep_prefix == '': + endpoint_name = 'replace_cover_from_preview' + else: + endpoint_name = f'replace_{prefix}_cover_from_preview' + + @app.route(f'/{prefix}//replace_cover_from_preview', + methods=['POST'], endpoint=endpoint_name) + def replace_cover(slug): + entity = Model.query.filter_by(slug=slug).first_or_404() + preview_path = request.form.get('preview_path') + if preview_path and os.path.exists( + os.path.join(current_app.config['UPLOAD_FOLDER'], preview_path)): + entity.image_path = preview_path + db.session.commit() + flash('Cover image updated!') + else: + flash('No valid preview image selected.', 'error') + return redirect(url_for(detail_ep, slug=slug)) + + +def _register_save_defaults_route(app, cfg): + """Register POST ///save_defaults route.""" + Model = cfg['model'] + prefix = cfg['url_prefix'] + detail_ep = cfg['detail_endpoint'] + ep_prefix = cfg['endpoint_prefix'] + category = cfg['category_folder'] + + if ep_prefix == '': + endpoint_name = 'save_defaults' + else: + endpoint_name = f'save_{prefix}_defaults' + + # Display name for the flash message + display = category.rstrip('s') + + @app.route(f'/{prefix}//save_defaults', methods=['POST'], + endpoint=endpoint_name) + def save_defaults(slug): + entity = Model.query.filter_by(slug=slug).first_or_404() + selected_fields = request.form.getlist('include_field') + entity.default_fields = selected_fields + db.session.commit() + flash(f'Default prompt selection saved for this {display}!') + return redirect(url_for(detail_ep, slug=slug)) + + +def _register_clone_route(app, cfg): + """Register POST ///clone route.""" + Model = cfg['model'] + prefix = cfg['url_prefix'] + detail_ep = cfg['detail_endpoint'] + config_dir = cfg['config_dir'] + id_field = cfg['id_field'] + name_field = cfg['name_field'] + + endpoint_name = f'clone_{prefix}' + + @app.route(f'/{prefix}//clone', methods=['POST'], + endpoint=endpoint_name) + def clone_entity(slug): + entity = Model.query.filter_by(slug=slug).first_or_404() + + base_id = getattr(entity, id_field, None) or entity.data.get(id_field) + match = re.match(r'^(.+?)_(\d+)$', base_id) + if match: + base_name = match.group(1) + current_num = int(match.group(2)) + else: + base_name = base_id + current_num = 1 + + next_num = current_num + 1 + while True: + new_id = f"{base_name}_{next_num:02d}" + new_filename = f"{new_id}.json" + new_path = os.path.join( + current_app.config[config_dir], new_filename) + if not os.path.exists(new_path): + break + next_num += 1 + + new_data = dict(entity.data) + new_data[id_field] = new_id + new_data[name_field] = f"{entity.name} (Copy)" + + with open(new_path, 'w') as f: + json.dump(new_data, f, indent=2) + + new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id) + kwargs = { + id_field: new_id, + 'slug': new_slug, + 'filename': new_filename, + 'name': new_data[name_field], + 'data': new_data, + } + new_entity = Model(**kwargs) + db.session.add(new_entity) + db.session.commit() + + flash(f'Cloned as "{new_id}"!') + return redirect(url_for(detail_ep, slug=new_slug)) + + +def _register_save_json_route(app, cfg): + """Register POST ///save_json route.""" + Model = cfg['model'] + prefix = cfg['url_prefix'] + config_dir = cfg['config_dir'] + ep_prefix = cfg['endpoint_prefix'] + + if ep_prefix == '': + endpoint_name = 'save_character_json' + else: + endpoint_name = f'save_{prefix}_json' + + @app.route(f'/{prefix}//save_json', methods=['POST'], + endpoint=endpoint_name) + def save_json(slug): + entity = Model.query.filter_by(slug=slug).first_or_404() + try: + new_data = json.loads(request.form.get('json_data', '')) + except (ValueError, TypeError) as e: + return {'success': False, 'error': f'Invalid JSON: {e}'}, 400 + entity.data = new_data + flag_modified(entity, 'data') + db.session.commit() + if entity.filename: + file_path = os.path.join( + current_app.config[config_dir], entity.filename) + with open(file_path, 'w') as f: + json.dump(new_data, f, indent=2) + return {'success': True} + + +def _register_get_missing_route(app, cfg): + """Register GET /get_missing_ route.""" + Model = cfg['model'] + category = cfg['category_folder'] + + endpoint_name = f'get_missing_{category}' + + @app.route(f'/get_missing_{category}', endpoint=endpoint_name) + def get_missing(): + missing = Model.query.filter( + (Model.image_path == None) | (Model.image_path == '') + ).order_by(Model.name).all() + return {'missing': [{'slug': e.slug, 'name': e.name} for e in missing]} + + +def _register_clear_covers_route(app, cfg): + """Register POST /clear_all__covers route.""" + Model = cfg['model'] + category = cfg['category_folder'] + ep_prefix = cfg['endpoint_prefix'] + + # Characters use 'clear_all_covers', others use 'clear_all_{category}_covers' + if ep_prefix == '': + endpoint_name = 'clear_all_covers' + url = '/clear_all_covers' + else: + endpoint_name = f'clear_all_{category.rstrip("s")}_covers' + url = f'/clear_all_{category.rstrip("s")}_covers' + + @app.route(url, methods=['POST'], endpoint=endpoint_name) + def clear_covers(): + entities = Model.query.all() + for entity in entities: + entity.image_path = None + db.session.commit() + return {'success': True} + + +# --------------------------------------------------------------------------- +# Main registration function +# --------------------------------------------------------------------------- + +def register_common_routes(app, category): + """Register all common routes for a category. + + Call this from each route module's register_routes(app) function. + """ + cfg = _get_config(category) + + _register_favourite_route(app, cfg) + _register_upload_route(app, cfg) + _register_replace_cover_route(app, cfg) + _register_save_defaults_route(app, cfg) + _register_clone_route(app, cfg) + _register_save_json_route(app, cfg) + _register_get_missing_route(app, cfg) + _register_clear_covers_route(app, cfg) diff --git a/routes/strengths.py b/routes/strengths.py index e82d3e1..4888bf3 100644 --- a/routes/strengths.py +++ b/routes/strengths.py @@ -263,7 +263,7 @@ def register_routes(app): else: character = None - print(f"[Strengths] char_slug={char_slug!r} → character={character.slug if character else 'none'}") + logger.debug("Strengths: char_slug=%r -> character=%s", char_slug, character.slug if character else 'none') action_obj = None extra_positive = '' @@ -274,7 +274,7 @@ def register_routes(app): action_obj = Action.query.filter_by(slug=action_slug).first() extra_positive = session.get(f'extra_pos_detailer_{slug}', '') extra_negative = session.get(f'extra_neg_detailer_{slug}', '') - print(f"[Strengths] detailer session — char={char_slug}, action={action_slug}, extra_pos={bool(extra_positive)}, extra_neg={bool(extra_negative)}") + logger.debug("Strengths: detailer session -- char=%s, action=%s, extra_pos=%s, extra_neg=%s", char_slug, action_slug, bool(extra_positive), bool(extra_negative)) prompts = _build_strengths_prompts(category, entity, character, action=action_obj, extra_positive=extra_positive) @@ -321,7 +321,7 @@ def register_routes(app): return {'status': 'queued', 'job_id': job['id']} except Exception as e: - print(f"[Strengths] generate error: {e}") + logger.exception("Strengths generate error: %s", e) return {'error': str(e)}, 500 @app.route('/strengths///list') diff --git a/routes/styles.py b/routes/styles.py index d8ed545..d91c558 100644 --- a/routes/styles.py +++ b/routes/styles.py @@ -4,23 +4,24 @@ import re import random import logging -from flask import render_template, request, redirect, url_for, flash, session, current_app -from werkzeug.utils import secure_filename +from flask import render_template, request, redirect, url_for, flash, session from sqlalchemy.orm.attributes import flag_modified -from models import db, Character, Style, Detailer, Settings +from models import db, Character, Style, Settings from services.workflow import _prepare_workflow, _get_default_checkpoint from services.job_queue import _enqueue_job, _make_finalize, _enqueue_task from services.prompts import build_prompt, _resolve_character, _ensure_character_fields, _append_background from services.sync import sync_styles from services.file_io import get_available_loras from services.llm import load_prompt, call_llm -from utils import allowed_file, _WARDROBE_KEYS +from routes.shared import register_common_routes +from utils import _WARDROBE_KEYS logger = logging.getLogger('gaze') def register_routes(app): + register_common_routes(app, 'styles') def _build_style_workflow(style_obj, character=None, selected_fields=None, fixed_seed=None, extra_positive=None, extra_negative=None): """Build and return a prepared ComfyUI workflow dict for a style generation.""" @@ -188,40 +189,11 @@ def register_routes(app): return redirect(url_for('style_detail', slug=slug)) except Exception as e: - print(f"Edit error: {e}") + logger.exception("Edit error: %s", e) flash(f"Error saving changes: {str(e)}") return render_template('styles/edit.html', style=style, loras=loras) - @app.route('/style//upload', methods=['POST']) - def upload_style_image(slug): - style = Style.query.filter_by(slug=slug).first_or_404() - - if 'image' not in request.files: - flash('No file part') - return redirect(request.url) - - file = request.files['image'] - if file.filename == '': - flash('No selected file') - return redirect(request.url) - - if file and allowed_file(file.filename): - # Create style subfolder - style_folder = os.path.join(app.config['UPLOAD_FOLDER'], f"styles/{slug}") - os.makedirs(style_folder, exist_ok=True) - - filename = secure_filename(file.filename) - file_path = os.path.join(style_folder, filename) - file.save(file_path) - - # Store relative path in DB - style.image_path = f"styles/{slug}/{filename}" - db.session.commit() - flash('Image uploaded successfully!') - - return redirect(url_for('style_detail', slug=slug)) - @app.route('/style//generate', methods=['POST']) def generate_style_image(slug): style_obj = Style.query.filter_by(slug=slug).first_or_404() @@ -269,59 +241,12 @@ def register_routes(app): return redirect(url_for('style_detail', slug=slug)) except Exception as e: - print(f"Generation error: {e}") + logger.exception("Generation error: %s", e) if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return {'error': str(e)}, 500 flash(f"Error during generation: {str(e)}") return redirect(url_for('style_detail', slug=slug)) - @app.route('/style//save_defaults', methods=['POST']) - def save_style_defaults(slug): - style = Style.query.filter_by(slug=slug).first_or_404() - selected_fields = request.form.getlist('include_field') - style.default_fields = selected_fields - db.session.commit() - flash('Default prompt selection saved for this style!') - return redirect(url_for('style_detail', slug=slug)) - - @app.route('/style//replace_cover_from_preview', methods=['POST']) - def replace_style_cover_from_preview(slug): - style = Style.query.filter_by(slug=slug).first_or_404() - preview_path = request.form.get('preview_path') - if preview_path and os.path.exists(os.path.join(app.config['UPLOAD_FOLDER'], preview_path)): - style.image_path = preview_path - db.session.commit() - flash('Cover image updated!') - else: - flash('No valid preview image selected.', 'error') - return redirect(url_for('style_detail', slug=slug)) - - @app.route('/get_missing_styles') - def get_missing_styles(): - missing = Style.query.filter((Style.image_path == None) | (Style.image_path == '')).order_by(Style.name).all() - return {'missing': [{'slug': s.slug, 'name': s.name} for s in missing]} - - @app.route('/get_missing_detailers') - def get_missing_detailers(): - missing = Detailer.query.filter((Detailer.image_path == None) | (Detailer.image_path == '')).order_by(Detailer.name).all() - return {'missing': [{'slug': d.slug, 'name': d.name} for d in missing]} - - @app.route('/clear_all_detailer_covers', methods=['POST']) - def clear_all_detailer_covers(): - detailers = Detailer.query.all() - for detailer in detailers: - detailer.image_path = None - db.session.commit() - return {'success': True} - - @app.route('/clear_all_style_covers', methods=['POST']) - def clear_all_style_covers(): - styles = Style.query.all() - for style in styles: - style.image_path = None - db.session.commit() - return {'success': True} - @app.route('/styles/generate_missing', methods=['POST']) def generate_missing_styles(): missing = Style.query.filter( @@ -347,7 +272,7 @@ def register_routes(app): _make_finalize('styles', style_obj.slug, Style)) enqueued += 1 except Exception as e: - print(f"Error queuing cover generation for style {style_obj.name}: {e}") + logger.exception("Error queuing cover generation for style %s: %s", style_obj.name, e) flash(f"Queued {enqueued} style cover image generation job{'s' if enqueued != 1 else ''}.") return redirect(url_for('styles_index')) @@ -511,73 +436,9 @@ def register_routes(app): flash('Style created successfully!') return redirect(url_for('style_detail', slug=safe_slug)) except Exception as e: - print(f"Save error: {e}") + logger.exception("Save error: %s", e) flash(f"Failed to create style: {e}") return render_template('styles/create.html', form_data=form_data) return render_template('styles/create.html', form_data=form_data) - @app.route('/style//clone', methods=['POST']) - def clone_style(slug): - style = Style.query.filter_by(slug=slug).first_or_404() - - base_id = style.style_id - match = re.match(r'^(.+?)_(\d+)$', base_id) - if match: - base_name = match.group(1) - current_num = int(match.group(2)) - else: - base_name = base_id - current_num = 1 - - next_num = current_num + 1 - while True: - new_id = f"{base_name}_{next_num:02d}" - new_filename = f"{new_id}.json" - new_path = os.path.join(app.config['STYLES_DIR'], new_filename) - if not os.path.exists(new_path): - break - next_num += 1 - - new_data = style.data.copy() - new_data['style_id'] = new_id - new_data['style_name'] = f"{style.name} (Copy)" - - with open(new_path, 'w') as f: - json.dump(new_data, f, indent=2) - - new_slug = re.sub(r'[^a-zA-Z0-9_]', '', new_id) - new_style = Style( - style_id=new_id, slug=new_slug, filename=new_filename, - name=new_data['style_name'], data=new_data - ) - db.session.add(new_style) - db.session.commit() - - flash(f'Style cloned as "{new_id}"!') - return redirect(url_for('style_detail', slug=new_slug)) - - @app.route('/style//save_json', methods=['POST']) - def save_style_json(slug): - style = Style.query.filter_by(slug=slug).first_or_404() - try: - new_data = json.loads(request.form.get('json_data', '')) - except (ValueError, TypeError) as e: - return {'success': False, 'error': f'Invalid JSON: {e}'}, 400 - style.data = new_data - flag_modified(style, 'data') - db.session.commit() - if style.filename: - file_path = os.path.join(app.config['STYLES_DIR'], style.filename) - with open(file_path, 'w') as f: - json.dump(new_data, f, indent=2) - return {'success': True} - - @app.route('/style//favourite', methods=['POST']) - def toggle_style_favourite(slug): - style_obj = Style.query.filter_by(slug=slug).first_or_404() - style_obj.is_favourite = not style_obj.is_favourite - db.session.commit() - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return {'success': True, 'is_favourite': style_obj.is_favourite} - return redirect(url_for('style_detail', slug=slug)) diff --git a/services/job_queue.py b/services/job_queue.py index 2a593a1..cb38880 100644 --- a/services/job_queue.py +++ b/services/job_queue.py @@ -272,8 +272,7 @@ def _make_finalize(category, slug, db_model_class=None, action=None, metadata=No logger.debug("=" * 80) return - logger.warning("FINALIZE - No images found in outputs!") - logger.debug("=" * 80) + raise RuntimeError("No images found in ComfyUI outputs") return _finalize diff --git a/services/llm.py b/services/llm.py index a269c79..a4b399e 100644 --- a/services/llm.py +++ b/services/llm.py @@ -1,12 +1,15 @@ import os import json import asyncio +import logging import requests from flask import has_request_context, request as flask_request from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client from models import Settings +logger = logging.getLogger('gaze') + DANBOORU_TOOLS = [ { "type": "function", @@ -73,7 +76,7 @@ def call_mcp_tool(name, arguments): try: return asyncio.run(_run_mcp_tool(name, arguments)) except Exception as e: - print(f"MCP Tool Error: {e}") + logger.error("MCP Tool Error: %s", e) return json.dumps({"error": str(e)}) @@ -95,7 +98,7 @@ def call_character_mcp_tool(name, arguments): try: return asyncio.run(_run_character_mcp_tool(name, arguments)) except Exception as e: - print(f"Character MCP Tool Error: {e}") + logger.error("Character MCP Tool Error: %s", e) return None @@ -164,7 +167,7 @@ def call_llm(prompt, system_prompt="You are a creative assistant."): # If 400 Bad Request and we were using tools, try once without tools if response.status_code == 400 and use_tools: - print(f"LLM Provider {settings.llm_provider} rejected tools. Retrying without tool calling...") + logger.warning("LLM Provider %s rejected tools. Retrying without tool calling...", settings.llm_provider) use_tools = False max_turns += 1 # Reset turn for the retry continue @@ -186,7 +189,7 @@ def call_llm(prompt, system_prompt="You are a creative assistant."): for tool_call in message['tool_calls']: name = tool_call['function']['name'] args = json.loads(tool_call['function']['arguments']) - print(f"Executing MCP tool: {name}({args})") + logger.debug("Executing MCP tool: %s(%s)", name, args) tool_result = call_mcp_tool(name, args) messages.append({ "role": "tool", @@ -195,7 +198,7 @@ def call_llm(prompt, system_prompt="You are a creative assistant."): "content": tool_result }) if tool_turns_remaining <= 0: - print("Tool turn limit reached — next request will not offer tools") + logger.warning("Tool turn limit reached — next request will not offer tools") continue return message['content'] @@ -209,7 +212,7 @@ def call_llm(prompt, system_prompt="You are a creative assistant."): raw = "" try: raw = response.text[:500] except: pass - print(f"Unexpected LLM response format (key={e}). Raw response: {raw}") + logger.warning("Unexpected LLM response format (key=%s). Raw response: %s", e, raw) if format_retries > 0: format_retries -= 1 max_turns += 1 # don't burn a turn on a format error @@ -222,7 +225,7 @@ def call_llm(prompt, system_prompt="You are a creative assistant."): "Do not include any explanation or markdown — only the raw JSON object." ) }) - print(f"Retrying after format error ({format_retries} retries left)…") + logger.info("Retrying after format error (%d retries left)…", format_retries) continue raise RuntimeError(f"Unexpected LLM response format after retries: {str(e)}") from e diff --git a/services/mcp.py b/services/mcp.py index 29d3164..5be016e 100644 --- a/services/mcp.py +++ b/services/mcp.py @@ -1,6 +1,9 @@ import os +import logging import subprocess +logger = logging.getLogger('gaze') + # Path to the MCP docker-compose projects, relative to the main app file. MCP_TOOLS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'tools') MCP_COMPOSE_DIR = os.path.join(MCP_TOOLS_DIR, 'danbooru-mcp') @@ -19,28 +22,28 @@ def _ensure_mcp_repo(): os.makedirs(MCP_TOOLS_DIR, exist_ok=True) try: if not os.path.isdir(MCP_COMPOSE_DIR): - print(f'Cloning danbooru-mcp from {MCP_REPO_URL} …') + logger.info('Cloning danbooru-mcp from %s …', MCP_REPO_URL) subprocess.run( ['git', 'clone', MCP_REPO_URL, MCP_COMPOSE_DIR], timeout=120, check=True, ) - print('danbooru-mcp cloned successfully.') + logger.info('danbooru-mcp cloned successfully.') else: - print('Updating danbooru-mcp via git pull …') + logger.info('Updating danbooru-mcp via git pull …') subprocess.run( ['git', 'pull'], cwd=MCP_COMPOSE_DIR, timeout=60, check=True, ) - print('danbooru-mcp updated.') + logger.info('danbooru-mcp updated.') except FileNotFoundError: - print('WARNING: git not found on PATH — danbooru-mcp repo will not be cloned/updated.') + logger.warning('git not found on PATH — danbooru-mcp repo will not be cloned/updated.') except subprocess.CalledProcessError as e: - print(f'WARNING: git operation failed for danbooru-mcp: {e}') + logger.warning('git operation failed for danbooru-mcp: %s', e) except subprocess.TimeoutExpired: - print('WARNING: git timed out while cloning/updating danbooru-mcp.') + logger.warning('git timed out while cloning/updating danbooru-mcp.') except Exception as e: - print(f'WARNING: Could not clone/update danbooru-mcp repo: {e}') + logger.warning('Could not clone/update danbooru-mcp repo: %s', e) def ensure_mcp_server_running(): @@ -55,7 +58,7 @@ def ensure_mcp_server_running(): danbooru-mcp service is managed by compose instead). """ if os.environ.get('SKIP_MCP_AUTOSTART', '').lower() == 'true': - print('SKIP_MCP_AUTOSTART set — skipping danbooru-mcp auto-start.') + logger.info('SKIP_MCP_AUTOSTART set — skipping danbooru-mcp auto-start.') return _ensure_mcp_repo() try: @@ -64,22 +67,22 @@ def ensure_mcp_server_running(): capture_output=True, text=True, timeout=10, ) if 'danbooru-mcp' in result.stdout: - print('danbooru-mcp container already running.') + logger.info('danbooru-mcp container already running.') return # Container not running — start it via docker compose - print('Starting danbooru-mcp container via docker compose …') + logger.info('Starting danbooru-mcp container via docker compose …') subprocess.run( ['docker', 'compose', 'up', '-d'], cwd=MCP_COMPOSE_DIR, timeout=120, ) - print('danbooru-mcp container started.') + logger.info('danbooru-mcp container started.') except FileNotFoundError: - print('WARNING: docker not found on PATH — danbooru-mcp will not be started automatically.') + logger.warning('docker not found on PATH — danbooru-mcp will not be started automatically.') except subprocess.TimeoutExpired: - print('WARNING: docker timed out while starting danbooru-mcp.') + logger.warning('docker timed out while starting danbooru-mcp.') except Exception as e: - print(f'WARNING: Could not ensure danbooru-mcp is running: {e}') + logger.warning('Could not ensure danbooru-mcp is running: %s', e) def _ensure_character_mcp_repo(): @@ -92,28 +95,28 @@ def _ensure_character_mcp_repo(): os.makedirs(MCP_TOOLS_DIR, exist_ok=True) try: if not os.path.isdir(CHAR_MCP_COMPOSE_DIR): - print(f'Cloning character-mcp from {CHAR_MCP_REPO_URL} …') + logger.info('Cloning character-mcp from %s …', CHAR_MCP_REPO_URL) subprocess.run( ['git', 'clone', CHAR_MCP_REPO_URL, CHAR_MCP_COMPOSE_DIR], timeout=120, check=True, ) - print('character-mcp cloned successfully.') + logger.info('character-mcp cloned successfully.') else: - print('Updating character-mcp via git pull …') + logger.info('Updating character-mcp via git pull …') subprocess.run( ['git', 'pull'], cwd=CHAR_MCP_COMPOSE_DIR, timeout=60, check=True, ) - print('character-mcp updated.') + logger.info('character-mcp updated.') except FileNotFoundError: - print('WARNING: git not found on PATH — character-mcp repo will not be cloned/updated.') + logger.warning('git not found on PATH — character-mcp repo will not be cloned/updated.') except subprocess.CalledProcessError as e: - print(f'WARNING: git operation failed for character-mcp: {e}') + logger.warning('git operation failed for character-mcp: %s', e) except subprocess.TimeoutExpired: - print('WARNING: git timed out while cloning/updating character-mcp.') + logger.warning('git timed out while cloning/updating character-mcp.') except Exception as e: - print(f'WARNING: Could not clone/update character-mcp repo: {e}') + logger.warning('Could not clone/update character-mcp repo: %s', e) def ensure_character_mcp_server_running(): @@ -128,7 +131,7 @@ def ensure_character_mcp_server_running(): character-mcp service is managed by compose instead). """ if os.environ.get('SKIP_MCP_AUTOSTART', '').lower() == 'true': - print('SKIP_MCP_AUTOSTART set — skipping character-mcp auto-start.') + logger.info('SKIP_MCP_AUTOSTART set — skipping character-mcp auto-start.') return _ensure_character_mcp_repo() try: @@ -137,19 +140,19 @@ def ensure_character_mcp_server_running(): capture_output=True, text=True, timeout=10, ) if 'character-mcp' in result.stdout: - print('character-mcp container already running.') + logger.info('character-mcp container already running.') return # Container not running — start it via docker compose - print('Starting character-mcp container via docker compose …') + logger.info('Starting character-mcp container via docker compose …') subprocess.run( ['docker', 'compose', 'up', '-d'], cwd=CHAR_MCP_COMPOSE_DIR, timeout=120, ) - print('character-mcp container started.') + logger.info('character-mcp container started.') except FileNotFoundError: - print('WARNING: docker not found on PATH — character-mcp will not be started automatically.') + logger.warning('docker not found on PATH — character-mcp will not be started automatically.') except subprocess.TimeoutExpired: - print('WARNING: docker timed out while starting character-mcp.') + logger.warning('docker timed out while starting character-mcp.') except Exception as e: - print(f'WARNING: Could not ensure character-mcp is running: {e}') + logger.warning('Could not ensure character-mcp is running: %s', e) diff --git a/services/sync.py b/services/sync.py index a82c758..6e4d998 100644 --- a/services/sync.py +++ b/services/sync.py @@ -57,7 +57,7 @@ def sync_characters(): if character.image_path: full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], character.image_path) if not os.path.exists(full_img_path): - print(f"Image missing for {character.name}, clearing path.") + logger.warning("Image missing for %s, clearing path.", character.name) character.image_path = None # Explicitly tell SQLAlchemy the JSON field was modified @@ -73,7 +73,7 @@ def sync_characters(): _sync_nsfw_from_tags(new_char, data) db.session.add(new_char) except Exception as e: - print(f"Error importing {filename}: {e}") + logger.error("Error importing %s: %s", filename, e) # Remove characters that are no longer in the folder all_characters = Character.query.all() @@ -83,66 +83,82 @@ def sync_characters(): db.session.commit() -def sync_outfits(): - if not os.path.exists(current_app.config['CLOTHING_DIR']): +def _sync_category(config_key, model_class, id_field, name_field, + extra_fn=None, sync_nsfw=True): + """Generic sync: load JSON files from a data directory into the database. + + Args: + config_key: app.config key for the data directory (e.g. 'CLOTHING_DIR') + model_class: SQLAlchemy model class (e.g. Outfit) + id_field: JSON key for the entity ID (e.g. 'outfit_id') + name_field: JSON key for the display name (e.g. 'outfit_name') + extra_fn: optional callable(entity, data) for category-specific field updates + sync_nsfw: if True, call _sync_nsfw_from_tags on create/update + """ + data_dir = current_app.config.get(config_key) + if not data_dir or not os.path.exists(data_dir): return current_ids = [] - for filename in os.listdir(current_app.config['CLOTHING_DIR']): + for filename in os.listdir(data_dir): if filename.endswith('.json'): - file_path = os.path.join(current_app.config['CLOTHING_DIR'], filename) + file_path = os.path.join(data_dir, filename) try: with open(file_path, 'r') as f: data = json.load(f) - outfit_id = data.get('outfit_id') or filename.replace('.json', '') + entity_id = data.get(id_field) or filename.replace('.json', '') + current_ids.append(entity_id) - current_ids.append(outfit_id) + slug = re.sub(r'[^a-zA-Z0-9_]', '', entity_id) + entity = model_class.query.filter_by(**{id_field: entity_id}).first() + name = data.get(name_field, entity_id.replace('_', ' ').title()) - # Generate URL-safe slug: remove special characters from outfit_id - slug = re.sub(r'[^a-zA-Z0-9_]', '', outfit_id) + if entity: + entity.data = data + entity.name = name + entity.slug = slug + entity.filename = filename + if sync_nsfw: + _sync_nsfw_from_tags(entity, data) + if extra_fn: + extra_fn(entity, data) - # Check if outfit already exists - outfit = Outfit.query.filter_by(outfit_id=outfit_id).first() - name = data.get('outfit_name', outfit_id.replace('_', ' ').title()) - - if outfit: - outfit.data = data - outfit.name = name - outfit.slug = slug - outfit.filename = filename - _sync_nsfw_from_tags(outfit, data) - - # Check if cover image still exists - if outfit.image_path: - full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], outfit.image_path) + if entity.image_path: + full_img_path = os.path.join( + current_app.config['UPLOAD_FOLDER'], entity.image_path) if not os.path.exists(full_img_path): - print(f"Image missing for {outfit.name}, clearing path.") - outfit.image_path = None + logger.warning("Image missing for %s, clearing path.", entity.name) + entity.image_path = None - # Explicitly tell SQLAlchemy the JSON field was modified - flag_modified(outfit, "data") + flag_modified(entity, "data") else: - new_outfit = Outfit( - outfit_id=outfit_id, - slug=slug, - filename=filename, - name=name, - data=data - ) - _sync_nsfw_from_tags(new_outfit, data) - db.session.add(new_outfit) + kwargs = { + id_field: entity_id, + 'slug': slug, + 'filename': filename, + 'name': name, + 'data': data, + } + new_entity = model_class(**kwargs) + if sync_nsfw: + _sync_nsfw_from_tags(new_entity, data) + if extra_fn: + extra_fn(new_entity, data) + db.session.add(new_entity) except Exception as e: - print(f"Error importing outfit {filename}: {e}") + logger.error("Error importing %s: %s", filename, e) - # Remove outfits that are no longer in the folder - all_outfits = Outfit.query.all() - for outfit in all_outfits: - if outfit.outfit_id not in current_ids: - db.session.delete(outfit) + for entity in model_class.query.all(): + if getattr(entity, id_field) not in current_ids: + db.session.delete(entity) db.session.commit() + +def sync_outfits(): + _sync_category('CLOTHING_DIR', Outfit, 'outfit_id', 'outfit_name') + def ensure_default_outfit(): """Ensure a default outfit file exists and is registered in the database. @@ -226,114 +242,16 @@ def ensure_default_outfit(): +def _sync_look_extra(entity, data): + entity.character_id = data.get('character_id', None) + def sync_looks(): - if not os.path.exists(current_app.config['LOOKS_DIR']): - return - - current_ids = [] - - for filename in os.listdir(current_app.config['LOOKS_DIR']): - if filename.endswith('.json'): - file_path = os.path.join(current_app.config['LOOKS_DIR'], filename) - try: - with open(file_path, 'r') as f: - data = json.load(f) - look_id = data.get('look_id') or filename.replace('.json', '') - - current_ids.append(look_id) - - slug = re.sub(r'[^a-zA-Z0-9_]', '', look_id) - - look = Look.query.filter_by(look_id=look_id).first() - name = data.get('look_name', look_id.replace('_', ' ').title()) - character_id = data.get('character_id', None) - - if look: - look.data = data - look.name = name - look.slug = slug - look.filename = filename - look.character_id = character_id - _sync_nsfw_from_tags(look, data) - - if look.image_path: - full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], look.image_path) - if not os.path.exists(full_img_path): - look.image_path = None - - flag_modified(look, "data") - else: - new_look = Look( - look_id=look_id, - slug=slug, - filename=filename, - name=name, - character_id=character_id, - data=data - ) - _sync_nsfw_from_tags(new_look, data) - db.session.add(new_look) - except Exception as e: - print(f"Error importing look {filename}: {e}") - - all_looks = Look.query.all() - for look in all_looks: - if look.look_id not in current_ids: - db.session.delete(look) - - db.session.commit() + _sync_category('LOOKS_DIR', Look, 'look_id', 'look_name', + extra_fn=_sync_look_extra) def sync_presets(): - if not os.path.exists(current_app.config['PRESETS_DIR']): - return - - current_ids = [] - - for filename in os.listdir(current_app.config['PRESETS_DIR']): - if filename.endswith('.json'): - file_path = os.path.join(current_app.config['PRESETS_DIR'], filename) - try: - with open(file_path, 'r') as f: - data = json.load(f) - preset_id = data.get('preset_id') or filename.replace('.json', '') - - current_ids.append(preset_id) - - slug = re.sub(r'[^a-zA-Z0-9_]', '', preset_id) - - preset = Preset.query.filter_by(preset_id=preset_id).first() - name = data.get('preset_name', preset_id.replace('_', ' ').title()) - - if preset: - preset.data = data - preset.name = name - preset.slug = slug - preset.filename = filename - - if preset.image_path: - full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], preset.image_path) - if not os.path.exists(full_img_path): - preset.image_path = None - - flag_modified(preset, "data") - else: - new_preset = Preset( - preset_id=preset_id, - slug=slug, - filename=filename, - name=name, - data=data - ) - db.session.add(new_preset) - except Exception as e: - print(f"Error importing preset {filename}: {e}") - - all_presets = Preset.query.all() - for preset in all_presets: - if preset.preset_id not in current_ids: - db.session.delete(preset) - - db.session.commit() + _sync_category('PRESETS_DIR', Preset, 'preset_id', 'preset_name', + sync_nsfw=False) # --------------------------------------------------------------------------- @@ -404,240 +322,16 @@ def _resolve_preset_fields(preset_data): def sync_actions(): - if not os.path.exists(current_app.config['ACTIONS_DIR']): - return - - current_ids = [] - - for filename in os.listdir(current_app.config['ACTIONS_DIR']): - if filename.endswith('.json'): - file_path = os.path.join(current_app.config['ACTIONS_DIR'], filename) - try: - with open(file_path, 'r') as f: - data = json.load(f) - action_id = data.get('action_id') or filename.replace('.json', '') - - current_ids.append(action_id) - - # Generate URL-safe slug - slug = re.sub(r'[^a-zA-Z0-9_]', '', action_id) - - # Check if action already exists - action = Action.query.filter_by(action_id=action_id).first() - name = data.get('action_name', action_id.replace('_', ' ').title()) - - if action: - action.data = data - action.name = name - action.slug = slug - action.filename = filename - _sync_nsfw_from_tags(action, data) - - # Check if cover image still exists - if action.image_path: - full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], action.image_path) - if not os.path.exists(full_img_path): - print(f"Image missing for {action.name}, clearing path.") - action.image_path = None - - flag_modified(action, "data") - else: - new_action = Action( - action_id=action_id, - slug=slug, - filename=filename, - name=name, - data=data - ) - _sync_nsfw_from_tags(new_action, data) - db.session.add(new_action) - except Exception as e: - print(f"Error importing action {filename}: {e}") - - # Remove actions that are no longer in the folder - all_actions = Action.query.all() - for action in all_actions: - if action.action_id not in current_ids: - db.session.delete(action) - - db.session.commit() + _sync_category('ACTIONS_DIR', Action, 'action_id', 'action_name') def sync_styles(): - if not os.path.exists(current_app.config['STYLES_DIR']): - return - - current_ids = [] - - for filename in os.listdir(current_app.config['STYLES_DIR']): - if filename.endswith('.json'): - file_path = os.path.join(current_app.config['STYLES_DIR'], filename) - try: - with open(file_path, 'r') as f: - data = json.load(f) - style_id = data.get('style_id') or filename.replace('.json', '') - - current_ids.append(style_id) - - # Generate URL-safe slug - slug = re.sub(r'[^a-zA-Z0-9_]', '', style_id) - - # Check if style already exists - style = Style.query.filter_by(style_id=style_id).first() - name = data.get('style_name', style_id.replace('_', ' ').title()) - - if style: - style.data = data - style.name = name - style.slug = slug - style.filename = filename - _sync_nsfw_from_tags(style, data) - - # Check if cover image still exists - if style.image_path: - full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], style.image_path) - if not os.path.exists(full_img_path): - print(f"Image missing for {style.name}, clearing path.") - style.image_path = None - - flag_modified(style, "data") - else: - new_style = Style( - style_id=style_id, - slug=slug, - filename=filename, - name=name, - data=data - ) - _sync_nsfw_from_tags(new_style, data) - db.session.add(new_style) - except Exception as e: - print(f"Error importing style {filename}: {e}") - - # Remove styles that are no longer in the folder - all_styles = Style.query.all() - for style in all_styles: - if style.style_id not in current_ids: - db.session.delete(style) - - db.session.commit() + _sync_category('STYLES_DIR', Style, 'style_id', 'style_name') def sync_detailers(): - if not os.path.exists(current_app.config['DETAILERS_DIR']): - return - - current_ids = [] - - for filename in os.listdir(current_app.config['DETAILERS_DIR']): - if filename.endswith('.json'): - file_path = os.path.join(current_app.config['DETAILERS_DIR'], filename) - try: - with open(file_path, 'r') as f: - data = json.load(f) - detailer_id = data.get('detailer_id') or filename.replace('.json', '') - - current_ids.append(detailer_id) - - # Generate URL-safe slug - slug = re.sub(r'[^a-zA-Z0-9_]', '', detailer_id) - - # Check if detailer already exists - detailer = Detailer.query.filter_by(detailer_id=detailer_id).first() - name = data.get('detailer_name', detailer_id.replace('_', ' ').title()) - - if detailer: - detailer.data = data - detailer.name = name - detailer.slug = slug - detailer.filename = filename - _sync_nsfw_from_tags(detailer, data) - - # Check if cover image still exists - if detailer.image_path: - full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], detailer.image_path) - if not os.path.exists(full_img_path): - print(f"Image missing for {detailer.name}, clearing path.") - detailer.image_path = None - - flag_modified(detailer, "data") - else: - new_detailer = Detailer( - detailer_id=detailer_id, - slug=slug, - filename=filename, - name=name, - data=data - ) - _sync_nsfw_from_tags(new_detailer, data) - db.session.add(new_detailer) - except Exception as e: - print(f"Error importing detailer {filename}: {e}") - - # Remove detailers that are no longer in the folder - all_detailers = Detailer.query.all() - for detailer in all_detailers: - if detailer.detailer_id not in current_ids: - db.session.delete(detailer) - - db.session.commit() + _sync_category('DETAILERS_DIR', Detailer, 'detailer_id', 'detailer_name') def sync_scenes(): - if not os.path.exists(current_app.config['SCENES_DIR']): - return - - current_ids = [] - - for filename in os.listdir(current_app.config['SCENES_DIR']): - if filename.endswith('.json'): - file_path = os.path.join(current_app.config['SCENES_DIR'], filename) - try: - with open(file_path, 'r') as f: - data = json.load(f) - scene_id = data.get('scene_id') or filename.replace('.json', '') - - current_ids.append(scene_id) - - # Generate URL-safe slug - slug = re.sub(r'[^a-zA-Z0-9_]', '', scene_id) - - # Check if scene already exists - scene = Scene.query.filter_by(scene_id=scene_id).first() - name = data.get('scene_name', scene_id.replace('_', ' ').title()) - - if scene: - scene.data = data - scene.name = name - scene.slug = slug - scene.filename = filename - _sync_nsfw_from_tags(scene, data) - - # Check if cover image still exists - if scene.image_path: - full_img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], scene.image_path) - if not os.path.exists(full_img_path): - print(f"Image missing for {scene.name}, clearing path.") - scene.image_path = None - - flag_modified(scene, "data") - else: - new_scene = Scene( - scene_id=scene_id, - slug=slug, - filename=filename, - name=name, - data=data - ) - _sync_nsfw_from_tags(new_scene, data) - db.session.add(new_scene) - except Exception as e: - print(f"Error importing scene {filename}: {e}") - - # Remove scenes that are no longer in the folder - all_scenes = Scene.query.all() - for scene in all_scenes: - if scene.scene_id not in current_ids: - db.session.delete(scene) - - db.session.commit() + _sync_category('SCENES_DIR', Scene, 'scene_id', 'scene_name') def _default_checkpoint_data(checkpoint_path, filename): """Return template-default data for a checkpoint with no JSON file.""" @@ -650,6 +344,7 @@ def _default_checkpoint_data(checkpoint_path, filename): "steps": 25, "cfg": 5, "sampler_name": "euler_ancestral", + "scheduler": "normal", "vae": "integrated" } @@ -669,7 +364,7 @@ def sync_checkpoints(): if ckpt_path: json_data_by_path[ckpt_path] = data except Exception as e: - print(f"Error reading checkpoint JSON {filename}: {e}") + logger.error("Error reading checkpoint JSON %s: %s", filename, e) current_ids = [] dirs = [ diff --git a/static/js/detail-common.js b/static/js/detail-common.js new file mode 100644 index 0000000..bb6e20d --- /dev/null +++ b/static/js/detail-common.js @@ -0,0 +1,267 @@ +/** + * Shared JS for all resource detail pages. + * + * Usage: call initDetailPage(options) from the template's {% endblock %} diff --git a/templates/actions/index.html b/templates/actions/index.html index 79ce164..658062c 100644 --- a/templates/actions/index.html +++ b/templates/actions/index.html @@ -71,8 +71,8 @@ {% set lora_name = action.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %} {{ lora_name }} {% else %}{% endif %} - + diff --git a/templates/checkpoints/detail.html b/templates/checkpoints/detail.html index c8f0d88..7044043 100644 --- a/templates/checkpoints/detail.html +++ b/templates/checkpoints/detail.html @@ -101,6 +101,19 @@ + + {% set tags = ckpt.data.tags if ckpt.data is mapping and ckpt.data.tags is mapping else {} %} + {% if tags %} +
+
Tags
+
+ {% if tags.art_style %}{{ tags.art_style }}{% endif %} + {% if tags.base_model %}{{ tags.base_model }}{% endif %} + {% if ckpt.is_nsfw %}NSFW{% endif %} + {% if ckpt.is_favourite %}★ Favourite{% endif %} +
+
+ {% endif %}
@@ -235,184 +248,13 @@ {% endblock %} {% block scripts %} + {% endblock %} diff --git a/templates/checkpoints/index.html b/templates/checkpoints/index.html index 7aea906..fb3a5d7 100644 --- a/templates/checkpoints/index.html +++ b/templates/checkpoints/index.html @@ -57,8 +57,8 @@
diff --git a/templates/detail.html b/templates/detail.html index f06840a..14c1afb 100644 --- a/templates/detail.html +++ b/templates/detail.html @@ -271,114 +271,10 @@ {% endblock %} {% block scripts %} + {% endblock %} diff --git a/templates/detailers/detail.html b/templates/detailers/detail.html index 7da9e62..9854ebc 100644 --- a/templates/detailers/detail.html +++ b/templates/detailers/detail.html @@ -122,6 +122,19 @@ + + {% set tags = detailer.data.tags if detailer.data.tags is mapping else {} %} + {% if tags %} +
+
Tags
+
+ {% if tags.associated_resource %}{{ tags.associated_resource }}{% endif %} + {% if tags.adetailer_targets %}{{ tags.adetailer_targets | join(', ') }}{% endif %} + {% if detailer.is_nsfw %}NSFW{% endif %} + {% if detailer.is_favourite %}★ Favourite{% endif %} +
+
+ {% endif %}
@@ -265,188 +278,13 @@ {% endblock %} {% block scripts %} + {% endblock %} diff --git a/templates/detailers/index.html b/templates/detailers/index.html index b569d3c..65cea2f 100644 --- a/templates/detailers/index.html +++ b/templates/detailers/index.html @@ -73,8 +73,8 @@ {% set lora_name = detailer.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %} {{ lora_name }} {% else %}{% endif %} - +
diff --git a/templates/gallery.html b/templates/gallery.html index 2bfcc69..a162292 100644 --- a/templates/gallery.html +++ b/templates/gallery.html @@ -332,10 +332,10 @@ class="btn btn-sm btn-outline-light py-0 px-2" onclick="event.stopPropagation()">Generator {% endif %} - diff --git a/templates/index.html b/templates/index.html index 2f161ba..1696ee2 100644 --- a/templates/index.html +++ b/templates/index.html @@ -84,8 +84,8 @@ {% set lora_name = char.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %} {{ lora_name }} {% else %}{% endif %} - + diff --git a/templates/layout.html b/templates/layout.html index e723b8d..2698742 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -217,192 +217,8 @@ document.getElementById('rdm-name').textContent = name; resourceDeleteModal.show(); } - async function confirmResourceDelete(mode) { - resourceDeleteModal.hide(); - try { - const res = await fetch(`/resource/${_rdmCategory}/${_rdmSlug}/delete`, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({mode}), - }); - const data = await res.json(); - if (data.status === 'ok') { - const card = document.getElementById(`card-${_rdmSlug}`); - if (card) card.remove(); - } else { - alert('Delete failed: ' + (data.error || 'unknown error')); - } - } catch (e) { - alert('Delete failed: ' + e); - } - } - - function regenerateTags(category, slug) { - const btn = document.getElementById('regenerate-tags-btn'); - if (!btn) return; - const origText = btn.innerHTML; - btn.disabled = true; - btn.innerHTML = 'Regenerating…'; - fetch(`/api/${category}/${slug}/regenerate_tags`, { - method: 'POST', - headers: {'X-Requested-With': 'XMLHttpRequest'} - }) - .then(r => r.json().then(d => ({ok: r.ok, data: d}))) - .then(({ok, data}) => { - if (ok && data.success) { - location.reload(); - } else { - alert('Regeneration failed: ' + (data.error || 'Unknown error')); - btn.disabled = false; - btn.innerHTML = origText; - } - }) - .catch(err => { - alert('Regeneration failed: ' + err); - btn.disabled = false; - btn.innerHTML = origText; - }); - } - - function initJsonEditor(saveUrl) { - const jsonModal = document.getElementById('jsonEditorModal'); - if (!jsonModal) return; - const textarea = document.getElementById('json-editor-textarea'); - const errBox = document.getElementById('json-editor-error'); - const simplePanel = document.getElementById('json-simple-panel'); - const advancedPanel = document.getElementById('json-advanced-panel'); - const simpleTab = document.getElementById('json-simple-tab'); - const advancedTab = document.getElementById('json-advanced-tab'); - let activeTab = 'simple'; - - function buildSimpleForm(data) { - simplePanel.innerHTML = ''; - for (const [key, value] of Object.entries(data)) { - const row = document.createElement('div'); - row.className = 'row mb-2 align-items-start'; - - const labelCol = document.createElement('div'); - labelCol.className = 'col-sm-3 col-form-label fw-semibold text-capitalize small pt-1'; - labelCol.textContent = key.replace(/_/g, ' '); - - const inputCol = document.createElement('div'); - inputCol.className = 'col-sm-9'; - - let el; - if (typeof value === 'boolean') { - const wrap = document.createElement('div'); - wrap.className = 'form-check mt-2'; - el = document.createElement('input'); - el.type = 'checkbox'; - el.className = 'form-check-input'; - el.checked = value; - el.dataset.dtype = 'boolean'; - wrap.appendChild(el); - inputCol.appendChild(wrap); - } else if (typeof value === 'number') { - el = document.createElement('input'); - el.type = 'number'; - el.step = 'any'; - el.className = 'form-control form-control-sm'; - el.value = value; - el.dataset.dtype = 'number'; - inputCol.appendChild(el); - } else if (typeof value === 'string') { - if (value.length > 80) { - el = document.createElement('textarea'); - el.className = 'form-control form-control-sm'; - el.rows = 3; - } else { - el = document.createElement('input'); - el.type = 'text'; - el.className = 'form-control form-control-sm'; - } - el.value = value; - el.dataset.dtype = 'string'; - inputCol.appendChild(el); - } else { - el = document.createElement('textarea'); - el.className = 'form-control form-control-sm font-monospace'; - const lines = JSON.stringify(value, null, 2).split('\n'); - el.rows = Math.min(10, lines.length + 1); - el.value = JSON.stringify(value, null, 2); - el.dataset.dtype = 'json'; - inputCol.appendChild(el); - } - el.dataset.key = key; - row.appendChild(labelCol); - row.appendChild(inputCol); - simplePanel.appendChild(row); - } - } - - function readSimpleForm() { - const result = {}; - simplePanel.querySelectorAll('[data-key]').forEach(el => { - const key = el.dataset.key; - const dtype = el.dataset.dtype; - if (dtype === 'boolean') result[key] = el.checked; - else if (dtype === 'number') { const n = parseFloat(el.value); result[key] = isNaN(n) ? el.value : n; } - else if (dtype === 'json') { try { result[key] = JSON.parse(el.value); } catch { result[key] = el.value; } } - else result[key] = el.value; - }); - return result; - } - - simpleTab.addEventListener('click', () => { - errBox.classList.add('d-none'); - let data; - try { data = JSON.parse(textarea.value); } - catch (e) { errBox.textContent = 'Cannot switch: invalid JSON — ' + e.message; errBox.classList.remove('d-none'); return; } - buildSimpleForm(data); - simplePanel.classList.remove('d-none'); - advancedPanel.classList.add('d-none'); - simpleTab.classList.add('active'); - advancedTab.classList.remove('active'); - activeTab = 'simple'; - }); - - advancedTab.addEventListener('click', () => { - textarea.value = JSON.stringify(readSimpleForm(), null, 2); - advancedPanel.classList.remove('d-none'); - simplePanel.classList.add('d-none'); - advancedTab.classList.add('active'); - simpleTab.classList.remove('active'); - activeTab = 'advanced'; - }); - - jsonModal.addEventListener('show.bs.modal', () => { - const raw = document.getElementById('json-raw-data').textContent; - let data; - try { data = JSON.parse(raw); } catch { data = {}; } - buildSimpleForm(data); - textarea.value = JSON.stringify(data, null, 2); - simplePanel.classList.remove('d-none'); - advancedPanel.classList.add('d-none'); - simpleTab.classList.add('active'); - advancedTab.classList.remove('active'); - activeTab = 'simple'; - errBox.classList.add('d-none'); - }); - - document.getElementById('json-save-btn').addEventListener('click', async () => { - errBox.classList.add('d-none'); - let parsed; - if (activeTab === 'simple') { - parsed = readSimpleForm(); - } else { - try { parsed = JSON.parse(textarea.value); } - catch (e) { errBox.textContent = 'Invalid JSON: ' + e.message; errBox.classList.remove('d-none'); return; } - } - const fd = new FormData(); - fd.append('json_data', JSON.stringify(parsed)); - const resp = await fetch(saveUrl, { method: 'POST', body: fd }); - const result = await resp.json(); - if (result.success) { bootstrap.Modal.getInstance(jsonModal).hide(); location.reload(); } - else { errBox.textContent = result.error || 'Save failed.'; errBox.classList.remove('d-none'); } - }); - } + {% endblock %} diff --git a/templates/looks/index.html b/templates/looks/index.html index 8890c34..7c8f570 100644 --- a/templates/looks/index.html +++ b/templates/looks/index.html @@ -73,8 +73,8 @@ {% set lora_name = look.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %} {{ lora_name }} {% else %}{% endif %} - + diff --git a/templates/outfits/detail.html b/templates/outfits/detail.html index 488b3f2..9980239 100644 --- a/templates/outfits/detail.html +++ b/templates/outfits/detail.html @@ -275,209 +275,13 @@ {% endblock %} {% block scripts %} + {% endblock %} \ No newline at end of file diff --git a/templates/outfits/index.html b/templates/outfits/index.html index e20e8d3..17b0097 100644 --- a/templates/outfits/index.html +++ b/templates/outfits/index.html @@ -74,8 +74,8 @@ {% set lora_name = outfit.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %} {{ lora_name }} {% else %}{% endif %} - + diff --git a/templates/presets/detail.html b/templates/presets/detail.html index d46085d..5d697f7 100644 --- a/templates/presets/detail.html +++ b/templates/presets/detail.html @@ -113,20 +113,19 @@ -
-
- Selected Preview -
-
- Preview -
- @@ -304,119 +303,12 @@ {% endblock %} {% block scripts %} + {% endblock %} diff --git a/templates/scenes/detail.html b/templates/scenes/detail.html index 232db09..89380b7 100644 --- a/templates/scenes/detail.html +++ b/templates/scenes/detail.html @@ -111,6 +111,18 @@
+ + {% set tags = scene.data.tags if scene.data.tags is mapping else {} %} + {% if tags %} +
+
Tags
+
+ {% if tags.scene_type %}{{ tags.scene_type }}{% endif %} + {% if scene.is_nsfw %}NSFW{% endif %} + {% if scene.is_favourite %}★ Favourite{% endif %} +
+
+ {% endif %}
@@ -271,192 +283,13 @@ {% endblock %} {% block scripts %} + {% endblock %} diff --git a/templates/scenes/index.html b/templates/scenes/index.html index 3d3125e..49c0ce8 100644 --- a/templates/scenes/index.html +++ b/templates/scenes/index.html @@ -71,8 +71,8 @@ {% set lora_name = scene.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %} {{ lora_name }} {% else %}{% endif %} - +
diff --git a/templates/styles/detail.html b/templates/styles/detail.html index 49d951a..307cfb9 100644 --- a/templates/styles/detail.html +++ b/templates/styles/detail.html @@ -111,6 +111,18 @@ + + {% set tags = style.data.tags if style.data.tags is mapping else {} %} + {% if tags %} +
+
Tags
+
+ {% if tags.style_type %}{{ tags.style_type }}{% endif %} + {% if style.is_nsfw %}NSFW{% endif %} + {% if style.is_favourite %}★ Favourite{% endif %} +
+
+ {% endif %}
@@ -263,185 +275,20 @@ {% endblock %} {% block scripts %} + {% endblock %} diff --git a/templates/styles/index.html b/templates/styles/index.html index 6ab41b2..0600dff 100644 --- a/templates/styles/index.html +++ b/templates/styles/index.html @@ -71,8 +71,8 @@ {% set lora_name = style.data.lora.lora_name.split('/')[-1].replace('.safetensors', '') %} {{ lora_name }} {% else %}{% endif %} - +