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>

8d608bcd725ce7f9e26719c85128f547d631a8ce

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
4 files changed, +418 -81Ignore whitespace
public/scripts/extensions.js+6 -0
@@ -210,6 +210,12 @@ export const extension_settings = {
210210 * @type {string[]}
211211 */
212212 disabled_attachments: [],
213+ gallery: {
214+ /** @type {{[characterKey: string]: string}} */
215+ folders: {},
216+ /** @type {string} */
217+ sort: 'dateAsc',
218+ },
213219};
214220
215221function showHideExtensionsMenu() {
public/scripts/extensions/gallery/index.js+329 -74
@@ -6,7 +6,7 @@ import {
66 event_types,
77} from '../../../script.js';
88import { groups, selected_group } from '../../group-chats.js';
99import { loadFileToDocument, delay, getBase64Async, getSanitizedFilename } from '../../utils.js';
1010import { loadMovingUIState } from '../../power-user.js';
1111import { dragElement } from '../../RossAscends-mods.js';
1212import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
@@ -14,7 +14,7 @@ import { SlashCommand } from '../../slash-commands/SlashCommand.js';
1414import { ARGUMENT_TYPE, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
1515import { DragAndDropHandler } from '../../dragdrop.js';
1616import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
1717import { t, translate } from '../../i18n.js';
1818
1919const extensionName = 'gallery';
2020const extensionFolderPath = `scripts/extensions/${extensionName}/`;
@@ -50,6 +50,48 @@ mutationObserver.observe(document.body, {
5050 subtree: false,
5151});
5252
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+
5395/**
5496 * Retrieves a list of gallery items based on a given URL. This function calls an API endpoint
5597 * to get the filenames and then constructs the item list.
@@ -58,11 +100,20 @@ mutationObserver.observe(document.body, {
58100 * @returns {Promise<Array>} - Resolves with an array of gallery item objects, rejects on error.
59101 */
60102async 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', {
62106 method: 'POST',
63107 headers: getRequestHeaders(),
108+ body: JSON.stringify({
109+ folder: url,
110+ sortField: sortObj.field,
111+ sortOrder: sortObj.order,
112+ }),
64113 });
65114
115+ url = await getSanitizedFilename(url);
116+
66117 const data = await response.json();
67118 const items = data.map((file) => ({
68119 src: `user/images/${url}/${file}`,
@@ -74,6 +125,46 @@ async function getGalleryItems(url) {
74125}
75126
76127/**
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+/**
77168 * Initializes a gallery using the provided items and sets up the drag-and-drop functionality.
78169 * It uses the nanogallery2 library to display the items and also initializes
79170 * event listeners to handle drag-and-drop of files onto the gallery.
@@ -106,11 +197,28 @@ async function initGallery(items, url) {
106197 },
107198 galleryDisplayMode: 'pagination',
108199 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+ },
109204 });
110205
111206 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);
114222 });
115223
116224 const resizeHandler = function () {
@@ -170,14 +278,13 @@ async function showCharGallery() {
170278 try {
171279 let url = selected_group || this_chid;
172280 if (!selected_group && this_chid !== undefined) {
173281 const charurl = getGalleryFolder(characters[this_chid]);
174- url = char.name;
175282 }
176283
177284 const items = await getGalleryItems(url);
178285 // if there already is a gallery, destroy it and place this one in its place
179286 $('#dragGallery').closest('#gallery').remove();
180287 await makeMovable(url);
181288 await delay(100);
182289 await initGallery(items, url);
183290 } catch (err) {
@@ -196,92 +303,79 @@ async function showCharGallery() {
196303 * @returns {Promise<void>} - Promise representing the completion of the file upload and gallery refresh.
197304 */
198305async 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;
203309
204310 // Create the payload
205311 const payload = {
206312 image: base64Data,
313+ ch_name: url,
207314 };
208315
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- }
229321
230- const result = await response.json();
322+ if (!response.ok) {
323+ throw new Error(`HTTP error! Status: ${response.status}`);
324+ }
231325
232- toastr.success('File uploaded successfully. Saved at: ' + result.path);
326+ const result = await response.json();
233327
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);
242331
243332 // Replacing alert with toastr error notification
244333 toastr.error('t`Failed to upload the file.'`);
245334 }
246- };
247- reader.readAsDataURL(file);
248335}
249336
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-
267337/**
268338 * Creates a new draggable container based on a template.
269339 * This function takes a template with the ID 'generic_draggable_template' and clones it.
270340 * The cloned element has its attributes set, a new child div appended, and is made visible on the body.
271341 * 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.
272344 */
273345async function makeMovable(id = 'gallery'url) {
274-
275346 console.debug('making new container from template');
347+ const id = 'gallery';
276348 const template = $('#generic_draggable_template').html();
277349 const newElement = $(template);
278350 newElement.css('background-color', 'var(--SmartThemeBlurTintColor)');
279351 newElement.attr('forChar', id);
280352 newElement.attr('id', id);
281353 newElement.find('.drag-grabber').attr('id', `${id}header`);
282354 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+
285379 // add no-scrollbar class to this element
286380 newElement.addClass('no-scrollbar');
287381
@@ -290,6 +384,81 @@ function makeMovable(id = 'gallery') {
290384 closeButton.attr('id', `${id}close`);
291385 closeButton.attr('data-related-id', `${id}`);
292386
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+
293462 $('#dragGallery').css('display', 'block');
294463
295464 $('#movingDivs').append(newElement);
@@ -306,6 +475,59 @@ function makeMovable(id = 'gallery') {
306475}
307476
308477/**
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+/**
309531 * Creates a new draggable image based on a template.
310532 *
311533 * This function clones a provided template with the ID 'generic_draggable_template',
@@ -331,7 +553,7 @@ function makeDragImg(id, url) {
331553 const imgElem = document.createElement('img');
332554 imgElem.src = url;
333555 let uniqueId = `draggable_${id}`;
334556 const draggableElem = /** @type {HTMLElement} */ (newElement.querySelector('.draggable'));
335557 if (draggableElem) {
336558 draggableElem.appendChild(imgElem);
337559
@@ -351,7 +573,7 @@ function makeDragImg(id, url) {
351573
352574 // Add an id to the close button
353575 // If the close button exists, set related-id
354576 const closeButton = /** @type {HTMLElement} */ (draggableElem.querySelector('.dragClose'));
355577 if (closeButton) {
356578 closeButton.id = `${uniqueId}close`;
357579 closeButton.dataset.relatedId = uniqueId;
@@ -456,8 +678,7 @@ async function listGalleryCommand(args) {
456678 try {
457679 let url = args.char ?? (args.group ? groups.find(it => it.name == args.group)?.id : null) ?? (selected_group || this_chid);
458680 if (!args.char && !args.group && !selected_group && this_chid !== undefined) {
459681 const charurl = getGalleryFolder(characters[this_chid]);
460- url = char.name;
461682 }
462683
463684 const items = await getGalleryItems(url);
@@ -469,3 +690,37 @@ async function listGalleryCommand(args) {
469690 }
470691 return JSON.stringify([]);
471692}
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+})();
public/scripts/extensions/gallery/style.css+40 -0
@@ -3,3 +3,43 @@
33 align-items: center;
44 justify-content: center;
55}
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+}
src/endpoints/images.js+43 -7
@@ -76,18 +76,54 @@ router.post('/upload', jsonParser, async (request, response) => {
7676 }
7777});
7878
7979router.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+ }
8185
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;
8488 }
8589
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+ }
88106 return response.send(images);
89107 } catch (error) {
90108 console.error(error);
91109 return response.status(500).send({ error: 'Unable to retrieve files' });
92110 }
93111});
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+});