""" PromptStyler style pack tool. This script helps you add more styles without editing JSON by hand. It writes (or updates) a JSON pack under styles/packs/, e.g.: styles/packs/99_user_custom.json """ from __future__ import annotations import argparse import csv import json import os import re import sys from collections import Counter from typing import Any, Dict, List, Optional, Sequence ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) if ROOT not in sys.path: sys.path.insert(0, ROOT) from style_library import ( CATEGORY_BASE_PREFIX, CATEGORY_BASE_SUFFIX, LOAD_POLICY_STRICT, StyleLibraryError, available_categories, flux_join_sentences, load_style_library, z_join, ) PACKS_DIR = os.path.join(ROOT, "styles", "packs") DEFAULT_PACK_PATH = os.path.join(PACKS_DIR, "99_user_custom.json") def _slugify(name: str) -> str: base = "".join(ch.lower() if ch.isalnum() else "_" for ch in (name or "")).strip("_") while "__" in base: base = base.replace("__", "_") return base or "style" def _normalize_user_subcategory(value: str) -> str: cleaned = "".join(ch if (ch.isalnum() or ch.isspace()) else " " for ch in (value or "")) cleaned = " ".join(cleaned.split()).strip() return cleaned or "Custom" def _split_csv_list(value: Optional[str]) -> List[str]: if not value: return [] return [part.strip() for part in value.split(",") if part.strip()] def _read_json(path: str) -> Dict[str, Any]: with open(path, "r", encoding="utf-8") as handle: return json.load(handle) or {} def _write_json(path: str, data: Dict[str, Any]) -> None: os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as handle: json.dump(data, handle, indent=2, ensure_ascii=True) handle.write("\n") def _load_library(): return load_style_library(load_policy=LOAD_POLICY_STRICT) def _existing_identity_sets() -> tuple[set[str], set[str]]: library = _load_library() return ({style.id for style in library.styles}, {style.name for style in library.styles}) def _ensure_unique_id(style_id: str, existing_ids: set[str]) -> str: if style_id not in existing_ids: return style_id i = 2 while True: candidate = f"{style_id}_{i}" if candidate not in existing_ids: return candidate i += 1 def _ensure_unique_name(name: str, existing_names: set[str]) -> str: if name not in existing_names: return name i = 2 while True: candidate = f"{name} ({i})" if candidate not in existing_names: return candidate i += 1 def _load_or_init_pack(path: str) -> Dict[str, Any]: if os.path.isfile(path): data = _read_json(path) if not isinstance(data, dict): raise ValueError(f"Pack is not a JSON object: {path}") if "styles" not in data: data["styles"] = [] if "version" not in data: data["version"] = 1 return data return {"version": 1, "styles": []} def _make_style_entry( *, name: str, category: str, core: Sequence[str], details: Sequence[str], tags: Sequence[str], style_id: Optional[str], id_prefix: str, flux_suffix: Optional[str] = None, existing_ids: Optional[set[str]] = None, existing_names: Optional[set[str]] = None, ) -> Dict[str, Any]: if existing_ids is None or existing_names is None: existing_ids, existing_names = _existing_identity_sets() base_id = style_id.strip() if style_id else f"{id_prefix}_{_slugify(name)}" final_id = _ensure_unique_id(base_id, existing_ids) final_name = _ensure_unique_name(name.strip(), existing_names) existing_ids.add(final_id) existing_names.add(final_name) prefix = z_join(tuple(CATEGORY_BASE_PREFIX.get(category, ())) + tuple(core)) suffix = z_join(tuple(details) + tuple(CATEGORY_BASE_SUFFIX.get(category, ()))) tags2 = [tag.strip() for tag in tags if (tag or "").strip()] if not tags2: tags2 = ["user"] if flux_suffix is None: parts = [f"Style: {final_name}"] core_hint = z_join(core[:12]) if core else "" detail_hint = z_join(details[:12]) if details else "" if core_hint: parts.append(f"Core cues: {core_hint}") if detail_hint: parts.append(f"Details: {detail_hint}") parts.append("Lighting: coherent and intentional") parts.append("Mood: consistent with the user prompt") flux_suffix2 = flux_join_sentences(parts) else: flux_suffix2 = str(flux_suffix or "").strip() if flux_suffix2 and not re.search(r"[.!?]$", flux_suffix2): flux_suffix2 += "." return { "id": final_id, "name": final_name, "category": category, "default": {"prefix": prefix, "suffix": suffix}, "models": {"flux_2_klein": {"prefix": "", "suffix": flux_suffix2}}, "tags": tags2, } def _read_bulk_csv(path: str) -> List[Dict[str, str]]: rows: List[Dict[str, str]] = [] with open(path, "r", encoding="utf-8-sig", newline="") as handle: reader = csv.DictReader(handle) for row in reader: if not row: continue rows.append({key.strip(): (value or "").strip() for key, value in row.items() if key}) return rows def _read_bulk_json(path: str) -> List[Dict[str, Any]]: data = _read_json(path) if isinstance(data, dict) and "styles" in data: data = data["styles"] if not isinstance(data, list): raise ValueError("JSON bulk file must be a list (or an object with a 'styles' list).") return [item for item in data if isinstance(item, dict)] def cmd_categories(_args) -> int: library = _load_library() counts = Counter(style.category or "Uncategorized" for style in library.styles) for category in available_categories(library.styles): print(f"{category} ({counts.get(category, 0)})") return 0 def cmd_stats(_args) -> int: library = _load_library() counts = Counter(style.category or "Uncategorized" for style in library.styles) total = sum(counts.values()) print(f"styles {total}") for category, count in counts.most_common(): print(f"{count:4d} {category}") return 0 def cmd_add(args) -> int: pack_path = os.path.abspath(args.pack) pack = _load_or_init_pack(pack_path) existing_ids, existing_names = _existing_identity_sets() core = _split_csv_list(args.core) + _split_csv_list(args.prefix_extra) details = _split_csv_list(args.details) + _split_csv_list(args.suffix_extra) tags = _split_csv_list(args.tags) entry = _make_style_entry( name=args.name, category=args.category, core=core, details=details, tags=tags, style_id=args.id, id_prefix=args.id_prefix, existing_ids=existing_ids, existing_names=existing_names, ) pack["styles"].append(entry) pack["styles"] = sorted( pack["styles"], key=lambda style: ( str(style.get("category", "")).casefold(), str(style.get("name", "")).casefold(), str(style.get("id", "")), ), ) _write_json(pack_path, pack) print(f"Added: {entry['category']} | {entry['name']} | {entry['id']}") print(f"Pack: {pack_path}") return 0 def cmd_bulk(args) -> int: pack_path = os.path.abspath(args.pack) pack = _load_or_init_pack(pack_path) existing_ids, existing_names = _existing_identity_sets() if args.csv: items = _read_bulk_csv(os.path.abspath(args.csv)) elif args.json: items = _read_bulk_json(os.path.abspath(args.json)) else: raise RuntimeError("bulk requires --csv or --json") added = 0 for item in items: name = (item.get("name") or "").strip() category = (item.get("category") or "").strip() if not name or not category: continue entry = _make_style_entry( name=name, category=category, core=_split_csv_list(item.get("core")) + _split_csv_list(item.get("prefix_extra")), details=_split_csv_list(item.get("details")) + _split_csv_list(item.get("suffix_extra")), tags=_split_csv_list(item.get("tags")), style_id=(item.get("id") or "").strip() or None, id_prefix=args.id_prefix, flux_suffix=(item.get("flux") or item.get("flux_suffix") or "").strip() or None, existing_ids=existing_ids, existing_names=existing_names, ) pack["styles"].append(entry) added += 1 pack["styles"] = sorted( pack["styles"], key=lambda style: ( str(style.get("category", "")).casefold(), str(style.get("name", "")).casefold(), str(style.get("id", "")), ), ) _write_json(pack_path, pack) print(f"Added {added} styles to {pack_path}") return 0 def _prompt_line(label: str, *, default: Optional[str] = None) -> str: if default is None: return input(f"{label}: ").strip() value = input(f"{label} [{default}]: ").strip() return value if value else default def cmd_wizard(args) -> int: pack_path = os.path.abspath(args.pack) pack = _load_or_init_pack(pack_path) existing_ids, existing_names = _existing_identity_sets() print("PromptStyler: User Style Wizard") print("Tip: use comma-separated phrases (node splits default templates on ', ').") print("") subcat = _normalize_user_subcategory( _prompt_line("User subcategory (creates category User/)", default="Custom") ) category = f"User/{subcat}" name = _prompt_line("Style name") if not name.strip(): print("ERROR: style name is required") return 2 core = _split_csv_list(_prompt_line("Core descriptors (comma-separated)", default="")) details = _split_csv_list(_prompt_line("Detail descriptors (comma-separated)", default="")) tags = _split_csv_list(_prompt_line("Tags (comma-separated, optional)", default="")) tags.extend(["user", _slugify(subcat)]) flux_suffix = _prompt_line("Flux suffix (optional; blank to auto-generate)", default="").strip() or None entry = _make_style_entry( name=name, category=category, core=core, details=details, tags=tags, style_id=None, id_prefix=args.id_prefix, flux_suffix=flux_suffix, existing_ids=existing_ids, existing_names=existing_names, ) print("") print("Preview") print(f" Category: {entry['category']}") print(f" Name: {entry['name']}") print(f" ID: {entry['id']}") print(f" Prefix: {entry['default']['prefix']}") print(f" Suffix: {entry['default']['suffix']}") print(f" Flux: {entry['models']['flux_2_klein']['suffix']}") print(f" Tags: {', '.join(entry.get('tags', []))}") print(f" Pack: {pack_path}") confirm = _prompt_line("Write this style? (y/n)", default="y").strip().lower() if confirm not in ("y", "yes"): print("Canceled.") return 0 pack["styles"].append(entry) pack["styles"] = sorted( pack["styles"], key=lambda style: ( str(style.get("category", "")).casefold(), str(style.get("name", "")).casefold(), str(style.get("id", "")), ), ) _write_json(pack_path, pack) print("") print(f"Added: {entry['category']} | {entry['name']} | {entry['id']}") print(f"Pack: {pack_path}") print("Tip: paste the id into the node's style_id_override to use it immediately.") return 0 def main(argv: Optional[List[str]] = None) -> int: parser = argparse.ArgumentParser(prog="add_styles.py", description="PromptStyler style pack helper") sub = parser.add_subparsers(dest="cmd", required=True) p_categories = sub.add_parser("categories", help="List available categories") p_categories.set_defaults(func=cmd_categories) p_stats = sub.add_parser("stats", help="Show style counts per category") p_stats.set_defaults(func=cmd_stats) p_add = sub.add_parser("add", help="Add a single style to a pack") p_add.add_argument("--pack", default=DEFAULT_PACK_PATH, help="Output pack JSON path") p_add.add_argument("--id-prefix", default="user", help="Prefix for generated ids (default: user)") p_add.add_argument("--id", default=None, help="Optional explicit id (must be unique)") p_add.add_argument("--name", required=True, help="Style display name") p_add.add_argument("--category", required=True, help="Style category (as shown in the dropdown)") p_add.add_argument("--core", default="", help="Comma-separated core descriptors") p_add.add_argument("--details", default="", help="Comma-separated detail descriptors") p_add.add_argument("--prefix-extra", default="", help="Alias for --core") p_add.add_argument("--suffix-extra", default="", help="Alias for --details") p_add.add_argument("--tags", default="", help="Comma-separated tags") p_add.set_defaults(func=cmd_add) p_bulk = sub.add_parser("bulk", help="Bulk add styles from CSV or JSON") p_bulk.add_argument("--pack", default=DEFAULT_PACK_PATH, help="Output pack JSON path") p_bulk.add_argument("--id-prefix", default="user", help="Prefix for generated ids (default: user)") p_bulk.add_argument("--csv", default=None, help="CSV file path (columns: name,category,core,details,tags,id)") p_bulk.add_argument("--json", default=None, help="JSON file path (list of style objects)") p_bulk.set_defaults(func=cmd_bulk) p_wizard = sub.add_parser("wizard", help="Interactive wizard to add a user style") p_wizard.add_argument("--pack", default=DEFAULT_PACK_PATH, help="Output pack JSON path") p_wizard.add_argument("--id-prefix", default="user", help="Prefix for generated ids (default: user)") p_wizard.set_defaults(func=cmd_wizard) args = parser.parse_args(argv) try: return int(args.func(args)) except StyleLibraryError as exc: print(f"ERROR: {exc}") return 2 if __name__ == "__main__": raise SystemExit(main())