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 | * @type {string[]} | 210 | * @type {string[]} |
| 211 | */ | 211 | */ |
| 212 | disabled_attachments: [], | 212 | disabled_attachments: [], |
| 213 | gallery: { | ||
| 214 | /** @type {{[characterKey: string]: string}} */ | ||
| 215 | folders: {}, | ||
| 216 | /** @type {string} */ | ||
| 217 | sort: 'dateAsc', | ||
| 218 | }, | ||
| 213 | }; | 219 | }; |
| 214 | 220 | ||
| 215 | function showHideExtensionsMenu() { | 221 | function showHideExtensionsMenu() { |
| @@ -6,7 +6,7 @@ import { | |||
| 6 | event_types, | 6 | event_types, |
| 7 | } from '../../../script.js'; | 7 | } from '../../../script.js'; |
| 8 | import { groups, selected_group } from '../../group-chats.js'; | 8 | import { groups, selected_group } from '../../group-chats.js'; |
| 9 | import { loadFileToDocument, delay } from '../../utils.js'; | 9 | import { loadFileToDocument, delay, getBase64Async, getSanitizedFilename } from '../../utils.js'; |
| 10 | import { loadMovingUIState } from '../../power-user.js'; | 10 | import { loadMovingUIState } from '../../power-user.js'; |
| 11 | import { dragElement } from '../../RossAscends-mods.js'; | 11 | import { dragElement } from '../../RossAscends-mods.js'; |
| 12 | import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; | 12 | import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; |
| @@ -14,7 +14,7 @@ import { SlashCommand } from '../../slash-commands/SlashCommand.js'; | |||
| 14 | import { ARGUMENT_TYPE, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js'; | 14 | import { ARGUMENT_TYPE, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js'; |
| 15 | import { DragAndDropHandler } from '../../dragdrop.js'; | 15 | import { DragAndDropHandler } from '../../dragdrop.js'; |
| 16 | import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; | 16 | import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 17 | import { translate } from '../../i18n.js'; | 17 | import { t, translate } from '../../i18n.js'; |
| 18 | 18 | ||
| 19 | const extensionName = 'gallery'; | 19 | const extensionName = 'gallery'; |
| 20 | const extensionFolderPath = `scripts/extensions/${extensionName}/`; | 20 | const extensionFolderPath = `scripts/extensions/${extensionName}/`; |
| @@ -50,6 +50,48 @@ mutationObserver.observe(document.body, { | |||
| 50 | subtree: false, | 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 | * Retrieves a list of gallery items based on a given URL. This function calls an API endpoint | 96 | * Retrieves a list of gallery items based on a given URL. This function calls an API endpoint |
| 55 | * to get the filenames and then constructs the item list. | 97 | * to get the filenames and then constructs the item list. |
| @@ -58,11 +100,20 @@ mutationObserver.observe(document.body, { | |||
| 58 | * @returns {Promise<Array>} - Resolves with an array of gallery item objects, rejects on error. | 100 | * @returns {Promise<Array>} - Resolves with an array of gallery item objects, rejects on error. |
| 59 | */ | 101 | */ |
| 60 | async function getGalleryItems(url) { | 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 | method: 'POST', | 106 | method: 'POST', |
| 63 | headers: getRequestHeaders(), | 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 | const data = await response.json(); | 117 | const data = await response.json(); |
| 67 | const items = data.map((file) => ({ | 118 | const items = data.map((file) => ({ |
| 68 | src: `user/images/${url}/${file}`, | 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 | * Initializes a gallery using the provided items and sets up the drag-and-drop functionality. | 168 | * Initializes a gallery using the provided items and sets up the drag-and-drop functionality. |
| 78 | * It uses the nanogallery2 library to display the items and also initializes | 169 | * It uses the nanogallery2 library to display the items and also initializes |
| 79 | * event listeners to handle drag-and-drop of files onto the gallery. | 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 | galleryDisplayMode: 'pagination', | 198 | galleryDisplayMode: 'pagination', |
| 108 | fnThumbnailOpen: viewWithDragbox, | 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 | const dragDropHandler = new DragAndDropHandler(`#dragGallery.${nonce}`, async (files, event) => { | 206 | const dragDropHandler = new DragAndDropHandler(`#dragGallery.${nonce}`, async (files) => { |
| 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 | const resizeHandler = function () { | 224 | const resizeHandler = function () { |
| @@ -170,14 +278,13 @@ async function showCharGallery() { | |||
| 170 | try { | 278 | try { |
| 171 | let url = selected_group || this_chid; | 279 | let url = selected_group || this_chid; |
| 172 | if (!selected_group && this_chid !== undefined) { | 280 | if (!selected_group && this_chid !== undefined) { |
| 173 | const char = characters[this_chid]; | 281 | url = getGalleryFolder(characters[this_chid]); |
| 174 | url = char.name; | ||
| 175 | } | 282 | } |
| 176 | 283 | ||
| 177 | const items = await getGalleryItems(url); | 284 | const items = await getGalleryItems(url); |
| 178 | // if there already is a gallery, destroy it and place this one in its place | 285 | // if there already is a gallery, destroy it and place this one in its place |
| 179 | $('#dragGallery').closest('#gallery').remove(); | 286 | $('#dragGallery').closest('#gallery').remove(); |
| 180 | makeMovable(); | 287 | await makeMovable(url); |
| 181 | await delay(100); | 288 | await delay(100); |
| 182 | await initGallery(items, url); | 289 | await initGallery(items, url); |
| 183 | } catch (err) { | 290 | } catch (err) { |
| @@ -196,92 +303,79 @@ async function showCharGallery() { | |||
| 196 | * @returns {Promise<void>} - Promise representing the completion of the file upload and gallery refresh. | 303 | * @returns {Promise<void>} - Promise representing the completion of the file upload and gallery refresh. |
| 197 | */ | 304 | */ |
| 198 | async function uploadFile(file, url) { | 305 | async function uploadFile(file, url) { |
| 199 | // Convert the file to a base64 string | 306 | try { |
| 200 | const reader = new FileReader(); | 307 | // Convert the file to a base64 string |
| 201 | reader.onloadend = async function () { | 308 | const base64Data = await getBase64Async(file); |
| 202 | const base64Data = reader.result; | ||
| 203 | 309 | ||
| 204 | // Create the payload | 310 | // Create the payload |
| 205 | const payload = { | 311 | const payload = { |
| 206 | image: base64Data, | 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) | 316 | const response = await fetch('/api/images/upload', { |
| 210 | payload.ch_name = url; | 317 | method: 'POST', |
| 211 | 318 | headers: getRequestHeaders(), | |
| 212 | try { | 319 | body: JSON.stringify(payload), |
| 213 | const headers = await getRequestHeaders(); | 320 | }); |
| 214 | |||
| 215 | // Merge headers with content-type for JSON | ||
| 216 | Object.assign(headers, { | ||
| 217 | 'Content-Type': 'application/json', | ||
| 218 | }); | ||
| 219 | |||
| 220 | const response = await fetch('/api/images/upload', { | ||
| 221 | method: 'POST', | ||
| 222 | headers: headers, | ||
| 223 | body: JSON.stringify(payload), | ||
| 224 | }); | ||
| 225 | |||
| 226 | if (!response.ok) { | ||
| 227 | throw new Error(`HTTP error! Status: ${response.status}`); | ||
| 228 | } | ||
| 229 | 321 | ||
| 230 | const result = await response.json(); | 322 | if (!response.ok) { |
| 323 | throw new Error(`HTTP error! Status: ${response.status}`); | ||
| 324 | } | ||
| 231 | 325 | ||
| 232 | toastr.success('File uploaded successfully. Saved at: ' + result.path); | 326 | const result = await response.json(); |
| 233 | 327 | ||
| 234 | // Refresh the gallery | 328 | toastr.success(t`File uploaded successfully. Saved at: ${result.path}`); |
| 235 | const newItems = await getGalleryItems(url); // Fetch the latest items | 329 | } catch (error) { |
| 236 | $('#dragGallery').closest('#gallery').remove(); // Destroy old gallery | 330 | console.error('There was an issue uploading the file:', error); |
| 237 | makeMovable(); | ||
| 238 | await delay(100); | ||
| 239 | await initGallery(newItems, url); // Reinitialize the gallery with new items and pass 'url' | ||
| 240 | } catch (error) { | ||
| 241 | console.error('There was an issue uploading the file:', error); | ||
| 242 | 331 | ||
| 243 | // Replacing alert with toastr error notification | 332 | // Replacing alert with toastr error notification |
| 244 | toastr.error('Failed to upload the file.'); | 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 | * Creates a new draggable container based on a template. | 338 | * Creates a new draggable container based on a template. |
| 269 | * This function takes a template with the ID 'generic_draggable_template' and clones it. | 339 | * This function takes a template with the ID 'generic_draggable_template' and clones it. |
| 270 | * The cloned element has its attributes set, a new child div appended, and is made visible on the body. | 340 | * The cloned element has its attributes set, a new child div appended, and is made visible on the body. |
| 271 | * Additionally, it sets up the element to prevent dragging on its images. | 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 | function makeMovable(id = 'gallery') { | 345 | async function makeMovable(url) { |
| 274 | |||
| 275 | console.debug('making new container from template'); | 346 | console.debug('making new container from template'); |
| 347 | const id = 'gallery'; | ||
| 276 | const template = $('#generic_draggable_template').html(); | 348 | const template = $('#generic_draggable_template').html(); |
| 277 | const newElement = $(template); | 349 | const newElement = $(template); |
| 278 | newElement.css('background-color', 'var(--SmartThemeBlurTintColor)'); | 350 | newElement.css('background-color', 'var(--SmartThemeBlurTintColor)'); |
| 279 | newElement.attr('forChar', id); | 351 | newElement.attr('forChar', id); |
| 280 | newElement.attr('id', id); | 352 | newElement.attr('id', id); |
| 281 | newElement.find('.drag-grabber').attr('id', `${id}header`); | 353 | newElement.find('.drag-grabber').attr('id', `${id}header`); |
| 282 | newElement.find('.dragTitle').text('Image Gallery'); | 354 | const dragTitle = newElement.find('.dragTitle'); |
| 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 | // add no-scrollbar class to this element | 379 | // add no-scrollbar class to this element |
| 286 | newElement.addClass('no-scrollbar'); | 380 | newElement.addClass('no-scrollbar'); |
| 287 | 381 | ||
| @@ -290,6 +384,81 @@ function makeMovable(id = 'gallery') { | |||
| 290 | closeButton.attr('id', `${id}close`); | 384 | closeButton.attr('id', `${id}close`); |
| 291 | closeButton.attr('data-related-id', `${id}`); | 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 | $('#dragGallery').css('display', 'block'); | 462 | $('#dragGallery').css('display', 'block'); |
| 294 | 463 | ||
| 295 | $('#movingDivs').append(newElement); | 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 | * Creates a new draggable image based on a template. | 531 | * Creates a new draggable image based on a template. |
| 310 | * | 532 | * |
| 311 | * This function clones a provided template with the ID 'generic_draggable_template', | 533 | * This function clones a provided template with the ID 'generic_draggable_template', |
| @@ -331,7 +553,7 @@ function makeDragImg(id, url) { | |||
| 331 | const imgElem = document.createElement('img'); | 553 | const imgElem = document.createElement('img'); |
| 332 | imgElem.src = url; | 554 | imgElem.src = url; |
| 333 | let uniqueId = `draggable_${id}`; | 555 | let uniqueId = `draggable_${id}`; |
| 334 | const draggableElem = newElement.querySelector('.draggable'); | 556 | const draggableElem = /** @type {HTMLElement} */ (newElement.querySelector('.draggable')); |
| 335 | if (draggableElem) { | 557 | if (draggableElem) { |
| 336 | draggableElem.appendChild(imgElem); | 558 | draggableElem.appendChild(imgElem); |
| 337 | 559 | ||
| @@ -351,7 +573,7 @@ function makeDragImg(id, url) { | |||
| 351 | 573 | ||
| 352 | // Add an id to the close button | 574 | // Add an id to the close button |
| 353 | // If the close button exists, set related-id | 575 | // If the close button exists, set related-id |
| 354 | const closeButton = draggableElem.querySelector('.dragClose'); | 576 | const closeButton = /** @type {HTMLElement} */ (draggableElem.querySelector('.dragClose')); |
| 355 | if (closeButton) { | 577 | if (closeButton) { |
| 356 | closeButton.id = `${uniqueId}close`; | 578 | closeButton.id = `${uniqueId}close`; |
| 357 | closeButton.dataset.relatedId = uniqueId; | 579 | closeButton.dataset.relatedId = uniqueId; |
| @@ -456,8 +678,7 @@ async function listGalleryCommand(args) { | |||
| 456 | try { | 678 | try { |
| 457 | let url = args.char ?? (args.group ? groups.find(it => it.name == args.group)?.id : null) ?? (selected_group || this_chid); | 679 | let url = args.char ?? (args.group ? groups.find(it => it.name == args.group)?.id : null) ?? (selected_group || this_chid); |
| 458 | if (!args.char && !args.group && !selected_group && this_chid !== undefined) { | 680 | if (!args.char && !args.group && !selected_group && this_chid !== undefined) { |
| 459 | const char = characters[this_chid]; | 681 | url = getGalleryFolder(characters[this_chid]); |
| 460 | url = char.name; | ||
| 461 | } | 682 | } |
| 462 | 683 | ||
| 463 | const items = await getGalleryItems(url); | 684 | const items = await getGalleryItems(url); |
| @@ -469,3 +690,37 @@ async function listGalleryCommand(args) { | |||
| 469 | } | 690 | } |
| 470 | return JSON.stringify([]); | 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 | align-items: center; | 3 | align-items: center; |
| 4 | justify-content: center; | 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 | router.post('/list/:folder', (request, response) => { | 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 | } | ||
| 81 | 85 | ||
| 82 | if (!fs.existsSync(directoryPath)) { | 86 | console.warn('Deprecated: Use POST /api/images/list with folder in request body'); |
| 83 | fs.mkdirSync(directoryPath, { recursive: true }); | 87 | request.body.folder = request.params.folder; |
| 84 | } | 88 | } |
| 85 | 89 | ||
| 86 | try { | 90 | if (!request.body.folder) { |
| 87 | const images = getImages(directoryPath, 'date'); | 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'; | ||
| 97 | |||
| 98 | if (!fs.existsSync(directoryPath)) { | ||
| 99 | fs.mkdirSync(directoryPath, { recursive: true }); | ||
| 100 | } | ||
| 101 | |||
| 102 | const images = getImages(directoryPath, sort); | ||
| 103 | if (order === 'desc') { | ||
| 104 | images.reverse(); | ||
| 105 | } | ||
| 88 | return response.send(images); | 106 | return response.send(images); |
| 89 | } catch (error) { | 107 | } catch (error) { |
| 90 | console.error(error); | 108 | console.error(error); |
| 91 | return response.status(500).send({ error: 'Unable to retrieve files' }); | 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 | }); | ||