* refactor(api): add multi-root output dir infrastructure (#126) - Change _gallery_cache from None to {} (dict keyed by path string) - Update invalidate_gallery_cache() to clear dict instead of setting None - Add _get_all_output_dirs() helper as single source of truth for resolving configured + fallback output directories - Update _enrich_prompt_images() to iterate all output roots when computing relative paths and thumbnail URLs * feat(api): support multiple gallery scan paths in settings endpoint (#126) * feat(ui): dynamic multi-path settings with add/remove buttons (#126) * cleanup: remove dead galleryRootPath assignment in loadSettings * feat(api): multi-directory gallery scan with subfolder filter and multi-root serve (#126) * feat(db): add folder filter to search_prompts and get_prompt_subfolders (#126) * feat(api): add subfolders endpoint and folder filter to prompt search (#126) * feat(ui): add folder dropdown filter to search bar (#126) * fix: escape LIKE wildcards in folder filter and remove dead _gallery_cache_time field * feat(scan): scan all media types across all output dirs with batched extraction (#126) * chore: gitignore SQLite WAL-mode temp files * fix: enrich prompt images with URLs and check all output dirs for serving - Extract _enrich_images() for flat image lists, used by get_prompt_images - get_prompt_images now adds url/thumbnail_url (fixes stuck "Loading images...") - serve_image checks all output dirs not just first (fixes 403 on multi-path) - Fix diagnostics monitor check to read current module attribute, not stale import * feat(ui): add version-scoped update notice popup for rescan (#126) * chore: bump version to 3.2.0
This commit is contained in:
@@ -13,6 +13,9 @@ docs/
|
||||
logs/
|
||||
standalone_tagger.py
|
||||
prompts.db
|
||||
prompts.db-shm
|
||||
prompts.db-wal
|
||||
prompts.db-journal
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
||||
@@ -187,6 +187,7 @@ class PromptDatabase:
|
||||
rating_max: Optional[int] = None,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
folder: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> List[Dict[str, Any]]:
|
||||
@@ -201,6 +202,7 @@ class PromptDatabase:
|
||||
rating_max: Maximum rating filter
|
||||
date_from: Start date filter (ISO format)
|
||||
date_to: End date filter (ISO format)
|
||||
folder: Filter by subfolder name in generated image paths
|
||||
limit: Maximum number of results
|
||||
offset: Number of results to skip
|
||||
|
||||
@@ -227,6 +229,26 @@ class PromptDatabase:
|
||||
)
|
||||
params.append(tag)
|
||||
|
||||
if folder:
|
||||
# Escape LIKE wildcards in the folder name
|
||||
safe_folder = (
|
||||
folder.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
)
|
||||
query_parts.append(
|
||||
"AND prompts.id IN ("
|
||||
" SELECT DISTINCT prompt_id FROM generated_images"
|
||||
" WHERE image_path LIKE ? ESCAPE '\\'"
|
||||
" OR image_path LIKE ? ESCAPE '\\'"
|
||||
" OR image_path LIKE ? ESCAPE '\\'"
|
||||
")"
|
||||
)
|
||||
# Pattern 1: folder between forward slashes (Unix + normalized Windows)
|
||||
params.append(f"%/{safe_folder}/%")
|
||||
# Pattern 2: folder between backslashes (Windows native)
|
||||
params.append(f"%\\\\{safe_folder}\\\\%")
|
||||
# Pattern 3: folder at start of a relative path
|
||||
params.append(f"{safe_folder}/%")
|
||||
|
||||
if rating_min is not None:
|
||||
query_parts.append("AND rating >= ?")
|
||||
params.append(rating_min)
|
||||
@@ -435,6 +457,46 @@ class PromptDatabase:
|
||||
)
|
||||
return [row["category"] for row in cursor.fetchall()]
|
||||
|
||||
def get_prompt_subfolders(self, root_dirs: Optional[List[str]] = None) -> List[str]:
|
||||
"""
|
||||
Get distinct subfolder paths from generated_images.
|
||||
|
||||
Extracts the directory portion of image_path, made relative to
|
||||
root_dirs if provided. Returns sorted unique folder names.
|
||||
"""
|
||||
with self.model.get_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
"SELECT DISTINCT image_path FROM generated_images "
|
||||
"WHERE image_path IS NOT NULL AND image_path != ''"
|
||||
)
|
||||
|
||||
folders = set()
|
||||
for row in cursor.fetchall():
|
||||
image_path = row["image_path"]
|
||||
parent = os.path.dirname(image_path)
|
||||
if not parent:
|
||||
continue
|
||||
|
||||
made_relative = False
|
||||
if root_dirs:
|
||||
for root in root_dirs:
|
||||
try:
|
||||
rel = os.path.relpath(parent, root)
|
||||
if not rel.startswith(".."):
|
||||
if rel != ".":
|
||||
folders.add(rel)
|
||||
made_relative = True
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if not made_relative:
|
||||
basename = os.path.basename(parent)
|
||||
if basename:
|
||||
folders.add(basename)
|
||||
|
||||
return sorted(folders)
|
||||
|
||||
def get_all_tags(self) -> List[str]:
|
||||
"""
|
||||
Get all unique tags that are in use (linked to at least one prompt).
|
||||
|
||||
+67
-41
@@ -123,8 +123,7 @@ class PromptManagerAPI(
|
||||
self.db = PromptDatabase()
|
||||
self._cached_output_dir = None # Lazy-cached by _find_comfyui_output_dir()
|
||||
self._html_cache = {} # Cached HTML file contents keyed by path
|
||||
self._gallery_cache = None # Cached gallery file listing (Fix 2.4)
|
||||
self._gallery_cache_time = 0 # Timestamp of last cache fill
|
||||
self._gallery_cache = {} # dict: path_str -> (files, timestamp)
|
||||
self._gallery_cache_ttl = 30 # Cache TTL in seconds
|
||||
|
||||
# Run cleanup on initialization to remove any existing duplicates
|
||||
@@ -156,8 +155,7 @@ class PromptManagerAPI(
|
||||
|
||||
Called by the image monitor when new files are detected.
|
||||
"""
|
||||
self._gallery_cache = None
|
||||
self._gallery_cache_time = 0
|
||||
self._gallery_cache = {}
|
||||
|
||||
def add_routes(self, routes):
|
||||
"""Register all API routes with the ComfyUI server.
|
||||
@@ -391,47 +389,52 @@ class PromptManagerAPI(
|
||||
|
||||
# ── Shared utilities used by multiple mixins ──────────────────────
|
||||
|
||||
def _enrich_prompt_images(self, prompts):
|
||||
"""Add url and thumbnail_url to each image in prompt results."""
|
||||
def _enrich_images(self, images):
|
||||
"""Add url and thumbnail_url to a flat list of image dicts."""
|
||||
from urllib.parse import quote as url_quote
|
||||
|
||||
output_dir = self._find_comfyui_output_dir()
|
||||
output_path = Path(output_dir) if output_dir else None
|
||||
output_dirs = self._get_all_output_dirs()
|
||||
|
||||
for image in images:
|
||||
image_path_str = image.get("image_path", "")
|
||||
if not image_path_str:
|
||||
continue
|
||||
|
||||
img_path = Path(image_path_str)
|
||||
|
||||
# Set fallback url via image ID
|
||||
if image.get("id"):
|
||||
image["url"] = f"/prompt_manager/images/{image['id']}/file"
|
||||
|
||||
# Try each root to compute relative path and thumbnail URL
|
||||
for output_path in output_dirs:
|
||||
try:
|
||||
rel_path = img_path.resolve().relative_to(output_path.resolve())
|
||||
image["relative_path"] = str(rel_path)
|
||||
image["url"] = (
|
||||
f"/prompt_manager/images/serve/{url_quote(rel_path.as_posix(), safe='/')}"
|
||||
)
|
||||
|
||||
# Check for thumbnail
|
||||
rel_no_ext = rel_path.with_suffix("")
|
||||
thumb_rel = (
|
||||
f"thumbnails/{rel_no_ext.as_posix()}_thumb{rel_path.suffix}"
|
||||
)
|
||||
thumb_abs = output_path / thumb_rel
|
||||
if thumb_abs.exists():
|
||||
image["thumbnail_url"] = (
|
||||
f"/prompt_manager/images/serve/{url_quote(thumb_rel, safe='/')}"
|
||||
)
|
||||
break # Found matching root, stop searching
|
||||
except (ValueError, RuntimeError):
|
||||
continue # Try next root
|
||||
|
||||
return images
|
||||
|
||||
def _enrich_prompt_images(self, prompts):
|
||||
"""Add url and thumbnail_url to each image in prompt results."""
|
||||
for prompt in prompts:
|
||||
for image in prompt.get("images", []):
|
||||
image_path_str = image.get("image_path", "")
|
||||
if not image_path_str:
|
||||
continue
|
||||
|
||||
img_path = Path(image_path_str)
|
||||
|
||||
# Set fallback url via image ID
|
||||
if image.get("id"):
|
||||
image["url"] = f"/prompt_manager/images/{image['id']}/file"
|
||||
|
||||
# Try to compute relative path and thumbnail URL
|
||||
if output_path:
|
||||
try:
|
||||
rel_path = img_path.resolve().relative_to(output_path.resolve())
|
||||
image["relative_path"] = str(rel_path)
|
||||
image["url"] = (
|
||||
f"/prompt_manager/images/serve/{url_quote(rel_path.as_posix(), safe='/')}"
|
||||
)
|
||||
|
||||
# Check for thumbnail
|
||||
rel_no_ext = rel_path.with_suffix("")
|
||||
thumb_rel = (
|
||||
f"thumbnails/{rel_no_ext.as_posix()}_thumb{rel_path.suffix}"
|
||||
)
|
||||
thumb_abs = output_path / thumb_rel
|
||||
if thumb_abs.exists():
|
||||
image["thumbnail_url"] = (
|
||||
f"/prompt_manager/images/serve/{url_quote(thumb_rel, safe='/')}"
|
||||
)
|
||||
except (ValueError, RuntimeError):
|
||||
pass
|
||||
|
||||
self._enrich_images(prompt.get("images", []))
|
||||
return prompts
|
||||
|
||||
def _clean_nan_recursive(self, obj):
|
||||
@@ -550,6 +553,29 @@ class PromptManagerAPI(
|
||||
|
||||
return None
|
||||
|
||||
def _get_all_output_dirs(self):
|
||||
"""Get all configured output directories, falling back to auto-detect.
|
||||
|
||||
Returns:
|
||||
List[Path]: Valid output directory paths, possibly empty.
|
||||
"""
|
||||
from ..config import GalleryConfig
|
||||
|
||||
output_dirs = []
|
||||
if GalleryConfig.MONITORING_DIRECTORIES:
|
||||
for d in GalleryConfig.MONITORING_DIRECTORIES:
|
||||
p = Path(d).resolve()
|
||||
if p.is_dir():
|
||||
output_dirs.append(p)
|
||||
|
||||
# Fallback to auto-detect if no configured dirs are valid
|
||||
if not output_dirs:
|
||||
fallback = self._find_comfyui_output_dir()
|
||||
if fallback:
|
||||
output_dirs.append(Path(fallback))
|
||||
|
||||
return output_dirs
|
||||
|
||||
def _extract_comfyui_metadata(self, image_path):
|
||||
"""Extract ComfyUI workflow metadata from PNG image files."""
|
||||
try:
|
||||
|
||||
+187
-146
@@ -371,6 +371,9 @@ class AdminRoutesMixin:
|
||||
"settings": {
|
||||
"result_timeout": PromptManagerConfig.RESULT_TIMEOUT,
|
||||
"webui_display_mode": PromptManagerConfig.WEBUI_DISPLAY_MODE,
|
||||
"gallery_root_paths": list(
|
||||
GalleryConfig.MONITORING_DIRECTORIES
|
||||
),
|
||||
"gallery_root_path": (
|
||||
GalleryConfig.MONITORING_DIRECTORIES[0]
|
||||
if GalleryConfig.MONITORING_DIRECTORIES
|
||||
@@ -400,8 +403,68 @@ class AdminRoutesMixin:
|
||||
if "webui_display_mode" in data:
|
||||
PromptManagerConfig.WEBUI_DISPLAY_MODE = data["webui_display_mode"]
|
||||
|
||||
# Handle gallery root path
|
||||
if "gallery_root_path" in data:
|
||||
# Blocked system directories (shared by both path handlers)
|
||||
blocked = [
|
||||
"/etc",
|
||||
"/usr",
|
||||
"/bin",
|
||||
"/sbin",
|
||||
"/boot",
|
||||
"/proc",
|
||||
"/sys",
|
||||
"/dev",
|
||||
"/var/log",
|
||||
"/root",
|
||||
"C:\\Windows",
|
||||
"C:\\Program Files",
|
||||
]
|
||||
|
||||
# Handle gallery root paths (array — preferred)
|
||||
if "gallery_root_paths" in data:
|
||||
new_paths = data["gallery_root_paths"]
|
||||
if not isinstance(new_paths, list):
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "gallery_root_paths must be a list",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
validated_paths = []
|
||||
for path_str in new_paths:
|
||||
path_str = path_str.strip()
|
||||
if not path_str:
|
||||
continue
|
||||
resolved = Path(path_str).resolve()
|
||||
if not resolved.is_dir():
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Path does not exist or is not a directory: {path_str}",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
for b in blocked:
|
||||
if str(resolved).startswith(b):
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Cannot use system directory: {path_str}",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
validated_paths.append(path_str)
|
||||
|
||||
old_paths = list(GalleryConfig.MONITORING_DIRECTORIES)
|
||||
if validated_paths != old_paths:
|
||||
GalleryConfig.MONITORING_DIRECTORIES = validated_paths
|
||||
self._cached_output_dir = None
|
||||
self._gallery_cache = {}
|
||||
restart_required = True
|
||||
|
||||
elif "gallery_root_path" in data:
|
||||
# Backward compat: single path string
|
||||
new_path = data["gallery_root_path"].strip()
|
||||
old_path = (
|
||||
GalleryConfig.MONITORING_DIRECTORIES[0]
|
||||
@@ -411,9 +474,7 @@ class AdminRoutesMixin:
|
||||
|
||||
if new_path != old_path:
|
||||
if new_path:
|
||||
from pathlib import Path as _Path
|
||||
|
||||
resolved = _Path(new_path).resolve()
|
||||
resolved = Path(new_path).resolve()
|
||||
if not resolved.is_dir():
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -422,20 +483,6 @@ class AdminRoutesMixin:
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
blocked = [
|
||||
"/etc",
|
||||
"/usr",
|
||||
"/bin",
|
||||
"/sbin",
|
||||
"/boot",
|
||||
"/proc",
|
||||
"/sys",
|
||||
"/dev",
|
||||
"/var/log",
|
||||
"/root",
|
||||
"C:\\Windows",
|
||||
"C:\\Program Files",
|
||||
]
|
||||
for b in blocked:
|
||||
if str(resolved).startswith(b):
|
||||
return web.json_response(
|
||||
@@ -448,10 +495,8 @@ class AdminRoutesMixin:
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [new_path]
|
||||
else:
|
||||
GalleryConfig.MONITORING_DIRECTORIES = []
|
||||
# Invalidate caches so next lookup uses new config
|
||||
self._cached_output_dir = None
|
||||
self._gallery_cache = None
|
||||
self._gallery_cache_time = 0
|
||||
self._gallery_cache = {}
|
||||
restart_required = True
|
||||
|
||||
# Save to config file for persistence
|
||||
@@ -599,10 +644,11 @@ class AdminRoutesMixin:
|
||||
|
||||
# Check image monitor status
|
||||
try:
|
||||
from ...utils.image_monitor import _monitor_instance
|
||||
from ...utils import image_monitor as im_mod
|
||||
|
||||
if _monitor_instance is not None:
|
||||
monitor_status = _monitor_instance.get_status()
|
||||
monitor = im_mod._monitor_instance
|
||||
if monitor is not None:
|
||||
monitor_status = monitor.get_status()
|
||||
results["image_monitor"] = {
|
||||
"status": (
|
||||
"ok" if monitor_status.get("observer_alive") else "error"
|
||||
@@ -978,29 +1024,60 @@ class AdminRoutesMixin:
|
||||
async def scan_images(self, request):
|
||||
"""Scan ComfyUI output images for prompt metadata and add them to the database."""
|
||||
|
||||
BATCH_SIZE = 50 # Files per executor call for metadata extraction
|
||||
|
||||
def _collect_media_files(output_dirs):
|
||||
"""Collect all media files from output directories (blocking I/O)."""
|
||||
image_extensions = [".png", ".jpg", ".jpeg", ".webp", ".gif"]
|
||||
video_extensions = [".mp4", ".webm", ".avi", ".mov", ".mkv", ".m4v", ".wmv"]
|
||||
media_extensions = image_extensions + video_extensions
|
||||
all_files = []
|
||||
seen = set()
|
||||
for output_dir in output_dirs:
|
||||
for ext in media_extensions:
|
||||
for pattern in [f"*{ext}", f"*{ext.upper()}"]:
|
||||
for f in output_dir.rglob(pattern):
|
||||
if "thumbnails" not in f.parts:
|
||||
norm = str(f).lower()
|
||||
if norm not in seen:
|
||||
seen.add(norm)
|
||||
all_files.append(f)
|
||||
return all_files
|
||||
|
||||
def _extract_batch_metadata(file_batch):
|
||||
"""Extract metadata from a batch of files in one executor call."""
|
||||
results = []
|
||||
for f in file_batch:
|
||||
try:
|
||||
meta = self._extract_comfyui_metadata(str(f))
|
||||
results.append((f, meta))
|
||||
except Exception:
|
||||
results.append((f, {}))
|
||||
return results
|
||||
|
||||
async def stream_response():
|
||||
try:
|
||||
self.logger.info("Starting image scan operation")
|
||||
self.logger.info("Starting scan (timer clearing not implemented yet)")
|
||||
|
||||
output_dir = self._find_comfyui_output_dir()
|
||||
if not output_dir:
|
||||
self.logger.error("ComfyUI output directory not found")
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': 'ComfyUI output directory not found'})}\n\n"
|
||||
output_dirs = self._get_all_output_dirs()
|
||||
if not output_dirs:
|
||||
self.logger.error("No output directories found")
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': 'No output directories found. Configure scan directories in Settings.'})}\n\n"
|
||||
return
|
||||
|
||||
yield f"data: {json.dumps({'type': 'progress', 'progress': 0, 'status': 'Scanning for PNG files...', 'processed': 0, 'found': 0})}\n\n"
|
||||
dir_names = [str(d) for d in output_dirs]
|
||||
yield f"data: {json.dumps({'type': 'progress', 'progress': 0, 'status': f'Scanning {len(output_dirs)} directory(ies) for media files...', 'processed': 0, 'found': 0})}\n\n"
|
||||
|
||||
png_files = await self._run_in_executor(
|
||||
lambda: list(Path(output_dir).rglob("*.png"))
|
||||
media_files = await self._run_in_executor(
|
||||
_collect_media_files, output_dirs
|
||||
)
|
||||
total_files = len(png_files)
|
||||
total_files = len(media_files)
|
||||
|
||||
if total_files == 0:
|
||||
yield f"data: {json.dumps({'type': 'complete', 'processed': 0, 'found': 0, 'added': 0})}\n\n"
|
||||
yield f"data: {json.dumps({'type': 'complete', 'processed': 0, 'found': 0, 'added': 0, 'linked': 0, 'directories': dir_names})}\n\n"
|
||||
return
|
||||
|
||||
yield f"data: {json.dumps({'type': 'progress', 'progress': 5, 'status': f'Found {total_files} PNG files to process...', 'processed': 0, 'found': 0})}\n\n"
|
||||
yield f"data: {json.dumps({'type': 'progress', 'progress': 5, 'status': f'Found {total_files} media files to process...', 'processed': 0, 'found': 0})}\n\n"
|
||||
|
||||
processed_count = 0
|
||||
found_count = 0
|
||||
@@ -1018,133 +1095,97 @@ class AdminRoutesMixin:
|
||||
sys.path.insert(0, current_dir)
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
for i, png_file in enumerate(png_files):
|
||||
try:
|
||||
metadata = await self._run_in_executor(
|
||||
self._extract_comfyui_metadata, str(png_file)
|
||||
)
|
||||
processed_count += 1
|
||||
# Process files in batches for performance
|
||||
for batch_start in range(0, total_files, BATCH_SIZE):
|
||||
batch = media_files[batch_start : batch_start + BATCH_SIZE]
|
||||
|
||||
if metadata:
|
||||
self.logger.debug(
|
||||
f"Found metadata in {os.path.basename(png_file)}: {list(metadata.keys())}"
|
||||
)
|
||||
# Extract metadata for entire batch in one executor call
|
||||
batch_results = await self._run_in_executor(
|
||||
_extract_batch_metadata, batch
|
||||
)
|
||||
|
||||
for media_file, metadata in batch_results:
|
||||
try:
|
||||
processed_count += 1
|
||||
|
||||
if not metadata:
|
||||
continue
|
||||
|
||||
parsed_data = self._parse_comfyui_prompt(metadata)
|
||||
self.logger.debug(
|
||||
f"Parsed data keys: {list(parsed_data.keys())}, has prompt: {bool(parsed_data.get('prompt'))}, has parameters: {bool(parsed_data.get('parameters'))}"
|
||||
|
||||
if not (
|
||||
parsed_data.get("prompt")
|
||||
or parsed_data.get("parameters")
|
||||
):
|
||||
continue
|
||||
|
||||
found_count += 1
|
||||
prompt_text = self._extract_readable_prompt(parsed_data)
|
||||
|
||||
if prompt_text and not isinstance(prompt_text, str):
|
||||
prompt_text = str(prompt_text)
|
||||
|
||||
if not (prompt_text and prompt_text.strip()):
|
||||
continue
|
||||
|
||||
prompt_hash = generate_prompt_hash(prompt_text.strip())
|
||||
existing = await self._run_in_executor(
|
||||
self.db.get_prompt_by_hash, prompt_hash
|
||||
)
|
||||
|
||||
if parsed_data.get("prompt") or parsed_data.get(
|
||||
"parameters"
|
||||
):
|
||||
found_count += 1
|
||||
prompt_text = self._extract_readable_prompt(parsed_data)
|
||||
|
||||
if prompt_text:
|
||||
self.logger.debug(
|
||||
f"Found prompt in {os.path.basename(png_file)} (type: {type(prompt_text)}): {str(prompt_text)[:100]}..."
|
||||
if existing:
|
||||
try:
|
||||
await self._run_in_executor(
|
||||
self.db.link_image_to_prompt,
|
||||
existing["id"],
|
||||
str(media_file),
|
||||
)
|
||||
else:
|
||||
self.logger.debug(
|
||||
f"No readable prompt found in {os.path.basename(png_file)}, parsed_data keys: {list(parsed_data.keys())}"
|
||||
linked_count += 1
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Failed to link {media_file.name} to existing prompt: {e}"
|
||||
)
|
||||
|
||||
if prompt_text and not isinstance(prompt_text, str):
|
||||
self.logger.debug(
|
||||
f"Converting prompt_text from {type(prompt_text)} to string"
|
||||
)
|
||||
prompt_text = str(prompt_text)
|
||||
|
||||
if prompt_text and prompt_text.strip():
|
||||
else:
|
||||
prompt_id = await self._run_in_executor(
|
||||
self.db.save_prompt,
|
||||
prompt_text.strip(),
|
||||
"scanned",
|
||||
["auto-scanned"],
|
||||
None,
|
||||
f"Auto-scanned from {media_file.name}",
|
||||
prompt_hash,
|
||||
)
|
||||
if prompt_id:
|
||||
added_count += 1
|
||||
try:
|
||||
prompt_hash = generate_prompt_hash(
|
||||
prompt_text.strip()
|
||||
await self._run_in_executor(
|
||||
self.db.link_image_to_prompt,
|
||||
prompt_id,
|
||||
str(media_file),
|
||||
)
|
||||
self.logger.debug(
|
||||
f"Generated hash for prompt: {prompt_hash[:16]}..."
|
||||
)
|
||||
|
||||
existing = await self._run_in_executor(
|
||||
self.db.get_prompt_by_hash, prompt_hash
|
||||
)
|
||||
if existing:
|
||||
self.logger.debug(
|
||||
f"Found existing prompt ID {existing['id']} for image {os.path.basename(png_file)}"
|
||||
)
|
||||
try:
|
||||
await self._run_in_executor(
|
||||
self.db.link_image_to_prompt,
|
||||
existing["id"],
|
||||
str(png_file),
|
||||
)
|
||||
linked_count += 1
|
||||
self.logger.debug(
|
||||
f"Linked image {os.path.basename(png_file)} to existing prompt {existing['id']}"
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Failed to link image {png_file} to existing prompt: {e}"
|
||||
)
|
||||
else:
|
||||
self.logger.debug(
|
||||
f"Saving new prompt from {os.path.basename(png_file)}"
|
||||
)
|
||||
prompt_id = await self._run_in_executor(
|
||||
self.db.save_prompt,
|
||||
prompt_text.strip(),
|
||||
"scanned",
|
||||
["auto-scanned"],
|
||||
None,
|
||||
f"Auto-scanned from {os.path.basename(png_file)}",
|
||||
prompt_hash,
|
||||
)
|
||||
|
||||
if prompt_id:
|
||||
added_count += 1
|
||||
self.logger.info(
|
||||
f"Successfully saved new prompt with ID {prompt_id} from {os.path.basename(png_file)}"
|
||||
)
|
||||
try:
|
||||
await self._run_in_executor(
|
||||
self.db.link_image_to_prompt,
|
||||
prompt_id,
|
||||
str(png_file),
|
||||
)
|
||||
self.logger.debug(
|
||||
f"Linked image {os.path.basename(png_file)} to new prompt {prompt_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Failed to link image {png_file} to new prompt: {e}"
|
||||
)
|
||||
else:
|
||||
self.logger.error(
|
||||
f"Failed to save prompt from {os.path.basename(png_file)} - no ID returned"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Failed to save prompt from {png_file}: {e}"
|
||||
f"Failed to link {media_file.name} to new prompt: {e}"
|
||||
)
|
||||
|
||||
# Update progress every 10 files
|
||||
if i % 10 == 0 or i == total_files - 1:
|
||||
progress = int((i + 1) / total_files * 100)
|
||||
status = f"Processing file {i + 1}/{total_files}..."
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Error processing {media_file.name}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
yield f"data: {json.dumps({'type': 'progress', 'progress': progress, 'status': status, 'processed': processed_count, 'found': found_count})}\n\n"
|
||||
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error processing {png_file}: {e}")
|
||||
continue
|
||||
# Progress update after each batch
|
||||
progress = int(
|
||||
min(batch_start + len(batch), total_files) / total_files * 100
|
||||
)
|
||||
yield f"data: {json.dumps({'type': 'progress', 'progress': progress, 'status': f'Processing file {min(batch_start + len(batch), total_files)}/{total_files}...', 'processed': processed_count, 'found': found_count})}\n\n"
|
||||
await asyncio.sleep(0)
|
||||
|
||||
self.logger.info(
|
||||
f"Scan completed: processed={processed_count}, found={found_count}, new_prompts_added={added_count}, images_linked_to_existing={linked_count}"
|
||||
f"Scan completed: processed={processed_count}, found={found_count}, "
|
||||
f"new_prompts_added={added_count}, images_linked_to_existing={linked_count}"
|
||||
)
|
||||
yield f"data: {json.dumps({'type': 'complete', 'processed': processed_count, 'found': found_count, 'added': added_count, 'linked': linked_count})}\n\n"
|
||||
yield f"data: {json.dumps({'type': 'complete', 'processed': processed_count, 'found': found_count, 'added': added_count, 'linked': linked_count, 'directories': dir_names})}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
self.logger.exception("Scan error")
|
||||
|
||||
+102
-69
@@ -68,6 +68,10 @@ class ImageRoutesMixin:
|
||||
async def clear_thumbnails_route(request):
|
||||
return await self.clear_thumbnails(request)
|
||||
|
||||
@routes.get("/prompt_manager/gallery/subfolders")
|
||||
async def get_gallery_subfolders_route(request):
|
||||
return await self.get_gallery_subfolders(request)
|
||||
|
||||
async def get_prompt_images(self, request):
|
||||
"""Get all images for a specific prompt."""
|
||||
try:
|
||||
@@ -77,6 +81,9 @@ class ImageRoutesMixin:
|
||||
# Clean up any NaN values that cause JSON parsing errors (recursive)
|
||||
cleaned_images = [self._clean_nan_recursive(image) for image in images]
|
||||
|
||||
# Add url and thumbnail_url so the frontend can serve them
|
||||
self._enrich_images(cleaned_images)
|
||||
|
||||
# Additional fallback: convert to JSON string and clean NaN values manually
|
||||
try:
|
||||
response_data = {"success": True, "images": cleaned_images}
|
||||
@@ -145,7 +152,10 @@ class ImageRoutesMixin:
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
|
||||
def _scan_gallery_files_sync(self, output_path):
|
||||
"""Scan output directory for media files (blocking I/O, run in executor)."""
|
||||
"""Scan output directory for media files (blocking I/O, run in executor).
|
||||
|
||||
Returns list of (path, mtime) tuples sorted by mtime descending.
|
||||
"""
|
||||
image_extensions = [".png", ".jpg", ".jpeg", ".webp", ".gif"]
|
||||
video_extensions = [".mp4", ".webm", ".avi", ".mov", ".mkv", ".m4v", ".wmv"]
|
||||
media_extensions = image_extensions + video_extensions
|
||||
@@ -159,62 +169,73 @@ class ImageRoutesMixin:
|
||||
normalized_path = str(media_path).lower()
|
||||
if normalized_path not in seen_paths:
|
||||
seen_paths.add(normalized_path)
|
||||
all_images.append(media_path)
|
||||
try:
|
||||
mtime = media_path.stat().st_mtime
|
||||
all_images.append((media_path, mtime))
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# Sort by modification time (newest first)
|
||||
all_images.sort(key=lambda x: x.stat().st_mtime, reverse=True)
|
||||
all_images.sort(key=lambda x: x[1], reverse=True)
|
||||
return all_images
|
||||
|
||||
async def _get_gallery_files(self, output_path):
|
||||
"""Get gallery files with TTL cache. Invalidated by image monitor."""
|
||||
"""Get gallery files with per-directory TTL cache."""
|
||||
now = _time.monotonic()
|
||||
if (
|
||||
self._gallery_cache is not None
|
||||
and (now - self._gallery_cache_time) < self._gallery_cache_ttl
|
||||
):
|
||||
return self._gallery_cache
|
||||
key = str(output_path)
|
||||
cached = self._gallery_cache.get(key)
|
||||
if cached and (now - cached[1]) < self._gallery_cache_ttl:
|
||||
return cached[0]
|
||||
|
||||
all_images = await self._run_in_executor(
|
||||
self._scan_gallery_files_sync, output_path
|
||||
)
|
||||
self._gallery_cache = all_images
|
||||
self._gallery_cache_time = now
|
||||
return all_images
|
||||
files = await self._run_in_executor(self._scan_gallery_files_sync, output_path)
|
||||
self._gallery_cache[key] = (files, now)
|
||||
return files
|
||||
|
||||
async def get_output_images(self, request):
|
||||
"""Get all images from ComfyUI output folder."""
|
||||
"""Get all images from ComfyUI output folder(s)."""
|
||||
try:
|
||||
from urllib.parse import quote
|
||||
|
||||
# Find ComfyUI output directory
|
||||
output_dir = self._find_comfyui_output_dir()
|
||||
if not output_dir:
|
||||
output_dirs = self._get_all_output_dirs()
|
||||
if not output_dirs:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "ComfyUI output directory not found",
|
||||
"error": "No output directories found",
|
||||
"images": [],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Get pagination parameters
|
||||
limit = int(request.query.get("limit", 100))
|
||||
offset = int(request.query.get("offset", 0))
|
||||
subfolder = request.query.get("subfolder", "").strip()
|
||||
|
||||
# Collect (path, mtime, root) from all directories
|
||||
all_images = []
|
||||
for output_path in output_dirs:
|
||||
dir_images = await self._get_gallery_files(output_path)
|
||||
for img_path, mtime in dir_images:
|
||||
all_images.append((img_path, mtime, output_path))
|
||||
|
||||
# Sort combined results by mtime (newest first) — no .stat() calls
|
||||
all_images.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
# Apply subfolder filter if provided
|
||||
if subfolder:
|
||||
filtered = []
|
||||
for img_path, mtime, root in all_images:
|
||||
rel_dir = str(img_path.relative_to(root).parent)
|
||||
if rel_dir == subfolder or rel_dir.startswith(subfolder + os.sep):
|
||||
filtered.append((img_path, mtime, root))
|
||||
all_images = filtered
|
||||
|
||||
total = len(all_images)
|
||||
paginated = all_images[offset : offset + limit]
|
||||
|
||||
output_path = Path(output_dir)
|
||||
thumbnails_dir = output_path / "thumbnails"
|
||||
video_extensions = [".mp4", ".webm", ".avi", ".mov", ".mkv", ".m4v", ".wmv"]
|
||||
|
||||
# Use cached file listing (Fix 2.4)
|
||||
all_images = await self._get_gallery_files(output_path)
|
||||
|
||||
# Apply pagination
|
||||
paginated_images = all_images[offset : offset + limit]
|
||||
|
||||
# Format media data in executor (stat calls are blocking)
|
||||
def _format_page():
|
||||
images = []
|
||||
for media_path in paginated_images:
|
||||
for media_path, mtime, output_path in paginated:
|
||||
try:
|
||||
stat = media_path.stat()
|
||||
rel_path = media_path.relative_to(output_path)
|
||||
@@ -223,6 +244,7 @@ class ImageRoutesMixin:
|
||||
media_type = "video" if is_video else "image"
|
||||
|
||||
thumbnail_url = None
|
||||
thumbnails_dir = output_path / "thumbnails"
|
||||
if thumbnails_dir.exists():
|
||||
thumbnail_ext = ".jpg" if is_video else extension
|
||||
rel_path_no_ext = rel_path.with_suffix("")
|
||||
@@ -237,6 +259,7 @@ class ImageRoutesMixin:
|
||||
"filename": media_path.name,
|
||||
"path": str(media_path),
|
||||
"relative_path": str(rel_path),
|
||||
"root_dir": str(output_path),
|
||||
"url": f"/prompt_manager/images/serve/{rel_path.as_posix()}",
|
||||
"thumbnail_url": thumbnail_url,
|
||||
"size": stat.st_size,
|
||||
@@ -257,18 +280,15 @@ class ImageRoutesMixin:
|
||||
{
|
||||
"success": True,
|
||||
"images": images,
|
||||
"total": len(all_images),
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"has_more": offset + limit < len(all_images),
|
||||
"has_more": offset + limit < total,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Get output images error: {e}")
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(e), "images": []}, status=500
|
||||
)
|
||||
self.logger.error(f"Output images error: {e}")
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
|
||||
async def serve_image(self, request):
|
||||
"""Serve the actual image file using streamed FileResponse."""
|
||||
@@ -283,11 +303,13 @@ class ImageRoutesMixin:
|
||||
|
||||
image_path = Path(image["image_path"]).resolve()
|
||||
|
||||
# Validate path is within the ComfyUI output directory
|
||||
output_dir = self._find_comfyui_output_dir()
|
||||
if output_dir:
|
||||
output_path = Path(output_dir).resolve()
|
||||
if not image_path.is_relative_to(output_path):
|
||||
# Validate path is within any configured output directory
|
||||
output_dirs = self._get_all_output_dirs()
|
||||
if output_dirs:
|
||||
allowed = any(
|
||||
image_path.is_relative_to(d.resolve()) for d in output_dirs
|
||||
)
|
||||
if not allowed:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Access denied"}, status=403
|
||||
)
|
||||
@@ -314,43 +336,54 @@ class ImageRoutesMixin:
|
||||
try:
|
||||
filepath = request.match_info["filepath"]
|
||||
|
||||
# Find ComfyUI output directory
|
||||
output_dir = self._find_comfyui_output_dir()
|
||||
if not output_dir:
|
||||
# Search all configured output directories
|
||||
output_dirs = self._get_all_output_dirs()
|
||||
if not output_dirs:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "ComfyUI output directory not found"},
|
||||
status=404,
|
||||
)
|
||||
|
||||
# Construct full image path
|
||||
image_path = Path(output_dir) / filepath
|
||||
# Try each root directory until the file is found
|
||||
for output_path in output_dirs:
|
||||
image_path = (output_path / filepath).resolve()
|
||||
|
||||
# Security check: make sure the path is within the output directory
|
||||
try:
|
||||
image_path = image_path.resolve()
|
||||
output_path = Path(output_dir).resolve()
|
||||
if not image_path.is_relative_to(output_path):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Access denied"}, status=403
|
||||
)
|
||||
except Exception:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid file path"}, status=400
|
||||
)
|
||||
# Security check: must be within this output directory
|
||||
if not image_path.is_relative_to(output_path.resolve()):
|
||||
continue
|
||||
|
||||
if not image_path.exists():
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Image file not found"}, status=404
|
||||
)
|
||||
if image_path.exists():
|
||||
response = web.FileResponse(image_path)
|
||||
response.headers["Cache-Control"] = "public, max-age=3600"
|
||||
return response
|
||||
|
||||
response = web.FileResponse(image_path)
|
||||
response.headers["Cache-Control"] = "public, max-age=3600"
|
||||
return response
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Image file not found"}, status=404
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Serve output image error: {e}")
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
|
||||
async def get_gallery_subfolders(self, request):
|
||||
"""Get distinct subfolders from gallery output directories."""
|
||||
try:
|
||||
output_dirs = self._get_all_output_dirs()
|
||||
subfolders = set()
|
||||
for output_path in output_dirs:
|
||||
files = await self._get_gallery_files(output_path)
|
||||
for f, _mtime in files:
|
||||
rel_dir = str(f.relative_to(output_path).parent)
|
||||
if rel_dir and rel_dir != ".":
|
||||
subfolders.add(rel_dir)
|
||||
|
||||
return web.json_response(
|
||||
{"success": True, "subfolders": sorted(subfolders)}
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Subfolders error: {e}")
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
|
||||
async def generate_thumbnails(self, request):
|
||||
"""Generate thumbnails for all images and videos in the ComfyUI output directory."""
|
||||
try:
|
||||
|
||||
@@ -113,6 +113,10 @@ class PromptRoutesMixin:
|
||||
async def bulk_set_category_route(request):
|
||||
return await self.bulk_set_category(request)
|
||||
|
||||
@routes.get("/prompt_manager/subfolders")
|
||||
async def get_subfolders_route(request):
|
||||
return await self.get_subfolders(request)
|
||||
|
||||
# Export functionality
|
||||
@routes.get("/prompt_manager/export")
|
||||
async def export_prompts_route(request):
|
||||
@@ -127,6 +131,8 @@ class PromptRoutesMixin:
|
||||
min_rating = request.query.get("min_rating", 0)
|
||||
limit = int(request.query.get("limit", 50))
|
||||
|
||||
folder = request.query.get("folder", "").strip() or None
|
||||
|
||||
tags = None
|
||||
if tags_str:
|
||||
tags = [tag.strip() for tag in tags_str.split(",") if tag.strip()]
|
||||
@@ -143,6 +149,7 @@ class PromptRoutesMixin:
|
||||
tags=tags,
|
||||
rating_min=min_rating,
|
||||
limit=limit,
|
||||
folder=folder,
|
||||
)
|
||||
self._enrich_prompt_images(results)
|
||||
|
||||
@@ -157,6 +164,20 @@ class PromptRoutesMixin:
|
||||
status=500,
|
||||
)
|
||||
|
||||
async def get_subfolders(self, request):
|
||||
"""Get distinct subfolder values derived from generated image paths."""
|
||||
try:
|
||||
from ..config import GalleryConfig
|
||||
|
||||
root_dirs = list(GalleryConfig.MONITORING_DIRECTORIES) or None
|
||||
subfolders = await self._run_in_executor(
|
||||
self.db.get_prompt_subfolders, root_dirs
|
||||
)
|
||||
return web.json_response({"success": True, "subfolders": subfolders})
|
||||
except Exception as e:
|
||||
self.logger.error(f"Subfolders error: {e}")
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
|
||||
async def get_recent_prompts(self, request):
|
||||
"""Retrieve recently created prompts with pagination support."""
|
||||
try:
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "promptmanager"
|
||||
description = "A powerful ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, and an automatic image gallery system using SQLite."
|
||||
version = "3.1.6"
|
||||
version = "3.2.0"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = ["# Core dependencies for PromptManager", "# Note: Most dependencies are already included with ComfyUI", "# Already included with Python standard library:", "# - sqlite3", "# - hashlib", "# - json", "# - datetime", "# - os", "# - typing", "# - threading", "# - uuid", "# Required for gallery functionality:", "watchdog>=2.1.0 # For file system monitoring", "Pillow>=8.0.0 # For image metadata extraction (usually included with ComfyUI)", "# Optional dependencies for enhanced search functionality:", "# fuzzywuzzy[speedup]>=0.18.0 # For fuzzy string matching (optional)", "# sqlalchemy>=1.4.0 # For advanced ORM features (optional)", "# Development dependencies (optional):", "# pytest>=6.0.0 # For running tests", "# black>=22.0.0 # For code formatting", "# flake8>=4.0.0 # For linting", "# mypy>=0.910 # For type checking"]
|
||||
|
||||
|
||||
+66
-5
@@ -78,7 +78,7 @@
|
||||
<div class="px-4 py-3">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="bg-pm-surface rounded-pm-lg p-4 border border-pm">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-3 mb-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-7 gap-3 mb-3">
|
||||
<div class="lg:col-span-2">
|
||||
<label class="block text-xs font-medium text-pm-secondary mb-1 uppercase tracking-wide">Search Text</label>
|
||||
<input type="text" id="searchText" placeholder="Search prompt content..."
|
||||
@@ -91,6 +91,13 @@
|
||||
<option value="">All Categories</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-pm-secondary mb-1 uppercase tracking-wide">Folder</label>
|
||||
<select id="searchFolder"
|
||||
class="w-full px-2.5 py-1.5 bg-pm-input border border-pm rounded-pm-sm text-pm text-[13px] focus:outline-none">
|
||||
<option value="">All Folders</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-pm-secondary mb-1 uppercase tracking-wide">Tags</label>
|
||||
<input type="text" id="searchTags" placeholder="tag1, tag2..."
|
||||
@@ -353,10 +360,18 @@
|
||||
<h4 class="text-xs font-semibold text-pm-secondary mb-3 uppercase tracking-wide">Gallery Settings</h4>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="block text-xs font-medium text-pm-secondary mb-1">Image Scan Directory</label>
|
||||
<input type="text" id="galleryRootPath" placeholder="Leave empty for auto-detect"
|
||||
class="w-full px-2.5 py-1.5 bg-pm-input border border-pm rounded-pm-sm text-pm text-[13px] focus:outline-none">
|
||||
<p class="text-xs text-pm-muted mt-1">Custom path to scan for images. Leave empty to use ComfyUI's default output directory.</p>
|
||||
<label class="block text-xs font-medium text-pm-secondary mb-1">Image Scan Directories</label>
|
||||
<div id="scanPathsList" class="space-y-2">
|
||||
<!-- Populated dynamically by JS -->
|
||||
</div>
|
||||
<button id="addScanPathBtn" type="button"
|
||||
class="mt-2 flex items-center gap-1 text-xs text-pm-accent hover:text-pm-accent-hover">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
Add Directory
|
||||
</button>
|
||||
<p class="text-xs text-pm-muted mt-1">Directories to scan for images. Leave empty to use ComfyUI default output directory.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
@@ -1224,6 +1239,52 @@ No sentences, no commentary, no captions. Only tags. Keep length short but descr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Update Notice Modal -->
|
||||
<div id="updateNoticeModal" class="fixed inset-0 bg-black/60 backdrop-blur-[2px] hidden items-center justify-center z-50">
|
||||
<div class="bg-pm-surface rounded-pm-lg p-4 max-w-lg w-full mx-4 border border-pm shadow-pm">
|
||||
<div class="flex items-start gap-3 mb-3">
|
||||
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-pm-warning-tint flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-pm-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-pm">Image Rescan Required</h3>
|
||||
<p class="text-xs text-pm-secondary mt-0.5">Multi-directory support update</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-pm-warning-tint border border-pm-warning/40 rounded-pm-md p-3 mb-3">
|
||||
<p class="text-pm text-xs leading-relaxed">
|
||||
This update changes how images are stored and organized. To ensure your gallery works correctly
|
||||
with the new multi-directory support, you need to <strong>rescan your images</strong>.
|
||||
</p>
|
||||
<ul class="text-pm text-xs mt-2 space-y-1 list-disc list-inside text-pm-secondary">
|
||||
<li>Folder information will be added to existing image records</li>
|
||||
<li>Your prompts and tags will not be affected</li>
|
||||
<li>The scan may take a few minutes depending on your library size</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" id="dismissUpdateNotice" class="rounded border-pm bg-pm-input text-pm-accent focus:ring-pm-accent w-3.5 h-3.5">
|
||||
<span class="text-xs text-pm-muted">Don't show this again</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button id="updateNoticeDismiss"
|
||||
class="px-3 py-1.5 bg-pm-surface border border-pm hover:bg-pm-hover text-pm text-xs font-medium rounded-pm-sm transition-colors">
|
||||
Dismiss
|
||||
</button>
|
||||
<button id="updateNoticeScan"
|
||||
class="px-3 py-1.5 bg-pm-accent hover:bg-pm-accent-hover text-pm-accent-fg text-xs font-medium rounded-pm-sm transition-colors">
|
||||
Scan Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto Tag Download Modal -->
|
||||
<div id="autoTagDownloadModal" class="fixed inset-0 bg-black/60 backdrop-blur-[2px] hidden items-center justify-center z-50">
|
||||
<div class="bg-pm-surface rounded-pm-lg p-4 max-w-md w-full mx-4 border border-pm shadow-pm">
|
||||
|
||||
+120
-6
@@ -8,6 +8,7 @@
|
||||
};
|
||||
this.categories = [];
|
||||
this.tags = [];
|
||||
this.subfolders = [];
|
||||
this.imageViewMode = 'fit'; // 'fit' or 'full'
|
||||
this.naturalImageSize = { width: 0, height: 0 };
|
||||
|
||||
@@ -28,6 +29,7 @@
|
||||
this.bindEvents();
|
||||
this.initRouter();
|
||||
this.loadInitialData();
|
||||
this.checkUpdateNotice();
|
||||
}
|
||||
|
||||
initRouter() {
|
||||
@@ -108,7 +110,7 @@
|
||||
this.bindModalEvents();
|
||||
|
||||
// Auto-search on filter changes
|
||||
["searchCategory"].forEach((id) => {
|
||||
["searchCategory", "searchFolder"].forEach((id) => {
|
||||
document.getElementById(id).addEventListener("change", () => this.search());
|
||||
});
|
||||
|
||||
@@ -121,6 +123,7 @@
|
||||
document.getElementById("saveSettings").addEventListener("click", () => this.saveSettings());
|
||||
document.getElementById("cancelSettings").addEventListener("click", () => this.hideModal("settingsModal"));
|
||||
document.getElementById("refreshMonitoringStatus").addEventListener("click", () => this.updateMonitoringStatus());
|
||||
document.getElementById("addScanPathBtn").addEventListener("click", () => this.addScanPath());
|
||||
|
||||
// Bulk tag modal
|
||||
document.getElementById("confirmBulkTag").addEventListener("click", () => this.confirmBulkTag());
|
||||
@@ -212,6 +215,7 @@
|
||||
await Promise.all([
|
||||
this.loadStatistics(),
|
||||
this.loadCategories(),
|
||||
this.loadSubfolders(),
|
||||
this.loadTags(),
|
||||
this.loadRecentPrompts(),
|
||||
this.loadSettings(),
|
||||
@@ -230,8 +234,12 @@
|
||||
if (data.success && data.settings) {
|
||||
this.settings.resultTimeout = data.settings.result_timeout || 5;
|
||||
this.settings.webuiDisplayMode = data.settings.webui_display_mode || 'popup';
|
||||
this.settings.galleryRootPath = data.settings.gallery_root_path || '';
|
||||
this.settings.monitoredDirectories = data.settings.monitored_directories || [];
|
||||
this.settings.galleryRootPaths = data.settings.gallery_root_paths || [];
|
||||
// Backward compat: if server only returned old field
|
||||
if (!this.settings.galleryRootPaths.length && data.settings.gallery_root_path) {
|
||||
this.settings.galleryRootPaths = [data.settings.gallery_root_path];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -310,6 +318,38 @@
|
||||
});
|
||||
}
|
||||
|
||||
async loadSubfolders() {
|
||||
try {
|
||||
const response = await fetch("/prompt_manager/subfolders");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
this.subfolders = data.subfolders;
|
||||
this.populateFolderDropdown();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Subfolders error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
populateFolderDropdown() {
|
||||
const select = document.getElementById("searchFolder");
|
||||
const current = select.value;
|
||||
select.textContent = '';
|
||||
const defaultOpt = document.createElement("option");
|
||||
defaultOpt.value = "";
|
||||
defaultOpt.textContent = "All Folders";
|
||||
select.appendChild(defaultOpt);
|
||||
this.subfolders.forEach((folder) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = folder;
|
||||
option.textContent = folder;
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.value = current;
|
||||
}
|
||||
|
||||
async loadRecentPrompts(page = 1) {
|
||||
try {
|
||||
this.pagination.currentPage = page;
|
||||
@@ -377,6 +417,8 @@
|
||||
if (searchText) params.append("text", searchText);
|
||||
if (category) params.append("category", category);
|
||||
if (tags) params.append("tags", tags);
|
||||
const folder = document.getElementById("searchFolder").value;
|
||||
if (folder) params.append("folder", folder);
|
||||
params.append("limit", "100");
|
||||
|
||||
const response = await fetch(`/prompt_manager/search?${params}`);
|
||||
@@ -687,10 +729,31 @@
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
|
||||
checkUpdateNotice() {
|
||||
const NOTICE_VERSION = "3.2.0";
|
||||
const dismissed = localStorage.getItem("pm_update_notice_dismissed");
|
||||
if (dismissed === NOTICE_VERSION) return;
|
||||
|
||||
this.showModal("updateNoticeModal");
|
||||
|
||||
document.getElementById("updateNoticeDismiss").addEventListener("click", () => {
|
||||
if (document.getElementById("dismissUpdateNotice").checked) {
|
||||
localStorage.setItem("pm_update_notice_dismissed", NOTICE_VERSION);
|
||||
}
|
||||
this.hideModal("updateNoticeModal");
|
||||
});
|
||||
|
||||
document.getElementById("updateNoticeScan").addEventListener("click", () => {
|
||||
localStorage.setItem("pm_update_notice_dismissed", NOTICE_VERSION);
|
||||
this.hideModal("updateNoticeModal");
|
||||
this.showScanModal();
|
||||
});
|
||||
}
|
||||
|
||||
showSettingsModal() {
|
||||
document.getElementById("resultTimeout").value = this.settings.resultTimeout;
|
||||
document.getElementById("webuiDisplayMode").value = this.settings.webuiDisplayMode;
|
||||
document.getElementById("galleryRootPath").value = this.settings.galleryRootPath || '';
|
||||
this.renderScanPaths();
|
||||
this.updateMonitoringStatus();
|
||||
this.showModal("settingsModal");
|
||||
}
|
||||
@@ -716,11 +779,11 @@
|
||||
async saveSettings() {
|
||||
const timeout = parseInt(document.getElementById("resultTimeout").value);
|
||||
const displayMode = document.getElementById("webuiDisplayMode").value;
|
||||
const galleryPath = document.getElementById("galleryRootPath").value.trim();
|
||||
const galleryPaths = this._collectScanPaths().filter(p => p !== '');
|
||||
|
||||
this.settings.resultTimeout = timeout;
|
||||
this.settings.webuiDisplayMode = displayMode;
|
||||
this.settings.galleryRootPath = galleryPath;
|
||||
this.settings.galleryRootPaths = galleryPaths;
|
||||
|
||||
try {
|
||||
const response = await fetch("/prompt_manager/settings", {
|
||||
@@ -729,7 +792,7 @@
|
||||
body: JSON.stringify({
|
||||
result_timeout: timeout,
|
||||
webui_display_mode: displayMode,
|
||||
gallery_root_path: galleryPath
|
||||
gallery_root_paths: galleryPaths
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -749,6 +812,57 @@
|
||||
}
|
||||
}
|
||||
|
||||
renderScanPaths() {
|
||||
const container = document.getElementById("scanPathsList");
|
||||
const paths = this.settings.galleryRootPaths || [];
|
||||
container.textContent = ''; // Clear safely
|
||||
|
||||
const toRender = paths.length > 0 ? paths : [''];
|
||||
toRender.forEach((p, i) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'flex gap-2 items-center';
|
||||
row.dataset.pathIndex = i;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = p;
|
||||
input.placeholder = 'Leave empty for auto-detect';
|
||||
input.className = 'flex-1 px-2.5 py-1.5 bg-pm-input border border-pm rounded-pm-sm text-pm text-[13px] focus:outline-none scan-path-input';
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.className = 'text-pm-muted hover:text-red-400 p-1 text-lg leading-none';
|
||||
removeBtn.title = 'Remove';
|
||||
removeBtn.textContent = '\u00d7'; // multiplication sign as X
|
||||
removeBtn.addEventListener('click', () => this.removeScanPath(i));
|
||||
|
||||
row.appendChild(input);
|
||||
row.appendChild(removeBtn);
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
addScanPath() {
|
||||
const paths = this._collectScanPaths();
|
||||
paths.push('');
|
||||
this.settings.galleryRootPaths = paths;
|
||||
this.renderScanPaths();
|
||||
const inputs = document.querySelectorAll('.scan-path-input');
|
||||
if (inputs.length) inputs[inputs.length - 1].focus();
|
||||
}
|
||||
|
||||
removeScanPath(index) {
|
||||
const paths = this._collectScanPaths();
|
||||
paths.splice(index, 1);
|
||||
this.settings.galleryRootPaths = paths;
|
||||
this.renderScanPaths();
|
||||
}
|
||||
|
||||
_collectScanPaths() {
|
||||
return Array.from(document.querySelectorAll('.scan-path-input'))
|
||||
.map(input => input.value.trim());
|
||||
}
|
||||
|
||||
toggleSelectAll(checked) {
|
||||
document.querySelectorAll(".prompt-checkbox").forEach((checkbox) => {
|
||||
checkbox.checked = checked;
|
||||
|
||||
Reference in New Issue
Block a user