tmp/webapp-docs/frontend/components/ManageBlockEditor.js

/**
 * The block (generator) editor for the Manage tab. Edits a `.dpl` generator's contents, name, and
 * description, with NSFW gating, plus full JS-sidecar support: if a `.js` sidecar exists it offers a
 * DPL ⇄ JS tab switch; if not, a one-click "Create JS sidecar" scaffolds one from boilerplate. Saving
 * writes the files and hot-applies (`onChanged` refreshes the catalog) — except that an edited JS
 * *module body* only takes effect on reload (running fetched JS would need eval), which the JS tab
 * flags. The `.dpl` and everything else apply live.
 * @module gui/components/ManageBlockEditor
 */
import { useEffect, useRef, useState } from "react";
import { useIntl, defineMessages } from "react-intl";
import { javascript } from "@codemirror/lang-javascript";
import { readFile, writeFile, saveSidecar, fsOp } from "../lib/manageApi.js";
import { hasNsfwToken } from "../../../../engine/gatedLists.js";
import { rewritePrompt } from "../lib/rewrite.js";
import { effectiveKey } from "../lib/sessionKeys.js";
import { getProvider } from "../lib/providers/index.js";
import { cleanDplOutput, buildCustomPrompt, DPL_CREATE_MODE, DPL_CUSTOM_MODE } from "../lib/dpl/dplRefine.js";
import { m as rf } from "../lib/dpl/dplRefineMessages.js";
import { validateDpl } from "../lib/dpl/validateDpl.js";
import DplEditor from "./DplEditor.jsx";
import DplInsertBar from "./DplInsertBar.jsx";
import DplRefineBar from "./DplRefineBar.jsx";
import DplAskCorner from "./DplAskCorner.jsx";
import DplStatus from "./DplStatus.jsx";
import CodeEditor from "./CodeEditor.jsx";
import { jsxDEV as _jsxDEV, Fragment as _Fragment } from "react/jsx-dev-runtime";
const JS_BOILERPLATE = `/**
 * JS sidecar for this generator. Return a string to inject (or "" to contribute nothing).
 * The .dpl calls it via \`script:\`, \`{js:}\`, or \`insert js:\`.
 */
export default function (settings, imageSettings, upscaleSettings) {
  return "";
}

// export const suggestion_exclude = true; // keep this generator out of random suggestions
`;
const msgs = defineMessages({
  genName: {
    id: "blockEd.genName",
    defaultMessage: "Generator name"
  },
  block: {
    id: "blockEd.block",
    defaultMessage: "block"
  },
  rename: {
    id: "blockEd.rename",
    defaultMessage: "Rename"
  },
  saving: {
    id: "blockEd.saving",
    defaultMessage: "Saving…"
  },
  save: {
    id: "blockEd.save",
    defaultMessage: "Save"
  },
  savedJs: {
    id: "blockEd.savedJs",
    defaultMessage: "Saved — reload the app to run the changed JS."
  },
  saved: {
    id: "blockEd.saved",
    defaultMessage: "Saved."
  },
  renamed: {
    id: "blockEd.renamed",
    defaultMessage: "Renamed."
  },
  description: {
    id: "blockEd.description",
    defaultMessage: "Description"
  },
  descriptionPh: {
    id: "blockEd.descriptionPh",
    defaultMessage: "Editor tooltip for this block"
  },
  nsfwFromNameTitle: {
    id: "blockEd.nsfwFromNameTitle",
    defaultMessage: "This name contains an nsfw token, so it is always NSFW."
  },
  nsfwDisabledTitle: {
    id: "blockEd.nsfwDisabledTitle",
    defaultMessage: "NSFW option only available in NSFW mode."
  },
  nsfwMarkTitle: {
    id: "blockEd.nsfwMarkTitle",
    defaultMessage: "Mark this block as NSFW (gated behind adult mode)."
  },
  nsfwLabel: {
    id: "blockEd.nsfwLabel",
    defaultMessage: "NSFW"
  },
  nsfwLabelFromName: {
    id: "blockEd.nsfwLabelFromName",
    defaultMessage: "NSFW (from name)"
  },
  tabDpl: {
    id: "blockEd.tabDpl",
    defaultMessage: "DPL"
  },
  tabJs: {
    id: "blockEd.tabJs",
    defaultMessage: "JS sidecar"
  },
  ariaDpl: {
    id: "blockEd.ariaDpl",
    defaultMessage: "Generator DPL"
  },
  ariaJs: {
    id: "blockEd.ariaJs",
    defaultMessage: "Generator JS sidecar"
  },
  createJs: {
    id: "blockEd.createJs",
    defaultMessage: "+ Create JS sidecar"
  },
  jsNote: {
    id: "blockEd.jsNote",
    defaultMessage: "JS runs on reload — DPL and the rest hot-apply."
  },
  override: {
    id: "blockEd.override",
    defaultMessage: "Create override"
  },
  overrideTitle: {
    id: "blockEd.overrideTitle",
    defaultMessage: "Copy this built-in block into your overlay so you can edit it safely — your copy wins and survives app updates; the original stays untouched."
  },
  loading: {
    id: "blockEd.loading",
    defaultMessage: "Loading…"
  }
});

/**
 * @param {object} props
 * @param {object} props.entry The selected generator entry `{ root, path, ext, label, hasJsSidecar }`.
 * @param {object} props.settings App settings (DPL example expansion + `includeAdult` gating).
 * @param {Function} props.onChanged Called after a save/rename to hot-apply + refresh the tree.
 *   Receives the new logical path on a rename.
 * @returns {JSX.Element}
 */
export default function ManageBlockEditor({
  entry,
  settings,
  onChanged,
  onOverride
}) {
  const intl = useIntl();
  const base = entry.path; // logical key, no extension
  const folder = base.includes("/") ? base.slice(0, base.lastIndexOf("/")) : "";
  const nameNsfw = hasNsfwToken(base);
  const isBuiltIn = !String(entry.root).startsWith("user-"); // built-ins can be overridden into user/

  const [loading, setLoading] = useState(true);
  const [error, setError] = useState("");
  const [status, setStatus] = useState("");
  const [saving, setSaving] = useState(false);
  const [dplText, setDplText] = useState(entry.ext === "js" ? null : "");
  const [jsText, setJsText] = useState(null); // null = no sidecar
  const [tab, setTab] = useState(entry.ext === "js" ? "js" : "dpl");
  const [name, setName] = useState(entry.label);
  const [description, setDescription] = useState("");
  const [nsfwFlag, setNsfwFlag] = useState(false);
  const [dirty, setDirty] = useState(false);
  const [refineBusy, setRefineBusy] = useState(""); // active DPL-refine mode, or "" when idle
  const [canUndo, setCanUndo] = useState(false); // a refine can be reverted to its snapshot
  const preRefine = useRef(null); // the DPL text captured just before the last refine
  const jsTouched = useRef(false);
  const dplEditorRef = useRef(null);

  // (Re)load whenever the selected generator changes.
  useEffect(() => {
    let alive = true;
    setLoading(true);
    setError("");
    setStatus("");
    setDirty(false);
    setRefineBusy("");
    setCanUndo(false);
    preRefine.current = null;
    jsTouched.current = false;
    setName(entry.label);
    setTab(entry.ext === "js" ? "js" : "dpl");
    (async () => {
      try {
        const dpl = entry.ext === "js" ? null : await readFile(entry.root, `${base}.dpl`);
        let js = null;
        if (entry.hasJsSidecar || entry.ext === "js") {
          try {
            js = await readFile(entry.root, `${base}.js`);
          } catch {
            js = null;
          }
        }
        let meta = {};
        try {
          meta = JSON.parse(await readFile(entry.root, `${base}.json`));
        } catch {
          meta = {};
        }
        if (!alive) return;
        setDplText(dpl);
        setJsText(js);
        setDescription(meta.description || "");
        setNsfwFlag(meta.nsfw === true || nameNsfw);
      } catch (e) {
        if (alive) setError(e.message || String(e));
      } finally {
        if (alive) setLoading(false);
      }
    })();
    return () => {
      alive = false;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [entry.root, entry.path]);
  const includeAdult = settings.includeAdult === true;
  function createJsSidecar() {
    setJsText(JS_BOILERPLATE);
    jsTouched.current = true;
    setTab("js");
    setDirty(true);
  }

  // Resolve the text (rewrite) provider + key for a DPL refine, mirroring the list editor's AI Expand.
  // Sets a friendly error and returns null when no provider is chosen or its key is missing.
  function resolveRewrite() {
    const providerId = settings.rewriteProvider;
    if (!providerId || providerId === "none") {
      setStatus("");
      setError(intl.formatMessage(rf.pickProvider));
      return null;
    }
    const key = effectiveKey(providerId, settings);
    const provider = getProvider(providerId);
    if (provider?.needsKey && !key) {
      setStatus("");
      setError(intl.formatMessage(rf.noKey, {
        provider: provider?.label || providerId
      }));
      return null;
    }
    return {
      providerId,
      key
    };
  }

  // Run one DPL refine / create / custom-modify through the text provider. `prompt` is the current
  // template (refine), the typed description (create), or the instruction+template message (custom).
  // The result replaces the editor content — stashed first so it can be reverted — and is validated so
  // the status can flag any issues to review. Not saved until Save. `kind` picks the status wording.
  async function runDpl(mode, prompt, {
    label,
    kind = "refine"
  } = {}) {
    if (!(prompt || "").trim()) {
      setStatus("");
      const needMsg = kind === "create" ? rf.needDescription : kind === "custom" ? rf.needInstruction : rf.needContent;
      setError(intl.formatMessage(needMsg));
      return;
    }
    const resolved = resolveRewrite();
    if (!resolved) return;
    setError("");
    setStatus("");
    setRefineBusy(mode);
    try {
      const out = await rewritePrompt({
        providerId: resolved.providerId,
        prompt,
        key: resolved.key,
        mode
      });
      const cleaned = cleanDplOutput(out);
      if (!cleaned) {
        setError(intl.formatMessage(rf.empty));
        return;
      }
      preRefine.current = dplText ?? "";
      setDplText(cleaned);
      setDirty(true);
      setCanUndo(true);
      const issues = validateDpl(cleaned, intl).filter(d => d.severity === "error").length;
      if (kind === "create") setStatus(intl.formatMessage(rf.drafted));else if (kind === "custom") setStatus(intl.formatMessage(issues ? rf.modifiedIssues : rf.modified, {
        count: issues
      }));else if (issues) setStatus(intl.formatMessage(rf.appliedIssues, {
        label,
        count: issues
      }));else setStatus(intl.formatMessage(rf.applied, {
        label
      }));
    } catch (e) {
      setError(intl.formatMessage(rf.failed, {
        error: e.message || String(e)
      }));
    } finally {
      setRefineBusy("");
    }
  }
  const handleRefine = action => runDpl(action.mode, dplText, {
    label: action.label
  });
  const handleCreate = desc => runDpl(DPL_CREATE_MODE, desc, {
    kind: "create"
  });

  // Free-text modify: needs both an instruction and a template to work on. Compose them into the
  // message the `dpl-custom` system prompt expects, then run it like any other refine.
  function handleCustom(instruction) {
    if (!(instruction || "").trim()) {
      setStatus("");
      setError(intl.formatMessage(rf.needInstruction));
      return;
    }
    if (!(dplText || "").trim()) {
      setStatus("");
      setError(intl.formatMessage(rf.needContent));
      return;
    }
    runDpl(DPL_CUSTOM_MODE, buildCustomPrompt(instruction, dplText), {
      kind: "custom"
    });
  }
  function undoRefine() {
    if (preRefine.current == null) return;
    setDplText(preRefine.current);
    setDirty(true);
    setCanUndo(false);
    preRefine.current = null;
    setError("");
    setStatus(intl.formatMessage(rf.reverted));
  }
  async function save() {
    setSaving(true);
    setError("");
    setStatus("");
    try {
      if (dplText !== null) await writeFile(entry.root, `${base}.dpl`, dplText);
      if (jsText !== null) await writeFile(entry.root, `${base}.js`, jsText);
      await saveSidecar(entry.root, base, {
        description: description.trim() || null,
        // Only persist an explicit nsfw flag when the name doesn't already carry the token.
        nsfw: !nameNsfw && nsfwFlag ? true : null
      });
      setDirty(false);
      setStatus(intl.formatMessage(jsTouched.current ? msgs.savedJs : msgs.saved));
      await onChanged?.();
    } catch (e) {
      setError(e.message || String(e));
    } finally {
      setSaving(false);
    }
  }
  async function rename() {
    const clean = name.trim().replace(/\.(dpl|js|json)$/i, "");
    if (!clean || clean === entry.label) return;
    const target = folder ? `${folder}/${clean}` : clean;
    setSaving(true);
    setError("");
    try {
      if (dplText !== null) await fsOp("move", {
        root: entry.root,
        path: `${base}.dpl`,
        to: `${target}.dpl`
      });
      if (jsText !== null) await fsOp("move", {
        root: entry.root,
        path: `${base}.js`,
        to: `${target}.js`
      });
      try {
        await fsOp("move", {
          root: entry.root,
          path: `${base}.json`,
          to: `${target}.json`
        });
      } catch {
        // no sidecar to move — fine
      }
      setStatus(intl.formatMessage(msgs.renamed));
      await onChanged?.({
        ...entry,
        path: target,
        label: clean
      });
    } catch (e) {
      setError(e.message || String(e));
    } finally {
      setSaving(false);
    }
  }
  if (loading) return /*#__PURE__*/_jsxDEV("section", {
    className: "card mg-detail",
    children: /*#__PURE__*/_jsxDEV("p", {
      className: "empty",
      children: intl.formatMessage(msgs.loading)
    }, void 0, false)
  }, void 0, false);
  const hasJs = jsText !== null;
  const showDpl = dplText !== null;
  return /*#__PURE__*/_jsxDEV("section", {
    className: "card mg-detail mg-editor",
    children: [/*#__PURE__*/_jsxDEV("div", {
      className: "mg-editor-head",
      children: [/*#__PURE__*/_jsxDEV("input", {
        className: "mg-name-input",
        value: name,
        onChange: e => setName(e.target.value),
        "aria-label": intl.formatMessage(msgs.genName)
      }, void 0, false), /*#__PURE__*/_jsxDEV("span", {
        className: "mg-kind kind-generator",
        children: intl.formatMessage(msgs.block)
      }, void 0, false), /*#__PURE__*/_jsxDEV("button", {
        className: "link-btn",
        onClick: rename,
        disabled: saving || name.trim() === entry.label,
        children: intl.formatMessage(msgs.rename)
      }, void 0, false), /*#__PURE__*/_jsxDEV("div", {
        className: "grow"
      }, void 0, false), tab === "dpl" && dplText !== null && /*#__PURE__*/_jsxDEV(DplStatus, {
        value: dplText,
        className: "mg-status"
      }, void 0, false), /*#__PURE__*/_jsxDEV("button", {
        className: "primary",
        onClick: save,
        disabled: saving || !dirty && !jsTouched.current,
        children: intl.formatMessage(saving ? msgs.saving : msgs.save)
      }, void 0, false)]
    }, void 0, true), /*#__PURE__*/_jsxDEV("label", {
      className: "mg-field",
      children: [/*#__PURE__*/_jsxDEV("span", {
        children: intl.formatMessage(msgs.description)
      }, void 0, false), /*#__PURE__*/_jsxDEV("input", {
        value: description,
        onChange: e => {
          setDescription(e.target.value);
          setDirty(true);
        },
        placeholder: intl.formatMessage(msgs.descriptionPh)
      }, void 0, false)]
    }, void 0, true), /*#__PURE__*/_jsxDEV("label", {
      className: `mg-field mg-nsfw-field${!includeAdult || nameNsfw ? " is-disabled" : ""}`,
      children: [/*#__PURE__*/_jsxDEV("input", {
        type: "checkbox",
        checked: nsfwFlag,
        disabled: !includeAdult || nameNsfw,
        onChange: e => {
          setNsfwFlag(e.target.checked);
          setDirty(true);
        }
      }, void 0, false), /*#__PURE__*/_jsxDEV("span", {
        title: intl.formatMessage(nameNsfw ? msgs.nsfwFromNameTitle : !includeAdult ? msgs.nsfwDisabledTitle : msgs.nsfwMarkTitle),
        children: intl.formatMessage(nameNsfw ? msgs.nsfwLabelFromName : msgs.nsfwLabel)
      }, void 0, false)]
    }, void 0, true), showDpl && hasJs && /*#__PURE__*/_jsxDEV("div", {
      className: "mg-tabs",
      role: "tablist",
      children: [/*#__PURE__*/_jsxDEV("button", {
        role: "tab",
        "aria-selected": tab === "dpl",
        className: `mg-tab${tab === "dpl" ? " on" : ""}`,
        onClick: () => setTab("dpl"),
        children: intl.formatMessage(msgs.tabDpl)
      }, void 0, false), /*#__PURE__*/_jsxDEV("button", {
        role: "tab",
        "aria-selected": tab === "js",
        className: `mg-tab${tab === "js" ? " on" : ""}`,
        onClick: () => setTab("js"),
        children: intl.formatMessage(msgs.tabJs)
      }, void 0, false)]
    }, void 0, true), showDpl && tab === "dpl" && /*#__PURE__*/_jsxDEV(_Fragment, {
      children: [/*#__PURE__*/_jsxDEV(DplInsertBar, {
        editorRef: dplEditorRef,
        settings: settings
      }, void 0, false), /*#__PURE__*/_jsxDEV(DplRefineBar, {
        busyMode: refineBusy,
        disabled: saving,
        onRefine: handleRefine
      }, void 0, false)]
    }, void 0, true), /*#__PURE__*/_jsxDEV("div", {
      className: "mg-editor-body",
      children: [showDpl && tab === "dpl" && /*#__PURE__*/_jsxDEV("div", {
        className: "dpl-editor-wrap",
        children: [/*#__PURE__*/_jsxDEV(DplEditor, {
          ref: dplEditorRef,
          value: dplText,
          onChange: v => {
            setDplText(v);
            setDirty(true);
            setCanUndo(false); // a manual edit supersedes the last refine's revert point
          },
          settings: settings,
          className: "mg-cm",
          ariaLabel: intl.formatMessage(msgs.ariaDpl)
        }, void 0, false), /*#__PURE__*/_jsxDEV(DplAskCorner, {
          busyMode: refineBusy,
          disabled: saving,
          onCreate: handleCreate,
          onCustom: handleCustom
        }, void 0, false)]
      }, void 0, true), hasJs && tab === "js" && /*#__PURE__*/_jsxDEV(CodeEditor, {
        value: jsText,
        onChange: v => {
          setJsText(v);
          jsTouched.current = true;
          setDirty(true);
        },
        language: javascript,
        className: "mg-cm",
        ariaLabel: intl.formatMessage(msgs.ariaJs)
      }, void 0, false)]
    }, void 0, true), /*#__PURE__*/_jsxDEV("div", {
      className: "mg-editor-foot",
      children: [isBuiltIn && onOverride && /*#__PURE__*/_jsxDEV("button", {
        className: "link-btn",
        onClick: () => onOverride(entry),
        disabled: saving || Boolean(refineBusy),
        title: intl.formatMessage(msgs.overrideTitle),
        children: intl.formatMessage(msgs.override)
      }, void 0, false), !hasJs && /*#__PURE__*/_jsxDEV("button", {
        className: "link-btn",
        onClick: createJsSidecar,
        children: intl.formatMessage(msgs.createJs)
      }, void 0, false), canUndo && /*#__PURE__*/_jsxDEV("button", {
        className: "link-btn",
        onClick: undoRefine,
        disabled: saving || Boolean(refineBusy),
        children: intl.formatMessage(rf.undo)
      }, void 0, false), hasJs && /*#__PURE__*/_jsxDEV("span", {
        className: "mg-note",
        children: intl.formatMessage(msgs.jsNote)
      }, void 0, false), status && /*#__PURE__*/_jsxDEV("span", {
        className: "mg-ok",
        children: status
      }, void 0, false), error && /*#__PURE__*/_jsxDEV("span", {
        className: "error",
        children: error
      }, void 0, false)]
    }, void 0, true)]
  }, void 0, true);
}