Background Folders (#5187) * backend, frontend, bugfixes * Mobile button and sizing * lint * clear folder thumbnailFile on delete, rename thumbnailFile on rename * use filteredImages when changing sort option * Address all the review comments * Fix friendly title generation to handle empty strings * Move add folder button to the header * instead of search filtering the backgrounds in a folder and showing the folder if the results > 0, search the folder names. * Trade button places * Adjust button text * feat: restrict folder creation to the Global tab --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

54bba07420225e4c27f0b2fb5aaf1d13655703ed

Lucas Scala <123923688+Vibecoder9000@users.noreply.github.com>

Signed
6 files changed, +1079 -16Ignore whitespace
public/css/backgrounds.css+168 -1
@@ -107,7 +107,8 @@
107 opacity: 1;107 opacity: 1;
108}108}
109109
110.bg_example .mobile-only-menu-toggle {110.bg_example .mobile-only-menu-toggle,
111.bg_folder_tile .mobile-only-menu-toggle {
111 display: none;112 display: none;
112}113}
113114
@@ -376,3 +377,169 @@
376#bg_custom_content:not(:empty)~#bg_chat_hint {377#bg_custom_content:not(:empty)~#bg_chat_hint {
377 display: none;378 display: none;
378}379}
380
381.bg_folder_grid.bg_list {
382 display: grid;
383 gap: 5px;
384 width: 100%;
385 grid-template-columns: repeat(calc(var(--bg-thumb-columns, 5) + 2), 1fr);
386 margin-bottom: 10px;
387}
388
389.bg_folder_grid:empty {
390 display: none;
391}
392
393.bg_folder_tile {
394 cursor: pointer;
395 position: relative;
396 overflow: hidden;
397 border-radius: 8px;
398 height: auto;
399 aspect-ratio: 1 / 1;
400 outline: 2px solid var(--SmartThemeBorderColor);
401 outline-offset: -1px;
402 box-shadow: 0 0 7px var(--black50a);
403 display: flex;
404 flex-direction: column;
405 align-items: center;
406 justify-content: center;
407 background-color: var(--SmartThemeBlurTintColor);
408 transition: filter var(--animation-duration) ease;
409}
410
411.bg_folder_tile:hover {
412 filter: brightness(1.1);
413}
414
415.bg_folder_tile_cover {
416 position: absolute;
417 top: 0;
418 left: 0;
419 right: 0;
420 bottom: 0;
421 background-size: cover;
422 background-position: center;
423 border-radius: inherit;
424}
425
426.bg_folder_tile_overlay {
427 position: absolute;
428 bottom: 0;
429 left: 0;
430 right: 0;
431 background: linear-gradient(transparent, rgba(0, 0, 0, 0.85));
432 color: var(--SmartThemeBodyColor);
433 padding: 6px 8px 4px;
434 display: flex;
435 align-items: center;
436 gap: 5px;
437 font-size: 0.85em;
438 font-weight: 600;
439 pointer-events: none;
440 border-radius: 0 0 8px 8px;
441}
442
443.bg_folder_tile_overlay i {
444 font-size: 0.9em;
445 opacity: 0.8;
446}
447
448.bg_folder_tile_name {
449 white-space: nowrap;
450 overflow: hidden;
451 text-overflow: ellipsis;
452}
453
454/* Folder tile action menu */
455.bg_folder_tile .bg_folder_tile_menu {
456 display: flex;
457 position: absolute;
458 top: 2px;
459 right: 2px;
460 background-color: rgba(0, 0, 0, 0.5);
461 border-radius: 5px;
462 padding: 3px;
463 z-index: 3;
464 backdrop-filter: blur(4px);
465 border: 1px solid var(--SmartThemeBorderColor);
466 align-items: center;
467
468 opacity: 0;
469 visibility: hidden;
470 transform: scale(0.9);
471 transform-origin: center;
472 transition: opacity var(--animation-duration) ease-out, visibility var(--animation-duration) ease-out, transform var(--animation-duration) ease-out;
473}
474
475.bg_folder_tile:hover .bg_folder_tile_menu,
476.bg_folder_tile:focus-within .bg_folder_tile_menu {
477 opacity: 1;
478 visibility: visible;
479 transform: scale(1);
480}
481
482.bg_folder_tile .jg-button {
483 display: flex;
484 width: 24px;
485 height: 24px;
486 align-items: center;
487 justify-content: center;
488 color: white;
489 padding: 5px;
490 font-size: 1.1em;
491 border-radius: 5px;
492 transition: background-color var(--animation-duration) ease;
493}
494
495.bg_folder_tile .jg-button:hover {
496 background-color: rgba(255, 255, 255, 0.2);
497}
498
499/* New Folder placeholder tile */
500.bg_new_folder_tile {
501 border: 2px dashed var(--SmartThemeBorderColor);
502 outline: none;
503 color: var(--SmartThemeBodyColor);
504 opacity: 0.6;
505 gap: 6px;
506 font-size: 0.85em;
507 font-weight: 600;
508}
509
510.bg_new_folder_tile:hover {
511 opacity: 1;
512 transform: scale(1.03);
513}
514
515/* Breadcrumb bar */
516.bg_folder_breadcrumb {
517 display: flex;
518 align-items: center;
519 gap: 8px;
520 padding: 5px 0;
521 margin-bottom: 5px;
522}
523
524.bg_current_folder_name {
525 font-weight: 600;
526 font-size: 1em;
527 color: var(--SmartThemeBodyColor);
528 white-space: nowrap;
529 overflow: hidden;
530 text-overflow: ellipsis;
531}
532
533#Backgrounds:not(.in-folder-view) .jg-set-cover {
534 display: none !important;
535}
536
537#Backgrounds.in-folder-view .jg-set-cover {
538 display: flex !important;
539}
540
541/* Hide folder actions on custom (chat-specific) backgrounds */
542.bg_example[custom="true"] .jg-folder,
543.bg_example[custom="true"] .jg-set-cover {
544 display: none !important;
545}
public/css/mobile-styles.css+52 -1
@@ -38,6 +38,56 @@
38 grid-template-columns: repeat(var(--bg-thumb-columns, 3), 1fr);38 grid-template-columns: repeat(var(--bg-thumb-columns, 3), 1fr);
39 }39 }
4040
41 .bg_folder_grid {
42 grid-template-columns: repeat(var(--bg-thumb-columns, 3), 1fr);
43 }
44
45 .bg_folder_tile .bg_folder_tile_menu {
46 opacity: 0;
47 visibility: hidden;
48 transform: scale(0.9);
49 }
50
51 .bg_folder_tile:hover .bg_folder_tile_menu,
52 .bg_folder_tile:focus-within .bg_folder_tile_menu {
53 display: none;
54 }
55
56 .bg_folder_tile.mobile-menu-open .bg_folder_tile_menu {
57 display: flex;
58 opacity: 1;
59 visibility: visible;
60 transform: scale(1);
61 z-index: 4;
62 }
63
64 .bg_folder_tile.mobile-menu-open .mobile-only-menu-toggle {
65 opacity: 0;
66 pointer-events: none;
67 }
68
69 .bg_folder_tile .mobile-only-menu-toggle {
70 display: flex;
71 align-items: center;
72 justify-content: center;
73 position: absolute;
74 top: 5px;
75 right: 5px;
76 width: 30px;
77 height: 30px;
78 background-color: rgba(0, 0, 0, 0.4);
79 color: white;
80 border-radius: 6px;
81 z-index: 3;
82 cursor: pointer;
83 backdrop-filter: blur(2px);
84 }
85
86 .bg_folder_tile .jg-button {
87 width: 30px;
88 height: 30px;
89 }
90
41 .bg_list {91 .bg_list {
42 width: unset;92 width: unset;
43 }93 }
@@ -102,7 +152,8 @@
102 }152 }
103153
104 #add_background_button_top>span,154 #add_background_button_top>span,
105 #auto_background>span {155 #auto_background>span,
156 #bg_add_folder_button>span {
106 display: none;157 display: none;
107 }158 }
108159
public/index.html+36 -0
@@ -5597,6 +5597,10 @@
5597 <i class="fa-solid fa-wand-magic"></i>5597 <i class="fa-solid fa-wand-magic"></i>
5598 <span data-i18n="Auto-select">Auto-select</span>5598 <span data-i18n="Auto-select">Auto-select</span>
5599 </div>5599 </div>
5600 <button id="bg_add_folder_button" class="menu_button menu_button_icon" data-i18n="[title]New Folder" title="New Folder">
5601 <i class="fa-solid fa-folder-plus"></i>
5602 <span data-i18n="New Folder">New Folder</span>
5603 </button>
5600 <label for="add_bg_button" id="add_background_button_top" class="menu_button menu_button_icon" data-i18n="[title]Add a new background" title="Add a new background">5604 <label for="add_bg_button" id="add_background_button_top" class="menu_button menu_button_icon" data-i18n="[title]Add a new background" title="Add a new background">
5601 <i class="fa-solid fa-upload"></i>5605 <i class="fa-solid fa-upload"></i>
5602 <span data-i18n="Add Background">Add Background</span>5606 <span data-i18n="Add Background">Add Background</span>
@@ -5630,6 +5634,14 @@
5630 </div>5634 </div>
5631 </ul>5635 </ul>
5632 <div id="bg_global_tab" class="bg_tab_panel">5636 <div id="bg_global_tab" class="bg_tab_panel">
5637 <div id="bg_folder_breadcrumb" class="bg_folder_breadcrumb" style="display:none;">
5638 <button id="bg_back_to_folders" class="menu_button menu_button_icon">
5639 <i class="fa-solid fa-arrow-left"></i>
5640 <span data-i18n="Back">Back</span>
5641 </button>
5642 <span id="bg_current_folder_name" class="bg_current_folder_name"></span>
5643 </div>
5644 <div id="bg_folder_grid" class="bg_list bg_folder_grid"></div>
5633 <div id="bg_menu_content" class="bg_list"></div>5645 <div id="bg_menu_content" class="bg_list"></div>
5634 </div>5646 </div>
5635 <div id="bg_chat_tab" class="bg_tab_panel">5647 <div id="bg_chat_tab" class="bg_tab_panel">
@@ -6598,10 +6610,34 @@
6598 <div data-action="copy" class="jg-button jg-copy fa-solid fa-file-arrow-up" data-i18n="[title]Copy to global backgrounds" title="Copy to global backgrounds"></div>6610 <div data-action="copy" class="jg-button jg-copy fa-solid fa-file-arrow-up" data-i18n="[title]Copy to global backgrounds" title="Copy to global backgrounds"></div>
6599 <div data-action="edit" class="jg-button jg-edit fa-solid fa-pen-to-square fa-fw pointer" data-i18n="[title]Rename Background" title="Rename Background"></div>6611 <div data-action="edit" class="jg-button jg-edit fa-solid fa-pen-to-square fa-fw pointer" data-i18n="[title]Rename Background" title="Rename Background"></div>
6600 <div data-action="delete" class="jg-button jg-delete fa-solid fa-trash-can fa-fw pointer" data-i18n="[title]Delete Background" title="Delete Background"></div>6612 <div data-action="delete" class="jg-button jg-delete fa-solid fa-trash-can fa-fw pointer" data-i18n="[title]Delete Background" title="Delete Background"></div>
6613 <div data-action="folder" class="jg-button jg-folder fa-solid fa-folder fa-fw pointer" data-i18n="[title]Add to folder" title="Add to folder"></div>
6614 <div data-action="set-cover" class="jg-button jg-set-cover fa-solid fa-image fa-fw pointer" data-i18n="[title]Set as folder cover" title="Set as folder cover"></div>
6601 </div>6615 </div>
6602 <div class="BGSampleTitle"></div>6616 <div class="BGSampleTitle"></div>
6603 </div>6617 </div>
6604 </div>6618 </div>
6619 <div id="bg_folder_tile_template" class="template_element">
6620 <div class="bg_folder_tile" data-folder-id="">
6621 <div class="bg_folder_tile_cover"></div>
6622 <div class="bg_folder_tile_overlay">
6623 <i class="fa-solid fa-folder"></i>
6624 <span class="bg_folder_tile_name"></span>
6625 </div>
6626 <div class="mobile-only-menu-toggle">
6627 <i class="fa-solid fa-ellipsis-vertical"></i>
6628 </div>
6629 <div class="bg_folder_tile_menu jg-menu">
6630 <div data-action="rename-folder" class="jg-button fa-solid fa-pen-to-square fa-fw pointer" data-i18n="[title]Rename folder" title="Rename folder"></div>
6631 <div data-action="delete-folder" class="jg-button fa-solid fa-trash-can fa-fw pointer" data-i18n="[title]Delete folder" title="Delete folder"></div>
6632 </div>
6633 </div>
6634 </div>
6635 <div id="bg_new_folder_template" class="template_element">
6636 <div class="bg_folder_tile bg_new_folder_tile">
6637 <i class="fa-solid fa-folder-plus fa-2x"></i>
6638 <span data-i18n="New Folder">New Folder</span>
6639 </div>
6640 </div>
6605 <!-- templates for JS to reuse when needed -->6641 <!-- templates for JS to reuse when needed -->
6606 <div id="character_world_template" class="template_element">6642 <div id="character_world_template" class="template_element">
6607 <div class="character_world range-block flexFlowColumn flex-container">6643 <div class="character_world range-block flexFlowColumn flex-container">
public/scripts/backgrounds.js+494 -5
@@ -6,7 +6,7 @@ import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
6import { createThumbnail, flashHighlight, getBase64Async, stringFormat, debounce, setupScrollToTop, saveBase64AsFile, getFileExtension, sortIgnoreCaseAndAccents } from './utils.js';6import { createThumbnail, flashHighlight, getBase64Async, stringFormat, debounce, setupScrollToTop, saveBase64AsFile, getFileExtension, sortIgnoreCaseAndAccents } from './utils.js';
7import { debounce_timeout } from './constants.js';7import { debounce_timeout } from './constants.js';
8import { t } from './i18n.js';8import { t } from './i18n.js';
9import { Popup } from './popup.js';9import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js';
10import { groups, selected_group } from './group-chats.js';10import { groups, selected_group } from './group-chats.js';
11import { humanizedDateTime } from './RossAscends-mods.js';11import { humanizedDateTime } from './RossAscends-mods.js';
12import { deleteMediaFromServer } from './chats.js';12import { deleteMediaFromServer } from './chats.js';
@@ -14,6 +14,13 @@ import { deleteMediaFromServer } from './chats.js';
14const BG_METADATA_KEY = 'custom_background';14const BG_METADATA_KEY = 'custom_background';
15const LIST_METADATA_KEY = 'chat_backgrounds';15const LIST_METADATA_KEY = 'chat_backgrounds';
1616
17/** @type {Array<{id: string, name: string, thumbnailFile: string}>} */
18let folderList = [];
19/** @type {Object.<string, string[]>} filename → folderIds */
20let imageFolderMap = {};
21/** @type {string|null} Currently active folder drill-in, or null for root */
22let activeFolderId = null;
23
17// A single transparent PNG pixel used as a placeholder for errored backgrounds24// A single transparent PNG pixel used as a placeholder for errored backgrounds
18const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';25const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
19const PNG_PIXEL_BLOB = new Blob([Uint8Array.from(atob(PNG_PIXEL), c => c.charCodeAt(0))], { type: 'image/png' });26const PNG_PIXEL_BLOB = new Blob([Uint8Array.from(atob(PNG_PIXEL), c => c.charCodeAt(0))], { type: 'image/png' });
@@ -170,7 +177,7 @@ function createThumbnailElement(imageData) {
170177
171 const url = generateUrlParameter(bg, isCustom);178 const url = generateUrlParameter(bg, isCustom);
172 const title = isCustom ? bg.split('/').pop() : bg;179 const title = isCustom ? bg.split('/').pop() : bg;
173 const friendlyTitle = title.slice(0, title.lastIndexOf('.'));180 const friendlyTitle = String(title || '').slice(0, title.lastIndexOf('.'));
174181
175 thumbnail.attr('title', title);182 thumbnail.attr('title', title);
176 thumbnail.attr('bgfile', bg);183 thumbnail.attr('bgfile', bg);
@@ -278,6 +285,17 @@ export function getBackgroundPath(fileUrl) {
278 return `backgrounds/${encodeURIComponent(fileUrl)}`;285 return `backgrounds/${encodeURIComponent(fileUrl)}`;
279}286}
280287
288/**
289 * Gets the raw server-side relative path for a background image (no URL encoding).
290 * Used when communicating paths to the API (stored as plain strings in metadata).
291 * @param {string} file File name of the background image
292 * @returns {string} Raw relative path, e.g. "backgrounds/my file.jpg"
293 */
294function getBackgroundRelativePath(file) {
295 return `backgrounds/${file}`;
296}
297
298
281function highlightLockedBackground() {299function highlightLockedBackground() {
282 $('.bg_example.locked-background').removeClass('locked-background');300 $('.bg_example.locked-background').removeClass('locked-background');
283301
@@ -553,6 +571,24 @@ async function onDeleteBackgroundClick(e) {
553 }571 }
554 }572 }
555573
574 // Remove from local image list so it doesn't reappear on re-render
575 const deletedBg = bgToDelete.attr('bgfile');
576 if (deletedBg) {
577 const cachedIdx = cachedSystemBackgrounds.findIndex(img => img.filename === deletedBg);
578 if (cachedIdx !== -1) cachedSystemBackgrounds.splice(cachedIdx, 1);
579
580 // Update folder map and clear folder thumbnail if it referenced this image
581 if (imageFolderMap[deletedBg]) {
582 delete imageFolderMap[deletedBg];
583 }
584 for (const folder of folderList) {
585 if (folder.thumbnailFile === deletedBg) {
586 folder.thumbnailFile = '';
587 }
588 }
589 renderFolderGrid();
590 }
591
556 bgToDelete.remove();592 bgToDelete.remove();
557593
558 if (url === chat_metadata[BG_METADATA_KEY]) {594 if (url === chat_metadata[BG_METADATA_KEY]) {
@@ -664,9 +700,14 @@ export async function getBackgrounds() {
664 const { images, config } = await response.json();700 const { images, config } = await response.json();
665 Object.assign(THUMBNAIL_CONFIG, config);701 Object.assign(THUMBNAIL_CONFIG, config);
666 cachedSystemBackgrounds = images;702 cachedSystemBackgrounds = images;
703
704 // Load folders first so getFilteredImages() works correctly in folder view
705 await loadFolders();
706
667 await preloadImageMetadata();707 await preloadImageMetadata();
668708
669 renderSystemBackgrounds(images);709 // Render only filtered images if inside a folder, otherwise all
710 renderSystemBackgrounds(getFilteredImages());
670 highlightSelectedBackground();711 highlightSelectedBackground();
671 }712 }
672}713}
@@ -696,6 +737,389 @@ async function preloadImageMetadata() {
696 }737 }
697}738}
698739
740/**
741 * Loads folder data from the server (separate from image loading).
742 */
743async function loadFolders() {
744 try {
745 const response = await fetch('/api/backgrounds/folders', {
746 method: 'POST',
747 headers: getRequestHeaders(),
748 body: JSON.stringify({}),
749 });
750 if (response.ok) {
751 const data = await response.json();
752 folderList = data.folders || [];
753 imageFolderMap = data.imageFolderMap || {};
754
755 // Auto-assign thumbnail for folders that don't have one, then persist
756 const allImages = cachedSystemBackgrounds.map(img => img.filename);
757 /** @type {{id: string, thumbnailFile: string}[]} */
758 const thumbnailUpdates = [];
759 for (const folder of folderList) {
760 if (!folder.thumbnailFile) {
761 const firstImage = allImages.find(img => {
762 const fids = imageFolderMap[img];
763 return fids && fids.includes(folder.id);
764 });
765 if (firstImage) {
766 folder.thumbnailFile = firstImage;
767 thumbnailUpdates.push({ id: folder.id, thumbnailFile: firstImage });
768 }
769 }
770 }
771 if (thumbnailUpdates.length > 0) {
772 await fetch('/api/image-metadata/folders/set-thumbnails', {
773 method: 'POST',
774 headers: getRequestHeaders(),
775 body: JSON.stringify({ updates: thumbnailUpdates }),
776 }).catch(err => console.debug('Auto-thumbnail save failed:', err));
777 }
778
779 renderFolderGrid();
780 }
781 } catch (error) {
782 console.error('Error loading folders:', error);
783 }
784}
785
786/**
787 * Renders the folder grid inside #bg_folder_grid.
788 */
789function renderFolderGrid() {
790 const container = $('#bg_folder_grid');
791 container.empty();
792
793 if (folderList.length === 0 && !activeFolderId) {
794 return;
795 }
796
797 for (const folder of folderList) {
798 const tile = createFolderTileElement(folder);
799 container.append(tile);
800 }
801}
802
803/**
804 * Creates a single folder tile DOM element.
805 * @param {{id: string, name: string, thumbnailFile: string}} folder
806 * @returns {HTMLElement}
807 */
808function createFolderTileElement(folder) {
809 const tile = $('#bg_folder_tile_template .bg_folder_tile').clone();
810 tile.attr('data-folder-id', folder.id);
811 tile.find('.bg_folder_tile_name').text(folder.name);
812
813 // Set cover image (async, update when resolved)
814 getFolderCoverUrl(folder).then(coverUrl => {
815 if (coverUrl) {
816 tile.find('.bg_folder_tile_cover').css('background-image', `url("${coverUrl}")`);
817 }
818 });
819
820 return tile.get(0);
821}
822
823/**
824 * Gets the cover image URL for a folder.
825 * Uses thumbnailFile if set, otherwise falls back to the first image in the folder.
826 * @param {{id: string, name: string, thumbnailFile: string}} folder
827 * @returns {Promise<string|null>}
828 */
829async function getFolderCoverUrl(folder) {
830 const file = folder.thumbnailFile || cachedSystemBackgrounds.find(img => {
831 const fids = imageFolderMap[img.filename];
832 return fids && fids.includes(folder.id);
833 })?.filename;
834 if (!file) return null;
835
836 if (isAnimatedBackgroundExtension(file) && !background_settings.animation) {
837 return getThumbnailFromStorage(file, false);
838 }
839 return getThumbnailUrl('bg', file);
840}
841
842/**
843 * Gets images filtered by the active folder.
844 * @returns {Array<{filename: string, isAnimated: boolean}>}
845 */
846function getFilteredImages() {
847 if (!activeFolderId) return cachedSystemBackgrounds;
848 return cachedSystemBackgrounds.filter(img => {
849 const fids = imageFolderMap[img.filename];
850 return fids && fids.includes(activeFolderId);
851 });
852}
853
854/**
855 * Drills into a folder — hides folder grid, shows breadcrumb, filters images.
856 * @param {string} folderId
857 */
858function onFolderDrillIn(folderId) {
859 const folder = folderList.find(f => f.id === folderId);
860 if (!folder) return;
861
862 activeFolderId = folderId;
863 $('#Backgrounds').addClass('in-folder-view');
864
865 // Hide folder grid, show breadcrumb
866 $('#bg_folder_grid').hide();
867 $('#bg_folder_breadcrumb').show();
868 $('#bg_current_folder_name').text(folder.name);
869
870 // Render only this folder's images
871 renderSystemBackgrounds(getFilteredImages());
872 highlightSelectedBackground();
873}
874
875/**
876 * Returns to the root folder overview.
877 */
878function onBackToFolders() {
879 activeFolderId = null;
880 $('#Backgrounds').removeClass('in-folder-view');
881
882 // Show folder grid, hide breadcrumb
883 $('#bg_folder_grid').show();
884 $('#bg_folder_breadcrumb').hide();
885 $('#bg_current_folder_name').text('');
886
887 // Show all images
888 renderSystemBackgrounds(getFilteredImages());
889 highlightSelectedBackground();
890}
891
892/**
893 * Creates a new folder via API.
894 */
895async function onCreateFolder() {
896 const currentTab = getActiveBackgroundTab();
897 if (currentTab !== BG_SOURCES.GLOBAL) {
898 toastr.warning(t`Folders can only be created in the Global tab`);
899 return;
900 }
901
902 const name = await Popup.show.input(t`Enter folder name:`);
903 if (!name || !name.trim()) return;
904
905 try {
906 const response = await fetch('/api/image-metadata/folders/create', {
907 method: 'POST',
908 headers: getRequestHeaders(),
909 body: JSON.stringify({ name: name.trim() }),
910 });
911 if (response.ok) {
912 const folder = await response.json();
913 folderList.push(folder);
914 renderFolderGrid();
915 toastr.success(t`Folder created: ${folder.name}`);
916 }
917 } catch (error) {
918 console.error('Error creating folder:', error);
919 toastr.error(t`Failed to create folder`);
920 }
921}
922
923/**
924 * Renames a folder via API.
925 * @param {string} folderId
926 */
927async function onRenameFolder(folderId) {
928 const folder = folderList.find(f => f.id === folderId);
929 if (!folder) return;
930
931 const newName = await Popup.show.input(t`Enter new folder name:`, null, folder.name);
932 if (!newName || !newName.trim() || newName.trim() === folder.name) return;
933
934 try {
935 const response = await fetch('/api/image-metadata/folders/update', {
936 method: 'POST',
937 headers: getRequestHeaders(),
938 body: JSON.stringify({ id: folderId, name: newName.trim() }),
939 });
940 if (response.ok) {
941 folder.name = newName.trim();
942 renderFolderGrid();
943 toastr.success(t`Folder renamed`);
944 }
945 } catch (error) {
946 console.error('Error renaming folder:', error);
947 toastr.error(t`Failed to rename folder`);
948 }
949}
950
951/**
952 * Deletes a folder via API.
953 * @param {string} folderId
954 */
955async function onDeleteFolder(folderId) {
956 const folder = folderList.find(f => f.id === folderId);
957 if (!folder) return;
958
959 const confirm = await Popup.show.confirm(t`Delete folder "${folder.name}"?`, t`Images will not be deleted, only the folder grouping.`);
960 if (!confirm) return;
961
962 try {
963 const response = await fetch('/api/image-metadata/folders/delete', {
964 method: 'POST',
965 headers: getRequestHeaders(),
966 body: JSON.stringify({ id: folderId }),
967 });
968 if (response.ok) {
969 folderList = folderList.filter(f => f.id !== folderId);
970 // Clean imageFolderMap
971 for (const fids of Object.values(imageFolderMap)) {
972 const idx = fids.indexOf(folderId);
973 if (idx !== -1) fids.splice(idx, 1);
974 }
975 // If we were inside this folder, go back
976 if (activeFolderId === folderId) {
977 onBackToFolders();
978 }
979 renderFolderGrid();
980 toastr.success(t`Folder deleted`);
981 }
982 } catch (error) {
983 console.error('Error deleting folder:', error);
984 toastr.error(t`Failed to delete folder`);
985 }
986}
987
988/**
989 * Shows a folder assignment popup for an image.
990 * @param {string} bgFile - The background filename
991 */
992async function onAssignToFolder(bgFile) {
993 if (folderList.length === 0) {
994 toastr.info(t`Create a folder first`);
995 return;
996 }
997
998 const currentFolderIds = imageFolderMap[bgFile] || [];
999
1000 // Build checkbox inputs for Popup using DOM construction (avoids HTML injection)
1001 const contentEl = document.createElement('div');
1002 const heading = document.createElement('h3');
1003 heading.textContent = t`Assign to folders`;
1004 contentEl.appendChild(heading);
1005
1006 for (const f of folderList) {
1007 const label = document.createElement('label');
1008 label.className = 'checkbox_label flexGap5';
1009 label.style.margin = '4px 0';
1010
1011 const checkbox = document.createElement('input');
1012 checkbox.type = 'checkbox';
1013 checkbox.dataset.folderId = f.id;
1014 checkbox.checked = currentFolderIds.includes(f.id);
1015
1016 const span = document.createElement('span');
1017 span.textContent = f.name;
1018
1019 label.appendChild(checkbox);
1020 label.appendChild(span);
1021 contentEl.appendChild(label);
1022 }
1023
1024 const content = $(contentEl);
1025
1026 const result = await callGenericPopup(content, POPUP_TYPE.CONFIRM, '', { okButton: t`Save`, cancelButton: t`Cancel` });
1027 if (!result) return;
1028
1029 // Determine which folders were toggled on/off
1030 const toAssign = [];
1031 const toUnassign = [];
1032 content.find('input[type="checkbox"]').each(function () {
1033 const fid = $(this).data('folder-id');
1034 const isChecked = $(this).prop('checked');
1035 const wasChecked = currentFolderIds.includes(fid);
1036 if (isChecked && !wasChecked) toAssign.push(fid);
1037 if (!isChecked && wasChecked) toUnassign.push(fid);
1038 });
1039
1040 const relativePath = getBackgroundRelativePath(bgFile);
1041
1042 try {
1043 for (const fid of toAssign) {
1044 const resp = await fetch('/api/image-metadata/folders/assign', {
1045 method: 'POST',
1046 headers: getRequestHeaders(),
1047 body: JSON.stringify({ id: fid, paths: [relativePath] }),
1048 });
1049 if (!resp.ok) throw new Error(`Assign to folder ${fid} failed: ${resp.status}`);
1050 }
1051 for (const fid of toUnassign) {
1052 const resp = await fetch('/api/image-metadata/folders/unassign', {
1053 method: 'POST',
1054 headers: getRequestHeaders(),
1055 body: JSON.stringify({ id: fid, paths: [relativePath] }),
1056 });
1057 if (!resp.ok) throw new Error(`Unassign from folder ${fid} failed: ${resp.status}`);
1058 }
1059
1060 // Update local state
1061 const newFolderIds = folderList
1062 .filter(f => {
1063 const wasIn = currentFolderIds.includes(f.id);
1064 if (toAssign.includes(f.id)) return true;
1065 if (toUnassign.includes(f.id)) return false;
1066 return wasIn;
1067 })
1068 .map(f => f.id);
1069
1070 if (newFolderIds.length > 0) {
1071 imageFolderMap[bgFile] = newFolderIds;
1072 } else {
1073 delete imageFolderMap[bgFile];
1074 }
1075
1076 renderFolderGrid();
1077
1078 // Re-render filtered image list if currently inside a folder view
1079 if (activeFolderId) {
1080 renderSystemBackgrounds(getFilteredImages());
1081 highlightSelectedBackground();
1082 }
1083
1084 toastr.success(t`Folder assignment updated`);
1085 } catch (error) {
1086 console.error('Error assigning to folder:', error);
1087 toastr.error(t`Failed to update folder assignment`);
1088 }
1089}
1090
1091/**
1092 * Sets an image as the folder cover.
1093 * @param {string} bgFile - The background filename
1094 */
1095async function onSetFolderCover(bgFile) {
1096 if (!activeFolderId) return;
1097
1098 try {
1099 const response = await fetch('/api/image-metadata/folders/update', {
1100 method: 'POST',
1101 headers: getRequestHeaders(),
1102 body: JSON.stringify({ id: activeFolderId, thumbnailFile: bgFile }),
1103 });
1104 if (response.ok) {
1105 const folder = folderList.find(f => f.id === activeFolderId);
1106 if (folder) {
1107 folder.thumbnailFile = bgFile;
1108 // Update the DOM tile cover image
1109 const coverUrl = await getFolderCoverUrl(folder);
1110 if (coverUrl) {
1111 $(`.bg_folder_tile[data-folder-id="${folder.id}"] .bg_folder_tile_cover`)
1112 .css('background-image', `url('${coverUrl}')`);
1113 }
1114 }
1115 toastr.success(t`Folder cover updated`);
1116 }
1117 } catch (error) {
1118 console.error('Error setting folder cover:', error);
1119 toastr.error(t`Failed to set folder cover`);
1120 }
1121}
1122
699function activateLazyLoader() {1123function activateLazyLoader() {
700 // Disconnect previous observer to prevent memory leaks1124 // Disconnect previous observer to prevent memory leaks
701 if (lazyLoadObserver) {1125 if (lazyLoadObserver) {
@@ -1001,6 +1425,21 @@ function onBackgroundFilterInput() {
1001 const hasMatch = title.toLowerCase().includes(filterValue);1425 const hasMatch = title.toLowerCase().includes(filterValue);
1002 $bg.toggle(hasMatch);1426 $bg.toggle(hasMatch);
1003 });1427 });
1428
1429 // Show/hide folder tiles based on whether folder name matches the filter
1430 if (!activeFolderId) {
1431 $('#bg_folder_grid .bg_folder_tile').each(function () {
1432 const $tile = $(this);
1433 const folderId = $tile.attr('data-folder-id');
1434 if (!folderId || !filterValue) {
1435 $tile.show();
1436 return;
1437 }
1438 const folder = folderList.find(f => f.id === folderId);
1439 const folderName = folder ? folder.name.toLowerCase() : '';
1440 $tile.toggle(folderName.includes(filterValue));
1441 });
1442 }
1004}1443}
10051444
1006const debouncedOnBackgroundFilterInput = debounce(onBackgroundFilterInput, debounce_timeout.standard);1445const debouncedOnBackgroundFilterInput = debounce(onBackgroundFilterInput, debounce_timeout.standard);
@@ -1017,6 +1456,41 @@ export function initBackgrounds() {
1017 eventSource.on(event_types.CHAT_CHANGED, onChatChanged);1456 eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
1018 eventSource.on(event_types.FORCE_SET_BACKGROUND, forceSetBackground);1457 eventSource.on(event_types.FORCE_SET_BACKGROUND, forceSetBackground);
10191458
1459 // Folder event handlers
1460 $(document)
1461 .on('click', '.bg_folder_tile:not(.bg_new_folder_tile)', function (e) {
1462 if ($(e.target).closest('.jg-button').length) return; // let button handler run
1463 const folderId = $(this).attr('data-folder-id');
1464 if (folderId) onFolderDrillIn(folderId);
1465 })
1466 .on('click', '#bg_add_folder_button', function () {
1467 onCreateFolder();
1468 })
1469 .on('click', '#bg_back_to_folders', function () {
1470 onBackToFolders();
1471 })
1472 .on('click', '.bg_folder_tile [data-action="rename-folder"]', function (e) {
1473 e.stopPropagation();
1474 const folderId = $(this).closest('.bg_folder_tile').attr('data-folder-id');
1475 if (folderId) onRenameFolder(folderId);
1476 })
1477 .on('click', '.bg_folder_tile [data-action="delete-folder"]', function (e) {
1478 e.stopPropagation();
1479 const folderId = $(this).closest('.bg_folder_tile').attr('data-folder-id');
1480 if (folderId) onDeleteFolder(folderId);
1481 })
1482 .on('click', '.bg_folder_tile .mobile-only-menu-toggle', function (e) {
1483 e.stopPropagation();
1484 const $context = $(this).closest('.bg_folder_tile');
1485 const wasOpen = $context.hasClass('mobile-menu-open');
1486 // Close all other open menus before opening a new one.
1487 $('.bg_folder_tile.mobile-menu-open').removeClass('mobile-menu-open');
1488 $('.bg_example.mobile-menu-open').removeClass('mobile-menu-open');
1489 if (!wasOpen) {
1490 $context.addClass('mobile-menu-open');
1491 }
1492 });
1493
1020 $(document)1494 $(document)
1021 .off('click', '.bg_example').on('click', '.bg_example', onSelectBackgroundClick)1495 .off('click', '.bg_example').on('click', '.bg_example', onSelectBackgroundClick)
1022 .off('click', '.bg_example .mobile-only-menu-toggle').on('click', '.bg_example .mobile-only-menu-toggle', function (e) {1496 .off('click', '.bg_example .mobile-only-menu-toggle').on('click', '.bg_example .mobile-only-menu-toggle', function (e) {
@@ -1025,6 +1499,7 @@ export function initBackgrounds() {
1025 const wasOpen = $context.hasClass('mobile-menu-open');1499 const wasOpen = $context.hasClass('mobile-menu-open');
1026 // Close all other open menus before opening a new one.1500 // Close all other open menus before opening a new one.
1027 $('.bg_example.mobile-menu-open').removeClass('mobile-menu-open');1501 $('.bg_example.mobile-menu-open').removeClass('mobile-menu-open');
1502 $('.bg_folder_tile.mobile-menu-open').removeClass('mobile-menu-open');
1028 if (!wasOpen) {1503 if (!wasOpen) {
1029 $context.addClass('mobile-menu-open');1504 $context.addClass('mobile-menu-open');
1030 }1505 }
@@ -1054,6 +1529,20 @@ export function initBackgrounds() {
1054 case 'copy':1529 case 'copy':
1055 onCopyToSystemBackgroundClick.call(this, e.originalEvent);1530 onCopyToSystemBackgroundClick.call(this, e.originalEvent);
1056 break;1531 break;
1532 case 'folder': {
1533 const bgEl = $(this).closest('.bg_example');
1534 if (bgEl.attr('custom') === 'true') break; // Only system backgrounds
1535 const bgFile = bgEl.attr('bgfile');
1536 if (bgFile) onAssignToFolder(bgFile);
1537 break;
1538 }
1539 case 'set-cover': {
1540 const bgEl = $(this).closest('.bg_example');
1541 if (bgEl.attr('custom') === 'true') break; // Only system backgrounds
1542 const bgFile = bgEl.attr('bgfile');
1543 if (bgFile) onSetFolderCover(bgFile);
1544 break;
1545 }
1057 }1546 }
1058 });1547 });
10591548
@@ -1069,8 +1558,8 @@ export function initBackgrounds() {
1069 $('#bg-sort').on('change', function () {1558 $('#bg-sort').on('change', function () {
1070 background_settings.sortOrder = String($(this).val());1559 background_settings.sortOrder = String($(this).val());
1071 saveSettingsDebounced();1560 saveSettingsDebounced();
1072 // Re-render both galleries with new sort order1561 // Re-render both galleries with new sort order (respecting active folder filter)
1073 renderSystemBackgrounds(cachedSystemBackgrounds);1562 renderSystemBackgrounds(getFilteredImages());
1074 renderChatBackgrounds();1563 renderChatBackgrounds();
1075 highlightSelectedBackground();1564 highlightSelectedBackground();
1076 highlightLockedBackground();1565 highlightLockedBackground();
src/endpoints/backgrounds.js+29 -2
@@ -5,7 +5,7 @@ import express from 'express';
5import sanitize from 'sanitize-filename';5import sanitize from 'sanitize-filename';
66
7import { invalidateThumbnail } from './thumbnails.js';7import { invalidateThumbnail } from './thumbnails.js';
8import { getOrGenerateMetadataBatch, removeMetadata, renameMetadata, thumbnailDimensions } from './image-metadata.js';8import { thumbnailDimensions, readMetadataIndex, renameMetadata, removeMetadata, getOrGenerateMetadataBatch } from './image-metadata.js';
9import { getImages } from '../util.js';9import { getImages } from '../util.js';
10import { getFileNameValidationFunction } from '../middleware/validateFileName.js';10import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
1111
@@ -37,6 +37,34 @@ router.post('/all', async function (request, response) {
37 }37 }
38});38});
3939
40/**
41 * POST /api/backgrounds/folders
42 * Returns folders and per-image folderIds from the metadata index.
43 * Loaded separately from /all to avoid blocking image rendering.
44 */
45router.post('/folders', async function (request, response) {
46 try {
47 const index = await readMetadataIndex(request.user.directories.root);
48 const folders = index.folders || [];
49
50 // Build a slim map of image → folderIds for the frontend
51 /** @type {Object.<string, string[]>} */
52 const imageFolderMap = {};
53 for (const [relativePath, meta] of Object.entries(index.images)) {
54 if (Array.isArray(meta.folderIds) && meta.folderIds.length > 0) {
55 // Strip the directory prefix to get just the filename
56 const filename = relativePath.split('/').pop() || relativePath;
57 imageFolderMap[filename] = meta.folderIds;
58 }
59 }
60
61 response.json({ folders, imageFolderMap });
62 } catch (error) {
63 console.error('[Backgrounds] Folders endpoint error:', error);
64 response.status(500).json({ error: 'Internal server error.' });
65 }
66});
67
40router.post('/delete', getFileNameValidationFunction('bg'), async function (request, response) {68router.post('/delete', getFileNameValidationFunction('bg'), async function (request, response) {
41 try {69 try {
42 if (!request.body) return response.sendStatus(400);70 if (!request.body) return response.sendStatus(400);
@@ -110,7 +138,6 @@ router.post('/upload', async function (request, response) {
110138
111 const img_path = path.join(request.file.destination, request.file.filename);139 const img_path = path.join(request.file.destination, request.file.filename);
112 const filename = sanitize(request.file.originalname);140 const filename = sanitize(request.file.originalname);
113
114 fs.copyFileSync(img_path, path.join(request.user.directories.backgrounds, filename));141 fs.copyFileSync(img_path, path.join(request.user.directories.backgrounds, filename));
115 fs.unlinkSync(img_path);142 fs.unlinkSync(img_path);
116 invalidateThumbnail(request.user.directories, 'bg', filename);143 invalidateThumbnail(request.user.directories, 'bg', filename);
src/endpoints/image-metadata.js+300 -7
@@ -10,19 +10,19 @@ import { imageSize } from 'image-size';
10import writeFileAtomic from 'write-file-atomic';10import writeFileAtomic from 'write-file-atomic';
11import express from 'express';11import express from 'express';
12import { Jimp } from '../jimp.js';12import { Jimp } from '../jimp.js';
13import { getConfigValue, isPathUnderParent } from '../util.js';13import { getConfigValue, isPathUnderParent, uuidv4 } from '../util.js';
1414
15export const METADATA_FILE = 'image-metadata.json';15export const METADATA_FILE = 'image-metadata.json';
1616
17/**17/**
18 * @typedef {Object} ImageMetadata18 * @typedef {Object} ImageMetadata
19 * @property {string} hash - SHA-256 hash of the image file.19 * @property {string} [hash] - SHA-256 hash of the image file.
20 * @property {number} aspectRatio - Aspect ratio (width / height) of the image.20 * @property {number} [aspectRatio] - Aspect ratio (width / height) of the image.
21 * @property {boolean} isAnimated - Whether the image is animated.21 * @property {boolean} [isAnimated] - Whether the image is animated.
22 * @property {string} dominantColor - Dominant color in hex format (e.g., '#RRGGBB').22 * @property {string} [dominantColor] - Dominant color in hex format (e.g., '#RRGGBB').
23 * @property {string[]} folderIds - Array of virtual folder IDs the image belongs to.23 * @property {string[]} folderIds - Array of virtual folder IDs the image belongs to.
24 * @property {number} addedTimestamp - Timestamp when the image was added.24 * @property {number} [addedTimestamp] - Timestamp when the image was added.
25 * @property {number} thumbnailResolution - Thumbnail resolution (width * height) for cache invalidation.25 * @property {number} [thumbnailResolution] - Thumbnail resolution (width * height) for cache invalidation.
26 * @property {number} [mtime] - File modification time for cache invalidation (internal use).26 * @property {number} [mtime] - File modification time for cache invalidation (internal use).
27 */27 */
2828
@@ -255,6 +255,17 @@ export async function removeMetadata(userDataRoot, relativePath) {
255 const index = await readMetadataIndex(userDataRoot);255 const index = await readMetadataIndex(userDataRoot);
256 if (index.images[posixPath]) {256 if (index.images[posixPath]) {
257 delete index.images[posixPath];257 delete index.images[posixPath];
258
259 // Clear any folder thumbnailFile references that point to the deleted file
260 const deletedFileName = path.posix.basename(posixPath);
261 if (Array.isArray(index.folders)) {
262 for (const folder of index.folders) {
263 if (folder.thumbnailFile === deletedFileName) {
264 folder.thumbnailFile = '';
265 }
266 }
267 }
268
258 await writeMetadataIndex(userDataRoot, index);269 await writeMetadataIndex(userDataRoot, index);
259 }270 }
260}271}
@@ -278,6 +289,18 @@ export async function renameMetadata(userDataRoot, oldRelativePath, newRelativeP
278289
279 delete index.images[posixOldPath];290 delete index.images[posixOldPath];
280 index.images[posixNewPath] = data;291 index.images[posixNewPath] = data;
292
293 // Update any folder thumbnailFile references that point to the old filename
294 const oldFileName = path.posix.basename(posixOldPath);
295 const newFileName = path.posix.basename(posixNewPath);
296 if (oldFileName !== newFileName && Array.isArray(index.folders)) {
297 for (const folder of index.folders) {
298 if (folder.thumbnailFile === oldFileName) {
299 folder.thumbnailFile = newFileName;
300 }
301 }
302 }
303
281 await writeMetadataIndex(userDataRoot, index);304 await writeMetadataIndex(userDataRoot, index);
282305
283 return data;306 return data;
@@ -319,10 +342,280 @@ export async function cleanupOrphanedMetadata(userDataRoot) {
319 return orphanedPaths;342 return orphanedPaths;
320}343}
321344
345/**
346 * Creates a new virtual folder.
347 * @param {string} userDataRoot
348 * @param {string} name
349 * @returns {Promise<{id: string, name: string, thumbnailFile: string}>}
350 */
351export async function createFolder(userDataRoot, name) {
352 const index = await readMetadataIndex(userDataRoot);
353 const id = uuidv4();
354 const folder = { id, name, thumbnailFile: '' };
355 index.folders.push(folder);
356 await writeMetadataIndex(userDataRoot, index);
357 return folder;
358}
359
360/**
361 * Sets thumbnail files for multiple folders in a single atomic read-modify-write.
362 * Folders not found in the index are silently skipped.
363 * @param {string} userDataRoot
364 * @param {{id: string, thumbnailFile: string}[]} updates
365 * @returns {Promise<void>}
366 */
367export async function setFolderThumbnailsBatch(userDataRoot, updates) {
368 const index = await readMetadataIndex(userDataRoot);
369 for (const { id, thumbnailFile } of updates) {
370 const folder = index.folders.find(f => f.id === id);
371 if (folder) {
372 folder.thumbnailFile = thumbnailFile;
373 }
374 }
375 await writeMetadataIndex(userDataRoot, index);
376}
377
378/**
379 * Renames or updates a virtual folder.
380 * @param {string} userDataRoot
381 * @param {string} folderId
382 * @param {{name?: string, thumbnailFile?: string}} updates
383 * @returns {Promise<{id: string, name: string, thumbnailFile: string}>}
384 */
385export async function updateFolder(userDataRoot, folderId, updates) {
386 const index = await readMetadataIndex(userDataRoot);
387 const folder = index.folders.find(f => f.id === folderId);
388 if (!folder) throw new Error(`Folder '${folderId}' not found.`);
389 if (updates.name !== undefined) folder.name = updates.name;
390 if (updates.thumbnailFile !== undefined) folder.thumbnailFile = updates.thumbnailFile;
391 await writeMetadataIndex(userDataRoot, index);
392 return folder;
393}
394
395/**
396 * Deletes a virtual folder and removes its ID from all images.
397 * @param {string} userDataRoot
398 * @param {string} folderId
399 * @returns {Promise<void>}
400 */
401export async function deleteFolder(userDataRoot, folderId) {
402 const index = await readMetadataIndex(userDataRoot);
403 const idx = index.folders.findIndex(f => f.id === folderId);
404 if (idx === -1) throw new Error(`Folder '${folderId}' not found.`);
405 index.folders.splice(idx, 1);
406 // Remove folderId from all images
407 for (const meta of Object.values(index.images)) {
408 if (Array.isArray(meta.folderIds)) {
409 const fi = meta.folderIds.indexOf(folderId);
410 if (fi !== -1) meta.folderIds.splice(fi, 1);
411 }
412 }
413 await writeMetadataIndex(userDataRoot, index);
414}
415
416/**
417 * Assigns images to a folder.
418 * @param {string} userDataRoot
419 * @param {string} folderId
420 * @param {string[]} relativePaths
421 * @returns {Promise<void>}
422 */
423export async function assignImagesToFolder(userDataRoot, folderId, relativePaths) {
424 const index = await readMetadataIndex(userDataRoot);
425 if (!index.folders.some(f => f.id === folderId)) {
426 throw new Error(`Folder '${folderId}' not found.`);
427 }
428 for (const rp of relativePaths) {
429 const posixPath = rp.replaceAll(path.sep, path.posix.sep);
430
431 // Validate: must be a backgrounds/ path, and no path-traversal segments
432 const normalized = path.posix.normalize(posixPath);
433 if (!normalized.startsWith('backgrounds/') || normalized.split('/').some(seg => seg === '..')) {
434 throw new Error(`Invalid background path: '${posixPath}'`);
435 }
436
437 // Validate: skip silently on missing files
438 const absPath = path.join(userDataRoot, normalized);
439 try {
440 await fs.access(absPath);
441 } catch {
442 console.warn(`[ImageMetadata] Skipping missing background file: '${posixPath}'`);
443 continue;
444 }
445
446 let meta = index.images[normalized];
447 if (!meta) {
448 // Create a stub entry so folderIds can be stored even before full metadata generation
449 meta = { folderIds: [] };
450 index.images[normalized] = meta;
451 }
452 if (!Array.isArray(meta.folderIds)) meta.folderIds = [];
453 if (!meta.folderIds.includes(folderId)) {
454 meta.folderIds.push(folderId);
455 }
456 }
457 await writeMetadataIndex(userDataRoot, index);
458}
459
460/**
461 * Unassigns images from a folder.
462 * @param {string} userDataRoot
463 * @param {string} folderId
464 * @param {string[]} relativePaths
465 * @returns {Promise<void>}
466 */
467export async function unassignImagesFromFolder(userDataRoot, folderId, relativePaths) {
468 const index = await readMetadataIndex(userDataRoot);
469 for (const rp of relativePaths) {
470 const posixPath = rp.replaceAll(path.sep, path.posix.sep);
471 const meta = index.images[posixPath];
472 if (!meta || !Array.isArray(meta.folderIds)) continue;
473 const fi = meta.folderIds.indexOf(folderId);
474 if (fi !== -1) meta.folderIds.splice(fi, 1);
475 }
476 await writeMetadataIndex(userDataRoot, index);
477}
322478
323export const router = express.Router();479export const router = express.Router();
324480
325/**481/**
482 * POST /api/image-metadata/folders/get
483 * List all virtual folders.
484 */
485router.post('/folders/get', async function (request, response) {
486 try {
487 const index = await readMetadataIndex(request.user.directories.root);
488 return response.json(index.folders || []);
489 } catch (error) {
490 console.error('[ImageMetadata] Folders list error:', error);
491 return response.status(500).json({ error: 'Internal server error.' });
492 }
493});
494
495/**
496 * POST /api/image-metadata/folders/create
497 * Create a new folder. Body: { name: string }
498 */
499router.post('/folders/create', async function (request, response) {
500 try {
501 const { name } = request.body;
502 if (!name || typeof name !== 'string') {
503 return response.status(400).json({ error: '"name" is required.' });
504 }
505 const folder = await createFolder(request.user.directories.root, name.trim());
506 return response.json(folder);
507 } catch (error) {
508 console.error('[ImageMetadata] Folder create error:', error);
509 return response.status(500).json({ error: 'Internal server error.' });
510 }
511});
512
513/**
514 * POST /api/image-metadata/folders/set-thumbnails
515 * Batch-set thumbnail files for multiple folders in one write. Body: { updates: [{id, thumbnailFile}] }
516 */
517router.post('/folders/set-thumbnails', async function (request, response) {
518 try {
519 const { updates } = request.body;
520 if (!Array.isArray(updates) || updates.some(u => !u.id || typeof u.thumbnailFile !== 'string')) {
521 return response.status(400).json({ error: '"updates" must be an array of {id, thumbnailFile}.' });
522 }
523 await setFolderThumbnailsBatch(request.user.directories.root, updates);
524 return response.json({ ok: true });
525 } catch (error) {
526 console.error('[ImageMetadata] Folder set-thumbnails error:', error);
527 return response.status(500).json({ error: 'Internal server error.' });
528 }
529});
530
531/**
532 * POST /api/image-metadata/folders/update
533 * Update a folder. Body: { id: string, name?: string, thumbnailFile?: string }
534 */
535router.post('/folders/update', async function (request, response) {
536 try {
537 const { id, ...updates } = request.body;
538 if (!id || typeof id !== 'string') {
539 return response.status(400).json({ error: '"id" is required.' });
540 }
541 const folder = await updateFolder(request.user.directories.root, id, updates);
542 return response.json(folder);
543 } catch (error) {
544 if (error.message.includes('not found')) {
545 return response.status(404).json({ error: error.message });
546 }
547 console.error('[ImageMetadata] Folder update error:', error);
548 return response.status(500).json({ error: 'Internal server error.' });
549 }
550});
551
552/**
553 * POST /api/image-metadata/folders/delete
554 * Delete a folder and unassign all images. Body: { id: string }
555 */
556router.post('/folders/delete', async function (request, response) {
557 try {
558 const { id } = request.body;
559 if (!id || typeof id !== 'string') {
560 return response.status(400).json({ error: '"id" is required.' });
561 }
562 await deleteFolder(request.user.directories.root, id);
563 return response.json({ ok: true });
564 } catch (error) {
565 if (error.message.includes('not found')) {
566 return response.status(404).json({ error: error.message });
567 }
568 console.error('[ImageMetadata] Folder delete error:', error);
569 return response.status(500).json({ error: 'Internal server error.' });
570 }
571});
572
573/**
574 * POST /api/image-metadata/folders/assign
575 * Assign images to a folder. Body: { id: string, paths: string[] }
576 */
577router.post('/folders/assign', async function (request, response) {
578 try {
579 const { id, paths } = request.body;
580 if (!id || typeof id !== 'string') {
581 return response.status(400).json({ error: '"id" is required.' });
582 }
583 if (!Array.isArray(paths)) {
584 return response.status(400).json({ error: '"paths" array is required.' });
585 }
586 await assignImagesToFolder(request.user.directories.root, id, paths);
587 return response.json({ ok: true });
588 } catch (error) {
589 if (error.message.includes('not found')) {
590 return response.status(404).json({ error: error.message });
591 }
592 console.error('[ImageMetadata] Folder assign error:', error);
593 return response.status(500).json({ error: 'Internal server error.' });
594 }
595});
596
597/**
598 * POST /api/image-metadata/folders/unassign
599 * Unassign images from a folder. Body: { id: string, paths: string[] }
600 */
601router.post('/folders/unassign', async function (request, response) {
602 try {
603 const { id, paths } = request.body;
604 if (!id || typeof id !== 'string') {
605 return response.status(400).json({ error: '"id" is required.' });
606 }
607 if (!Array.isArray(paths)) {
608 return response.status(400).json({ error: '"paths" array is required.' });
609 }
610 await unassignImagesFromFolder(request.user.directories.root, id, paths);
611 return response.json({ ok: true });
612 } catch (error) {
613 console.error('[ImageMetadata] Folder unassign error:', error);
614 return response.status(500).json({ error: 'Internal server error.' });
615 }
616});
617
618/**
326 * POST /api/image-metadata619 * POST /api/image-metadata
327 * Get metadata for image(s) by path.620 * Get metadata for image(s) by path.
328 */621 */