Merge pull request #100 from ComfyAssets/feature/tagger

Feature/tagger
This commit is contained in:
Vito
2026-02-06 14:36:17 -08:00
committed by GitHub
6 changed files with 322 additions and 119 deletions
+20 -10
View File
@@ -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
"""
)
+92 -22
View File
@@ -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
View File
@@ -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"]
+14 -38
View File
@@ -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
View File
@@ -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
View File
@@ -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;