feat: Add gallery feature for user images

This commit introduces a new gallery feature that allows you to mark images
in your user data folder and view them in a dedicated gallery page.

Key changes:

Backend (`app/user_manager.py`):
- Added a `GALLERY_SUFFIX` constant (`.gallery`).
- Implemented `toggle_gallery_status` to add/remove the gallery suffix from
  filenames (e.g., `image.png` <-> `image.gallery.png`).
- Added a POST route `/userdata/{file}/gallery` to toggle an image's gallery
  status.
- Implemented `list_gallery_files` to retrieve all images marked for the
  gallery.
- Added a GET route `/gallery` to list gallery items for you.

Frontend:
- Created `web/gallery.html` as the main page for the gallery view.
- Created `web/css/gallery.css` for basic styling of the gallery.
- Created `web/scripts/gallery.js` to fetch and display gallery items,
  allowing you to remove items from the gallery.
- Created `web/scripts/gallery_integration.js` to dynamically add a "Gallery"
  button/link to the main application UI, which navigates to `gallery.html`.
- Updated `web/index.html` to include the gallery integration script.

Tests (`tests-unit/app_test/user_manager_gallery_routes_test.py`):
- Added comprehensive unit tests for the new backend routes, covering:
  - Listing gallery items (empty, single, multiple, subdirectories).
  - Toggling gallery status (add, remove, file not found, invalid params).
  - Correct filename transformations and path handling.
This commit is contained in:
google-labs-jules[bot] 2025-06-21 05:14:56 +00:00
parent 37fda45c09
commit 9dc31cd71e
7 changed files with 520 additions and 0 deletions

View File

@ -14,6 +14,7 @@ from .app_settings import AppSettings
from typing import TypedDict from typing import TypedDict
default_user = "default" default_user = "default"
GALLERY_SUFFIX = ".gallery"
class FileInfo(TypedDict): class FileInfo(TypedDict):
@ -268,6 +269,62 @@ class UserManager():
return web.json_response(resp) return web.json_response(resp)
async def toggle_gallery_status(self, request):
file = request.match_info.get("file", None)
if not file:
return web.Response(status=400, text="File not specified")
filepath = self.get_request_user_filepath(request, file)
if not filepath or not os.path.exists(filepath):
return web.Response(status=404, text="File not found")
filename_no_ext, ext = os.path.splitext(filepath)
if filename_no_ext.endswith(GALLERY_SUFFIX):
# Remove gallery status by removing suffix from filename part
new_filename_no_ext = filename_no_ext[:-len(GALLERY_SUFFIX)]
new_filepath = new_filename_no_ext + ext
else:
# Add gallery status by adding suffix to filename part
new_filepath = filename_no_ext + GALLERY_SUFFIX + ext
os.rename(filepath, new_filepath)
new_filename = os.path.basename(new_filepath)
return web.json_response({"filename": new_filename}, status=200)
@routes.post("/userdata/{file}/gallery")
async def post_toggle_gallery_status(request):
return await self.toggle_gallery_status(request)
async def list_gallery_files(self, request):
user_root_dir = self.get_request_user_filepath(request, None)
if not user_root_dir or not os.path.isdir(user_root_dir):
return web.json_response({"error": "User directory not found"}, status=404)
gallery_files_info = []
# Search for files like *.gallery.png, *.gallery.jpg etc. in all subdirectories
pattern = os.path.join(glob.escape(user_root_dir), '**', '*' + GALLERY_SUFFIX + '.*')
for filepath in glob.glob(pattern, recursive=True):
if os.path.isfile(filepath):
original_filename_with_ext = os.path.basename(filepath)
# Remove .gallery suffix to get original filename
original_filename = original_filename_with_ext.replace(GALLERY_SUFFIX, "")
file_info = {
"filename": original_filename,
"path": os.path.relpath(filepath, user_root_dir).replace(os.sep, '/'),
"size": os.path.getsize(filepath),
"modified": os.path.getmtime(filepath)
}
gallery_files_info.append(file_info)
return web.json_response(gallery_files_info)
@routes.get("/gallery")
async def get_gallery_files(request):
return await self.list_gallery_files(request)
@routes.delete("/userdata/{file}") @routes.delete("/userdata/{file}")
async def delete_userdata(request): async def delete_userdata(request):
path = get_user_data_path(request, check_exists=True) path = get_user_data_path(request, check_exists=True)

View File

@ -0,0 +1,240 @@
import pytest
import os
import shutil
import json
import time
from unittest import IsolatedAsyncioTestCase # Using IsolatedAsyncioTestCase for async test methods
from unittest.mock import patch, MagicMock
from aiohttp import web
# Assuming UserManager is in app.user_manager
# Adjust the import path if your project structure is different
from app.user_manager import UserManager, GALLERY_SUFFIX
# Mock comfy.cli_args and folder_paths before they are imported by UserManager
# This is a common pattern if these modules are read at import time by the tested code.
mock_args = MagicMock()
mock_args.multi_user = False # Default to single-user mode for simplicity in most tests
mock_folder_paths = MagicMock()
# We'll set get_user_directory dynamically in test setup using tmp_path
# Apply patches at the module level if they need to be active before UserManager is imported
# or within specific test classes/methods if more fine-grained control is needed.
# For now, let's assume UserManager can be instantiated after these are patched.
@pytest.fixture
def app_client_factory(event_loop): # event_loop is a pytest-asyncio fixture
"""Factory to create aiohttp test clients."""
async def _create_client(routes_def_func, *args_for_func):
app = web.Application(loop=event_loop)
routes = web.RouteTableDef()
routes_def_func(routes, *args_for_func) # Call the function that defines routes
app.add_routes(routes)
return await event_loop.create_task(pytest.aiohttp.plugin.make_aiohttp_client(app))
return _create_client
class TestUserManagerGalleryRoutes(IsolatedAsyncioTestCase):
def setUp(self):
# Create a temporary directory for user data
self.test_user_dir_root = "temp_test_user_data"
os.makedirs(self.test_user_dir_root, exist_ok=True)
self.default_user_path = os.path.join(self.test_user_dir_root, "default")
os.makedirs(self.default_user_path, exist_ok=True)
# Patch folder_paths.get_user_directory and args
self.patch_folder_paths = patch('app.user_manager.folder_paths', mock_folder_paths)
self.patch_args = patch('app.user_manager.args', mock_args)
self.mock_folder_paths = self.patch_folder_paths.start()
self.mock_args = self.patch_args.start()
self.mock_folder_paths.get_user_directory.return_value = self.test_user_dir_root
self.mock_args.multi_user = False # Explicitly set for each test run
self.user_manager = UserManager()
# Setup routes for the user_manager
self.app = web.Application()
self.user_manager.add_routes(self.app.router)
async def asyncSetUp(self):
# Create a test client for making requests
self.client = await pytest.aiohttp.plugin.make_aiohttp_client(self.app)
async def asyncTearDown(self):
await self.client.close() # Close the client
self.patch_folder_paths.stop()
self.patch_args.stop()
if os.path.exists(self.test_user_dir_root):
shutil.rmtree(self.test_user_dir_root)
# --- Helper Methods ---
def _create_file(self, filename, content="test", user="default", subdir=None):
user_specific_path = os.path.join(self.test_user_dir_root, user)
if subdir:
user_specific_path = os.path.join(user_specific_path, subdir)
os.makedirs(user_specific_path, exist_ok=True)
filepath = os.path.join(user_specific_path, filename)
with open(filepath, "w") as f:
f.write(content)
return filepath
def _get_user_data_path(self, filename, user="default", subdir=None):
user_specific_path = os.path.join(self.test_user_dir_root, user)
if subdir:
user_specific_path = os.path.join(user_specific_path, subdir)
return os.path.join(user_specific_path, filename)
# --- Test Cases for /gallery (GET) ---
async def test_list_gallery_empty(self):
resp = await self.client.get("/gallery")
assert resp.status == 200
data = await resp.json()
assert data == []
async def test_list_one_gallery_item(self):
filename_orig = "image.png"
filename_gallery = f"image{GALLERY_SUFFIX}.png"
self._create_file(filename_gallery) # Create the .gallery.png file
resp = await self.client.get("/gallery")
assert resp.status == 200
data = await resp.json()
assert len(data) == 1
item = data[0]
assert item["filename"] == "image.png" # Original name without .gallery
assert item["path"] == filename_gallery # Path includes .gallery
assert item["size"] == 4 # "test"
assert "modified" in item
async def test_list_multiple_gallery_items_and_subdirs(self):
self._create_file(f"img1{GALLERY_SUFFIX}.jpg")
self._create_file(f"img2{GALLERY_SUFFIX}.jpeg", subdir="photos")
self._create_file(f"document{GALLERY_SUFFIX}.pdf", subdir="docs/work")
self._create_file("not_gallery.txt") # Should not be listed
self._create_file(f"also_not_gallery{GALLERY_SUFFIX}") # No extension, should not match *.gallery.*
self._create_file(f"another.gallery#fake.png") # Invalid char, but testing suffix rule
resp = await self.client.get("/gallery")
assert resp.status == 200
data = await resp.json()
assert len(data) == 3 # Only 3 valid gallery items
filenames_found = sorted([item["filename"] for item in data])
expected_filenames = sorted(["img1.jpg", "img2.jpeg", "document.pdf"])
assert filenames_found == expected_filenames
paths_found = sorted([item["path"] for item in data])
expected_paths = sorted([
f"img1{GALLERY_SUFFIX}.jpg",
f"photos/img2{GALLERY_SUFFIX}.jpeg",
f"docs/work/document{GALLERY_SUFFIX}.pdf"
])
assert paths_found == expected_paths
async def test_list_non_gallery_items_not_listed(self):
self._create_file("textfile.txt")
self._create_file(f"image_not_gallery.png") # No .gallery suffix in name
self._create_file(f"image_with_gallery_suffix_only{GALLERY_SUFFIX}") # No further extension
resp = await self.client.get("/gallery")
assert resp.status == 200
data = await resp.json()
assert data == []
# --- Test Cases for /userdata/{file}/gallery (POST) ---
async def test_toggle_gallery_add(self):
filename = "add_me.png"
created_path = self._create_file(filename)
resp = await self.client.post(f"/userdata/{filename}/gallery")
assert resp.status == 200
data = await resp.json()
expected_new_filename = f"add_me{GALLERY_SUFFIX}.png"
assert data["filename"] == expected_new_filename
assert not os.path.exists(created_path)
assert os.path.exists(self._get_user_data_path(expected_new_filename))
async def test_toggle_gallery_add_with_subdir(self):
filename = "add_me_subdir.jpg"
subdir = "level1/level2"
created_path = self._create_file(filename, subdir=subdir)
# Path in URL needs to be URL encoded if it has slashes
url_path = f"{subdir}/{filename}"
resp = await self.client.post(f"/userdata/{url_path}/gallery")
assert resp.status == 200
data = await resp.json()
expected_new_filename = f"add_me_subdir{GALLERY_SUFFIX}.jpg"
assert data["filename"] == expected_new_filename # Response is basename
assert not os.path.exists(created_path)
assert os.path.exists(self._get_user_data_path(expected_new_filename, subdir=subdir))
async def test_toggle_gallery_remove(self):
original_filename_part = "remove_me"
ext = ".jpeg"
gallery_filename = f"{original_filename_part}{GALLERY_SUFFIX}{ext}"
created_gallery_path = self._create_file(gallery_filename)
resp = await self.client.post(f"/userdata/{gallery_filename}/gallery")
assert resp.status == 200
data = await resp.json()
expected_new_filename = f"{original_filename_part}{ext}"
assert data["filename"] == expected_new_filename
assert not os.path.exists(created_gallery_path)
assert os.path.exists(self._get_user_data_path(expected_new_filename))
async def test_toggle_gallery_file_not_found(self):
resp = await self.client.post("/userdata/nonexistentfile.png/gallery")
assert resp.status == 404 # Or 400 if file not specified, but here it is specified
data = await resp.json() # Assuming error responses are JSON
assert "File not found" in data.get("error", "") or "File not found" in await resp.text()
async def test_toggle_gallery_invalid_file_param(self):
# Test with an empty file parameter or one that might be problematic
# The route itself might catch this before user_manager logic if path is malformed
# Depending on aiohttp's routing, this might result in a 404 for the route itself
# or a 400 if the handler's file extraction fails.
# UserManager's toggle_gallery_status expects `file` from `request.match_info`.
# If `file` is empty, it returns 400 "File not specified".
# This test is more about how aiohttp handles empty path parameters
# For instance, a route like /userdata//gallery might not match or might pass an empty string.
# Let's assume it passes an empty string if the route matches /userdata/{file}/gallery
# For this, we'd need to register a route that can produce an empty 'file' match_info.
# The current route definition /userdata/{file}/gallery will likely not match /userdata//gallery.
# So, let's test the handler directly with a mock request if we want to ensure "File not specified".
mock_request = MagicMock(spec=web.Request)
mock_request.match_info = {} # No 'file'
mock_request.headers = {} # For get_request_user_id
# Mock get_request_user_id if it's called before file check
# self.user_manager.get_request_user_id = MagicMock(return_value="default")
response = await self.user_manager.toggle_gallery_status(mock_request)
assert response.status == 400
# text_response = await response.text() # Not needed if using response.text
assert "File not specified" in response.text
if __name__ == "__main__":
pytest.main()

37
web/css/gallery.css vendored Normal file
View File

@ -0,0 +1,37 @@
#gallery-container {
display: flex;
flex-wrap: wrap;
gap: 15px; /* spacing between items */
padding: 10px;
}
.gallery-item {
margin: 10px;
border: 1px solid #ccc;
padding: 5px;
box-shadow: 2px 2px 5px rgba(0,0,0,0.1);
display: flex;
flex-direction: column;
align-items: center;
width: calc(200px + 10px); /* width of thumbnail + padding */
}
.gallery-thumbnail {
width: 200px;
height: 200px;
object-fit: cover; /* scales the image to cover the container while maintaining aspect ratio */
margin-bottom: 5px;
}
.gallery-item button {
padding: 8px 12px;
background-color: #f44336; /* Red */
color: white;
border: none;
cursor: pointer;
font-size: 0.9em;
}
.gallery-item button:hover {
background-color: #d32f2f; /* Darker red */
}

16
web/gallery.html vendored Normal file
View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gallery</title>
<link rel="stylesheet" href="css/gallery.css">
</head>
<body>
<h1>My Gallery</h1>
<div id="gallery-container">
<!-- Gallery items will be loaded here by JavaScript -->
</div>
<script src="scripts/gallery.js"></script>
</body>
</html>

1
web/index.html vendored
View File

@ -8,6 +8,7 @@
<link rel="stylesheet" type="text/css" href="materialdesignicons.min.css" /> <link rel="stylesheet" type="text/css" href="materialdesignicons.min.css" />
<script type="module" crossorigin src="./assets/index-DIU5yZe9.js"></script> <script type="module" crossorigin src="./assets/index-DIU5yZe9.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-1vLlIVor.css"> <link rel="stylesheet" crossorigin href="./assets/index-1vLlIVor.css">
<script src="scripts/gallery_integration.js" defer></script>
</head> </head>
<body class="litegraph grid"> <body class="litegraph grid">
<div id="vue-app"></div> <div id="vue-app"></div>

77
web/scripts/gallery.js vendored Normal file
View File

@ -0,0 +1,77 @@
document.addEventListener('DOMContentLoaded', () => {
initGallery();
});
async function initGallery() {
try {
const items = await fetchGalleryItems();
renderGalleryItems(items);
} catch (error) {
console.error("Error initializing gallery:", error);
const galleryContainer = document.getElementById('gallery-container');
if (galleryContainer) {
galleryContainer.innerHTML = '<p>Error loading gallery items. Please try again later.</p>';
}
}
}
async function fetchGalleryItems() {
const response = await fetch('/gallery');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
}
function renderGalleryItems(items) {
const galleryContainer = document.getElementById('gallery-container');
if (!galleryContainer) {
console.error("Gallery container not found!");
return;
}
galleryContainer.innerHTML = ''; // Clear previous items
if (!items || items.length === 0) {
galleryContainer.innerHTML = '<p>No items in the gallery.</p>';
return;
}
items.forEach(item => {
const itemDiv = document.createElement('div');
itemDiv.className = 'gallery-item';
const img = document.createElement('img');
// Assuming 'item.path' is the correct relative path including any necessary subdirectories
// and the '.gallery' part of the filename.
img.src = `userdata/${item.path}`;
img.alt = item.filename;
img.className = 'gallery-thumbnail';
img.onerror = () => { // Basic error handling for broken images
img.alt = 'Image not found';
// Optionally, display a placeholder or hide the item
};
const removeButton = document.createElement('button');
removeButton.textContent = 'Remove from Gallery';
removeButton.onclick = async () => {
try {
// item.path should be the full relative path including any .gallery part
const response = await fetch(`/userdata/${item.path}/gallery`, { method: 'POST' });
if (!response.ok) {
const errorData = await response.json();
throw new Error(`Failed to remove item: ${errorData.error || response.status}`);
}
// Refresh the gallery to show changes
initGallery();
} catch (error) {
console.error('Error removing item from gallery:', error);
alert(`Error: ${error.message}`);
}
};
itemDiv.appendChild(img);
itemDiv.appendChild(removeButton);
galleryContainer.appendChild(itemDiv);
});
}

92
web/scripts/gallery_integration.js vendored Normal file
View File

@ -0,0 +1,92 @@
document.addEventListener('DOMContentLoaded', () => {
// Attempt to find a known menu element
let menuElement = document.querySelector(".comfy-menu-buttons");
if (!menuElement) {
menuElement = document.querySelector(".comfy-horizontal-menu");
}
if (!menuElement) {
const queueButton = document.getElementById("queue-button");
if (queueButton && queueButton.parentElement) {
menuElement = queueButton.parentElement;
}
}
// Fallback: try to find any element with 'menu' in its class or id
if (!menuElement) {
const allElements = document.getElementsByTagName('*');
for (let i = 0; i < allElements.length; i++) {
const el = allElements[i];
if ((el.className && typeof el.className === 'string' && el.className.includes('menu')) ||
(el.id && el.id.includes('menu'))) {
// Check if it's a plausible candidate (e.g., not too deep, visible)
// This is a very rough heuristic
if (el.children.length > 0 && el.children.length < 10 && el.offsetParent !== null) {
menuElement = el;
console.log("Found a generic menu element:", menuElement);
break;
}
}
}
}
if (menuElement) {
const galleryLink = document.createElement('a');
galleryLink.href = 'gallery.html';
galleryLink.textContent = 'Gallery';
galleryLink.id = 'gallery-button'; // Added an ID for easier selection/styling if needed
// Basic styling to make it look like other buttons if possible
// This is highly dependent on the existing CSS of the application
// Attempt to copy styles from an existing button if one exists
const existingButton = menuElement.querySelector('button') || menuElement.querySelector('a');
if (existingButton) {
galleryLink.className = existingButton.className; // Copy class
// Copy some inline styles if they exist (might not be ideal but can work)
if (existingButton.style.padding) galleryLink.style.padding = existingButton.style.padding;
if (existingButton.style.margin) galleryLink.style.margin = existingButton.style.margin;
if (existingButton.style.textDecoration) galleryLink.style.textDecoration = existingButton.style.textDecoration;
if (existingButton.style.color) galleryLink.style.color = existingButton.style.color;
if (existingButton.style.backgroundColor) galleryLink.style.backgroundColor = existingButton.style.backgroundColor;
if (existingButton.style.border) galleryLink.style.border = existingButton.style.border;
if (existingButton.style.borderRadius) galleryLink.style.borderRadius = existingButton.style.borderRadius;
} else {
// Default minimal styling
galleryLink.style.padding = '5px 10px';
galleryLink.style.margin = '0 5px';
galleryLink.style.textDecoration = 'none';
galleryLink.style.border = '1px solid #333';
galleryLink.style.borderRadius = '4px';
galleryLink.style.color = '#333';
galleryLink.style.backgroundColor = '#f0f0f0';
}
// Specific style for our gallery button if not overridden by copied styles
if (!galleryLink.style.display) galleryLink.style.display = 'inline-block'; // Ensure it's displayed
menuElement.appendChild(galleryLink);
console.log('Gallery link added to menu:', menuElement);
} else {
console.warn('Could not find a suitable menu element to add the gallery link.');
// Fallback: Add it to the body or a prominent header if nothing else is found
const body = document.body;
const galleryLink = document.createElement('a');
galleryLink.href = 'gallery.html';
galleryLink.textContent = 'Open Gallery';
galleryLink.style.position = 'fixed';
galleryLink.style.top = '10px';
galleryLink.style.right = '10px';
galleryLink.style.padding = '10px';
galleryLink.style.backgroundColor = '#007bff';
galleryLink.style.color = 'white';
galleryLink.style.textDecoration = 'none';
galleryLink.style.zIndex = '1000';
galleryLink.style.border = '1px solid #0056b3'
galleryLink.style.borderRadius = '5px';
body.appendChild(galleryLink);
console.log('Gallery link added as a fallback floating button.');
}
});