// ==UserScript==
// @name JanitorAI SillyTavern Importer & Exporter
// @namespace http://tampermonkey.net/
// @version 2.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.
// @author Minoa (https://bio.minoa.cat)
// @match https://janitorai.com/create_character
// @match https://janitorai.com/edit_character/*
/* @match https://janitorai.com/characters/* disabled rn as its not working*/
// @require https://cdn.jsdelivr.net/npm/[email protected]/crc32.min.js
// @grant GM_addStyle
// @grant GM_notification
// @grant GM_xmlhttpRequest
// @grant GM_download
// @connect api.jannyai.com
// @connect *.janitorai.com
// @connect *
// @license MIT; https://opensource.org/licenses/MIT
// @contributionURL https://bio.minoa.cat
// ==/UserScript==
/*
* Image Handling code structure inspired by Character Editor by Avakson:
* https://avakson.github.io/character-editor/
*
* Script Author: Minoa
* Website: https://bio.minoa.cat
*/
(function() {
'use strict';
// --- Constants ---
const SCRIPT_VERSION = "2.5"; // Updated
const TAVERN_V2_SPEC = { spec: "chara_card_v2", spec_version: "2.0" };
const CUSTOM_TOOL_META = {
name: "JanitorAI Import & Exporter Tampermonkey Script By Minoa",
version: SCRIPT_VERSION,
url: "https://bio.minoa.cat"
};
// --- CRC32 Logic ---
// Included via @require
// --- PNG Parsing Logic (PngHelper object remains the same) ---
const PngHelper = {
_uint8: new Uint8Array(4), _int32: null, _uint32: null,
PNG_SIGNATURE: new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
initialize() { if (!this._int32) { this._int32 = new Int32Array(this._uint8.buffer); this._uint32 = new Uint32Array(this._uint8.buffer); } },
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); },
uint32ToBytes(num) { this.initialize(); this._uint32[0] = num; return new Uint8Array([this._uint8[3], this._uint8[2], this._uint8[1], this._uint8[0]]); },
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]; },
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 }; },
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 }; },
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; },
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}`); } },
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; },
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' }); }
};
// --- Helper Functions ---
// *** ENSURE THESE ARE CORRECTLY DEFINED AND ACCESSIBLE ***
function setInputValue(selector, value, isTextArea = false) {
const element = document.querySelector(selector);
if (element && value !== undefined && value !== null) {
element.value = value;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
if (isTextArea) {
element.dispatchEvent(new Event('textarea', { bubbles: true }));
element.style.height = 'auto'; element.style.height = (element.scrollHeight) + 'px';
}
console.log(`Set ${selector} to: ${String(value).substring(0, 50)}...`);
} else if (!element) console.warn(`Element not found: ${selector}`);
else { element.value = ''; element.dispatchEvent(new Event('input', { bubbles: true })); element.dispatchEvent(new Event('change', { bubbles: true })); }
}
function getInputValue(selector) {
const element = document.querySelector(selector);
// Add a check to see if the element exists before trying to get value
if (!element) {
console.warn(`Element not found for getInputValue: ${selector}`);
return undefined; // Return undefined if not found
}
return element.value;
}
function setRichTextValue(selector, value) {
const element = document.querySelector(selector);
if (element && value !== undefined && value !== null) {
while (element.firstChild) element.removeChild(element.firstChild);
const p = document.createElement('p'); p.textContent = value; element.appendChild(p);
element.dispatchEvent(new Event('input', { bubbles: true, composed: true })); element.dispatchEvent(new Event('blur', { bubbles: true }));
console.log(`Set Rich Text ${selector} to: ${String(value).substring(0, 50)}...`);
} else if (!element) console.warn(`Rich text element not found: ${selector}`);
else { while (element.firstChild) element.removeChild(element.firstChild); element.dispatchEvent(new Event('input', { bubbles: true, composed: true })); element.dispatchEvent(new Event('blur', { bubbles: true })); }
}
function getRichTextValue(selector) {
const element = document.querySelector(selector);
// Add a check to see if the element exists
if (!element) {
console.warn(`Rich text element not found for getRichTextValue: ${selector}`);
return undefined; // Return undefined if not found
}
return element.textContent;
}
function getTagsFromReactSelect(containerSelector) {
const tags = [];
// Use the container selector passed to it, not a hardcoded one
const controlElement = document.querySelector(containerSelector + ' .react-select__control');
if (controlElement) {
controlElement.querySelectorAll(`.react-select__multi-value__label`).forEach(el => tags.push(el.textContent.trim().replace(/^#/, '')));
} else console.warn("React Select control element not found for tags inside:", containerSelector);
return tags;
}
function downloadFile(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url);
console.log(`Triggered download for: ${filename}`);
}
function sanitizeFilename(name) {
// Ensure name is a string before calling replace
const nameStr = String(name || ''); // Default to empty string if name is null/undefined
return nameStr.trim().replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, '_');
}
function fetchUrlAsBlob(url) { // Changed to fetch Blob
return new Promise((resolve, reject) => {
console.log(`Fetching URL as Blob: ${url}`);
GM_xmlhttpRequest({
method: "GET", url: url, responseType: "blob", // Request blob
onload: r => (r.status >= 200 && r.status < 300) ? resolve(r.response) : reject(new Error(`HTTP ${r.status}: ${r.statusText}`)),
onerror: r => reject(new Error(`Network error: ${r.error || 'Unknown'}`)),
ontimeout: () => reject(new Error("Request timed out"))
});
});
}
function fetchUrlAsArrayBuffer(url) { // Kept for API download
return new Promise((resolve, reject) => {
console.log(`Fetching URL as ArrayBuffer: ${url}`);
GM_xmlhttpRequest({
method: "GET", url: url, responseType: "arraybuffer",
onload: r => (r.status >= 200 && r.status < 300) ? resolve(r.response) : reject(new Error(`HTTP ${r.status}: ${r.statusText}`)),
onerror: r => reject(new Error(`Network error: ${r.error || 'Unknown'}`)),
ontimeout: () => reject(new Error("Request timed out"))
});
});
}
// --- Image Conversion Helper ---
function convertBlobToPngArrayBuffer(blob) {
return new Promise((resolve, reject) => {
const objectURL = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
console.log(`Converting ${blob.type} to PNG...`);
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth; canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0);
canvas.toBlob(pngBlob => {
if (!pngBlob) { reject(new Error("Canvas toBlob failed.")); return; }
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result); // Resolve with ArrayBuffer
reader.onerror = (e) => reject(new Error(`FileReader error: ${e.target.error}`));
reader.readAsArrayBuffer(pngBlob);
}, 'image/png');
URL.revokeObjectURL(objectURL);
};
img.onerror = (err) => { URL.revokeObjectURL(objectURL); console.error("Image load error:", err); reject(new Error(`Failed to load image for conversion (Type: ${blob.type})`)); };
img.src = objectURL;
});
}
// --- Importer Logic (Edit/Create Page) ---
function populateForm(characterData, imageFile = null) {
console.log("Populating form with character data:", characterData);
setInputValue('input[placeholder="Provide a unique name for your character"]', characterData.name || '');
setInputValue('input#chat_name', characterData.name || '');
setRichTextValue('div.tiptap.ProseMirror[contenteditable="true"]', characterData.description || '');
setInputValue('textarea#personality', characterData.personality || '', true);
setInputValue('textarea#scenario', characterData.scenario || '', true);
setInputValue('textarea#first_message', characterData.first_mes || '', true);
let examples = characterData.mes_example || '';
examples = examples.replace(/<START>/g, '').trim();
examples = examples.replace(/\{\{user\}\}/gi, '{{user}}');
examples = examples.replace(/\{\{random_user_1\}\}/gi, '{{user}}');
examples = examples.replace(/\{\{char\}\}/gi, '{{char}}');
examples = examples.replace(/\{\{random_user_2\}\}/gi, '{{char}}');
setInputValue('textarea#example_dialogs', examples, true);
if (imageFile instanceof File) { injectImageFile(imageFile); }
else { console.log("Not setting image from import source."); }
if (characterData.tags && Array.isArray(characterData.tags) && characterData.tags.length > 0) { console.warn("Tag import experimental:", characterData.tags); }
else { console.log("No tags found in imported data."); }
GM_notification('Character data imported successfully!', 'JanitorAI Importer');
alert('Character data imported! Please review all fields.');
}
function injectImageFile(file) {
const fileInput = document.querySelector('label[for="avatar"] + div input[type="file"]');
const dropZone = document.querySelector('label[for="avatar"] + div .css-1rotys8');
if (!fileInput && !dropZone) { console.error('Avatar input/dropzone not found!'); alert('Error: Could not find avatar upload element.'); return; }
if (!file || !(file instanceof File)) { console.error('Invalid file for image input.'); alert('Error: Invalid image file data.'); return; }
try {
const dataTransfer = new DataTransfer(); dataTransfer.items.add(file);
if (fileInput) { fileInput.files = dataTransfer.files; fileInput.dispatchEvent(new Event('change', { bubbles: true })); console.log(`Injected image file "${file.name}"`); }
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}"`); }
} catch (error) { console.error('Error setting image:', error); alert(`Error injecting image: ${error.message}`); }
}
function handleFileImport(event) {
const file = event.target.files[0]; if (!file) return;
const importButton = document.getElementById('jnai-import-button');
if (importButton) { importButton.textContent = 'Processing...'; importButton.disabled = true; }
const reader = new FileReader();
reader.onload = async (e) => {
let characterDataForForm; let imageFile = null;
try {
if (file.type === 'image/png') {
console.log("Processing PNG file..."); const arrayBuffer = e.target.result;
if (!PngHelper.isPng(arrayBuffer)) throw new Error("Selected file is not a valid PNG.");
characterDataForForm = PngHelper.extractCharaData(arrayBuffer); imageFile = file;
} else if (file.type === 'application/json') {
console.log("Processing JSON file..."); const jsonString = e.target.result; const jsonData = JSON.parse(jsonString);
if (jsonData.spec === "chara_card_v2" && jsonData.data && jsonData.name && jsonData.first_mes) {
console.log("Detected NEW complex JSON format. Using 'data' object."); characterDataForForm = jsonData.data;
if (!characterDataForForm.tags) characterDataForForm.tags = jsonData.data?.tags || [];
} else if (jsonData.spec === "chara_card_v2" && jsonData.data) {
console.log("Detected standard Tavern V2 JSON format."); characterDataForForm = jsonData.data;
if (!characterDataForForm.tags) characterDataForForm.tags = jsonData.data?.tags || [];
} else {
console.log("Assuming Tavern V1 JSON format (or unknown)."); characterDataForForm = jsonData;
if (!characterDataForForm.tags) characterDataForForm.tags = [];
}
} else { throw new Error('Unsupported file type. Please select a .png or .json file.'); }
if (!characterDataForForm || typeof characterDataForForm !== 'object') throw new Error("Parsed data is not a valid character object.");
if (!characterDataForForm.name && !characterDataForForm.description) {
console.warn("Imported data missing key fields:", characterDataForForm);
if (!confirm("Warning: Imported data seems incomplete. Attempt anyway?")) { throw new Error("Import cancelled by user."); }
}
populateForm(characterDataForForm, imageFile);
} catch (error) { console.error('Import failed:', error); alert(`Import failed: ${error.message}`); GM_notification(`Import failed: ${error.message}`, 'JanitorAI Importer Error');
} finally { if (importButton) { importButton.textContent = 'Import Card/JSON'; importButton.disabled = false; } event.target.value = null; }
};
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; };
if (file.type === 'image/png') reader.readAsArrayBuffer(file);
else if (file.type === 'application/json') reader.readAsText(file);
else { alert('Unsupported file type.'); if (importButton) { importButton.textContent = 'Import Card/JSON'; importButton.disabled = false; } event.target.value = null; }
}
// --- Exporter Logic (Edit Page) ---
function scrapeDataFromEditPage() {
console.log("Scraping data from Edit Page...");
const data = {};
// Use the helper functions correctly
data.jai_char_name = getInputValue('input#name');
data.jai_chat_name = getInputValue('input#chat_name');
data.jai_char_bio = getRichTextValue('div.tiptap.ProseMirror[contenteditable="true"]');
data.jai_personality = getInputValue('textarea#personality');
data.jai_scenario = getInputValue('textarea#scenario');
data.jai_first_message = getInputValue('textarea#first_message');
data.jai_example_dialogs = getInputValue('textarea#example_dialogs');
const imgElement = document.querySelector('div.css-aqwq0n img.chakra-image');
if (imgElement && imgElement.src) {
data.imageUrl = imgElement.src.split('?')[0]; // Clean URL
console.log(`Found image URL (cleaned): ${data.imageUrl}`);
} else {
data.imageUrl = null;
console.warn("Could not find character image preview using selector 'div.css-aqwq0n img.chakra-image'.");
}
const tagsFormControl = Array.from(document.querySelectorAll('.chakra-form-control'))
.find(el => el.querySelector('label')?.textContent?.includes('Character Tags'));
if (tagsFormControl) {
// Find the specific div that IS the react-select container
const reactSelectContainer = tagsFormControl.querySelector('.css-tau7tx > div > div[class*="react-select"]');
if(reactSelectContainer) {
// Pass the actual container selector to the helper
// We need a unique way to identify this specific container if there are multiple react-selects.
// Let's try finding its class name dynamically.
const containerClasses = reactSelectContainer.className.split(' ').map(c => c.trim()).filter(c => c.length > 0);
const specificSelector = '.' + containerClasses.join('.'); // Reconstruct a specific selector
console.log("Using specific selector for tags:", specificSelector);
data.tags = getTagsFromReactSelect(specificSelector); // Pass the specific selector
} else {
console.warn("Could not find react-select container within tags form control.");
data.tags = [];
}
} else {
console.warn("Could not find form control for tags by label.");
data.tags = [];
}
// *** CRITICAL: Log the scraped data *before* validation ***
console.log("Scraped raw data:", data);
// Validation (check if chat name was actually found)
if (data.jai_chat_name === undefined) { // Check for undefined explicitly
alert("Cannot export: Failed to scrape 'Character Chat Name'. The page structure might have changed or the element is not ready.");
return null; // Return null if essential data is missing
}
if (!data.jai_chat_name) { // Check if it's empty after being found
alert("Cannot export: 'Character Chat Name' is empty. This is required for export.");
return null;
}
return data;
}
// Exports JSON in the NEW complex format
async function handleExportJsonEdit() {
const scrapedData = scrapeDataFromEditPage();
if (!scrapedData) return; // Exit if scraping failed validation
const exportJson = {
"name": scrapedData.jai_chat_name || scrapedData.jai_char_name || "",
"description": scrapedData.jai_char_bio || "",
"first_mes": scrapedData.jai_first_message || "",
"personality": scrapedData.jai_personality || "",
"scenario": scrapedData.jai_scenario || "",
"mes_example": scrapedData.jai_example_dialogs || "",
"spec": "chara_card_v2", "spec_version": "2.0",
"data": {
"name": scrapedData.jai_chat_name || scrapedData.jai_char_name || "",
"description": scrapedData.jai_char_bio || "",
"personality": scrapedData.jai_personality || "",
"scenario": scrapedData.jai_scenario || "",
"first_mes": scrapedData.jai_first_message || "",
"mes_example": scrapedData.jai_example_dialogs || "",
"creator_notes": "", "system_prompt": "", "post_history_instructions": "", "alternate_greetings": [],
"character_version": "", "tags": scrapedData.tags || [], "creator": "",
"extensions": { "talkativeness": "0.5", "depth_prompt": { "prompt": "", "depth": "" } }
},
"alternative": { /* Default/empty */ }, "misc": { /* Default/empty */ },
"metadata": { "version": 1, "created": Date.now(), "modified": Date.now(), "source": "JanitorAI Edit Page Scrape", "tool": CUSTOM_TOOL_META }
};
// Add default empty structures if needed
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:[] };
if (!exportJson.misc) exportJson.misc = { rentry:"", rentry_alt:"" };
const jsonString = JSON.stringify(exportJson, null, 2);
const blob = new Blob([jsonString], { type: 'application/json;charset=utf-8' });
const filename = `${sanitizeFilename(scrapedData.jai_chat_name || scrapedData.jai_char_name || 'character')}.json`;
downloadFile(blob, filename);
GM_notification('Character exported as custom JSON.', 'JanitorAI Exporter');
}
// Exports PNG using standard V2 embedding, handles non-PNG avatars
async function handleExportPngEdit() {
const exportButton = document.getElementById('jnai-export-png-button');
if(exportButton) exportButton.disabled = true;
// *** Scrape data FIRST ***
const scrapedData = scrapeDataFromEditPage();
if (!scrapedData) { // Exit if scraping failed validation
if(exportButton) exportButton.disabled = false;
return;
}
if (!scrapedData.imageUrl) {
alert("Cannot export as PNG: Image URL not found during scraping.");
if(exportButton) exportButton.disabled = false;
return;
}
// *** THEN handle image fetching and conversion ***
try {
console.log("Fetching image for PNG export...");
const imageBlob = await fetchUrlAsBlob(scrapedData.imageUrl);
let pngArrayBuffer;
console.log(`Fetched image blob. Type: ${imageBlob.type}, Size: ${imageBlob.size}`);
if (imageBlob.type === 'image/png') {
console.log("Image is already PNG.");
pngArrayBuffer = await imageBlob.arrayBuffer();
} else if (imageBlob.type.startsWith('image/')) {
console.log(`Image is not PNG (${imageBlob.type}). Converting...`);
pngArrayBuffer = await convertBlobToPngArrayBuffer(imageBlob);
console.log("Conversion to PNG successful.");
} else {
throw new Error(`Fetched file is not a recognizable image type: ${imageBlob.type || 'unknown'}`);
}
if (!pngArrayBuffer || pngArrayBuffer.byteLength < 8 || !PngHelper.isPng(pngArrayBuffer)) {
throw new Error("Failed to obtain a valid PNG ArrayBuffer.");
}
// Prepare data for STANDARD V2 embedding using the ALREADY scraped data
const dataToEmbed = {
name: scrapedData.jai_chat_name || scrapedData.jai_char_name || "",
description: scrapedData.jai_char_bio || "",
personality: scrapedData.jai_personality || "",
scenario: scrapedData.jai_scenario || "",
first_mes: scrapedData.jai_first_message || "",
mes_example: scrapedData.jai_example_dialogs || "",
tags: scrapedData.tags || [],
creator_notes: "", system_prompt: "", post_history_instructions: "", alternate_greetings: [],
character_version: "", creator: "",
extensions: { talkativeness: "0.5", depth_prompt: { prompt: "", depth: "" } }
};
console.log("Embedding standard V2 data into PNG...");
const finalPngBlob = PngHelper.embedCharaData(pngArrayBuffer, dataToEmbed);
console.log("Data embedded.");
// Use the scraped name for the filename
const filename = `${sanitizeFilename(scrapedData.jai_chat_name || scrapedData.jai_char_name || 'character')}.png`;
downloadFile(finalPngBlob, filename);
GM_notification('Character exported as PNG (Standard V2 Format).', 'JanitorAI Exporter');
} catch (error) {
console.error('PNG Export failed:', error);
alert(`PNG Export failed: ${error.message}`);
GM_notification(`PNG Export failed: ${error.message}`, 'JanitorAI Exporter Error');
} finally {
if(exportButton) exportButton.disabled = false;
}
}
// --- Exporter Logic (View Page using API) ---
async function downloadJannyCharacterApi(uuid) {
return new Promise((resolve, reject) => { /* ... (same as before) ... */ });
}
async function handleExportPngView() { /* ... (same as before) ... */ }
async function handleExportJsonView() { /* ... (same as before, uses downloadJannyCharacterApi & PngHelper.extractCharaData) ... */ }
// --- UI Injection ---
function addButtons() { /* ... (same as before, calls the correct handlers) ... */
const pathname = window.location.pathname; const buttonContainerId = 'jnai-button-container';
const existingContainer = document.getElementById(buttonContainerId); if (existingContainer) existingContainer.remove();
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' });
if (pathname === '/create_character' || pathname.startsWith('/edit_character/')) {
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);
const importButton = document.createElement('button'); importButton.textContent = 'Import Card/JSON'; importButton.id = 'jnai-import-button'; importButton.className = 'jnai-button'; importButton.addEventListener('click', () => fileInput.click());
container.appendChild(importButton); if (!document.getElementById(fileInput.id)) document.body.appendChild(fileInput);
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);
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);
}
if (pathname.startsWith('/characters/') && pathname.split('/').length > 2 && pathname.split('/')[2].length > 0) {
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);
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);
}
if (container.hasChildNodes()) document.body.appendChild(container);
else console.log("No buttons added for current page:", pathname);
}
// --- Styling ---
GM_addStyle(`
.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; }
.jnai-button:hover { background-color: #8363a8; } .jnai-button:active { transform: scale(0.98); }
.jnai-button:disabled { background-color: #cccccc; color: #666666; cursor: not-allowed; }
.jnai-button-api { background-color: #5a7a9a; } .jnai-button-api:hover { background-color: #7395b8; }
`);
// --- Initialization ---
console.log(`JanitorAI SillyTavern Importer/Exporter v${SCRIPT_VERSION} Initializing...`);
let currentPage = window.location.pathname;
const observer = new MutationObserver(() => {
const mainContentSelector = 'main, [role="main"], #app main, .css-1t6eusi, .css-lo240v';
const isReady = document.querySelector(mainContentSelector);
const urlChanged = window.location.pathname !== currentPage;
if (isReady && (urlChanged || !document.getElementById('jnai-button-container'))) {
console.log("Page change/load detected, adding/refreshing buttons for:", window.location.pathname);
currentPage = window.location.pathname;
// Increased delay slightly, might help ensure elements are populated
setTimeout(addButtons, 800);
} else if (urlChanged) {
currentPage = window.location.pathname;
}
});
observer.observe(document.body, { childList: true, subtree: true });
// Increased initial delay
setTimeout(addButtons, 1500);
})();