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>
This commit is contained in:
+168
-1
@@ -107,7 +107,8 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.bg_example .mobile-only-menu-toggle {
|
||||
.bg_example .mobile-only-menu-toggle,
|
||||
.bg_folder_tile .mobile-only-menu-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -376,3 +377,169 @@
|
||||
#bg_custom_content:not(:empty)~#bg_chat_hint {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bg_folder_grid.bg_list {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
grid-template-columns: repeat(calc(var(--bg-thumb-columns, 5) + 2), 1fr);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.bg_folder_grid:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bg_folder_tile {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
height: auto;
|
||||
aspect-ratio: 1 / 1;
|
||||
outline: 2px solid var(--SmartThemeBorderColor);
|
||||
outline-offset: -1px;
|
||||
box-shadow: 0 0 7px var(--black50a);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--SmartThemeBlurTintColor);
|
||||
transition: filter var(--animation-duration) ease;
|
||||
}
|
||||
|
||||
.bg_folder_tile:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.bg_folder_tile_cover {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.bg_folder_tile_overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.85));
|
||||
color: var(--SmartThemeBodyColor);
|
||||
padding: 6px 8px 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
.bg_folder_tile_overlay i {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.bg_folder_tile_name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Folder tile action menu */
|
||||
.bg_folder_tile .bg_folder_tile_menu {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 5px;
|
||||
padding: 3px;
|
||||
z-index: 3;
|
||||
backdrop-filter: blur(4px);
|
||||
border: 1px solid var(--SmartThemeBorderColor);
|
||||
align-items: center;
|
||||
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: scale(0.9);
|
||||
transform-origin: center;
|
||||
transition: opacity var(--animation-duration) ease-out, visibility var(--animation-duration) ease-out, transform var(--animation-duration) ease-out;
|
||||
}
|
||||
|
||||
.bg_folder_tile:hover .bg_folder_tile_menu,
|
||||
.bg_folder_tile:focus-within .bg_folder_tile_menu {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.bg_folder_tile .jg-button {
|
||||
display: flex;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
padding: 5px;
|
||||
font-size: 1.1em;
|
||||
border-radius: 5px;
|
||||
transition: background-color var(--animation-duration) ease;
|
||||
}
|
||||
|
||||
.bg_folder_tile .jg-button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* New Folder placeholder tile */
|
||||
.bg_new_folder_tile {
|
||||
border: 2px dashed var(--SmartThemeBorderColor);
|
||||
outline: none;
|
||||
color: var(--SmartThemeBodyColor);
|
||||
opacity: 0.6;
|
||||
gap: 6px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bg_new_folder_tile:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
/* Breadcrumb bar */
|
||||
.bg_folder_breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 0;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.bg_current_folder_name {
|
||||
font-weight: 600;
|
||||
font-size: 1em;
|
||||
color: var(--SmartThemeBodyColor);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#Backgrounds:not(.in-folder-view) .jg-set-cover {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#Backgrounds.in-folder-view .jg-set-cover {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
/* Hide folder actions on custom (chat-specific) backgrounds */
|
||||
.bg_example[custom="true"] .jg-folder,
|
||||
.bg_example[custom="true"] .jg-set-cover {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,56 @@
|
||||
grid-template-columns: repeat(var(--bg-thumb-columns, 3), 1fr);
|
||||
}
|
||||
|
||||
.bg_folder_grid {
|
||||
grid-template-columns: repeat(var(--bg-thumb-columns, 3), 1fr);
|
||||
}
|
||||
|
||||
.bg_folder_tile .bg_folder_tile_menu {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.bg_folder_tile:hover .bg_folder_tile_menu,
|
||||
.bg_folder_tile:focus-within .bg_folder_tile_menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bg_folder_tile.mobile-menu-open .bg_folder_tile_menu {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: scale(1);
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.bg_folder_tile.mobile-menu-open .mobile-only-menu-toggle {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bg_folder_tile .mobile-only-menu-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
z-index: 3;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.bg_folder_tile .jg-button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.bg_list {
|
||||
width: unset;
|
||||
}
|
||||
@@ -102,7 +152,8 @@
|
||||
}
|
||||
|
||||
#add_background_button_top>span,
|
||||
#auto_background>span {
|
||||
#auto_background>span,
|
||||
#bg_add_folder_button>span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -5597,6 +5597,10 @@
|
||||
<i class="fa-solid fa-wand-magic"></i>
|
||||
<span data-i18n="Auto-select">Auto-select</span>
|
||||
</div>
|
||||
<button id="bg_add_folder_button" class="menu_button menu_button_icon" data-i18n="[title]New Folder" title="New Folder">
|
||||
<i class="fa-solid fa-folder-plus"></i>
|
||||
<span data-i18n="New Folder">New Folder</span>
|
||||
</button>
|
||||
<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">
|
||||
<i class="fa-solid fa-upload"></i>
|
||||
<span data-i18n="Add Background">Add Background</span>
|
||||
@@ -5630,6 +5634,14 @@
|
||||
</div>
|
||||
</ul>
|
||||
<div id="bg_global_tab" class="bg_tab_panel">
|
||||
<div id="bg_folder_breadcrumb" class="bg_folder_breadcrumb" style="display:none;">
|
||||
<button id="bg_back_to_folders" class="menu_button menu_button_icon">
|
||||
<i class="fa-solid fa-arrow-left"></i>
|
||||
<span data-i18n="Back">Back</span>
|
||||
</button>
|
||||
<span id="bg_current_folder_name" class="bg_current_folder_name"></span>
|
||||
</div>
|
||||
<div id="bg_folder_grid" class="bg_list bg_folder_grid"></div>
|
||||
<div id="bg_menu_content" class="bg_list"></div>
|
||||
</div>
|
||||
<div id="bg_chat_tab" class="bg_tab_panel">
|
||||
@@ -6598,10 +6610,34 @@
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div class="BGSampleTitle"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="bg_folder_tile_template" class="template_element">
|
||||
<div class="bg_folder_tile" data-folder-id="">
|
||||
<div class="bg_folder_tile_cover"></div>
|
||||
<div class="bg_folder_tile_overlay">
|
||||
<i class="fa-solid fa-folder"></i>
|
||||
<span class="bg_folder_tile_name"></span>
|
||||
</div>
|
||||
<div class="mobile-only-menu-toggle">
|
||||
<i class="fa-solid fa-ellipsis-vertical"></i>
|
||||
</div>
|
||||
<div class="bg_folder_tile_menu jg-menu">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="bg_new_folder_template" class="template_element">
|
||||
<div class="bg_folder_tile bg_new_folder_tile">
|
||||
<i class="fa-solid fa-folder-plus fa-2x"></i>
|
||||
<span data-i18n="New Folder">New Folder</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- templates for JS to reuse when needed -->
|
||||
<div id="character_world_template" class="template_element">
|
||||
<div class="character_world range-block flexFlowColumn flex-container">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
|
||||
import { createThumbnail, flashHighlight, getBase64Async, stringFormat, debounce, setupScrollToTop, saveBase64AsFile, getFileExtension, sortIgnoreCaseAndAccents } from './utils.js';
|
||||
import { debounce_timeout } from './constants.js';
|
||||
import { t } from './i18n.js';
|
||||
import { Popup } from './popup.js';
|
||||
import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js';
|
||||
import { groups, selected_group } from './group-chats.js';
|
||||
import { humanizedDateTime } from './RossAscends-mods.js';
|
||||
import { deleteMediaFromServer } from './chats.js';
|
||||
@@ -14,6 +14,13 @@ import { deleteMediaFromServer } from './chats.js';
|
||||
const BG_METADATA_KEY = 'custom_background';
|
||||
const LIST_METADATA_KEY = 'chat_backgrounds';
|
||||
|
||||
/** @type {Array<{id: string, name: string, thumbnailFile: string}>} */
|
||||
let folderList = [];
|
||||
/** @type {Object.<string, string[]>} filename → folderIds */
|
||||
let imageFolderMap = {};
|
||||
/** @type {string|null} Currently active folder drill-in, or null for root */
|
||||
let activeFolderId = null;
|
||||
|
||||
// A single transparent PNG pixel used as a placeholder for errored backgrounds
|
||||
const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
||||
const PNG_PIXEL_BLOB = new Blob([Uint8Array.from(atob(PNG_PIXEL), c => c.charCodeAt(0))], { type: 'image/png' });
|
||||
@@ -170,7 +177,7 @@ function createThumbnailElement(imageData) {
|
||||
|
||||
const url = generateUrlParameter(bg, isCustom);
|
||||
const title = isCustom ? bg.split('/').pop() : bg;
|
||||
const friendlyTitle = title.slice(0, title.lastIndexOf('.'));
|
||||
const friendlyTitle = String(title || '').slice(0, title.lastIndexOf('.'));
|
||||
|
||||
thumbnail.attr('title', title);
|
||||
thumbnail.attr('bgfile', bg);
|
||||
@@ -278,6 +285,17 @@ export function getBackgroundPath(fileUrl) {
|
||||
return `backgrounds/${encodeURIComponent(fileUrl)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the raw server-side relative path for a background image (no URL encoding).
|
||||
* Used when communicating paths to the API (stored as plain strings in metadata).
|
||||
* @param {string} file File name of the background image
|
||||
* @returns {string} Raw relative path, e.g. "backgrounds/my file.jpg"
|
||||
*/
|
||||
function getBackgroundRelativePath(file) {
|
||||
return `backgrounds/${file}`;
|
||||
}
|
||||
|
||||
|
||||
function highlightLockedBackground() {
|
||||
$('.bg_example.locked-background').removeClass('locked-background');
|
||||
|
||||
@@ -553,6 +571,24 @@ async function onDeleteBackgroundClick(e) {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from local image list so it doesn't reappear on re-render
|
||||
const deletedBg = bgToDelete.attr('bgfile');
|
||||
if (deletedBg) {
|
||||
const cachedIdx = cachedSystemBackgrounds.findIndex(img => img.filename === deletedBg);
|
||||
if (cachedIdx !== -1) cachedSystemBackgrounds.splice(cachedIdx, 1);
|
||||
|
||||
// Update folder map and clear folder thumbnail if it referenced this image
|
||||
if (imageFolderMap[deletedBg]) {
|
||||
delete imageFolderMap[deletedBg];
|
||||
}
|
||||
for (const folder of folderList) {
|
||||
if (folder.thumbnailFile === deletedBg) {
|
||||
folder.thumbnailFile = '';
|
||||
}
|
||||
}
|
||||
renderFolderGrid();
|
||||
}
|
||||
|
||||
bgToDelete.remove();
|
||||
|
||||
if (url === chat_metadata[BG_METADATA_KEY]) {
|
||||
@@ -664,9 +700,14 @@ export async function getBackgrounds() {
|
||||
const { images, config } = await response.json();
|
||||
Object.assign(THUMBNAIL_CONFIG, config);
|
||||
cachedSystemBackgrounds = images;
|
||||
|
||||
// Load folders first so getFilteredImages() works correctly in folder view
|
||||
await loadFolders();
|
||||
|
||||
await preloadImageMetadata();
|
||||
|
||||
renderSystemBackgrounds(images);
|
||||
// Render only filtered images if inside a folder, otherwise all
|
||||
renderSystemBackgrounds(getFilteredImages());
|
||||
highlightSelectedBackground();
|
||||
}
|
||||
}
|
||||
@@ -696,6 +737,389 @@ async function preloadImageMetadata() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads folder data from the server (separate from image loading).
|
||||
*/
|
||||
async function loadFolders() {
|
||||
try {
|
||||
const response = await fetch('/api/backgrounds/folders', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
folderList = data.folders || [];
|
||||
imageFolderMap = data.imageFolderMap || {};
|
||||
|
||||
// Auto-assign thumbnail for folders that don't have one, then persist
|
||||
const allImages = cachedSystemBackgrounds.map(img => img.filename);
|
||||
/** @type {{id: string, thumbnailFile: string}[]} */
|
||||
const thumbnailUpdates = [];
|
||||
for (const folder of folderList) {
|
||||
if (!folder.thumbnailFile) {
|
||||
const firstImage = allImages.find(img => {
|
||||
const fids = imageFolderMap[img];
|
||||
return fids && fids.includes(folder.id);
|
||||
});
|
||||
if (firstImage) {
|
||||
folder.thumbnailFile = firstImage;
|
||||
thumbnailUpdates.push({ id: folder.id, thumbnailFile: firstImage });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (thumbnailUpdates.length > 0) {
|
||||
await fetch('/api/image-metadata/folders/set-thumbnails', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ updates: thumbnailUpdates }),
|
||||
}).catch(err => console.debug('Auto-thumbnail save failed:', err));
|
||||
}
|
||||
|
||||
renderFolderGrid();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading folders:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the folder grid inside #bg_folder_grid.
|
||||
*/
|
||||
function renderFolderGrid() {
|
||||
const container = $('#bg_folder_grid');
|
||||
container.empty();
|
||||
|
||||
if (folderList.length === 0 && !activeFolderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const folder of folderList) {
|
||||
const tile = createFolderTileElement(folder);
|
||||
container.append(tile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a single folder tile DOM element.
|
||||
* @param {{id: string, name: string, thumbnailFile: string}} folder
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
function createFolderTileElement(folder) {
|
||||
const tile = $('#bg_folder_tile_template .bg_folder_tile').clone();
|
||||
tile.attr('data-folder-id', folder.id);
|
||||
tile.find('.bg_folder_tile_name').text(folder.name);
|
||||
|
||||
// Set cover image (async, update when resolved)
|
||||
getFolderCoverUrl(folder).then(coverUrl => {
|
||||
if (coverUrl) {
|
||||
tile.find('.bg_folder_tile_cover').css('background-image', `url("${coverUrl}")`);
|
||||
}
|
||||
});
|
||||
|
||||
return tile.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the cover image URL for a folder.
|
||||
* Uses thumbnailFile if set, otherwise falls back to the first image in the folder.
|
||||
* @param {{id: string, name: string, thumbnailFile: string}} folder
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async function getFolderCoverUrl(folder) {
|
||||
const file = folder.thumbnailFile || cachedSystemBackgrounds.find(img => {
|
||||
const fids = imageFolderMap[img.filename];
|
||||
return fids && fids.includes(folder.id);
|
||||
})?.filename;
|
||||
if (!file) return null;
|
||||
|
||||
if (isAnimatedBackgroundExtension(file) && !background_settings.animation) {
|
||||
return getThumbnailFromStorage(file, false);
|
||||
}
|
||||
return getThumbnailUrl('bg', file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets images filtered by the active folder.
|
||||
* @returns {Array<{filename: string, isAnimated: boolean}>}
|
||||
*/
|
||||
function getFilteredImages() {
|
||||
if (!activeFolderId) return cachedSystemBackgrounds;
|
||||
return cachedSystemBackgrounds.filter(img => {
|
||||
const fids = imageFolderMap[img.filename];
|
||||
return fids && fids.includes(activeFolderId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drills into a folder — hides folder grid, shows breadcrumb, filters images.
|
||||
* @param {string} folderId
|
||||
*/
|
||||
function onFolderDrillIn(folderId) {
|
||||
const folder = folderList.find(f => f.id === folderId);
|
||||
if (!folder) return;
|
||||
|
||||
activeFolderId = folderId;
|
||||
$('#Backgrounds').addClass('in-folder-view');
|
||||
|
||||
// Hide folder grid, show breadcrumb
|
||||
$('#bg_folder_grid').hide();
|
||||
$('#bg_folder_breadcrumb').show();
|
||||
$('#bg_current_folder_name').text(folder.name);
|
||||
|
||||
// Render only this folder's images
|
||||
renderSystemBackgrounds(getFilteredImages());
|
||||
highlightSelectedBackground();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns to the root folder overview.
|
||||
*/
|
||||
function onBackToFolders() {
|
||||
activeFolderId = null;
|
||||
$('#Backgrounds').removeClass('in-folder-view');
|
||||
|
||||
// Show folder grid, hide breadcrumb
|
||||
$('#bg_folder_grid').show();
|
||||
$('#bg_folder_breadcrumb').hide();
|
||||
$('#bg_current_folder_name').text('');
|
||||
|
||||
// Show all images
|
||||
renderSystemBackgrounds(getFilteredImages());
|
||||
highlightSelectedBackground();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new folder via API.
|
||||
*/
|
||||
async function onCreateFolder() {
|
||||
const currentTab = getActiveBackgroundTab();
|
||||
if (currentTab !== BG_SOURCES.GLOBAL) {
|
||||
toastr.warning(t`Folders can only be created in the Global tab`);
|
||||
return;
|
||||
}
|
||||
|
||||
const name = await Popup.show.input(t`Enter folder name:`);
|
||||
if (!name || !name.trim()) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/image-metadata/folders/create', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ name: name.trim() }),
|
||||
});
|
||||
if (response.ok) {
|
||||
const folder = await response.json();
|
||||
folderList.push(folder);
|
||||
renderFolderGrid();
|
||||
toastr.success(t`Folder created: ${folder.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating folder:', error);
|
||||
toastr.error(t`Failed to create folder`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a folder via API.
|
||||
* @param {string} folderId
|
||||
*/
|
||||
async function onRenameFolder(folderId) {
|
||||
const folder = folderList.find(f => f.id === folderId);
|
||||
if (!folder) return;
|
||||
|
||||
const newName = await Popup.show.input(t`Enter new folder name:`, null, folder.name);
|
||||
if (!newName || !newName.trim() || newName.trim() === folder.name) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/image-metadata/folders/update', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ id: folderId, name: newName.trim() }),
|
||||
});
|
||||
if (response.ok) {
|
||||
folder.name = newName.trim();
|
||||
renderFolderGrid();
|
||||
toastr.success(t`Folder renamed`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error renaming folder:', error);
|
||||
toastr.error(t`Failed to rename folder`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a folder via API.
|
||||
* @param {string} folderId
|
||||
*/
|
||||
async function onDeleteFolder(folderId) {
|
||||
const folder = folderList.find(f => f.id === folderId);
|
||||
if (!folder) return;
|
||||
|
||||
const confirm = await Popup.show.confirm(t`Delete folder "${folder.name}"?`, t`Images will not be deleted, only the folder grouping.`);
|
||||
if (!confirm) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/image-metadata/folders/delete', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ id: folderId }),
|
||||
});
|
||||
if (response.ok) {
|
||||
folderList = folderList.filter(f => f.id !== folderId);
|
||||
// Clean imageFolderMap
|
||||
for (const fids of Object.values(imageFolderMap)) {
|
||||
const idx = fids.indexOf(folderId);
|
||||
if (idx !== -1) fids.splice(idx, 1);
|
||||
}
|
||||
// If we were inside this folder, go back
|
||||
if (activeFolderId === folderId) {
|
||||
onBackToFolders();
|
||||
}
|
||||
renderFolderGrid();
|
||||
toastr.success(t`Folder deleted`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting folder:', error);
|
||||
toastr.error(t`Failed to delete folder`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a folder assignment popup for an image.
|
||||
* @param {string} bgFile - The background filename
|
||||
*/
|
||||
async function onAssignToFolder(bgFile) {
|
||||
if (folderList.length === 0) {
|
||||
toastr.info(t`Create a folder first`);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentFolderIds = imageFolderMap[bgFile] || [];
|
||||
|
||||
// Build checkbox inputs for Popup using DOM construction (avoids HTML injection)
|
||||
const contentEl = document.createElement('div');
|
||||
const heading = document.createElement('h3');
|
||||
heading.textContent = t`Assign to folders`;
|
||||
contentEl.appendChild(heading);
|
||||
|
||||
for (const f of folderList) {
|
||||
const label = document.createElement('label');
|
||||
label.className = 'checkbox_label flexGap5';
|
||||
label.style.margin = '4px 0';
|
||||
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.dataset.folderId = f.id;
|
||||
checkbox.checked = currentFolderIds.includes(f.id);
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.textContent = f.name;
|
||||
|
||||
label.appendChild(checkbox);
|
||||
label.appendChild(span);
|
||||
contentEl.appendChild(label);
|
||||
}
|
||||
|
||||
const content = $(contentEl);
|
||||
|
||||
const result = await callGenericPopup(content, POPUP_TYPE.CONFIRM, '', { okButton: t`Save`, cancelButton: t`Cancel` });
|
||||
if (!result) return;
|
||||
|
||||
// Determine which folders were toggled on/off
|
||||
const toAssign = [];
|
||||
const toUnassign = [];
|
||||
content.find('input[type="checkbox"]').each(function () {
|
||||
const fid = $(this).data('folder-id');
|
||||
const isChecked = $(this).prop('checked');
|
||||
const wasChecked = currentFolderIds.includes(fid);
|
||||
if (isChecked && !wasChecked) toAssign.push(fid);
|
||||
if (!isChecked && wasChecked) toUnassign.push(fid);
|
||||
});
|
||||
|
||||
const relativePath = getBackgroundRelativePath(bgFile);
|
||||
|
||||
try {
|
||||
for (const fid of toAssign) {
|
||||
const resp = await fetch('/api/image-metadata/folders/assign', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ id: fid, paths: [relativePath] }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`Assign to folder ${fid} failed: ${resp.status}`);
|
||||
}
|
||||
for (const fid of toUnassign) {
|
||||
const resp = await fetch('/api/image-metadata/folders/unassign', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ id: fid, paths: [relativePath] }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`Unassign from folder ${fid} failed: ${resp.status}`);
|
||||
}
|
||||
|
||||
// Update local state
|
||||
const newFolderIds = folderList
|
||||
.filter(f => {
|
||||
const wasIn = currentFolderIds.includes(f.id);
|
||||
if (toAssign.includes(f.id)) return true;
|
||||
if (toUnassign.includes(f.id)) return false;
|
||||
return wasIn;
|
||||
})
|
||||
.map(f => f.id);
|
||||
|
||||
if (newFolderIds.length > 0) {
|
||||
imageFolderMap[bgFile] = newFolderIds;
|
||||
} else {
|
||||
delete imageFolderMap[bgFile];
|
||||
}
|
||||
|
||||
renderFolderGrid();
|
||||
|
||||
// Re-render filtered image list if currently inside a folder view
|
||||
if (activeFolderId) {
|
||||
renderSystemBackgrounds(getFilteredImages());
|
||||
highlightSelectedBackground();
|
||||
}
|
||||
|
||||
toastr.success(t`Folder assignment updated`);
|
||||
} catch (error) {
|
||||
console.error('Error assigning to folder:', error);
|
||||
toastr.error(t`Failed to update folder assignment`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an image as the folder cover.
|
||||
* @param {string} bgFile - The background filename
|
||||
*/
|
||||
async function onSetFolderCover(bgFile) {
|
||||
if (!activeFolderId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/image-metadata/folders/update', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ id: activeFolderId, thumbnailFile: bgFile }),
|
||||
});
|
||||
if (response.ok) {
|
||||
const folder = folderList.find(f => f.id === activeFolderId);
|
||||
if (folder) {
|
||||
folder.thumbnailFile = bgFile;
|
||||
// Update the DOM tile cover image
|
||||
const coverUrl = await getFolderCoverUrl(folder);
|
||||
if (coverUrl) {
|
||||
$(`.bg_folder_tile[data-folder-id="${folder.id}"] .bg_folder_tile_cover`)
|
||||
.css('background-image', `url('${coverUrl}')`);
|
||||
}
|
||||
}
|
||||
toastr.success(t`Folder cover updated`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting folder cover:', error);
|
||||
toastr.error(t`Failed to set folder cover`);
|
||||
}
|
||||
}
|
||||
|
||||
function activateLazyLoader() {
|
||||
// Disconnect previous observer to prevent memory leaks
|
||||
if (lazyLoadObserver) {
|
||||
@@ -1001,6 +1425,21 @@ function onBackgroundFilterInput() {
|
||||
const hasMatch = title.toLowerCase().includes(filterValue);
|
||||
$bg.toggle(hasMatch);
|
||||
});
|
||||
|
||||
// Show/hide folder tiles based on whether folder name matches the filter
|
||||
if (!activeFolderId) {
|
||||
$('#bg_folder_grid .bg_folder_tile').each(function () {
|
||||
const $tile = $(this);
|
||||
const folderId = $tile.attr('data-folder-id');
|
||||
if (!folderId || !filterValue) {
|
||||
$tile.show();
|
||||
return;
|
||||
}
|
||||
const folder = folderList.find(f => f.id === folderId);
|
||||
const folderName = folder ? folder.name.toLowerCase() : '';
|
||||
$tile.toggle(folderName.includes(filterValue));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedOnBackgroundFilterInput = debounce(onBackgroundFilterInput, debounce_timeout.standard);
|
||||
@@ -1017,6 +1456,41 @@ export function initBackgrounds() {
|
||||
eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
|
||||
eventSource.on(event_types.FORCE_SET_BACKGROUND, forceSetBackground);
|
||||
|
||||
// Folder event handlers
|
||||
$(document)
|
||||
.on('click', '.bg_folder_tile:not(.bg_new_folder_tile)', function (e) {
|
||||
if ($(e.target).closest('.jg-button').length) return; // let button handler run
|
||||
const folderId = $(this).attr('data-folder-id');
|
||||
if (folderId) onFolderDrillIn(folderId);
|
||||
})
|
||||
.on('click', '#bg_add_folder_button', function () {
|
||||
onCreateFolder();
|
||||
})
|
||||
.on('click', '#bg_back_to_folders', function () {
|
||||
onBackToFolders();
|
||||
})
|
||||
.on('click', '.bg_folder_tile [data-action="rename-folder"]', function (e) {
|
||||
e.stopPropagation();
|
||||
const folderId = $(this).closest('.bg_folder_tile').attr('data-folder-id');
|
||||
if (folderId) onRenameFolder(folderId);
|
||||
})
|
||||
.on('click', '.bg_folder_tile [data-action="delete-folder"]', function (e) {
|
||||
e.stopPropagation();
|
||||
const folderId = $(this).closest('.bg_folder_tile').attr('data-folder-id');
|
||||
if (folderId) onDeleteFolder(folderId);
|
||||
})
|
||||
.on('click', '.bg_folder_tile .mobile-only-menu-toggle', function (e) {
|
||||
e.stopPropagation();
|
||||
const $context = $(this).closest('.bg_folder_tile');
|
||||
const wasOpen = $context.hasClass('mobile-menu-open');
|
||||
// Close all other open menus before opening a new one.
|
||||
$('.bg_folder_tile.mobile-menu-open').removeClass('mobile-menu-open');
|
||||
$('.bg_example.mobile-menu-open').removeClass('mobile-menu-open');
|
||||
if (!wasOpen) {
|
||||
$context.addClass('mobile-menu-open');
|
||||
}
|
||||
});
|
||||
|
||||
$(document)
|
||||
.off('click', '.bg_example').on('click', '.bg_example', onSelectBackgroundClick)
|
||||
.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() {
|
||||
const wasOpen = $context.hasClass('mobile-menu-open');
|
||||
// Close all other open menus before opening a new one.
|
||||
$('.bg_example.mobile-menu-open').removeClass('mobile-menu-open');
|
||||
$('.bg_folder_tile.mobile-menu-open').removeClass('mobile-menu-open');
|
||||
if (!wasOpen) {
|
||||
$context.addClass('mobile-menu-open');
|
||||
}
|
||||
@@ -1054,6 +1529,20 @@ export function initBackgrounds() {
|
||||
case 'copy':
|
||||
onCopyToSystemBackgroundClick.call(this, e.originalEvent);
|
||||
break;
|
||||
case 'folder': {
|
||||
const bgEl = $(this).closest('.bg_example');
|
||||
if (bgEl.attr('custom') === 'true') break; // Only system backgrounds
|
||||
const bgFile = bgEl.attr('bgfile');
|
||||
if (bgFile) onAssignToFolder(bgFile);
|
||||
break;
|
||||
}
|
||||
case 'set-cover': {
|
||||
const bgEl = $(this).closest('.bg_example');
|
||||
if (bgEl.attr('custom') === 'true') break; // Only system backgrounds
|
||||
const bgFile = bgEl.attr('bgfile');
|
||||
if (bgFile) onSetFolderCover(bgFile);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1069,8 +1558,8 @@ export function initBackgrounds() {
|
||||
$('#bg-sort').on('change', function () {
|
||||
background_settings.sortOrder = String($(this).val());
|
||||
saveSettingsDebounced();
|
||||
// Re-render both galleries with new sort order
|
||||
renderSystemBackgrounds(cachedSystemBackgrounds);
|
||||
// Re-render both galleries with new sort order (respecting active folder filter)
|
||||
renderSystemBackgrounds(getFilteredImages());
|
||||
renderChatBackgrounds();
|
||||
highlightSelectedBackground();
|
||||
highlightLockedBackground();
|
||||
|
||||
@@ -5,7 +5,7 @@ import express from 'express';
|
||||
import sanitize from 'sanitize-filename';
|
||||
|
||||
import { invalidateThumbnail } from './thumbnails.js';
|
||||
import { getOrGenerateMetadataBatch, removeMetadata, renameMetadata, thumbnailDimensions } from './image-metadata.js';
|
||||
import { thumbnailDimensions, readMetadataIndex, renameMetadata, removeMetadata, getOrGenerateMetadataBatch } from './image-metadata.js';
|
||||
import { getImages } from '../util.js';
|
||||
import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
|
||||
|
||||
@@ -37,6 +37,34 @@ router.post('/all', async function (request, response) {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/backgrounds/folders
|
||||
* Returns folders and per-image folderIds from the metadata index.
|
||||
* Loaded separately from /all to avoid blocking image rendering.
|
||||
*/
|
||||
router.post('/folders', async function (request, response) {
|
||||
try {
|
||||
const index = await readMetadataIndex(request.user.directories.root);
|
||||
const folders = index.folders || [];
|
||||
|
||||
// Build a slim map of image → folderIds for the frontend
|
||||
/** @type {Object.<string, string[]>} */
|
||||
const imageFolderMap = {};
|
||||
for (const [relativePath, meta] of Object.entries(index.images)) {
|
||||
if (Array.isArray(meta.folderIds) && meta.folderIds.length > 0) {
|
||||
// Strip the directory prefix to get just the filename
|
||||
const filename = relativePath.split('/').pop() || relativePath;
|
||||
imageFolderMap[filename] = meta.folderIds;
|
||||
}
|
||||
}
|
||||
|
||||
response.json({ folders, imageFolderMap });
|
||||
} catch (error) {
|
||||
console.error('[Backgrounds] Folders endpoint error:', error);
|
||||
response.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/delete', getFileNameValidationFunction('bg'), async function (request, response) {
|
||||
try {
|
||||
if (!request.body) return response.sendStatus(400);
|
||||
@@ -110,7 +138,6 @@ router.post('/upload', async function (request, response) {
|
||||
|
||||
const img_path = path.join(request.file.destination, request.file.filename);
|
||||
const filename = sanitize(request.file.originalname);
|
||||
|
||||
fs.copyFileSync(img_path, path.join(request.user.directories.backgrounds, filename));
|
||||
fs.unlinkSync(img_path);
|
||||
invalidateThumbnail(request.user.directories, 'bg', filename);
|
||||
|
||||
@@ -10,19 +10,19 @@ import { imageSize } from 'image-size';
|
||||
import writeFileAtomic from 'write-file-atomic';
|
||||
import express from 'express';
|
||||
import { Jimp } from '../jimp.js';
|
||||
import { getConfigValue, isPathUnderParent } from '../util.js';
|
||||
import { getConfigValue, isPathUnderParent, uuidv4 } from '../util.js';
|
||||
|
||||
export const METADATA_FILE = 'image-metadata.json';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ImageMetadata
|
||||
* @property {string} hash - SHA-256 hash of the image file.
|
||||
* @property {number} aspectRatio - Aspect ratio (width / height) of the image.
|
||||
* @property {boolean} isAnimated - Whether the image is animated.
|
||||
* @property {string} dominantColor - Dominant color in hex format (e.g., '#RRGGBB').
|
||||
* @property {string} [hash] - SHA-256 hash of the image file.
|
||||
* @property {number} [aspectRatio] - Aspect ratio (width / height) of the image.
|
||||
* @property {boolean} [isAnimated] - Whether the image is animated.
|
||||
* @property {string} [dominantColor] - Dominant color in hex format (e.g., '#RRGGBB').
|
||||
* @property {string[]} folderIds - Array of virtual folder IDs the image belongs to.
|
||||
* @property {number} addedTimestamp - Timestamp when the image was added.
|
||||
* @property {number} thumbnailResolution - Thumbnail resolution (width * height) for cache invalidation.
|
||||
* @property {number} [addedTimestamp] - Timestamp when the image was added.
|
||||
* @property {number} [thumbnailResolution] - Thumbnail resolution (width * height) for cache invalidation.
|
||||
* @property {number} [mtime] - File modification time for cache invalidation (internal use).
|
||||
*/
|
||||
|
||||
@@ -255,6 +255,17 @@ export async function removeMetadata(userDataRoot, relativePath) {
|
||||
const index = await readMetadataIndex(userDataRoot);
|
||||
if (index.images[posixPath]) {
|
||||
delete index.images[posixPath];
|
||||
|
||||
// Clear any folder thumbnailFile references that point to the deleted file
|
||||
const deletedFileName = path.posix.basename(posixPath);
|
||||
if (Array.isArray(index.folders)) {
|
||||
for (const folder of index.folders) {
|
||||
if (folder.thumbnailFile === deletedFileName) {
|
||||
folder.thumbnailFile = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await writeMetadataIndex(userDataRoot, index);
|
||||
}
|
||||
}
|
||||
@@ -278,6 +289,18 @@ export async function renameMetadata(userDataRoot, oldRelativePath, newRelativeP
|
||||
|
||||
delete index.images[posixOldPath];
|
||||
index.images[posixNewPath] = data;
|
||||
|
||||
// Update any folder thumbnailFile references that point to the old filename
|
||||
const oldFileName = path.posix.basename(posixOldPath);
|
||||
const newFileName = path.posix.basename(posixNewPath);
|
||||
if (oldFileName !== newFileName && Array.isArray(index.folders)) {
|
||||
for (const folder of index.folders) {
|
||||
if (folder.thumbnailFile === oldFileName) {
|
||||
folder.thumbnailFile = newFileName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await writeMetadataIndex(userDataRoot, index);
|
||||
|
||||
return data;
|
||||
@@ -319,9 +342,279 @@ export async function cleanupOrphanedMetadata(userDataRoot) {
|
||||
return orphanedPaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new virtual folder.
|
||||
* @param {string} userDataRoot
|
||||
* @param {string} name
|
||||
* @returns {Promise<{id: string, name: string, thumbnailFile: string}>}
|
||||
*/
|
||||
export async function createFolder(userDataRoot, name) {
|
||||
const index = await readMetadataIndex(userDataRoot);
|
||||
const id = uuidv4();
|
||||
const folder = { id, name, thumbnailFile: '' };
|
||||
index.folders.push(folder);
|
||||
await writeMetadataIndex(userDataRoot, index);
|
||||
return folder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets thumbnail files for multiple folders in a single atomic read-modify-write.
|
||||
* Folders not found in the index are silently skipped.
|
||||
* @param {string} userDataRoot
|
||||
* @param {{id: string, thumbnailFile: string}[]} updates
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function setFolderThumbnailsBatch(userDataRoot, updates) {
|
||||
const index = await readMetadataIndex(userDataRoot);
|
||||
for (const { id, thumbnailFile } of updates) {
|
||||
const folder = index.folders.find(f => f.id === id);
|
||||
if (folder) {
|
||||
folder.thumbnailFile = thumbnailFile;
|
||||
}
|
||||
}
|
||||
await writeMetadataIndex(userDataRoot, index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames or updates a virtual folder.
|
||||
* @param {string} userDataRoot
|
||||
* @param {string} folderId
|
||||
* @param {{name?: string, thumbnailFile?: string}} updates
|
||||
* @returns {Promise<{id: string, name: string, thumbnailFile: string}>}
|
||||
*/
|
||||
export async function updateFolder(userDataRoot, folderId, updates) {
|
||||
const index = await readMetadataIndex(userDataRoot);
|
||||
const folder = index.folders.find(f => f.id === folderId);
|
||||
if (!folder) throw new Error(`Folder '${folderId}' not found.`);
|
||||
if (updates.name !== undefined) folder.name = updates.name;
|
||||
if (updates.thumbnailFile !== undefined) folder.thumbnailFile = updates.thumbnailFile;
|
||||
await writeMetadataIndex(userDataRoot, index);
|
||||
return folder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a virtual folder and removes its ID from all images.
|
||||
* @param {string} userDataRoot
|
||||
* @param {string} folderId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function deleteFolder(userDataRoot, folderId) {
|
||||
const index = await readMetadataIndex(userDataRoot);
|
||||
const idx = index.folders.findIndex(f => f.id === folderId);
|
||||
if (idx === -1) throw new Error(`Folder '${folderId}' not found.`);
|
||||
index.folders.splice(idx, 1);
|
||||
// Remove folderId from all images
|
||||
for (const meta of Object.values(index.images)) {
|
||||
if (Array.isArray(meta.folderIds)) {
|
||||
const fi = meta.folderIds.indexOf(folderId);
|
||||
if (fi !== -1) meta.folderIds.splice(fi, 1);
|
||||
}
|
||||
}
|
||||
await writeMetadataIndex(userDataRoot, index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns images to a folder.
|
||||
* @param {string} userDataRoot
|
||||
* @param {string} folderId
|
||||
* @param {string[]} relativePaths
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function assignImagesToFolder(userDataRoot, folderId, relativePaths) {
|
||||
const index = await readMetadataIndex(userDataRoot);
|
||||
if (!index.folders.some(f => f.id === folderId)) {
|
||||
throw new Error(`Folder '${folderId}' not found.`);
|
||||
}
|
||||
for (const rp of relativePaths) {
|
||||
const posixPath = rp.replaceAll(path.sep, path.posix.sep);
|
||||
|
||||
// Validate: must be a backgrounds/ path, and no path-traversal segments
|
||||
const normalized = path.posix.normalize(posixPath);
|
||||
if (!normalized.startsWith('backgrounds/') || normalized.split('/').some(seg => seg === '..')) {
|
||||
throw new Error(`Invalid background path: '${posixPath}'`);
|
||||
}
|
||||
|
||||
// Validate: skip silently on missing files
|
||||
const absPath = path.join(userDataRoot, normalized);
|
||||
try {
|
||||
await fs.access(absPath);
|
||||
} catch {
|
||||
console.warn(`[ImageMetadata] Skipping missing background file: '${posixPath}'`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let meta = index.images[normalized];
|
||||
if (!meta) {
|
||||
// Create a stub entry so folderIds can be stored even before full metadata generation
|
||||
meta = { folderIds: [] };
|
||||
index.images[normalized] = meta;
|
||||
}
|
||||
if (!Array.isArray(meta.folderIds)) meta.folderIds = [];
|
||||
if (!meta.folderIds.includes(folderId)) {
|
||||
meta.folderIds.push(folderId);
|
||||
}
|
||||
}
|
||||
await writeMetadataIndex(userDataRoot, index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unassigns images from a folder.
|
||||
* @param {string} userDataRoot
|
||||
* @param {string} folderId
|
||||
* @param {string[]} relativePaths
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function unassignImagesFromFolder(userDataRoot, folderId, relativePaths) {
|
||||
const index = await readMetadataIndex(userDataRoot);
|
||||
for (const rp of relativePaths) {
|
||||
const posixPath = rp.replaceAll(path.sep, path.posix.sep);
|
||||
const meta = index.images[posixPath];
|
||||
if (!meta || !Array.isArray(meta.folderIds)) continue;
|
||||
const fi = meta.folderIds.indexOf(folderId);
|
||||
if (fi !== -1) meta.folderIds.splice(fi, 1);
|
||||
}
|
||||
await writeMetadataIndex(userDataRoot, index);
|
||||
}
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
/**
|
||||
* POST /api/image-metadata/folders/get
|
||||
* List all virtual folders.
|
||||
*/
|
||||
router.post('/folders/get', async function (request, response) {
|
||||
try {
|
||||
const index = await readMetadataIndex(request.user.directories.root);
|
||||
return response.json(index.folders || []);
|
||||
} catch (error) {
|
||||
console.error('[ImageMetadata] Folders list error:', error);
|
||||
return response.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/image-metadata/folders/create
|
||||
* Create a new folder. Body: { name: string }
|
||||
*/
|
||||
router.post('/folders/create', async function (request, response) {
|
||||
try {
|
||||
const { name } = request.body;
|
||||
if (!name || typeof name !== 'string') {
|
||||
return response.status(400).json({ error: '"name" is required.' });
|
||||
}
|
||||
const folder = await createFolder(request.user.directories.root, name.trim());
|
||||
return response.json(folder);
|
||||
} catch (error) {
|
||||
console.error('[ImageMetadata] Folder create error:', error);
|
||||
return response.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/image-metadata/folders/set-thumbnails
|
||||
* Batch-set thumbnail files for multiple folders in one write. Body: { updates: [{id, thumbnailFile}] }
|
||||
*/
|
||||
router.post('/folders/set-thumbnails', async function (request, response) {
|
||||
try {
|
||||
const { updates } = request.body;
|
||||
if (!Array.isArray(updates) || updates.some(u => !u.id || typeof u.thumbnailFile !== 'string')) {
|
||||
return response.status(400).json({ error: '"updates" must be an array of {id, thumbnailFile}.' });
|
||||
}
|
||||
await setFolderThumbnailsBatch(request.user.directories.root, updates);
|
||||
return response.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('[ImageMetadata] Folder set-thumbnails error:', error);
|
||||
return response.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/image-metadata/folders/update
|
||||
* Update a folder. Body: { id: string, name?: string, thumbnailFile?: string }
|
||||
*/
|
||||
router.post('/folders/update', async function (request, response) {
|
||||
try {
|
||||
const { id, ...updates } = request.body;
|
||||
if (!id || typeof id !== 'string') {
|
||||
return response.status(400).json({ error: '"id" is required.' });
|
||||
}
|
||||
const folder = await updateFolder(request.user.directories.root, id, updates);
|
||||
return response.json(folder);
|
||||
} catch (error) {
|
||||
if (error.message.includes('not found')) {
|
||||
return response.status(404).json({ error: error.message });
|
||||
}
|
||||
console.error('[ImageMetadata] Folder update error:', error);
|
||||
return response.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/image-metadata/folders/delete
|
||||
* Delete a folder and unassign all images. Body: { id: string }
|
||||
*/
|
||||
router.post('/folders/delete', async function (request, response) {
|
||||
try {
|
||||
const { id } = request.body;
|
||||
if (!id || typeof id !== 'string') {
|
||||
return response.status(400).json({ error: '"id" is required.' });
|
||||
}
|
||||
await deleteFolder(request.user.directories.root, id);
|
||||
return response.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error.message.includes('not found')) {
|
||||
return response.status(404).json({ error: error.message });
|
||||
}
|
||||
console.error('[ImageMetadata] Folder delete error:', error);
|
||||
return response.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/image-metadata/folders/assign
|
||||
* Assign images to a folder. Body: { id: string, paths: string[] }
|
||||
*/
|
||||
router.post('/folders/assign', async function (request, response) {
|
||||
try {
|
||||
const { id, paths } = request.body;
|
||||
if (!id || typeof id !== 'string') {
|
||||
return response.status(400).json({ error: '"id" is required.' });
|
||||
}
|
||||
if (!Array.isArray(paths)) {
|
||||
return response.status(400).json({ error: '"paths" array is required.' });
|
||||
}
|
||||
await assignImagesToFolder(request.user.directories.root, id, paths);
|
||||
return response.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error.message.includes('not found')) {
|
||||
return response.status(404).json({ error: error.message });
|
||||
}
|
||||
console.error('[ImageMetadata] Folder assign error:', error);
|
||||
return response.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/image-metadata/folders/unassign
|
||||
* Unassign images from a folder. Body: { id: string, paths: string[] }
|
||||
*/
|
||||
router.post('/folders/unassign', async function (request, response) {
|
||||
try {
|
||||
const { id, paths } = request.body;
|
||||
if (!id || typeof id !== 'string') {
|
||||
return response.status(400).json({ error: '"id" is required.' });
|
||||
}
|
||||
if (!Array.isArray(paths)) {
|
||||
return response.status(400).json({ error: '"paths" array is required.' });
|
||||
}
|
||||
await unassignImagesFromFolder(request.user.directories.root, id, paths);
|
||||
return response.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('[ImageMetadata] Folder unassign error:', error);
|
||||
return response.status(500).json({ error: 'Internal server error.' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/image-metadata
|
||||
* Get metadata for image(s) by path.
|
||||
|
||||
Reference in New Issue
Block a user