☰

JanitorAI Lorebook Exporter

Export JanitorAI lorebooks as SillyTavern World Info JSON.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         JanitorAI Lorebook Exporter
// @namespace    https://sleazyfork.org/en/scripts/600889-janitorai-lorebook-exporter
// @version      0.2.2
// @description  Export JanitorAI lorebooks as SillyTavern World Info JSON.
// @match        https://janitorai.com/*
// @icon         https://ella.janitorai.com/hotlink-ok/favicon-32x32.png
// @grant        none
// @noframes
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  const API = "/hampter";

  /* ------------------------------------------------------------------ */
  /* Conversion                                                          */
  /* ------------------------------------------------------------------ */

  // JanitorAI stores lorebook content as a JSON array, or an object keyed "0","1",...
  function parseEntries(raw) {
    const data = typeof raw === "string" ? JSON.parse(raw) : raw;
    if (Array.isArray(data)) return data.filter((e) => e && typeof e === "object");
    if (data && typeof data === "object") {
      const src = data.entries && typeof data.entries === "object" ? data.entries : data;
      return Object.keys(src)
        .sort((a, b) => (parseInt(a, 10) - parseInt(b, 10)) || a.localeCompare(b))
        .map((k) => src[k])
        .filter((e) => e && typeof e === "object");
    }
    return [];
  }

  function toList(v) {
    if (Array.isArray(v)) return v.map((s) => String(s).trim()).filter(Boolean);
    if (typeof v === "string") return v.split(",").map((s) => s.trim()).filter(Boolean);
    return [];
  }

  // "Jessie's Room" -> "Jessies_Room". Falls back to the first key, then "Entry_N".
  function tagName(e, keys, i) {
    const raw = e.name || e.comment || e.title || keys[0] || "";
    let tag = String(raw)
      .normalize("NFKD")
      .replace(/[\u0300-\u036f]/g, "")
      .replace(/['’`"]/g, "")
      .replace(/[^A-Za-z0-9_.-]+/g, "_")
      .replace(/^_+|_+$/g, "");
    if (!tag) tag = `Entry_${i + 1}`;
    if (!/^[A-Za-z_]/.test(tag)) tag = "_" + tag;
    return tag;
  }

  function wrapInTag(content, tag) {
    const body = String(content ?? "").trim();
    return `<${tag}>\n${body}\n</${tag}>`;
  }

  // SillyTavern-side validators reject floats/strings for numeric fields, so always emit integers.
  function toInt(v, fallback, min = -Infinity, max = Infinity) {
    const n = Math.round(Number(v));
    if (v === "" || v === null || v === undefined || !Number.isFinite(n)) return fallback;
    return Math.min(max, Math.max(min, n));
  }

  function convertEntry(e, i) {
    const keys = toList(e.key ?? e.keys ?? e.keywords ?? e.keysRaw);
    const secondary = toList(e.keysecondary ?? e.secondaryKeys ?? e.secondary_keys ?? e.keysecondaryRaw);
    const group = e.inclusionGroup ?? e.group ?? e.inclusionGroupRaw ?? "";
    const probability = toInt(e.probability, 100, 0, 100);
    const order = toInt(e.insertion_order ?? e.order, 100 * (i + 1));
    const tag = tagName(e, keys, i);
    return {
      uid: i,
      key: keys,
      keysecondary: secondary,
      comment: e.name || e.comment || e.title || tag,
      content: wrapInTag(e.content, tag),
      constant: !!e.constant,
      vectorized: false,
      selective: secondary.length > 0,
      selectiveLogic: [0, 1, 2, 3].includes(Number(e.selectiveLogic)) ? Number(e.selectiveLogic) : 0,
      addMemo: true,
      order,
      position: e.placementPosition === "before" ? 0 : 1,
      disable: e.enabled === false,
      excludeRecursion: false,
      preventRecursion: false,
      delayUntilRecursion: false,
      probability,
      useProbability: probability < 100,
      depth: 4,
      group: Array.isArray(group) ? group.join(", ") : String(group || ""),
      groupOverride: false,
      groupWeight: toInt(e.groupWeight, 100, 0),
      scanDepth: null,
      caseSensitive: e.case_sensitive ?? null,
      matchWholeWords: e.matchWholeWords ?? null,
      useGroupScoring: null,
      automationId: "",
      role: null,
      sticky: 0,
      cooldown: 0,
      delay: toInt(e.minMessages, 0, 0),
      displayIndex: i,
    };
  }

  function convertLorebook(raw, name) {
    const entries = {};
    parseEntries(raw).forEach((e, i) => {
      entries[String(i)] = convertEntry(e, i);
    });
    return { name: name || "JanitorAI Lorebook", entries };
  }

  /* ------------------------------------------------------------------ */
  /* API                                                                 */
  /* ------------------------------------------------------------------ */

  async function api(path) {
    const res = await fetch(API + path, { credentials: "include" });
    if (!res.ok) {
      const err = new Error(`${res.status} ${path}`);
      err.status = res.status;
      throw err;
    }
    const text = await res.text();
    return text ? JSON.parse(text) : null;
  }

  function currentCharacterId() {
    const m = location.pathname.match(/\/characters\/([0-9a-f-]{36})/i);
    return m ? m[1] : null;
  }

  function currentScriptId() {
    const m = location.pathname.match(/\/scripts\/([0-9a-f-]{36})/i);
    return m ? m[1] : null;
  }

  // Lorebooks attached to a character. character.scripts is not always populated for
  // anonymous viewers, so also scan the creator's public scripts for ones that list the character.
  async function findCharacterLorebooks(characterId) {
    const found = new Map();
    const character = await api(`/characters/${characterId}`);
    for (const s of character.scripts || []) {
      if (s && s.type === "lorebook") found.set(s.id, s);
    }
    if (character.creator_id) {
      for (let page = 1; page <= 20; page++) {
        let list;
        try {
          list = await api(`/script/user/${character.creator_id}?page=${page}`);
        } catch (_) {
          break;
        }
        const scripts = list?.scripts || [];
        for (const s of scripts) {
          if (s.type === "lorebook" && (s.characters || []).some((c) => c.id === characterId)) {
            found.set(s.id, { ...(found.get(s.id) || {}), ...s });
          }
        }
        if (scripts.length === 0 || page * scripts.length >= (list.total ?? 0)) break;
      }
    }
    return { character, scripts: [...found.values()] };
  }

  // Returns { raw, source } or throws if the code isn't readable.
  async function fetchLorebookCode(scriptId) {
    const meta = await api(`/script/${scriptId}`);
    if (meta.script && meta.script.trim()) return { meta, raw: meta.script };
    for (const type of ["published", "draft"]) {
      try {
        const c = await api(`/script/${scriptId}/content?type=${type}`);
        const raw = typeof c === "string" ? c : c?.content;
        if (raw && String(raw).trim()) return { meta, raw };
      } catch (_) {
        /* try next */
      }
    }
    const err = new Error("code-private");
    err.meta = meta;
    throw err;
  }

  /* ------------------------------------------------------------------ */
  /* UI                                                                  */
  /* ------------------------------------------------------------------ */

  function download(filename, obj) {
    const blob = new Blob([JSON.stringify(obj, null, 2)], { type: "application/json" });
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    a.remove();
    setTimeout(() => URL.revokeObjectURL(a.href), 5000);
  }

  const safeName = (s) => String(s || "lorebook").replace(/[^\w\-. ]+/g, "_").trim() || "lorebook";

  async function exportScript(scriptId, log) {
    try {
      const { meta, raw } = await fetchLorebookCode(scriptId);
      const book = convertLorebook(raw, meta.title);
      const n = Object.keys(book.entries).length;
      download(`${safeName(meta.title)}.worldinfo.json`, book);
      log(`✔ "${meta.title}": ${n} entries exported.`);
    } catch (e) {
      if (e.message === "code-private") {
        log(
          `✖ "${e.meta?.title}" has private code (creator disabled code sharing), so the entries are not available. ` +
            "Only the creator can export it."
        );
      } else {
        log(`✖ ${scriptId}: ${e.message}`);
      }
    }
  }

  async function run(log) {
    const characterId = currentCharacterId();
    const scriptId = currentScriptId();
    if (scriptId) return exportScript(scriptId, log);
    if (!characterId) return log("Open a character page or a lorebook (/scripts/…) page first.");
    log("Looking for lorebooks…");
    const { character, scripts } = await findCharacterLorebooks(characterId);
    if (!scripts.length) {
      return log(
        `No lorebook found for "${character.name}". It may have none, or be attached to an account-only/unlisted script.`
      );
    }
    log(`Found ${scripts.length} lorebook(s).`);
    for (const s of scripts) await exportScript(s.id, log);
  }

  function buildUI() {
    if (document.getElementById("jlx-root")) return;
    const root = document.createElement("div");
    root.id = "jlx-root";
    root.style.cssText =
      "position:fixed;right:16px;bottom:16px;z-index:99999;font:13px system-ui,sans-serif;color:#eee;";
    root.innerHTML = `
      <div id="jlx-panel" style="display:none;width:320px;margin-bottom:8px;padding:12px;border-radius:10px;background:#1f1f24;border:1px solid #444;box-shadow:0 4px 16px #0008">
        <div style="font-weight:600;margin-bottom:8px">Lorebook → SillyTavern</div>
        <button id="jlx-run" style="width:100%;padding:6px;margin-bottom:6px;cursor:pointer">Export lorebook(s) for this page</button>
        <label style="display:block;margin-bottom:6px">Or convert a JanitorAI lorebook JSON file:
          <input id="jlx-file" type="file" accept=".json,application/json" style="width:100%;margin-top:4px"></label>
        <pre id="jlx-log" style="margin:0;max-height:160px;overflow:auto;white-space:pre-wrap;font-size:12px"></pre>
      </div>
      <button id="jlx-toggle" title="Lorebook exporter" style="float:right;width:40px;height:40px;border-radius:50%;border:1px solid #555;background:#2b2b33;color:#eee;font-size:18px;cursor:pointer">📖</button>`;
    document.body.appendChild(root);

    const panel = root.querySelector("#jlx-panel");
    const logEl = root.querySelector("#jlx-log");
    const log = (m) => {
      logEl.textContent += m + "\n";
      logEl.scrollTop = logEl.scrollHeight;
    };
    root.querySelector("#jlx-toggle").onclick = () => {
      panel.style.display = panel.style.display === "none" ? "block" : "none";
    };
    root.querySelector("#jlx-run").onclick = () => {
      logEl.textContent = "";
      run(log).catch((e) => log("✖ " + e.message));
    };
    root.querySelector("#jlx-file").onchange = async (ev) => {
      const f = ev.target.files[0];
      if (!f) return;
      logEl.textContent = "";
      try {
        const name = f.name.replace(/\.json$/i, "");
        const book = convertLorebook(await f.text(), name);
        download(`${safeName(name)}.worldinfo.json`, book);
        log(`✔ ${Object.keys(book.entries).length} entries converted.`);
      } catch (e) {
        log("✖ " + e.message);
      }
      ev.target.value = "";
    };
  }

  /* ------------------------------------------------------------------ */
  /* Inline download buttons                                             */
  /* ------------------------------------------------------------------ */

  const BTN_ATTR = "data-jlx-btn";

  function makeInlineButton(label, onClick) {
    const b = document.createElement("button");
    b.type = "button";
    b.setAttribute(BTN_ATTR, "1");
    b.textContent = label;
    b.title = "Download as SillyTavern World Info (JSON)";
    b.style.cssText =
      "margin:0 6px;padding:4px 10px;border-radius:8px;border:1px solid #666;background:#2b2b33;color:#eee;font:600 12px system-ui,sans-serif;cursor:pointer;white-space:nowrap;";
    b.addEventListener("click", async (ev) => {
      ev.preventDefault();
      ev.stopPropagation();
      const old = b.textContent;
      b.disabled = true;
      b.textContent = "…";
      await onClick((m) => {
        if (m.startsWith("✖")) alert(m);
        console.log("[lorebook-exporter]", m);
      });
      b.disabled = false;
      b.textContent = old;
    });
    return b;
  }

  // /scripts/<id>: put the button next to the clone button.
  function injectScriptPageButton() {
    const id = currentScriptId();
    if (!id || document.querySelector(`[${BTN_ATTR}="page"]`)) return;
    const clone = [...document.querySelectorAll("button")].find((b) =>
      /clon/i.test((b.getAttribute("aria-label") || "") + " " + (b.title || ""))
    );
    if (!clone || !clone.parentElement) return;
    const row = clone.closest('[class*="heroActions"]') || clone.parentElement;
    let anchor = clone;
    while (anchor.parentElement && anchor.parentElement !== row) anchor = anchor.parentElement;
    const b = makeInlineButton("⬇ SillyTavern", (log) => exportScript(id, log));
    b.setAttribute(BTN_ATTR, "page");
    row.insertBefore(b, anchor);
  }

  // Character page: lorebook cards in the scripts section (matched to scripts by title).
  let cardScripts = { characterId: null, scripts: null, loading: false };

  async function injectCardButtons() {
    const characterId = currentCharacterId();
    if (!characterId) return;
    const cards = [...document.querySelectorAll('[class*="scriptsList"] > *')].filter(
      (c) => !c.querySelector(`[${BTN_ATTR}]`)
    );
    if (!cards.length) return;
    if (cardScripts.characterId !== characterId) cardScripts = { characterId, scripts: null, loading: false };
    if (!cardScripts.scripts) {
      if (cardScripts.loading) return;
      cardScripts.loading = true;
      try {
        cardScripts.scripts = (await findCharacterLorebooks(characterId)).scripts;
      } catch (_) {
        cardScripts.scripts = [];
      }
      cardScripts.loading = false;
    }
    for (const card of cards) {
      const title = card.querySelector('[class*="cardTitle"]')?.textContent.trim();
      const script = cardScripts.scripts.find((s) => (s.title || "").trim() === title);
      if (!script || card.querySelector(`[${BTN_ATTR}]`)) continue;
      const b = makeInlineButton("⬇ ST", (log) => exportScript(script.id, log));
      (card.querySelector('[class*="typeBadge"]')?.parentElement || card).appendChild(b);
    }
  }

  function refreshInline() {
    try {
      injectScriptPageButton();
      injectCardButtons();
    } catch (e) {
      console.warn("[lorebook-exporter]", e);
    }
  }

  if (typeof document !== "undefined" && typeof window !== "undefined" && !window.__JLX_TEST__) {
    buildUI();
    refreshInline();
    let timer = null;
    new MutationObserver(() => {
      clearTimeout(timer);
      timer = setTimeout(refreshInline, 300);
    }).observe(document.body, { childList: true, subtree: true });
  }
  if (typeof globalThis !== "undefined") globalThis.__JLX__ = { convertLorebook, parseEntries };
})();