- Add Wildcard Prompt Composer and Wildcard Character Composer builder nodes (core/builder, web/prompt_builder.js, builders/, vp wildcard lists) - Add browser-based wildcard review tool (tools/wildcard_review) with manifest-based undo - Add wildcard catalog/curation, surreal compiler/lint/scene, profile token budget - README: document all 18 nodes, bump to v2.2.0, fix stale node/platform lists; CHANGELOG 2.2.0 - Rewrite NODE_COMPARISON for 18 nodes; fix QUICKSTART (dead test_setup.py ref, menu path) and QUICK_REFERENCE node list - gitignore review manifests and CLAUDE.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1091 lines
55 KiB
Python
1091 lines
55 KiB
Python
"""
|
|
Prompt builders inside ComfyUI: builder definitions, the node base class, and the HTTP routes the
|
|
composer widget (web/prompt_builder.js) talks to.
|
|
|
|
Builder definitions (see core/builder/slots.py for the format):
|
|
<package>/builders/*.yaml shipped (character.yaml)
|
|
<package>/builders_user/*.yaml yours; a file with the same id replaces the shipped one
|
|
<package>/builders_user/my_buttons.yaml your own buttons ("+ Button" / Find ☆), shared by all builders
|
|
<package>/builders_user/templates/*.json your saved templates (a whole composer), shared by all builders
|
|
<package>/builders/templates/*.json shipped starter templates (read-only), offered alongside yours
|
|
|
|
Routes, all GET except preview (nsfw=off|suggestive|nudity|explicit is the node's nsfw level;
|
|
violence=0-3 is the node's violence_filter level on starter, slot, slot_counts, find, more and preview):
|
|
/eric_prompt_builder/builders builder definitions for the widget
|
|
/eric_prompt_builder/starter?builder=character a starter composer state, lists filled in
|
|
/eric_prompt_builder/slot?builder=&slot=&nsfw= the lists behind one slot button
|
|
/eric_prompt_builder/list?key=&q=&offset=&limit=&nsfw= the entries of one list
|
|
/eric_prompt_builder/platforms platform ids and their section order
|
|
/eric_prompt_builder/find?q=&nsfw=&gender=&names= lists for the Find box (names=1: key words only,
|
|
what a category button would use)
|
|
POST /eric_prompt_builder/buttons {action: save|delete, button|id} -> my_buttons.yaml
|
|
/eric_prompt_builder/templates your saved templates (name, builder, sections, ...)
|
|
/eric_prompt_builder/template?file=&nsfw= one template, plus the list keys this library lacks
|
|
POST /eric_prompt_builder/templates {action: save|delete, name|file, builder, platform, composer}
|
|
/eric_prompt_builder/more?builder=§ions=&nsfw= usable lists no button offers, grouped by path words
|
|
POST /eric_prompt_builder/merge/preview {builder, slot, keys, nsfw} -> folded rows, most-shared first
|
|
POST /eric_prompt_builder/merge/save {builder, slot, name, entries, sources, hide_sources,
|
|
overwrite, nsfw} -> <root>/mine/<slot>/<name>.txt
|
|
POST /eric_prompt_builder/preview {builder, composer, seed, platform, nsfw, violence}
|
|
-> what the node would output for that seed
|
|
|
|
Author: Eric Hiss (GitHub: EricRollei)
|
|
License: Dual License (Non-Commercial and Commercial Use). See LICENSE.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import re
|
|
import threading
|
|
import time
|
|
import traceback
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from .core.builder import (DEFAULT_ORDER, LAYOUT_FILE, OPTIONAL_SECTIONS, kinds_of, layout_hide, layout_move, read_layout,
|
|
write_layout, more_lists, merge_entries, merged_key, merged_path, pick_root, slot_words, write_merged,
|
|
MY_BUTTONS_FILE, SECTION_LABELS, SECTIONS, BuilderDef, ComposerState, PromptSections,
|
|
SlotMatcher, button_id, clean_button, join_sections, load_builders, order_sections,
|
|
read_buttons, section_label, section_order, section_seed_offset, write_buttons)
|
|
from .profile_support import get_profile, profile_ids
|
|
from .core.wildcards import NSFW_LEVELS, nsfw_level, prompt_block_reason, violence_level
|
|
from .core.wildcards.record import is_filter_note
|
|
from .wildcard_support import WILDCARD_NSFW_INPUT, WILDCARD_VIOLENCE_INPUT, get_wildcard_engine
|
|
from .core.wildcards.library import nsfw_folder_tier, strip_nsfw_prefix
|
|
|
|
PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
BUILDER_DIRS = [os.path.join(PACKAGE_DIR, "builders"), os.path.join(PACKAGE_DIR, "builders_user")]
|
|
MY_BUTTONS_PATH = os.path.join(PACKAGE_DIR, "builders_user", MY_BUTTONS_FILE)
|
|
LAYOUT_PATH = os.path.join(PACKAGE_DIR, "builders_user", LAYOUT_FILE)
|
|
SCENE_ONLY_NOTE = ("NO SUBJECT - the user chose a scene only (a landscape, an empty set or plate). "
|
|
"Do not add a person, animal or other main subject.")
|
|
TEMPLATES_DIR = os.path.join(PACKAGE_DIR, "builders_user", "templates")
|
|
SHIPPED_TEMPLATES_DIR = os.path.join(PACKAGE_DIR, "builders", "templates")
|
|
STARTER_PREFIX = "starter:" # marks a read-only shipped starter in the file id the widget sends back
|
|
_TEMPLATE_FILE = re.compile(r"[^a-z0-9_\-]+")
|
|
GENERIC_PLATFORM = "generic"
|
|
DEFAULT_PLATFORM = "krea2"
|
|
ROUTE = "/eric_prompt_builder"
|
|
LIST_PAGE_MAX = 500
|
|
_STARTER_SLOT = re.compile(r"\{([a-z0-9_]+)\}")
|
|
# "auburn hair" + typed "hair" -> "auburn hair hair": a doubled word of 3+ letters is collapsed.
|
|
_DOUBLED_WORD = re.compile(r"\b([A-Za-z]{3,})(\s+\1\b)+", re.IGNORECASE)
|
|
# Booru-style entries (arms_behind_back) read as words in a phrase.
|
|
_TAG_UNDERSCORE = re.compile(r"(?<=[A-Za-z])_(?=[A-Za-z])")
|
|
|
|
_builders_cache: Tuple[float, Dict[str, BuilderDef]] = (-1.0, {})
|
|
_matchers: Dict[int, SlotMatcher] = {}
|
|
_ROUTE_LOCK = threading.Lock() # route work runs in a thread pool; one at a time over the shared library
|
|
|
|
|
|
# ----------------------------------------------------------------------------- definitions
|
|
def _builder_dirs_mtime() -> float:
|
|
newest = 0.0
|
|
for folder in BUILDER_DIRS:
|
|
if os.path.isdir(folder):
|
|
newest = max(newest, os.path.getmtime(folder))
|
|
for name in os.listdir(folder):
|
|
if name.lower().endswith((".yaml", ".yml")):
|
|
newest = max(newest, os.path.getmtime(os.path.join(folder, name)))
|
|
return newest
|
|
|
|
|
|
def get_builders() -> Dict[str, BuilderDef]:
|
|
"""All builder definitions, re-read when a YAML file changes."""
|
|
global _builders_cache
|
|
stamp = _builder_dirs_mtime()
|
|
if stamp != _builders_cache[0]:
|
|
_builders_cache = (stamp, load_builders(BUILDER_DIRS, MY_BUTTONS_PATH, LAYOUT_PATH))
|
|
for matcher in _matchers.values(): # a button edited in place keeps its id; drop its old lists
|
|
matcher.clear()
|
|
return _builders_cache[1]
|
|
|
|
|
|
def get_builder(builder_id: str) -> Optional[BuilderDef]:
|
|
return get_builders().get(builder_id)
|
|
|
|
|
|
def get_matcher() -> SlotMatcher:
|
|
library = get_wildcard_engine().library
|
|
matcher = _matchers.get(id(library))
|
|
if matcher is None:
|
|
matcher = SlotMatcher(library)
|
|
_matchers[id(library)] = matcher
|
|
return matcher
|
|
|
|
|
|
def platform_choices() -> List[str]:
|
|
return [GENERIC_PLATFORM] + profile_ids(("image", "video"))
|
|
|
|
|
|
def platform_order(platform: str) -> List[str]:
|
|
if not platform or platform == GENERIC_PLATFORM:
|
|
return list(DEFAULT_ORDER)
|
|
profile = get_profile(platform)
|
|
return section_order(profile.structure if profile else "")
|
|
|
|
|
|
def node_order(own: List[str], platform: str) -> List[str]:
|
|
"""The node's own section order (dragged in the panel) first, then the platform's for the rest."""
|
|
base = platform_order(platform)
|
|
own = [section for section in (own or []) if section in SECTIONS]
|
|
return own + [section for section in base if section not in own]
|
|
|
|
|
|
# ----------------------------------------------------------------------------- composer state
|
|
def starter_state(builder: BuilderDef, nsfw="off", violence: int = 0) -> Dict[str, Any]:
|
|
"""Composer state from each part's `starter` line; each {slot} gets that slot's first list."""
|
|
matcher = get_matcher()
|
|
tokens: Dict[str, Dict[str, Any]] = {}
|
|
parts: Dict[str, List[Dict[str, Any]]] = {}
|
|
next_id = 1
|
|
for part in builder.parts:
|
|
if part.optional:
|
|
continue # Text and other optional sections start absent ("+ Section")
|
|
segments: List[Dict[str, Any]] = []
|
|
position = 0
|
|
for match in _STARTER_SLOT.finditer(part.starter):
|
|
slot = builder.slot(match.group(1))
|
|
if slot is None:
|
|
continue
|
|
segments.append({"t": "text", "v": part.starter[position:match.start()].strip()})
|
|
lists = matcher.lists_for(builder, slot, nsfw, violence=violence)
|
|
tokens[str(next_id)] = {"slot": slot.id, "key": lists[0].key if lists else "", "mode": "random",
|
|
"picks": [], "count": 1, "keep": "meaning"}
|
|
segments.append({"t": "tok", "id": next_id})
|
|
next_id += 1
|
|
position = match.end()
|
|
segments.append({"t": "text", "v": part.starter[position:].strip()})
|
|
parts[part.section] = segments
|
|
return {"v": 1, "parts": parts, "tokens": tokens, "next": next_id, "locks": {}, "fill": {}}
|
|
|
|
|
|
FILL_HEADING = "SECTIONS TO WRITE"
|
|
|
|
|
|
def fill_block(builder: BuilderDef, sections: List[str]) -> str:
|
|
"""Instruction for the LLM rewriter: write the sections the user left empty on purpose."""
|
|
if not sections:
|
|
return ""
|
|
lines = [f"{FILL_HEADING} - the user left these for you. Write each in a few words that suit the rest of the prompt:"]
|
|
for section in sections:
|
|
part = builder.part(section)
|
|
hint = part.hint if part and part.hint else section_label(section).lower()
|
|
lines.append(f" * {section_label(section)}: {hint}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
CONTENT_REROLLS = 6 # seeds tried when the whole assembled prompt fails the content filter
|
|
|
|
|
|
def build(builder: BuilderDef, composer: str, seed: int, platform: str, nsfw,
|
|
upstream: Any = None, violence: int = 0) -> Dict[str, Any]:
|
|
"""
|
|
What a builder node outputs. Shared by the node and the preview route, so the preview is
|
|
exactly what a run with the same seed gives.
|
|
|
|
violence: the node's violence_filter level (0-3); picks in that tier or a lower one are skipped.
|
|
Content filter (config.json "content_filter", on by default): each section is checked by the
|
|
engine, and the assembled text here, since a minor term and an explicit term can come from
|
|
different sections. A failing roll is re-rolled with derived seeds when the picks caused it, and
|
|
blanked (report says why) when the typed text did or no re-roll passes.
|
|
"""
|
|
result = _build_once(builder, composer, seed, platform, nsfw, upstream, violence)
|
|
if not get_wildcard_engine().library.content_filter:
|
|
return result
|
|
reason = prompt_block_reason(result["text"])
|
|
if not reason:
|
|
return result
|
|
if not prompt_block_reason(result["template"]):
|
|
for attempt in range(1, CONTENT_REROLLS + 1):
|
|
retry = _build_once(builder, composer, (int(seed) + attempt * 7919) % 0x7FFFFFFF, platform,
|
|
nsfw, upstream, violence)
|
|
if not prompt_block_reason(retry["text"]):
|
|
note = f"content filter: seed {seed} picked {reason}; re-rolled to seed {retry['seed']}"
|
|
retry["warnings"].append(note)
|
|
retry["report"] = f"FILTER: {note}\n" + retry["report"]
|
|
return retry
|
|
note = (f"content filter: prompt blocked ({reason}); set \"content_filter\": false in config.json "
|
|
f"to turn the filter off")
|
|
result.update({"bundle": PromptSections.coerce(upstream), "text": "", "llm_instructions": "",
|
|
"own_sections": {}, "picks": {}, "blocked": reason})
|
|
result["warnings"].append(note)
|
|
result["report"] = f"FILTER: {note}\n" + result["report"]
|
|
return result
|
|
|
|
|
|
def _build_once(builder: BuilderDef, composer: str, seed: int, platform: str, nsfw,
|
|
upstream: Any = None, violence: int = 0) -> Dict[str, Any]:
|
|
"""One roll of build(): each section from seed + its offset, or from the seed it is locked at."""
|
|
if not (composer or "").strip():
|
|
composer = json.dumps(starter_state(builder, nsfw, violence))
|
|
state = ComposerState.from_json(composer)
|
|
templates = state.templates()
|
|
scene_sections = [section for section in state.parts if state.scene_only(section)]
|
|
for section in scene_sections:
|
|
templates.pop(section, None) # Scene only: the subject is left out on purpose
|
|
engine = get_wildcard_engine()
|
|
|
|
resolved: Dict[str, str] = {}
|
|
warnings: List[str] = list(state.errors)
|
|
instruction_blocks: List[str] = []
|
|
picks: Dict[str, str] = {}
|
|
summaries: List[str] = []
|
|
seeds: Dict[str, int] = {}
|
|
token_syntax = {token_id: token.syntax() for token_id, token in state.tokens.items()}
|
|
all_sections = list(dict.fromkeys(list(state.parts) + list(templates)))
|
|
for section in all_sections:
|
|
seeds[section] = state.locks.get(section, int(seed) + section_seed_offset(section))
|
|
for section in order_sections(templates, SECTIONS):
|
|
resolution = engine.process(templates[section], seed=seeds[section], legacy=False, nsfw=nsfw,
|
|
violence=violence)
|
|
resolved[section] = _DOUBLED_WORD.sub(r"\1", _TAG_UNDERSCORE.sub(" ", resolution.text.strip()))
|
|
warnings.extend(f"{section_label(section)}: {warning}" for warning in resolution.warnings)
|
|
block = resolution.format_for_llm()
|
|
if block:
|
|
instruction_blocks.append(block)
|
|
locked = " (locked)" if section in state.locks else ""
|
|
summary = "\n".join(line for line in resolution.summary().splitlines() if not line.startswith("FILTER: "))
|
|
summaries.append(f"{section_label(section)}{locked}: {summary}")
|
|
for slot in resolution.top_level_slots():
|
|
for token_id, syntax in token_syntax.items():
|
|
if syntax and slot.source == syntax and token_id not in picks:
|
|
picks[token_id] = slot.value
|
|
break
|
|
|
|
order = node_order(state.order, platform)
|
|
to_fill = [section for section in order_sections(state.fill, order)
|
|
if state.fill.get(section) and not resolved.get(section, "").strip() and section not in scene_sections]
|
|
block = fill_block(builder, to_fill)
|
|
if block:
|
|
instruction_blocks.append(block)
|
|
if scene_sections:
|
|
instruction_blocks.append(SCENE_ONLY_NOTE)
|
|
summaries.append("Scene only: no subject")
|
|
summaries.append("Left for the LLM to write: " + ", ".join(section_label(s) for s in to_fill)
|
|
+ " (connect llm_instructions to the rewriter's wildcard_instructions)")
|
|
|
|
bundle = PromptSections.coerce(upstream).merged_with(
|
|
resolved, templates, "\n\n".join(instruction_blocks), builder.label, nsfw=nsfw_level(nsfw))
|
|
return {
|
|
"bundle": bundle,
|
|
"text": join_sections(bundle.sections, order),
|
|
"template": join_sections(bundle.templates, order),
|
|
"llm_instructions": bundle.instructions_text(),
|
|
"own_sections": resolved,
|
|
"own_templates": templates,
|
|
"picks": picks,
|
|
"seeds": seeds,
|
|
"to_fill": to_fill,
|
|
"warnings": warnings,
|
|
"order": order,
|
|
"report": "\n".join([f"FILTER: {w}" for w in warnings if is_filter_note(w)] + summaries
|
|
+ [f"warning: {w}" for w in warnings
|
|
if not is_filter_note(w) and not any(w in s for s in summaries)]),
|
|
"seed": int(seed),
|
|
"blocked": "",
|
|
}
|
|
|
|
|
|
# ----------------------------------------------------------------------------- node base
|
|
class PromptBuilderNode:
|
|
"""
|
|
Base for builder nodes. A subclass sets BUILDER_ID; the widget and the slot lists come from
|
|
builders/<BUILDER_ID>.yaml. The composer widget is drawn by web/prompt_builder.js and saved
|
|
as JSON in the `composer` input.
|
|
"""
|
|
BUILDER_ID = ""
|
|
CATEGORY = "Eric Prompt Enhancers/Wildcard Composers"
|
|
FUNCTION = "run"
|
|
RETURN_TYPES = ("PROMPT_SECTIONS", "STRING", "STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("sections", "text", "llm_instructions", "template", "report")
|
|
OUTPUT_TOOLTIPS = (
|
|
"Every section filled so far (this builder and the ones before it). Connect to the next builder.",
|
|
"All sections as one prompt, in the platform's order. Connect to a Platform Prompt Rewriter's idea for LLM polish, or use as is.",
|
|
"Which picks were random and how to keep them (Meaning / Exact / Loose). Connect to the rewriter's wildcard_instructions.",
|
|
"The same prompt as wildcard syntax, unresolved. Useful to save, or to let another node roll it.",
|
|
"What was picked, and any warnings.",
|
|
)
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
choices = platform_choices()
|
|
default_platform = DEFAULT_PLATFORM if DEFAULT_PLATFORM in choices else GENERIC_PLATFORM
|
|
return {
|
|
"required": {
|
|
"composer": ("STRING", {
|
|
"default": "", "multiline": False, "eric_builder": cls.BUILDER_ID,
|
|
"tooltip": "Composer state (JSON), edited with the builder panel on the node. Empty = the starter phrase.",
|
|
}),
|
|
"platform": (choices, {
|
|
"default": default_platform,
|
|
"tooltip": "Target model. Sets the order sections are joined in the `text` output (from the profile's structure line).",
|
|
}),
|
|
"seed": ("INT", {
|
|
"default": 0, "min": 0, "max": 0x7FFFFFFF, "control_after_generate": True,
|
|
"tooltip": "Same seed + same composer + same wildcard files = same picks.",
|
|
}),
|
|
},
|
|
"optional": {
|
|
"sections": ("PROMPT_SECTIONS", {
|
|
"tooltip": "Sections from a builder earlier in the chain. This builder adds its own to them.",
|
|
}),
|
|
# New inputs are only ever APPENDED below this line, so saved workflows keep their widget order.
|
|
"nsfw": WILDCARD_NSFW_INPUT,
|
|
"violence_filter": WILDCARD_VIOLENCE_INPUT,
|
|
},
|
|
}
|
|
|
|
def run(self, composer: str, platform: str, seed: int, sections: Any = None, nsfw="off",
|
|
violence_filter: str = "off"):
|
|
builder = get_builder(self.BUILDER_ID)
|
|
if builder is None:
|
|
message = f"builder definition '{self.BUILDER_ID}' not found in {BUILDER_DIRS}"
|
|
return (PromptSections.coerce(sections), "", "", "", message)
|
|
try:
|
|
result = build(builder, composer, seed, platform, nsfw, upstream=sections,
|
|
violence=violence_level(violence_filter))
|
|
except Exception as error: # never break the graph over a prompt
|
|
traceback.print_exc()
|
|
return (PromptSections.coerce(sections), "", "", "", f"{builder.label} error: {error}")
|
|
print(f"[{builder.label}] {result['text'][:120]}")
|
|
return (result["bundle"], result["text"], result["llm_instructions"], result["template"], result["report"])
|
|
|
|
|
|
# ----------------------------------------------------------------------------- routes
|
|
def _flag(value: Optional[str]) -> bool:
|
|
return str(value or "").lower() in ("1", "true", "yes", "on")
|
|
|
|
|
|
def _level(value: Any) -> int:
|
|
"""An nsfw level from a query string or JSON body (a NSFW_LEVELS name, or 0-3)."""
|
|
return nsfw_level(value)
|
|
|
|
|
|
def _violence(value: Any) -> int:
|
|
"""A violence level from a query string or JSON body (0-3, or a VIOLENCE_LEVELS name)."""
|
|
return violence_level(value)
|
|
|
|
|
|
def _builder_payload(builder: BuilderDef) -> Dict[str, Any]:
|
|
return {
|
|
"id": builder.id,
|
|
"label": builder.label,
|
|
"description": builder.description,
|
|
"parts": [{"section": part.section, "label": part.label, "slots": part.slot_sections,
|
|
"multi": part.multi, "hint": part.hint, "optional": part.optional} for part in builder.parts],
|
|
"slots": [{"id": slot.id, "label": slot.label, "tooltip": slot.tooltip, "section": slot.section,
|
|
"group": slot.group, "custom": slot.custom, "lists": slot.lists if slot.custom else [],
|
|
"search": slot.search if slot.custom else "", "kinds": kinds_of(slot), "hidden": slot.hidden}
|
|
for slot in builder.slots],
|
|
}
|
|
|
|
|
|
def _list_payload(info) -> Dict[str, Any]:
|
|
return {"key": info.key, "count": info.count, "pinned": info.pinned, "also": info.also[:8],
|
|
"length": info.length_class, "quality": info.quality, "stars": info.stars,
|
|
"dialect": info.dialect, "display": info.display, "nsfw": info.nsfw, "gender": info.gender}
|
|
|
|
|
|
def _entry_payload(library, curation, key: str, entry, dropped, level: int, folder: bool = False) -> Dict[str, Any]:
|
|
"""
|
|
One entry for the list viewer: its rating, whether you set it, and whether this level leaves it out.
|
|
folder: the viewer shows a whole folder of lists; the entry's own list decides drops and ratings ("list").
|
|
"""
|
|
source = strip_nsfw_prefix(entry.key) if entry.key else key
|
|
if folder:
|
|
key = source
|
|
dropped = curation.dropped(source) if curation is not None else set()
|
|
rating = library.entry_rating(entry)
|
|
override = curation.entry_rating(key, entry.text) if curation is not None else None
|
|
return {"text": entry.text, "weight": entry.weight, "dropped": entry.text.strip().casefold() in dropped,
|
|
"rating": rating, "rated_by_you": override is not None, "above": rating > level, "list": source}
|
|
|
|
|
|
PROMPTS_ROOT = "prompts" # the name the _prompts/ folder answers to (core/wildcards/library.py)
|
|
PROMPT_BROWSE_MAX = 400 # files listed per folder (counting a file's prompts loads it)
|
|
PROMPT_SEARCH_MAX = 200
|
|
|
|
|
|
def prompt_browse(folder: str = PROMPTS_ROOT, query: str = "", nsfw="off") -> Dict[str, Any]:
|
|
"""
|
|
The Prompt line's browser over _prompts/: one folder's subfolders (with how many files are under each) and
|
|
files (with their prompt counts), or with `query` the files whose path has every word, from any folder.
|
|
"""
|
|
library = get_wildcard_engine().library
|
|
names = library.prompt_names(nsfw)
|
|
|
|
def row(name: str, shown: str) -> Dict[str, Any]:
|
|
return {"key": name, "name": shown, "count": len(library.entries_of(name, nsfw)),
|
|
"nsfw": library.list_rating(name)}
|
|
|
|
words = [word for word in (query or "").lower().split() if word]
|
|
if words:
|
|
found = [name for name in names if all(word in name for word in words)]
|
|
return {"folder": PROMPTS_ROOT, "query": query, "here": len(found), "folders": [],
|
|
"lists": [row(name, name[len(PROMPTS_ROOT) + 1:]) for name in found[:PROMPT_SEARCH_MAX]],
|
|
"more": max(0, len(found) - PROMPT_SEARCH_MAX)}
|
|
prefix = (folder or PROMPTS_ROOT).strip().strip("/").lower().replace("\\", "/")
|
|
if prefix != PROMPTS_ROOT and not prefix.startswith(PROMPTS_ROOT + "/"):
|
|
prefix = PROMPTS_ROOT
|
|
start = prefix + "/"
|
|
folders: Dict[str, int] = {}
|
|
direct: List[str] = []
|
|
here = 0
|
|
for name in names:
|
|
if not name.startswith(start):
|
|
continue
|
|
here += 1
|
|
rest = name[len(start):]
|
|
if "/" in rest:
|
|
first = rest.split("/", 1)[0]
|
|
folders[first] = folders.get(first, 0) + 1
|
|
else:
|
|
direct.append(name)
|
|
return {"folder": prefix, "query": "", "here": here,
|
|
"folders": [{"key": start + name, "name": name, "lists": count} for name, count in sorted(folders.items())],
|
|
"lists": [row(name, name[len(start):]) for name in direct[:PROMPT_BROWSE_MAX]],
|
|
"more": max(0, len(direct) - PROMPT_BROWSE_MAX)}
|
|
|
|
|
|
FIND_LIMIT_MAX = 400 # lists one Find returns (the slot matcher reads at most FIND_MAX_CANDIDATES)
|
|
|
|
|
|
def find_lists(query: str, nsfw="off", gender: str = "", names_only: bool = False,
|
|
limit: int = 40, violence: int = 0) -> List[Any]:
|
|
"""The Find box: lists whose key has every word, then (unless names_only) the catalog's hits on entries."""
|
|
engine = get_wildcard_engine()
|
|
also: List[str] = []
|
|
if not names_only and engine.catalog is not None:
|
|
try:
|
|
# Every word must be there (in the name, a sample entry or the facet): an any-word
|
|
# fallback floods the box with lists that merely contain "female".
|
|
also = [row.key for row in engine.catalog.search(query, 120, any_word=False)]
|
|
except Exception as error: # the catalog is a bonus here
|
|
print(f"[Wildcard Composer] catalog search skipped: {error}")
|
|
return get_matcher().find(query, nsfw, gender, also=also, limit=limit, violence=violence)
|
|
|
|
|
|
def save_button(raw: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Add or replace one of your buttons in my_buttons.yaml. Returns the saved button."""
|
|
button = clean_button(raw)
|
|
if button is None:
|
|
raise ValueError("a button needs a label and a list or search words")
|
|
if button["section"] not in SECTIONS:
|
|
raise ValueError(f"unknown section '{button['section']}'")
|
|
buttons = read_buttons(MY_BUTTONS_PATH)
|
|
builtin = {slot.id for builder in get_builders().values() for slot in builder.slots if not slot.custom}
|
|
if button["id"] and not button["id"].startswith("my_"):
|
|
button["id"] = ""
|
|
at = next((i for i, b in enumerate(buttons) if b["id"] == button["id"]), -1) if button["id"] else -1
|
|
if at >= 0:
|
|
buttons[at] = button
|
|
else:
|
|
button["id"] = button_id(button["label"], builtin | {b["id"] for b in buttons})
|
|
buttons.append(button)
|
|
write_buttons(MY_BUTTONS_PATH, buttons)
|
|
return button
|
|
|
|
|
|
def delete_button(button_key: str) -> bool:
|
|
buttons = read_buttons(MY_BUTTONS_PATH)
|
|
kept = [b for b in buttons if b["id"] != button_key]
|
|
if len(kept) == len(buttons):
|
|
return False
|
|
write_buttons(MY_BUTTONS_PATH, kept)
|
|
return True
|
|
|
|
|
|
# ----------------------------------------------------------------------------- more lists
|
|
MORE_MAX_LISTS = 300
|
|
|
|
|
|
def more_payload(builder_id: str, sections: List[str], nsfw="off", violence: int = 0) -> Dict[str, Any]:
|
|
"""The usable lists no button of the builder offers, for these sections, grouped by path words."""
|
|
from .core.builder.slots import gender_of
|
|
builder = get_builder(builder_id)
|
|
if builder is None:
|
|
raise ValueError("unknown builder")
|
|
engine = get_wildcard_engine()
|
|
if engine.catalog is None:
|
|
return {"groups": [], "total": 0, "note": "the wildcard catalog is off (config.json wildcards.catalog)"}
|
|
matcher = get_matcher()
|
|
unassigned = matcher.unassigned(builder, nsfw, violence)
|
|
def row_of(key: str):
|
|
# An _nsfw/ list answers to its name without the prefix; the catalog keeps the real key.
|
|
return engine.catalog.row(key) or next(
|
|
(row for row in (engine.catalog.row(real) for real in engine.library.real_keys(key)) if row is not None), None)
|
|
|
|
result = more_lists(unassigned, [s for s in sections if s in SECTIONS or s == "_unsorted"], row_of)
|
|
|
|
def payload(key: str) -> Dict[str, Any]:
|
|
row = result["rows"][key]
|
|
words = getattr(row, "median_words", 0) or 0
|
|
return {"key": key, "count": row.entries, "display": row.display, "quality": row.quality,
|
|
"dialect": row.dialect, "length": "short" if words <= 4 else "medium" if words <= 8 else "long",
|
|
"nsfw": engine.library.list_rating(key), "gender": gender_of(key), "stars": 0}
|
|
|
|
return {
|
|
"total": result["total"], "unsorted": result["unsorted"], "skipped": result["skipped"],
|
|
"groups": [{"word": g.word, "label": g.label, "count": len(g.keys),
|
|
"lists": [payload(k) for k in g.keys[:MORE_MAX_LISTS]]} for g in result["groups"]],
|
|
}
|
|
|
|
|
|
# ----------------------------------------------------------------------------- merging lists
|
|
MERGE_MAX_LISTS = 300
|
|
|
|
|
|
def _slot_or_error(builder_id: str, slot_id: str):
|
|
builder = get_builder(builder_id)
|
|
slot = builder.slot(slot_id) if builder else None
|
|
if slot is None:
|
|
raise ValueError("unknown builder or slot")
|
|
return builder, slot
|
|
|
|
|
|
def merge_preview(builder_id: str, slot_id: str, keys: List[str], nsfw="off") -> Dict[str, Any]:
|
|
"""The chosen lists folded into one: rows most-shared first, with counts for the dialog."""
|
|
_builder, slot = _slot_or_error(builder_id, slot_id)
|
|
keys = [str(key).strip().lower() for key in keys if str(key).strip()][:MERGE_MAX_LISTS]
|
|
library = get_wildcard_engine().library
|
|
texts = [[entry.text for entry in library.entries_of(key, nsfw=nsfw)] for key in keys]
|
|
rows = merge_entries(texts, slot_words(slot.label, slot.id))
|
|
return {
|
|
"keys": keys,
|
|
"entries": sum(len(t) for t in texts),
|
|
"sizes": [len(t) for t in texts],
|
|
"rows": [row.to_dict() for row in rows],
|
|
"shared": sum(1 for row in rows if row.lists >= 2),
|
|
}
|
|
|
|
|
|
def merge_save(builder_id: str, slot_id: str, name: str, entries: List[str], sources: List[str],
|
|
hide_sources: bool = False, overwrite: bool = False) -> Dict[str, Any]:
|
|
"""Save a merged list under mine/<slot>/ in the folder most sources come from; optionally hide the sources."""
|
|
_builder, slot = _slot_or_error(builder_id, slot_id)
|
|
engine = get_wildcard_engine()
|
|
library = engine.library
|
|
key_roots: Dict[str, str] = {}
|
|
for root, files in library.catalog_sources().items():
|
|
for _relative, (_path, keys) in files.items():
|
|
for key in keys:
|
|
key_roots.setdefault(key, root)
|
|
roots = [next((key_roots[real] for real in [key] + library.real_keys(key) if real in key_roots), None)
|
|
for key in sources]
|
|
shipped = os.path.join(PACKAGE_DIR, "wildcards")
|
|
root = pick_root(roots, library.search_paths, avoid=shipped)
|
|
if root is None:
|
|
raise ValueError("no wildcard folder to save into")
|
|
path = merged_path(root, slot.id, name)
|
|
count = write_merged(path, entries, sources, slot.label, overwrite=overwrite)
|
|
library.refresh(force=True)
|
|
hidden = []
|
|
curation = library.curation
|
|
if hide_sources and curation is not None:
|
|
for key in sources:
|
|
try:
|
|
curation.update(key, hidden=True)
|
|
hidden.append(key)
|
|
except Exception as error: # a key the curation can't place: leave it visible
|
|
print(f"[Wildcard Composer] could not hide {key}: {error}")
|
|
get_matcher().clear()
|
|
return {"key": merged_key(slot.id, name), "path": path, "count": count, "hidden": hidden}
|
|
|
|
|
|
# ----------------------------------------------------------------------------- templates
|
|
def template_file(name: str) -> str:
|
|
"""'Noir portrait #2' -> 'noir_portrait_2.json' (letters, digits, _ and - only)."""
|
|
slug = _TEMPLATE_FILE.sub("_", (name or "").strip().lower()).strip("_-")[:80]
|
|
if not slug:
|
|
raise ValueError("a template needs a name")
|
|
return slug + ".json"
|
|
|
|
|
|
def _template_path(file_name: str) -> str:
|
|
if not file_name or os.path.basename(file_name) != file_name or not file_name.endswith(".json"):
|
|
raise ValueError(f"bad template file '{file_name}'")
|
|
return os.path.join(TEMPLATES_DIR, file_name)
|
|
|
|
|
|
def _shipped_template_path(file_name: str) -> str:
|
|
if not file_name or os.path.basename(file_name) != file_name or not file_name.endswith(".json"):
|
|
raise ValueError(f"bad template file '{file_name}'")
|
|
return os.path.join(SHIPPED_TEMPLATES_DIR, file_name)
|
|
|
|
|
|
def _template_summary(file_name: str, data: Dict[str, Any], starter: bool = False) -> Dict[str, Any]:
|
|
composer = data.get("composer") or {}
|
|
parts = composer.get("parts") or {}
|
|
used = [section for section, segs in parts.items()
|
|
if any(seg.get("t") == "tok" or str(seg.get("v", "")).strip() for seg in segs or [])]
|
|
return {"file": (STARTER_PREFIX + file_name) if starter else file_name,
|
|
"name": data.get("name") or file_name[:-5], "builder": data.get("builder", ""),
|
|
"platform": data.get("platform", ""), "saved": data.get("saved", ""), "sections": used,
|
|
"pills": len(composer.get("tokens") or {}), "starter": starter}
|
|
|
|
|
|
def list_templates() -> List[Dict[str, Any]]:
|
|
"""Every saved template and shipped starter, your own first, then the starters."""
|
|
found = []
|
|
for folder, starter in ((TEMPLATES_DIR, False), (SHIPPED_TEMPLATES_DIR, True)):
|
|
if not os.path.isdir(folder):
|
|
continue
|
|
for file_name in sorted(os.listdir(folder)):
|
|
if not file_name.endswith(".json"):
|
|
continue
|
|
try:
|
|
with open(os.path.join(folder, file_name), "r", encoding="utf-8") as handle:
|
|
found.append(_template_summary(file_name, json.load(handle), starter))
|
|
except Exception as error: # one broken file must not hide the others
|
|
print(f"[Wildcard Composer] skipped template {file_name}: {error}")
|
|
return sorted(found, key=lambda item: (item["starter"], item["name"].casefold()))
|
|
|
|
|
|
def read_template(file_name: str, nsfw="off") -> Dict[str, Any]:
|
|
"""A saved template, and which of its lists this library doesn't have (those pills show red)."""
|
|
starter = file_name.startswith(STARTER_PREFIX)
|
|
base = file_name[len(STARTER_PREFIX):] if starter else file_name
|
|
path = _shipped_template_path(base) if starter else _template_path(base)
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
data = json.load(handle)
|
|
composer = data.get("composer") or {}
|
|
keys = {str(token.get("key", "")).strip().lower() for token in (composer.get("tokens") or {}).values()
|
|
if isinstance(token, dict) and token.get("key")}
|
|
known = set(get_wildcard_engine().library.visible_keys(nsfw, include_prompts=True)) if keys else set()
|
|
return dict(data, file=file_name, missing=sorted(keys - known), starter=starter)
|
|
|
|
|
|
def save_template(name: str, builder_id: str, composer: Any, platform: str = "", overwrite: bool = False) -> Dict[str, Any]:
|
|
"""
|
|
Save a whole composer as builders_user/templates/<name>.json. Locks are left out (a template
|
|
loads free to re-roll); everything else is kept: text, pills with their lists, modes, picks,
|
|
counts and keep, extra subjects, the Woman / Man choice, LLM fills, and the platform.
|
|
"""
|
|
if isinstance(composer, str):
|
|
composer = json.loads(composer or "{}")
|
|
if not isinstance(composer, dict) or not isinstance(composer.get("parts"), dict):
|
|
raise ValueError("nothing to save: the composer is empty")
|
|
file_name = template_file(name)
|
|
path = _template_path(file_name)
|
|
if os.path.exists(path) and not overwrite:
|
|
raise FileExistsError(f"a template called '{name}' already exists")
|
|
state = {k: v for k, v in composer.items() if k not in ("locks", "template")}
|
|
state["locks"] = {}
|
|
data = {"v": 1, "name": name.strip(), "builder": builder_id, "platform": platform,
|
|
"saved": time.strftime("%Y-%m-%d %H:%M"), "composer": state}
|
|
os.makedirs(TEMPLATES_DIR, exist_ok=True)
|
|
temp = path + ".tmp"
|
|
with open(temp, "w", encoding="utf-8") as handle:
|
|
json.dump(data, handle, indent=1, ensure_ascii=False)
|
|
os.replace(temp, path)
|
|
return _template_summary(file_name, data)
|
|
|
|
|
|
def delete_template(file_name: str) -> bool:
|
|
if file_name.startswith(STARTER_PREFIX):
|
|
raise ValueError("starter templates are read-only")
|
|
path = _template_path(file_name)
|
|
if not os.path.exists(path):
|
|
return False
|
|
os.remove(path)
|
|
return True
|
|
|
|
|
|
def register_routes() -> bool:
|
|
"""Attach the routes to ComfyUI's server. Returns False outside ComfyUI."""
|
|
try:
|
|
from aiohttp import web
|
|
from server import PromptServer
|
|
except Exception:
|
|
return False
|
|
routes = PromptServer.instance.routes
|
|
|
|
def warm_up():
|
|
"""Scan the wildcard folders once in the background, so the first slot click is quick."""
|
|
try:
|
|
with _ROUTE_LOCK:
|
|
get_wildcard_engine().library.visible_keys("off")
|
|
# Count the lists behind every slot button once, so the panel opens with empty buttons hidden.
|
|
matcher = get_matcher()
|
|
for builder in get_builders().values():
|
|
for slot in builder.slots:
|
|
with _ROUTE_LOCK:
|
|
matcher.lists_for(builder, slot, False)
|
|
except Exception as error:
|
|
print(f"[Wildcard Composer] warm-up skipped: {error}")
|
|
threading.Thread(target=warm_up, name="prompt-builder-warmup", daemon=True).start()
|
|
|
|
async def off_loop(function, *args):
|
|
"""File scans and list loading can take seconds on a big library; keep them off the server loop."""
|
|
def locked():
|
|
with _ROUTE_LOCK:
|
|
return function(*args)
|
|
return await asyncio.get_running_loop().run_in_executor(None, locked)
|
|
|
|
def fail(error: Exception, status: int = 500):
|
|
traceback.print_exc()
|
|
return web.json_response({"error": str(error)}, status=status)
|
|
|
|
@routes.get(ROUTE + "/builders")
|
|
async def builders_route(request):
|
|
try:
|
|
return web.json_response({"builders": [_builder_payload(b) for b in get_builders().values()],
|
|
"sections": {s: SECTION_LABELS[s] for s in SECTIONS},
|
|
"default_order": list(DEFAULT_ORDER), "optional": list(OPTIONAL_SECTIONS)})
|
|
except Exception as error:
|
|
return fail(error)
|
|
|
|
@routes.get(ROUTE + "/starter")
|
|
async def starter_route(request):
|
|
try:
|
|
builder = get_builder(request.query.get("builder", ""))
|
|
if builder is None:
|
|
return web.json_response({"error": "unknown builder"}, status=404)
|
|
return web.json_response(await off_loop(starter_state, builder, _level(request.query.get("nsfw")),
|
|
_violence(request.query.get("violence"))))
|
|
except Exception as error:
|
|
return fail(error)
|
|
|
|
@routes.get(ROUTE + "/slot")
|
|
async def slot_route(request):
|
|
try:
|
|
builder = get_builder(request.query.get("builder", ""))
|
|
slot = builder.slot(request.query.get("slot", "")) if builder else None
|
|
if slot is None:
|
|
return web.json_response({"error": "unknown builder or slot"}, status=404)
|
|
started = time.time()
|
|
lists = await off_loop(get_matcher().lists_for, builder, slot, _level(request.query.get("nsfw")),
|
|
request.query.get("gender", ""), _violence(request.query.get("violence")))
|
|
return web.json_response({
|
|
"slot": slot.id, "label": slot.label, "seconds": round(time.time() - started, 3),
|
|
"lists": [_list_payload(info) for info in lists],
|
|
})
|
|
except Exception as error:
|
|
return fail(error)
|
|
|
|
@routes.get(ROUTE + "/slot_counts")
|
|
async def slot_counts_route(request):
|
|
"""
|
|
?builder=&nsfw= -> {counts: {slot id: number of lists}, nsfw_only: [ids]}. The panel hides
|
|
built-in buttons with none, and (at nsfw off) your own buttons whose lists are all rated above off.
|
|
"""
|
|
try:
|
|
builder = get_builder(request.query.get("builder", ""))
|
|
if builder is None:
|
|
return web.json_response({"error": "unknown builder"}, status=404)
|
|
nsfw = _level(request.query.get("nsfw"))
|
|
violence = _violence(request.query.get("violence"))
|
|
matcher = get_matcher()
|
|
counts = await off_loop(lambda: {slot.id: len(matcher.lists_for(builder, slot, nsfw, violence=violence))
|
|
for slot in builder.slots})
|
|
nsfw_only = [] if nsfw else await off_loop(lambda: [
|
|
slot.id for slot in builder.slots
|
|
if slot.custom and counts.get(slot.id) == 0 and matcher.lists_for(builder, slot, "explicit", violence=violence)])
|
|
return web.json_response({"counts": counts, "nsfw_only": nsfw_only})
|
|
except Exception as error:
|
|
return fail(error)
|
|
|
|
@routes.get(ROUTE + "/list")
|
|
async def list_route(request):
|
|
try:
|
|
key = request.query.get("key", "").strip()
|
|
query = request.query.get("q", "").strip().casefold()
|
|
offset = max(0, int(request.query.get("offset", 0) or 0))
|
|
limit = max(1, min(LIST_PAGE_MAX, int(request.query.get("limit", 200) or 200)))
|
|
library = get_wildcard_engine().library
|
|
level = _level(request.query.get("nsfw"))
|
|
# Every entry, rated: the viewer shows what this level leaves out and lets you fix a rating.
|
|
entries = await off_loop(lambda: library.entries_of(key, "explicit", include_dropped=True))
|
|
folder_files = 0
|
|
if not entries and key.startswith("prompts/"):
|
|
# A whole prompt folder (prompts/<pack>/<theme>/* or the folder's name): every file in it. Each entry
|
|
# keeps its own file, so drops and ratings set here go to that file.
|
|
names = await off_loop(lambda: library._matching_names(key, "explicit", warn=False))
|
|
folder_files = len(names)
|
|
entries = await off_loop(lambda: [entry for name in names
|
|
for entry in library.entries_of(name, "explicit", include_dropped=True)])
|
|
curation = library.curation
|
|
dropped = curation.dropped(key) if curation is not None else set()
|
|
matched = [entry for entry in entries if not query or query in entry.text.casefold()]
|
|
row = curation.catalog.row(key) if curation is not None and curation.catalog is not None else None
|
|
return web.json_response({
|
|
"key": key, "total": len(entries), "matched": len(matched), "offset": offset,
|
|
"stars": curation.stars(key) if curation is not None else 0,
|
|
"hidden": curation.hidden(key) if curation is not None else False,
|
|
"catalog": row.to_dict() if row is not None else None,
|
|
"nsfw": level, "list_rating": library.list_rating(key),
|
|
"list_override": str((curation._entry(key).get("nsfw", "") if curation is not None else "") or ""),
|
|
# Under an _nsfw folder the folder's level wins (the rating box is shown locked at it).
|
|
"rating_locked": max((nsfw_folder_tier(real) for real in library.real_keys(key)), default=0),
|
|
"folder": bool(folder_files), "files": folder_files,
|
|
"entries": [_entry_payload(library, curation, key, entry, dropped, level, folder=bool(folder_files))
|
|
for entry in matched[offset:offset + limit]],
|
|
})
|
|
except Exception as error:
|
|
return fail(error)
|
|
|
|
@routes.get(ROUTE + "/prompts")
|
|
async def prompts_route(request):
|
|
"""?folder=prompts/<pack>&q=words&nsfw= -> the Prompt line's browser: folders and prompt files."""
|
|
try:
|
|
folder = request.query.get("folder", PROMPTS_ROOT)
|
|
query = request.query.get("q", "")
|
|
nsfw = _level(request.query.get("nsfw"))
|
|
return web.json_response(await off_loop(prompt_browse, folder, query, nsfw))
|
|
except Exception as error:
|
|
return fail(error)
|
|
|
|
@routes.get(ROUTE + "/find")
|
|
async def find_route(request):
|
|
"""?q=words&nsfw=&gender=&names=1&limit= -> lists for the Find box (same fields as /slot)."""
|
|
try:
|
|
query = request.query.get("q", "").strip()
|
|
limit = max(1, min(FIND_LIMIT_MAX, int(request.query.get("limit", 40) or 40)))
|
|
started = time.time()
|
|
lists = await off_loop(find_lists, query, _level(request.query.get("nsfw")),
|
|
request.query.get("gender", ""), _flag(request.query.get("names")), limit,
|
|
_violence(request.query.get("violence")))
|
|
# capped: more lists have these words than were read or returned (the box says "N+").
|
|
capped = len(lists) >= limit or bool(getattr(get_matcher(), "last_truncated", False))
|
|
return web.json_response({"q": query, "seconds": round(time.time() - started, 3), "capped": capped,
|
|
"lists": [_list_payload(info) for info in lists]})
|
|
except Exception as error:
|
|
return fail(error)
|
|
|
|
@routes.post(ROUTE + "/buttons")
|
|
async def buttons_route(request):
|
|
"""{action: 'save', button: {id?, label, section, lists?, search?}} or {action: 'delete', id}."""
|
|
try:
|
|
data = await request.json()
|
|
action = str(data.get("action", "save"))
|
|
if action == "delete":
|
|
removed = await off_loop(delete_button, str(data.get("id", "")))
|
|
return web.json_response({"deleted": removed}, status=200 if removed else 404)
|
|
button = await off_loop(save_button, data.get("button") or {})
|
|
return web.json_response({"button": button})
|
|
except ValueError as error:
|
|
return web.json_response({"error": str(error)}, status=400)
|
|
except Exception as error:
|
|
return fail(error)
|
|
|
|
@routes.get(ROUTE + "/more")
|
|
async def more_route(request):
|
|
"""?builder=§ions=subject,action&nsfw= -> lists no button offers, grouped."""
|
|
try:
|
|
sections = [s.strip() for s in request.query.get("sections", "").split(",") if s.strip()]
|
|
started = time.time()
|
|
data = await off_loop(more_payload, request.query.get("builder", ""), sections, _level(request.query.get("nsfw")),
|
|
_violence(request.query.get("violence")))
|
|
data["seconds"] = round(time.time() - started, 2)
|
|
return web.json_response(data)
|
|
except ValueError as error:
|
|
return web.json_response({"error": str(error)}, status=400)
|
|
except Exception as error:
|
|
return fail(error)
|
|
|
|
@routes.post(ROUTE + "/layout")
|
|
async def layout_route(request):
|
|
"""
|
|
Your button layout (builders_user/button_layout.yaml), for every node:
|
|
{action: 'move', slot, section?, group?, before?, section_ids?} | {action: 'hide', slot, hidden}
|
|
| {action: 'reset'}
|
|
"""
|
|
try:
|
|
data = await request.json()
|
|
action = str(data.get("action", ""))
|
|
slot_id = str(data.get("slot", ""))
|
|
|
|
def change():
|
|
if action == "reset":
|
|
if os.path.exists(LAYOUT_PATH):
|
|
os.remove(LAYOUT_PATH)
|
|
return {"reset": True}
|
|
layout = read_layout(LAYOUT_PATH)
|
|
if not slot_id:
|
|
raise ValueError("no slot")
|
|
if action == "move":
|
|
section = data.get("section")
|
|
if section and section not in SECTIONS:
|
|
raise ValueError(f"unknown section '{section}'")
|
|
group = data.get("group")
|
|
layout = layout_move(layout, slot_id, section or None, None if group is None else str(group),
|
|
data.get("before") or None, data.get("section_ids"))
|
|
elif action == "hide":
|
|
layout = layout_hide(layout, slot_id, bool(data.get("hidden", True)))
|
|
else:
|
|
raise ValueError(f"unknown action '{action}'")
|
|
write_layout(LAYOUT_PATH, layout)
|
|
return {"layout": layout}
|
|
return web.json_response(await off_loop(change))
|
|
except ValueError as error:
|
|
return web.json_response({"error": str(error)}, status=400)
|
|
except Exception as error:
|
|
return fail(error)
|
|
|
|
@routes.post(ROUTE + "/merge/preview")
|
|
async def merge_preview_route(request):
|
|
try:
|
|
data = await request.json()
|
|
result = await off_loop(merge_preview, str(data.get("builder", "")), str(data.get("slot", "")),
|
|
list(data.get("keys") or []), _level(data.get("nsfw")))
|
|
return web.json_response(result)
|
|
except ValueError as error:
|
|
return web.json_response({"error": str(error)}, status=400)
|
|
except Exception as error:
|
|
return fail(error)
|
|
|
|
@routes.post(ROUTE + "/merge/save")
|
|
async def merge_save_route(request):
|
|
try:
|
|
data = await request.json()
|
|
result = await off_loop(merge_save, str(data.get("builder", "")), str(data.get("slot", "")),
|
|
str(data.get("name", "")), list(data.get("entries") or []),
|
|
list(data.get("sources") or []), bool(data.get("hide_sources")),
|
|
bool(data.get("overwrite")))
|
|
return web.json_response(result)
|
|
except FileExistsError:
|
|
return web.json_response({"error": "a merged list with that name exists", "exists": True}, status=409)
|
|
except ValueError as error:
|
|
return web.json_response({"error": str(error)}, status=400)
|
|
except Exception as error:
|
|
return fail(error)
|
|
|
|
@routes.get(ROUTE + "/templates")
|
|
async def templates_route(request):
|
|
try:
|
|
return web.json_response({"templates": await off_loop(list_templates)})
|
|
except Exception as error:
|
|
return fail(error)
|
|
|
|
@routes.get(ROUTE + "/template")
|
|
async def template_route(request):
|
|
try:
|
|
data = await off_loop(read_template, request.query.get("file", ""), _level(request.query.get("nsfw")))
|
|
return web.json_response(data)
|
|
except FileNotFoundError:
|
|
return web.json_response({"error": "no such template"}, status=404)
|
|
except ValueError as error:
|
|
return web.json_response({"error": str(error)}, status=400)
|
|
except Exception as error:
|
|
return fail(error)
|
|
|
|
@routes.post(ROUTE + "/templates")
|
|
async def templates_post_route(request):
|
|
"""{action: 'save', name, builder, platform, composer, overwrite?} or {action: 'delete', file}."""
|
|
try:
|
|
data = await request.json()
|
|
if str(data.get("action", "save")) == "delete":
|
|
removed = await off_loop(delete_template, str(data.get("file", "")))
|
|
return web.json_response({"deleted": removed}, status=200 if removed else 404)
|
|
saved = await off_loop(save_template, str(data.get("name", "")), str(data.get("builder", "")),
|
|
data.get("composer"), str(data.get("platform", "")), bool(data.get("overwrite")))
|
|
return web.json_response({"template": saved})
|
|
except FileExistsError as error:
|
|
return web.json_response({"error": str(error), "exists": True}, status=409)
|
|
except ValueError as error:
|
|
return web.json_response({"error": str(error)}, status=400)
|
|
except Exception as error:
|
|
return fail(error)
|
|
|
|
@routes.get(ROUTE + "/platforms")
|
|
async def platforms_route(request):
|
|
try:
|
|
payload = []
|
|
for platform in platform_choices():
|
|
profile = None if platform == GENERIC_PLATFORM else get_profile(platform)
|
|
payload.append({
|
|
"id": platform,
|
|
"label": getattr(profile, "name", "") or platform,
|
|
"order": platform_order(platform),
|
|
"structure": getattr(profile, "structure", "") if profile else "",
|
|
"target_words": list(getattr(profile, "target_words", ()) or ()) if profile else [],
|
|
})
|
|
return web.json_response({"platforms": payload})
|
|
except Exception as error:
|
|
return fail(error)
|
|
|
|
@routes.post(ROUTE + "/preview")
|
|
async def preview_route(request):
|
|
try:
|
|
data = await request.json()
|
|
builder = get_builder(str(data.get("builder", "")))
|
|
if builder is None:
|
|
return web.json_response({"error": "unknown builder"}, status=404)
|
|
composer = data.get("composer", "")
|
|
if not isinstance(composer, str):
|
|
composer = json.dumps(composer)
|
|
result = await off_loop(build, builder, composer, int(data.get("seed", 0) or 0),
|
|
str(data.get("platform", "")), _level(data.get("nsfw", "off")),
|
|
None, _violence(data.get("violence", 0)))
|
|
return web.json_response({
|
|
"blocked": result.get("blocked", ""),
|
|
"text": result["text"], "template": result["template"], "sections": result["own_sections"],
|
|
"templates": result["own_templates"], "picks": result["picks"], "warnings": result["warnings"],
|
|
"order": result["order"], "seeds": result["seeds"], "to_fill": result["to_fill"],
|
|
})
|
|
except Exception as error:
|
|
return fail(error)
|
|
|
|
@routes.post(ROUTE + "/rate")
|
|
async def rate_route(request):
|
|
"""
|
|
{key, stars?, hidden?, nsfw?, name?, drop?, undrop?, rate?} -> the key's new settings in _catalog_user.yaml.
|
|
nsfw: the list's level ('' = back to its folder / name rating); rate: {text, level} for one entry ('' clears).
|
|
"""
|
|
try:
|
|
data = await request.json()
|
|
key = str(data.get("key", "")).strip().lower()
|
|
curation = get_wildcard_engine().library.curation
|
|
if not key or curation is None:
|
|
return web.json_response({"error": "no key, or curation is off"}, status=400)
|
|
changes = {name: data[name] for name in ("stars", "hidden", "nsfw", "name", "drop", "undrop", "rate") if name in data}
|
|
entry = await off_loop(lambda: curation.update(key, **changes))
|
|
return web.json_response({"key": key, "settings": entry})
|
|
except KeyError as error:
|
|
return web.json_response({"error": str(error)}, status=404)
|
|
except Exception as error:
|
|
return fail(error)
|
|
|
|
@routes.get(ROUTE + "/catalog/status")
|
|
async def catalog_status_route(request):
|
|
engine = get_wildcard_engine()
|
|
catalog = engine.catalog
|
|
if catalog is None:
|
|
return web.json_response({"state": "off"})
|
|
rows = catalog.rows()
|
|
return web.json_response(dict(catalog.status, lists=len(rows),
|
|
files={root: c.db_path for root, c in catalog.catalogs.items()}))
|
|
|
|
@routes.post(ROUTE + "/catalog/rebuild")
|
|
async def catalog_rebuild_route(request):
|
|
engine = get_wildcard_engine()
|
|
if engine.catalog is None:
|
|
return web.json_response({"state": "off"})
|
|
threading.Thread(target=engine.update_catalog, name="wildcard-catalog", daemon=True).start()
|
|
return web.json_response({"state": "started"})
|
|
|
|
@routes.get(ROUTE + "/catalog/search")
|
|
async def catalog_search_route(request):
|
|
"""?q=words&facet=&nsfw=0&limit=50 -> lists by name and sample entries, best first."""
|
|
try:
|
|
engine = get_wildcard_engine()
|
|
if engine.catalog is None:
|
|
return web.json_response({"lists": []})
|
|
query = request.query.get("q", "")
|
|
facet = request.query.get("facet", "")
|
|
nsfw = _level(request.query.get("nsfw"))
|
|
limit = max(1, min(200, int(request.query.get("limit", 50) or 50)))
|
|
visible = set(engine.library.visible_keys(nsfw))
|
|
curation = engine.library.curation
|
|
rows = [row for row in engine.catalog.search(query, limit * 4)
|
|
if row.key in visible and (not facet or row.facet == facet) and not curation.hidden(row.key)]
|
|
# Text relevance first, nudged by stars and quality.
|
|
order = {row.key: index for index, row in enumerate(rows)}
|
|
rows.sort(key=lambda row: order[row.key] - 20 * curation.score(row.key))
|
|
return web.json_response({"lists": [dict(row.to_dict(), stars=curation.stars(row.key)) for row in rows[:limit]]})
|
|
except Exception as error:
|
|
return fail(error)
|
|
|
|
return True
|