您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Export any adventure or scenario to a local file.
// ==UserScript== // @version 1.1.2 // @name Adventure + Scenario Exporter // @description Export any adventure or scenario to a local file. // @author Magic <[email protected]> // @supportURL https://github.com/magicoflolis/userscriptrepo/issues // @namespace https://github.com/magicoflolis/userscriptrepo/tree/master/userscripts/AIDungeon // @homepageURL https://github.com/magicoflolis/userscriptrepo/tree/master/userscripts/AIDungeon // @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAAFiUAABYlAUlSJPAAAAIuSURBVHhe7di/Li1RFMfxk0iuiKtC0N5GoxaNQrSi8QJa9zXETYSKF9BSqUlEpziJB6AQfzoSuaXm8BvzdWQyM87Y48zs2Wd9khPMXnvt9dsZiWgZY4wxxpiKdTqdCb4dTK+iS9jhx8ETXUBEl3DBo8FC/ne6hFMeDwYF/kX2Lj1bZzl8CrtK7gSWwxf93pM5hZKwkTXPLGXhImguysKk1/+YnLkoDRMZv6RL2qc8LAq2S8ae2BIWshXClnAo03McrRi2hUGv/l9yFcbW5lP4GTJ9C9ubTeFTf/MXRYtmI4sTWjQXOVy1adNMevVfCOJqlFYJej7Ot/5S+IM4gztapXy15gXNNxoNWYYuMPOfpVoaYn2DR/6JBixD4W5olaK1I8r8fAs04BrzOaNVJko+LPPYHwzmTBc4R6sULU/FVZ9Y8oOG/81cTrT/mlaZtP5EaRdLftA8h/FYbmiTi7IEXcoSy/VjJicK8kibTFq/ojRBz88pqR8zOVGQe9qkaHkkrspGWf2Yp4xhWiWwlouy+jFPWUO0e6c3o+ef05TWT8OeMVMp6vOgL+34p944vn4a/A8zVUZn/ud4PzBXZXQBWxztBw20yWyV4Fi/MFslONIvegvmma+vdM4kR/pHw90yZ1+o/yVH+YtZ+4Ij/Me8P4rWzcHcpem1v6Nl85DBmcKv0Kq5FGKbPIVpzwnbw6FQe+TLpZp/lIdNQaf1WdRnQZ8xHhtjjDHGGFOJVusN4nlaFWZwR4AAAAAASUVORK5CYII= // @license MIT // @compatible chrome // @compatible firefox // @compatible edge // @compatible opera // @compatible safari // @connect api-beta.aidungeon.com // @connect play.aidungeon.com // @connect beta.aidungeon.com // @grant unsafeWindow // @grant GM_addElement // @grant GM_info // @grant GM_registerMenuCommand // @grant GM.addElement // @grant GM.info // @grant GM.registerMenuCommand // @match https://play.aidungeon.com/* // @match https://beta.aidungeon.com/* // @noframes // @run-at document-start // ==/UserScript== (() => { 'use strict'; /******************************************************************************/ const inIframe = (() => { try { return window.self !== window.top; } catch (e) { return true; } })(); if (inIframe) { return; } let userjs = self.userjs; /** * Skip text/plain documents, based on uBlock Origin `vapi.js` file * * [source code](https://github.com/gorhill/uBlock/blob/68962453ff6eec7ff109615a738beb8699b9844a/platform/common/vapi.js#L35) */ if ( (document instanceof Document || (document instanceof XMLDocument && document.createElement('div') instanceof HTMLDivElement)) && /^text\/html|^application\/(xhtml|xml)/.test(document.contentType || '') === true && (self.userjs instanceof Object === false || userjs.UserJS !== true) ) { userjs = self.userjs = { UserJS: true }; } else { console.error('[%cAID Script+%c] %cERROR','color: rgb(69, 91, 106);','','color: rgb(249, 24, 128);', `MIME type is not a document, got "${document.contentType || ''}"`); } if (!(typeof userjs === 'object' && userjs.UserJS)) { return; } const translations = { "en": { "aiVersionsJSON": "Export Instructions (JSON)", "aiVersionsTXT": "Export Text Instructions (TXT)", "export_in": "Export in", "Export": "Export", "Import": "Import" } }; const main_css = `mujs-main { min-height: var(--t-size-8, 64px); margin-top: -1px; border-top-color: var(--c-coreA3, rgba(219, 241, 255, 0.22)); pointer-events: auto !important; border-top-style: solid; border-top-width: 1px; flex-direction: row; justify-content: space-between; gap: var(--t-space-3, 24px); align-items: center; position: relative; flex-shrink: 0; min-width: 0px; box-sizing: border-box; flex-basis: auto; display: flex; color: var(--color, rgb(255, 255, 255)); } mujs-main[data-section=play] { display: contents; } mujs-main[data-section=play] .mujs-inp { color: rgb(239, 68, 68); border-color: rgb(223, 49, 38); background-color: rgba(223, 49, 38, 0.1); } mujs-main[data-section=play] .mujs-inp:hover { background-color: rgba(223, 49, 38, 0.2) !important; border-color: rgba(223, 49, 38, 0.1) !important; } mujs-elem { font-family: VoyageIcons; cursor: pointer; display: flex; flex-grow: 1; flex-shrink: 1; flex-basis: auto; flex-direction: row; align-items: center; min-width: 0px; min-height: 0px; position: relative; } mujs-elem.mujs-inp { border-width: 0px !important; } mujs-elem[data-section=play] { padding-left: var(--t-space-2); padding-right: var(--t-space-2); border-radius: var(--t-radius-1); gap: var(--t-space-1); border-color: var(--borderColor); background-color: rgba(199, 231, 255, 0.13); height: var(--t-size-5); overflow: hidden; border-style: solid; border-width: 1px; justify-content: center; box-sizing: border-box; box-shadow: rgba(0, 0, 0, 0.2) 0px 0px 0px 0px; } mujs-elem[data-section=play]:hover { box-shadow: rgba(0, 0, 0, 0.3) 0px 0px 0px 0px !important; } mujs-elem[data-section=play]:not(.mujs-inp):hover { background-color: var(--backgroundHover) !important; border-color: var(--borderColorHover) !important; } mujs-elem[data-section=preview] { gap: var(--t-space-3); box-sizing: border-box; list-style-image: none; list-style-position: outside; list-style-type: none; padding-bottom: var(--t-space-2); padding-top: var(--t-space-2); pointer-events: auto; } mujs-elem[data-section=preview] span[data-section] { font-size: var(--f-size-3, 16px); font-weight: 500; } mujs-elem[data-section=preview] p { font-size: var(--f-size-2, 14px); } mujs-elem p { font-size: var(--f-size-1, 12px); } mujs-elem span[data-section] { font-family: IBMPlexSans; text-transform: uppercase; font-size: var(--f-size-2, 14px); }`; /******************************************************************************/ // #region Console const con = { title: '[%cAID Script%c]', color: 'color: rgb(69, 91, 106);', dbg(...msg) { const dt = new Date(); console.debug( `${con.title} %cDBG`, con.color, '', 'color: rgb(255, 212, 0);', `[${dt.getHours()}:${('0' + dt.getMinutes()).slice(-2)}:${('0' + dt.getSeconds()).slice(-2)}]`, ...msg ); }, err(...msg) { console.error(`${con.title} %cERROR`, con.color, '', 'color: rgb(249, 24, 128);', ...msg); const a = typeof alert !== 'undefined' && alert; const t = con.title.replace(/%c/g, ''); for (const ex of msg) { if (typeof ex === 'object' && 'cause' in ex && a) { a(`${t} (${ex.cause}) ${ex.message}`); } } }, info(...msg) { console.info(`${con.title} %cINF`, con.color, '', 'color: rgb(0, 186, 124);', ...msg); }, log(...msg) { console.log(`${con.title} %cLOG`, con.color, '', 'color: rgb(219, 160, 73);', ...msg); } }; const { err } = con; // #endregion // #region Constants const isMobile = (() => { try { if (navigator) { const { userAgent, userAgentData } = navigator; const { platform, mobile } = userAgentData ? Object(userAgentData) : {}; return ( /Mobile|Tablet/.test(userAgent ? String(userAgent) : '') || Boolean(mobile) || /Android|Apple/.test(platform ? String(platform) : '') ); } } catch (ex) { ex.cause = 'getUAData'; err(ex); } return false; })(); const isGM = typeof GM !== 'undefined'; // #endregion // #region Validators const isArr = (obj) => Array.isArray(obj); /** * @type { import("../typings/shared.d.ts").objToStr } */ const objToStr = (obj) => Object.prototype.toString.call(obj).match(/\[object (.*)\]/)[1]; /** * @type { import("../typings/shared.d.ts").isHTML } */ const isHTML = (obj) => /HTML/.test(objToStr(obj)); /** * @type { import("../typings/shared.d.ts").isObj } */ const isObj = (obj) => /Object/.test(objToStr(obj)); /** * @type { import("../typings/shared.d.ts").isFN } */ const isFN = (obj) => /Function/.test(objToStr(obj)); /** * @type { import("../typings/shared.d.ts").isNull } */ const isNull = (obj) => { return Object.is(obj, null) || Object.is(obj, undefined); }; /** * @type { import("../typings/shared.d.ts").isBlank } */ const isBlank = (obj) => { return ( (typeof obj === 'string' && Object.is(obj.trim(), '')) || ((obj instanceof Set || obj instanceof Map) && Object.is(obj.size, 0)) || (isArr(obj) && Object.is(obj.length, 0)) || (isObj(obj) && Object.is(Object.keys(obj).length, 0)) ); }; /** * @type { import("../typings/shared.d.ts").isEmpty } */ const isEmpty = (obj) => { return isNull(obj) || isBlank(obj); }; // #endregion // #region Utilities /** * @type { import("../typings/shared.d.ts").qs } */ const qs = (selectors, root) => { try { return (root || document).querySelector(selectors); } catch (ex) { err(ex); } return null; }; /** * @type { import("../typings/shared.d.ts").normalizeTarget } */ const normalizeTarget = (target, toQuery = true, root) => { if (Object.is(target, null) || Object.is(target, undefined)) { return []; } if (Array.isArray(target)) { return target; } if (typeof target === 'string') { return toQuery ? Array.from((root || document).querySelectorAll(target)) : Array.of(target); } if (/object HTML/.test(Object.prototype.toString.call(target))) { return Array.of(target); } return Array.from(target); }; /** * @type { import("../typings/shared.d.ts").observe } */ const observe = (element, listener, options = { subtree: true, childList: true }) => { const observer = new MutationObserver(listener); observer.observe(element, options); listener.call(element, [], observer); return observer; }; /** * @type { import("../typings/shared.d.ts").ael } */ const ael = (el, type, listener, options = {}) => { try { for (const elem of normalizeTarget(el).filter(isHTML)) { if (isMobile && type === 'click') { elem.addEventListener('touchstart', listener, options); continue; } elem.addEventListener(type, listener, options); } } catch (ex) { ex.cause = 'ael'; err(ex); } }; /** * @type { import("../typings/shared.d.ts").make } */ const make = (tagName, cname, attrs) => { let el; try { /** * @param {HTMLElement} elem * @param {string|string[]} str */ const addClass = (elem, str) => { const arr = (isArr(str) ? str : typeof str === 'string' ? str.split(' ') : []).filter( (s) => !isEmpty(s) ); return !isEmpty(arr) && elem.classList.add(...arr); }; /** * @type { import("../typings/shared.d.ts").formAttrs } */ const formAttrs = (elem, attr = {}) => { if (!elem) return elem; for (const [key, value] of Object.entries(attr)) { if (typeof value === 'object') { formAttrs(elem[key], value); } else if (isFN(value)) { if (/^on/.test(key)) { elem[key] = value; continue; } ael(elem, key, value); } else if (/^class/i.test(key)) { addClass(elem, value); } else if (elem.tagName === 'A' && /^(download|type)/i.test(key)) { elem.setAttribute(key, value); } else { elem[key] = value; } } return elem; }; el = document.createElement(tagName); if ((typeof cname === 'string' || isArr(cname)) && !isEmpty(cname)) addClass(el, cname); if (typeof attrs === 'string' && !isEmpty(attrs)) el.textContent = attrs; formAttrs(el, (isObj(cname) && cname) || (isObj(attrs) && attrs) || {}); } catch (ex) { if (ex instanceof DOMException) throw new Error(`${ex.name}: ${ex.message}`, { cause: 'make' }); ex.cause = 'make'; err(ex); } return el; }; //#endregion // #region i18n const Language = class { static i18nMap = new Map(Object.entries(translations)); /** * @param {string | Date | number} str */ static toDate(str = '') { const { navigator: { language } } = window; return new Intl.DateTimeFormat(language).format(typeof str === 'string' ? new Date(str) : str); } /** * @param {number | bigint} number */ static toNumber(number) { const { navigator: { language } } = window; return new Intl.NumberFormat(language).format(number); } /** * @type { import("../typings/UserJS.d.ts").i18n$ } */ static i18n$(key) { try { return Language.i18nMap.get(Language.current)?.[key] ?? 'Invalid Key'; } catch (e) { err(e); return 'error'; } } static get current() { const { navigator: { language } } = window; const [current = 'en'] = language.split('-'); return current; } }; const { i18n$ } = Language; // #endregion const $info = (() => { if (isGM) { if (isObj(GM.info)) { return GM.info; } else if (isObj(GM_info)) { return GM_info; } } return { script: { icon: '', name: 'Adventure + Scenario Exporter', namespace: 'https://github.com/magicoflolis/userscriptrepo/tree/master/userscripts/AIDungeon', updateURL: 'https://github.com/magicoflolis/userscriptrepo/raw/refs/heads/master/userscripts/AIDungeon/dist/main-userjs.user.js', version: 'Bookmarklet', bugs: 'https://github.com/magicoflolis/userscriptrepo/issues' } }; })(); /** * @param { string } css - CSS to inject * @param { string } name - Name of stylesheet * @param { boolean } [useGM=true] - Use `GM_addElement` instead of default method * @return { HTMLStyleElement | undefined } Style element */ const loadCSS = (css, name = 'CSS', useGM = true) => { try { if (userjs.stylesheet) return userjs.stylesheet; if (typeof name !== 'string') throw new Error('"name" must be a typeof "string"', { cause: 'loadCSS' }); if (typeof css !== 'string') throw new Error('"css" must be a typeof "string"', { cause: 'loadCSS' }); if (isBlank(css)) throw new Error(`"${name}" contains empty CSS string`, { cause: 'loadCSS' }); const parent = document.head ?? document.body ?? document.documentElement; if (useGM && isGM) { const fn = (typeof GM.addElement !== 'undefined' && isFN(GM.addElement) && GM.addElement) || (typeof GM_addElement !== 'undefined' && isFN(GM_addElement) && GM_addElement); if (!isFN(fn)) return loadCSS(css, name, false); const sty = fn(parent, 'style', { textContent: css }); if (/Element/.test(objToStr(sty))) { sty.dataset.insertedBy = $info.script.name; sty.dataset.role = name; userjs.stylesheet = sty; return userjs.stylesheet; } } const sty = make('style', { textContent: css, dataset: { insertedBy: $info.script.name, role: name } }); parent.appendChild(sty); userjs.stylesheet = sty; return userjs.stylesheet; } catch (ex) { err(ex); } }; /** * @type { import("../typings/shared.d.ts").Network } */ const Network = { async req(url, method = 'GET', responseType = 'json', data) { if (isEmpty(url)) throw new Error('"url" parameter is empty'); data = Object.assign({}, data); method = this.bscStr(method, false); responseType = this.bscStr(responseType); const params = { method, ...data }; return new Promise((resolve, reject) => { fetch(url, params) .then((response_1) => { if (!response_1.ok) reject(response_1); const check = (str_2 = 'text') => { return isFN(response_1[str_2]) ? response_1[str_2]() : response_1; }; if (responseType.match(/buffer/)) { resolve(check('arrayBuffer')); } else if (responseType.match(/json/)) { resolve(check('json')); } else if (responseType.match(/text/)) { resolve(check('text')); } else if (responseType.match(/blob/)) { resolve(check('blob')); } else if (responseType.match(/formdata/)) { resolve(check('formData')); } else if (responseType.match(/clone/)) { resolve(check('clone')); } else if (responseType.match(/document/)) { const respTxt = check('text'); const domParser = new DOMParser(); if (respTxt instanceof Promise) { respTxt.then((txt) => { const doc = domParser.parseFromString(txt, 'text/html'); resolve(doc); }); } else { const doc = domParser.parseFromString(respTxt, 'text/html'); resolve(doc); } } else { resolve(response_1); } }) .catch(reject); }); }, prog(evt) { return Object.is(evt.total, 0) ? '0%' : `${+((evt.loaded / evt.total) * 100).toFixed(2)}%`; }, bscStr(str = '', lowerCase = true) { return str[lowerCase ? 'toLowerCase' : 'toUpperCase']().replaceAll(/\W/g, ''); } }; /** * @template { {url: string | {}; filename?: string; type?: string} } D * @param {D} details */ const doDownloadProcess = (details) => { if (!isObj(details) || !details.url) return; details.url = `data:text/plain;charset=utf-8,${encodeURIComponent(isObj(details.url) ? JSON.stringify(details.url, null, ' ') : details.url)}`; const a = make('a', { download: details.filename || 'file', href: details.url, type: details.type || 'text/plain' }); a.dispatchEvent(new MouseEvent('click')); }; const Command = { cmds: new Set(), register(text, command) { if (!isGM) { return; } if (isFN(command)) { if (this.cmds.has(command)) { return; } this.cmds.add(command); } if (isFN(GM.registerMenuCommand)) { GM.registerMenuCommand(text, command); } else if (isFN(GM_registerMenuCommand)) { GM_registerMenuCommand(text, command); } } }; /** * @param {boolean} clear * @returns {Promise<string>} */ const getToken = (clear = false) => { return new Promise((resolve, reject) => { if (!clear && userjs.accessToken !== undefined) resolve(userjs.accessToken); const win = typeof unsafeWindow !== 'undefined' && unsafeWindow || window; const dbReq = win.indexedDB.open('firebaseLocalStorageDb'); dbReq.onerror = reject; dbReq.onsuccess = (event) => { const transaction = event.target.result.transaction(['firebaseLocalStorage'], 'readwrite'); const objectStore = transaction.objectStore('firebaseLocalStorage'); const allKeys = objectStore.getAllKeys(); allKeys.onerror = reject; allKeys.onsuccess = (evt) => { const key = evt.target.result.find((r) => r.includes('firebase:authUser:')); objectStore.get(key).onsuccess = (evt) => { const { value } = evt.target.result; userjs.accessToken = value.stsTokenManager.accessToken; resolve(userjs.accessToken); }; }; }; }); }; const dataStructure = class { /** @type { import("../typings/types.d.ts").dataStructure<this["token"]>["headers"] } */ #_; /** @type {string} */ token; /** @type {string} */ operationName; /** @type {{[key: string]: any}} */ variables; /** @type {string} */ query; constructor(accessToken) { this.token = accessToken; } get headers() { return this.#_; } set headers(data) { this.#_ = { authorization: `firebase ${this.token}`, 'content-type': 'application/json', 'x-gql-operation-name': data, 'Sec-GPC': '1', 'Sec-Fetch-Dest': 'empty', 'Sec-Fetch-Mode': 'cors', 'Sec-Fetch-Site': 'same-site', Priority: 'u=4' }; } get body() { return JSON.stringify(this); } set body(data) { this.operationName = data.operationName; this.variables = data.variables ?? {}; this.query = data.query; } format() { const { headers, body } = this; return { headers, body, referrer: location.origin }; } toJSON() { const { operationName, variables, query } = this; return { operationName, variables, query }; } }; /** * @type { import("../typings/types.d.ts").fromGraphQL } */ const fromGraphQL = async (type, shortId) => { const resp = { data: {} }; try { /** @type { import("../typings/types.d.ts").Templates } */ const template = { adventure: { headers: { 'x-gql-operation-name': 'GetGameplayAdventure' }, body: { operationName: 'GetGameplayAdventure', variables: { shortId, limit: 1000000, desc: true }, query: 'query GetGameplayAdventure($shortId: String, $limit: Int, $offset: Int, $desc: Boolean) {\n adventure(shortId: $shortId) {\n id\n publicId\n shortId\n scenarioId\n instructions\n title\n description\n tags\n nsfw\n isOwner\n userJoined\n gameState\n actionCount\n contentType\n createdAt\n showComments\n commentCount\n allowComments\n voteCount\n userVote\n editedAt\n published\n unlisted\n deletedAt\n saveCount\n isSaved\n user {\n id\n isCurrentUser\n isMember\n profile {\n id\n title\n thumbImageUrl\n __typename\n }\n __typename\n }\n shortCode\n thirdPerson\n imageStyle\n memory\n authorsNote\n image\n actionWindow(limit: $limit, offset: $offset, desc: $desc) {\n id\n imageText\n ...ActionSubscriptionAction\n __typename\n }\n allPlayers {\n ...PlayerSubscriptionPlayer\n __typename\n }\n storyCards {\n id\n ...StoryCard\n __typename\n }\n __typename\n }\n}\n\nfragment ActionSubscriptionAction on Action {\n id\n text\n type\n imageUrl\n shareUrl\n imageText\n adventureId\n decisionId\n undoneAt\n deletedAt\n createdAt\n logId\n __typename\n}\n\nfragment PlayerSubscriptionPlayer on Player {\n id\n userId\n characterName\n isTypingAt\n user {\n id\n isMember\n profile {\n id\n title\n thumbImageUrl\n __typename\n }\n __typename\n }\n createdAt\n deletedAt\n blockedAt\n __typename\n}\n\nfragment StoryCard on StoryCard {\n id\n type\n keys\n value\n title\n useForCharacterCreation\n description\n updatedAt\n deletedAt\n __typename\n}' } }, adventureDetails: { body: { operationName: 'GetAdventureDetails', variables: { shortId }, query: 'query GetAdventureDetails($shortId: String) {\n adventureState(shortId: $shortId) {\n id\n details\n __typename\n }\n}' } }, scenario: { headers: { 'x-gql-operation-name': 'GetScenario' }, body: { operationName: 'GetScenario', variables: { shortId }, query: 'query GetScenario($shortId: String) {\n scenario(shortId: $shortId) {\n id\n contentType\n createdAt\n editedAt\n publicId\n shortId\n title\n description\n prompt\n memory\n authorsNote\n image\n isOwner\n published\n unlisted\n allowComments\n showComments\n commentCount\n voteCount\n userVote\n saveCount\n storyCardCount\n isSaved\n tags\n adventuresPlayed\n thirdPerson\n nsfw\n contentRating\n contentRatingLockedAt\n contentRatingLockedMessage\n tags\n type\n details\n parentScenario {\n id\n shortId\n title\n __typename\n }\n user {\n isCurrentUser\n isMember\n profile {\n title\n thumbImageUrl\n __typename\n }\n __typename\n }\n options {\n id\n userId\n shortId\n title\n prompt\n parentScenarioId\n deletedAt\n __typename\n }\n storyCards {\n id\n ...StoryCard\n __typename\n }\n ...CardSearchable\n __typename\n }\n}\n\nfragment CardSearchable on Searchable {\n id\n contentType\n publicId\n shortId\n title\n description\n image\n tags\n userVote\n voteCount\n published\n unlisted\n publishedAt\n createdAt\n isOwner\n editedAt\n deletedAt\n blockedAt\n isSaved\n saveCount\n commentCount\n userId\n contentRating\n user {\n id\n isMember\n profile {\n id\n title\n thumbImageUrl\n __typename\n }\n __typename\n }\n ... on Adventure {\n actionCount\n userJoined\n playPublicId\n unlisted\n playerCount\n __typename\n }\n ... on Scenario {\n adventuresPlayed\n __typename\n }\n __typename\n}\n\nfragment StoryCard on StoryCard {\n id\n type\n keys\n value\n title\n useForCharacterCreation\n description\n updatedAt\n deletedAt\n __typename\n}' } }, scenarioScripting: { operationName: 'GetScenarioScripting', variables: { shortId }, query: 'query GetScenarioScripting($shortId: String) {\n scenario(shortId: $shortId) {\n gameCodeSharedLibrary\n gameCodeOnInput\n gameCodeOnOutput\n gameCodeOnModelContext\n recentScriptLogs\n lastModelContext\n }\n}' }, aiVersions: { headers: { 'x-gql-operation-name': 'GetAiVersions' }, body: { operationName: 'GetAiVersions', variables: {}, query: 'query GetAiVersions {\n aiVisibleVersions {\n success\n message\n aiVisibleVersions {\n id\n type\n versionName\n aiDetails\n aiSettings\n access\n release\n available\n instructions\n engineNameEngine {\n engineName\n available\n availableSettings\n __typename\n }\n __typename\n }\n visibleTextVersions {\n id\n type\n versionName\n aiDetails\n aiSettings\n access\n release\n available\n instructions\n engineNameEngine {\n engineName\n available\n availableSettings\n __typename\n }\n __typename\n }\n visibleImageVersions {\n id\n type\n versionName\n aiDetails\n aiSettings\n access\n release\n available\n instructions\n engineNameEngine {\n engineName\n available\n availableSettings\n __typename\n }\n __typename\n }\n __typename\n }\n}' } }, importStoryCards: { headers: { 'x-gql-operation-name': 'ImportStoryCards' }, body: { operationName: 'ImportStoryCards', variables: { input: shortId }, query: 'mutation ImportStoryCards($input: ImportStoryCardsInput!) { importStoryCards(input: $input) { success message storyCards { keys value type __typename } __typename }}' } }, UpdateScenario: { headers: { 'x-gql-operation-name': 'UpdateScenario' }, body: { operationName: 'UpdateScenario', variables: { input: shortId }, query: 'mutation UpdateScenario($input: ScenarioInput) { updateScenario(input: $input) { scenario { id title description prompt memory authorsNote tags nsfw contentRating contentRatingLockedAt contentRatingLockedMessage published thirdPerson allowComments unlisted image uploadId type details editedAt __typename } message success __typename }}' } }, UpdateScenarioScripts: { headers: { 'x-gql-operation-name': 'UpdateScenarioScripts' }, body: { operationName: 'UpdateScenarioScripts', variables: shortId, query: 'mutation UpdateScenarioScripts($shortId: String, $gameCode: JSONObject) { updateScenarioScripts(shortId: $shortId, gameCode: $gameCode) { success message scenario { id gameCodeSharedLibrary gameCodeOnInput gameCodeOnOutput gameCodeOnModelContext __typename } __typename }}' } }, UpdateOptionTitle: { headers: { 'x-gql-operation-name': 'UpdateOptionTitle' }, body: { operationName: 'UpdateOptionTitle', variables: { input: shortId }, query: 'mutation UpdateOptionTitle($input: ScenarioInput) { updateScenario(input: $input) { scenario { id shortId title prompt parentScenarioId deletedAt __typename } message success __typename }}' } }, UpdateAdventureState: { headers: { 'x-gql-operation-name': 'UpdateAdventureState' }, body: { operationName: 'UpdateAdventureState', variables: { input: shortId }, query: 'mutation UpdateAdventureState($input: AdventureStateInput) { updateAdventureState(input: $input) { adventure { id details editedAt __typename } message success __typename }}' } }, UpdateAdventurePlot: { headers: { 'x-gql-operation-name': 'UpdateAdventurePlot' }, body: { operationName: 'UpdateAdventurePlot', variables: { input: shortId }, query: 'mutation UpdateAdventurePlot($input: AdventurePlotInput) { updateAdventurePlot(input: $input) { adventure { id thirdPerson memory authorsNote editedAt __typename } message success __typename }}' } } }; const sel = template[type]; if (!sel) { return resp; } const accessToken = await getToken(); const ds = new dataStructure(accessToken); ds.headers = sel.headers; ds.body = sel.body; const req = await Network.req('https://api.aidungeon.com/graphql', 'POST', 'json', ds.format()); if (/adventure/.test(type)) { ds.body = template['adventureDetails']; const state = await Network.req( 'https://api.aidungeon.com/graphql', 'POST', 'json', ds.format() ); Object.assign(resp.data, { ...req.data, ...state.data }); return resp; } else if (/scenario/.test(type)) { ds.body = template['scenarioScripting']; const state = await Network.req( 'https://api.aidungeon.com/graphql', 'POST', 'json', ds.format() ); if (state.data && state.data.scenario) { const { scenario } = state.data; for (const [k, v] of Object.entries(scenario)) { if (k in req.data.scenario) continue; Object.assign(req.data.scenario, { [k]: v }); } Object.assign(resp.data, { ...req.data }); return resp; } } Object.assign(resp, req); return resp; } catch (ex) { err(ex); } return resp; }; const codeBlock = (language, content) => { return content === undefined ? `\`\`\`\n${language}\n\`\`\`` : `\`\`\`${language}\n${content}\n\`\`\``; }; const add = (s = '', l = 50, template = '-') => { let base = ''; while (base.length < l / 2) { base += template; } base += s; while (base.length < l * 2) { base += template; } return base; }; /** * @param {string} fileFormat * @param {'Export'|'Import'} type * @param {import("../typings/types.d.ts").fromPath} content */ const startDownload = async (fileFormat = 'json', type = 'Export', content = {}) => { const [,contentType, shortId] = /\/(adventure|scenario)\/([\w-]+)\/.+(\/)?/.exec(location.pathname) ?? []; if (!contentType) throw new Error('Navigate to an adventure or scenario first!', { cause: 'startDownload' }); if (type === 'Import') { if (isEmpty(content)) throw new Error('"content" field is empty', { cause: 'startDownload' }); const r = content.data.adventure ?? content.data.scenario; const fetchRecords = []; const update = { adventureState: { shortId, details: r.details }, adventurePlot: { shortId, memory: r.memory, authorsNote: r.authorsNote, thirdPerson: r.thirdPerson }, scenario: { shortId, title: r.title, description: r.description, prompt: r.prompt, memory: r.memory, authorsNote: r.authorsNote, tags: r.tags, contentRating: r.contentRating, thirdPerson: r.thirdPerson, allowComments: r.allowComments, image: r.image, type: r.type, details: r.details }, scripting: { shortId, gameCode: { onInput: r.gameCodeOnInput, onModelContext: r.gameCodeOnModelContext, onOutput: r.gameCodeOnOutput, sharedLibrary: r.gameCodeSharedLibrary } }, storyCards: { contentType, shortId, storyCards: isArr(r.storyCards) && r.storyCards.map(({ type, keys, value, title, description, useForCharacterCreation }) => { return { type, keys, value, title, description, useForCharacterCreation }; }) } }; if (contentType === 'scenario' && content.data.scenario) { fetchRecords.push( fromGraphQL('UpdateScenario', update.scenario).catch(err), fromGraphQL('UpdateScenarioScripts', update.scripting).catch(err) ); } else if (contentType === 'adventure') { if (r.details) { fetchRecords.push(fromGraphQL('UpdateAdventureState', update.adventureState).catch(err)); } fetchRecords.push(fromGraphQL('UpdateAdventurePlot', update.adventurePlot).catch(err)); } if (isArr(r.storyCards)) { fetchRecords.push(fromGraphQL('importStoryCards', update.storyCards)); } const records = await Promise.allSettled(fetchRecords); const msgs = ['Page reload required!']; for (const { value: { data } } of records) { const key = Object.keys(data)[0]; const resp = data[key]; if (resp.success === false) throw new Error(`Failed to import "${key}"`, { cause: 'startDownload' }); msgs.push(`${resp.message} - ${key}`); } alert(msgs.join('\n\n')); return; } /** * @type { import("../typings/types.d.ts").fromPath } */ const obj = await fromGraphQL(contentType, shortId); const root = obj.data.adventure ?? obj.data.scenario; if (obj.data.scenario && isArr(obj.data.scenario.options)) { for (const opt of root.options.filter((o) => o.shortId !== shortId)) { const r = await fromGraphQL(contentType, opt.shortId).catch(err); if (r.data) Object.assign(opt, { ...r.data.scenario }); } } const arr = []; let str; if (fileFormat === 'txt') { /** * @param { import("../typings/types.d.ts").storyCard[] } storyCard */ const storycards = (storyCard) => { const a = []; const count = { loaded: 0, total: storyCard.length * 1000 }; for (const card of storyCard) { const c = []; let title = ''; for (const [k, v] of Object.entries(card)) { if (isEmpty(v)) continue; if (k === 'keys') { c.push(`TRIGGERS: ${v}`); } else if (k === 'value') { count.loaded += v.length; const p = Network.prog({ loaded: v.length, total: 1000 }); c.push(`ENTRY (${v.length}/1000=${p}): ${v}`); } else if (k === 'description') { c.push(`NOTES: ${v}`); } else if (k === 'type') { c.push(`${k.toUpperCase()}: ${v}`); } else if (k === 'prompt') { c.push(v); } else if (k === 'title') { title = v; } } a.push(`${title ? `${title} ` : ''}[\n ${c.join('\n ')}\n]`); } return { ...count, percent: Network.prog(count), cards: a.join('\n') }; }; /** * @param { import("../typings/types.d.ts").actionWindow[] } actions */ const actionWindow = (actions) => { const a = []; for (const action of actions) { const c = []; for (const [k, v] of Object.entries(action)) { if (isEmpty(v)) continue; if (k === 'text') { c.push(v); } else if (k === 'type') { c.push(`${add(v.toUpperCase(), 25, '=')}\n`); } } a.push(c.join('\n')); } return a.join('\n'); }; for (const [k, v] of Object.entries(root)) { if (isEmpty(v)) continue; if (/title|description|prompt/.test(k)) { arr.push(`${add(k.toUpperCase())}\n${v}`); } else if (/memory/.test(k)) { arr.push(`${add('PLOT ESSENTIALS')}\n${v}`); } else if (/authorsNote/.test(k)) { arr.push(`${add("AUTHOR'S NOTE")}\n${v}`); } else if (/storyCards/.test(k)) { const sc = storycards(v); arr.push( `${add(`STORY CARDS [${v.length} cards | ${sc.loaded}/${sc.total}=${sc.percent}]`)}\n${sc.cards}` ); } else if (/actionWindow/.test(k)) { arr.push(`${add('ACTIONS')}\n${actionWindow(v)}`); } else if (/options/.test(k)) { arr.push(`${add('OPTIONS')}\n${storycards(v).cards}`); } else if (/details/.test(k)) { for (const [key, value] of Object.entries(v)) { if (isEmpty(value)) continue; if (/instructions/.test(key)) { arr.push(`${add('AI Instructions')}\n${JSON.stringify(value, null, ' ')}`); } else { arr.push(`${add(key.toUpperCase())}\n${value}`); } } } else if (/gameCodeSharedLibrary/.test(k)) { arr.push(`${add('SCRIPTING LIBRARY')}\n${v}`); } else if (/gameCodeOnInput/.test(k)) { arr.push(`${add('SCRIPTS INPUT')}\n${v}`); } else if (/gameCodeOnOutput/.test(k)) { arr.push(`${add('SCRIPTS OUTPUT')}\n${v}`); } else if (/gameCodeOnModelContext/.test(k)) { arr.push(`${add('SCRIPTS CONTEXT')}\n${v}`); } else if (/lastModelContext/.test(k)) { arr.push(`${add('SCRIPTS MODEL CONTEXT')}\n${v}`); } else if (/recentScriptLogs/.test(k)) { arr.push(`${add('SCRIPT LOGS')}\n${v.join('\n')}`); } } str = arr.join('\n'); } else if (fileFormat === 'md') { const mkSection = (innerHTML = '') => { const d = make('details'); const s = make('summary', { innerHTML }); d.append(s); return d; }; const toBlock = (s) => `\n\n${codeBlock('txt', s)}\n\n`; if (isArr(root.storyCards)) { const section = mkSection(`Story Cards (${root.storyCards.length})`); for (const card of root.storyCards) { const s = mkSection(`${card.title} (${card.type})`); s.innerHTML += `${toBlock(card.value)}${toBlock(card.keys)}${toBlock(card.description)}`; section.append(s); } arr.push(section.outerHTML); } if (isArr(root.actionWindow)) { const section = mkSection(`Actions (${root.actionWindow.length})`); for (const action of root.actionWindow) { const createdAt = new Date(action.createdAt); const s = mkSection( `${createdAt.toLocaleString(navigator.language)} ${action.type.toUpperCase()}` ); s.innerHTML += `\n\n${codeBlock('txt', action.text)}\n\n${codeBlock('txt', `# Discord TimeStamp\n<t:${+createdAt}></t:${+createdAt}>`)}\n\n`; section.append(s); } arr.push(section.outerHTML); } str = arr.join('\n'); } doDownloadProcess({ url: fileFormat === 'json' ? obj : str, filename: `${root.title}_${root.shortId}.${contentType}.${fileFormat}` }); }; /** * @template {HTMLElement} P * @param {P} parent * @param {string} section * @param {string} fileFormat * @param {'Export'|'Import'} type */ const inject = (parent, section = 'play', fileFormat = 'json', type = 'Export') => { if (!parent) return; if (section === 'play' && type === 'Export') { parent.style = 'flex-direction: column;'; } const o = { Export: { class: 'mujs-btn', color: 't_coreA1', text: 'w_export' }, Import: { class: 'mujs-inp', color: 't_redA', text: 'w_import' } }; if (qs(`.${o[type].class}[data-file-format="${fileFormat}"]`)) return; const parts = /\/(adventure|scenario)\/([\w-]+)\/.+(\/)?/.exec(location.pathname); const rootType = parts && parts[1]; const btn = make('mujs-elem', o[type].class, { dataset: { fileFormat, section } }); const txt = make('span', { textContent: `${i18n$(type)} ${rootType} (${fileFormat.toUpperCase()})`, dataset: { section } }); const ico = make('p', { textContent: o[type].text }); let inpJSON; const span = make('mujs-main', { dataset: { section } }); ico.importantforaccessibility = 'no'; ico['aria-hidden'] = true; btn.append(ico, txt); ael(btn, 'click', async (evt) => { evt.preventDefault(); if (type === 'Export') { await startDownload(fileFormat, type).catch(err); } else if (type === 'Import' && inpJSON) { inpJSON.click(); } }); span.append(btn); parent.appendChild(span); if (type === 'Import') { inpJSON = make('input', { type: 'file', accept: '.json', style: 'display: none;', onchange(evt) { const [file] = evt.target.files; if (file === undefined || file.name === '') return; const fr = new FileReader(); fr.onload = function () { if (typeof fr.result !== 'string') return; const content = JSON.parse(fr.result); startDownload(fileFormat, type, content).catch(err); }; fr.readAsText(file); } }); parent.appendChild(inpJSON); } }; /** * @template F * @param { (this: F, doc: Document) => * } onDomReady */ const loadDOM = (onDomReady) => { if (isFN(onDomReady)) { if (document.readyState === 'interactive' || document.readyState === 'complete') { onDomReady(document); } else { document.addEventListener('DOMContentLoaded', (evt) => onDomReady(evt.target), { once: true }); } } }; loadDOM((doc) => { try { if (window.location === null) { throw new Error('"window.location" is null, reload the webpage or use a different one', { cause: 'loadDOM' }); } if (doc === null) { throw new Error('"doc" is null, reload the webpage or use a different one', { cause: 'loadDOM' }); } if (isNull(loadCSS(main_css, 'primary-stylesheet'))) throw new Error('Failed to initialize script!', { cause: 'loadCSS' }); const fileFormats = ['json', 'txt', 'md']; const ignoreTags = new Set(['br', 'head', 'link', 'meta', 'script', 'style']); observe(doc, (mutations) => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.nodeType !== 1) continue; if (ignoreTags.has(node.localName)) continue; if (node.parentElement === null) continue; if (!(node instanceof HTMLElement)) continue; let n = null; n = qs('div[aria-label="Content stats"]', node); if (n) { for (const f of fileFormats) inject(n, 'preview', f); inject(n, 'preview', 'json', 'Import'); } n = qs('span > div.is_Column > div.is_Column > div.is_Row', node); if (node.localName === 'div' && n) { for (const f of fileFormats) inject(n, 'play', f); inject(n, 'play', 'json', 'Import'); } n = qs('div.css-175oi2r > div.is_Column > div.is_Column > div.is_Column > div.is_Row:nth-child(3)', node); if (n) { for (const f of fileFormats) inject(n, 'play', f); inject(n, 'play', 'json', 'Import'); } } } }); Command.register(i18n$('aiVersionsJSON'), async () => { try { const o = await fromGraphQL('aiVersions'); if (!o.data.aiVisibleVersions) throw new Error('failed to load', { cause: `${i18n$('aiVersionsJSON')} - aiVersions` }); doDownloadProcess({ url: o, filename: `Instructions-${Date.now()}.json` }); } catch (e) { err(e); } }); Command.register(i18n$('aiVersionsTXT'), async () => { try { const o = await fromGraphQL('aiVersions'); const root = o.data.aiVisibleVersions; if (!root) throw new Error('failed to load', { cause: `${i18n$('aiVersionsTXT')} - aiVersions` }); const arr = []; const $line = (v, end = '\n') => { arr.push(`${v}${end}`); }; for (const v of root.visibleTextVersions) { $line(add(v.aiDetails.title)); $line(v.instructions, '\n\n'); } doDownloadProcess({ url: arr.join('\n'), filename: `Text_Versions-${Date.now()}.txt` }); } catch (e) { err(e); } }); for (const f of fileFormats) { Command.register(`${i18n$('export_in')} (${f.toUpperCase()})`, async () => { await startDownload(f).catch(err); }); } } catch (ex) { err(ex); } }); })();