From 8d608bcd725ce7f9e26719c85128f547d631a8ce Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Tue, 4 Mar 2025 23:16:56 +0200 Subject: [PATCH] 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 --- public/scripts/extensions.js | 6 + public/scripts/extensions/gallery/index.js | 407 ++++++++++++++++---- public/scripts/extensions/gallery/style.css | 40 ++ src/endpoints/images.js | 52 ++- 4 files changed, 421 insertions(+), 84 deletions(-) diff --git a/public/scripts/extensions.js b/public/scripts/extensions.js index 773990fab..5fbcdbdb5 100644 --- a/public/scripts/extensions.js +++ b/public/scripts/extensions.js @@ -210,6 +210,12 @@ export const extension_settings = { * @type {string[]} */ disabled_attachments: [], + gallery: { + /** @type {{[characterKey: string]: string}} */ + folders: {}, + /** @type {string} */ + sort: 'dateAsc', + }, }; function showHideExtensionsMenu() { diff --git a/public/scripts/extensions/gallery/index.js b/public/scripts/extensions/gallery/index.js index e2fc98cc8..3e0375b04 100644 --- a/public/scripts/extensions/gallery/index.js +++ b/public/scripts/extensions/gallery/index.js @@ -6,7 +6,7 @@ import { event_types, } from '../../../script.js'; import { groups, selected_group } from '../../group-chats.js'; -import { loadFileToDocument, delay } from '../../utils.js'; +import { loadFileToDocument, delay, getBase64Async, getSanitizedFilename } from '../../utils.js'; import { loadMovingUIState } from '../../power-user.js'; import { dragElement } from '../../RossAscends-mods.js'; import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; @@ -14,7 +14,7 @@ import { SlashCommand } from '../../slash-commands/SlashCommand.js'; import { ARGUMENT_TYPE, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js'; import { DragAndDropHandler } from '../../dragdrop.js'; import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; -import { translate } from '../../i18n.js'; +import { t, translate } from '../../i18n.js'; const extensionName = 'gallery'; const extensionFolderPath = `scripts/extensions/${extensionName}/`; @@ -50,6 +50,48 @@ mutationObserver.observe(document.body, { subtree: false, }); +const SORT = Object.freeze({ + NAME_ASC: { value: 'nameAsc', field: 'name', order: 'asc', label: t`Sort By: Name (A-Z)` }, + NAME_DESC: { value: 'nameDesc', field: 'name', order: 'desc', label: t`Sort By: Name (Z-A)` }, + DATE_ASC: { value: 'dateAsc', field: 'date', order: 'asc', label: t`Sort By: Date (Oldest First)` }, + DATE_DESC: { value: 'dateDesc', field: 'date', order: 'desc', label: t`Sort By: Date (Newest First)` }, +}); + +const defaultSettings = Object.freeze({ + folders: {}, + sort: SORT.DATE_ASC.value, +}); + +/** + * Initializes the settings for the gallery extension. + */ +function initSettings() { + let shouldSave = false; + const context = SillyTavern.getContext(); + if (!context.extensionSettings.gallery) { + context.extensionSettings.gallery = structuredClone(defaultSettings); + shouldSave = true; + } + for (const key of Object.keys(defaultSettings)) { + if (!Object.hasOwn(context.extensionSettings.gallery, key)) { + context.extensionSettings.gallery[key] = structuredClone(defaultSettings[key]); + shouldSave = true; + } + } + if (shouldSave) { + context.saveSettingsDebounced(); + } +} + +/** + * Retrieves the gallery folder for a given character. + * @param {import('../../char-data.js').v1CharData} char Character data + * @returns {string} The gallery folder for the character + */ +function getGalleryFolder(char) { + return SillyTavern.getContext().extensionSettings.gallery.folders[char?.avatar] ?? char?.name; +} + /** * Retrieves a list of gallery items based on a given URL. This function calls an API endpoint * to get the filenames and then constructs the item list. @@ -58,11 +100,20 @@ mutationObserver.observe(document.body, { * @returns {Promise} - Resolves with an array of gallery item objects, rejects on error. */ async function getGalleryItems(url) { - const response = await fetch(`/api/images/list/${url}`, { + const sortValue = getSortOrder(); + const sortObj = Object.values(SORT).find(it => it.value === sortValue) ?? SORT.DATE_ASC; + const response = await fetch('/api/images/list', { method: 'POST', headers: getRequestHeaders(), + body: JSON.stringify({ + folder: url, + sortField: sortObj.field, + sortOrder: sortObj.order, + }), }); + url = await getSanitizedFilename(url); + const data = await response.json(); const items = data.map((file) => ({ src: `user/images/${url}/${file}`, @@ -73,6 +124,46 @@ async function getGalleryItems(url) { return items; } +/** + * Retrieves a list of gallery folders. This function calls an API endpoint + * @returns {Promise} - Resolves with an array of gallery folders. + */ +async function getGalleryFolders() { + try { + const response = await fetch('/api/images/folders', { + method: 'POST', + headers: getRequestHeaders(), + }); + + if (!response.ok) { + throw new Error(`HTTP error. Status: ${response.status}`); + } + const data = await response.json(); + return data; + } catch (error) { + console.error('Failed to fetch gallery folders:', error); + return []; + } +} + +/** + * Sets the sort order for the gallery. + * @param {string} order Sort order + */ +function setSortOrder(order) { + const context = SillyTavern.getContext(); + context.extensionSettings.gallery.sort = order; + context.saveSettingsDebounced(); +} + +/** + * Retrieves the current sort order for the gallery. + * @returns {string} The current sort order for the gallery. + */ +function getSortOrder() { + return SillyTavern.getContext().extensionSettings.gallery.sort ?? defaultSettings.sort; +} + /** * Initializes a gallery using the provided items and sets up the drag-and-drop functionality. * It uses the nanogallery2 library to display the items and also initializes @@ -106,11 +197,28 @@ async function initGallery(items, url) { }, galleryDisplayMode: 'pagination', fnThumbnailOpen: viewWithDragbox, + fnThumbnailInit: function (/** @type {JQuery} */ $thumbnail, /** @type {{src: string}} */ item) { + if (!item?.src) return; + $thumbnail.attr('title', String(item.src).split('/').pop()); + }, }); - const dragDropHandler = new DragAndDropHandler(`#dragGallery.${nonce}`, async (files, event) => { - let file = files[0]; - uploadFile(file, url); // Added url parameter to know where to upload + const dragDropHandler = new DragAndDropHandler(`#dragGallery.${nonce}`, async (files) => { + if (!Array.isArray(files) || files.length === 0) { + return; + } + + // Upload each file + for (const file of files) { + await uploadFile(file, url); + } + + // Refresh the gallery + const newItems = await getGalleryItems(url); + $('#dragGallery').closest('#gallery').remove(); + await makeMovable(url); + await delay(100); + await initGallery(newItems, url); }); const resizeHandler = function () { @@ -170,14 +278,13 @@ async function showCharGallery() { try { let url = selected_group || this_chid; if (!selected_group && this_chid !== undefined) { - const char = characters[this_chid]; - url = char.name; + url = getGalleryFolder(characters[this_chid]); } const items = await getGalleryItems(url); // if there already is a gallery, destroy it and place this one in its place $('#dragGallery').closest('#gallery').remove(); - makeMovable(); + await makeMovable(url); await delay(100); await initGallery(items, url); } catch (err) { @@ -196,92 +303,79 @@ async function showCharGallery() { * @returns {Promise} - Promise representing the completion of the file upload and gallery refresh. */ async function uploadFile(file, url) { - // Convert the file to a base64 string - const reader = new FileReader(); - reader.onloadend = async function () { - const base64Data = reader.result; + try { + // Convert the file to a base64 string + const base64Data = await getBase64Async(file); // Create the payload const payload = { image: base64Data, + ch_name: url, }; - // Add the ch_name from the provided URL (assuming it's the character name) - payload.ch_name = url; + const response = await fetch('/api/images/upload', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify(payload), + }); - try { - const headers = await getRequestHeaders(); - - // Merge headers with content-type for JSON - Object.assign(headers, { - 'Content-Type': 'application/json', - }); - - const response = await fetch('/api/images/upload', { - method: 'POST', - headers: headers, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`); - } - - const result = await response.json(); - - toastr.success('File uploaded successfully. Saved at: ' + result.path); - - // Refresh the gallery - const newItems = await getGalleryItems(url); // Fetch the latest items - $('#dragGallery').closest('#gallery').remove(); // Destroy old gallery - makeMovable(); - await delay(100); - await initGallery(newItems, url); // Reinitialize the gallery with new items and pass 'url' - } catch (error) { - console.error('There was an issue uploading the file:', error); - - // Replacing alert with toastr error notification - toastr.error('Failed to upload the file.'); + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); } - }; - reader.readAsDataURL(file); + + const result = await response.json(); + + toastr.success(t`File uploaded successfully. Saved at: ${result.path}`); + } catch (error) { + console.error('There was an issue uploading the file:', error); + + // Replacing alert with toastr error notification + toastr.error(t`Failed to upload the file.`); + } } -$(document).ready(function () { - // Register an event listener - eventSource.on('charManagementDropdown', (selectedOptionId) => { - if (selectedOptionId === 'show_char_gallery') { - showCharGallery(); - } - }); - - // Add an option to the dropdown - $('#char-management-dropdown').append( - $('