+20
-10
@@ -552,6 +552,7 @@ class PromptDatabase:
|
||||
old_name = old_name.strip()
|
||||
new_name = new_name.strip()
|
||||
affected = 0
|
||||
skipped = 0
|
||||
|
||||
with self.model.get_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
@@ -571,12 +572,14 @@ class PromptDatabase:
|
||||
(json.dumps(tags), datetime.datetime.now(datetime.timezone.utc).isoformat(), row['id'])
|
||||
)
|
||||
affected += 1
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
skipped += 1
|
||||
self.logger.warning(f"Skipped prompt {row['id']} during tag rename: {e}")
|
||||
continue
|
||||
conn.commit()
|
||||
|
||||
self.logger.info(f"Renamed tag '{old_name}' -> '{new_name}' in {affected} prompts")
|
||||
return {'success': True, 'affected_count': affected}
|
||||
self.logger.info(f"Renamed tag '{old_name}' -> '{new_name}' in {affected} prompts (skipped {skipped})")
|
||||
return {'success': True, 'affected_count': affected, 'skipped_count': skipped}
|
||||
|
||||
def delete_tag_all_prompts(self, tag_name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -593,6 +596,7 @@ class PromptDatabase:
|
||||
|
||||
tag_name = tag_name.strip()
|
||||
affected = 0
|
||||
skipped = 0
|
||||
|
||||
with self.model.get_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
@@ -610,12 +614,14 @@ class PromptDatabase:
|
||||
(json.dumps(tags), datetime.datetime.now(datetime.timezone.utc).isoformat(), row['id'])
|
||||
)
|
||||
affected += 1
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
skipped += 1
|
||||
self.logger.warning(f"Skipped prompt {row['id']} during tag delete: {e}")
|
||||
continue
|
||||
conn.commit()
|
||||
|
||||
self.logger.info(f"Deleted tag '{tag_name}' from {affected} prompts")
|
||||
return {'success': True, 'affected_count': affected}
|
||||
self.logger.info(f"Deleted tag '{tag_name}' from {affected} prompts (skipped {skipped})")
|
||||
return {'success': True, 'affected_count': affected, 'skipped_count': skipped}
|
||||
|
||||
def merge_tags(self, source_tags: List[str], target_tag: str) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -637,6 +643,7 @@ class PromptDatabase:
|
||||
source_tags = [t.strip() for t in source_tags if t.strip()]
|
||||
affected = 0
|
||||
tags_merged = 0
|
||||
skipped = 0
|
||||
|
||||
with self.model.get_connection() as conn:
|
||||
for src_tag in source_tags:
|
||||
@@ -658,15 +665,17 @@ class PromptDatabase:
|
||||
(json.dumps(tags), datetime.datetime.now(datetime.timezone.utc).isoformat(), row['id'])
|
||||
)
|
||||
src_affected += 1
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
skipped += 1
|
||||
self.logger.warning(f"Skipped prompt {row['id']} during tag merge: {e}")
|
||||
continue
|
||||
if src_affected > 0:
|
||||
tags_merged += 1
|
||||
affected += src_affected
|
||||
conn.commit()
|
||||
|
||||
self.logger.info(f"Merged {tags_merged} tags into '{target_tag}', affected {affected} prompts")
|
||||
return {'success': True, 'affected_count': affected, 'tags_merged': tags_merged}
|
||||
self.logger.info(f"Merged {tags_merged} tags into '{target_tag}', affected {affected} prompts (skipped {skipped})")
|
||||
return {'success': True, 'affected_count': affected, 'tags_merged': tags_merged, 'skipped_count': skipped}
|
||||
|
||||
def get_untagged_prompts_count(self) -> int:
|
||||
"""
|
||||
@@ -1085,7 +1094,8 @@ class PromptDatabase:
|
||||
"""
|
||||
SELECT gi.*, p.text as prompt_text, p.tags as prompt_tags
|
||||
FROM generated_images gi
|
||||
LEFT JOIN prompts p ON gi.prompt_id = p.id
|
||||
INNER JOIN prompts p ON gi.prompt_id = p.id
|
||||
WHERE gi.image_path IS NOT NULL AND gi.image_path != ''
|
||||
ORDER BY gi.generation_time DESC
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -820,8 +820,13 @@ class PromptManagerAPI:
|
||||
async def get_tags_stats(self, request):
|
||||
"""Get tags with usage counts, search, sort, and pagination."""
|
||||
try:
|
||||
limit = int(request.query.get("limit", 50))
|
||||
offset = int(request.query.get("offset", 0))
|
||||
try:
|
||||
limit = int(request.query.get("limit", 50))
|
||||
offset = int(request.query.get("offset", 0))
|
||||
except (ValueError, TypeError):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid limit or offset parameter"}, status=400
|
||||
)
|
||||
search = request.query.get("search", "").strip() or None
|
||||
sort = request.query.get("sort", "alpha_asc")
|
||||
|
||||
@@ -895,8 +900,13 @@ class PromptManagerAPI:
|
||||
status=400,
|
||||
)
|
||||
|
||||
limit = int(request.query.get("limit", 20))
|
||||
offset = int(request.query.get("offset", 0))
|
||||
try:
|
||||
limit = int(request.query.get("limit", 20))
|
||||
offset = int(request.query.get("offset", 0))
|
||||
except (ValueError, TypeError):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid limit or offset parameter"}, status=400
|
||||
)
|
||||
|
||||
result = self.db.get_prompts_by_tags([tag_name], 'and', limit, offset)
|
||||
self._enrich_prompt_images(result['prompts'])
|
||||
@@ -925,8 +935,13 @@ class PromptManagerAPI:
|
||||
untagged = request.query.get("untagged", "").lower() == "true"
|
||||
|
||||
if untagged:
|
||||
limit = int(request.query.get("limit", 20))
|
||||
offset = int(request.query.get("offset", 0))
|
||||
try:
|
||||
limit = int(request.query.get("limit", 20))
|
||||
offset = int(request.query.get("offset", 0))
|
||||
except (ValueError, TypeError):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid limit or offset parameter"}, status=400
|
||||
)
|
||||
result = self.db.get_untagged_prompts(limit, offset)
|
||||
self._enrich_prompt_images(result['prompts'])
|
||||
return web.json_response({
|
||||
@@ -954,8 +969,13 @@ class PromptManagerAPI:
|
||||
if mode not in ("and", "or"):
|
||||
mode = "and"
|
||||
|
||||
limit = int(request.query.get("limit", 20))
|
||||
offset = int(request.query.get("offset", 0))
|
||||
try:
|
||||
limit = int(request.query.get("limit", 20))
|
||||
offset = int(request.query.get("offset", 0))
|
||||
except (ValueError, TypeError):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid limit or offset parameter"}, status=400
|
||||
)
|
||||
|
||||
result = self.db.get_prompts_by_tags(tags_list, mode, limit, offset)
|
||||
self._enrich_prompt_images(result['prompts'])
|
||||
@@ -989,7 +1009,12 @@ class PromptManagerAPI:
|
||||
{"success": False, "error": "Tag name required"}, status=400
|
||||
)
|
||||
|
||||
body = await request.json()
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid JSON body"}, status=400
|
||||
)
|
||||
new_name = body.get("new_name", "").strip()
|
||||
if not new_name:
|
||||
return web.json_response(
|
||||
@@ -997,12 +1022,16 @@ class PromptManagerAPI:
|
||||
)
|
||||
|
||||
result = self.db.rename_tag_all_prompts(tag_name, new_name)
|
||||
return web.json_response({
|
||||
resp = {
|
||||
"success": True,
|
||||
"old_name": tag_name,
|
||||
"new_name": new_name,
|
||||
"affected_count": result['affected_count']
|
||||
})
|
||||
}
|
||||
if result.get('skipped_count', 0) > 0:
|
||||
resp['skipped_count'] = result['skipped_count']
|
||||
resp['warning'] = f"{result['skipped_count']} prompt(s) had corrupted tag data and were skipped"
|
||||
return web.json_response(resp)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Rename tag error: {e}", exc_info=True)
|
||||
return web.json_response(
|
||||
@@ -1020,11 +1049,15 @@ class PromptManagerAPI:
|
||||
)
|
||||
|
||||
result = self.db.delete_tag_all_prompts(tag_name)
|
||||
return web.json_response({
|
||||
resp = {
|
||||
"success": True,
|
||||
"tag_name": tag_name,
|
||||
"affected_count": result['affected_count']
|
||||
})
|
||||
}
|
||||
if result.get('skipped_count', 0) > 0:
|
||||
resp['skipped_count'] = result['skipped_count']
|
||||
resp['warning'] = f"{result['skipped_count']} prompt(s) had corrupted tag data and were skipped"
|
||||
return web.json_response(resp)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Delete tag error: {e}", exc_info=True)
|
||||
return web.json_response(
|
||||
@@ -1034,7 +1067,12 @@ class PromptManagerAPI:
|
||||
async def merge_tags_endpoint(self, request):
|
||||
"""Merge source tags into a target tag."""
|
||||
try:
|
||||
body = await request.json()
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid JSON body"}, status=400
|
||||
)
|
||||
source_tags = body.get("source_tags", [])
|
||||
target_tag = body.get("target_tag", "").strip()
|
||||
|
||||
@@ -1048,12 +1086,16 @@ class PromptManagerAPI:
|
||||
)
|
||||
|
||||
result = self.db.merge_tags(source_tags, target_tag)
|
||||
return web.json_response({
|
||||
resp = {
|
||||
"success": True,
|
||||
"target_tag": target_tag,
|
||||
"affected_count": result['affected_count'],
|
||||
"tags_merged": result['tags_merged']
|
||||
})
|
||||
}
|
||||
if result.get('skipped_count', 0) > 0:
|
||||
resp['skipped_count'] = result['skipped_count']
|
||||
resp['warning'] = f"{result['skipped_count']} prompt(s) had corrupted tag data and were skipped"
|
||||
return web.json_response(resp)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Merge tags error: {e}", exc_info=True)
|
||||
return web.json_response(
|
||||
@@ -4878,6 +4920,10 @@ class PromptManagerAPI:
|
||||
tagged = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
tagged_prompt_ids = set() # Track prompts already tagged this run
|
||||
|
||||
import time as _time
|
||||
last_update_time = _time.monotonic()
|
||||
|
||||
for i, image_data in enumerate(images):
|
||||
image_path = image_data.get('image_path')
|
||||
@@ -4887,9 +4933,26 @@ class PromptManagerAPI:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Skip if we already tagged this prompt during this run
|
||||
if prompt_id in tagged_prompt_ids:
|
||||
skipped += 1
|
||||
now = _time.monotonic()
|
||||
if (now - last_update_time) >= 0.5 or i == total_files - 1:
|
||||
progress = 10 + int((i + 1) / total_files * 85)
|
||||
yield f"data: {json.dumps({'type': 'progress', 'progress': progress, 'status': f'Skipping {i+1}/{total_files} (prompt already processed)...', 'processed': processed, 'tagged': tagged, 'skipped': skipped})}\n\n"
|
||||
await asyncio.sleep(0.01)
|
||||
last_update_time = now
|
||||
continue
|
||||
|
||||
# Check if file exists
|
||||
if not Path(image_path).exists():
|
||||
skipped += 1
|
||||
now = _time.monotonic()
|
||||
if (now - last_update_time) >= 0.5 or i == total_files - 1:
|
||||
progress = 10 + int((i + 1) / total_files * 85)
|
||||
yield f"data: {json.dumps({'type': 'progress', 'progress': progress, 'status': f'Skipping {i+1}/{total_files} (file missing)...', 'processed': processed, 'tagged': tagged, 'skipped': skipped})}\n\n"
|
||||
await asyncio.sleep(0.01)
|
||||
last_update_time = now
|
||||
continue
|
||||
|
||||
# Check if image already has real tags (skip_tagged option)
|
||||
@@ -4900,11 +4963,14 @@ class PromptManagerAPI:
|
||||
# Filter out "auto-scanned" - it's not a real tag
|
||||
real_tags = [t for t in prompt_tags if t != 'auto-scanned']
|
||||
if real_tags:
|
||||
tagged_prompt_ids.add(prompt_id) # Don't re-check other images for this prompt
|
||||
skipped += 1
|
||||
if i % 5 == 0 or i == total_files - 1:
|
||||
now = _time.monotonic()
|
||||
if (now - last_update_time) >= 0.5 or i == total_files - 1:
|
||||
progress = 10 + int((i + 1) / total_files * 85)
|
||||
yield f"data: {json.dumps({'type': 'progress', 'progress': progress, 'status': f'Skipping {i+1}/{total_files} (already tagged)...', 'processed': processed, 'tagged': tagged, 'skipped': skipped})}\n\n"
|
||||
await asyncio.sleep(0.01)
|
||||
last_update_time = now
|
||||
continue
|
||||
|
||||
try:
|
||||
@@ -4917,7 +4983,7 @@ class PromptManagerAPI:
|
||||
processed += 1
|
||||
|
||||
if tags:
|
||||
# Get existing prompt
|
||||
# Get existing prompt (live read, not snapshot)
|
||||
existing_prompt = self.db.get_prompt_by_id(prompt_id)
|
||||
if existing_prompt:
|
||||
existing_tags = existing_prompt.get('tags', [])
|
||||
@@ -4939,10 +5005,14 @@ class PromptManagerAPI:
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
if i % 5 == 0 or i == total_files - 1:
|
||||
progress = 10 + int((i + 1) / total_files * 85)
|
||||
yield f"data: {json.dumps({'type': 'progress', 'progress': progress, 'status': f'Processing {i+1}/{total_files}...', 'processed': processed, 'tagged': tagged, 'skipped': skipped})}\n\n"
|
||||
await asyncio.sleep(0.01)
|
||||
# Mark this prompt as done so other images for it are skipped
|
||||
tagged_prompt_ids.add(prompt_id)
|
||||
|
||||
# Always send progress after LLM inference (each call is slow)
|
||||
progress = 10 + int((i + 1) / total_files * 85)
|
||||
yield f"data: {json.dumps({'type': 'progress', 'progress': progress, 'status': f'Processing {i+1}/{total_files}...', 'processed': processed, 'tagged': tagged, 'skipped': skipped})}\n\n"
|
||||
await asyncio.sleep(0.01)
|
||||
last_update_time = _time.monotonic()
|
||||
|
||||
except Exception as img_err:
|
||||
self.logger.error(f"Error processing {image_path}: {img_err}")
|
||||
|
||||
+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.0.31"
|
||||
version = "3.0.32"
|
||||
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"]
|
||||
|
||||
|
||||
@@ -15,8 +15,7 @@ Key features:
|
||||
The integration works by:
|
||||
1. PromptManager nodes register their prompts during execution
|
||||
2. SaveImage node is patched to include registered prompts in metadata
|
||||
3. PromptManager class_type is changed to CLIPTextEncode for parser compatibility
|
||||
4. Standard tools can then extract prompts from the generated images
|
||||
3. Standard tools can then extract prompts from the generated images
|
||||
|
||||
Typical usage:
|
||||
from utils.comfyui_integration import get_comfyui_integration
|
||||
@@ -61,7 +60,6 @@ class ComfyUIMetadataIntegration:
|
||||
Key responsibilities:
|
||||
- Register prompts from PromptManager nodes during execution
|
||||
- Patch SaveImage to include PromptManager prompts in metadata
|
||||
- Convert PromptManager class_type to CLIPTextEncode for compatibility
|
||||
- Manage prompt lifecycle and cleanup
|
||||
"""
|
||||
|
||||
@@ -184,15 +182,16 @@ class ComfyUIMetadataIntegration:
|
||||
|
||||
This method modifies ComfyUI's SaveImage.save_images method to automatically
|
||||
include PromptManager prompts in the image metadata. The patching:
|
||||
|
||||
|
||||
1. Wraps the original save_images method
|
||||
2. Retrieves current PromptManager prompt text
|
||||
3. Modifies the workflow data to include the prompt
|
||||
4. Changes PromptManager class_type to CLIPTextEncode for compatibility
|
||||
5. Calls the original method with modified data
|
||||
|
||||
The patching is designed to be minimally invasive and maintain full
|
||||
compatibility with existing ComfyUI functionality.
|
||||
3. Updates the text input in PromptManager nodes to reflect actual prompt
|
||||
4. Calls the original method with the updated data
|
||||
|
||||
NOTE: We intentionally do NOT change class_type to CLIPTextEncode anymore.
|
||||
That approach was corrupting saved workflows - when users saved and reloaded
|
||||
workflows, ComfyUI would instantiate CLIPTextEncode instead of PromptManager,
|
||||
causing errors with prepend_text/append_text inputs.
|
||||
"""
|
||||
try:
|
||||
import nodes
|
||||
@@ -222,41 +221,18 @@ class ComfyUIMetadataIntegration:
|
||||
if not isinstance(prompt, dict):
|
||||
prompt = {}
|
||||
|
||||
# Find PromptManager nodes and fix them for standard parser compatibility
|
||||
prompt_updated = False
|
||||
# Find PromptManager nodes and ensure prompt text is captured
|
||||
# NOTE: We do NOT change class_type anymore - that was corrupting saved workflows
|
||||
# when users reload them. PromptManager stays as PromptManager.
|
||||
for node_id, node_data in prompt.items():
|
||||
if isinstance(node_data, dict):
|
||||
class_type = node_data.get('class_type', '')
|
||||
if 'promptmanager' in class_type.lower():
|
||||
# Update the inputs to include our actual prompt text
|
||||
# Ensure the text input reflects the actual prompt used
|
||||
if 'inputs' not in node_data:
|
||||
node_data['inputs'] = {}
|
||||
node_data['inputs']['text'] = current_prompt_text
|
||||
|
||||
# SIMPLE FIX: Change class_type to CLIPTextEncode for standard parser compatibility
|
||||
# Keep original class_type in metadata for reference
|
||||
if '_meta' not in node_data:
|
||||
node_data['_meta'] = {}
|
||||
node_data['_meta']['original_class_type'] = class_type
|
||||
node_data['class_type'] = 'CLIPTextEncode'
|
||||
|
||||
prompt_updated = True
|
||||
integration.logger.debug(f"Fixed PromptManager node {node_id} - changed class_type to CLIPTextEncode for compatibility")
|
||||
|
||||
# If no PromptManager nodes found, add a standalone one
|
||||
if not prompt_updated:
|
||||
virtual_node_id = "promptmanager_text"
|
||||
prompt[virtual_node_id] = {
|
||||
"class_type": "CLIPTextEncode", # Use CLIPTextEncode for compatibility
|
||||
"inputs": {
|
||||
"text": current_prompt_text
|
||||
},
|
||||
"_meta": {
|
||||
"original_class_type": "PromptManager",
|
||||
"virtual": True
|
||||
}
|
||||
}
|
||||
integration.logger.debug("Added standalone CLIPTextEncode node with PromptManager text")
|
||||
integration.logger.debug(f"Updated PromptManager node {node_id} with prompt text")
|
||||
|
||||
# Call original method with potentially modified prompt
|
||||
return original_save_images(self_node, images, filename_prefix, prompt, extra_pnginfo)
|
||||
|
||||
+15
-4
@@ -461,8 +461,10 @@
|
||||
<!-- Tag List -->
|
||||
<div id="tagsListContainer" class="flex-1 overflow-y-auto custom-scrollbar">
|
||||
<div id="tagsList" class="p-2 space-y-1"></div>
|
||||
<div id="tagsLoader" class="p-4 text-center hidden">
|
||||
<div class="w-6 h-6 border-2 border-green-500/30 border-t-green-500 rounded-full animate-spin mx-auto"></div>
|
||||
<div id="tagsLoader" class="text-center">
|
||||
<div class="tags-loader-spinner p-4 hidden">
|
||||
<div class="w-6 h-6 border-2 border-green-500/30 border-t-green-500 rounded-full animate-spin mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -480,8 +482,13 @@
|
||||
<!-- Prompts Grid -->
|
||||
<div id="tagPromptsContainer" class="flex-1 overflow-y-auto custom-scrollbar">
|
||||
<div id="tagPromptsGrid" class="p-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"></div>
|
||||
<div id="tagPromptsLoader" class="p-4 text-center hidden">
|
||||
<div class="w-6 h-6 border-2 border-green-500/30 border-t-green-500 rounded-full animate-spin mx-auto"></div>
|
||||
<div id="tagPromptsLoader" class="text-center">
|
||||
<div class="prompts-loader-spinner p-4 hidden">
|
||||
<div class="w-6 h-6 border-2 border-green-500/30 border-t-green-500 rounded-full animate-spin mx-auto"></div>
|
||||
</div>
|
||||
<button id="loadMorePromptsBtn" class="hidden px-4 py-2 my-3 text-sm text-gray-400 hover:text-green-400 border border-gray-700 hover:border-green-500/50 rounded-lg transition-colors">
|
||||
Load more prompts
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Empty State -->
|
||||
@@ -1487,6 +1494,10 @@ No sentences, no commentary, no captions. Only tags. Keep length short but descr
|
||||
const tagsPage = document.getElementById('tagsPageView');
|
||||
if (dashboard) dashboard.classList.remove('hidden');
|
||||
if (tagsPage) tagsPage.classList.add('hidden');
|
||||
if (this.tagsPage) {
|
||||
this.tagsPage.destroy();
|
||||
this.tagsPage = null;
|
||||
}
|
||||
}
|
||||
|
||||
showTagsPage() {
|
||||
|
||||
+180
-44
@@ -40,6 +40,10 @@ class TagsPageManager {
|
||||
// Context menu state
|
||||
this.contextMenuTag = null;
|
||||
|
||||
// Document listener refs for cleanup
|
||||
this._onDocClick = null;
|
||||
this._onDocKeydown = null;
|
||||
|
||||
this.bindEvents();
|
||||
this.initInfiniteScroll();
|
||||
this.buildContextMenu();
|
||||
@@ -105,8 +109,7 @@ class TagsPageManager {
|
||||
});
|
||||
if (this.tagSearch) params.set('search', this.tagSearch);
|
||||
|
||||
const resp = await fetch(`/prompt_manager/tags/stats?${params}`);
|
||||
const data = await resp.json();
|
||||
const data = await this.fetchJson(`/prompt_manager/tags/stats?${params}`);
|
||||
|
||||
if (data.success) {
|
||||
this.tags = data.tags;
|
||||
@@ -127,8 +130,17 @@ class TagsPageManager {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load tags:', err);
|
||||
const container = document.getElementById('tagsList');
|
||||
if (container) {
|
||||
container.replaceChildren();
|
||||
const errDiv = document.createElement('div');
|
||||
errDiv.className = 'text-center py-8 text-red-400 text-sm';
|
||||
errDiv.textContent = 'Failed to load tags. Try refreshing.';
|
||||
container.appendChild(errDiv);
|
||||
}
|
||||
} finally {
|
||||
this.isLoadingTags = false;
|
||||
requestAnimationFrame(() => this.refreshTagObserver());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,8 +148,8 @@ class TagsPageManager {
|
||||
if (this.isLoadingTags || !this.hasMoreTags) return;
|
||||
this.isLoadingTags = true;
|
||||
|
||||
const loader = document.getElementById('tagsLoader');
|
||||
if (loader) loader.classList.remove('hidden');
|
||||
const spinner = document.querySelector('#tagsLoader .tags-loader-spinner');
|
||||
if (spinner) spinner.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
@@ -147,24 +159,37 @@ class TagsPageManager {
|
||||
});
|
||||
if (this.tagSearch) params.set('search', this.tagSearch);
|
||||
|
||||
const resp = await fetch(`/prompt_manager/tags/stats?${params}`);
|
||||
const data = await resp.json();
|
||||
const data = await this.fetchJson(`/prompt_manager/tags/stats?${params}`);
|
||||
|
||||
if (data.success) {
|
||||
this.tags = this.tags.concat(data.tags);
|
||||
const newTags = data.tags;
|
||||
this.tags = this.tags.concat(newTags);
|
||||
this.hasMoreTags = data.pagination.has_more;
|
||||
this.tagOffset += this.tagLimit;
|
||||
this.maxTagCount = Math.max(1, ...this.tags.map(t => t.count));
|
||||
this.renderTagsList();
|
||||
this.appendTagElements(newTags);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load more tags:', err);
|
||||
this.hasMoreTags = false;
|
||||
} finally {
|
||||
this.isLoadingTags = false;
|
||||
if (loader) loader.classList.add('hidden');
|
||||
if (spinner) spinner.classList.add('hidden');
|
||||
requestAnimationFrame(() => this.refreshTagObserver());
|
||||
}
|
||||
}
|
||||
|
||||
appendTagElements(newTags) {
|
||||
const container = document.getElementById('tagsList');
|
||||
if (!container || newTags.length === 0) return;
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
newTags.forEach(tag => {
|
||||
fragment.appendChild(this.createTagElement(tag));
|
||||
});
|
||||
container.appendChild(fragment);
|
||||
}
|
||||
|
||||
renderTagsList() {
|
||||
const container = document.getElementById('tagsList');
|
||||
if (!container) return;
|
||||
@@ -287,13 +312,15 @@ class TagsPageManager {
|
||||
});
|
||||
menu.replaceChildren(fragment);
|
||||
|
||||
// Close on click outside
|
||||
document.addEventListener('click', (e) => {
|
||||
// Close on click outside (store refs for cleanup)
|
||||
this._onDocClick = (e) => {
|
||||
if (!menu.contains(e.target)) this.hideContextMenu();
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
};
|
||||
this._onDocKeydown = (e) => {
|
||||
if (e.key === 'Escape') this.hideContextMenu();
|
||||
});
|
||||
};
|
||||
document.addEventListener('click', this._onDocClick);
|
||||
document.addEventListener('keydown', this._onDocKeydown);
|
||||
}
|
||||
|
||||
showContextMenu(e, tagName) {
|
||||
@@ -329,14 +356,15 @@ class TagsPageManager {
|
||||
if (!newName || newName.trim() === '' || newName.trim() === tagName) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/prompt_manager/tags/${encodeURIComponent(tagName)}`, {
|
||||
const data = await this.fetchJson(`/prompt_manager/tags/${encodeURIComponent(tagName)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ new_name: newName.trim() })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
this.admin.showNotification(`Renamed "${tagName}" → "${newName.trim()}" (${data.affected_count} prompts)`, 'success');
|
||||
let msg = `Renamed "${tagName}" → "${newName.trim()}" (${data.affected_count} prompts)`;
|
||||
if (data.warning) msg += ` — ${data.warning}`;
|
||||
this.admin.showNotification(msg, data.warning ? 'warning' : 'success');
|
||||
// Update selectedTags if the renamed tag was selected
|
||||
const idx = this.selectedTags.indexOf(tagName);
|
||||
if (idx >= 0) this.selectedTags[idx] = newName.trim();
|
||||
@@ -357,12 +385,13 @@ class TagsPageManager {
|
||||
if (!confirm(`Delete tag "${tagName}" from all prompts? This cannot be undone.`)) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/prompt_manager/tags/${encodeURIComponent(tagName)}`, {
|
||||
const data = await this.fetchJson(`/prompt_manager/tags/${encodeURIComponent(tagName)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
this.admin.showNotification(`Deleted tag "${tagName}" from ${data.affected_count} prompts`, 'success');
|
||||
let msg = `Deleted tag "${tagName}" from ${data.affected_count} prompts`;
|
||||
if (data.warning) msg += ` — ${data.warning}`;
|
||||
this.admin.showNotification(msg, data.warning ? 'warning' : 'success');
|
||||
this.selectedTags = this.selectedTags.filter(t => t !== tagName);
|
||||
this.updateUrlHash();
|
||||
this.loadTagsList();
|
||||
@@ -387,17 +416,15 @@ class TagsPageManager {
|
||||
if (!targetTag || targetTag.trim() === '' || targetTag.trim() === tagName) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/prompt_manager/tags/merge', {
|
||||
const data = await this.fetchJson('/prompt_manager/tags/merge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ source_tags: [tagName], target_tag: targetTag.trim() })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
this.admin.showNotification(
|
||||
`Merged "${tagName}" → "${targetTag.trim()}" (${data.affected_count} prompts)`,
|
||||
'success'
|
||||
);
|
||||
let msg = `Merged "${tagName}" → "${targetTag.trim()}" (${data.affected_count} prompts)`;
|
||||
if (data.warning) msg += ` — ${data.warning}`;
|
||||
this.admin.showNotification(msg, data.warning ? 'warning' : 'success');
|
||||
this.selectedTags = this.selectedTags.filter(t => t !== tagName);
|
||||
this.updateUrlHash();
|
||||
this.loadTagsList();
|
||||
@@ -426,6 +453,15 @@ class TagsPageManager {
|
||||
this.loadTagPrompts();
|
||||
}
|
||||
|
||||
scrollToTag(name) {
|
||||
const container = document.getElementById('tagsListContainer');
|
||||
if (!container) return;
|
||||
const tagEl = container.querySelector(`[data-tag-name="${CSS.escape(name)}"]`);
|
||||
if (tagEl) {
|
||||
tagEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
toggleTagFilter(name) {
|
||||
const idx = this.selectedTags.indexOf(name);
|
||||
if (idx >= 0) {
|
||||
@@ -585,8 +621,7 @@ class TagsPageManager {
|
||||
url = `/prompt_manager/tags/filter?${params}`;
|
||||
}
|
||||
|
||||
const resp = await fetch(url);
|
||||
const data = await resp.json();
|
||||
const data = await this.fetchJson(url);
|
||||
|
||||
if (data.success) {
|
||||
this.prompts = data.prompts;
|
||||
@@ -595,6 +630,8 @@ class TagsPageManager {
|
||||
this.promptOffset = this.promptLimit;
|
||||
this.renderTagPrompts();
|
||||
this.updatePromptCountBadge();
|
||||
this.updateLoadMoreButton();
|
||||
requestAnimationFrame(() => this.refreshPromptObserver());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load tag prompts:', err);
|
||||
@@ -614,8 +651,8 @@ class TagsPageManager {
|
||||
if (this.isLoadingPrompts || !this.hasMorePrompts) return;
|
||||
this.isLoadingPrompts = true;
|
||||
|
||||
const loader = document.getElementById('tagPromptsLoader');
|
||||
if (loader) loader.classList.remove('hidden');
|
||||
const spinner = document.querySelector('#tagPromptsLoader .prompts-loader-spinner');
|
||||
if (spinner) spinner.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
@@ -637,33 +674,77 @@ class TagsPageManager {
|
||||
url = `/prompt_manager/tags/filter?${params}`;
|
||||
}
|
||||
|
||||
const resp = await fetch(url);
|
||||
const data = await resp.json();
|
||||
const data = await this.fetchJson(url);
|
||||
|
||||
if (data.success) {
|
||||
const startIndex = this.prompts.length;
|
||||
this.prompts = this.prompts.concat(data.prompts);
|
||||
this.hasMorePrompts = data.pagination.has_more;
|
||||
this.promptOffset += this.promptLimit;
|
||||
this.renderTagPrompts();
|
||||
this.appendTagPrompts(data.prompts, startIndex);
|
||||
this.updatePromptCountBadge();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load more prompts:', err);
|
||||
this.hasMorePrompts = false;
|
||||
} finally {
|
||||
this.isLoadingPrompts = false;
|
||||
if (loader) loader.classList.add('hidden');
|
||||
if (spinner) spinner.classList.add('hidden');
|
||||
this.updateLoadMoreButton();
|
||||
requestAnimationFrame(() => this.refreshPromptObserver());
|
||||
}
|
||||
}
|
||||
|
||||
updatePromptCountBadge() {
|
||||
const badge = document.getElementById('tagPromptsCountBadge');
|
||||
if (badge) {
|
||||
badge.textContent = this.promptTotal > 0
|
||||
? `${this.prompts.length} of ${this.promptTotal} prompts`
|
||||
: '';
|
||||
if (this.promptTotal > 0) {
|
||||
const remaining = this.promptTotal - this.prompts.length;
|
||||
badge.textContent = remaining > 0
|
||||
? `${this.prompts.length} of ${this.promptTotal} prompts — scroll for more`
|
||||
: `${this.promptTotal} prompts`;
|
||||
} else {
|
||||
badge.textContent = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateLoadMoreButton() {
|
||||
const btn = document.getElementById('loadMorePromptsBtn');
|
||||
if (!btn) return;
|
||||
if (this.hasMorePrompts) {
|
||||
const remaining = this.promptTotal - this.prompts.length;
|
||||
btn.textContent = `Load more prompts (${remaining} remaining)`;
|
||||
btn.classList.remove('hidden');
|
||||
} else {
|
||||
btn.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
appendTagPrompts(newPrompts, startIndex) {
|
||||
const grid = document.getElementById('tagPromptsGrid');
|
||||
if (!grid || newPrompts.length === 0) return;
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
newPrompts.forEach((p, i) => {
|
||||
const card = this.createPromptCard(p);
|
||||
card.style.opacity = '0';
|
||||
card.style.transform = 'translateY(8px)';
|
||||
card.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
|
||||
card.style.transitionDelay = (i % 9) * 30 + 'ms';
|
||||
fragment.appendChild(card);
|
||||
});
|
||||
grid.appendChild(fragment);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const cards = grid.querySelectorAll('.tag-prompt-card');
|
||||
for (let i = startIndex; i < cards.length; i++) {
|
||||
cards[i].style.opacity = '1';
|
||||
cards[i].style.transform = 'translateY(0)';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderTagPrompts() {
|
||||
const grid = document.getElementById('tagPromptsGrid');
|
||||
if (!grid) return;
|
||||
@@ -810,15 +891,23 @@ class TagsPageManager {
|
||||
|
||||
card.appendChild(metaRow);
|
||||
|
||||
// Tags
|
||||
// Tags (clickable — selects tag on left panel)
|
||||
if (prompt.tags && prompt.tags.length > 0) {
|
||||
const tagsDiv = document.createElement('div');
|
||||
tagsDiv.className = 'flex flex-wrap gap-1 mt-2';
|
||||
prompt.tags.forEach(tag => {
|
||||
prompt.tags.forEach(tagName => {
|
||||
const tagSpan = document.createElement('span');
|
||||
const isActive = this.selectedTags.includes(tag);
|
||||
tagSpan.className = `px-2 py-0.5 rounded text-xs ${isActive ? 'bg-green-600 text-white' : 'bg-gray-700 text-gray-300'}`;
|
||||
tagSpan.textContent = tag;
|
||||
const isActive = this.selectedTags.includes(tagName);
|
||||
tagSpan.className = `px-2 py-0.5 rounded text-xs cursor-pointer transition-colors ${
|
||||
isActive
|
||||
? 'bg-green-600 text-white hover:bg-green-500'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-green-600/40 hover:text-green-200'
|
||||
}`;
|
||||
tagSpan.textContent = tagName;
|
||||
tagSpan.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleTagFilter(tagName);
|
||||
});
|
||||
tagsDiv.appendChild(tagSpan);
|
||||
});
|
||||
card.appendChild(tagsDiv);
|
||||
@@ -836,13 +925,16 @@ class TagsPageManager {
|
||||
// If there might be more images, load them all
|
||||
if (prompt.image_count > images.length) {
|
||||
try {
|
||||
const resp = await fetch(`/prompt_manager/prompts/${prompt.id}/images`);
|
||||
const data = await resp.json();
|
||||
const data = await this.fetchJson(`/prompt_manager/prompts/${prompt.id}/images`);
|
||||
if (data.success && data.images) {
|
||||
images = data.images;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load all images:', err);
|
||||
this.admin.showNotification(
|
||||
`Showing ${images.length} of ${prompt.image_count} images — full load failed`,
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -897,8 +989,10 @@ class TagsPageManager {
|
||||
clearPrompts() {
|
||||
this.prompts = [];
|
||||
this.promptTotal = 0;
|
||||
this.hasMorePrompts = false;
|
||||
const grid = document.getElementById('tagPromptsGrid');
|
||||
if (grid) grid.replaceChildren();
|
||||
this.updateLoadMoreButton();
|
||||
const emptyState = document.getElementById('tagPromptsEmpty');
|
||||
if (emptyState) emptyState.classList.remove('hidden');
|
||||
this.updatePromptCountBadge();
|
||||
@@ -930,6 +1024,24 @@ class TagsPageManager {
|
||||
}
|
||||
}
|
||||
|
||||
refreshPromptObserver() {
|
||||
if (!this.promptObserver || !this.hasMorePrompts) return;
|
||||
const loader = document.getElementById('tagPromptsLoader');
|
||||
if (loader) {
|
||||
this.promptObserver.unobserve(loader);
|
||||
this.promptObserver.observe(loader);
|
||||
}
|
||||
}
|
||||
|
||||
refreshTagObserver() {
|
||||
if (!this.tagObserver || !this.hasMoreTags) return;
|
||||
const loader = document.getElementById('tagsLoader');
|
||||
if (loader) {
|
||||
this.tagObserver.unobserve(loader);
|
||||
this.tagObserver.observe(loader);
|
||||
}
|
||||
}
|
||||
|
||||
destroyInfiniteScroll() {
|
||||
if (this.tagObserver) {
|
||||
this.tagObserver.disconnect();
|
||||
@@ -970,16 +1082,40 @@ class TagsPageManager {
|
||||
const orBtn = document.getElementById('filterModeOr');
|
||||
if (andBtn) andBtn.addEventListener('click', () => this.setFilterMode('and'));
|
||||
if (orBtn) orBtn.addEventListener('click', () => this.setFilterMode('or'));
|
||||
|
||||
// Load More button (fallback for infinite scroll)
|
||||
const loadMoreBtn = document.getElementById('loadMorePromptsBtn');
|
||||
if (loadMoreBtn) {
|
||||
loadMoreBtn.addEventListener('click', () => this.loadMoreTagPrompts());
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyInfiniteScroll();
|
||||
this.hideContextMenu();
|
||||
clearTimeout(this.searchTimer);
|
||||
if (this._onDocClick) {
|
||||
document.removeEventListener('click', this._onDocClick);
|
||||
this._onDocClick = null;
|
||||
}
|
||||
if (this._onDocKeydown) {
|
||||
document.removeEventListener('keydown', this._onDocKeydown);
|
||||
this._onDocKeydown = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────
|
||||
|
||||
async fetchJson(url, options = {}) {
|
||||
const resp = await fetch(url, options);
|
||||
if (!resp.ok) {
|
||||
let errorMsg;
|
||||
try { errorMsg = (await resp.json()).error; } catch { errorMsg = resp.statusText; }
|
||||
throw new Error(`Server error ${resp.status}: ${errorMsg}`);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
getThumbnailUrl(image) {
|
||||
if (image.thumbnail_url) return image.thumbnail_url;
|
||||
if (image.url) return image.url;
|
||||
|
||||
Reference in New Issue
Block a user