Add gallery folder and sort order controls (#3605) * Add gallery folder and sort order controls Closes #3601 * Refactor sort constants to use Object.freeze for immutability * Add comment * Remove excessive null propagation * Update type hint for gallery.folders * Use defaultSettings.sort as a fallback * Throw in groups * Handle rename/deletion events * Merge init functions * Fix multiple gallery file uplods * Add min-height for gallery element * Fix gallery endpoint not parsing body * translatable toasts * Pass folder path in request body * Change restore pictogram * Add title to gallery thumbnail images * Allow optional folder parameter in image list endpoint and handle deprecated usage warning * Add validation for folder parameter in image list endpoint * Add border to gallery sort selection * Remove override if default folder is set to input * Use server-side path sanitation * Sanitize gallery folder input before updating --------- Co-authored-by: Wolfsblvt <wolfsblvt@gmail.com>
Signed| @@ -210,6 +210,12 @@ export const extension_settings = { | ||
| 210 | 210 | * @type {string[]} |
| 211 | 211 | */ |
| 212 | 212 | disabled_attachments: [], |
| 213 | + gallery: { | |
| 214 | + /** @type {{[characterKey: string]: string}} */ | |
| 215 | + folders: {}, | |
| 216 | + /** @type {string} */ | |
| 217 | + sort: 'dateAsc', | |
| 218 | + }, | |
| 213 | 219 | }; |
| 214 | 220 | |
| 215 | 221 | function showHideExtensionsMenu() { |
| @@ -6,7 +6,7 @@ import { | ||
| 6 | 6 | event_types, |
| 7 | 7 | } from '../../../script.js'; |
| 8 | 8 | import { groups, selected_group } from '../../group-chats.js'; |
| 9 | 9 | import { loadFileToDocument, delay, getBase64Async, getSanitizedFilename } from '../../utils.js'; |
| 10 | 10 | import { loadMovingUIState } from '../../power-user.js'; |
| 11 | 11 | import { dragElement } from '../../RossAscends-mods.js'; |
| 12 | 12 | import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; |
| @@ -14,7 +14,7 @@ import { SlashCommand } from '../../slash-commands/SlashCommand.js'; | ||
| 14 | 14 | import { ARGUMENT_TYPE, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js'; |
| 15 | 15 | import { DragAndDropHandler } from '../../dragdrop.js'; |
| 16 | 16 | import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 17 | 17 | import { t, translate } from '../../i18n.js'; |
| 18 | 18 | |
| 19 | 19 | const extensionName = 'gallery'; |
| 20 | 20 | const extensionFolderPath = `scripts/extensions/${extensionName}/`; |
| @@ -50,6 +50,48 @@ mutationObserver.observe(document.body, { | ||
| 50 | 50 | subtree: false, |
| 51 | 51 | }); |
| 52 | 52 | |
| 53 | +const SORT = Object.freeze({ | |
| 54 | + NAME_ASC: { value: 'nameAsc', field: 'name', order: 'asc', label: t`Sort By: Name (A-Z)` }, | |
| 55 | + NAME_DESC: { value: 'nameDesc', field: 'name', order: 'desc', label: t`Sort By: Name (Z-A)` }, | |
| 56 | + DATE_ASC: { value: 'dateAsc', field: 'date', order: 'asc', label: t`Sort By: Date (Oldest First)` }, | |
| 57 | + DATE_DESC: { value: 'dateDesc', field: 'date', order: 'desc', label: t`Sort By: Date (Newest First)` }, | |
| 58 | +}); | |
| 59 | + | |
| 60 | +const defaultSettings = Object.freeze({ | |
| 61 | + folders: {}, | |
| 62 | + sort: SORT.DATE_ASC.value, | |
| 63 | +}); | |
| 64 | + | |
| 65 | +/** | |
| 66 | + * Initializes the settings for the gallery extension. | |
| 67 | + */ | |
| 68 | +function initSettings() { | |
| 69 | + let shouldSave = false; | |
| 70 | + const context = SillyTavern.getContext(); | |
| 71 | + if (!context.extensionSettings.gallery) { | |
| 72 | + context.extensionSettings.gallery = structuredClone(defaultSettings); | |
| 73 | + shouldSave = true; | |
| 74 | + } | |
| 75 | + for (const key of Object.keys(defaultSettings)) { | |
| 76 | + if (!Object.hasOwn(context.extensionSettings.gallery, key)) { | |
| 77 | + context.extensionSettings.gallery[key] = structuredClone(defaultSettings[key]); | |
| 78 | + shouldSave = true; | |
| 79 | + } | |
| 80 | + } | |
| 81 | + if (shouldSave) { | |
| 82 | + context.saveSettingsDebounced(); | |
| 83 | + } | |
| 84 | +} | |
| 85 | + | |
| 86 | +/** | |
| 87 | + * Retrieves the gallery folder for a given character. | |
| 88 | + * @param {import('../../char-data.js').v1CharData} char Character data | |
| 89 | + * @returns {string} The gallery folder for the character | |
| 90 | + */ | |
| 91 | +function getGalleryFolder(char) { | |
| 92 | + return SillyTavern.getContext().extensionSettings.gallery.folders[char?.avatar] ?? char?.name; | |
| 93 | +} | |
| 94 | + | |
| 53 | 95 | /** |
| 54 | 96 | * Retrieves a list of gallery items based on a given URL. This function calls an API endpoint |
| 55 | 97 | * to get the filenames and then constructs the item list. |
| @@ -58,11 +100,20 @@ mutationObserver.observe(document.body, { | ||
| 58 | 100 | * @returns {Promise<Array>} - Resolves with an array of gallery item objects, rejects on error. |
| 59 | 101 | */ |
| 60 | 102 | async function getGalleryItems(url) { |
| 61 | - const response = await fetch(`/api/images/list/${url}`, { | |
| 103 | + const sortValue = getSortOrder(); | |
| 104 | + const sortObj = Object.values(SORT).find(it => it.value === sortValue) ?? SORT.DATE_ASC; | |
| 105 | + const response = await fetch('/api/images/list', { | |
| 62 | 106 | method: 'POST', |
| 63 | 107 | headers: getRequestHeaders(), |
| 108 | + body: JSON.stringify({ | |
| 109 | + folder: url, | |
| 110 | + sortField: sortObj.field, | |
| 111 | + sortOrder: sortObj.order, | |
| 112 | + }), | |
| 64 | 113 | }); |
| 65 | 114 | |
| 115 | + url = await getSanitizedFilename(url); | |
| 116 | + | |
| 66 | 117 | const data = await response.json(); |
| 67 | 118 | const items = data.map((file) => ({ |
| 68 | 119 | src: `user/images/${url}/${file}`, |
| @@ -74,6 +125,46 @@ async function getGalleryItems(url) { | ||
| 74 | 125 | } |
| 75 | 126 | |
| 76 | 127 | /** |
| 128 | + * Retrieves a list of gallery folders. This function calls an API endpoint | |
| 129 | + * @returns {Promise<string[]>} - Resolves with an array of gallery folders. | |
| 130 | + */ | |
| 131 | +async function getGalleryFolders() { | |
| 132 | + try { | |
| 133 | + const response = await fetch('/api/images/folders', { | |
| 134 | + method: 'POST', | |
| 135 | + headers: getRequestHeaders(), | |
| 136 | + }); | |
| 137 | + | |
| 138 | + if (!response.ok) { | |
| 139 | + throw new Error(`HTTP error. Status: ${response.status}`); | |
| 140 | + } | |
| 141 | + const data = await response.json(); | |
| 142 | + return data; | |
| 143 | + } catch (error) { | |
| 144 | + console.error('Failed to fetch gallery folders:', error); | |
| 145 | + return []; | |
| 146 | + } | |
| 147 | +} | |
| 148 | + | |
| 149 | +/** | |
| 150 | + * Sets the sort order for the gallery. | |
| 151 | + * @param {string} order Sort order | |
| 152 | + */ | |
| 153 | +function setSortOrder(order) { | |
| 154 | + const context = SillyTavern.getContext(); | |
| 155 | + context.extensionSettings.gallery.sort = order; | |
| 156 | + context.saveSettingsDebounced(); | |
| 157 | +} | |
| 158 | + | |
| 159 | +/** | |
| 160 | + * Retrieves the current sort order for the gallery. | |
| 161 | + * @returns {string} The current sort order for the gallery. | |
| 162 | + */ | |
| 163 | +function getSortOrder() { | |
| 164 | + return SillyTavern.getContext().extensionSettings.gallery.sort ?? defaultSettings.sort; | |
| 165 | +} | |
| 166 | + | |
| 167 | +/** | |
| 77 | 168 | * Initializes a gallery using the provided items and sets up the drag-and-drop functionality. |
| 78 | 169 | * It uses the nanogallery2 library to display the items and also initializes |
| 79 | 170 | * event listeners to handle drag-and-drop of files onto the gallery. |
| @@ -106,11 +197,28 @@ async function initGallery(items, url) { | ||
| 106 | 197 | }, |
| 107 | 198 | galleryDisplayMode: 'pagination', |
| 108 | 199 | fnThumbnailOpen: viewWithDragbox, |
| 200 | + fnThumbnailInit: function (/** @type {JQuery<HTMLElement>} */ $thumbnail, /** @type {{src: string}} */ item) { | |
| 201 | + if (!item?.src) return; | |
| 202 | + $thumbnail.attr('title', String(item.src).split('/').pop()); | |
| 203 | + }, | |
| 109 | 204 | }); |
| 110 | 205 | |
| 111 | 206 | const dragDropHandler = new DragAndDropHandler(`#dragGallery.${nonce}`, async (files, event) => { |
| 112 | - let file = files[0]; | |
| 207 | + if (!Array.isArray(files) || files.length === 0) { | |
| 113 | - uploadFile(file, url); // Added url parameter to know where to upload | |
| 208 | + return; | |
| 209 | + } | |
| 210 | + | |
| 211 | + // Upload each file | |
| 212 | + for (const file of files) { | |
| 213 | + await uploadFile(file, url); | |
| 214 | + } | |
| 215 | + | |
| 216 | + // Refresh the gallery | |
| 217 | + const newItems = await getGalleryItems(url); | |
| 218 | + $('#dragGallery').closest('#gallery').remove(); | |
| 219 | + await makeMovable(url); | |
| 220 | + await delay(100); | |
| 221 | + await initGallery(newItems, url); | |
| 114 | 222 | }); |
| 115 | 223 | |
| 116 | 224 | const resizeHandler = function () { |
| @@ -170,14 +278,13 @@ async function showCharGallery() { | ||
| 170 | 278 | try { |
| 171 | 279 | let url = selected_group || this_chid; |
| 172 | 280 | if (!selected_group && this_chid !== undefined) { |
| 173 | 281 | const charurl = getGalleryFolder(characters[this_chid]); |
| 174 | - url = char.name; | |
| 175 | 282 | } |
| 176 | 283 | |
| 177 | 284 | const items = await getGalleryItems(url); |
| 178 | 285 | // if there already is a gallery, destroy it and place this one in its place |
| 179 | 286 | $('#dragGallery').closest('#gallery').remove(); |
| 180 | 287 | await makeMovable(url); |
| 181 | 288 | await delay(100); |
| 182 | 289 | await initGallery(items, url); |
| 183 | 290 | } catch (err) { |
| @@ -196,30 +303,19 @@ async function showCharGallery() { | ||
| 196 | 303 | * @returns {Promise<void>} - Promise representing the completion of the file upload and gallery refresh. |
| 197 | 304 | */ |
| 198 | 305 | async function uploadFile(file, url) { |
| 306 | + try { | |
| 199 | 307 | // Convert the file to a base64 string |
| 200 | 308 | const readerbase64Data = newawait FileReadergetBase64Async(file); |
| 201 | - reader.onloadend = async function () { | |
| 202 | - const base64Data = reader.result; | |
| 203 | 309 | |
| 204 | 310 | // Create the payload |
| 205 | 311 | const payload = { |
| 206 | 312 | image: base64Data, |
| 313 | + ch_name: url, | |
| 207 | 314 | }; |
| 208 | 315 | |
| 209 | - // Add the ch_name from the provided URL (assuming it's the character name) | |
| 210 | - payload.ch_name = url; | |
| 211 | - | |
| 212 | - try { | |
| 213 | - const headers = await getRequestHeaders(); | |
| 214 | - | |
| 215 | - // Merge headers with content-type for JSON | |
| 216 | - Object.assign(headers, { | |
| 217 | - 'Content-Type': 'application/json', | |
| 218 | - }); | |
| 219 | - | |
| 220 | 316 | const response = await fetch('/api/images/upload', { |
| 221 | 317 | method: 'POST', |
| 222 | 318 | headers: headersgetRequestHeaders(), |
| 223 | 319 | body: JSON.stringify(payload), |
| 224 | 320 | }); |
| 225 | 321 | |
| @@ -229,59 +325,57 @@ async function uploadFile(file, url) { | ||
| 229 | 325 | |
| 230 | 326 | const result = await response.json(); |
| 231 | 327 | |
| 232 | 328 | toastr.success('t`File uploaded successfully. Saved at: ' + ${result.path}`); |
| 233 | - | |
| 234 | - // Refresh the gallery | |
| 235 | - const newItems = await getGalleryItems(url); // Fetch the latest items | |
| 236 | - $('#dragGallery').closest('#gallery').remove(); // Destroy old gallery | |
| 237 | - makeMovable(); | |
| 238 | - await delay(100); | |
| 239 | - await initGallery(newItems, url); // Reinitialize the gallery with new items and pass 'url' | |
| 240 | 329 | } catch (error) { |
| 241 | 330 | console.error('There was an issue uploading the file:', error); |
| 242 | 331 | |
| 243 | 332 | // Replacing alert with toastr error notification |
| 244 | 333 | toastr.error('t`Failed to upload the file.'`); |
| 245 | 334 | } |
| 246 | - }; | |
| 247 | - reader.readAsDataURL(file); | |
| 248 | 335 | } |
| 249 | 336 | |
| 250 | -$(document).ready(function () { | |
| 251 | - // Register an event listener | |
| 252 | - eventSource.on('charManagementDropdown', (selectedOptionId) => { | |
| 253 | - if (selectedOptionId === 'show_char_gallery') { | |
| 254 | - showCharGallery(); | |
| 255 | - } | |
| 256 | - }); | |
| 257 | - | |
| 258 | - // Add an option to the dropdown | |
| 259 | - $('#char-management-dropdown').append( | |
| 260 | - $('<option>', { | |
| 261 | - id: 'show_char_gallery', | |
| 262 | - text: translate('Show Gallery'), | |
| 263 | - }), | |
| 264 | - ); | |
| 265 | -}); | |
| 266 | - | |
| 267 | 337 | /** |
| 268 | 338 | * Creates a new draggable container based on a template. |
| 269 | 339 | * This function takes a template with the ID 'generic_draggable_template' and clones it. |
| 270 | 340 | * The cloned element has its attributes set, a new child div appended, and is made visible on the body. |
| 271 | 341 | * Additionally, it sets up the element to prevent dragging on its images. |
| 342 | + * @param {string} url - The URL of the image source. | |
| 343 | + * @returns {Promise<void>} - Promise representing the completion of the draggable container creation. | |
| 272 | 344 | */ |
| 273 | 345 | async function makeMovable(id = 'gallery'url) { |
| 274 | - | |
| 275 | 346 | console.debug('making new container from template'); |
| 347 | + const id = 'gallery'; | |
| 276 | 348 | const template = $('#generic_draggable_template').html(); |
| 277 | 349 | const newElement = $(template); |
| 278 | 350 | newElement.css('background-color', 'var(--SmartThemeBlurTintColor)'); |
| 279 | 351 | newElement.attr('forChar', id); |
| 280 | 352 | newElement.attr('id', id); |
| 281 | 353 | newElement.find('.drag-grabber').attr('id', `${id}header`); |
| 282 | 354 | const dragTitle = newElement.find('.dragTitle').text('Image Gallery'); |
| 283 | - //add a div for the gallery | |
| 355 | + dragTitle.addClass('flex-container justifySpaceBetween alignItemsBaseline'); | |
| 284 | - newElement.append('<div id="dragGallery"></div>'); | |
| 356 | + const titleText = document.createElement('span'); | |
| 357 | + titleText.textContent = t`Image Gallery`; | |
| 358 | + dragTitle.append(titleText); | |
| 359 | + const sortSelect = document.createElement('select'); | |
| 360 | + sortSelect.classList.add('gallery-sort-select'); | |
| 361 | + | |
| 362 | + for (const sort of Object.values(SORT)) { | |
| 363 | + const option = document.createElement('option'); | |
| 364 | + option.value = sort.value; | |
| 365 | + option.textContent = sort.label; | |
| 366 | + sortSelect.appendChild(option); | |
| 367 | + } | |
| 368 | + | |
| 369 | + sortSelect.addEventListener('change', async () => { | |
| 370 | + const selectedOption = sortSelect.options[sortSelect.selectedIndex].value; | |
| 371 | + setSortOrder(selectedOption); | |
| 372 | + closeButton.trigger('click'); | |
| 373 | + await showCharGallery(); | |
| 374 | + }); | |
| 375 | + | |
| 376 | + sortSelect.value = getSortOrder(); | |
| 377 | + dragTitle.append(sortSelect); | |
| 378 | + | |
| 285 | 379 | // add no-scrollbar class to this element |
| 286 | 380 | newElement.addClass('no-scrollbar'); |
| 287 | 381 | |
| @@ -290,6 +384,81 @@ function makeMovable(id = 'gallery') { | ||
| 290 | 384 | closeButton.attr('id', `${id}close`); |
| 291 | 385 | closeButton.attr('data-related-id', `${id}`); |
| 292 | 386 | |
| 387 | + const topBarElement = document.createElement('div'); | |
| 388 | + topBarElement.classList.add('flex-container', 'alignItemsCenter'); | |
| 389 | + | |
| 390 | + const onChangeFolder = async (/** @type {Event} */ e) => { | |
| 391 | + if (e instanceof KeyboardEvent && e.key !== 'Enter') { | |
| 392 | + return; | |
| 393 | + } | |
| 394 | + | |
| 395 | + try { | |
| 396 | + const newUrl = await getSanitizedFilename(galleryFolderInput.value); | |
| 397 | + updateGalleryFolder(newUrl); | |
| 398 | + closeButton.trigger('click'); | |
| 399 | + await showCharGallery(); | |
| 400 | + toastr.info(t`Gallery folder changed to ${newUrl}`); | |
| 401 | + galleryFolderInput.value = newUrl; | |
| 402 | + } catch (error) { | |
| 403 | + console.error('Failed to change gallery folder:', error); | |
| 404 | + toastr.error(error?.message || t`Unknown error`, t`Failed to change gallery folder`); | |
| 405 | + } | |
| 406 | + }; | |
| 407 | + | |
| 408 | + const onRestoreFolder = async () => { | |
| 409 | + try { | |
| 410 | + restoreGalleryFolder(); | |
| 411 | + closeButton.trigger('click'); | |
| 412 | + await showCharGallery(); | |
| 413 | + } catch (error) { | |
| 414 | + console.error('Failed to restore gallery folder:', error); | |
| 415 | + toastr.error(error?.message || t`Unknown error`, t`Failed to restore gallery folder`); | |
| 416 | + } | |
| 417 | + }; | |
| 418 | + | |
| 419 | + const galleryFolderInput = document.createElement('input'); | |
| 420 | + galleryFolderInput.type = 'text'; | |
| 421 | + galleryFolderInput.placeholder = t`Folder Name`; | |
| 422 | + galleryFolderInput.title = t`Enter a folder name to change the gallery folder`; | |
| 423 | + galleryFolderInput.value = url; | |
| 424 | + galleryFolderInput.classList.add('text_pole', 'gallery-folder-input', 'flex1'); | |
| 425 | + galleryFolderInput.addEventListener('keyup', onChangeFolder); | |
| 426 | + | |
| 427 | + const galleryFolderAccept = document.createElement('div'); | |
| 428 | + galleryFolderAccept.classList.add('right_menu_button', 'fa-solid', 'fa-check', 'fa-fw'); | |
| 429 | + galleryFolderAccept.title = t`Change gallery folder`; | |
| 430 | + galleryFolderAccept.addEventListener('click', onChangeFolder); | |
| 431 | + | |
| 432 | + const galleryFolderRestore = document.createElement('div'); | |
| 433 | + galleryFolderRestore.classList.add('right_menu_button', 'fa-solid', 'fa-recycle', 'fa-fw'); | |
| 434 | + galleryFolderRestore.title = t`Restore gallery folder`; | |
| 435 | + galleryFolderRestore.addEventListener('click', onRestoreFolder); | |
| 436 | + | |
| 437 | + topBarElement.appendChild(galleryFolderInput); | |
| 438 | + topBarElement.appendChild(galleryFolderAccept); | |
| 439 | + topBarElement.appendChild(galleryFolderRestore); | |
| 440 | + newElement.append(topBarElement); | |
| 441 | + | |
| 442 | + // Populate the gallery folder input with a list of available folders | |
| 443 | + const folders = await getGalleryFolders(); | |
| 444 | + $(galleryFolderInput) | |
| 445 | + .autocomplete({ | |
| 446 | + source: (i, o) => { | |
| 447 | + const term = i.term.toLowerCase(); | |
| 448 | + const filtered = folders.filter(f => f.toLowerCase().includes(term)); | |
| 449 | + o(filtered); | |
| 450 | + }, | |
| 451 | + select: (e, u) => { | |
| 452 | + galleryFolderInput.value = u.item.value; | |
| 453 | + onChangeFolder(e); | |
| 454 | + }, | |
| 455 | + minLength: 0, | |
| 456 | + }) | |
| 457 | + .on('focus', () => $(galleryFolderInput).autocomplete('search', '')); | |
| 458 | + | |
| 459 | + //add a div for the gallery | |
| 460 | + newElement.append('<div id="dragGallery"></div>'); | |
| 461 | + | |
| 293 | 462 | $('#dragGallery').css('display', 'block'); |
| 294 | 463 | |
| 295 | 464 | $('#movingDivs').append(newElement); |
| @@ -306,6 +475,59 @@ function makeMovable(id = 'gallery') { | ||
| 306 | 475 | } |
| 307 | 476 | |
| 308 | 477 | /** |
| 478 | + * Sets the gallery folder to a new URL. | |
| 479 | + * @param {string} newUrl - The new URL to set for the gallery folder. | |
| 480 | + */ | |
| 481 | +function updateGalleryFolder(newUrl) { | |
| 482 | + if (!newUrl) { | |
| 483 | + throw new Error('Folder name cannot be empty'); | |
| 484 | + } | |
| 485 | + const context = SillyTavern.getContext(); | |
| 486 | + if (context.groupId) { | |
| 487 | + throw new Error('Cannot change gallery folder in group chat'); | |
| 488 | + } | |
| 489 | + if (context.characterId === undefined) { | |
| 490 | + throw new Error('Character is not selected'); | |
| 491 | + } | |
| 492 | + const avatar = context.characters[context.characterId]?.avatar; | |
| 493 | + const name = context.characters[context.characterId]?.name; | |
| 494 | + if (!avatar) { | |
| 495 | + throw new Error('Character PNG ID is not found'); | |
| 496 | + } | |
| 497 | + if (newUrl === name) { | |
| 498 | + // Default folder name is picked, remove the override | |
| 499 | + delete context.extensionSettings.gallery.folders[avatar]; | |
| 500 | + } else { | |
| 501 | + // Custom folder name is provided, set the override | |
| 502 | + context.extensionSettings.gallery.folders[avatar] = newUrl; | |
| 503 | + } | |
| 504 | + context.saveSettingsDebounced(); | |
| 505 | +} | |
| 506 | + | |
| 507 | +/** | |
| 508 | + * Restores the gallery folder to the default value. | |
| 509 | + */ | |
| 510 | +function restoreGalleryFolder() { | |
| 511 | + const context = SillyTavern.getContext(); | |
| 512 | + if (context.groupId) { | |
| 513 | + throw new Error('Cannot change gallery folder in group chat'); | |
| 514 | + } | |
| 515 | + if (context.characterId === undefined) { | |
| 516 | + throw new Error('Character is not selected'); | |
| 517 | + } | |
| 518 | + const avatar = context.characters[context.characterId]?.avatar; | |
| 519 | + if (!avatar) { | |
| 520 | + throw new Error('Character PNG ID is not found'); | |
| 521 | + } | |
| 522 | + const existingOverride = context.extensionSettings.gallery.folders[avatar]; | |
| 523 | + if (!existingOverride) { | |
| 524 | + throw new Error('No folder override found'); | |
| 525 | + } | |
| 526 | + delete context.extensionSettings.gallery.folders[avatar]; | |
| 527 | + context.saveSettingsDebounced(); | |
| 528 | +} | |
| 529 | + | |
| 530 | +/** | |
| 309 | 531 | * Creates a new draggable image based on a template. |
| 310 | 532 | * |
| 311 | 533 | * This function clones a provided template with the ID 'generic_draggable_template', |
| @@ -331,7 +553,7 @@ function makeDragImg(id, url) { | ||
| 331 | 553 | const imgElem = document.createElement('img'); |
| 332 | 554 | imgElem.src = url; |
| 333 | 555 | let uniqueId = `draggable_${id}`; |
| 334 | 556 | const draggableElem = /** @type {HTMLElement} */ (newElement.querySelector('.draggable')); |
| 335 | 557 | if (draggableElem) { |
| 336 | 558 | draggableElem.appendChild(imgElem); |
| 337 | 559 | |
| @@ -351,7 +573,7 @@ function makeDragImg(id, url) { | ||
| 351 | 573 | |
| 352 | 574 | // Add an id to the close button |
| 353 | 575 | // If the close button exists, set related-id |
| 354 | 576 | const closeButton = /** @type {HTMLElement} */ (draggableElem.querySelector('.dragClose')); |
| 355 | 577 | if (closeButton) { |
| 356 | 578 | closeButton.id = `${uniqueId}close`; |
| 357 | 579 | closeButton.dataset.relatedId = uniqueId; |
| @@ -456,8 +678,7 @@ async function listGalleryCommand(args) { | ||
| 456 | 678 | try { |
| 457 | 679 | let url = args.char ?? (args.group ? groups.find(it => it.name == args.group)?.id : null) ?? (selected_group || this_chid); |
| 458 | 680 | if (!args.char && !args.group && !selected_group && this_chid !== undefined) { |
| 459 | 681 | const charurl = getGalleryFolder(characters[this_chid]); |
| 460 | - url = char.name; | |
| 461 | 682 | } |
| 462 | 683 | |
| 463 | 684 | const items = await getGalleryItems(url); |
| @@ -469,3 +690,37 @@ async function listGalleryCommand(args) { | ||
| 469 | 690 | } |
| 470 | 691 | return JSON.stringify([]); |
| 471 | 692 | } |
| 693 | + | |
| 694 | +// On extension load, ensure the settings are initialized | |
| 695 | +(function () { | |
| 696 | + initSettings(); | |
| 697 | + eventSource.on(event_types.CHARACTER_RENAMED, (oldAvatar, newAvatar) => { | |
| 698 | + const context = SillyTavern.getContext(); | |
| 699 | + const galleryFolder = context.extensionSettings.gallery.folders[oldAvatar]; | |
| 700 | + if (galleryFolder) { | |
| 701 | + context.extensionSettings.gallery.folders[newAvatar] = galleryFolder; | |
| 702 | + delete context.extensionSettings.gallery.folders[oldAvatar]; | |
| 703 | + context.saveSettingsDebounced(); | |
| 704 | + } | |
| 705 | + }); | |
| 706 | + eventSource.on(event_types.CHARACTER_DELETED, (data) => { | |
| 707 | + const avatar = data?.character?.avatar; | |
| 708 | + if (!avatar) return; | |
| 709 | + const context = SillyTavern.getContext(); | |
| 710 | + delete context.extensionSettings.gallery.folders[avatar]; | |
| 711 | + context.saveSettingsDebounced(); | |
| 712 | + }); | |
| 713 | + eventSource.on('charManagementDropdown', (selectedOptionId) => { | |
| 714 | + if (selectedOptionId === 'show_char_gallery') { | |
| 715 | + showCharGallery(); | |
| 716 | + } | |
| 717 | + }); | |
| 718 | + | |
| 719 | + // Add an option to the dropdown | |
| 720 | + $('#char-management-dropdown').append( | |
| 721 | + $('<option>', { | |
| 722 | + id: 'show_char_gallery', | |
| 723 | + text: translate('Show Gallery'), | |
| 724 | + }), | |
| 725 | + ); | |
| 726 | +})(); | |
| @@ -3,3 +3,43 @@ | ||
| 3 | 3 | align-items: center; |
| 4 | 4 | justify-content: center; |
| 5 | 5 | } |
| 6 | + | |
| 7 | +.gallery-folder-input { | |
| 8 | + background-color: transparent; | |
| 9 | + font-size: calc(var(--mainFontSize)* 0.9); | |
| 10 | + opacity: 0.8; | |
| 11 | + flex-grow: 1; | |
| 12 | +} | |
| 13 | + | |
| 14 | +.gallery-folder-input:placeholder-shown { | |
| 15 | + font-style: italic; | |
| 16 | + opacity: 0.5; | |
| 17 | + border-color: transparent; | |
| 18 | +} | |
| 19 | + | |
| 20 | +.gallery-sort-select { | |
| 21 | + width: max-content; | |
| 22 | + flex: 1; | |
| 23 | + cursor: pointer; | |
| 24 | + overflow-x: hidden; | |
| 25 | + white-space: nowrap; | |
| 26 | + text-overflow: ellipsis; | |
| 27 | + width: 100%; | |
| 28 | + opacity: 0.8; | |
| 29 | + background: none; | |
| 30 | + background-image: url(/img/down-arrow.svg); | |
| 31 | + background-repeat: no-repeat; | |
| 32 | + background-position: right 6px center; | |
| 33 | + background-size: 8px 5px; | |
| 34 | + padding-right: 20px; | |
| 35 | + font-size: calc(var(--mainFontSize)* 0.9); | |
| 36 | + margin-bottom: 0; | |
| 37 | +} | |
| 38 | + | |
| 39 | +#gallery .dragTitle { | |
| 40 | + margin-right: 30px; | |
| 41 | +} | |
| 42 | + | |
| 43 | +#dragGallery { | |
| 44 | + min-height: 25dvh; | |
| 45 | +} | |
| @@ -76,18 +76,54 @@ router.post('/upload', jsonParser, async (request, response) => { | ||
| 76 | 76 | } |
| 77 | 77 | }); |
| 78 | 78 | |
| 79 | 79 | router.post('/list/:folder?', jsonParser, (request, response) => { |
| 80 | - const directoryPath = path.join(request.user.directories.userImages, sanitize(request.params.folder)); | |
| 80 | + try { | |
| 81 | + if (request.params.folder) { | |
| 82 | + if (request.body.folder) { | |
| 83 | + return response.status(400).send({ error: 'Folder specified in both URL and body' }); | |
| 84 | + } | |
| 85 | + | |
| 86 | + console.warn('Deprecated: Use POST /api/images/list with folder in request body'); | |
| 87 | + request.body.folder = request.params.folder; | |
| 88 | + } | |
| 89 | + | |
| 90 | + if (!request.body.folder) { | |
| 91 | + return response.status(400).send({ error: 'No folder specified' }); | |
| 92 | + } | |
| 93 | + | |
| 94 | + const directoryPath = path.join(request.user.directories.userImages, sanitize(request.body.folder)); | |
| 95 | + const sort = request.body.sortField || 'date'; | |
| 96 | + const order = request.body.sortOrder || 'asc'; | |
| 81 | 97 | |
| 82 | 98 | if (!fs.existsSync(directoryPath)) { |
| 83 | 99 | fs.mkdirSync(directoryPath, { recursive: true }); |
| 84 | 100 | } |
| 85 | 101 | |
| 86 | - try { | |
| 102 | + const images = getImages(directoryPath, sort); | |
| 87 | - const images = getImages(directoryPath, 'date'); | |
| 103 | + if (order === 'desc') { | |
| 104 | + images.reverse(); | |
| 105 | + } | |
| 88 | 106 | return response.send(images); |
| 89 | 107 | } catch (error) { |
| 90 | 108 | console.error(error); |
| 91 | 109 | return response.status(500).send({ error: 'Unable to retrieve files' }); |
| 92 | 110 | } |
| 93 | 111 | }); |
| 112 | + | |
| 113 | +router.post('/folders', (request, response) => { | |
| 114 | + try { | |
| 115 | + const directoryPath = request.user.directories.userImages; | |
| 116 | + if (!fs.existsSync(directoryPath)) { | |
| 117 | + fs.mkdirSync(directoryPath, { recursive: true }); | |
| 118 | + } | |
| 119 | + | |
| 120 | + const folders = fs.readdirSync(directoryPath, { withFileTypes: true }) | |
| 121 | + .filter(dirent => dirent.isDirectory()) | |
| 122 | + .map(dirent => dirent.name); | |
| 123 | + | |
| 124 | + return response.send(folders); | |
| 125 | + } catch (error) { | |
| 126 | + console.error(error); | |
| 127 | + return response.status(500).send({ error: 'Unable to retrieve folders' }); | |
| 128 | + } | |
| 129 | +}); | |