Files
EricRollei-Local_LLM_Prompt…/core/builder/slots.py
T
Eric HissandCopilot 373ea8bda3 v2.2.0: Prompt Builder nodes, wildcard review tool, docs refresh
- 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>
2026-10-28 20:36:44 -07:00

727 lines
33 KiB
Python

"""
Builder definitions: which slot buttons a builder node shows, and which wildcard lists back each slot.
A builder is one YAML file (builders/prompt.yaml and builders/character.yaml ship with the
package; files in builders_user/ with the same id replace them):
id: prompt
label: Wildcard Prompt Composer
parts: # one composer line per prompt section
- section: subject
label: Subject
multi: true # "+ Subject" adds subject_2, subject_3 ...
hint: who or what the picture is about
starter: "a {age} {occupation} with {hair_color} hair"
slots: [subject, action] # optional: which sections' buttons this part shows
slots:
- section: subject # buttons shown for that section
slots:
- id: hair_color
label: Hair color
match: ['hair_?colou?rs?'] # regexes, full match on a list's leaf name
avoid: ['fantasy'] # regexes, searched in the whole key
lists: ['mypack/best'] # optional: pinned lists, shown first
folders: ['names?'] # optional: regexes searched in the list's folder path;
# every list in a matching folder counts (after `match`)
search: 'famous buildings' # optional: words that must all appear in the key
# (see key_has_terms); what "My buttons" categories use
id: character
extends: prompt # takes prompt's slots for the sections its parts show
parts: [{section: subject, slots: [subject, action], starter: "..."}]
In `starter`, {slot_id} places a wildcard pill for that slot; everything else is text.
Your own buttons live in builders_user/my_buttons.yaml (made by the panel's "+ Button"; shared by
every builder, shown under "My buttons" in their section):
buttons:
- id: my_black_names
label: Black names
section: subject
lists: [blackamericanfemalefirstnames_v10/black_female_names]
- id: my_famous_buildings
label: Famous buildings
section: setting
search: famous buildings
Until the wildcard catalog (dev/WILDCARD_CATALOG_SPEC.md, P2) ranks lists by quality, a slot's
lists are found by name: every visible list whose leaf name (the last path segment, lower case,
with pack prefixes such as cops_wc_ or cyber_ removed) fully matches one of the slot's `match`
patterns. Your stars rank first, then earlier patterns, then the catalog's quality score (short,
clean prose entries read best between typed words), then bigger lists. Hidden lists are left out. Lists with identical content (the same
file copied into several packs) are shown once.
(c) Eric Hiss. Dual license, see LICENSE.
"""
from __future__ import annotations
import hashlib
import os
import re
from dataclasses import dataclass, field
from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple
from ..wildcards.library import NSFW_OFF, nsfw_level, violence_name_tier
try:
import yaml
except ImportError: # pragma: no cover - PyYAML ships with ComfyUI
yaml = None
# Pack-specific prefixes seen in real libraries; removed before matching a leaf name.
_LEAF_PREFIX = re.compile(r"^(?:cops_(?:bp_)?(?:male_|female_)?wc_?|cyber_|bk_|rd_|fg3_|vc_|wc_|tr_)")
_SEPARATORS = re.compile(r"[\s\-.]+")
# Lists never offered by a slot, whatever their name (lists rated above the run's nsfw level are already hidden).
_ALWAYS_AVOID = re.compile(r"lora")
# Also left out of runs at nsfw off by name (lists rated by folder / name / content are already invisible to them).
_SFW_AVOID = re.compile(r"e621|nsfw|panties|no_bottom|nud(e|ity)|topless|lingerie")
GENDERS = ("female", "male")
_TOKENS = re.compile(r"[a-z]+")
_FEMALE_WORDS = {"female", "females", "women", "womens", "woman", "girl", "girls", "fem", "feminine", "lady",
"ladies"}
_MALE_WORDS = {"male", "males", "men", "mens", "man", "boy", "boys", "guy", "guys", "masculine", "gentleman",
"gentlemen"}
def gender_of(key: str) -> str:
"""'female', 'male' or '' (neutral or both) from the words in a list's key."""
spaced = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", key).lower()
tokens = _TOKENS.findall(spaced)
female = any(t in _FEMALE_WORDS or t.startswith(("female", "women", "woman", "girl")) for t in tokens)
male = any(t in _MALE_WORDS or t.startswith(("male", "guy")) or (t.startswith("men") and len(t) <= 4)
for t in tokens)
if female and not male:
return "female"
if male and not female:
return "male"
return ""
# Keys the wildcard grammar can reference as __key__ (see core/wildcards/parser.py).
_REFERENCEABLE = re.compile(r"[a-z0-9][a-z0-9_\-/. ]*")
MIN_ENTRIES = 3
MAX_LISTS_PER_SLOT = 250
FIND_MAX_CANDIDATES = 400
MY_BUTTONS_FILE = "my_buttons.yaml"
MY_BUTTONS_GROUP = "My buttons"
_KEY_TOKENS = re.compile(r"[a-z0-9]+")
def search_terms(text: str) -> List[str]:
return [term for term in _KEY_TOKENS.findall((text or "").lower()) if term]
def key_has_terms(key: str, terms: Sequence[str]) -> bool:
"""
True when every term is in the key (folders included). A term of 4+ letters may sit inside a
word ('names' finds peoplenamemegalist ... via 'name'), a shorter one must start a word; a
trailing plural s is optional. Gender words ('female', 'men') match lists named for that gender,
so 'male' does not find female lists.
"""
if not terms:
return False
spaced = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", key).lower()
tokens = _KEY_TOKENS.findall(spaced)
gender = None
for term in terms:
if term in _FEMALE_WORDS or term in _MALE_WORDS:
wanted = "female" if term in _FEMALE_WORDS else "male"
gender = gender_of(key) if gender is None else gender
if gender != wanted:
return False
continue
stems = {term, term[:-1]} if len(term) > 3 and term.endswith("s") else {term}
if not any((token.startswith(stem) or (len(stem) >= 4 and stem in token)) for token in tokens for stem in stems):
return False
return True
@dataclass
class SlotDef:
id: str
label: str
match: List[str] = field(default_factory=list)
avoid: List[str] = field(default_factory=list)
lists: List[str] = field(default_factory=list)
section: str = "subject" # which prompt section's palette shows this slot
tooltip: str = ""
group: str = "" # palette heading (Person, Clothing, ...)
folders: List[str] = field(default_factory=list) # regexes searched in the folder part of a key
search: str = "" # words that must all be in the key (key_has_terms)
custom: bool = False # from my_buttons.yaml
kinds: List[str] = field(default_factory=list) # subject types it serves (person, animal ...); [] = see kinds_of
hidden: bool = False # hidden by your button layout (shown in Arrange mode)
def __post_init__(self) -> None:
self._match = [re.compile(pattern, re.IGNORECASE) for pattern in self.match]
self._avoid = [re.compile(pattern, re.IGNORECASE) for pattern in self.avoid]
self._folders = [re.compile(pattern, re.IGNORECASE) for pattern in self.folders]
self._terms = search_terms(self.search)
def _avoided(self, key: str, nsfw) -> bool:
return bool(_ALWAYS_AVOID.search(key) or any(pattern.search(key) for pattern in self._avoid)
or (nsfw_level(nsfw) == NSFW_OFF and _SFW_AVOID.search(key)))
def leaf_rank(self, key: str, nsfw="explicit") -> Optional[int]:
"""Index of the first `match` pattern the key's own name fully matches, or None."""
if not self._match or self._avoided(key, nsfw):
return None
leaf = leaf_name(key)
for index, pattern in enumerate(self._match):
if pattern.fullmatch(leaf):
return index
return None
def match_rank(self, key: str, nsfw="off", folder_ok: bool = False) -> Optional[int]:
"""
Rank of the key for this slot, or None: its own name against `match` (0, 1, ...), then
`search` words, then `folders`, then (folder_ok: the name itself says nothing any slot knows,
like 'basemodel/eye-color/brown') its folder names against `match`.
"""
if self._avoided(key, nsfw):
return None
leaf = leaf_name(key)
for index, pattern in enumerate(self._match):
if pattern.fullmatch(leaf):
return index
if self._terms and key_has_terms(key, self._terms):
return len(self._match)
folder = key.rsplit("/", 1)[0] if "/" in key else ""
if folder and any(pattern.search(folder) for pattern in self._folders):
return len(self._match) + 1
if folder_ok and self._match:
for parent in parent_names(key):
if any(pattern.fullmatch(parent) for pattern in self._match):
return len(self._match) + 2
return None
@dataclass
class PartDef:
section: str
label: str
starter: str = ""
slots: List[str] = field(default_factory=list) # sections whose slot buttons this part shows ([] = its own)
multi: bool = False # "+ Subject" may add Subject 2, 3, ...
hint: str = "" # what goes here; also tells the LLM what to write when asked
optional: bool = False # shown only when added with "+ Section" (Text)
@property
def slot_sections(self) -> List[str]:
return self.slots or [self.section]
@dataclass
class BuilderDef:
id: str
label: str
parts: List[PartDef]
slots: List[SlotDef]
description: str = ""
origin: str = ""
extends: str = ""
def slot(self, slot_id: str) -> Optional[SlotDef]:
for slot in self.slots:
if slot.id == slot_id:
return slot
return None
def part(self, section: str) -> Optional[PartDef]:
"""The part for a section; Subject 2, 3 ... ('subject_2') use the subject part."""
base = base_section(section)
for part in self.parts:
if part.section == base:
return part
return None
def base_section(section: str) -> str:
"""'subject_2' -> 'subject'. Extra subjects share the subject section's slots and place in the order."""
return re.sub(r"_\d+$", "", section or "")
@dataclass
class ListInfo:
key: str
count: int
rank: int
pinned: bool = False
also: List[str] = field(default_factory=list) # keys with identical content, folded into this one
words: int = 0 # median words per entry: 1-4 short, 5-8 medium, more = long
quality: float = -1.0 # catalog quality 0..1 (-1 = not indexed yet)
stars: int = 0 # the user's stars (0 = none)
dialect: str = "" # prose | tags | mixed (from the catalog)
display: str = ""
nsfw: int = 0 # the list's rating 0..3 (library.NSFW_LEVELS)
gender: str = "" # female | male | '' (from the key's words)
@property
def order_score(self) -> float:
"""What ranks lists inside one match pattern: the catalog's quality (tag lists read worse
between typed words), or, before the catalog is built, entry length alone."""
if self.quality >= 0:
return self.quality - (0.15 if self.dialect == "tags" else 0.05 if self.dialect == "mixed" else 0.0)
return {"short": 0.8, "medium": 0.6, "long": 0.3}[self.length_class]
@property
def length_class(self) -> str:
return "short" if self.words <= 4 else "medium" if self.words <= 8 else "long"
# Gender and rating words around a list name ("female_hair_colors", "poses_nsfw") don't change what
# the list is: they are stripped before matching (gender is judged separately, see gender_of).
_QUALIFIER_PREFIX = re.compile(r"^(?:(?:fe)?males?|(?:wo)?mens?|girls?|boys?|fem|n?sfw)_")
_QUALIFIER_SUFFIX = re.compile(r"_(?:(?:fe)?males?|(?:wo)?mens?|girls?|boys?|fem|n?sfw)$")
_NUMBER_PREFIX = re.compile(r"^\d+_+(?=[a-z])") # "10-lighting" (The Loom's numbered folders)
def leaf_name(key: str) -> str:
leaf = key.rsplit("/", 1)[-1].lower()
leaf = _SEPARATORS.sub("_", leaf).strip("_")
leaf = _NUMBER_PREFIX.sub("", leaf)
leaf = _LEAF_PREFIX.sub("", leaf)
for _ in range(2):
stripped = _QUALIFIER_SUFFIX.sub("", _QUALIFIER_PREFIX.sub("", leaf))
if not stripped or stripped == leaf:
break
leaf = stripped
return leaf
# Folder names that say nothing about the content ("female/", "general/", "v2/"): looked past.
_GENERIC_FOLDER = re.compile(r"(?:(?:fe)?males?|(?:wo)?mens?|girls?|boys?|unisex|n?sfw|general|base|basic|main|misc|"
r"others?|all|common|default|standard|regular|simple|extra|custom|v\d+|types?|\d+|\d*wildcards?(_\w+)?|"
r"collections?|packs?|lists?|misc_\w+)")
def parent_names(key: str, limit: int = 1) -> List[str]:
"""
The nearest meaningful folder name(s) of a key, normalised like leaf names, nearest first:
'basemodel/eye-color/brown' -> ['eye_color']; 'pack/10-lighting/female/soft' -> ['lighting'].
The top folder (the pack) is never used. One level by default: further up, folders tend to
name a whole scene ('locations/.../kneeling/fully_clothed'), not the list.
"""
parts = key.split("/")[1:-1]
found: List[str] = []
for part in reversed(parts):
name = leaf_name(part)
if not name or _GENERIC_FOLDER.fullmatch(name):
continue
found.append(name)
if len(found) >= limit:
break
return found
# ----------------------------------------------------------------------------- loading
def _as_list(value) -> List[str]:
if value is None:
return []
if isinstance(value, str):
return [value]
return [str(item) for item in value if item is not None]
def _slot_from_dict(raw: Dict, default_section: str = "subject") -> Optional[SlotDef]:
if not isinstance(raw, dict) or not raw.get("id"):
return None
return SlotDef(
id=str(raw["id"]).strip(),
label=str(raw.get("label") or raw["id"]).strip(),
match=_as_list(raw.get("match")),
avoid=_as_list(raw.get("avoid")),
lists=[item.strip().replace("\\", "/").lower() for item in _as_list(raw.get("lists"))],
section=str(raw.get("section", "") or default_section).strip(),
tooltip=str(raw.get("tooltip", "") or "").strip(),
group=str(raw.get("group", "") or "").strip(),
folders=_as_list(raw.get("folders")),
search=str(raw.get("search", "") or "").strip(),
kinds=[kind.strip().lower() for kind in _as_list(raw.get("kinds")) if kind.strip()],
)
def builder_from_dict(data: Dict, origin: str = "") -> BuilderDef:
builder_id = str(data.get("id") or os.path.splitext(os.path.basename(origin))[0]).strip()
parts = []
for part in (data.get("parts") or [{"section": "subject", "label": "Subject"}]):
if not isinstance(part, dict):
continue
section = str(part.get("section", "subject")).strip() or "subject"
parts.append(PartDef(
section=section,
label=str(part.get("label") or section.title()).strip(),
starter=str(part.get("starter", "") or ""),
slots=_as_list(part.get("slots")),
multi=bool(part.get("multi", False)),
hint=str(part.get("hint", "") or "").strip(),
optional=bool(part.get("optional", False)),
))
slots: List[SlotDef] = []
for group in data.get("slots") or []:
# Either a flat list of slots, or {section: x, slots: [...]} groups.
if isinstance(group, dict) and isinstance(group.get("slots"), list) and not group.get("id"):
for raw in group["slots"]:
slot = _slot_from_dict(raw, str(group.get("section", "subject")))
if slot:
slots.append(slot)
else:
slot = _slot_from_dict(group)
if slot:
slots.append(slot)
return BuilderDef(id=builder_id, label=str(data.get("label") or builder_id), parts=parts, slots=slots,
description=str(data.get("description", "") or "").strip(), origin=origin,
extends=str(data.get("extends", "") or "").strip())
def _resolve_extends(builders: Dict[str, BuilderDef]) -> None:
"""A builder with `extends: other` gets the other's slots for the sections its parts show (its own slots win)."""
for builder in builders.values():
base = builders.get(builder.extends) if builder.extends else None
if base is None or base is builder:
continue
wanted = {section for part in builder.parts for section in part.slot_sections}
own = {slot.id for slot in builder.slots}
inherited = [slot for slot in base.slots if slot.section in wanted and slot.id not in own]
builder.slots = inherited + builder.slots
for part in builder.parts:
base_part = base.part(part.section)
if base_part and not part.hint:
part.hint = base_part.hint
# ----------------------------------------------------------------------------- my buttons
_BUTTON_ID = re.compile(r"[^a-z0-9_]+")
def button_id(label: str, taken: Iterable[str] = ()) -> str:
"""'Famous buildings' -> 'my_famous_buildings' (with _2, _3 ... when taken)."""
base = "my_" + (_BUTTON_ID.sub("_", (label or "").lower()).strip("_") or "button")
taken = set(taken)
candidate, n = base, 2
while candidate in taken:
candidate, n = f"{base}_{n}", n + 1
return candidate
def clean_button(raw: Dict) -> Optional[Dict]:
"""A my_buttons.yaml entry with only the fields it may have, or None when it has no label or lists/search."""
if not isinstance(raw, dict):
return None
label = str(raw.get("label", "") or "").strip()
lists = [item.strip().replace("\\", "/").lower() for item in _as_list(raw.get("lists")) if item.strip()]
search = " ".join(search_terms(str(raw.get("search", "") or "")))
if not label or not (lists or search):
return None
button = {"id": str(raw.get("id", "") or "").strip(), "label": label,
"section": str(raw.get("section", "") or "subject").strip() or "subject"}
if lists:
button["lists"] = lists
if search:
button["search"] = search
tooltip = str(raw.get("tooltip", "") or "").strip()
if tooltip:
button["tooltip"] = tooltip
return button
def read_buttons(path: str) -> List[Dict]:
"""The entries of my_buttons.yaml (cleaned, ids filled in). A missing or broken file gives []."""
if yaml is None or not path or not os.path.isfile(path):
return []
try:
with open(path, "r", encoding="utf-8") as handle:
data = yaml.safe_load(handle) or {}
except Exception as error:
print(f"[Wildcard Composer] skipped {path}: {error}")
return []
raw_buttons = data.get("buttons") if isinstance(data, dict) else data
buttons: List[Dict] = []
for raw in raw_buttons or []:
button = clean_button(raw)
if button is None:
continue
if not button["id"] or button["id"] in {b["id"] for b in buttons}:
button["id"] = button_id(button["label"], (b["id"] for b in buttons))
buttons.append(button)
return buttons
def write_buttons(path: str, buttons: List[Dict]) -> None:
"""Save my_buttons.yaml atomically (a temp file, then replace)."""
if yaml is None:
raise RuntimeError("PyYAML is not installed")
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
header = ("# Your Wildcard Composer buttons. Made by the panel's \"+ Button\"; safe to edit by hand.\n"
"# lists: exact list keys (shown first). search: words that must all be in a list's key.\n")
text = header + yaml.safe_dump({"buttons": buttons}, sort_keys=False, allow_unicode=True, width=120)
temp = path + ".tmp"
with open(temp, "w", encoding="utf-8") as handle:
handle.write(text)
os.replace(temp, path)
def button_slot(button: Dict) -> SlotDef:
return SlotDef(id=button["id"], label=button["label"], lists=list(button.get("lists", [])),
search=button.get("search", ""), section=button.get("section", "subject"),
tooltip=button.get("tooltip", ""), group=MY_BUTTONS_GROUP, custom=True)
def apply_buttons(builders: Dict[str, BuilderDef], buttons: List[Dict]) -> None:
"""Add your buttons to every builder whose parts show their section (after its own slots)."""
for builder in builders.values():
shown = {section for part in builder.parts for section in part.slot_sections}
own = {slot.id for slot in builder.slots}
builder.slots = [slot for slot in builder.slots if not slot.custom] + [
button_slot(button) for button in buttons if button["section"] in shown and button["id"] not in own]
# Subject types (the Subject lane's type menu) and which subject buttons each shows.
SUBJECT_KINDS = ("person", "animal", "creature", "object", "vehicle", "food", "scene")
PERSON_GROUPS = ("Person", "Face & hair", "Clothing", "Accessories", "Makeup")
def kinds_of(slot: SlotDef) -> List[str]:
"""The subject types a subject button serves: its `kinds`, else person for the person groups, else all."""
if slot.kinds:
return list(slot.kinds)
if slot.section == "subject" and slot.group in PERSON_GROUPS:
return ["person"]
return []
def load_builders(folders: Sequence[str], buttons_path: str = "", layout_path: str = "") -> Dict[str, BuilderDef]:
"""Read every *.yaml in the folders; a later folder's builder replaces an earlier one with the same id.
buttons_path (my_buttons.yaml) adds your own buttons to every builder."""
builders: Dict[str, BuilderDef] = {}
if yaml is None:
return builders
for folder in folders:
if not folder or not os.path.isdir(folder):
continue
for filename in sorted(os.listdir(folder)):
if not filename.lower().endswith((".yaml", ".yml")) or filename.startswith(("_", ".")):
continue
if filename.lower() in (MY_BUTTONS_FILE, "button_layout.yaml"):
continue
path = os.path.join(folder, filename)
try:
with open(path, "r", encoding="utf-8") as handle:
data = yaml.safe_load(handle) or {}
builder = builder_from_dict(data, origin=path)
except Exception as error: # a broken user file must not hide the others
print(f"[Wildcard Composer] skipped {path}: {error}")
continue
builders[builder.id] = builder
layout = None
if layout_path:
from .layout import apply_layout, read_layout
layout = read_layout(layout_path)
apply_layout(builders, layout) # before extends, so a button moved into Subject reaches the Character Composer
_resolve_extends(builders)
if buttons_path:
apply_buttons(builders, read_buttons(buttons_path))
if layout is not None:
apply_layout(builders, layout) # again, for your own buttons and the inherited order
return builders
# ----------------------------------------------------------------------------- matching lists
def _signature(texts: Iterable[str]) -> str:
digest = hashlib.sha1()
for text in sorted({text.strip().casefold() for text in texts}):
digest.update(text.encode("utf-8", "replace"))
digest.update(b"\n")
return digest.hexdigest()
def slot_has_lists(matcher: "SlotMatcher", builder: "BuilderDef", slot: "SlotDef", nsfw="off") -> int:
"""How many lists back a slot (for hiding buttons that would have none)."""
return len(matcher.lists_for(builder, slot, nsfw))
def _violence_hidden(key: str, violence: int) -> bool:
"""A list a node with this violence_filter level does not offer: its name is in that tier or a lower one."""
if not violence:
return False
tier = violence_name_tier(key)
return bool(tier) and tier <= violence
class SlotMatcher:
"""
Finds the lists behind each slot in one WildcardLibrary. Results are cached per
(builder, slot, nsfw level) until the library's key set changes.
"""
def __init__(self, library):
self.library = library
self._cache: Dict[Tuple, Tuple[int, List[ListInfo]]] = {}
self._claimed: Dict[str, Tuple[int, Set[str]]] = {}
self._unassigned: Dict[Tuple, Tuple[Tuple[int, int], List[str]]] = {}
def lists_for(self, builder: BuilderDef, slot: SlotDef, nsfw="off", gender: str = "",
violence: int = 0) -> List[ListInfo]:
"""
The lists behind a slot, best first (cached until the library or your ratings change). gender 'female' / 'male' leaves out lists named for the
other one and puts lists named for this one first (neutral lists follow). violence: a
violence_filter level (0-3); lists whose name falls in that tier or a lower one are left out.
"""
gender = gender if gender in GENDERS else ""
curation = getattr(self.library, "curation", None)
generation = (self.library.generation, curation.version if curation is not None else 0)
cache_key = (builder.id, slot.id, nsfw_level(nsfw), gender, int(violence))
cached = self._cache.get(cache_key)
if cached and cached[0] == generation:
return cached[1]
found = self._collect(slot, nsfw, gender, claimed=self.claimed(builder),
violence=int(violence))[:MAX_LISTS_PER_SLOT]
self._cache[cache_key] = (generation, found)
return found
def clear(self) -> None:
self._cache.clear()
self._claimed.clear()
self._unassigned.clear()
def claimed(self, builder: BuilderDef) -> Set[str]:
"""Keys whose own name some slot of the builder knows. Only the others are matched by folder."""
generation = self.library.generation
cached = self._claimed.get(builder.id)
if cached and cached[0] == generation:
return cached[1]
slots = [slot for slot in builder.slots if slot._match]
found = {key for key in self.library.visible_keys("explicit")
if any(slot.leaf_rank(key) is not None for slot in slots)}
self._claimed[builder.id] = (generation, found)
return found
def unassigned(self, builder: BuilderDef, nsfw="off", violence: int = 0) -> List[str]:
"""Visible keys no slot button of the builder offers (for the panel's More lists)."""
curation = getattr(self.library, "curation", None)
generation = (self.library.generation, curation.version if curation is not None else 0)
cache_key = (builder.id, nsfw_level(nsfw), int(violence))
cached = self._unassigned.get(cache_key)
if cached and cached[0] == generation:
return cached[1]
claimed = self.claimed(builder)
found = []
for key in self.library.visible_keys(nsfw):
if "__" in key or not _REFERENCEABLE.fullmatch(key) or _ALWAYS_AVOID.search(key):
continue
if curation is not None and curation.hidden(key):
continue
if key in claimed or _violence_hidden(key, violence):
continue
if any(key in slot.lists or slot.match_rank(key, "explicit", folder_ok=True) is not None for slot in builder.slots):
continue
found.append(key)
self._unassigned[cache_key] = (generation, found)
return found
def find(self, query: str, nsfw="off", gender: str = "", also: Sequence[str] = (),
limit: int = 40, violence: int = 0) -> List[ListInfo]:
"""
Lists for the panel's Find box: every list whose key has all the words (key_has_terms),
then `also` (e.g. the catalog's full-text hits on entries), deduplicated and ranked like a slot.
"""
slot = SlotDef(id="_find", label="", search=query)
if not slot._terms:
return []
return self._collect(slot, nsfw, gender if gender in GENDERS else "", extra=also,
max_candidates=FIND_MAX_CANDIDATES, violence=int(violence))[:limit]
def _collect(self, slot: SlotDef, nsfw, gender: str, extra: Sequence[str] = (),
max_candidates: int = 0, claimed: Optional[Set[str]] = None, violence: int = 0) -> List[ListInfo]:
curation = getattr(self.library, "curation", None)
visible = self.library.visible_keys(nsfw)
if violence:
visible = [key for key in visible if not _violence_hidden(key, violence)]
visible_set = set(visible)
# A button you pin to a prompt file (prompts/...) offers it; nothing else ever matches one.
pinnable = visible_set | set(getattr(self.library, "prompt_names", lambda _n: [])(nsfw))
candidates: List[Tuple[int, bool, str]] = []
for key in slot.lists:
if key in pinnable:
candidates.append((-1, True, key))
# Your merged lists for this slot (mine/<slot id>/<name>, see merge.py) are pinned too.
mine = f"mine/{slot.id}/"
if slot.id != "_find":
listed = set(slot.lists)
candidates.extend((-1, True, key) for key in visible if key.startswith(mine) and key not in listed)
# Lists you assigned to this button in the wildcard review (_catalog_user.yaml slot:) are pinned next.
if curation is not None and hasattr(curation, "slot_keys"):
taken = {key for _rank, _pin, key in candidates}
candidates.extend((-1, True, key) for key in curation.slot_keys(slot.id)
if key in pinnable and key not in taken)
pinned = {key for _rank, _pin, key in candidates}
for key in visible:
if key in pinned or "__" in key or not _REFERENCEABLE.fullmatch(key):
continue
if curation is not None and curation.hidden(key):
continue
if gender and gender_of(key) not in ("", gender):
continue
rank = slot.match_rank(key, nsfw, folder_ok=claimed is not None and key not in claimed)
if rank is not None:
candidates.append((rank, False, key))
named = {key for _rank, _pin, key in candidates}
extra_rank = len(slot.match) + 2
for key in extra:
if key in named or key not in visible_set or "__" in key or not _REFERENCEABLE.fullmatch(key):
continue
if _ALWAYS_AVOID.search(key) or (curation is not None and curation.hidden(key)):
continue
if gender and gender_of(key) not in ("", gender):
continue
named.add(key)
candidates.append((extra_rank, False, key))
# Whether this search read only its likeliest candidates (the Find box then says "N+ lists").
self.last_truncated = bool(max_candidates and len(candidates) > max_candidates)
if max_candidates and len(candidates) > max_candidates:
# A broad Find ('hair') can name thousands of lists; read the likeliest ones only.
candidates.sort(key=lambda item: (item[0], len(item[2])))
candidates = candidates[:max_candidates]
found: List[ListInfo] = []
by_signature: Dict[str, ListInfo] = {}
for rank, is_pinned, key in candidates:
entries = self.library.entries_of(key, nsfw=nsfw)
if len(entries) < MIN_ENTRIES and not is_pinned:
continue
signature = _signature(entry.text for entry in entries)
twin = by_signature.get(signature)
if twin is not None:
# Same content in another pack: keep the shorter (usually top-level) key.
if len(key) < len(twin.key) and not twin.pinned:
twin.also.append(twin.key)
twin.key = key
else:
twin.also.append(key)
twin.rank = min(twin.rank, rank)
continue
lengths = sorted(len(entry.text.split()) for entry in entries)
info = ListInfo(key=key, count=len(entries), rank=rank, pinned=is_pinned,
words=lengths[len(lengths) // 2] if lengths else 0,
nsfw=self.library.list_rating(key), gender=gender_of(key))
if curation is not None:
info.stars = curation.stars(key)
row = curation.catalog.row(key) if curation.catalog is not None else None
if row is not None:
info.quality, info.dialect, info.display = row.quality, row.dialect, row.display
by_signature[signature] = info
found.append(info)
# Pinned, then your stars, then the match pattern's rank, then quality (short, clean prose
# entries read best between typed words), then size. Long or tag lists stay in the viewer.
found.sort(key=lambda info: (not info.pinned, -info.stars, bool(gender) and info.gender != gender, info.rank,
-round(info.order_score, 1), -min(info.count, 2000), info.key))
return found