Guest User

JanitorAI SillyTavern Importer & Exporter 2.7

a guest
Feb 11th, 2026
75
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
  1. // ==UserScript==
  2. // @name         JanitorAI SillyTavern Importer & Exporter
  3. // @namespace    http://tampermonkey.net/
  4. // @version      2.6
  5. // @description  Import/Export SillyTavern V2/Custom JSON & PNG characters on JanitorAI (create, edit, view pages). Handles WEBP/non-PNG images gracefully for edit page export.
  6. // @author       Minoa (https://bio.minoa.cat)
  7. // @match        https://janitorai.com/create_character
  8. // @match        https://janitorai.com/edit_character/*
  9. // @require      https://cdn.jsdelivr.net/npm/[email protected]/crc32.min.js
  10. // @grant        GM_addStyle
  11. // @grant        GM_notification
  12. // @grant        GM_xmlhttpRequest
  13. // @grant        GM_download
  14. // @connect      api.jannyai.com
  15. // @connect      *.janitorai.com
  16. // @connect      *
  17. // @license      MIT; https://opensource.org/licenses/MIT
  18. // @contributionURL https://bio.minoa.cat
  19. // ==/UserScript==
  20. /*
  21.  * Image Handling code structure inspired by Character Editor by Avakson:
  22.  * https://avakson.github.io/character-editor/
  23.  *
  24.  * Script Author: Minoa
  25.  * Website: https://bio.minoa.cat
  26.  */
  27.  
  28. (function ()
  29. {
  30.     'use strict';
  31.  
  32.     // --- Constants ---
  33.     const SCRIPT_VERSION = "2.6"; // Updated
  34.     const TAVERN_V2_SPEC = { spec: "chara_card_v2", spec_version: "2.0" };
  35.     const CUSTOM_TOOL_META = {
  36.         name: "JanitorAI Import & Exporter Tampermonkey Script By Minoa",
  37.         version: SCRIPT_VERSION,
  38.         url: "https://bio.minoa.cat"
  39.     };
  40.  
  41.     // --- CRC32 Logic ---
  42.     // Included via @require
  43.  
  44.     // --- PNG Parsing Logic (PngHelper object remains the same) ---
  45.     const PngHelper = {
  46.         _uint8: new Uint8Array(4), _int32: null, _uint32: null,
  47.         PNG_SIGNATURE: new Uint8Array([ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A ]),
  48.         initialize() { if (!this._int32) { this._int32 = new Int32Array(this._uint8.buffer); this._uint32 = new Uint32Array(this._uint8.buffer); } },
  49.         isPng(arrayBuffer) { if (!arrayBuffer || arrayBuffer.byteLength < 8) return false; const h = new Uint8Array(arrayBuffer.slice(0, 8)); return this.PNG_SIGNATURE.every((b, i) => h[ i ] === b); },
  50.         uint32ToBytes(num) { this.initialize(); this._uint32[ 0 ] = num; return new Uint8Array([ this._uint8[ 3 ], this._uint8[ 2 ], this._uint8[ 1 ], this._uint8[ 0 ] ]); },
  51.         bytesToUint32(bytes, offset = 0) { this.initialize(); this._uint8[ 3 ] = bytes[ offset ]; this._uint8[ 2 ] = bytes[ offset + 1 ]; this._uint8[ 1 ] = bytes[ offset + 2 ]; this._uint8[ 0 ] = bytes[ offset + 3 ]; return this._uint32[ 0 ]; },
  52.         decodeText(data) { let n = true, k = '', t = ''; for (let i = 0; i < data.length; i++) { const c = data[ i ]; if (n) { if (c) k += String.fromCharCode(c); else n = false; } else { if (c) t += String.fromCharCode(c); else console.warn('Null char in PNG tEXt data.'); } } return { keyword: k, text: t }; },
  53.         readChunk(data, idx) { this.initialize(); const l = this.bytesToUint32(data, idx); idx += 4; const typeBytes = data.slice(idx, idx + 4); const type = String.fromCharCode(...typeBytes); idx += 4; const chunkData = data.slice(idx, idx + l); idx += l; const crc = this.bytesToUint32(data, idx); idx += 4; const typeAndData = new Uint8Array(4 + chunkData.length); typeAndData.set(typeBytes, 0); typeAndData.set(chunkData, 4); const calcCrc = CRC32.buf(typeAndData); if (this.bytesToUint32(this.uint32ToBytes(crc)) !== this.bytesToUint32(this.uint32ToBytes(calcCrc))) { console.warn(`CRC mismatch: "${type}". Expected ${crc}, got ${calcCrc}.`); } return { type: type, data: chunkData, length: l, crc: crc, nextIndex: idx }; },
  54.         readChunks(data) { if (!this.isPng(data)) throw new Error('Invalid PNG header'); const chunks = []; let idx = 8; while (idx < data.byteLength) { try { const r = this.readChunk(data, idx); chunks.push({ type: r.type, data: r.data, length: r.length, crc: r.crc }); idx = r.nextIndex; if (r.type === 'IEND') break; } catch (e) { throw new Error(`Read chunk failed: ${e.message}`); } } if (chunks.length === 0 || chunks[ 0 ].type !== 'IHDR') throw new Error('Missing IHDR/chunks.'); if (!chunks.find(c => c.type === 'IEND')) console.warn('Missing IEND.'); return chunks; },
  55.         extractCharaData(arrayBuffer) { const data = new Uint8Array(arrayBuffer); const chunks = this.readChunks(data); const textChunks = chunks.filter(c => c.type === 'tEXt').map(c => this.decodeText(c.data)); if (textChunks.length < 1) throw new Error('No tEXt chunks.'); const charaChunk = textChunks.find(t => t.keyword === 'chara'); if (!charaChunk) throw new Error('No "chara" tEXt chunk.'); try { const b64 = atob(charaChunk.text); const u8 = new Uint8Array(b64.length); for (let i = 0; i < b64.length; i++) u8[ i ] = b64.charCodeAt(i); const jsonStr = new TextDecoder().decode(u8); const jsonData = JSON.parse(jsonStr); return (jsonData.spec === "chara_card_v2" && jsonData.data) ? jsonData.data : jsonData; } catch (e) { throw new Error(`Decode/parse "chara" failed: ${e.message}`); } },
  56.         createTEXtChunk(keyword, text) { this.initialize(); const keywordBytes = new TextEncoder().encode(keyword); const textBytes = new TextEncoder().encode(text); const chunkData = new Uint8Array(keywordBytes.length + 1 + textBytes.length); chunkData.set(keywordBytes, 0); chunkData[ keywordBytes.length ] = 0; chunkData.set(textBytes, keywordBytes.length + 1); const chunkType = new TextEncoder().encode('tEXt'); const lengthBytes = this.uint32ToBytes(chunkData.length); const typeAndData = new Uint8Array(4 + chunkData.length); typeAndData.set(chunkType, 0); typeAndData.set(chunkData, 4); const crc = CRC32.buf(typeAndData); const crcBytes = this.uint32ToBytes(crc); const chunk = new Uint8Array(4 + 4 + chunkData.length + 4); chunk.set(lengthBytes, 0); chunk.set(chunkType, 4); chunk.set(chunkData, 8); chunk.set(crcBytes, 8 + chunkData.length); return chunk; },
  57.         embedCharaData(imageArrayBuffer, charaJsonData) { const imageData = new Uint8Array(imageArrayBuffer); const originalChunks = this.readChunks(imageData); const tavernV2Data = { ...TAVERN_V2_SPEC, data: charaJsonData }; const jsonString = JSON.stringify(tavernV2Data); const base64String = btoa(unescape(encodeURIComponent(jsonString))); const charaChunkBytes = this.createTEXtChunk('chara', base64String); const newPngParts = [ this.PNG_SIGNATURE ]; let inserted = false; for (let i = 0; i < originalChunks.length; i++) { const chunk = originalChunks[ i ]; if (chunk.type === 'IEND' && !inserted) { newPngParts.push(charaChunkBytes); inserted = true; console.log("Inserted 'chara' tEXt before IEND."); } const chunkTypeBytes = new TextEncoder().encode(chunk.type); const lengthBytes = this.uint32ToBytes(chunk.length); const crcBytes = this.uint32ToBytes(chunk.crc); const fullChunk = new Uint8Array(4 + 4 + chunk.data.length + 4); fullChunk.set(lengthBytes, 0); fullChunk.set(chunkTypeBytes, 4); fullChunk.set(chunk.data, 8); fullChunk.set(crcBytes, 8 + chunk.data.length); newPngParts.push(fullChunk); } if (!inserted) { console.warn("IEND not found. Appending 'chara' and new IEND."); newPngParts.push(charaChunkBytes); const iendChunk = new Uint8Array([ 0, 0, 0, 0, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82 ]); newPngParts.push(iendChunk); } return new Blob(newPngParts, { type: 'image/png' }); }
  58.     };
  59.  
  60.     // --- Helper Functions ---
  61.     // *** ENSURE THESE ARE CORRECTLY DEFINED AND ACCESSIBLE ***
  62.     function getInputPlaceholder(placeholderText)
  63.     {
  64.         const escaped = CSS.escape(placeholderText);
  65.         const selector = `input[placeholder="${escaped}"], textarea[placeholder="${escaped}"]`;
  66.  
  67.         const element = document.querySelector(selector);
  68.  
  69.         if (!element)
  70.         {
  71.             console.warn(`Element not found for placeholder: ${placeholderText}`);
  72.             return undefined;
  73.         }
  74.  
  75.         console.log(`placeholder: '${placeholderText}'`);
  76.         console.log(`found: ${element.value}`);
  77.  
  78.         return element.value;
  79.     }
  80.  
  81.     function setInputValue(selector, value, isTextArea = false)
  82.     {
  83.         const element = document.querySelector(selector);
  84.         if (element && value !== undefined && value !== null)
  85.         {
  86.             element.value = value;
  87.             element.dispatchEvent(new Event('input', { bubbles: true }));
  88.             element.dispatchEvent(new Event('change', { bubbles: true }));
  89.             if (isTextArea)
  90.             {
  91.                 element.dispatchEvent(new Event('textarea', { bubbles: true }));
  92.                 element.style.height = 'auto'; element.style.height = (element.scrollHeight) + 'px';
  93.             }
  94.             console.log(`Set ${selector} to: ${String(value).substring(0, 50)}...`);
  95.         } else if (!element) console.warn(`Element not found: ${selector}`);
  96.         else { element.value = ''; element.dispatchEvent(new Event('input', { bubbles: true })); element.dispatchEvent(new Event('change', { bubbles: true })); }
  97.     }
  98.  
  99.     function getInputValue(selector)
  100.     {
  101.         const element = document.querySelector(selector);
  102.         // Add a check to see if the element exists before trying to get value
  103.         if (!element)
  104.         {
  105.             console.warn(`Element not found for getInputValue: ${selector}`);
  106.             return undefined; // Return undefined if not found
  107.         }
  108.         return element.value;
  109.     }
  110.  
  111.     function setRichTextValue(selector, value)
  112.     {
  113.         const element = document.querySelector(selector);
  114.         if (element && value !== undefined && value !== null)
  115.         {
  116.             while (element.firstChild) element.removeChild(element.firstChild);
  117.             const p = document.createElement('p'); p.textContent = value; element.appendChild(p);
  118.             element.dispatchEvent(new Event('input', { bubbles: true, composed: true })); element.dispatchEvent(new Event('blur', { bubbles: true }));
  119.             console.log(`Set Rich Text ${selector} to: ${String(value).substring(0, 50)}...`);
  120.         } else if (!element) console.warn(`Rich text element not found: ${selector}`);
  121.         else { while (element.firstChild) element.removeChild(element.firstChild); element.dispatchEvent(new Event('input', { bubbles: true, composed: true })); element.dispatchEvent(new Event('blur', { bubbles: true })); }
  122.     }
  123.  
  124.     function getRichTextValue(selector)
  125.     {
  126.         const element = document.querySelector(selector);
  127.         // Add a check to see if the element exists
  128.         if (!element)
  129.         {
  130.             console.warn(`Rich text element not found for getRichTextValue: ${selector}`);
  131.             return undefined; // Return undefined if not found
  132.         }
  133.         return element.textContent;
  134.     }
  135.  
  136.     function getTagsFromReactSelect(containerElement)
  137.     {
  138.         const tags = [];
  139.  
  140.         // Look for the react-select control within the container element
  141.         const controlElement = containerElement.querySelector('.react-select__control');
  142.         if (controlElement)
  143.         {
  144.             console.log("Found react-select control, looking for tag labels...");
  145.             const tagLabels = controlElement.querySelectorAll('.react-select__multi-value__label');
  146.             console.log(`Found ${tagLabels.length} tag label elements`);
  147.  
  148.             tagLabels.forEach(el => {
  149.                 let tagText = el.textContent.trim();
  150.                 console.log(`Raw tag text: "${tagText}"`);
  151.  
  152.                 // Clean up the tag text - remove emoji and extra spaces
  153.                 // Remove leading emoji pattern (like 👩‍🦰 or 👨)
  154.                 tagText = tagText.replace(/^[\u{1F000}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]\s*/u, '');
  155.                 // Remove zero-width joiners and other special characters that might be in emoji
  156.                 tagText = tagText.replace(/[\u200D\uFE0F]/g, '');
  157.                 // Clean up any remaining emoji or special characters at the start
  158.                 tagText = tagText.replace(/^[^A-Za-z0-9#]+/, '');
  159.  
  160.                 if (tagText) {
  161.                     tags.push(tagText);
  162.                     console.log(`Added tag: "${tagText}"`);
  163.                 }
  164.             });
  165.         } else {
  166.             console.warn("React Select control element not found within tags form control.");
  167.             // Fallback: look for any elements that might contain tag text
  168.             const fallbackTags = containerElement.querySelectorAll('[class*="multi-value"] [class*="label"]');
  169.             console.log(`Fallback: found ${fallbackTags.length} potential tag elements`);
  170.  
  171.             fallbackTags.forEach(el => {
  172.                 let tagText = el.textContent.trim();
  173.                 if (tagText && tagText.length > 0 && tagText.length < 50) {
  174.                     // Clean emoji and special characters
  175.                     tagText = tagText.replace(/^[\u{1F000}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]\s*/u, '');
  176.                     tagText = tagText.replace(/[\u200D\uFE0F]/g, '');
  177.                     tagText = tagText.replace(/^[^A-Za-z0-9#]+/, '');
  178.  
  179.                     if (tagText) {
  180.                         tags.push(tagText);
  181.                         console.log(`Fallback added tag: "${tagText}"`);
  182.                     }
  183.                 }
  184.             });
  185.         }
  186.  
  187.         console.log(`Total tags found: ${tags.length}`, tags);
  188.         return tags;
  189.     }
  190.  
  191.     function downloadFile(blob, filename)
  192.     {
  193.         const url = URL.createObjectURL(blob);
  194.         const a = document.createElement('a');
  195.         a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url);
  196.         console.log(`Triggered download for: ${filename}`);
  197.     }
  198.  
  199.     function sanitizeFilename(name)
  200.     {
  201.         // Ensure name is a string before calling replace
  202.         const nameStr = String(name || ''); // Default to empty string if name is null/undefined
  203.         return nameStr.trim().replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, '_');
  204.    }
  205.  
  206.    function fetchUrlAsBlob(url)
  207.    { // Changed to fetch Blob
  208.        return new Promise((resolve, reject) =>
  209.        {
  210.            console.log(`Fetching URL as Blob: ${url}`);
  211.            GM_xmlhttpRequest({
  212.                method: "GET", url: url, responseType: "blob", // Request blob
  213.                onload: r => (r.status >= 200 && r.status < 300) ? resolve(r.response) : reject(new Error(`HTTP ${r.status}: ${r.statusText}`)),
  214.                onerror: r => reject(new Error(`Network error: ${r.error || 'Unknown'}`)),
  215.                ontimeout: () => reject(new Error("Request timed out"))
  216.            });
  217.        });
  218.    }
  219.  
  220.    function fetchUrlAsArrayBuffer(url)
  221.    { // Kept for API download
  222.        return new Promise((resolve, reject) =>
  223.        {
  224.            console.log(`Fetching URL as ArrayBuffer: ${url}`);
  225.            GM_xmlhttpRequest({
  226.                method: "GET", url: url, responseType: "arraybuffer",
  227.                onload: r => (r.status >= 200 && r.status < 300) ? resolve(r.response) : reject(new Error(`HTTP ${r.status}: ${r.statusText}`)),
  228.                onerror: r => reject(new Error(`Network error: ${r.error || 'Unknown'}`)),
  229.                ontimeout: () => reject(new Error("Request timed out"))
  230.            });
  231.        });
  232.    }
  233.  
  234.    // --- Image Conversion Helper ---
  235.    function convertBlobToPngArrayBuffer(blob)
  236.    {
  237.        return new Promise((resolve, reject) =>
  238.        {
  239.            const objectURL = URL.createObjectURL(blob);
  240.            const img = new Image();
  241.            img.onload = () =>
  242.            {
  243.                console.log(`Converting ${blob.type} to PNG...`);
  244.                const canvas = document.createElement('canvas');
  245.                canvas.width = img.naturalWidth; canvas.height = img.naturalHeight;
  246.                const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0);
  247.                canvas.toBlob(pngBlob =>
  248.                {
  249.                    if (!pngBlob) { reject(new Error("Canvas toBlob failed.")); return; }
  250.                    const reader = new FileReader();
  251.                    reader.onload = (e) => resolve(e.target.result); // Resolve with ArrayBuffer
  252.                    reader.onerror = (e) => reject(new Error(`FileReader error: ${e.target.error}`));
  253.                    reader.readAsArrayBuffer(pngBlob);
  254.                }, 'image/png');
  255.                URL.revokeObjectURL(objectURL);
  256.            };
  257.            img.onerror = (err) => { URL.revokeObjectURL(objectURL); console.error("Image load error:", err); reject(new Error(`Failed to load image for conversion (Type: ${blob.type})`)); };
  258.            img.src = objectURL;
  259.        });
  260.    }
  261.  
  262.  
  263.    // --- Importer Logic (Edit/Create Page) ---
  264.    function populateForm(characterData, imageFile = null)
  265.    {
  266.        console.log("Populating form with character data:", characterData);
  267.        setInputValue('input[placeholder="Provide a unique name for your character"]', characterData.name || '');
  268.        setInputValue('input#chat_name', characterData.name || '');
  269.        setRichTextValue('div.tiptap.ProseMirror[contenteditable="true"]', characterData.description || '');
  270.        setInputValue('textarea#personality', characterData.personality || '', true);
  271.        setInputValue('textarea#scenario', characterData.scenario || '', true);
  272.        setInputValue('textarea#first_message', characterData.first_mes || '', true);
  273.        let examples = characterData.mes_example || '';
  274.        examples = examples.replace(/<START>/g, '').trim();
  275.        examples = examples.replace(/\{\{user\}\}/gi, '{{user}}');
  276.        examples = examples.replace(/\{\{random_user_1\}\}/gi, '{{user}}');
  277.        examples = examples.replace(/\{\{char\}\}/gi, '{{char}}');
  278.        examples = examples.replace(/\{\{random_user_2\}\}/gi, '{{char}}');
  279.        setInputValue('textarea#example_dialogs', examples, true);
  280.        if (imageFile instanceof File) { injectImageFile(imageFile); }
  281.        else { console.log("Not setting image from import source."); }
  282.        if (characterData.tags && Array.isArray(characterData.tags) && characterData.tags.length > 0) { console.warn("Tag import experimental:", characterData.tags); }
  283.        else { console.log("No tags found in imported data."); }
  284.        GM_notification('Character data imported successfully!', 'JanitorAI Importer');
  285.        alert('Character data imported! Please review all fields.');
  286.    }
  287.  
  288.    function injectImageFile(file)
  289.    {
  290.        const fileInput = document.querySelector('label[for="avatar"] + div input[type="file"]');
  291.        const dropZone = document.querySelector('label[for="avatar"] + div .css-1rotys8');
  292.        if (!fileInput && !dropZone) { console.error('Avatar input/dropzone not found!'); alert('Error: Could not find avatar upload element.'); return; }
  293.        if (!file || !(file instanceof File)) { console.error('Invalid file for image input.'); alert('Error: Invalid image file data.'); return; }
  294.        try
  295.        {
  296.            const dataTransfer = new DataTransfer(); dataTransfer.items.add(file);
  297.            if (fileInput) { fileInput.files = dataTransfer.files; fileInput.dispatchEvent(new Event('change', { bubbles: true })); console.log(`Injected image file "${file.name}"`); }
  298.            else if (dropZone) { console.log("Attempting drop event simulation..."); const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dataTransfer }); dropZone.dispatchEvent(dropEvent); const inputEvent = new Event('input', { bubbles: true }); dropZone.dispatchEvent(inputEvent); console.log(`Simulated drop event for "${file.name}"`); }
  299.        } catch (error) { console.error('Error setting image:', error); alert(`Error injecting image: ${error.message}`); }
  300.    }
  301.  
  302.    function handleFileImport(event)
  303.    {
  304.        const file = event.target.files[ 0 ]; if (!file) return;
  305.        const importButton = document.getElementById('jnai-import-button');
  306.        if (importButton) { importButton.textContent = 'Processing...'; importButton.disabled = true; }
  307.        const reader = new FileReader();
  308.        reader.onload = async (e) =>
  309.        {
  310.            let characterDataForForm; let imageFile = null;
  311.            try
  312.            {
  313.                if (file.type === 'image/png')
  314.                {
  315.                    console.log("Processing PNG file..."); const arrayBuffer = e.target.result;
  316.                    if (!PngHelper.isPng(arrayBuffer)) throw new Error("Selected file is not a valid PNG.");
  317.                    characterDataForForm = PngHelper.extractCharaData(arrayBuffer); imageFile = file;
  318.                } else if (file.type === 'application/json')
  319.                {
  320.                    console.log("Processing JSON file..."); const jsonString = e.target.result; const jsonData = JSON.parse(jsonString);
  321.                    if (jsonData.spec === "chara_card_v2" && jsonData.data && jsonData.name && jsonData.first_mes)
  322.                    {
  323.                        console.log("Detected NEW complex JSON format. Using 'data' object."); characterDataForForm = jsonData.data;
  324.                        if (!characterDataForForm.tags) characterDataForForm.tags = jsonData.data?.tags || [];
  325.                    } else if (jsonData.spec === "chara_card_v2" && jsonData.data)
  326.                    {
  327.                        console.log("Detected standard Tavern V2 JSON format."); characterDataForForm = jsonData.data;
  328.                        if (!characterDataForForm.tags) characterDataForForm.tags = jsonData.data?.tags || [];
  329.                    } else
  330.                    {
  331.                        console.log("Assuming Tavern V1 JSON format (or unknown)."); characterDataForForm = jsonData;
  332.                        if (!characterDataForForm.tags) characterDataForForm.tags = [];
  333.                    }
  334.                } else { throw new Error('Unsupported file type. Please select a .png or .json file.'); }
  335.                if (!characterDataForForm || typeof characterDataForForm !== 'object') throw new Error("Parsed data is not a valid character object.");
  336.                if (!characterDataForForm.name && !characterDataForForm.description)
  337.                {
  338.                    console.warn("Imported data missing key fields:", characterDataForForm);
  339.                    if (!confirm("Warning: Imported data seems incomplete. Attempt anyway?")) { throw new Error("Import cancelled by user."); }
  340.                }
  341.                populateForm(characterDataForForm, imageFile);
  342.            } catch (error)
  343.            {
  344.                console.error('Import failed:', error); alert(`Import failed: ${error.message}`); GM_notification(`Import failed: ${error.message}`, 'JanitorAI Importer Error');
  345.            } finally { if (importButton) { importButton.textContent = 'Import Card/JSON'; importButton.disabled = false; } event.target.value = null; }
  346.        };
  347.        reader.onerror = (e) => { console.error("FileReader error:", e); alert(`Error reading file: ${e.target.error}`); if (importButton) { importButton.textContent = 'Import Card/JSON'; importButton.disabled = false; } event.target.value = null; };
  348.        if (file.type === 'image/png') reader.readAsArrayBuffer(file);
  349.        else if (file.type === 'application/json') reader.readAsText(file);
  350.        else { alert('Unsupported file type.'); if (importButton) { importButton.textContent = 'Import Card/JSON'; importButton.disabled = false; } event.target.value = null; }
  351.    }
  352.  
  353.    // --- Exporter Logic (Edit Page) ---
  354.    function scrapeDataFromEditPage()
  355.    {
  356.        console.log("Scraping data from Edit Page...");
  357.        const data = {};
  358.  
  359.        // Use the helper functions correctly
  360.        data.jai_char_name = getInputPlaceholder('Provide a unique name for your character');
  361.        data.jai_chat_name = getInputPlaceholder('Character Chat Name');
  362.        data.jai_char_bio = getRichTextValue('div.tiptap.ProseMirror[contenteditable="true"]');
  363.        data.jai_personality = getInputPlaceholder('Describe your character\'s persona. This will help define how the character interacts with others.');
  364.        data.jai_scenario = getInputPlaceholder('Outline the context and setting for your character\'s conversations.');
  365.        // Extract first message and alternate greetings from tab buttons
  366.        const tabButtons = document.querySelectorAll('button[role="tab"][data-index]');
  367.        console.log(`Found ${tabButtons.length} tab buttons for messages.`);
  368.        const messages = [];
  369.  
  370.        tabButtons.forEach(button => {
  371.            const messageDiv = button.querySelector('.css-1m8k2qb');
  372.            if (messageDiv && messageDiv.textContent.trim()) {
  373.                messages.push(messageDiv.textContent.trim());
  374.            }
  375.        });
  376.  
  377.        // First message is the first tab (index 0)
  378.        data.jai_first_message = messages.length > 0 ? messages[0] : '';
  379.  
  380.        // Alternate greetings are all the other tabs (index 1+)
  381.        data.jai_alternate_greetings = messages.slice(1) || [];
  382.        data.jai_example_dialogs = getInputPlaceholder('Provide example conversations to guide your character\'s responses.');
  383.  
  384.        const imageSelector = 'img.preview-image';
  385.        const imgElement = document.querySelector(imageSelector);
  386.        if (imgElement && imgElement.src)
  387.        {
  388.            data.imageUrl = imgElement.src.split('?')[ 0 ]; // Clean URL
  389.            console.log(`Found image URL (cleaned): ${data.imageUrl}`);
  390.        } else
  391.        {
  392.            data.imageUrl = null;
  393.            console.warn("Could not find character image preview using selector '${imageSelector}'.");
  394.        }
  395.  
  396.        const tagsFormControl = Array.from(document.querySelectorAll('.chakra-form-control'))
  397.            .find(el => el.querySelector('label')?.textContent?.includes('Character Tags'));
  398.        if (tagsFormControl)
  399.        {
  400.            console.log("Found tags form control, extracting tags...");
  401.            data.tags = getTagsFromReactSelect(tagsFormControl);
  402.        } else
  403.        {
  404.            console.warn("Could not find form control for tags by label.");
  405.            data.tags = [];
  406.        }
  407.  
  408.        // *** CRITICAL: Log the scraped data *before* validation ***
  409.        console.log("Scraped raw data:", data);
  410.  
  411.        // Validation (check if chat name was actually found)
  412.        if (data.jai_chat_name === undefined)
  413.        { // Check for undefined explicitly
  414.            alert("Cannot export: Failed to scrape 'Character Chat Name'. The page structure might have changed or the element is not ready.");
  415.            return null; // Return null if essential data is missing
  416.        }
  417.        if (!data.jai_chat_name)
  418.        { // Check if it's empty after being found
  419.            alert("Cannot export: 'Character Chat Name' is empty. This is required for export.");
  420.            return null;
  421.        }
  422.        return data;
  423.    }
  424.  
  425.    // Exports JSON in the NEW complex format
  426.    async function handleExportJsonEdit()
  427.    {
  428.        const scrapedData = scrapeDataFromEditPage();
  429.        if (!scrapedData) return; // Exit if scraping failed validation
  430.  
  431.        const exportJson = {
  432.            "name": scrapedData.jai_chat_name || scrapedData.jai_char_name || "",
  433.            "description": scrapedData.jai_personality || "",
  434.            "first_mes": scrapedData.jai_first_message || "",
  435.            "personality": scrapedData.jai_personality || "",
  436.            "scenario": scrapedData.jai_scenario || "",
  437.            "mes_example": scrapedData.jai_example_dialogs || "",
  438.            "spec": "chara_card_v2", "spec_version": "2.0",
  439.            "data": {
  440.                "name": scrapedData.jai_chat_name || scrapedData.jai_char_name || "",
  441.                "description": scrapedData.jai_personality || "",
  442.                "personality": scrapedData.jai_personality || "",
  443.                "scenario": scrapedData.jai_scenario || "",
  444.                "first_mes": scrapedData.jai_first_message || "",
  445.                "mes_example": scrapedData.jai_example_dialogs || "",
  446.                "creator_notes": scrapedData.jai_char_bio || "", "system_prompt": "", "post_history_instructions": "", "alternate_greetings": scrapedData.jai_alternate_greetings || [],
  447.                "character_version": "", "tags": scrapedData.tags || [], "creator": "",
  448.                "extensions": { "talkativeness": "0.5", "depth_prompt": { "prompt": "", "depth": "" } }
  449.            },
  450.            "alternative": { /* Default/empty */ }, "misc": { /* Default/empty */ },
  451.            "metadata": { "version": 1, "created": Date.now(), "modified": Date.now(), "source": "JanitorAI Edit Page Scrape", "tool": CUSTOM_TOOL_META }
  452.        };
  453.        // Add default empty structures if needed
  454.        if (!exportJson.alternative) exportJson.alternative = { name_alt: "", description_alt: "", first_mes_alt: "", alternate_greetings_alt: [], personality_alt: "", scenario_alt: "", mes_example_alt: "", creator_alt: "", extensions_alt: { talkativeness_alt: "0.5", depth_prompt_alt: { prompt_alt: "", depth_alt: "" } }, system_prompt_alt: "", post_history_instructions_alt: "", creator_notes_alt: "", character_version_alt: "", tags_alt: [] };
  455.        if (!exportJson.misc) exportJson.misc = { rentry: "", rentry_alt: "" };
  456.  
  457.        const jsonString = JSON.stringify(exportJson, null, 2);
  458.        const blob = new Blob([ jsonString ], { type: 'application/json;charset=utf-8' });
  459.        const filename = `${sanitizeFilename(scrapedData.jai_chat_name || scrapedData.jai_char_name || 'character')}.json`;
  460.        downloadFile(blob, filename);
  461.        GM_notification('Character exported as custom JSON.', 'JanitorAI Exporter');
  462.    }
  463.  
  464.    // Exports PNG using standard V2 embedding, handles non-PNG avatars
  465.    async function handleExportPngEdit()
  466.    {
  467.        const exportButton = document.getElementById('jnai-export-png-button');
  468.        if (exportButton) exportButton.disabled = true;
  469.  
  470.        // *** Scrape data FIRST ***
  471.        const scrapedData = scrapeDataFromEditPage();
  472.        if (!scrapedData)
  473.        { // Exit if scraping failed validation
  474.            if (exportButton) exportButton.disabled = false;
  475.            return;
  476.        }
  477.        if (!scrapedData.imageUrl)
  478.        {
  479.            alert("Cannot export as PNG: Image URL not found during scraping.");
  480.            if (exportButton) exportButton.disabled = false;
  481.            return;
  482.        }
  483.  
  484.        // *** THEN handle image fetching and conversion ***
  485.        try
  486.        {
  487.            console.log("Fetching image for PNG export...");
  488.            const imageBlob = await fetchUrlAsBlob(scrapedData.imageUrl);
  489.            let pngArrayBuffer;
  490.            console.log(`Fetched image blob. Type: ${imageBlob.type}, Size: ${imageBlob.size}`);
  491.  
  492.            if (imageBlob.type === 'image/png')
  493.            {
  494.                console.log("Image is already PNG.");
  495.                pngArrayBuffer = await imageBlob.arrayBuffer();
  496.            } else if (imageBlob.type.startsWith('image/'))
  497.            {
  498.                console.log(`Image is not PNG (${imageBlob.type}). Converting...`);
  499.                pngArrayBuffer = await convertBlobToPngArrayBuffer(imageBlob);
  500.                console.log("Conversion to PNG successful.");
  501.            } else
  502.            {
  503.                throw new Error(`Fetched file is not a recognizable image type: ${imageBlob.type || 'unknown'}`);
  504.            }
  505.  
  506.            if (!pngArrayBuffer || pngArrayBuffer.byteLength < 8 || !PngHelper.isPng(pngArrayBuffer))
  507.            {
  508.                throw new Error("Failed to obtain a valid PNG ArrayBuffer.");
  509.            }
  510.  
  511.            // Prepare data for STANDARD V2 embedding using the ALREADY scraped data
  512.            const dataToEmbed = {
  513.                name: scrapedData.jai_chat_name || scrapedData.jai_char_name || "",
  514.                description: scrapedData.jai_personality || "",
  515.                scenario: scrapedData.jai_scenario || "",
  516.                first_mes: scrapedData.jai_first_message || "",
  517.                mes_example: scrapedData.jai_example_dialogs || "",
  518.                tags: scrapedData.tags || [],
  519.                creator_notes: scrapedData.jai_char_bio || "", system_prompt: "", post_history_instructions: "", alternate_greetings: scrapedData.jai_alternate_greetings || [],
  520.                character_version: "", creator: "",
  521.                extensions: { talkativeness: "0.5", depth_prompt: { prompt: "", depth: "" } }
  522.            };
  523.  
  524.            console.log("Embedding standard V2 data into PNG...");
  525.            const finalPngBlob = PngHelper.embedCharaData(pngArrayBuffer, dataToEmbed);
  526.            console.log("Data embedded.");
  527.            // Use the scraped name for the filename
  528.            const filename = `${sanitizeFilename(scrapedData.jai_chat_name || scrapedData.jai_char_name || 'character')}.png`;
  529.            downloadFile(finalPngBlob, filename);
  530.            GM_notification('Character exported as PNG (Standard V2 Format).', 'JanitorAI Exporter');
  531.        } catch (error)
  532.        {
  533.            console.error('PNG Export failed:', error);
  534.            alert(`PNG Export failed: ${error.message}`);
  535.            GM_notification(`PNG Export failed: ${error.message}`, 'JanitorAI Exporter Error');
  536.        } finally
  537.        {
  538.            if (exportButton) exportButton.disabled = false;
  539.        }
  540.    }
  541.  
  542.  
  543.    // --- Exporter Logic (View Page using API) ---
  544.    async function downloadJannyCharacterApi(uuid)
  545.    {
  546.        return new Promise((resolve, reject) => { /* ... (same as before) ... */ });
  547.    }
  548.    async function handleExportPngView() { /* ... (same as before) ... */ }
  549.    async function handleExportJsonView() { /* ... (same as before, uses downloadJannyCharacterApi & PngHelper.extractCharaData) ... */ }
  550.  
  551.  
  552.    // --- UI Injection ---
  553.    function addButtons()
  554.    { /* ... (same as before, calls the correct handlers) ... */
  555.        const pathname = window.location.pathname; const buttonContainerId = 'jnai-button-container';
  556.        const existingContainer = document.getElementById(buttonContainerId); if (existingContainer) existingContainer.remove();
  557.        const container = document.createElement('div'); container.id = buttonContainerId; Object.assign(container.style, { position: 'fixed', top: '10px', right: '15px', zIndex: '9999', display: 'flex', flexDirection: 'column', gap: '5px' });
  558.  
  559.        if (pathname === '/create_character' || pathname.startsWith('/edit_character/'))
  560.        {
  561.            const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = '.png,.json'; fileInput.style.display = 'none'; fileInput.id = 'jnai-import-input'; fileInput.addEventListener('change', handleFileImport);
  562.            const importButton = document.createElement('button'); importButton.textContent = 'Import Card/JSON'; importButton.id = 'jnai-import-button'; importButton.className = 'jnai-button'; importButton.addEventListener('click', () => fileInput.click());
  563.            container.appendChild(importButton); if (!document.getElementById(fileInput.id)) document.body.appendChild(fileInput);
  564.  
  565.            const exportJsonButton = document.createElement('button'); exportJsonButton.textContent = 'Export JSON (Custom)'; exportJsonButton.id = 'jnai-export-json-button'; exportJsonButton.className = 'jnai-button'; exportJsonButton.title = 'Exports in the specified custom JSON format.'; exportJsonButton.addEventListener('click', handleExportJsonEdit); container.appendChild(exportJsonButton);
  566.            const exportPngButton = document.createElement('button'); exportPngButton.textContent = 'Export PNG (Std V2)'; exportPngButton.id = 'jnai-export-png-button'; exportPngButton.className = 'jnai-button'; exportPngButton.title = 'Embeds standard V2 data into the current avatar (converts if needed).'; exportPngButton.addEventListener('click', handleExportPngEdit); container.appendChild(exportPngButton);
  567.        }
  568.  
  569.        if (pathname.startsWith('/characters/') && pathname.split('/').length > 2 && pathname.split('/')[ 2 ].length > 0)
  570.        {
  571.            const exportJsonButton = document.createElement('button'); exportJsonButton.textContent = 'Export JSON (API/Custom)'; exportJsonButton.id = 'jnai-export-json-button'; exportJsonButton.className = 'jnai-button jnai-button-api'; exportJsonButton.title = 'Downloads card via API and formats as custom JSON.'; exportJsonButton.addEventListener('click', handleExportJsonView); container.appendChild(exportJsonButton);
  572.            const exportPngButton = document.createElement('button'); exportPngButton.textContent = 'Export PNG (API/Std V2)'; exportPngButton.id = 'jnai-export-png-button'; exportPngButton.className = 'jnai-button jnai-button-api'; exportPngButton.title = 'Downloads card via API (should be standard V2 PNG).'; exportPngButton.addEventListener('click', handleExportPngView); container.appendChild(exportPngButton);
  573.        }
  574.  
  575.        if (container.hasChildNodes()) document.body.appendChild(container);
  576.        else console.log("No buttons added for current page:", pathname);
  577.    }
  578.  
  579.    // --- Styling ---
  580.    GM_addStyle(`
  581.        .jnai-button { padding: 8px 12px; background-color: #6a4f8a; color: white; border: none; border-radius: 5px; cursor: pointer; font-size: 12px; /* Smaller font */ box-shadow: 0 1px 3px rgba(0,0,0,0.2); transition: background-color 0.2s ease, transform 0.1s ease; text-align: center; white-space: nowrap; }
  582.        .jnai-button:hover { background-color: #8363a8; } .jnai-button:active { transform: scale(0.98); }
  583.        .jnai-button:disabled { background-color: #cccccc; color: #666666; cursor: not-allowed; }
  584.        .jnai-button-api { background-color: #5a7a9a; } .jnai-button-api:hover { background-color: #7395b8; }
  585.    `);
  586.  
  587.    // --- Initialization ---
  588.    console.log(`JanitorAI SillyTavern Importer/Exporter v${SCRIPT_VERSION} Initializing...`);
  589.    let currentPage = window.location.pathname;
  590.    const observer = new MutationObserver(() =>
  591.    {
  592.        const mainContentSelector = 'main, [role="main"], #app main, .css-1t6eusi, .css-lo240v';
  593.        const isReady = document.querySelector(mainContentSelector);
  594.        const urlChanged = window.location.pathname !== currentPage;
  595.        if (isReady && (urlChanged || !document.getElementById('jnai-button-container')))
  596.        {
  597.            console.log("Page change/load detected, adding/refreshing buttons for:", window.location.pathname);
  598.            currentPage = window.location.pathname;
  599.            // Increased delay slightly, might help ensure elements are populated
  600.            setTimeout(addButtons, 800);
  601.        } else if (urlChanged)
  602.        {
  603.            currentPage = window.location.pathname;
  604.        }
  605.    });
  606.    observer.observe(document.body, { childList: true, subtree: true });
  607.    // Increased initial delay
  608.    setTimeout(addButtons, 1500);
  609.  
  610. })();
  611.  
Add Comment
Please, Sign In to add comment