JanitorAI – Dump chat to file

Adds "Dump chat to file" to the JanitorAI menu; exports full chat

// ==UserScript==
// @name         JanitorAI – Dump chat to file
// @namespace    gboy.jai.export
// @version      2.0.0
// @description  Adds "Dump chat to file" to the JanitorAI menu; exports full chat
// @author       Gboy
// @match        http*://janitorai.com/*
// @match        http*://www.janitorai.com/*
// @match        http*://*.janitorai.com/*
// @run-at       document-start
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  //SPA stuff idk it works
  const onRoute = (() => {
    const subs = new Set();
    const fire = () => subs.forEach(fn => { try { fn(); } catch (e) { console.error('[jai]', e); } });
    const wrap = k => {
      const orig = history[k];
      history[k] = function (...args) {
        const rv = orig.apply(this, args);
        window.dispatchEvent(new Event('jai:route'));
        return rv;
      };
    };
    wrap('pushState'); wrap('replaceState');
    window.addEventListener('popstate', () => window.dispatchEvent(new Event('jai:route')));
    window.addEventListener('jai:route', fire);
    return fn => subs.add(fn);
  })();

  const isChatPage = () => /\/chats\/\d+/.test(location.pathname);

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', boot);
  } else {
    boot();
  }
  onRoute(() => { if (isChatPage()) ensureMenuInjector(true); });

  function boot() {
    if (!isChatPage()) return;
    ensureMenuInjector();
    console.debug('[jai] userscript active', location.href);
  }
//insert to menu
  function ensureMenuInjector(force = false) {
    if (ensureMenuInjector._ok && !force) return;
    ensureMenuInjector._ok = true;
    const ICON_SVG = `
      <svg stroke="currentColor" fill="currentColor" viewBox="0 0 493.525 493.525" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
        <path d="M246.763,273.005c8.441,0,15.562-3.01,21.366-9.042l95.22-95.22c5.521-5.52,8.277-11.896,8.277-19.126
          c0-7.23-2.759-13.419-8.277-18.559c-5.331-5.14-11.516-7.708-18.555-7.708c-7.043,0-13.229,2.568-18.559,7.708l-47.391,47.391
          V18.271c0-7.043-2.52-13.229-7.566-18.559C266.231,0.381,260.189,0,252.858,0c-7.332,0-13.419,0.381-18.274,1.152
          c-4.855,0.762-9.137,2.952-12.847,6.567c-3.72,3.613-5.57,8.231-5.57,13.846v161.178l-47.391-47.391
          c-5.52-5.14-11.706-7.708-18.556-7.708c-7.043,0-13.229,2.568-18.559,7.708c-5.52,5.14-8.277,11.329-8.277,18.559
          c0,7.23,2.759,13.606,8.277,19.126l95.225,95.22C231.387,269.995,238.511,273.005,246.763,273.005z"/>
        <path d="M473.086,311.218c-13.033-12.936-29.215-19.406-48.537-19.406H68.97c-19.32,0-35.5,6.471-48.537,19.406
          C7.403,324.149,0,340.333,0,359.66v73.091c0,19.318,7.403,35.5,20.433,48.533c13.037,12.939,29.217,19.404,48.537,19.404h355.579
          c19.322,0,35.503-6.465,48.537-19.404c13.037-13.033,20.439-29.215,20.439-48.533V359.66
          C493.525,340.333,486.123,324.149,473.086,311.218z M430.518,432.751c0,5.492-2.054,10.543-6.169,15.153
          c-4.109,4.61-9.092,6.912-14.935,6.912H84.1c-5.851,0-10.833-2.302-14.949-6.912c-4.117-4.61-6.171-9.661-6.171-15.153v-63.333
          c0-5.478,2.054-10.521,6.171-15.133c4.116-4.61,9.099-6.916,14.949-6.916h325.313c5.843,0,10.826,2.305,14.935,6.916
          c4.115,4.612,6.169,9.655,6.169,15.133V432.751z"/>
      </svg>`.replace(/\s+/g, ' '); //just the icon

    const MENU_LIST_SEL = '._menuList_162rw_8._open_162rw_22, ._menuList_162rw_8._open_';
    const TEXT_STREAM_ROW_SEL = '._menuItemSwitch_hs488_94';
    const MENU_ITEM_CLASS = '_menuItem_162rw_45';

    const mo = new MutationObserver(() => {
      document.querySelectorAll(MENU_LIST_SEL).forEach(menu => {
        if (menu.__jai_dump_added) return;
        const textRow = menu.querySelector(TEXT_STREAM_ROW_SEL);
        if (!textRow) return;

        const btn = document.createElement('button');
        btn.type = 'button';
        btn.className = MENU_ITEM_CLASS;
        btn.style.display = 'flex';
        btn.style.width = '100%';
        btn.innerHTML = `
          <span class="_menuItemIcon_162rw_81">${ICON_SVG}</span>
          <span class="_menuItemContent_162rw_96">Dump chat to file</span>
        `;
        btn.addEventListener('click', () => exportChat().catch(err => {
          console.error('[jai] export error', err);
          alert('Export failed: ' + (err?.message || err));
        }));
        textRow.insertAdjacentElement('afterend', btn);
        menu.__jai_dump_added = true;
      });
    });
    mo.observe(document.body, { childList: true, subtree: true });
    window.__jai_menuMO = mo;
  }
//hud/userdisplay
  function hud() {
    if (hud.el) return hud;
    const el = document.createElement('div');
    el.style.cssText = `
      position:fixed; z-index:999999; right:8px; bottom:8px; font:12px/1.35 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
      background:#111C; color:#fff; padding:8px 10px; border:1px solid #444; border-radius:10px; backdrop-filter: blur(3px); max-width: 40vw; pointer-events:none;
      white-space:pre-wrap;
    `;
    document.documentElement.appendChild(el);
    hud.el = el;
    hud.tick = (obj) => { el.textContent = `[JAI Export]\n${Object.entries(obj).map(([k,v])=>`${k}: ${v}`).join('\n')}`; };
    hud.done = (msg='Done') => { el.textContent += `\n${msg}`; setTimeout(()=>el.remove(), 3000); hud.el = null; };
    return hud;
  }
//util funcs
  const sleep = ms => new Promise(r => setTimeout(r, ms));
  const sel = s => document.querySelector(s);
  const selAll = s => Array.from(document.querySelectorAll(s));

  function getScroller() {
    return document.querySelector('[data-testid="virtuoso-scroller"][data-virtuoso-scroller="true"]');
  }
  function getItemList() {
    return document.querySelector('[data-testid="virtuoso-item-list"]');
  }
  function getAllItems() {
    const list = getItemList();
    return list ? Array.from(list.querySelectorAll('[data-index][data-item-index]')) : [];
  }
  function filenameFromHeader() {
    const who = document.querySelector('._customDivider_7bh05_1 ._customDividerText_7bh05_26');
    const name = (who?.textContent?.trim() || 'JanitorAI');
    const id = location.pathname.split('/').pop() || 'chat';
    const stamp = new Date().toISOString().slice(0,19).replace(/[:T]/g,'-');
    return `${name}_${id}_${stamp}.txt`;
  }
//viewport
  function applyViewportBoost(scroller, { enableZoom = true, zoomScale = 0.8, heightVH = 500 } = {}) {
    const prev = {
      bodyZoom: document.body.style.zoom || '',
      scrHeight: scroller.style.height || '',
      htmlScrollBehavior: document.documentElement.style.scrollBehavior || '',
    };
    if (enableZoom) document.body.style.zoom = String(zoomScale);     // emulate Cmd/-
    scroller.style.height = `${heightVH}vh`;                           // load more items per sweep
    document.documentElement.style.scrollBehavior = 'auto';            // avoid “pendulum” at edges
    return () => {
      document.body.style.zoom = prev.bodyZoom;
      scroller.style.height = prev.scrHeight;
      document.documentElement.style.scrollBehavior = prev.htmlScrollBehavior;
    };
  }
//sweep
  async function sweepAll({ dir = 'down', stepPx = 1600, idleLimit = 12, stallJiggle = 3, maxMs = 180000 } = {}) {
    const H = hud(); H.tick({ phase: 'sweep init', dir, stepPx, idleLimit });
    const scr = getScroller();
    if (!scr) throw new Error('Scroller not found');
    const off = applyViewportBoost(scr, { enableZoom: true, zoomScale: 0.8, heightVH: 500 });
    const start = performance.now();

    let lastMax = -1, idle = 0, totalSteps = 0;
    const seen = new Set();

    const getMaxIndex = () => {
      let max = -1;
      getAllItems().forEach(it => {
        const idx = Number(it.getAttribute('data-index'));
        if (!Number.isNaN(idx)) max = Math.max(max, idx);
        if (it.dataset._jai_seen !== '1') {
          it.dataset._jai_seen = '1';
          seen.add(idx);
        }
      });
      return max;
    };

    await sleep(50);
    lastMax = getMaxIndex();

    while (true) {
      if ((performance.now() - start) > maxMs) { H.tick({ phase:'sweep timeout', totalSteps, maxIndex:lastMax, seen:seen.size }); break; }
      totalSteps++;
      const nextTop = scr.scrollTop + (dir === 'down' ? stepPx : -stepPx);
      scr.scrollTo({ top: Math.max(0, nextTop) });
      await sleep(50);

      const maxIdx = getMaxIndex();
      if (maxIdx > lastMax) { idle = 0; lastMax = maxIdx; }
      else {
        idle++;
        if (idle % stallJiggle === 0) { scr.scrollBy({ top: -300 }); await sleep(30); scr.scrollBy({ top: +600 }); await sleep(30); }
      }

      const atBottom = Math.abs(scr.scrollHeight - (scr.scrollTop + scr.clientHeight)) < 4;
      const atTop = scr.scrollTop <= 2;

      H.tick({ phase:'sweeping', step: totalSteps, maxIndex: lastMax, idle, jiggles: Math.floor(idle/stallJiggle), atBottom, atTop, seen: seen.size });

      if ((dir === 'down' && atBottom && idle >= idleLimit) || (dir === 'up' && atTop && idle >= idleLimit)) {
        H.tick({ phase:'sweep complete', steps: totalSteps, maxIndex:lastMax, seen: seen.size });
        break;
      }
    }

    off();
    return { steps: totalSteps, maxIndex: lastMax, distinct: seen.size };
  }

//extraction
  function extractMessages() {
    const items = getAllItems();
    const out = [];
    for (const it of items) {
      const body = it.querySelector('._messageBody_cj98i_56');
      if (!body) continue;

      const nameNode = body.querySelector('._nameContainer_prxth_282 ._nameText_prxth_288');
      const name = (nameNode?.textContent || 'Unknown').trim();

      const contentRoot = body.querySelector('.css-ji4crq');
      if (!contentRoot) continue;

      const chunks = [];
      contentRoot.querySelectorAll(':scope > .css-0 > div').forEach(div => {
        const t = div.textContent;
        if (t && t.trim().length) chunks.push(t.replace(/\u00A0/g, ' ').trim());
      });
      let text = chunks.join('\n\n').trim();
      if (!text) continue;

      const header = `[${name}]:`;
      out.push(`${header}\n${text}\n`);
    }
    return out;
  }
//export entry
  async function exportChat() {
    const H = hud(); H.tick({ phase: 'start' });

    await sweepAll({ dir:'down', stepPx: 1800, idleLimit: 10, stallJiggle: 3, maxMs: 180000 });
    await sleep(80);
    await sweepAll({ dir:'up', stepPx: 1400, idleLimit: 8, stallJiggle: 2, maxMs: 60000 });

    const msgs = extractMessages();
    if (!msgs.length) throw new Error('No messages extracted');

    const header = `URL: ${location.href}\nExported: ${new Date().toString()}\nMessages: ${msgs.length}\n---\n\n`;
    const blob = new Blob([header + msgs.join('\n')], { type: 'text/plain;charset=utf-8' });
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = filenameFromHeader();
    document.body.appendChild(a);
    a.click();
    setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 2000);
    H.done('Saved.');
  }

})();