diff --git a/app/user_manager.py b/app/user_manager.py index e7381e621..658d2bb7a 100644 --- a/app/user_manager.py +++ b/app/user_manager.py @@ -14,6 +14,7 @@ from .app_settings import AppSettings from typing import TypedDict default_user = "default" +GALLERY_SUFFIX = ".gallery" class FileInfo(TypedDict): @@ -268,6 +269,62 @@ class UserManager(): 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}") async def delete_userdata(request): path = get_user_data_path(request, check_exists=True) diff --git a/tests-unit/app_test/user_manager_gallery_routes_test.py b/tests-unit/app_test/user_manager_gallery_routes_test.py new file mode 100644 index 000000000..4c3217c8f --- /dev/null +++ b/tests-unit/app_test/user_manager_gallery_routes_test.py @@ -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() diff --git a/web/css/gallery.css b/web/css/gallery.css new file mode 100644 index 000000000..08dcc913b --- /dev/null +++ b/web/css/gallery.css @@ -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 */ +} diff --git a/web/gallery.html b/web/gallery.html new file mode 100644 index 000000000..b285faaac --- /dev/null +++ b/web/gallery.html @@ -0,0 +1,16 @@ + + + + + + Gallery + + + +

My Gallery

+ + + + diff --git a/web/index.html b/web/index.html index da24d39b1..081221123 100644 --- a/web/index.html +++ b/web/index.html @@ -8,6 +8,7 @@ +
diff --git a/web/scripts/gallery.js b/web/scripts/gallery.js new file mode 100644 index 000000000..647ffa886 --- /dev/null +++ b/web/scripts/gallery.js @@ -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 = '

Error loading gallery items. Please try again later.

'; + } + } +} + +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 = '

No items in the gallery.

'; + 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); + }); +} diff --git a/web/scripts/gallery_integration.js b/web/scripts/gallery_integration.js new file mode 100644 index 000000000..ab74b4998 --- /dev/null +++ b/web/scripts/gallery_integration.js @@ -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.'); + } +});