engine/blockCatalog.js

/**
 * @file The categorized building-block catalog — the "token cloud" behind the palette and the DPL
 * editor's autocomplete.
 *
 * This is **engine domain**: it describes the engine's own content pools (generators + lists), their
 * folder categories, the virtual `{#any}` / `{keyword}` wildcards, the NSFW gate, and the naming
 * rules. Nothing about it is a UI concern — the UI merely renders what this returns.
 *
 * It is a pure function of a **loader**, which is why it can live here at all: every target passes
 * its own (`nodeLoader` / `runtimeLoader` in the browser / `metroLoader` on the phone) and gets the
 * same catalog. It used to exist twice — `targets/web/frontend/lib/promptEngine.js` owned it and the
 * mobile target carried a 218-line hand-port (`lib/blockCatalog.js`) with **no drift check at all**,
 * which is worse than a guarded copy, not better. See `notes/plans/de-duplication.md`.
 *
 * Recomputed from the **current** loader state on every call, so a hot-apply refresh (or browsing
 * after a Manage edit) shows the live catalog.
 *
 * Shapes:
 *   group  = { title, hint, items }
 *   items  = a flat run of [category pill, its chips, category pill, its chips, …]
 *   pill   = { category: true, label, token?, description?, forceList? }
 *   chip   = { token, label, description? }
 */
import { computeButtonNames, compareNames } from "./nameOrder.js";
import { isGatedBlock } from "./gatedLists.js";

const lastSeg = (f) => (f === "" ? "misc" : f.split("/").pop());

/** The default category priority when a folder's sidecar sets none (lower = higher in the picker). */
const DEFAULT_CAT_PRIORITY = 1000;

// Expansion generators (referenced as {#rays}, {#dap}, …) live under expansion/ and are NOT listed as
// pickable chips — they're excluded from the Blocks walk.
const isExpansionKey = (n) => n.startsWith("expansion/");

// The virtual "any" wildcard category (priority 0 → leads the picker): `{#any}` (and -sfw/-nsfw)
// draws one random generator from the whole catalog.
const anyGroup = () => ({
  priority: 0,
  name: "any",
  pill: {
    category: true,
    label: "any",
    token: "{#any}",
    description: "One random generator (SFW; +NSFW when adult is on).",
  },
  entries: [
    { token: "{#any-sfw}", label: "any-sfw", description: "One random generator, SFW only." },
    {
      token: "{#any-nsfw}",
      label: "any-nsfw",
      description: "One random generator, including NSFW (adult mode only).",
    },
  ],
});

// The virtual "special" category (priority 9000 → trails the picker): engine controls (the seed-salt)
// that aren't drawn from any list or generator.
const specialGroup = () => ({
  priority: 9000,
  name: "special",
  pill: {
    category: true,
    label: "special",
    description: "Engine controls that aren't drawn from any list or generator.",
  },
  entries: [
    {
      token: "{salt}",
      label: "salt",
      description:
        "Inject a random seed-salt number — nudges the result without changing the prompt.",
    },
  ],
});

/**
 * Build the categorized building-block groups (Blocks = the generators, Lists = the word lists).
 *
 * Within Blocks, the category/folder pills are ordered by each category's sidecar `priority`
 * (ascending; default 1000), with the virtual `any` (0) leading and `special` (9000) trailing.
 * @param {object} loader The content loader (nodeLoader / runtimeLoader / metroLoader).
 * @param {object} [opts]
 * @param {boolean} [opts.includeAdult] When false (default), NSFW generators are hidden entirely and
 *   any category that empties out is dropped.
 * @returns {object[]} The Blocks + Lists groups.
 */
export function buildBlocks(loader, opts = {}) {
  const includeAdult = opts.includeAdult === true;

  // --- Catalog derivations, read live from the loader (so edits/refresh are reflected) ---
  const allDynNames = loader.blockNames();
  const forcedDirs = loader.blockForcedPrefixDirs();
  const groupSet = new Set(loader.blockGroupDirs());
  const btnNames = computeButtonNames(allDynNames, forcedDirs);

  // Tooltip text for a generator: prefer the `.dpl` front-matter `description:`, falling back to the
  // optional `.json` sidecar.
  const dpDescFor = (key) => {
    const mod = loader.loadBlock(key);
    return mod?.meta?.description || loader.readBlockMeta(key)?.description || undefined;
  };
  // A generator is adult (hard-hidden when NSFW is off) when its sidecar carries `nsfw: true` or its
  // name carries an `nsfw` token — the same predicate the engine gates on.
  const isNsfwKey = (key) => loader.readBlockMeta(key)?.nsfw === true || isGatedBlock(key);

  // Folder-grouped category descriptors for a set of generator keys.
  const dynCatGroups = (keys) => {
    const visible = includeAdult ? keys : keys.filter((k) => !isNsfwKey(k));
    const byFolder = new Map();
    for (const k of visible) {
      const i = k.lastIndexOf("/");
      const folder = i < 0 ? "" : k.slice(0, i);
      if (!byFolder.has(folder)) byFolder.set(folder, []);
      byFolder.get(folder).push(k);
    }
    return [...byFolder.entries()].map(([folder, members]) => {
      const meta = loader.readBlockMeta(folder) || {};
      const pill = { category: true, label: lastSeg(folder), description: dpDescFor(folder) };
      if (groupSet.has(folder)) pill.token = `{#${lastSeg(folder)}}`;
      return {
        priority: typeof meta.priority === "number" ? meta.priority : DEFAULT_CAT_PRIORITY,
        name: lastSeg(folder),
        pill,
        entries: members
          .map((k) => ({
            token: `{#${btnNames[k]}}`,
            label: btnNames[k],
            description: dpDescFor(k),
          }))
          .sort((a, b) => compareNames(a.label, b.label)),
      };
    });
  };

  // Shortest unambiguous display token per list.
  const listDisplay = computeButtonNames(loader.listNames(), loader.forcedPrefixDirs());
  const descFor = (n) =>
    (loader.readListMeta(n) || loader.readListMeta(`${n}-sfw`) || null)?.description;
  const forceListFor = (folder) => {
    const m = loader.readListMeta(folder) || loader.readListMeta(`${folder}-sfw`) || null;
    return m?.forceList === true;
  };

  // Build the Lists block as folder categories.
  const listItems = () => {
    const names = loader.listNames();
    const groupDirs = new Set(loader.groupListDirs());
    const byFolder = new Map();
    for (const n of names) {
      if (groupDirs.has(n)) continue; // folder-group names become pills, not entries
      const i = n.lastIndexOf("/");
      const folder = i < 0 ? "" : n.slice(0, i);
      if (!byFolder.has(folder)) byFolder.set(folder, []);
      byFolder.get(folder).push(n);
    }
    const cats = [];
    for (const [folder, members] of byFolder) {
      cats.push({
        label: lastSeg(folder),
        token: groupDirs.has(folder) ? `{${listDisplay[folder]}}` : null,
        description: descFor(folder),
        forceList: forceListFor(folder),
        entries: members
          .map((n) => ({
            token: `{${listDisplay[n]}}`,
            label: listDisplay[n],
            description: descFor(n),
          }))
          .sort((a, b) => a.label.localeCompare(b.label)),
      });
    }
    // The reserved `keyword` wildcard isn't a folder/file — give it its own category.
    cats.push({
      label: "keyword",
      token: "{keyword}",
      description: "A random word drawn from ALL loaded vocabulary (every list).",
      entries: [
        { token: "{keyword-sfw}", label: "keyword-sfw", description: "All vocabulary, SFW only." },
        {
          token: "{keyword-nsfw}",
          label: "keyword-nsfw",
          description: "All vocabulary, including NSFW (adult mode only).",
        },
      ],
    });
    cats.sort((a, b) => a.label.localeCompare(b.label));
    const out = [];
    for (const c of cats) {
      const pill = { category: true, label: c.label, description: c.description };
      if (c.token) pill.token = c.token;
      if (c.forceList) pill.forceList = true;
      out.push(pill, ...c.entries);
    }
    return out;
  };

  const dynKeys = allDynNames.filter((n) => !isExpansionKey(n));
  const blockItems = [anyGroup(), ...dynCatGroups(dynKeys), specialGroup()]
    .filter((g) => g.entries.length > 0)
    .sort((a, b) => a.priority - b.priority || compareNames(a.name, b.name))
    .flatMap((g) => [g.pill, ...g.entries]);

  // With adult off, drop any explicitly-NSFW button and prune an emptied category header.
  const dropNsfw = (items) => {
    if (includeAdult) return items;
    const kept = items.filter((i) => i.category || !/nsfw/i.test(i.token || ""));
    return kept.filter((it, k) => !it.category || (kept[k + 1] && !kept[k + 1].category));
  };

  return [
    {
      title: "Blocks",
      hint: "Every building block — scenes, subjects, fragments, and styles.",
      items: dropNsfw(blockItems),
    },
    {
      title: "Lists",
      hint: "Word lists — each insertion becomes one random entry from the list.",
      items: dropNsfw(listItems()),
    },
  ];
}

/**
 * Flatten the building-block catalog into autocomplete entries for the DPL editor.
 * @param {object} loader The content loader.
 * @param {object} [opts] Same options as {@link buildBlocks}.
 * @returns {Array<{token: string, label: string, kind: ("gen"|"list"), description: (string|undefined), group: string, category: string}>}
 *   The completion entries.
 */
export function buildDplCompletions(loader, opts = {}) {
  const out = [];
  const seen = new Set();
  for (const b of buildBlocks(loader, opts)) {
    let category = b.title;
    for (const it of b.items) {
      if (it.category) category = it.label || b.title;
      if (!it.token || seen.has(it.token)) continue;
      seen.add(it.token);
      out.push({
        token: it.token,
        label: it.label,
        kind: it.token.startsWith("{#") ? "gen" : "list",
        description: it.description,
        group: b.title,
        category,
      });
    }
  }
  return out;
}