mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
Merge pull request #1318 from artisticMink/feature/bulk-edit-enhanced
Feature/bulk edit enhanced
This commit is contained in:
98
public/css/character-group-overlay.css
Normal file
98
public/css/character-group-overlay.css
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
#rm_print_characters_block .character_select,
|
||||||
|
#rm_print_characters_block .group_select{
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#rm_print_characters_block.group_overlay_mode_select .character_select {
|
||||||
|
transition: background-color 0.4s ease;
|
||||||
|
margin-bottom: 1px;
|
||||||
|
background-color: rgba(170, 170, 170, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
#rm_print_characters_block.group_overlay_mode_select .character_select input.bulk_select_checkbox {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#rm_print_characters_block.group_overlay_mode_select .character_select.character_selected {
|
||||||
|
background-color: var(--SmartThemeQuoteColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
#rm_print_characters_block.group_overlay_mode_select .character_select .bulk_select_checkbox {
|
||||||
|
visibility: hidden;
|
||||||
|
height: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#character_context_menu.hidden { display: none; }
|
||||||
|
#character_context_menu {
|
||||||
|
position: absolute;
|
||||||
|
padding: 3px;
|
||||||
|
z-index: 10000;
|
||||||
|
background-color: var(--black90a);
|
||||||
|
border: 1px solid var(--black90a);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#character_context_menu ul li button {
|
||||||
|
border: 0;
|
||||||
|
border-bottom-color: currentcolor;
|
||||||
|
color: var(--SmartThemeQuoteColor);
|
||||||
|
background-color: transparent;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1em;
|
||||||
|
padding: 0.5em;
|
||||||
|
border-bottom: 1px dotted var(--SmartThemeQuoteColor);
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#character_context_menu ul li button:hover {
|
||||||
|
background-color: var(--SmartThemeBlurTintColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
#character_context_menu ul li:last-child button {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#character_context_menu ul li #character_context_menu_delete {
|
||||||
|
color: var(--fullred);
|
||||||
|
}
|
||||||
|
|
||||||
|
#character_context_menu ul {
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#character_context_menu .character_context_menu_separator {
|
||||||
|
height: 1px;
|
||||||
|
background-color: var(--SmartThemeBotMesBlurTintColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
#character_context_menu li:hover {
|
||||||
|
background-color: var(--SmartThemeBotMesBlurTintColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
#bulkEditButton.bulk_edit_overlay_active {
|
||||||
|
color: var(--golden);
|
||||||
|
}
|
||||||
|
|
||||||
|
#bulk_tag_shadow_popup {
|
||||||
|
backdrop-filter: blur(calc(var(--SmartThemeBlurStrength) * 2));
|
||||||
|
-webkit-backdrop-filter: blur(calc(var(--SmartThemeBlurStrength) * 2));
|
||||||
|
background-color: var(--black30a);
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
height: 100svh;
|
||||||
|
z-index: 9998;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bulk_tag_shadow_popup #bulk_tag_popup {
|
||||||
|
padding: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bulk_tag_shadow_popup #bulk_tag_popup #dialogue_popup_controls .menu_button {
|
||||||
|
width: 100px;
|
||||||
|
padding: 0.25em;
|
||||||
|
}
|
@ -1,3 +1,4 @@
|
|||||||
|
#bulk_tags_div,
|
||||||
#tags_div {
|
#tags_div {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@ -86,10 +87,12 @@
|
|||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#bulkTagsList,
|
||||||
#tagList.tags {
|
#tagList.tags {
|
||||||
margin: 5px 0;
|
margin: 5px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#bulkTagsList,
|
||||||
#tagList .tag {
|
#tagList .tag {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
@ -100,6 +100,16 @@
|
|||||||
<div id="bg_custom"></div>
|
<div id="bg_custom"></div>
|
||||||
<div id="bg1"></div>
|
<div id="bg1"></div>
|
||||||
|
|
||||||
|
<div id="character_context_menu" class="hidden">
|
||||||
|
<ul>
|
||||||
|
<li><button id="character_context_menu_favorite">Favorite</button></li>
|
||||||
|
<li><button id="character_context_menu_tag">Tag</button></li>
|
||||||
|
<li><button id="character_context_menu_duplicate">Duplicate</button></li>
|
||||||
|
<li><button id="character_context_menu_persona">Persona</button></li>
|
||||||
|
<li><button id="character_context_menu_delete">Delete</button></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- top bar central settings buttons -->
|
<!-- top bar central settings buttons -->
|
||||||
<div id="top-bar">
|
<div id="top-bar">
|
||||||
</div>
|
</div>
|
||||||
@ -4309,6 +4319,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="character_template" class="template_element">
|
<div id="character_template" class="template_element">
|
||||||
<div class="character_select flex-container wide100p alignitemsflexstart" chid="" id="">
|
<div class="character_select flex-container wide100p alignitemsflexstart" chid="" id="">
|
||||||
<div class="avatar" title="">
|
<div class="avatar" title="">
|
||||||
|
@ -187,6 +187,7 @@ import { getFriendlyTokenizerName, getTokenCount, getTokenizerModel, initTokeniz
|
|||||||
import { initPersonas, selectCurrentPersona, setPersonaDescription } from "./scripts/personas.js";
|
import { initPersonas, selectCurrentPersona, setPersonaDescription } from "./scripts/personas.js";
|
||||||
import { getBackgrounds, initBackgrounds } from "./scripts/backgrounds.js";
|
import { getBackgrounds, initBackgrounds } from "./scripts/backgrounds.js";
|
||||||
import { hideLoader, showLoader } from "./scripts/loader.js";
|
import { hideLoader, showLoader } from "./scripts/loader.js";
|
||||||
|
import {CharacterContextMenu, BulkEditOverlay} from "./scripts/BulkEditOverlay.js";
|
||||||
|
|
||||||
//exporting functions and vars for mods
|
//exporting functions and vars for mods
|
||||||
export {
|
export {
|
||||||
@ -299,6 +300,9 @@ export const event_types = {
|
|||||||
OAI_PRESET_CHANGED_AFTER: 'oai_preset_changed_after',
|
OAI_PRESET_CHANGED_AFTER: 'oai_preset_changed_after',
|
||||||
WORLDINFO_SETTINGS_UPDATED: 'worldinfo_settings_updated',
|
WORLDINFO_SETTINGS_UPDATED: 'worldinfo_settings_updated',
|
||||||
CHARACTER_EDITED: 'character_edited',
|
CHARACTER_EDITED: 'character_edited',
|
||||||
|
CHARACTER_PAGE_LOADED: 'character_page_loaded',
|
||||||
|
CHARACTER_GROUP_OVERLAY_STATE_CHANGE_BEFORE: 'character_group_overlay_state_change_before',
|
||||||
|
CHARACTER_GROUP_OVERLAY_STATE_CHANGE_AFTER: 'character_group_overlay_state_change_after',
|
||||||
USER_MESSAGE_RENDERED: 'user_message_rendered',
|
USER_MESSAGE_RENDERED: 'user_message_rendered',
|
||||||
CHARACTER_MESSAGE_RENDERED: 'character_message_rendered',
|
CHARACTER_MESSAGE_RENDERED: 'character_message_rendered',
|
||||||
FORCE_SET_BACKGROUND: 'force_set_background',
|
FORCE_SET_BACKGROUND: 'force_set_background',
|
||||||
@ -316,6 +320,10 @@ eventSource.on(event_types.CHAT_CHANGED, displayOverrideWarnings);
|
|||||||
eventSource.on(event_types.MESSAGE_RECEIVED, processExtensionHelpers);
|
eventSource.on(event_types.MESSAGE_RECEIVED, processExtensionHelpers);
|
||||||
eventSource.on(event_types.MESSAGE_SENT, processExtensionHelpers);
|
eventSource.on(event_types.MESSAGE_SENT, processExtensionHelpers);
|
||||||
|
|
||||||
|
const characterGroupOverlay = new BulkEditOverlay();
|
||||||
|
const characterContextMenu = new CharacterContextMenu(characterGroupOverlay);
|
||||||
|
eventSource.on(event_types.CHARACTER_PAGE_LOADED, characterGroupOverlay.onPageLoad);
|
||||||
|
|
||||||
hljs.addPlugin({ "before:highlightElement": ({ el }) => { el.textContent = el.innerText } });
|
hljs.addPlugin({ "before:highlightElement": ({ el }) => { el.textContent = el.innerText } });
|
||||||
|
|
||||||
// Markdown converter
|
// Markdown converter
|
||||||
@ -1048,6 +1056,7 @@ async function printCharacters(fullRefresh = false) {
|
|||||||
$("#rm_print_characters_block").append(getGroupBlock(i.item));
|
$("#rm_print_characters_block").append(getGroupBlock(i.item));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
eventSource.emit(event_types.CHARACTER_PAGE_LOADED);
|
||||||
},
|
},
|
||||||
afterSizeSelectorChange: function (e) {
|
afterSizeSelectorChange: function (e) {
|
||||||
localStorage.setItem(storageKey, e.target.value);
|
localStorage.setItem(storageKey, e.target.value);
|
||||||
@ -1076,7 +1085,7 @@ export function getEntitiesList({ doFilter } = {}) {
|
|||||||
return entities;
|
return entities;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getOneCharacter(avatarUrl) {
|
export async function getOneCharacter(avatarUrl) {
|
||||||
const response = await fetch("/getonecharacter", {
|
const response = await fetch("/getonecharacter", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: getRequestHeaders(),
|
headers: getRequestHeaders(),
|
||||||
@ -5523,6 +5532,8 @@ export async function getChatsFromFiles(data, isGroupChat) {
|
|||||||
* in descending order by file name. Returns `undefined` if the fetch request is unsuccessful.
|
* in descending order by file name. Returns `undefined` if the fetch request is unsuccessful.
|
||||||
*/
|
*/
|
||||||
async function getPastCharacterChats() {
|
async function getPastCharacterChats() {
|
||||||
|
if (!characters.includes(this_chid)) return;
|
||||||
|
|
||||||
const response = await fetch("/getallchatsofcharacter", {
|
const response = await fetch("/getallchatsofcharacter", {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ avatar_url: characters[this_chid].avatar }),
|
body: JSON.stringify({ avatar_url: characters[this_chid].avatar }),
|
||||||
@ -7204,7 +7215,8 @@ function doCloseChat() {
|
|||||||
* @param {boolean} delete_chats - Whether to delete chats or not.
|
* @param {boolean} delete_chats - Whether to delete chats or not.
|
||||||
*/
|
*/
|
||||||
export async function handleDeleteCharacter(popup_type, this_chid, delete_chats) {
|
export async function handleDeleteCharacter(popup_type, this_chid, delete_chats) {
|
||||||
if (popup_type !== "del_ch") {
|
if (popup_type !== "del_ch" ||
|
||||||
|
!characters[this_chid]) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
571
public/scripts/BulkEditOverlay.js
Normal file
571
public/scripts/BulkEditOverlay.js
Normal file
@ -0,0 +1,571 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
import {
|
||||||
|
callPopup,
|
||||||
|
characters,
|
||||||
|
deleteCharacter,
|
||||||
|
event_types,
|
||||||
|
eventSource,
|
||||||
|
getCharacters,
|
||||||
|
getRequestHeaders,
|
||||||
|
printCharacters,
|
||||||
|
this_chid
|
||||||
|
} from "../script.js";
|
||||||
|
|
||||||
|
import {favsToHotswap} from "./RossAscends-mods.js";
|
||||||
|
import {convertCharacterToPersona} from "./personas.js";
|
||||||
|
import {createTagInput, getTagKeyForCharacter, tag_map} from "./tags.js";
|
||||||
|
|
||||||
|
// Utility object for popup messages.
|
||||||
|
const popupMessage = {
|
||||||
|
deleteChat(characterCount) {
|
||||||
|
return `<h3>Delete ${characterCount} characters?</h3>
|
||||||
|
<b>THIS IS PERMANENT!<br><br>
|
||||||
|
<label for="del_char_checkbox" class="checkbox_label justifyCenter">
|
||||||
|
<input type="checkbox" id="del_char_checkbox" />
|
||||||
|
<span>Also delete the chat files</span>
|
||||||
|
</label><br></b>`;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static object representing the actions of the
|
||||||
|
* character context menu override.
|
||||||
|
*/
|
||||||
|
class CharacterContextMenu {
|
||||||
|
/**
|
||||||
|
* Tag one or more characters,
|
||||||
|
* opens a popup.
|
||||||
|
*
|
||||||
|
* @param selectedCharacters
|
||||||
|
*/
|
||||||
|
static tag = (selectedCharacters) => {
|
||||||
|
BulkTagPopupHandler.show(selectedCharacters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duplicate one or more characters
|
||||||
|
*
|
||||||
|
* @param characterId
|
||||||
|
* @returns {Promise<Response>}
|
||||||
|
*/
|
||||||
|
static duplicate = async (characterId) => {
|
||||||
|
const character = CharacterContextMenu.#getCharacter(characterId);
|
||||||
|
|
||||||
|
return fetch('/dupecharacter', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getRequestHeaders(),
|
||||||
|
body: JSON.stringify({ avatar_url: character.avatar }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Favorite a character
|
||||||
|
* and highlight it.
|
||||||
|
*
|
||||||
|
* @param characterId
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
static favorite = async (characterId) => {
|
||||||
|
const character = CharacterContextMenu.#getCharacter(characterId);
|
||||||
|
|
||||||
|
// Only set fav for V2 spec
|
||||||
|
const data = {
|
||||||
|
name: character.name,
|
||||||
|
avatar: character.avatar,
|
||||||
|
data: {
|
||||||
|
extensions: {
|
||||||
|
fav: !character.data.extensions.fav
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return fetch('/v2/editcharacterattribute', {
|
||||||
|
method: "POST",
|
||||||
|
headers: getRequestHeaders(),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}).then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
const element = document.getElementById(`CharID${characterId}`);
|
||||||
|
element.classList.toggle('is_fav');
|
||||||
|
} else {
|
||||||
|
response.json().then(json => toastr.error('Character not saved. Error: ' + json.message + '. Field: ' + json.error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert one or more characters to persona,
|
||||||
|
* may open a popup for one or more characters.
|
||||||
|
*
|
||||||
|
* @param characterId
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
static persona = async (characterId) => await convertCharacterToPersona(characterId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete one or more characters,
|
||||||
|
* opens a popup.
|
||||||
|
*
|
||||||
|
* @param characterId
|
||||||
|
* @param deleteChats
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
static delete = async (characterId, deleteChats = false) => {
|
||||||
|
const character = CharacterContextMenu.#getCharacter(characterId);
|
||||||
|
|
||||||
|
return fetch('/deletecharacter', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getRequestHeaders(),
|
||||||
|
body: JSON.stringify({ avatar_url: character.avatar , delete_chats: deleteChats }),
|
||||||
|
cache: 'no-cache',
|
||||||
|
}).then(response => {
|
||||||
|
if (response.ok) {
|
||||||
|
deleteCharacter(character.name, character.avatar).then(() => {
|
||||||
|
if (deleteChats) {
|
||||||
|
fetch("/getallchatsofcharacter", {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ avatar_url: character.avatar }),
|
||||||
|
headers: getRequestHeaders(),
|
||||||
|
}).then( (response) => {
|
||||||
|
let data = response.json();
|
||||||
|
data = Object.values(data);
|
||||||
|
const pastChats = data.sort((a, b) => a["file_name"].localeCompare(b["file_name"])).reverse();
|
||||||
|
|
||||||
|
for (const chat of pastChats) {
|
||||||
|
const name = chat.file_name.replace('.jsonl', '');
|
||||||
|
eventSource.emit(event_types.CHAT_DELETED, name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
eventSource.emit('characterDeleted', { id: this_chid, character: characters[this_chid] });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static #getCharacter = (characterId) => characters[characterId] ?? null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the context menu at the given position
|
||||||
|
*
|
||||||
|
* @param positionX
|
||||||
|
* @param positionY
|
||||||
|
*/
|
||||||
|
static show = (positionX, positionY) => {
|
||||||
|
let contextMenu = document.getElementById(BulkEditOverlay.contextMenuId);
|
||||||
|
contextMenu.style.left = `${positionX}px`;
|
||||||
|
contextMenu.style.top = `${positionY}px`;
|
||||||
|
|
||||||
|
document.getElementById(BulkEditOverlay.contextMenuId).classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the context menu
|
||||||
|
*/
|
||||||
|
static hide = () => document.getElementById(BulkEditOverlay.contextMenuId).classList.add('hidden');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up the context menu for the given overlay
|
||||||
|
*
|
||||||
|
* @param characterGroupOverlay
|
||||||
|
*/
|
||||||
|
constructor(characterGroupOverlay) {
|
||||||
|
const contextMenuItems = [
|
||||||
|
{id: 'character_context_menu_favorite', callback: characterGroupOverlay.handleContextMenuFavorite},
|
||||||
|
{id: 'character_context_menu_duplicate', callback: characterGroupOverlay.handleContextMenuDuplicate},
|
||||||
|
{id: 'character_context_menu_delete', callback: characterGroupOverlay.handleContextMenuDelete},
|
||||||
|
{id: 'character_context_menu_persona', callback: characterGroupOverlay.handleContextMenuPersona},
|
||||||
|
{id: 'character_context_menu_tag', callback: characterGroupOverlay.handleContextMenuTag}
|
||||||
|
];
|
||||||
|
|
||||||
|
contextMenuItems.forEach(contextMenuItem => document.getElementById(contextMenuItem.id).addEventListener('click', contextMenuItem.callback))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a tag control not bound to a single character
|
||||||
|
*/
|
||||||
|
class BulkTagPopupHandler {
|
||||||
|
static #getHtml = (characterIds) => {
|
||||||
|
const characterData = JSON.stringify({characterIds: characterIds});
|
||||||
|
return `<div id="bulk_tag_shadow_popup">
|
||||||
|
<div id="bulk_tag_popup">
|
||||||
|
<div id="bulk_tag_popup_holder">
|
||||||
|
<h3 class="m-b-1">Add tags to ${characterIds.length} characters</h3>
|
||||||
|
<br>
|
||||||
|
<div id="bulk_tags_div" class="marginBot5" data-characters='${characterData}'>
|
||||||
|
<div class="tag_controls">
|
||||||
|
<input id="bulkTagInput" class="text_pole tag_input wide100p margin0" data-i18n="[placeholder]Search / Create Tags" placeholder="Search / Create tags" maxlength="25" />
|
||||||
|
<div class="tags_view menu_button fa-solid fa-tags" title="View all tags" data-i18n="[title]View all tags"></div>
|
||||||
|
</div>
|
||||||
|
<div id="bulkTagList" class="m-t-1 tags"></div>
|
||||||
|
</div>
|
||||||
|
<div id="dialogue_popup_controls" class="m-t-1">
|
||||||
|
<div id="bulk_tag_popup_cancel" class="menu_button" data-i18n="Cancel">Close</div>
|
||||||
|
<div id="bulk_tag_popup_reset" class="menu_button" data-i18n="Cancel">Remove all</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append and show the tag control
|
||||||
|
*
|
||||||
|
* @param characters - The characters assigned to this control
|
||||||
|
*/
|
||||||
|
static show(characters) {
|
||||||
|
document.body.insertAdjacentHTML('beforeend', this.#getHtml(characters));
|
||||||
|
createTagInput('#bulkTagInput', '#bulkTagList');
|
||||||
|
document.querySelector('#bulk_tag_popup_cancel').addEventListener('click', this.hide.bind(this));
|
||||||
|
document.querySelector('#bulk_tag_popup_reset').addEventListener('click', this.resetTags.bind(this, characters));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide and remove the tag control
|
||||||
|
*/
|
||||||
|
static hide() {
|
||||||
|
let popupElement = document.querySelector('#bulk_tag_shadow_popup');
|
||||||
|
if (popupElement) {
|
||||||
|
document.body.removeChild(popupElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
printCharacters(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty the tag map for the given characters
|
||||||
|
*
|
||||||
|
* @param characterIds
|
||||||
|
*/
|
||||||
|
static resetTags(characterIds) {
|
||||||
|
characterIds.forEach((characterId) => {
|
||||||
|
const key = getTagKeyForCharacter(characterId);
|
||||||
|
if (key) tag_map[key] = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
printCharacters(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BulkEditOverlayState {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
static browse = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
static select = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implement a SingletonPattern, allowing access to the group overlay instance
|
||||||
|
* from everywhere via (new CharacterGroupOverlay())
|
||||||
|
*
|
||||||
|
* @type BulkEditOverlay
|
||||||
|
*/
|
||||||
|
let bulkEditOverlayInstance = null;
|
||||||
|
|
||||||
|
class BulkEditOverlay {
|
||||||
|
static containerId = 'rm_print_characters_block';
|
||||||
|
static contextMenuId = 'character_context_menu';
|
||||||
|
static characterClass = 'character_select';
|
||||||
|
static selectModeClass = 'group_overlay_mode_select';
|
||||||
|
static selectedClass = 'character_selected';
|
||||||
|
static legacySelectedClass = 'bulk_select_checkbox';
|
||||||
|
|
||||||
|
static longPressDelay = 1500;
|
||||||
|
|
||||||
|
#state = BulkEditOverlayState.browse;
|
||||||
|
#longPress = false;
|
||||||
|
#stateChangeCallbacks = [];
|
||||||
|
#selectedCharacters = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type HTMLElement
|
||||||
|
*/
|
||||||
|
container = null;
|
||||||
|
|
||||||
|
get state() {
|
||||||
|
return this.#state;
|
||||||
|
}
|
||||||
|
|
||||||
|
set state(newState) {
|
||||||
|
if (this.#state === newState) return;
|
||||||
|
|
||||||
|
eventSource.emit(event_types.CHARACTER_GROUP_OVERLAY_STATE_CHANGE_BEFORE, newState)
|
||||||
|
.then(() => {
|
||||||
|
this.#state = newState;
|
||||||
|
eventSource.emit(event_types.CHARACTER_GROUP_OVERLAY_STATE_CHANGE_AFTER, this.state)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get isLongPress() {
|
||||||
|
return this.#longPress;
|
||||||
|
}
|
||||||
|
|
||||||
|
set isLongPress(longPress) {
|
||||||
|
this.#longPress = longPress;
|
||||||
|
}
|
||||||
|
|
||||||
|
get stateChangeCallbacks() {
|
||||||
|
return this.#stateChangeCallbacks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @returns {*[]}
|
||||||
|
*/
|
||||||
|
get selectedCharacters() {
|
||||||
|
return this.#selectedCharacters;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (bulkEditOverlayInstance instanceof BulkEditOverlay)
|
||||||
|
return bulkEditOverlayInstance
|
||||||
|
|
||||||
|
this.container = document.getElementById(BulkEditOverlay.containerId);
|
||||||
|
this.container.addEventListener('click', this.handleCancelClick);
|
||||||
|
|
||||||
|
eventSource.on(event_types.CHARACTER_GROUP_OVERLAY_STATE_CHANGE_AFTER, this.handleStateChange);
|
||||||
|
bulkEditOverlayInstance = Object.freeze(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the overlay to browse mode
|
||||||
|
*/
|
||||||
|
browseState = () => this.state = BulkEditOverlayState.browse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the overlay to select mode
|
||||||
|
*/
|
||||||
|
selectState = () => this.state = BulkEditOverlayState.select;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up a Sortable grid for the loaded page
|
||||||
|
*/
|
||||||
|
onPageLoad = () => {
|
||||||
|
this.browseState();
|
||||||
|
|
||||||
|
const elements = this.#getEnabledElements();
|
||||||
|
elements.forEach(element => element.addEventListener('touchstart', this.handleHold));
|
||||||
|
elements.forEach(element => element.addEventListener('mousedown', this.handleHold));
|
||||||
|
|
||||||
|
elements.forEach(element => element.addEventListener('touchend', this.handleLongPressEnd));
|
||||||
|
elements.forEach(element => element.addEventListener('mouseup', this.handleLongPressEnd));
|
||||||
|
elements.forEach(element => element.addEventListener('dragend', this.handleLongPressEnd));
|
||||||
|
|
||||||
|
const grid = document.getElementById(BulkEditOverlay.containerId);
|
||||||
|
grid.addEventListener('click', this.handleCancelClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle state changes
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
handleStateChange = () => {
|
||||||
|
switch (this.state) {
|
||||||
|
case BulkEditOverlayState.browse:
|
||||||
|
this.container.classList.remove(BulkEditOverlay.selectModeClass);
|
||||||
|
this.#enableClickEventsForCharacters();
|
||||||
|
this.clearSelectedCharacters();
|
||||||
|
this.disableContextMenu();
|
||||||
|
this.#disableBulkEditButtonHighlight();
|
||||||
|
CharacterContextMenu.hide();
|
||||||
|
break;
|
||||||
|
case BulkEditOverlayState.select:
|
||||||
|
this.container.classList.add(BulkEditOverlay.selectModeClass);
|
||||||
|
this.#disableClickEventsForCharacters();
|
||||||
|
this.enableContextMenu();
|
||||||
|
this.#enableBulkEditButtonHighlight();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stateChangeCallbacks.forEach(callback => callback(this.state));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block the browsers native context menu and
|
||||||
|
* set a click event to hide the custom context menu.
|
||||||
|
*/
|
||||||
|
enableContextMenu = () => {
|
||||||
|
document.addEventListener('contextmenu', this.handleContextMenuShow);
|
||||||
|
document.addEventListener('click', this.handleContextMenuHide);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove event listeners, allowing the native browser context
|
||||||
|
* menu to be opened.
|
||||||
|
*/
|
||||||
|
disableContextMenu = () => {
|
||||||
|
document.removeEventListener('contextmenu', this.handleContextMenuShow);
|
||||||
|
document.removeEventListener('click', this.handleContextMenuHide);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHold = (event) => {
|
||||||
|
if (0 !== event.button && event.type !== 'touchstart') return;
|
||||||
|
|
||||||
|
// Prevent call for mobile browser context menu on long-press.
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
this.isLongPress = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.isLongPress) {
|
||||||
|
if (this.state === BulkEditOverlayState.browse)
|
||||||
|
this.selectState();
|
||||||
|
else if (this.state === BulkEditOverlayState.select)
|
||||||
|
CharacterContextMenu.show(...this.#getContextMenuPosition(event));
|
||||||
|
}
|
||||||
|
}, BulkEditOverlay.longPressDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLongPressEnd = () => {
|
||||||
|
this.isLongPress = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCancelClick = () => {
|
||||||
|
this.state = BulkEditOverlayState.browse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the position of the mouse/touch location
|
||||||
|
*
|
||||||
|
* @param event
|
||||||
|
* @returns {(boolean|number|*)[]}
|
||||||
|
*/
|
||||||
|
#getContextMenuPosition = (event) => [
|
||||||
|
event.clientX || event.touches[0].clientX,
|
||||||
|
event.clientY || event.touches[0].clientY,
|
||||||
|
];
|
||||||
|
|
||||||
|
#enableClickEventsForCharacters = () => this.#getEnabledElements().forEach(element => element.removeEventListener('click', this.toggleCharacterSelected));
|
||||||
|
|
||||||
|
#disableClickEventsForCharacters = () => this.#getEnabledElements().forEach(element => element.addEventListener('click', this.toggleCharacterSelected));
|
||||||
|
|
||||||
|
#enableBulkEditButtonHighlight = () => document.getElementById('bulkEditButton').classList.add('bulk_edit_overlay_active');
|
||||||
|
|
||||||
|
#disableBulkEditButtonHighlight = () => document.getElementById('bulkEditButton').classList.remove('bulk_edit_overlay_active');
|
||||||
|
|
||||||
|
#getEnabledElements = () => [...this.container.getElementsByClassName(BulkEditOverlay.characterClass)];
|
||||||
|
|
||||||
|
toggleCharacterSelected = event => {
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const character = event.currentTarget;
|
||||||
|
const characterId = character.getAttribute('chid');
|
||||||
|
|
||||||
|
const alreadySelected = this.selectedCharacters.includes(characterId)
|
||||||
|
|
||||||
|
const legacyBulkEditCheckbox = character.querySelector('.' + BulkEditOverlay.legacySelectedClass);
|
||||||
|
|
||||||
|
if (alreadySelected) {
|
||||||
|
character.classList.remove(BulkEditOverlay.selectedClass);
|
||||||
|
if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = false;
|
||||||
|
this.dismissCharacter(characterId);
|
||||||
|
} else {
|
||||||
|
character.classList.add(BulkEditOverlay.selectedClass)
|
||||||
|
if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = true;
|
||||||
|
this.selectCharacter(characterId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleContextMenuShow = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
document.getElementById(BulkEditOverlay.containerId).style.pointerEvents = 'none';
|
||||||
|
CharacterContextMenu.show(...this.#getContextMenuPosition(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
handleContextMenuHide = (event) => {
|
||||||
|
document.getElementById(BulkEditOverlay.containerId).style.pointerEvents = '';
|
||||||
|
let contextMenu = document.getElementById(BulkEditOverlay.contextMenuId);
|
||||||
|
if (false === contextMenu.contains(event.target)) {
|
||||||
|
CharacterContextMenu.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concurrently handle character favorite requests.
|
||||||
|
*
|
||||||
|
* @returns {Promise<number>}
|
||||||
|
*/
|
||||||
|
handleContextMenuFavorite = () => Promise.all(this.selectedCharacters.map(async characterId => CharacterContextMenu.favorite(characterId)))
|
||||||
|
.then(() => getCharacters())
|
||||||
|
.then(() => favsToHotswap())
|
||||||
|
.then(() => this.browseState())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concurrently handle character duplicate requests.
|
||||||
|
*
|
||||||
|
* @returns {Promise<number>}
|
||||||
|
*/
|
||||||
|
handleContextMenuDuplicate = () => Promise.all(this.selectedCharacters.map(async characterId => CharacterContextMenu.duplicate(characterId)))
|
||||||
|
.then(() => getCharacters())
|
||||||
|
.then(() => this.browseState())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sequentially handle all character-to-persona conversions.
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
handleContextMenuPersona = async () => {
|
||||||
|
for (const characterId of this.selectedCharacters) {
|
||||||
|
await CharacterContextMenu.persona(characterId)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.browseState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request user input before concurrently handle deletion
|
||||||
|
* requests.
|
||||||
|
*
|
||||||
|
* @returns {Promise<number>}
|
||||||
|
*/
|
||||||
|
handleContextMenuDelete = () => {
|
||||||
|
callPopup(
|
||||||
|
popupMessage.deleteChat(this.selectedCharacters.length), null)
|
||||||
|
.then((accept) => {
|
||||||
|
if (true !== accept) return;
|
||||||
|
|
||||||
|
const deleteChats = document.getElementById('del_char_checkbox').checked ?? false;
|
||||||
|
|
||||||
|
Promise.all(this.selectedCharacters.map(async characterId => CharacterContextMenu.delete(characterId, deleteChats)))
|
||||||
|
.then(() => getCharacters())
|
||||||
|
.then(() => this.browseState())}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attaches and opens the tag menu
|
||||||
|
*/
|
||||||
|
handleContextMenuTag = () => {
|
||||||
|
CharacterContextMenu.tag(this.selectedCharacters);
|
||||||
|
}
|
||||||
|
|
||||||
|
addStateChangeCallback = callback => this.stateChangeCallbacks.push(callback);
|
||||||
|
|
||||||
|
selectCharacter = characterId => this.selectedCharacters.push(String(characterId));
|
||||||
|
|
||||||
|
dismissCharacter = characterId => this.#selectedCharacters = this.selectedCharacters.filter(item => String(characterId) !== item);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears internal character storage and
|
||||||
|
* removes visual highlight.
|
||||||
|
*/
|
||||||
|
clearSelectedCharacters = () => {
|
||||||
|
document.querySelectorAll('#' + BulkEditOverlay.containerId + ' .' + BulkEditOverlay.selectedClass)
|
||||||
|
.forEach( element => element.classList.remove(BulkEditOverlay.selectedClass));
|
||||||
|
this.selectedCharacters.length = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {BulkEditOverlayState, CharacterContextMenu, BulkEditOverlay};
|
@ -1,24 +1,44 @@
|
|||||||
import { characters, getCharacters, handleDeleteCharacter, callPopup } from "../script.js";
|
import { characters, getCharacters, handleDeleteCharacter, callPopup } from "../script.js";
|
||||||
|
import {BulkEditOverlay, BulkEditOverlayState} from "./BulkEditOverlay.js";
|
||||||
|
|
||||||
|
|
||||||
let is_bulk_edit = false;
|
let is_bulk_edit = false;
|
||||||
|
|
||||||
|
const enableBulkEdit = () => {
|
||||||
|
enableBulkSelect();
|
||||||
|
(new BulkEditOverlay()).selectState();
|
||||||
|
// show the delete button
|
||||||
|
$("#bulkDeleteButton").show();
|
||||||
|
is_bulk_edit = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const disableBulkEdit = () => {
|
||||||
|
disableBulkSelect();
|
||||||
|
(new BulkEditOverlay()).browseState();
|
||||||
|
// hide the delete button
|
||||||
|
$("#bulkDeleteButton").hide();
|
||||||
|
is_bulk_edit = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleBulkEditMode = (isBulkEdit) => {
|
||||||
|
if (isBulkEdit) {
|
||||||
|
disableBulkEdit();
|
||||||
|
} else {
|
||||||
|
enableBulkEdit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(new BulkEditOverlay()).addStateChangeCallback((state) => {
|
||||||
|
if (state === BulkEditOverlayState.select) enableBulkEdit();
|
||||||
|
if (state === BulkEditOverlayState.browse) disableBulkEdit();
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggles bulk edit mode on/off when the edit button is clicked.
|
* Toggles bulk edit mode on/off when the edit button is clicked.
|
||||||
*/
|
*/
|
||||||
function onEditButtonClick() {
|
function onEditButtonClick() {
|
||||||
console.log("Edit button clicked");
|
console.log("Edit button clicked");
|
||||||
// toggle bulk edit mode
|
toggleBulkEditMode(is_bulk_edit);
|
||||||
if (is_bulk_edit) {
|
|
||||||
disableBulkSelect();
|
|
||||||
// hide the delete button
|
|
||||||
$("#bulkDeleteButton").hide();
|
|
||||||
is_bulk_edit = false;
|
|
||||||
} else {
|
|
||||||
enableBulkSelect();
|
|
||||||
// show the delete button
|
|
||||||
$("#bulkDeleteButton").show();
|
|
||||||
is_bulk_edit = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -42,16 +42,18 @@ async function createDummyPersona() {
|
|||||||
await uploadUserAvatar(default_avatar);
|
await uploadUserAvatar(default_avatar);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function convertCharacterToPersona() {
|
export async function convertCharacterToPersona(characterId = null) {
|
||||||
const avatarUrl = characters[this_chid]?.avatar;
|
|
||||||
|
|
||||||
|
if (null === characterId) characterId = this_chid;
|
||||||
|
|
||||||
|
const avatarUrl = characters[characterId]?.avatar;
|
||||||
if (!avatarUrl) {
|
if (!avatarUrl) {
|
||||||
console.log("No avatar found for this character");
|
console.log("No avatar found for this character");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = characters[this_chid]?.name;
|
const name = characters[characterId]?.name;
|
||||||
let description = characters[this_chid]?.description;
|
let description = characters[characterId]?.description;
|
||||||
const overwriteName = `${name} (Persona).png`;
|
const overwriteName = `${name} (Persona).png`;
|
||||||
|
|
||||||
if (overwriteName in power_user.personas) {
|
if (overwriteName in power_user.personas) {
|
||||||
|
@ -137,8 +137,12 @@ function getTagKey() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTagToMap(tagId) {
|
export function getTagKeyForCharacter(characterId = null) {
|
||||||
const key = getTagKey();
|
return characters[characterId]?.avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTagToMap(tagId, characterId = null) {
|
||||||
|
const key = getTagKey() ?? getTagKeyForCharacter(characterId);
|
||||||
|
|
||||||
if (!key) {
|
if (!key) {
|
||||||
return;
|
return;
|
||||||
@ -152,8 +156,8 @@ function addTagToMap(tagId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeTagFromMap(tagId) {
|
function removeTagFromMap(tagId, characterId = null) {
|
||||||
const key = getTagKey();
|
const key = getTagKey() ?? getTagKeyForCharacter(characterId);
|
||||||
|
|
||||||
if (!key) {
|
if (!key) {
|
||||||
return;
|
return;
|
||||||
@ -197,7 +201,17 @@ function selectTag(event, ui, listSelector) {
|
|||||||
// add tag to the UI and internal map
|
// add tag to the UI and internal map
|
||||||
appendTagToList(listSelector, tag, { removable: true });
|
appendTagToList(listSelector, tag, { removable: true });
|
||||||
appendTagToList(getInlineListSelector(), tag, { removable: false });
|
appendTagToList(getInlineListSelector(), tag, { removable: false });
|
||||||
|
|
||||||
|
// Optional, check for multiple character ids being present.
|
||||||
|
const characterData = event.target.closest('#bulk_tags_div')?.dataset.characters;
|
||||||
|
const characterIds = characterData ? JSON.parse(characterData).characterIds : null;
|
||||||
|
|
||||||
|
if (characterIds) {
|
||||||
|
characterIds.forEach((characterId) => addTagToMap(tag.id,characterId));
|
||||||
|
} else {
|
||||||
addTagToMap(tag.id);
|
addTagToMap(tag.id);
|
||||||
|
}
|
||||||
|
|
||||||
saveSettingsDebounced();
|
saveSettingsDebounced();
|
||||||
printTagFilters(tag_filter_types.character);
|
printTagFilters(tag_filter_types.character);
|
||||||
printTagFilters(tag_filter_types.group_member);
|
printTagFilters(tag_filter_types.group_member);
|
||||||
@ -383,8 +397,19 @@ function onTagRemoveClick(event) {
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const tag = $(this).closest(".tag");
|
const tag = $(this).closest(".tag");
|
||||||
const tagId = tag.attr("id");
|
const tagId = tag.attr("id");
|
||||||
|
|
||||||
|
// Optional, check for multiple character ids being present.
|
||||||
|
const characterData = event.target.closest('#bulk_tags_div')?.dataset.characters;
|
||||||
|
const characterIds = characterData ? JSON.parse(characterData).characterIds : null;
|
||||||
|
|
||||||
tag.remove();
|
tag.remove();
|
||||||
|
|
||||||
|
if (characterIds) {
|
||||||
|
characterIds.forEach((characterId) => removeTagFromMap(tagId, characterId));
|
||||||
|
} else {
|
||||||
removeTagFromMap(tagId);
|
removeTagFromMap(tagId);
|
||||||
|
}
|
||||||
|
|
||||||
$(`${getInlineListSelector()} .tag[id="${tagId}"]`).remove();
|
$(`${getInlineListSelector()} .tag[id="${tagId}"]`).remove();
|
||||||
|
|
||||||
printTagFilters(tag_filter_types.character);
|
printTagFilters(tag_filter_types.character);
|
||||||
@ -439,7 +464,7 @@ function applyTagsOnGroupSelect() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTagInput(inputSelector, listSelector) {
|
export function createTagInput(inputSelector, listSelector) {
|
||||||
$(inputSelector)
|
$(inputSelector)
|
||||||
.autocomplete({
|
.autocomplete({
|
||||||
source: (i, o) => findTag(i, o, listSelector),
|
source: (i, o) => findTag(i, o, listSelector),
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
@import url(css/promptmanager.css);
|
@import url(css/promptmanager.css);
|
||||||
@import url(css/loader.css);
|
@import url(css/loader.css);
|
||||||
|
@import url(css/character-group-overlay.css);
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--doc-height: 100%;
|
--doc-height: 100%;
|
||||||
@ -1863,6 +1864,7 @@ grammarly-extension {
|
|||||||
|
|
||||||
/* Focus */
|
/* Focus */
|
||||||
|
|
||||||
|
#bulk_tag_popup,
|
||||||
#dialogue_popup {
|
#dialogue_popup {
|
||||||
width: 500px;
|
width: 500px;
|
||||||
max-width: 90vw;
|
max-width: 90vw;
|
||||||
@ -1905,6 +1907,7 @@ grammarly-extension {
|
|||||||
width: unset !important;
|
width: unset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#bulk_tag_popup_holder,
|
||||||
#dialogue_popup_holder {
|
#dialogue_popup_holder {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -1925,6 +1928,7 @@ grammarly-extension {
|
|||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#bulk_tag_popup_reset,
|
||||||
#dialogue_popup_ok {
|
#dialogue_popup_ok {
|
||||||
background-color: var(--crimson70a);
|
background-color: var(--crimson70a);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -1935,6 +1939,7 @@ grammarly-extension {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#bulk_tag_popup_cancel,
|
||||||
#dialogue_popup_cancel {
|
#dialogue_popup_cancel {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
42
server.js
42
server.js
@ -57,7 +57,7 @@ const characterCardParser = require('./src/character-card-parser.js');
|
|||||||
const contentManager = require('./src/content-manager');
|
const contentManager = require('./src/content-manager');
|
||||||
const statsHelpers = require('./statsHelpers.js');
|
const statsHelpers = require('./statsHelpers.js');
|
||||||
const { readSecret, migrateSecrets, SECRET_KEYS } = require('./src/secrets');
|
const { readSecret, migrateSecrets, SECRET_KEYS } = require('./src/secrets');
|
||||||
const { delay, getVersion } = require('./src/util');
|
const { delay, getVersion, deepMerge} = require('./src/util');
|
||||||
const { invalidateThumbnail, ensureThumbnailCache } = require('./src/thumbnails');
|
const { invalidateThumbnail, ensureThumbnailCache } = require('./src/thumbnails');
|
||||||
const { getTokenizerModel, getTiktokenTokenizer, loadTokenizers, TEXT_COMPLETION_MODELS } = require('./src/tokenizers');
|
const { getTokenizerModel, getTiktokenTokenizer, loadTokenizers, TEXT_COMPLETION_MODELS } = require('./src/tokenizers');
|
||||||
const { convertClaudePrompt } = require('./src/chat-completion');
|
const { convertClaudePrompt } = require('./src/chat-completion');
|
||||||
@ -208,6 +208,7 @@ const AVATAR_HEIGHT = 600;
|
|||||||
const jsonParser = express.json({ limit: '100mb' });
|
const jsonParser = express.json({ limit: '100mb' });
|
||||||
const urlencodedParser = express.urlencoded({ extended: true, limit: '100mb' });
|
const urlencodedParser = express.urlencoded({ extended: true, limit: '100mb' });
|
||||||
const { DIRECTORIES, UPLOADS_PATH, PALM_SAFETY } = require('./src/constants');
|
const { DIRECTORIES, UPLOADS_PATH, PALM_SAFETY } = require('./src/constants');
|
||||||
|
const {TavernCardValidator} = require("./src/validator/TavernCardValidator");
|
||||||
|
|
||||||
// CSRF Protection //
|
// CSRF Protection //
|
||||||
if (cliArguments.disableCsrf === false) {
|
if (cliArguments.disableCsrf === false) {
|
||||||
@ -1167,6 +1168,45 @@ app.post("/editcharacterattribute", jsonParser, async function (request, respons
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a POST request to edit character properties.
|
||||||
|
*
|
||||||
|
* Merges the request body with the selected character and
|
||||||
|
* validates the result against TavernCard V2 specification.
|
||||||
|
*
|
||||||
|
* @param {Object} request - The HTTP request object.
|
||||||
|
* @param {Object} response - The HTTP response object.
|
||||||
|
*
|
||||||
|
* @returns {void}
|
||||||
|
* */
|
||||||
|
app.post("/v2/editcharacterattribute", jsonParser, async function (request, response) {
|
||||||
|
const update = request.body;
|
||||||
|
const avatarPath = path.join(charactersPath, update.avatar);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let character = JSON.parse(await charaRead(avatarPath));
|
||||||
|
character = deepMerge(character, update);
|
||||||
|
|
||||||
|
const validator = new TavernCardValidator(character);
|
||||||
|
|
||||||
|
//Accept either V1 or V2.
|
||||||
|
if (validator.validate()) {
|
||||||
|
await charaWrite(
|
||||||
|
avatarPath,
|
||||||
|
JSON.stringify(character),
|
||||||
|
(update.avatar).replace('.png', ''),
|
||||||
|
response,
|
||||||
|
'Character saved'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(validator.lastValidationError)
|
||||||
|
response.status(400).send({message: `Validation failed for ${character.name}`, error: validator.lastValidationError});
|
||||||
|
}
|
||||||
|
} catch (exception) {
|
||||||
|
response.status(500).send({message: 'Unexpected error while saving character.', error: exception.toString()});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/deletecharacter", jsonParser, async function (request, response) {
|
app.post("/deletecharacter", jsonParser, async function (request, response) {
|
||||||
if (!request.body || !request.body.avatar_url) {
|
if (!request.body || !request.body.avatar_url) {
|
||||||
return response.sendStatus(400);
|
return response.sendStatus(400);
|
||||||
|
22
src/util.js
22
src/util.js
@ -196,6 +196,27 @@ async function readAllChunks(readableStream) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isObject(item) {
|
||||||
|
return (item && typeof item === 'object' && !Array.isArray(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepMerge(target, source) {
|
||||||
|
let output = Object.assign({}, target);
|
||||||
|
if (isObject(target) && isObject(source)) {
|
||||||
|
Object.keys(source).forEach(key => {
|
||||||
|
if (isObject(source[key])) {
|
||||||
|
if (!(key in target))
|
||||||
|
Object.assign(output, { [key]: source[key] });
|
||||||
|
else
|
||||||
|
output[key] = deepMerge(target[key], source[key]);
|
||||||
|
} else {
|
||||||
|
Object.assign(output, { [key]: source[key] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getConfig,
|
getConfig,
|
||||||
getConfigValue,
|
getConfigValue,
|
||||||
@ -205,4 +226,5 @@ module.exports = {
|
|||||||
getImageBuffers,
|
getImageBuffers,
|
||||||
readAllChunks,
|
readAllChunks,
|
||||||
delay,
|
delay,
|
||||||
|
deepMerge,
|
||||||
};
|
};
|
||||||
|
127
src/validator/TavernCardValidator.js
Normal file
127
src/validator/TavernCardValidator.js
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Validates the data structure of character cards.
|
||||||
|
* Supported specs: V1, V2
|
||||||
|
* Up to: 8083fb3
|
||||||
|
*
|
||||||
|
* @link https://github.com/malfoyslastname/character-card-spec-v2
|
||||||
|
*/
|
||||||
|
class TavernCardValidator {
|
||||||
|
#lastValidationError = null;
|
||||||
|
|
||||||
|
constructor(card) {
|
||||||
|
this.card = card;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field that caused the validation to fail
|
||||||
|
*
|
||||||
|
* @returns {null|string}
|
||||||
|
*/
|
||||||
|
get lastValidationError() {
|
||||||
|
return this.#lastValidationError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate against V1 or V2 spec.
|
||||||
|
*
|
||||||
|
* @returns {number|boolean} - false when neither V1 nor V2 spec were matched. Specification version number otherwise.
|
||||||
|
*/
|
||||||
|
validate() {
|
||||||
|
this.#lastValidationError = null;
|
||||||
|
|
||||||
|
if (this.validateV1()) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.validateV2()) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate against V1 specification
|
||||||
|
*
|
||||||
|
* @returns {this is string[]}
|
||||||
|
*/
|
||||||
|
validateV1() {
|
||||||
|
const requiredFields = ['name', 'description', 'personality', 'scenario', 'first_mes', 'mes_example'];
|
||||||
|
return requiredFields.every(field => {
|
||||||
|
if (!this.card.hasOwnProperty(field)) {
|
||||||
|
this.#lastValidationError = field;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate against V2 specification
|
||||||
|
*
|
||||||
|
* @returns {false|boolean|*}
|
||||||
|
*/
|
||||||
|
validateV2() {
|
||||||
|
return this.#validateSpec()
|
||||||
|
&& this.#validateSpecVersion()
|
||||||
|
&& this.#validateData()
|
||||||
|
&& this.#validateCharacterBook();
|
||||||
|
}
|
||||||
|
|
||||||
|
#validateSpec() {
|
||||||
|
if (this.card.spec !== 'chara_card_v2') {
|
||||||
|
this.#lastValidationError = 'spec';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#validateSpecVersion() {
|
||||||
|
if (this.card.spec_version !== '2.0') {
|
||||||
|
this.#lastValidationError = 'spec_version';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#validateData() {
|
||||||
|
const data = this.card.data;
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
this.#lastValidationError = 'No tavern card data found';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredFields = ['name', 'description', 'personality', 'scenario', 'first_mes', 'mes_example', 'creator_notes', 'system_prompt', 'post_history_instructions', 'alternate_greetings', 'tags', 'creator', 'character_version', 'extensions'];
|
||||||
|
const isAllRequiredFieldsPresent = requiredFields.every(field => {
|
||||||
|
if (!data.hasOwnProperty(field)) {
|
||||||
|
this.#lastValidationError = `data.${field}`;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return isAllRequiredFieldsPresent && Array.isArray(data.alternate_greetings) && Array.isArray(data.tags) && typeof data.extensions === 'object';
|
||||||
|
}
|
||||||
|
|
||||||
|
#validateCharacterBook() {
|
||||||
|
const characterBook = this.card.data.character_book;
|
||||||
|
|
||||||
|
if (!characterBook) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredFields = ['extensions', 'entries'];
|
||||||
|
const isAllRequiredFieldsPresent = requiredFields.every(field => {
|
||||||
|
if (!characterBook.hasOwnProperty(field)) {
|
||||||
|
this.#lastValidationError = `data.character_book.${field}`;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return isAllRequiredFieldsPresent && Array.isArray(characterBook.entries) && typeof characterBook.extensions === 'object';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {TavernCardValidator}
|
Reference in New Issue
Block a user