diff --git a/assets/default_tag_library.json b/assets/default_tag_library.json new file mode 100644 index 0000000..241ed19 --- /dev/null +++ b/assets/default_tag_library.json @@ -0,0 +1,323 @@ +{ + "version": "1.0", + "description": "Default tag library for SageUtils Prompt Builder", + "categories": [ + { + "id": "quality", + "name": "Quality Tags", + "description": "Tags for image quality and detail", + "color": "#4a9eff", + "order": 0, + "tags": [ + "masterpiece", + "best quality", + "high quality", + "absurdres", + "detailed", + "extremely detailed", + "intricate details", + "sharp focus", + "crisp", + "clear", + "perfect", + "flawless", + "stunning", + "beautiful", + "gorgeous" + ], + "sets": [ + { + "id": "pony_quality", + "name": "Pony Quality", + "description": "Standard quality tags for Pony models", + "tags": ["score_9", "score_8_up", "score_7_up", "score_6_up", "score_5_up", "score_4_up"] + }, + { + "id": "anime_quality", + "name": "Anime Quality", + "description": "Quality tags for anime/manga style", + "tags": ["masterpiece", "best quality", "high quality", "absurdres"] + }, + { + "id": "realistic_quality", + "name": "Realistic Quality", + "description": "Quality tags for realistic/photographic style", + "tags": ["photorealistic", "hyperrealistic", "sharp focus", "detailed"] + } + ] + }, + { + "id": "pony_source", + "name": "Pony Source", + "description": "Source Tags for Pony v6", + "tags": [ + "source_pony", + "source_furry", + "source_anime", + "source_cartoon", + "source_3d", + "source_western", + "source_comic", + "source_monster" + ] + }, + { + "id": "pony_rating", + "name": "Pony Rating", + "description": "Rating Tags for Pony v6", + "tags": [ + "rating_safe", + "rating_questionable", + "rating_explicit" + ] + }, + { + "id": "style", + "name": "Art Style", + "description": "Artistic styles and mediums", + "color": "#ff6b6b", + "order": 1, + "tags": [ + "anime", + "manga", + "realistic", + "semi-realistic", + "cartoon", + "digital art", + "oil painting", + "watercolor", + "pencil drawing", + "concept art", + "illustration", + "photorealistic", + "3d render", + "pixel art", + "vector art", + "sketch", + "painting", + "photography" + ], + "sets": [ + { + "id": "anime_styles", + "name": "Anime Styles", + "description": "Common anime and manga styles", + "tags": ["anime", "manga", "cel shading", "flat colors"] + }, + { + "id": "realistic_styles", + "name": "Realistic Styles", + "description": "Photographic and realistic rendering styles", + "tags": ["photorealistic", "hyperrealistic", "photography", "cinematic"] + }, + { + "id": "artistic_mediums", + "name": "Artistic Mediums", + "description": "Traditional and digital art mediums", + "tags": ["oil painting", "watercolor", "digital art", "pencil drawing"] + } + ] + }, + { + "id": "lighting", + "name": "Lighting", + "description": "Lighting and atmosphere tags", + "color": "#ffd93d", + "order": 2, + "tags": [ + "soft lighting", + "dramatic lighting", + "natural lighting", + "golden hour", + "blue hour", + "sunset", + "sunrise", + "rim lighting", + "backlighting", + "studio lighting", + "neon lighting", + "ambient lighting", + "moody lighting", + "cinematic lighting", + "volumetric lighting" + ], + "sets": [ + { + "id": "time_of_day", + "name": "Time of Day", + "description": "Time-based lighting conditions", + "tags": ["golden hour", "blue hour", "sunset", "sunrise", "midday", "twilight"] + }, + { + "id": "studio_lighting", + "name": "Studio Lighting", + "description": "Professional photography lighting setups", + "tags": ["studio lighting", "soft lighting", "rim lighting", "key lighting"] + } + ] + }, + { + "id": "composition", + "name": "Composition", + "description": "Camera angles and framing", + "color": "#6bcf7f", + "order": 3, + "tags": [ + "close-up", + "medium shot", + "full body", + "portrait", + "landscape", + "wide shot", + "overhead view", + "low angle", + "high angle", + "dutch angle", + "rule of thirds", + "centered", + "dynamic angle", + "from behind", + "from side", + "front view" + ], + "sets": [ + { + "id": "portrait_shots", + "name": "Portrait Shots", + "description": "Character and portrait framing", + "tags": ["close-up", "portrait", "headshot", "upper body"] + }, + { + "id": "camera_angles", + "name": "Camera Angles", + "description": "Various camera angle perspectives", + "tags": ["low angle", "high angle", "dutch angle", "overhead view"] + } + ] + }, + { + "id": "character", + "name": "Character", + "description": "Character-related tags", + "color": "#ff9ff3", + "order": 4, + "tags": [ + "1girl", + "1boy", + "solo", + "multiple girls", + "multiple boys", + "young", + "adult", + "elderly", + "child", + "teenager", + "mature", + "cute", + "beautiful", + "handsome", + "elegant" + ], + "sets": [ + { + "id": "character_count", + "name": "Character Count", + "description": "Number and gender of characters", + "tags": ["1girl", "1boy", "solo", "multiple girls", "multiple boys"] + }, + { + "id": "age_groups", + "name": "Age Groups", + "description": "Character age descriptions", + "tags": ["young", "adult", "mature", "elderly", "child", "teenager"] + } + ] + }, + { + "id": "colors", + "name": "Colors", + "description": "Color-related tags", + "color": "#9c88ff", + "order": 5, + "tags": [ + "red", + "blue", + "green", + "yellow", + "purple", + "orange", + "pink", + "black", + "white", + "gray", + "brown", + "colorful", + "monochrome", + "sepia", + "vibrant colors", + "pastel colors" + ], + "sets": [ + { + "id": "primary_colors", + "name": "Primary Colors", + "description": "Basic color palette", + "tags": ["red", "blue", "yellow", "green", "purple", "orange"] + }, + { + "id": "color_styles", + "name": "Color Styles", + "description": "Color treatment and mood", + "tags": ["colorful", "monochrome", "vibrant colors", "pastel colors", "sepia"] + } + ] + }, + { + "id": "mood", + "name": "Mood & Atmosphere", + "description": "Emotional and atmospheric tags", + "color": "#ff6b9d", + "order": 6, + "tags": [ + "peaceful", + "serene", + "dramatic", + "mysterious", + "romantic", + "melancholic", + "joyful", + "energetic", + "calm", + "intense", + "dreamy", + "ethereal", + "dark", + "bright", + "cozy", + "epic" + ], + "sets": [ + { + "id": "positive_moods", + "name": "Positive Moods", + "description": "Uplifting and positive atmospheres", + "tags": ["joyful", "peaceful", "bright", "cozy", "romantic"] + }, + { + "id": "dramatic_moods", + "name": "Dramatic Moods", + "description": "Intense and dramatic atmospheres", + "tags": ["dramatic", "mysterious", "intense", "epic", "dark"] + } + ] + } + ], + "metadata": { + "created": "2025-09-15", + "version": "1.0", + "total_categories": 7, + "total_tags": 0, + "total_sets": 0, + "description": "Comprehensive default tag library for AI image generation prompt building" + } +} diff --git a/js/components/promptBuilder/promptBuilderApi.js b/js/components/promptBuilder/promptBuilderApi.js new file mode 100644 index 0000000..edef9ce --- /dev/null +++ b/js/components/promptBuilder/promptBuilderApi.js @@ -0,0 +1,235 @@ +/** + * Prompt Builder API Module + * Handles communication with the server for prompt builder functionality + */ + +import { api } from "../../../../scripts/api.js"; +import { generateWildcardPrompt } from "../../shared/wildcardApi.js"; + +/** + * Prompt Builder API class for managing server communication + */ +export class PromptBuilderApi { + constructor() { + this.cache = new Map(); + this.cacheTimeout = 5 * 60 * 1000; // 5 minutes + } + + /** + * Generate a prompt using the wildcard system + * @param {string} prompt - The prompt text containing wildcards + * @param {number} seed - The random seed for generation + * @returns {Promise<{success: boolean, result?: string, error?: string}>} + */ + async generatePrompt(prompt, seed = 0) { + try { + console.debug('Generating prompt:', { prompt, seed }); + + // Use the existing wildcard API + const result = await generateWildcardPrompt(prompt, seed); + + if (result.success) { + console.debug('Prompt generated successfully:', result.result); + return { + success: true, + result: result.result, + originalPrompt: result.original_prompt, + seed: result.seed + }; + } else { + console.error('Failed to generate prompt:', result.error); + return { + success: false, + error: result.error || 'Unknown error occurred' + }; + } + } catch (error) { + console.error('Error in generatePrompt:', error); + return { + success: false, + error: error.message || 'Network error occurred' + }; + } + } + + /** + * Generate multiple prompts with incremented seeds + * @param {string} positivePrompt - The positive prompt text + * @param {string} negativePrompt - The negative prompt text + * @param {number} baseSeed - The base seed for generation + * @param {number} count - Number of prompts to generate + * @returns {Promise} - Array of generated prompt objects + */ + async generateMultiplePrompts(positivePrompt, negativePrompt, baseSeed, count) { + const results = []; + + try { + for (let i = 0; i < count; i++) { + const currentSeed = baseSeed + i; + + // Generate positive prompt + const positiveResult = positivePrompt.trim() + ? await this.generatePrompt(positivePrompt, currentSeed) + : { success: true, result: '', seed: currentSeed }; + + // Generate negative prompt + const negativeResult = negativePrompt.trim() + ? await this.generatePrompt(negativePrompt, currentSeed) + : { success: true, result: '', seed: currentSeed }; + + if (positiveResult.success && negativeResult.success) { + results.push({ + id: `prompt_${Date.now()}_${i}`, + seed: currentSeed, + positive: positiveResult.result || '', + negative: negativeResult.result || '', + originalPositive: positivePrompt, + originalNegative: negativePrompt, + timestamp: new Date().toISOString() + }); + } else { + // Log error but continue with other prompts + console.error(`Failed to generate prompt ${i + 1}:`, { + positive: positiveResult.error, + negative: negativeResult.error + }); + + results.push({ + id: `prompt_${Date.now()}_${i}`, + seed: currentSeed, + positive: positiveResult.result || '', + negative: negativeResult.result || '', + originalPositive: positivePrompt, + originalNegative: negativePrompt, + timestamp: new Date().toISOString(), + error: positiveResult.error || negativeResult.error + }); + } + } + } catch (error) { + console.error('Error in generateMultiplePrompts:', error); + throw error; + } + + return results; + } + + /** + * Generate a random seed + * @returns {number} - Random seed value + */ + generateRandomSeed() { + return Math.floor(Math.random() * 2147483647); + } + + /** + * Validate a prompt for wildcard syntax + * @param {string} prompt - The prompt to validate + * @returns {Object} - Validation result with wildcards found + */ + validatePrompt(prompt) { + if (!prompt || typeof prompt !== 'string') { + return { + isValid: true, + wildcards: [], + hasWildcards: false + }; + } + + // Extract wildcard patterns + const wildcardPattern = /__([^_]+)__/g; + const wildcards = []; + let match; + + while ((match = wildcardPattern.exec(prompt)) !== null) { + const wildcardName = match[1]; + if (!wildcards.includes(wildcardName)) { + wildcards.push(wildcardName); + } + } + + return { + isValid: true, + wildcards, + hasWildcards: wildcards.length > 0, + wildcardCount: wildcards.length + }; + } + + /** + * Format prompt for display + * @param {string} prompt - The prompt to format + * @param {number} maxLength - Maximum length before truncation + * @returns {string} - Formatted prompt + */ + formatPromptPreview(prompt, maxLength = 100) { + if (!prompt) return ''; + + if (prompt.length <= maxLength) { + return prompt; + } + + return prompt.substring(0, maxLength - 3) + '...'; + } + + /** + * Copy text to clipboard + * @param {string} text - Text to copy + * @returns {Promise} - Success status + */ + async copyToClipboard(text) { + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + return true; + } else { + // Fallback for older browsers + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-9999px'; + document.body.appendChild(textArea); + textArea.select(); + const success = document.execCommand('copy'); + document.body.removeChild(textArea); + return success; + } + } catch (error) { + console.error('Failed to copy to clipboard:', error); + return false; + } + } + + /** + * Clear API cache + */ + clearCache() { + this.cache.clear(); + } + + /** + * Get cache statistics + * @returns {Object} - Cache statistics + */ + getCacheStats() { + return { + size: this.cache.size, + timeout: this.cacheTimeout + }; + } +} + +// Export singleton instance +export const promptBuilderApi = new PromptBuilderApi(); + +// Export individual functions for convenience +export const { + generatePrompt, + generateMultiplePrompts, + generateRandomSeed, + validatePrompt, + formatPromptPreview, + copyToClipboard +} = promptBuilderApi; + +export default promptBuilderApi; diff --git a/js/components/promptBuilder/promptGeneration.js b/js/components/promptBuilder/promptGeneration.js new file mode 100644 index 0000000..97de937 --- /dev/null +++ b/js/components/promptBuilder/promptGeneration.js @@ -0,0 +1,865 @@ +/** + * Prompt Generation Component + * Handles the main prompt input, generation controls, and results display + */ + +import { promptBuilderApi } from './promptBuilderApi.js'; + +/** + * Prompt Generation Component + */ +export const promptGenerationComponent = { + // Component state + state: { + isGenerating: false, + results: [], + positivePrompt: '', + negativePrompt: '', + seed: 0, + count: 1 + }, + + // Component elements + elements: { + container: null, + positiveTextarea: null, + negativeTextarea: null, + seedInput: null, + countInput: null, + generateButton: null, + randomSeedButton: null, + resultsContainer: null, + clearButton: null, + loadingIndicator: null + }, + + /** + * Create the prompt generation component + * @returns {HTMLElement} - The component container + */ + create() { + this.elements.container = document.createElement('div'); + this.elements.container.className = 'prompt-generation-component'; + + // Create prompt input section + const inputSection = this.createInputSection(); + this.elements.container.appendChild(inputSection); + + // Create controls section + const controlsSection = this.createControlsSection(); + this.elements.container.appendChild(controlsSection); + + // Create results section + const resultsSection = this.createResultsSection(); + this.elements.container.appendChild(resultsSection); + + // Add component styles + this.addStyles(); + + // Initialize with random seed + this.generateRandomSeed(); + + return this.elements.container; + }, + + /** + * Create the prompt input section + * @returns {HTMLElement} - The input section + */ + createInputSection() { + const section = document.createElement('div'); + section.className = 'prompt-input-section'; + + // Positive prompt + const positiveGroup = document.createElement('div'); + positiveGroup.className = 'input-group'; + + const positiveLabel = document.createElement('label'); + positiveLabel.textContent = 'Positive Prompt'; + positiveLabel.className = 'input-label'; + positiveLabel.htmlFor = 'positive-prompt'; + + this.elements.positiveTextarea = document.createElement('textarea'); + this.elements.positiveTextarea.id = 'positive-prompt'; + this.elements.positiveTextarea.className = 'prompt-textarea positive-prompt'; + this.elements.positiveTextarea.placeholder = 'Enter positive prompt with __wildcards__...\n\nExample: __character__ in a __location__, __art_style__'; + this.elements.positiveTextarea.rows = 4; + + positiveGroup.appendChild(positiveLabel); + positiveGroup.appendChild(this.elements.positiveTextarea); + + // Negative prompt + const negativeGroup = document.createElement('div'); + negativeGroup.className = 'input-group'; + + const negativeLabel = document.createElement('label'); + negativeLabel.textContent = 'Negative Prompt'; + negativeLabel.className = 'input-label'; + negativeLabel.htmlFor = 'negative-prompt'; + + this.elements.negativeTextarea = document.createElement('textarea'); + this.elements.negativeTextarea.id = 'negative-prompt'; + this.elements.negativeTextarea.className = 'prompt-textarea negative-prompt'; + this.elements.negativeTextarea.placeholder = 'Enter negative prompt...'; + this.elements.negativeTextarea.rows = 3; + + negativeGroup.appendChild(negativeLabel); + negativeGroup.appendChild(this.elements.negativeTextarea); + + section.appendChild(positiveGroup); + section.appendChild(negativeGroup); + + // Add event listeners + this.elements.positiveTextarea.addEventListener('input', () => { + this.state.positivePrompt = this.elements.positiveTextarea.value; + this.updateWildcardHighlighting(this.elements.positiveTextarea); + }); + + this.elements.negativeTextarea.addEventListener('input', () => { + this.state.negativePrompt = this.elements.negativeTextarea.value; + this.updateWildcardHighlighting(this.elements.negativeTextarea); + }); + + return section; + }, + + /** + * Create the controls section + * @returns {HTMLElement} - The controls section + */ + createControlsSection() { + const section = document.createElement('div'); + section.className = 'controls-section'; + + // Seed control + const seedGroup = document.createElement('div'); + seedGroup.className = 'control-group'; + + const seedLabel = document.createElement('label'); + seedLabel.textContent = 'Seed'; + seedLabel.className = 'control-label'; + + const seedContainer = document.createElement('div'); + seedContainer.className = 'seed-container'; + + this.elements.seedInput = document.createElement('input'); + this.elements.seedInput.type = 'number'; + this.elements.seedInput.className = 'seed-input'; + this.elements.seedInput.min = '0'; + this.elements.seedInput.max = '2147483647'; + this.elements.seedInput.value = '0'; + + this.elements.randomSeedButton = document.createElement('button'); + this.elements.randomSeedButton.textContent = '🎲'; + this.elements.randomSeedButton.className = 'random-seed-button'; + this.elements.randomSeedButton.title = 'Generate random seed'; + + seedContainer.appendChild(this.elements.seedInput); + seedContainer.appendChild(this.elements.randomSeedButton); + + seedGroup.appendChild(seedLabel); + seedGroup.appendChild(seedContainer); + + // Count control + const countGroup = document.createElement('div'); + countGroup.className = 'control-group'; + + const countLabel = document.createElement('label'); + countLabel.textContent = 'Count'; + countLabel.className = 'control-label'; + + this.elements.countInput = document.createElement('input'); + this.elements.countInput.type = 'number'; + this.elements.countInput.className = 'count-input'; + this.elements.countInput.min = '1'; + this.elements.countInput.max = '10'; + this.elements.countInput.value = '1'; + + countGroup.appendChild(countLabel); + countGroup.appendChild(this.elements.countInput); + + // Action buttons + const actionGroup = document.createElement('div'); + actionGroup.className = 'action-group'; + + this.elements.generateButton = document.createElement('button'); + this.elements.generateButton.textContent = '✨ Generate Prompts'; + this.elements.generateButton.className = 'generate-button primary-button'; + + this.elements.clearButton = document.createElement('button'); + this.elements.clearButton.textContent = '🗑️ Clear Results'; + this.elements.clearButton.className = 'clear-button secondary-button'; + + actionGroup.appendChild(this.elements.generateButton); + actionGroup.appendChild(this.elements.clearButton); + + section.appendChild(seedGroup); + section.appendChild(countGroup); + section.appendChild(actionGroup); + + // Add event listeners + this.elements.seedInput.addEventListener('input', () => { + this.state.seed = parseInt(this.elements.seedInput.value) || 0; + }); + + this.elements.countInput.addEventListener('input', () => { + this.state.count = Math.max(1, Math.min(10, parseInt(this.elements.countInput.value) || 1)); + this.elements.countInput.value = this.state.count; + }); + + this.elements.randomSeedButton.addEventListener('click', () => { + this.generateRandomSeed(); + }); + + this.elements.generateButton.addEventListener('click', () => { + this.generatePrompts(); + }); + + this.elements.clearButton.addEventListener('click', () => { + this.clearResults(); + }); + + return section; + }, + + /** + * Create the results section + * @returns {HTMLElement} - The results section + */ + createResultsSection() { + const section = document.createElement('div'); + section.className = 'results-section'; + + const header = document.createElement('div'); + header.className = 'results-header'; + + const title = document.createElement('h4'); + title.textContent = 'Generated Prompts'; + title.className = 'results-title'; + + this.elements.loadingIndicator = document.createElement('div'); + this.elements.loadingIndicator.className = 'loading-indicator hidden'; + this.elements.loadingIndicator.innerHTML = '
Generating...'; + + header.appendChild(title); + header.appendChild(this.elements.loadingIndicator); + + this.elements.resultsContainer = document.createElement('div'); + this.elements.resultsContainer.className = 'results-container'; + + section.appendChild(header); + section.appendChild(this.elements.resultsContainer); + + return section; + }, + + /** + * Generate random seed + */ + generateRandomSeed() { + const randomSeed = promptBuilderApi.generateRandomSeed(); + this.state.seed = randomSeed; + this.elements.seedInput.value = randomSeed; + }, + + /** + * Generate prompts using the API + */ + async generatePrompts() { + if (this.state.isGenerating) return; + + const positivePrompt = this.elements.positiveTextarea.value.trim(); + const negativePrompt = this.elements.negativeTextarea.value.trim(); + + if (!positivePrompt && !negativePrompt) { + this.showMessage('Please enter at least one prompt (positive or negative)', 'warning'); + return; + } + + this.setGenerating(true); + + try { + const results = await promptBuilderApi.generateMultiplePrompts( + positivePrompt, + negativePrompt, + this.state.seed, + this.state.count + ); + + this.state.results = [...this.state.results, ...results]; + this.renderResults(); + this.showMessage(`Generated ${results.length} prompt(s) successfully!`, 'success'); + + } catch (error) { + console.error('Error generating prompts:', error); + this.showMessage('Failed to generate prompts. Please try again.', 'error'); + } finally { + this.setGenerating(false); + } + }, + + /** + * Set generating state + * @param {boolean} isGenerating - Whether generation is in progress + */ + setGenerating(isGenerating) { + this.state.isGenerating = isGenerating; + this.elements.generateButton.disabled = isGenerating; + this.elements.loadingIndicator.classList.toggle('hidden', !isGenerating); + + if (isGenerating) { + this.elements.generateButton.textContent = '⏳ Generating...'; + } else { + this.elements.generateButton.textContent = '✨ Generate Prompts'; + } + }, + + /** + * Clear all results + */ + clearResults() { + this.state.results = []; + this.renderResults(); + this.showMessage('Results cleared', 'info'); + }, + + /** + * Render the results + */ + renderResults() { + this.elements.resultsContainer.innerHTML = ''; + + if (this.state.results.length === 0) { + const emptyState = document.createElement('div'); + emptyState.className = 'empty-state'; + emptyState.textContent = 'No prompts generated yet. Enter prompts above and click Generate!'; + this.elements.resultsContainer.appendChild(emptyState); + return; + } + + this.state.results.forEach((result, index) => { + const resultItem = this.createResultItem(result, index); + this.elements.resultsContainer.appendChild(resultItem); + }); + }, + + /** + * Create a result item element + * @param {Object} result - The result data + * @param {number} index - The result index + * @returns {HTMLElement} - The result item element + */ + createResultItem(result, index) { + const item = document.createElement('div'); + item.className = 'result-item'; + if (result.error) { + item.classList.add('has-error'); + } + + const header = document.createElement('div'); + header.className = 'result-header'; + + const title = document.createElement('div'); + title.className = 'result-title'; + title.textContent = `Prompt ${index + 1} (Seed: ${result.seed})`; + + const actions = document.createElement('div'); + actions.className = 'result-actions'; + + // Copy positive button + if (result.positive) { + const copyPositiveBtn = document.createElement('button'); + copyPositiveBtn.textContent = '📋 Copy Positive'; + copyPositiveBtn.className = 'copy-button'; + copyPositiveBtn.addEventListener('click', () => { + this.copyResult(result.positive, 'Positive prompt copied!'); + }); + actions.appendChild(copyPositiveBtn); + } + + // Copy negative button + if (result.negative) { + const copyNegativeBtn = document.createElement('button'); + copyNegativeBtn.textContent = '📋 Copy Negative'; + copyNegativeBtn.className = 'copy-button'; + copyNegativeBtn.addEventListener('click', () => { + this.copyResult(result.negative, 'Negative prompt copied!'); + }); + actions.appendChild(copyNegativeBtn); + } + + // Load button + const loadBtn = document.createElement('button'); + loadBtn.textContent = '⬆️ Load'; + loadBtn.className = 'load-button'; + loadBtn.addEventListener('click', () => { + this.loadResult(result); + }); + actions.appendChild(loadBtn); + + header.appendChild(title); + header.appendChild(actions); + + const content = document.createElement('div'); + content.className = 'result-content'; + + if (result.positive) { + const positiveDiv = document.createElement('div'); + positiveDiv.className = 'result-text positive'; + positiveDiv.innerHTML = `Positive: ${result.positive}`; + content.appendChild(positiveDiv); + } + + if (result.negative) { + const negativeDiv = document.createElement('div'); + negativeDiv.className = 'result-text negative'; + negativeDiv.innerHTML = `Negative: ${result.negative}`; + content.appendChild(negativeDiv); + } + + if (result.error) { + const errorDiv = document.createElement('div'); + errorDiv.className = 'result-error'; + errorDiv.textContent = `Error: ${result.error}`; + content.appendChild(errorDiv); + } + + item.appendChild(header); + item.appendChild(content); + + return item; + }, + + /** + * Copy result to clipboard + * @param {string} text - Text to copy + * @param {string} message - Success message + */ + async copyResult(text, message) { + const success = await promptBuilderApi.copyToClipboard(text); + if (success) { + this.showMessage(message, 'success'); + } else { + this.showMessage('Failed to copy to clipboard', 'error'); + } + }, + + /** + * Load result back into input fields + * @param {Object} result - The result to load + */ + loadResult(result) { + if (result.positive) { + this.elements.positiveTextarea.value = result.originalPositive || result.positive; + this.state.positivePrompt = this.elements.positiveTextarea.value; + } + if (result.negative) { + this.elements.negativeTextarea.value = result.originalNegative || result.negative; + this.state.negativePrompt = this.elements.negativeTextarea.value; + } + if (result.seed) { + this.elements.seedInput.value = result.seed; + this.state.seed = result.seed; + } + this.showMessage('Prompt loaded into fields', 'success'); + }, + + /** + * Update wildcard highlighting (placeholder for future enhancement) + * @param {HTMLElement} textarea - The textarea to update + */ + updateWildcardHighlighting(textarea) { + // For now, just validate the prompt + const validation = promptBuilderApi.validatePrompt(textarea.value); + + // Update textarea title with wildcard info + if (validation.hasWildcards) { + textarea.title = `Contains ${validation.wildcardCount} wildcard(s): ${validation.wildcards.join(', ')}`; + } else { + textarea.title = ''; + } + }, + + /** + * Show a message to the user + * @param {string} message - The message to show + * @param {string} type - The message type (success, error, warning, info) + */ + showMessage(message, type = 'info') { + // For now, just log to console. In the future, this could show toast notifications + console.log(`[${type.toUpperCase()}] ${message}`); + + // You could implement a toast notification system here + // For now, we'll use a simple alert for errors + if (type === 'error') { + // Could show a non-intrusive notification instead + setTimeout(() => { + if (this.elements.container) { + const notification = document.createElement('div'); + notification.className = `notification ${type}`; + notification.textContent = message; + this.elements.container.appendChild(notification); + + setTimeout(() => { + if (notification.parentNode) { + notification.parentNode.removeChild(notification); + } + }, 3000); + } + }, 100); + } + }, + + /** + * Add component styles + */ + addStyles() { + const styleId = 'prompt-generation-styles'; + + if (document.getElementById(styleId)) return; + + const style = document.createElement('style'); + style.id = styleId; + style.textContent = ` + .prompt-generation-component { + padding: 16px; + } + + .prompt-input-section { + margin-bottom: 20px; + } + + .input-group { + margin-bottom: 16px; + } + + .input-label { + display: block; + margin-bottom: 6px; + font-weight: 500; + color: var(--fg-color, #ffffff); + } + + .prompt-textarea { + width: 100%; + padding: 12px; + border: 1px solid var(--border-color, #444); + border-radius: 6px; + background: var(--bg-color, #1a1a1a); + color: var(--fg-color, #ffffff); + font-family: monospace; + font-size: 14px; + line-height: 1.4; + resize: vertical; + min-height: 80px; + } + + .prompt-textarea:focus { + outline: none; + border-color: var(--primary-color, #4a9eff); + box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.2); + } + + .positive-prompt { + border-left: 3px solid #4caf50; + } + + .negative-prompt { + border-left: 3px solid #f44336; + } + + .controls-section { + display: flex; + gap: 16px; + margin-bottom: 20px; + flex-wrap: wrap; + align-items: end; + } + + .control-group { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 120px; + } + + .control-label { + font-weight: 500; + color: var(--fg-color, #ffffff); + font-size: 14px; + } + + .seed-container { + display: flex; + gap: 4px; + } + + .seed-input, .count-input { + padding: 8px 12px; + border: 1px solid var(--border-color, #444); + border-radius: 4px; + background: var(--bg-color, #1a1a1a); + color: var(--fg-color, #ffffff); + font-size: 14px; + width: 100px; + } + + .random-seed-button { + padding: 8px; + border: 1px solid var(--border-color, #444); + border-radius: 4px; + background: var(--bg-color-secondary, #2a2a2a); + color: var(--fg-color, #ffffff); + cursor: pointer; + font-size: 16px; + } + + .random-seed-button:hover { + background: var(--hover-color, #3a3a3a); + } + + .action-group { + display: flex; + gap: 12px; + margin-left: auto; + } + + .primary-button, .secondary-button { + padding: 10px 20px; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + } + + .primary-button { + background: var(--primary-color, #4a9eff); + color: white; + } + + .primary-button:hover:not(:disabled) { + background: var(--primary-dark, #357abd); + transform: translateY(-1px); + } + + .primary-button:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + } + + .secondary-button { + background: var(--bg-color-secondary, #2a2a2a); + color: var(--fg-color, #ffffff); + border: 1px solid var(--border-color, #444); + } + + .secondary-button:hover { + background: var(--hover-color, #3a3a3a); + } + + .results-section { + border-top: 1px solid var(--border-color, #444); + padding-top: 20px; + } + + .results-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + } + + .results-title { + margin: 0; + font-size: 16px; + color: var(--fg-color, #ffffff); + } + + .loading-indicator { + display: flex; + align-items: center; + gap: 8px; + color: var(--primary-color, #4a9eff); + font-size: 14px; + } + + .loading-indicator.hidden { + display: none; + } + + .spinner { + width: 16px; + height: 16px; + border: 2px solid transparent; + border-top: 2px solid currentColor; + border-radius: 50%; + animation: spin 1s linear infinite; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } + + .results-container { + display: flex; + flex-direction: column; + gap: 12px; + } + + .empty-state { + text-align: center; + padding: 40px; + color: var(--text-secondary, #888); + font-style: italic; + } + + .result-item { + border: 1px solid var(--border-color, #444); + border-radius: 6px; + background: var(--bg-color-secondary, #2a2a2a); + overflow: hidden; + } + + .result-item.has-error { + border-color: #f44336; + } + + .result-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--bg-color-tertiary, #1e1e1e); + border-bottom: 1px solid var(--border-color, #444); + } + + .result-title { + font-weight: 500; + color: var(--fg-color, #ffffff); + } + + .result-actions { + display: flex; + gap: 8px; + } + + .copy-button, .load-button { + padding: 4px 8px; + border: 1px solid var(--border-color, #444); + border-radius: 4px; + background: var(--bg-color, #1a1a1a); + color: var(--fg-color, #ffffff); + font-size: 12px; + cursor: pointer; + } + + .copy-button:hover, .load-button:hover { + background: var(--hover-color, #3a3a3a); + } + + .result-content { + padding: 16px; + } + + .result-text { + margin-bottom: 12px; + padding: 8px; + border-radius: 4px; + font-family: monospace; + font-size: 13px; + line-height: 1.4; + } + + .result-text.positive { + background: rgba(76, 175, 80, 0.1); + border-left: 3px solid #4caf50; + } + + .result-text.negative { + background: rgba(244, 67, 54, 0.1); + border-left: 3px solid #f44336; + } + + .result-text strong { + color: var(--fg-color, #ffffff); + } + + .result-error { + color: #f44336; + font-size: 12px; + font-style: italic; + padding: 8px; + background: rgba(244, 67, 54, 0.1); + border-radius: 4px; + } + + .notification { + position: fixed; + top: 20px; + right: 20px; + padding: 12px 16px; + border-radius: 6px; + color: white; + font-size: 14px; + z-index: 10000; + animation: slideIn 0.3s ease; + } + + .notification.error { + background: #f44336; + } + + .notification.success { + background: #4caf50; + } + + .notification.warning { + background: #ff9800; + } + + .notification.info { + background: var(--primary-color, #4a9eff); + } + + @keyframes slideIn { + from { transform: translateX(100%); } + to { transform: translateX(0); } + } + + /* Responsive design */ + @media (max-width: 768px) { + .controls-section { + flex-direction: column; + align-items: stretch; + } + + .action-group { + margin-left: 0; + justify-content: stretch; + } + + .primary-button, .secondary-button { + flex: 1; + } + + .result-header { + flex-direction: column; + align-items: start; + gap: 8px; + } + + .result-actions { + align-self: stretch; + justify-content: space-between; + } + } + `; + + document.head.appendChild(style); + } +}; + +export default promptGenerationComponent; diff --git a/js/components/promptBuilder/tagLibrary.js b/js/components/promptBuilder/tagLibrary.js new file mode 100644 index 0000000..df028b7 --- /dev/null +++ b/js/components/promptBuilder/tagLibrary.js @@ -0,0 +1,1520 @@ +/** + * Tag Library Component + * Provides UI for managing and using tag collections for prompt building + */ + +import { tagApi } from '../../shared/tagApi.js'; + +/** + * Tag Library class for managing the tag system UI + */ +export class TagLibrary { + constructor(options = {}) { + this.options = { + allowEdit: true, + showSearch: true, + compactMode: false, + onTagInsert: null, + onTagSetInsert: null, + ...options + }; + + this.tagLibrary = null; + this.activeCategory = null; + this.searchQuery = ''; + this.container = null; + + // UI elements + this.categoryTabs = null; + this.searchInput = null; + this.tagContainer = null; + this.actionBar = null; + } + + /** + * Create the tag library component + * @param {HTMLElement} container - Container element + * @returns {Promise} - Created component element + */ + async create(container) { + this.container = container; + + // Clear the container first + container.innerHTML = ''; + + // Load tag library data + await this.loadTagLibrary(); + + // Create main structure + const wrapper = document.createElement('div'); + wrapper.className = 'tag-library-wrapper'; + + // Create header with search and actions + if (this.options.showSearch || this.options.allowEdit) { + const header = this.createHeader(); + wrapper.appendChild(header); + } + + // Create category tabs + const tabsContainer = this.createCategoryTabs(); + wrapper.appendChild(tabsContainer); + + // Create content area + const contentArea = this.createContentArea(); + wrapper.appendChild(contentArea); + + // Add styles + this.addStyles(); + + // Set initial active category + if (this.tagLibrary?.categories?.length > 0) { + this.setActiveCategory(this.tagLibrary.categories[0].id); + } + + // Append to container + container.appendChild(wrapper); + + return wrapper; + } + + /** + * Load tag library from server + */ + async loadTagLibrary() { + try { + const result = await tagApi.getTagLibrary(); + if (result.success) { + this.tagLibrary = result.data; + } else { + console.error('Failed to load tag library:', result.error); + this.tagLibrary = this.getEmptyLibrary(); + } + } catch (error) { + console.error('Error loading tag library:', error); + this.tagLibrary = this.getEmptyLibrary(); + } + } + + /** + * Get empty library structure + */ + getEmptyLibrary() { + return { + version: '1.0', + categories: [], + metadata: { + total_categories: 0, + total_tags: 0, + total_sets: 0 + } + }; + } + + /** + * Create the header section + */ + createHeader() { + const header = document.createElement('div'); + header.className = 'tag-library-header'; + + if (this.options.showSearch) { + const searchContainer = document.createElement('div'); + searchContainer.className = 'search-container'; + + this.searchInput = document.createElement('input'); + this.searchInput.type = 'text'; + this.searchInput.placeholder = 'Search tags...'; + this.searchInput.className = 'tag-search-input'; + + this.searchInput.addEventListener('input', (e) => { + this.searchQuery = e.target.value; + this.updateTagDisplay(); + }); + + const searchIcon = document.createElement('span'); + searchIcon.className = 'search-icon'; + searchIcon.textContent = '🔍'; + + searchContainer.appendChild(searchIcon); + searchContainer.appendChild(this.searchInput); + header.appendChild(searchContainer); + } + + if (this.options.allowEdit) { + this.actionBar = document.createElement('div'); + this.actionBar.className = 'tag-library-actions'; + + const addCategoryBtn = this.createButton('+ Category', () => this.showAddCategoryDialog()); + addCategoryBtn.className = 'btn btn-primary btn-sm'; + + const refreshBtn = this.createButton('🔄', () => this.refreshLibrary()); + refreshBtn.className = 'btn btn-secondary btn-sm'; + refreshBtn.title = 'Refresh library'; + + const resetBtn = this.createButton('🔄 Reset to Defaults', () => this.resetToDefaults()); + resetBtn.className = 'btn btn-warning btn-sm'; + resetBtn.title = 'Reset tag library to default categories and tags'; + + const mergeBtn = this.createButton('+ Add Defaults', () => this.mergeWithDefaults()); + mergeBtn.className = 'btn btn-info btn-sm'; + mergeBtn.title = 'Add default categories and tags to current library'; + + this.actionBar.appendChild(addCategoryBtn); + this.actionBar.appendChild(refreshBtn); + this.actionBar.appendChild(resetBtn); + this.actionBar.appendChild(mergeBtn); + header.appendChild(this.actionBar); + } + + return header; + } + + /** + * Create category tabs + */ + createCategoryTabs() { + const tabsWrapper = document.createElement('div'); + tabsWrapper.className = 'category-tabs-wrapper'; + + this.categoryTabs = document.createElement('div'); + this.categoryTabs.className = 'category-tabs'; + + this.updateCategoryTabs(); + + tabsWrapper.appendChild(this.categoryTabs); + return tabsWrapper; + } + + /** + * Update category tabs display + */ + updateCategoryTabs() { + if (!this.categoryTabs) return; + + this.categoryTabs.innerHTML = ''; + + if (!this.tagLibrary?.categories?.length) { + const emptyMsg = document.createElement('div'); + emptyMsg.className = 'empty-categories-message'; + emptyMsg.textContent = 'No categories found. Click "+ Category" to add one.'; + this.categoryTabs.appendChild(emptyMsg); + return; + } + + this.tagLibrary.categories + .sort((a, b) => (a.order || 0) - (b.order || 0)) + .forEach(category => { + const tab = this.createCategoryTab(category); + this.categoryTabs.appendChild(tab); + }); + } + + /** + * Create a single category tab + */ + createCategoryTab(category) { + const tab = document.createElement('button'); + tab.className = 'category-tab'; + tab.setAttribute('data-category-id', category.id); + + if (this.activeCategory === category.id) { + tab.classList.add('active'); + } + + // Add category color if specified + if (category.color) { + tab.style.borderBottomColor = category.color; + } + + const name = document.createElement('span'); + name.className = 'category-name'; + name.textContent = category.name; + + const count = document.createElement('span'); + count.className = 'category-count'; + const tagCount = (category.tags?.length || 0) + (category.sets?.length || 0); + count.textContent = `(${tagCount})`; + + tab.appendChild(name); + tab.appendChild(count); + + tab.addEventListener('click', () => { + this.setActiveCategory(category.id); + }); + + if (this.options.allowEdit) { + tab.addEventListener('contextmenu', (e) => { + e.preventDefault(); + this.showCategoryContextMenu(category, e.clientX, e.clientY); + }); + } + + return tab; + } + + /** + * Create content area + */ + createContentArea() { + this.tagContainer = document.createElement('div'); + this.tagContainer.className = 'tag-content-area'; + + return this.tagContainer; + } + + /** + * Set active category + */ + setActiveCategory(categoryId) { + this.activeCategory = categoryId; + + // Update tab states + const tabs = this.categoryTabs?.querySelectorAll('.category-tab'); + tabs?.forEach(tab => { + tab.classList.toggle('active', tab.getAttribute('data-category-id') === categoryId); + }); + + // Update content display + this.updateTagDisplay(); + } + + /** + * Update tag display for active category + */ + updateTagDisplay() { + if (!this.tagContainer || !this.activeCategory) return; + + this.tagContainer.innerHTML = ''; + + const category = this.tagLibrary?.categories?.find(c => c.id === this.activeCategory); + if (!category) { + this.tagContainer.innerHTML = '
Category not found
'; + return; + } + + // Filter tags and sets based on search query + const filteredTags = this.filterTags(category.tags || []); + const filteredSets = this.filterTagSets(category.sets || []); + + // Create tag sets section + if (filteredSets.length > 0) { + const setsSection = this.createTagSetsSection(filteredSets, category); + this.tagContainer.appendChild(setsSection); + } + + // Create individual tags section + if (filteredTags.length > 0) { + const tagsSection = this.createTagsSection(filteredTags, category); + this.tagContainer.appendChild(tagsSection); + } + + // Show empty state if no content + if (filteredTags.length === 0 && filteredSets.length === 0) { + const emptyState = this.createEmptyState(category); + this.tagContainer.appendChild(emptyState); + } + } + + /** + * Filter tags based on search query + */ + filterTags(tags) { + if (!this.searchQuery) return tags; + const query = this.searchQuery.toLowerCase(); + return tags.filter(tag => tag.toLowerCase().includes(query)); + } + + /** + * Filter tag sets based on search query + */ + filterTagSets(sets) { + if (!this.searchQuery) return sets; + const query = this.searchQuery.toLowerCase(); + return sets.filter(set => + set.name.toLowerCase().includes(query) || + set.tags?.some(tag => tag.toLowerCase().includes(query)) + ); + } + + /** + * Create tag sets section + */ + createTagSetsSection(tagSets, category) { + const section = document.createElement('div'); + section.className = 'tag-sets-section'; + + const header = document.createElement('div'); + header.className = 'section-header'; + + const title = document.createElement('h4'); + title.textContent = '📦 Tag Sets'; + title.className = 'section-title'; + + if (this.options.allowEdit) { + const addSetBtn = this.createButton('+', () => this.showAddTagSetDialog(category)); + addSetBtn.className = 'btn btn-sm btn-outline add-set-btn'; + addSetBtn.title = 'Add tag set'; + header.appendChild(title); + header.appendChild(addSetBtn); + } else { + header.appendChild(title); + } + + section.appendChild(header); + + const setsGrid = document.createElement('div'); + setsGrid.className = 'tag-sets-grid'; + + tagSets.forEach(tagSet => { + const setElement = this.createTagSetElement(tagSet, category); + setsGrid.appendChild(setElement); + }); + + section.appendChild(setsGrid); + return section; + } + + /** + * Create individual tag set element + */ + createTagSetElement(tagSet, category) { + const setElement = document.createElement('div'); + setElement.className = 'tag-set'; + + const header = document.createElement('div'); + header.className = 'tag-set-header'; + + const name = document.createElement('span'); + name.className = 'tag-set-name'; + name.textContent = tagSet.name; + + const count = document.createElement('span'); + count.className = 'tag-set-count'; + count.textContent = `${tagSet.tags?.length || 0} tags`; + + header.appendChild(name); + header.appendChild(count); + + if (this.options.allowEdit) { + const actions = document.createElement('div'); + actions.className = 'tag-set-actions'; + + const editBtn = this.createButton('✏️', () => this.showEditTagSetDialog(tagSet, category)); + editBtn.className = 'btn btn-xs'; + editBtn.title = 'Edit tag set'; + + const deleteBtn = this.createButton('🗑️', () => this.deleteTagSet(tagSet, category)); + deleteBtn.className = 'btn btn-xs btn-danger'; + deleteBtn.title = 'Delete tag set'; + + actions.appendChild(editBtn); + actions.appendChild(deleteBtn); + header.appendChild(actions); + } + + setElement.appendChild(header); + + // Add description if available + if (tagSet.description) { + const description = document.createElement('div'); + description.className = 'tag-set-description'; + description.textContent = tagSet.description; + setElement.appendChild(description); + } + + // Add tags preview + const tagsPreview = document.createElement('div'); + tagsPreview.className = 'tag-set-preview'; + + const previewTags = (tagSet.tags || []).slice(0, 5); + previewTags.forEach(tag => { + const tagSpan = document.createElement('span'); + tagSpan.className = 'preview-tag'; + tagSpan.textContent = tag; + tagsPreview.appendChild(tagSpan); + }); + + if (tagSet.tags?.length > 5) { + const more = document.createElement('span'); + more.className = 'preview-more'; + more.textContent = `+${tagSet.tags.length - 5} more`; + tagsPreview.appendChild(more); + } + + setElement.appendChild(tagsPreview); + + // Add click handler for tag insertion + setElement.addEventListener('click', () => { + this.insertTagSet(tagSet); + }); + + return setElement; + } + + /** + * Create individual tags section + */ + createTagsSection(tags, category) { + const section = document.createElement('div'); + section.className = 'individual-tags-section'; + + const header = document.createElement('div'); + header.className = 'section-header'; + + const title = document.createElement('h4'); + title.textContent = '🏷️ Individual Tags'; + title.className = 'section-title'; + + if (this.options.allowEdit) { + const addTagBtn = this.createButton('+', () => this.showAddTagDialog(category)); + addTagBtn.className = 'btn btn-sm btn-outline add-tag-btn'; + addTagBtn.title = 'Add tag'; + header.appendChild(title); + header.appendChild(addTagBtn); + } else { + header.appendChild(title); + } + + section.appendChild(header); + + const tagsGrid = document.createElement('div'); + tagsGrid.className = 'tags-grid'; + + tags.forEach(tag => { + const tagElement = this.createTagElement(tag, category); + tagsGrid.appendChild(tagElement); + }); + + section.appendChild(tagsGrid); + return section; + } + + /** + * Create individual tag element + */ + createTagElement(tag, category) { + const tagElement = document.createElement('span'); + tagElement.className = 'individual-tag'; + tagElement.textContent = tag; + + if (this.options.allowEdit) { + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'tag-delete-btn'; + deleteBtn.textContent = '×'; + deleteBtn.title = 'Remove tag'; + deleteBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.deleteTag(tag, category); + }); + tagElement.appendChild(deleteBtn); + } + + tagElement.addEventListener('click', () => { + this.insertTag(tag); + }); + + return tagElement; + } + + /** + * Create empty state + */ + createEmptyState(category) { + const emptyState = document.createElement('div'); + emptyState.className = 'empty-state'; + + if (this.searchQuery) { + emptyState.innerHTML = ` +
🔍
+
No tags found
+
No tags match "${this.searchQuery}" in ${category.name}
+ `; + } else { + emptyState.innerHTML = ` +
🏷️
+
No tags yet
+
Add some tags to the ${category.name} category
+ `; + + if (this.options.allowEdit) { + const addBtn = this.createButton('Add First Tag', () => this.showAddTagDialog(category)); + addBtn.className = 'btn btn-primary'; + emptyState.appendChild(addBtn); + } + } + + return emptyState; + } + + /** + * Create a button element + */ + createButton(text, onClick) { + const button = document.createElement('button'); + button.textContent = text; + button.addEventListener('click', onClick); + return button; + } + + /** + * Insert a tag into the active prompt field + */ + insertTag(tag) { + if (this.options.onTagInsert) { + this.options.onTagInsert(tag); + } else { + // Default behavior - copy to clipboard + this.copyToClipboard(tag); + this.showToast(`Copied "${tag}" to clipboard`); + } + } + + /** + * Insert a tag set into the active prompt field + */ + insertTagSet(tagSet) { + const tagsText = tagSet.tags?.join(', ') || ''; + if (this.options.onTagSetInsert) { + this.options.onTagSetInsert(tagSet, tagsText); + } else if (this.options.onTagInsert) { + this.options.onTagInsert(tagsText); + } else { + // Default behavior - copy to clipboard + this.copyToClipboard(tagsText); + this.showToast(`Copied "${tagSet.name}" tag set to clipboard`); + } + } + + /** + * Copy text to clipboard + */ + async copyToClipboard(text) { + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + return true; + } else { + // Fallback for older browsers + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-9999px'; + document.body.appendChild(textArea); + textArea.select(); + const success = document.execCommand('copy'); + document.body.removeChild(textArea); + return success; + } + } catch (error) { + console.error('Failed to copy to clipboard:', error); + return false; + } + } + + /** + * Show a toast notification + */ + showToast(message, type = 'info') { + // Simple toast implementation + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.textContent = message; + + toast.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + padding: 12px 16px; + background: #333; + color: white; + border-radius: 4px; + z-index: 10000; + animation: slideIn 0.3s ease; + `; + + document.body.appendChild(toast); + + setTimeout(() => { + toast.style.animation = 'slideOut 0.3s ease'; + setTimeout(() => toast.remove(), 300); + }, 3000); + } + + /** + * Show add category dialog + */ + showAddCategoryDialog() { + // This would show a modal dialog for adding categories + // For now, use a simple prompt + const name = prompt('Enter category name:'); + if (name) { + const id = tagApi.generateId(name); + const newCategory = { + id, + name, + description: '', + color: '#4a9eff', + order: this.tagLibrary.categories.length, + tags: [], + sets: [] + }; + this.addCategory(newCategory); + } + } + + /** + * Show add tag dialog + */ + showAddTagDialog(category) { + const tag = prompt(`Add tag to "${category.name}":`); + if (tag) { + this.addTag(tag, category); + } + } + + /** + * Show add tag set dialog + */ + showAddTagSetDialog(category) { + const name = prompt(`Enter tag set name for "${category.name}":`); + if (name) { + const tags = prompt('Enter tags (comma separated):'); + if (tags) { + const tagArray = tags.split(',').map(t => t.trim()).filter(t => t); + const newSet = { + id: tagApi.generateId(name), + name, + description: '', + tags: tagArray + }; + this.addTagSet(newSet, category); + } + } + } + + /** + * Show edit tag set dialog + */ + showEditTagSetDialog(tagSet, category) { + const name = prompt(`Edit tag set name:`, tagSet.name); + if (name && name !== tagSet.name) { + tagSet.name = name; + } + + const description = prompt(`Edit description (optional):`, tagSet.description || ''); + tagSet.description = description || ''; + + const tagsText = prompt(`Edit tags (comma separated):`, tagSet.tags.join(', ')); + if (tagsText) { + const tagArray = tagsText.split(',').map(t => t.trim()).filter(t => t); + tagSet.tags = tagArray; + + // Save the updated tag set + this.updateTagSet(tagSet, category); + } + } + + /** + * Show category context menu + */ + showCategoryContextMenu(category, x, y) { + // Simple implementation using confirm dialogs + // In a real implementation, this would show a proper context menu + const action = prompt(`Category "${category.name}" actions:\n1. Edit\n2. Delete\n3. Add Tag\n4. Add Tag Set\n\nEnter number (1-4):`); + + switch(action) { + case '1': + this.showEditCategoryDialog(category); + break; + case '2': + this.deleteCategory(category); + break; + case '3': + this.showAddTagDialog(category); + break; + case '4': + this.showAddTagSetDialog(category); + break; + } + } + + /** + * Show edit category dialog + */ + showEditCategoryDialog(category) { + const name = prompt(`Edit category name:`, category.name); + if (name && name !== category.name) { + category.name = name; + category.id = tagApi.generateId(name); // Update ID based on new name + } + + const description = prompt(`Edit description (optional):`, category.description || ''); + category.description = description || ''; + + const color = prompt(`Edit color (hex code):`, category.color || '#4a9eff'); + if (color && color.match(/^#[0-9A-Fa-f]{6}$/)) { + category.color = color; + } + + // Save the updated category + this.updateCategory(category); + } + + /** + * Add a new category + */ + async addCategory(category) { + try { + const result = await tagApi.saveCategory(category); + if (result.success) { + await this.refreshLibrary(); + this.setActiveCategory(category.id); + this.showToast(`Category "${category.name}" added successfully`); + } else { + this.showToast(`Failed to add category: ${result.error}`, 'error'); + } + } catch (error) { + this.showToast(`Error adding category: ${error.message}`, 'error'); + } + } + + /** + * Add a tag to category + */ + async addTag(tag, category) { + try { + const result = await tagApi.addTagToCategory(category.id, tag); + if (result.success) { + await this.refreshLibrary(); + this.updateTagDisplay(); + this.showToast(`Tag "${tag}" added successfully`); + } else { + this.showToast(`Failed to add tag: ${result.error}`, 'error'); + } + } catch (error) { + this.showToast(`Error adding tag: ${error.message}`, 'error'); + } + } + + /** + * Add a tag set to category + */ + async addTagSet(tagSet, category) { + try { + const result = await tagApi.addTagSetToCategory(category.id, tagSet); + if (result.success) { + await this.refreshLibrary(); + this.updateTagDisplay(); + this.showToast(`Tag set "${tagSet.name}" added successfully`); + } else { + this.showToast(`Failed to add tag set: ${result.error}`, 'error'); + } + } catch (error) { + this.showToast(`Error adding tag set: ${error.message}`, 'error'); + } + } + + /** + * Delete a tag from category + */ + async deleteTag(tag, category) { + if (!confirm(`Remove "${tag}" from "${category.name}"?`)) return; + + try { + const result = await tagApi.removeTagFromCategory(category.id, tag); + if (result.success) { + await this.refreshLibrary(); + this.updateTagDisplay(); + this.showToast(`Tag "${tag}" removed successfully`); + } else { + this.showToast(`Failed to remove tag: ${result.error}`, 'error'); + } + } catch (error) { + this.showToast(`Error removing tag: ${error.message}`, 'error'); + } + } + + /** + * Delete a tag set from category + */ + async deleteTagSet(tagSet, category) { + if (!confirm(`Delete tag set "${tagSet.name}"?`)) return; + + try { + const result = await tagApi.removeTagSetFromCategory(category.id, tagSet.id); + if (result.success) { + await this.refreshLibrary(); + this.updateTagDisplay(); + this.showToast(`Tag set "${tagSet.name}" deleted successfully`); + } else { + this.showToast(`Failed to delete tag set: ${result.error}`, 'error'); + } + } catch (error) { + this.showToast(`Error deleting tag set: ${error.message}`, 'error'); + } + } + + /** + * Update a category + */ + async updateCategory(category) { + try { + const result = await tagApi.saveCategory(category); + if (result.success) { + await this.refreshLibrary(); + this.setActiveCategory(category.id); + this.showToast(`Category "${category.name}" updated successfully`); + } else { + this.showToast(`Failed to update category: ${result.error}`, 'error'); + } + } catch (error) { + this.showToast(`Error updating category: ${error.message}`, 'error'); + } + } + + /** + * Update a tag set + */ + async updateTagSet(tagSet, category) { + try { + // Remove the old tag set and add the updated one + const result = await tagApi.removeTagSetFromCategory(category.id, tagSet.id); + if (result.success) { + const addResult = await tagApi.addTagSetToCategory(category.id, tagSet); + if (addResult.success) { + await this.refreshLibrary(); + this.updateTagDisplay(); + this.showToast(`Tag set "${tagSet.name}" updated successfully`); + } else { + this.showToast(`Failed to update tag set: ${addResult.error}`, 'error'); + } + } else { + this.showToast(`Failed to update tag set: ${result.error}`, 'error'); + } + } catch (error) { + this.showToast(`Error updating tag set: ${error.message}`, 'error'); + } + } + + /** + * Delete a category + */ + async deleteCategory(category) { + if (!confirm(`Delete category "${category.name}" and all its tags and sets?`)) return; + + try { + const result = await tagApi.deleteCategory(category.id); + if (result.success) { + await this.refreshLibrary(); + // Set active category to first available or null + if (this.tagLibrary?.categories?.length > 0) { + this.setActiveCategory(this.tagLibrary.categories[0].id); + } else { + this.activeCategory = null; + this.updateTagDisplay(); + } + this.showToast(`Category "${category.name}" deleted successfully`); + } else { + this.showToast(`Failed to delete category: ${result.error}`, 'error'); + } + } catch (error) { + this.showToast(`Error deleting category: ${error.message}`, 'error'); + } + } + + /** + * Refresh library from server + */ + async refreshLibrary() { + tagApi.clearCache(); + await this.loadTagLibrary(); + this.updateCategoryTabs(); + this.updateTagDisplay(); + } + + /** + * Reset tag library to defaults + */ + async resetToDefaults() { + const confirmed = confirm( + 'This will replace your entire tag library with the default categories and tags.\n\n' + + 'All your custom categories, tags, and tag sets will be lost.\n\n' + + 'Are you sure you want to continue?' + ); + + if (!confirmed) return; + + try { + // Load default library structure from the server + const defaultLibrary = await this.loadDefaultLibrary(); + if (!defaultLibrary) { + this.showToast('Failed to load default library', 'error'); + return; + } + + // Save the default library as the current library + const result = await tagApi.saveTagLibrary(defaultLibrary); + if (result.success) { + await this.refreshLibrary(); + this.showToast('Tag library reset to defaults successfully', 'success'); + } else { + this.showToast(`Failed to reset library: ${result.error}`, 'error'); + } + } catch (error) { + this.showToast(`Error resetting library: ${error.message}`, 'error'); + } + } + + /** + * Merge current library with defaults + */ + async mergeWithDefaults() { + const confirmed = confirm( + 'This will add default categories and tags to your current library.\n\n' + + 'Existing categories with the same names will be merged with default tags.\n\n' + + 'Continue?' + ); + + if (!confirmed) return; + + try { + // Load default library structure + const defaultLibrary = await this.loadDefaultLibrary(); + if (!defaultLibrary) { + this.showToast('Failed to load default library', 'error'); + return; + } + + // Get current library + const currentResult = await tagApi.getTagLibrary(false); + if (!currentResult.success) { + this.showToast(`Failed to get current library: ${currentResult.error}`, 'error'); + return; + } + + const currentLibrary = currentResult.data; + const mergedLibrary = this.mergeLibraries(currentLibrary, defaultLibrary); + + // Save the merged library + const result = await tagApi.saveTagLibrary(mergedLibrary); + if (result.success) { + await this.refreshLibrary(); + this.showToast('Default categories and tags added successfully', 'success'); + } else { + this.showToast(`Failed to merge libraries: ${result.error}`, 'error'); + } + } catch (error) { + this.showToast(`Error merging libraries: ${error.message}`, 'error'); + } + } + + /** + * Load default library from assets + */ + async loadDefaultLibrary() { + try { + // Use the server route to get default library + const response = await fetch('/sage_utils/tags/defaults'); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const result = await response.json(); + if (result.success && result.data) { + return result.data; + } else { + throw new Error(result.error || 'Invalid response format'); + } + } catch (error) { + console.error('Error loading default library:', error); + + // Fallback: try to get a fresh library that might contain defaults + try { + // Get fresh library (should load defaults if no user library exists) + const freshResult = await tagApi.getTagLibrary(false); + if (freshResult.success && freshResult.data.categories.length > 2) { + return freshResult.data; + } + } catch (fallbackError) { + console.error('Fallback also failed:', fallbackError); + } + + return null; + } + } + + /** + * Merge two libraries, combining categories and avoiding duplicates + */ + mergeLibraries(currentLibrary, defaultLibrary) { + const merged = { + version: currentLibrary.version || defaultLibrary.version, + categories: [...currentLibrary.categories], + metadata: currentLibrary.metadata || defaultLibrary.metadata + }; + + // Process each default category + for (const defaultCategory of defaultLibrary.categories) { + const existingCategory = merged.categories.find(c => c.id === defaultCategory.id); + + if (existingCategory) { + // Merge tags (avoid duplicates) + const existingTags = new Set(existingCategory.tags || []); + for (const tag of defaultCategory.tags || []) { + existingTags.add(tag); + } + existingCategory.tags = Array.from(existingTags).sort(); + + // Merge tag sets (avoid duplicates by ID) + const existingSets = existingCategory.sets || []; + const existingSetIds = new Set(existingSets.map(s => s.id)); + + for (const defaultSet of defaultCategory.sets || []) { + if (!existingSetIds.has(defaultSet.id)) { + existingSets.push({ ...defaultSet }); + } + } + existingCategory.sets = existingSets; + + // Update other properties if not set + if (!existingCategory.description && defaultCategory.description) { + existingCategory.description = defaultCategory.description; + } + if (!existingCategory.color && defaultCategory.color) { + existingCategory.color = defaultCategory.color; + } + } else { + // Add new category (deep copy) + merged.categories.push({ + ...defaultCategory, + tags: [...(defaultCategory.tags || [])], + sets: (defaultCategory.sets || []).map(s => ({ ...s, tags: [...s.tags] })) + }); + } + } + + // Sort categories by order + merged.categories.sort((a, b) => (a.order || 0) - (b.order || 0)); + + // Update metadata + merged.metadata.total_categories = merged.categories.length; + merged.metadata.total_tags = merged.categories.reduce((sum, cat) => sum + (cat.tags?.length || 0), 0); + merged.metadata.total_sets = merged.categories.reduce((sum, cat) => sum + (cat.sets?.length || 0), 0); + + return merged; + } + + /** + * Add CSS styles for the tag library + */ + addStyles() { + const styleId = 'tag-library-styles'; + + // Remove existing styles if they exist + const existingStyles = document.getElementById(styleId); + if (existingStyles) { + existingStyles.remove(); + } + + const style = document.createElement('style'); + style.id = styleId; + style.textContent = ` + .tag-library-wrapper { + display: flex; + flex-direction: column; + height: 100%; + background: var(--bg-color-tertiary, #1e1e1e); + border-radius: 8px; + overflow: hidden; + } + + .tag-library-header { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--border-color, #444); + background: var(--bg-color-secondary, #2a2a2a); + } + + .search-container { + flex: 1; + position: relative; + display: flex; + align-items: center; + } + + .search-icon { + position: absolute; + left: 8px; + color: var(--text-secondary, #888); + pointer-events: none; + } + + .tag-search-input { + width: 100%; + padding: 6px 12px 6px 32px; + border: 1px solid var(--border-color, #444); + border-radius: 4px; + background: var(--bg-color, #1a1a1a); + color: var(--fg-color, #ffffff); + font-size: 14px; + } + + .tag-search-input:focus { + outline: none; + border-color: var(--primary-color, #4a9eff); + } + + .tag-library-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; + align-items: center; + } + + .tag-library-actions .btn { + white-space: nowrap; + min-width: 0; + } + + .category-tabs-wrapper { + border-bottom: 1px solid var(--border-color, #444); + background: var(--bg-color-secondary, #2a2a2a); + } + + .category-tabs { + display: flex; + gap: 4px; + padding: 8px 16px 0 16px; + overflow-x: auto; + } + + .category-tab { + padding: 8px 16px; + border: none; + border-bottom: 3px solid transparent; + background: none; + color: var(--text-secondary, #888); + cursor: pointer; + white-space: nowrap; + display: flex; + align-items: center; + gap: 6px; + transition: all 0.2s ease; + } + + .category-tab:hover { + color: var(--fg-color, #ffffff); + background: var(--bg-color-tertiary, #1e1e1e); + } + + .category-tab.active { + color: var(--fg-color, #ffffff); + border-bottom-color: var(--primary-color, #4a9eff); + } + + .category-count { + font-size: 12px; + opacity: 0.7; + } + + .tag-content-area { + flex: 1; + padding: 16px; + overflow-y: auto; + } + + .section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + } + + .section-title { + margin: 0; + font-size: 16px; + font-weight: 500; + color: var(--fg-color, #ffffff); + } + + .tag-sets-section, + .individual-tags-section { + margin-bottom: 24px; + } + + .tag-sets-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 12px; + } + + .tag-set { + border: 1px solid var(--border-color, #444); + border-radius: 6px; + padding: 12px; + background: var(--bg-color, #1a1a1a); + cursor: pointer; + transition: all 0.2s ease; + } + + .tag-set:hover { + border-color: var(--primary-color, #4a9eff); + background: var(--bg-color-secondary, #2a2a2a); + } + + .tag-set-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + } + + .tag-set-name { + font-weight: 500; + color: var(--fg-color, #ffffff); + } + + .tag-set-count { + font-size: 12px; + color: var(--text-secondary, #888); + } + + .tag-set-actions { + display: flex; + gap: 4px; + } + + .tag-set-description { + font-size: 12px; + color: var(--text-secondary, #888); + margin-bottom: 8px; + } + + .tag-set-preview { + display: flex; + flex-wrap: wrap; + gap: 4px; + } + + .preview-tag { + padding: 2px 6px; + background: var(--bg-color-tertiary, #1e1e1e); + border-radius: 3px; + font-size: 11px; + color: var(--text-secondary, #888); + } + + .preview-more { + font-size: 11px; + color: var(--text-secondary, #888); + font-style: italic; + } + + .tags-grid { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + + .individual-tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 10px; + background: var(--bg-color, #1a1a1a); + border: 1px solid var(--border-color, #444); + border-radius: 16px; + color: var(--fg-color, #ffffff); + cursor: pointer; + transition: all 0.2s ease; + font-size: 13px; + } + + .individual-tag:hover { + border-color: var(--primary-color, #4a9eff); + background: var(--bg-color-secondary, #2a2a2a); + } + + .tag-delete-btn { + background: none; + border: none; + color: var(--text-secondary, #888); + cursor: pointer; + padding: 0; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 14px; + line-height: 1; + } + + .tag-delete-btn:hover { + background: var(--error-color, #ff6b6b); + color: white; + } + + .empty-state { + text-align: center; + padding: 40px 20px; + color: var(--text-secondary, #888); + } + + .empty-icon { + font-size: 48px; + margin-bottom: 16px; + } + + .empty-title { + font-size: 18px; + font-weight: 500; + margin-bottom: 8px; + color: var(--fg-color, #ffffff); + } + + .empty-message { + margin-bottom: 16px; + } + + .btn { + padding: 6px 12px; + border: 1px solid var(--border-color, #444); + border-radius: 4px; + background: var(--bg-color, #1a1a1a); + color: var(--fg-color, #ffffff); + cursor: pointer; + font-size: 12px; + transition: all 0.2s ease; + } + + .btn:hover { + background: var(--bg-color-secondary, #2a2a2a); + } + + .btn-primary { + background: var(--primary-color, #4a9eff); + border-color: var(--primary-color, #4a9eff); + color: white; + } + + .btn-primary:hover { + background: var(--primary-dark, #357abd); + } + + .btn-warning { + background: var(--warning-color, #ffa726); + border-color: var(--warning-color, #ffa726); + color: white; + } + + .btn-warning:hover { + background: var(--warning-dark, #f57c00); + } + + .btn-info { + background: var(--info-color, #29b6f6); + border-color: var(--info-color, #29b6f6); + color: white; + } + + .btn-info:hover { + background: var(--info-dark, #0288d1); + } + + .btn-sm { + padding: 4px 8px; + font-size: 11px; + } + + .btn-xs { + padding: 2px 4px; + font-size: 10px; + } + + .btn-outline { + background: transparent; + } + + .btn-danger:hover { + background: var(--error-color, #ff6b6b); + border-color: var(--error-color, #ff6b6b); + } + + .toast { + animation: slideIn 0.3s ease; + } + + @keyframes slideIn { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } + } + + @keyframes slideOut { + from { transform: translateX(0); opacity: 1; } + to { transform: translateX(100%); opacity: 0; } + } + + /* Responsive design */ + @media (max-width: 768px) { + .tag-sets-grid { + grid-template-columns: 1fr; + } + + .tag-library-header { + flex-direction: column; + gap: 8px; + } + + .tag-library-actions { + justify-content: center; + gap: 4px; + } + + .tag-library-actions .btn { + font-size: 10px; + padding: 3px 6px; + } + + .category-tabs { + padding: 8px; + } + } + `; + + document.head.appendChild(style); + } + + /** + * Destroy the tag library component + */ + destroy() { + // Remove styles + const styles = document.getElementById('tag-library-styles'); + if (styles) { + styles.remove(); + } + + // Clear references + this.container = null; + this.categoryTabs = null; + this.searchInput = null; + this.tagContainer = null; + this.actionBar = null; + } +} + +// Export the tag library component +export const tagLibraryComponent = { + create: (container, options = {}) => { + const tagLibrary = new TagLibrary(options); + return tagLibrary.create(container); + }, + + TagLibrary +}; + +export default tagLibraryComponent; diff --git a/js/shared/tagApi.js b/js/shared/tagApi.js new file mode 100644 index 0000000..32425b8 --- /dev/null +++ b/js/shared/tagApi.js @@ -0,0 +1,526 @@ +/** + * Tag Management API Module + * Handles communication with the server for tag library functionality + */ + +import { api } from "../../../scripts/api.js"; + +/** + * Tag API class for managing tag library operations + */ +export class TagApi { + constructor() { + this.cache = new Map(); + this.cacheTimeout = 5 * 60 * 1000; // 5 minutes + } + + /** + * Get the complete tag library + * @param {boolean} useCache - Whether to use cached data (default: true) + * @returns {Promise<{success: boolean, data?: Object, error?: string}>} + */ + async getTagLibrary(useCache = true) { + try { + const cacheKey = 'tag_library'; + + // Check cache first + if (useCache && this.cache.has(cacheKey)) { + const cached = this.cache.get(cacheKey); + if (Date.now() - cached.timestamp < this.cacheTimeout) { + return { success: true, data: cached.data }; + } + } + + const response = await api.fetchApi('/sage_utils/tags/library'); + const result = await response.json(); + + // Handle the response format - check if data is nested + if (result.success && result.data) { + // Cache the result + this.cache.set(cacheKey, { + data: result.data, + timestamp: Date.now() + }); + + return { + success: true, + data: result.data + }; + } + + return result; + } catch (error) { + console.error('Error getting tag library:', error); + return { + success: false, + error: error.message || 'Network error occurred' + }; + } + } + + /** + * Save the complete tag library + * @param {Object} libraryData - The complete library data + * @returns {Promise<{success: boolean, data?: Object, error?: string}>} + */ + async saveTagLibrary(libraryData) { + try { + const response = await api.fetchApi('/sage_utils/tags/library', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(libraryData) + }); + + const result = await response.json(); + + // Clear cache on successful save + if (result.success) { + this.clearCache(); + } + + // Handle the response format - check if data is nested + if (result.success && result.data) { + return { + success: true, + data: result.data, + message: result.message + }; + } + + return result; + } catch (error) { + console.error('Error saving tag library:', error); + return { + success: false, + error: error.message || 'Network error occurred' + }; + } + } + + /** + * Save or update a category + * @param {Object} categoryData - The category data + * @returns {Promise<{success: boolean, data?: Object, error?: string}>} + */ + async saveCategory(categoryData) { + try { + const response = await api.fetchApi('/sage_utils/tags/category', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(categoryData) + }); + + const result = await response.json(); + + // Clear cache on successful save + if (result.success) { + this.clearCache(); + } + + // Handle the response format - check if data is nested + if (result.success && result.data) { + return { + success: true, + data: result.data, + message: result.message + }; + } + + return result; + } catch (error) { + console.error('Error saving category:', error); + return { + success: false, + error: error.message || 'Network error occurred' + }; + } + } + + /** + * Delete a category + * @param {string} categoryId - The category ID to delete + * @returns {Promise<{success: boolean, message?: string, error?: string}>} + */ + async deleteCategory(categoryId) { + try { + const response = await api.fetchApi(`/sage_utils/tags/category/${encodeURIComponent(categoryId)}`, { + method: 'DELETE' + }); + + const result = await response.json(); + + // Clear cache on successful delete + if (result.success) { + this.clearCache(); + } + + return result; + } catch (error) { + console.error('Error deleting category:', error); + return { + success: false, + error: error.message || 'Network error occurred' + }; + } + } + + /** + * Search for tags across categories + * @param {string} query - Search query + * @param {string} categoryId - Optional category ID to limit search + * @param {number} limit - Optional limit (default: 50) + * @returns {Promise<{success: boolean, data?: Object, error?: string}>} + */ + async searchTags(query, categoryId = '', limit = 50) { + try { + const params = new URLSearchParams({ + q: query, + limit: limit.toString() + }); + + if (categoryId) { + params.append('category', categoryId); + } + + const response = await api.fetchApi(`/sage_utils/tags/search?${params}`); + const result = await response.json(); + + // Handle the response format - check if data is nested + if (result.success && result.data) { + return { + success: true, + data: result.data + }; + } + + return result; + } catch (error) { + console.error('Error searching tags:', error); + return { + success: false, + error: error.message || 'Network error occurred' + }; + } + } + + /** + * Add a tag to a category + * @param {string} categoryId - Category ID + * @param {string} tag - Tag to add + * @returns {Promise<{success: boolean, data?: Object, error?: string}>} + */ + async addTagToCategory(categoryId, tag) { + try { + // First get the current library + const libraryResult = await this.getTagLibrary(false); // Don't use cache + if (!libraryResult.success) { + return libraryResult; + } + + const library = libraryResult.data; + const category = library.categories.find(c => c.id === categoryId); + + if (!category) { + return { + success: false, + error: `Category '${categoryId}' not found` + }; + } + + // Add tag if it doesn't exist + if (!category.tags.includes(tag)) { + category.tags.push(tag); + category.tags.sort(); // Keep tags sorted + + // Save the updated category + return await this.saveCategory(category); + } + + return { + success: true, + message: `Tag '${tag}' already exists in category '${category.name}'` + }; + } catch (error) { + console.error('Error adding tag to category:', error); + return { + success: false, + error: error.message || 'Network error occurred' + }; + } + } + + /** + * Remove a tag from a category + * @param {string} categoryId - Category ID + * @param {string} tag - Tag to remove + * @returns {Promise<{success: boolean, data?: Object, error?: string}>} + */ + async removeTagFromCategory(categoryId, tag) { + try { + // First get the current library + const libraryResult = await this.getTagLibrary(false); // Don't use cache + if (!libraryResult.success) { + return libraryResult; + } + + const library = libraryResult.data; + const category = library.categories.find(c => c.id === categoryId); + + if (!category) { + return { + success: false, + error: `Category '${categoryId}' not found` + }; + } + + // Remove tag if it exists + const tagIndex = category.tags.indexOf(tag); + if (tagIndex > -1) { + category.tags.splice(tagIndex, 1); + + // Save the updated category + return await this.saveCategory(category); + } + + return { + success: false, + error: `Tag '${tag}' not found in category '${category.name}'` + }; + } catch (error) { + console.error('Error removing tag from category:', error); + return { + success: false, + error: error.message || 'Network error occurred' + }; + } + } + + /** + * Add a tag set to a category + * @param {string} categoryId - Category ID + * @param {Object} tagSet - Tag set object {id, name, description, tags} + * @returns {Promise<{success: boolean, data?: Object, error?: string}>} + */ + async addTagSetToCategory(categoryId, tagSet) { + try { + // First get the current library + const libraryResult = await this.getTagLibrary(false); // Don't use cache + if (!libraryResult.success) { + return libraryResult; + } + + const library = libraryResult.data; + const category = library.categories.find(c => c.id === categoryId); + + if (!category) { + return { + success: false, + error: `Category '${categoryId}' not found` + }; + } + + // Check if set ID already exists + const existingSet = category.sets.find(s => s.id === tagSet.id); + if (existingSet) { + return { + success: false, + error: `Tag set with ID '${tagSet.id}' already exists` + }; + } + + // Add the tag set + category.sets.push(tagSet); + + // Save the updated category + return await this.saveCategory(category); + } catch (error) { + console.error('Error adding tag set to category:', error); + return { + success: false, + error: error.message || 'Network error occurred' + }; + } + } + + /** + * Remove a tag set from a category + * @param {string} categoryId - Category ID + * @param {string} setId - Tag set ID to remove + * @returns {Promise<{success: boolean, data?: Object, error?: string}>} + */ + async removeTagSetFromCategory(categoryId, setId) { + try { + // First get the current library + const libraryResult = await this.getTagLibrary(false); // Don't use cache + if (!libraryResult.success) { + return libraryResult; + } + + const library = libraryResult.data; + const category = library.categories.find(c => c.id === categoryId); + + if (!category) { + return { + success: false, + error: `Category '${categoryId}' not found` + }; + } + + // Find and remove the tag set + const setIndex = category.sets.findIndex(s => s.id === setId); + if (setIndex > -1) { + const removedSet = category.sets.splice(setIndex, 1)[0]; + + // Save the updated category + const result = await this.saveCategory(category); + if (result.success) { + result.message = `Tag set '${removedSet.name}' removed successfully`; + } + return result; + } + + return { + success: false, + error: `Tag set '${setId}' not found in category '${category.name}'` + }; + } catch (error) { + console.error('Error removing tag set from category:', error); + return { + success: false, + error: error.message || 'Network error occurred' + }; + } + } + + /** + * Get tags by category + * @param {string} categoryId - Category ID + * @returns {Promise<{success: boolean, data?: Object, error?: string}>} + */ + async getTagsByCategory(categoryId) { + try { + const libraryResult = await this.getTagLibrary(); + if (!libraryResult.success) { + return libraryResult; + } + + const category = libraryResult.data.categories.find(c => c.id === categoryId); + if (!category) { + return { + success: false, + error: `Category '${categoryId}' not found` + }; + } + + return { + success: true, + data: { + category: category, + tags: category.tags || [], + sets: category.sets || [] + } + }; + } catch (error) { + console.error('Error getting tags by category:', error); + return { + success: false, + error: error.message || 'Network error occurred' + }; + } + } + + /** + * Clear API cache + */ + clearCache() { + this.cache.clear(); + } + + /** + * Get cache statistics + * @returns {Object} - Cache statistics + */ + getCacheStats() { + return { + size: this.cache.size, + timeout: this.cacheTimeout + }; + } + + /** + * Generate a unique ID for new categories or tag sets + * @param {string} name - Name to base the ID on + * @returns {string} - Generated ID + */ + generateId(name) { + return name + .toLowerCase() + .replace(/[^a-z0-9\s]/g, '') + .replace(/\s+/g, '_') + .substring(0, 50); // Limit length + } + + /** + * Validate category data + * @param {Object} category - Category data to validate + * @returns {Object} - Validation result {isValid, errors} + */ + validateCategory(category) { + const errors = []; + + if (!category.id || typeof category.id !== 'string') { + errors.push('Category ID is required and must be a string'); + } + + if (!category.name || typeof category.name !== 'string') { + errors.push('Category name is required and must be a string'); + } + + if (category.tags && !Array.isArray(category.tags)) { + errors.push('Tags must be an array'); + } + + if (category.sets && !Array.isArray(category.sets)) { + errors.push('Sets must be an array'); + } + + // Validate tag sets + if (category.sets) { + for (const set of category.sets) { + if (!set.id || !set.name) { + errors.push('Each tag set must have an id and name'); + } + if (set.tags && !Array.isArray(set.tags)) { + errors.push('Tag set tags must be an array'); + } + } + } + + return { + isValid: errors.length === 0, + errors + }; + } +} + +// Export singleton instance +export const tagApi = new TagApi(); + +// Export individual functions for convenience +export const { + getTagLibrary, + saveTagLibrary, + saveCategory, + deleteCategory, + searchTags, + addTagToCategory, + removeTagFromCategory, + addTagSetToCategory, + removeTagSetFromCategory, + getTagsByCategory, + clearCache, + generateId, + validateCategory +} = tagApi; + +export default tagApi; diff --git a/js/sidebar/promptBuilderTab.js b/js/sidebar/promptBuilderTab.js index c8ff556..574dc16 100644 --- a/js/sidebar/promptBuilderTab.js +++ b/js/sidebar/promptBuilderTab.js @@ -3,6 +3,7 @@ */ import { promptGenerationComponent } from '../components/promptBuilder/promptGeneration.js'; +import { tagLibraryComponent } from '../components/promptBuilder/tagLibrary.js'; /** * Creates the main Prompt Builder tab content @@ -30,6 +31,14 @@ export function createPromptBuilderTab(container) { const generationSection = createGenerationSection(); contentArea.appendChild(generationSection); + // Add tag library section (collapsible) + const tagLibrarySection = createTagLibrarySection(); + contentArea.appendChild(tagLibrarySection); + + // TODO: Add saved prompts section + // const savedPromptsSection = createSavedPromptsSection(); + // contentArea.appendChild(savedPromptsSection); + wrapper.appendChild(contentArea); container.appendChild(wrapper); @@ -40,6 +49,7 @@ export function createPromptBuilderTab(container) { return { wrapper, generationSection, + tagLibrarySection, destroy: () => { destroyPromptBuilderTab(container); } @@ -108,6 +118,219 @@ function createGenerationSection() { return section; } +/** + * Creates the tag library section + * @returns {HTMLElement} - The tag library section element + */ +function createTagLibrarySection() { + const section = document.createElement('div'); + section.className = 'prompt-builder-section tag-library-section'; + + const sectionHeader = document.createElement('div'); + sectionHeader.className = 'section-header collapsible-header'; + + const sectionTitle = document.createElement('h3'); + sectionTitle.textContent = '🏷️ Tag Library'; + sectionTitle.className = 'section-title'; + + const collapseBtn = document.createElement('button'); + collapseBtn.className = 'collapse-btn'; + collapseBtn.textContent = '▼'; + collapseBtn.title = 'Toggle tag library'; + + sectionHeader.appendChild(sectionTitle); + sectionHeader.appendChild(collapseBtn); + section.appendChild(sectionHeader); + + const sectionContent = document.createElement('div'); + sectionContent.className = 'section-content'; + + // Add collapsible functionality + let isCollapsed = false; // Default: expanded + + const toggleCollapse = () => { + isCollapsed = !isCollapsed; + sectionContent.style.display = isCollapsed ? 'none' : 'block'; + collapseBtn.textContent = isCollapsed ? '▶' : '▼'; + section.classList.toggle('collapsed', isCollapsed); + }; + + sectionHeader.addEventListener('click', toggleCollapse); + + try { + // Create tag library component with tag insertion callbacks + const tagLibraryOptions = { + allowEdit: true, + showSearch: true, + compactMode: false, + onTagInsert: (tag) => { + insertTagIntoActivePrompt(tag); + }, + onTagSetInsert: (tagSet, tagsText) => { + insertTagIntoActivePrompt(tagsText); + } + }; + + // Show loading state while component loads + sectionContent.innerHTML = '
Loading tag library...
'; + + // Create the component asynchronously + tagLibraryComponent.create(sectionContent, tagLibraryOptions).then(() => { + // Component loaded successfully + console.log('Tag library component loaded successfully'); + }).catch((error) => { + console.error('Failed to load tag library component:', error); + // Show error fallback + sectionContent.innerHTML = ` +
+
+

Tag Library

+

Error loading tag library: ${error.message}

+

Please check the console for more details.

+
+
+ `; + }); + } catch (error) { + console.error('Error creating tag library component:', error); + // Fallback content + const fallbackContent = document.createElement('div'); + fallbackContent.className = 'tag-library-fallback'; + fallbackContent.innerHTML = ` +
+

Tag Library

+

Error loading tag library: ${error.message}

+

Please check the console for more details.

+
+ `; + sectionContent.appendChild(fallbackContent); + } + + section.appendChild(sectionContent); + return section; +} + +/** + * Insert a tag into the active prompt field + * @param {string} tag - The tag text to insert + */ +function insertTagIntoActivePrompt(tag) { + try { + // Find the currently focused prompt input + const activeInput = document.querySelector('.prompt-generation-section .positive-prompt:focus, .prompt-generation-section .negative-prompt:focus'); + + if (activeInput) { + // Insert at cursor position + const start = activeInput.selectionStart; + const end = activeInput.selectionEnd; + const currentValue = activeInput.value; + + // Add comma and space if there's existing text and it doesn't end with a comma or space + let insertText = tag; + if (start > 0 && !currentValue.slice(start - 1, start).match(/[,\s]/)) { + insertText = ', ' + tag; + } + + // Insert the tag + const newValue = currentValue.slice(0, start) + insertText + currentValue.slice(end); + activeInput.value = newValue; + + // Update cursor position + const newCursorPos = start + insertText.length; + activeInput.setSelectionRange(newCursorPos, newCursorPos); + + // Trigger input event to update any bound data + activeInput.dispatchEvent(new Event('input', { bubbles: true })); + + // Keep focus on the input + activeInput.focus(); + + // Show feedback + showTagInsertionFeedback(tag); + } else { + // No active input, copy to clipboard instead + copyToClipboard(tag); + showTagInsertionFeedback(tag, 'clipboard'); + } + } catch (error) { + console.error('Error inserting tag:', error); + // Fallback to clipboard + copyToClipboard(tag); + showTagInsertionFeedback(tag, 'clipboard'); + } +} + +/** + * Copy text to clipboard + * @param {string} text - Text to copy + */ +async function copyToClipboard(text) { + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + return true; + } else { + // Fallback for older browsers + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-9999px'; + document.body.appendChild(textArea); + textArea.select(); + const success = document.execCommand('copy'); + document.body.removeChild(textArea); + return success; + } + } catch (error) { + console.error('Failed to copy to clipboard:', error); + return false; + } +} + +/** + * Show feedback for tag insertion + * @param {string} tag - The inserted tag + * @param {string} method - 'insert' or 'clipboard' + */ +function showTagInsertionFeedback(tag, method = 'insert') { + const message = method === 'clipboard' + ? `Copied "${tag}" to clipboard` + : `Inserted "${tag}" into prompt`; + + // Find or create feedback element + let feedback = document.querySelector('.tag-insertion-feedback'); + if (!feedback) { + feedback = document.createElement('div'); + feedback.className = 'tag-insertion-feedback'; + document.body.appendChild(feedback); + } + + feedback.textContent = message; + feedback.style.cssText = ` + position: fixed; + top: 50px; + right: 20px; + padding: 8px 12px; + background: var(--primary-color, #4a9eff); + color: white; + border-radius: 4px; + font-size: 12px; + z-index: 10000; + opacity: 1; + transition: opacity 0.3s ease; + `; + + // Auto-hide after 2 seconds + setTimeout(() => { + feedback.style.opacity = '0'; + setTimeout(() => { + if (feedback.parentNode) { + feedback.parentNode.removeChild(feedback); + } + }, 300); + }, 2000); +} + /** * Adds CSS styles for the prompt builder */ @@ -198,22 +421,94 @@ function addPromptBuilderStyles() { color: white; } - .prompt-builder-fallback { + .tag-library-section { + border-color: var(--success-color, #6bcf7f); + } + + .tag-library-section .section-header { + background: linear-gradient(135deg, var(--success-color, #6bcf7f) 0%, var(--success-dark, #4a9a5a) 100%); + cursor: pointer; + } + + .tag-library-section .section-title { + color: white; + } + + .collapsible-header { + display: flex; + align-items: center; + justify-content: space-between; + user-select: none; + } + + .collapse-btn { + background: none; + border: none; + color: white; + font-size: 12px; + cursor: pointer; + padding: 4px; + border-radius: 3px; + transition: background-color 0.2s ease; + } + + .collapse-btn:hover { + background: rgba(255, 255, 255, 0.1); + } + + .section-content { + padding: 16px; + } + + .tag-library-section.collapsed .section-content { + display: none; + } + + .tag-library-fallback { padding: 16px; text-align: center; color: var(--text-secondary, #cccccc); } - .prompt-builder-fallback h4 { + .tag-library-fallback h4 { color: var(--error-color, #ff6b6b); margin: 0 0 12px 0; } - .prompt-builder-fallback p { + .tag-library-fallback p { margin: 8px 0; font-size: 14px; } + .loading-state { + padding: 32px 16px; + text-align: center; + color: var(--text-secondary, #cccccc); + font-size: 14px; + } + + .loading-state::before { + content: ''; + display: inline-block; + width: 20px; + height: 20px; + border: 2px solid var(--border-color, #444); + border-radius: 50%; + border-top-color: var(--primary-color, #4a9eff); + animation: spin 1s linear infinite; + margin-right: 8px; + vertical-align: middle; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } + + .tag-insertion-feedback { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + font-weight: 500; + } + /* Responsive design */ @media (max-width: 768px) { .prompt-builder-header { diff --git a/routes/__init__.py b/routes/__init__.py index 4c77c61..8198c4f 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -35,6 +35,7 @@ def register_routes(routes_instance): from . import notes_routes from . import gallery_routes from . import wildcard_routes + from . import tag_routes from . import utility_routes # Track successful registrations @@ -48,6 +49,7 @@ def register_routes(routes_instance): ("Notes", notes_routes), ("Gallery", gallery_routes), ("Wildcard", wildcard_routes), + ("Tag", tag_routes), ("Utility", utility_routes) ] @@ -158,6 +160,11 @@ ROUTE_GROUPS = { 'endpoints': 5, 'module': 'wildcard_routes' }, + 'tag': { + 'description': 'Tag library management and prompt tagging', + 'endpoints': 5, + 'module': 'tag_routes' + }, 'utility': { 'description': 'Utility functions and miscellaneous endpoints', 'endpoints': 3, diff --git a/routes/tag_routes.py b/routes/tag_routes.py new file mode 100644 index 0000000..2a273a8 --- /dev/null +++ b/routes/tag_routes.py @@ -0,0 +1,479 @@ +""" +Tag Management Routes Module +Handles tag library and tag set management for prompt building. +""" + +import logging +import json +from pathlib import Path +from aiohttp import web +from .base import route_error_handler, validate_json_body, success_response, error_response + +# Route list for documentation and registration tracking +_route_list = [] + + +def _get_tag_library_path(sage_users_path): + """ + Get the path to the tag library file. + + Args: + sage_users_path: Base SageUtils user directory path + + Returns: + Path: Path to tag library JSON file + """ + return Path(sage_users_path) / "tag_library.json" + + +def _get_default_tag_library(): + """ + Returns the default tag library structure loaded from config manager. + + Returns: + dict: Default tag library data + """ + try: + # Import the default tag library from config manager + from ..utils.config_manager import default_tag_library + + if default_tag_library and default_tag_library.get('categories'): + return default_tag_library + else: + # Fallback to minimal structure if no default data + logging.warning("Default tag library is empty or not loaded") + return { + "version": "1.0", + "categories": [], + "metadata": { + "created": None, + "modified": None, + "total_categories": 0, + "total_tags": 0, + "total_sets": 0 + } + } + except Exception as e: + logging.error(f"Error loading default tag library: {e}") + # Return minimal fallback structure + return { + "version": "1.0", + "categories": [], + "metadata": { + "created": None, + "modified": None, + "total_categories": 0, + "total_tags": 0, + "total_sets": 0 + } + } + + +def _validate_category_data(data): + """ + Validate category data structure. + + Args: + data: Category data to validate + + Returns: + tuple: (is_valid, error_message) + """ + required_fields = ['id', 'name'] + for field in required_fields: + if field not in data: + return False, f"Missing required field: {field}" + + if not isinstance(data.get('tags', []), list): + return False, "Tags must be a list" + + if not isinstance(data.get('sets', []), list): + return False, "Sets must be a list" + + # Validate sets + for tag_set in data.get('sets', []): + if not isinstance(tag_set, dict): + return False, "Each set must be an object" + if 'id' not in tag_set or 'name' not in tag_set: + return False, "Each set must have id and name" + if not isinstance(tag_set.get('tags', []), list): + return False, "Set tags must be a list" + + return True, None + + +def register_routes(routes_instance): + """ + Register tag management routes. + + Args: + routes_instance: The PromptServer routes instance + + Returns: + int: Number of routes registered + """ + global _route_list + _route_list.clear() + + @routes_instance.get('/sage_utils/tags/library') + @route_error_handler + async def get_tag_library(request): + """ + Gets the complete tag library. + + Response: + JSON with tag library data including categories, tags, and sets + """ + try: + # Dynamic import to avoid ComfyUI dependency issues + from ..utils import sage_users_path + + tag_library_path = _get_tag_library_path(sage_users_path) + + if not tag_library_path.exists(): + # Return default library if none exists + default_library = _get_default_tag_library() + return success_response(data=default_library) + + # Load existing library + with open(tag_library_path, 'r', encoding='utf-8') as f: + library_data = json.load(f) + + return success_response(data=library_data) + + except Exception as e: + logging.error(f"Get tag library error: {e}") + return error_response(f"Failed to get tag library: {str(e)}", status=500) + + @routes_instance.post('/sage_utils/tags/library') + @route_error_handler + @validate_json_body('categories') + async def save_tag_library(request): + """ + Saves the complete tag library. + + Request Body: + categories: Array of category objects + metadata: Optional metadata object + + Response: + JSON with success message and library statistics + """ + try: + # Dynamic import to avoid ComfyUI dependency issues + from ..utils import sage_users_path + import datetime + + data = request.json_data + categories = data.get('categories', []) + metadata = data.get('metadata', {}) + + # Update metadata + now = datetime.datetime.now().isoformat() + metadata.update({ + 'modified': now, + 'total_categories': len(categories), + 'total_tags': sum(len(cat.get('tags', [])) for cat in categories), + 'total_sets': sum(len(cat.get('sets', [])) for cat in categories) + }) + + if 'created' not in metadata: + metadata['created'] = now + + # Prepare library data + library_data = { + 'version': data.get('version', '1.0'), + 'categories': categories, + 'metadata': metadata + } + + # Validate categories + for category in categories: + is_valid, error_msg = _validate_category_data(category) + if not is_valid: + return error_response(f"Invalid category data: {error_msg}", status=400) + + # Save to file + tag_library_path = _get_tag_library_path(sage_users_path) + tag_library_path.parent.mkdir(parents=True, exist_ok=True) + + with open(tag_library_path, 'w', encoding='utf-8') as f: + json.dump(library_data, f, indent=2, ensure_ascii=False) + + return success_response( + message="Tag library saved successfully", + data={ + "categories": metadata['total_categories'], + "tags": metadata['total_tags'], + "sets": metadata['total_sets'], + "file_size": tag_library_path.stat().st_size + } + ) + + except Exception as e: + logging.error(f"Save tag library error: {e}") + return error_response(f"Failed to save tag library: {str(e)}", status=500) + + @routes_instance.post('/sage_utils/tags/category') + @route_error_handler + @validate_json_body('id', 'name') + async def save_category(request): + """ + Creates or updates a category in the tag library. + + Request Body: + id: Category ID + name: Category name + description: Optional description + color: Optional color code + tags: Array of tag strings + sets: Array of tag set objects + + Response: + JSON with success message and updated category data + """ + try: + # Dynamic import to avoid ComfyUI dependency issues + from ..utils import sage_users_path + import datetime + + data = request.json_data + + # Validate category data + is_valid, error_msg = _validate_category_data(data) + if not is_valid: + return error_response(error_msg, status=400) + + # Load existing library + tag_library_path = _get_tag_library_path(sage_users_path) + if tag_library_path.exists(): + with open(tag_library_path, 'r', encoding='utf-8') as f: + library_data = json.load(f) + else: + library_data = _get_default_tag_library() + + # Find and update category, or add new one + category_id = data['id'] + category_found = False + + for i, category in enumerate(library_data['categories']): + if category['id'] == category_id: + library_data['categories'][i] = data + category_found = True + break + + if not category_found: + # Add order if not specified + if 'order' not in data: + data['order'] = len(library_data['categories']) + library_data['categories'].append(data) + + # Update metadata + now = datetime.datetime.now().isoformat() + library_data['metadata']['modified'] = now + library_data['metadata']['total_categories'] = len(library_data['categories']) + library_data['metadata']['total_tags'] = sum(len(cat.get('tags', [])) for cat in library_data['categories']) + library_data['metadata']['total_sets'] = sum(len(cat.get('sets', [])) for cat in library_data['categories']) + + # Save updated library + with open(tag_library_path, 'w', encoding='utf-8') as f: + json.dump(library_data, f, indent=2, ensure_ascii=False) + + return success_response( + message=f"Category '{data['name']}' {'updated' if category_found else 'created'} successfully", + data=data + ) + + except Exception as e: + logging.error(f"Save category error: {e}") + return error_response(f"Failed to save category: {str(e)}", status=500) + + @routes_instance.delete('/sage_utils/tags/category/{category_id}') + @route_error_handler + async def delete_category(request): + """ + Deletes a category from the tag library. + + Path Parameters: + category_id: ID of the category to delete + + Response: + JSON with success message + """ + try: + # Dynamic import to avoid ComfyUI dependency issues + from ..utils import sage_users_path + import datetime + + category_id = request.match_info.get('category_id', '') + if not category_id: + return error_response("Category ID is required", status=400) + + # Load existing library + tag_library_path = _get_tag_library_path(sage_users_path) + if not tag_library_path.exists(): + return error_response("Tag library not found", status=404) + + with open(tag_library_path, 'r', encoding='utf-8') as f: + library_data = json.load(f) + + # Find and remove category + category_found = False + removed_category = None + for i, category in enumerate(library_data['categories']): + if category['id'] == category_id: + removed_category = library_data['categories'].pop(i) + category_found = True + break + + if not category_found: + return error_response(f"Category '{category_id}' not found", status=404) + + # Update metadata + now = datetime.datetime.now().isoformat() + library_data['metadata']['modified'] = now + library_data['metadata']['total_categories'] = len(library_data['categories']) + library_data['metadata']['total_tags'] = sum(len(cat.get('tags', [])) for cat in library_data['categories']) + library_data['metadata']['total_sets'] = sum(len(cat.get('sets', [])) for cat in library_data['categories']) + + # Save updated library + with open(tag_library_path, 'w', encoding='utf-8') as f: + json.dump(library_data, f, indent=2, ensure_ascii=False) + + return success_response( + message=f"Category '{removed_category['name'] if removed_category else category_id}' deleted successfully" + ) + + except Exception as e: + logging.error(f"Delete category error: {e}") + return error_response(f"Failed to delete category: {str(e)}", status=500) + + @routes_instance.get('/sage_utils/tags/search') + @route_error_handler + async def search_tags(request): + """ + Searches for tags across all categories. + + Query Parameters: + q: Search query + category: Optional category ID to limit search + limit: Optional limit (default: 50) + + Response: + JSON with search results + """ + try: + # Dynamic import to avoid ComfyUI dependency issues + from ..utils import sage_users_path + + query = request.query.get('q', '').lower().strip() + category_filter = request.query.get('category', '') + limit = int(request.query.get('limit', 50)) + + if not query: + return error_response("Search query is required", status=400) + + # Load tag library + tag_library_path = _get_tag_library_path(sage_users_path) + if not tag_library_path.exists(): + return success_response(data={"results": [], "total": 0}) + + with open(tag_library_path, 'r', encoding='utf-8') as f: + library_data = json.load(f) + + results = [] + + # Search through categories + for category in library_data['categories']: + if category_filter and category['id'] != category_filter: + continue + + # Search individual tags + for tag in category.get('tags', []): + if query in tag.lower(): + results.append({ + 'type': 'tag', + 'text': tag, + 'category_id': category['id'], + 'category_name': category['name'], + 'match_type': 'exact' if query == tag.lower() else 'partial' + }) + + # Search tag sets + for tag_set in category.get('sets', []): + if query in tag_set['name'].lower(): + results.append({ + 'type': 'set', + 'text': tag_set['name'], + 'tags': tag_set['tags'], + 'category_id': category['id'], + 'category_name': category['name'], + 'match_type': 'name' + }) + + # Search within set tags + for tag in tag_set.get('tags', []): + if query in tag.lower(): + results.append({ + 'type': 'set_tag', + 'text': tag, + 'set_name': tag_set['name'], + 'category_id': category['id'], + 'category_name': category['name'], + 'match_type': 'tag_in_set' + }) + + # Sort by relevance (exact matches first) + results.sort(key=lambda x: ( + 0 if x['match_type'] == 'exact' else 1, + x['text'].lower() + )) + + # Apply limit + results = results[:limit] + + return success_response(data={ + "results": results, + "total": len(results), + "query": query, + "limit": limit + }) + + except Exception as e: + logging.error(f"Search tags error: {e}") + return error_response(f"Failed to search tags: {str(e)}", status=500) + + @routes_instance.get('/sage_utils/tags/defaults') + @route_error_handler + async def get_default_tag_library(request): + """ + Gets the default tag library structure from assets. + + Response: + JSON with default tag library data + """ + try: + default_library = _get_default_tag_library() + return success_response(data=default_library) + + except Exception as e: + logging.error(f"Get default tag library error: {e}") + return error_response(f"Failed to get default tag library: {str(e)}", status=500) + + # Track registered routes + _route_list.extend([ + {"method": "GET", "path": "/sage_utils/tags/library", "description": "Get complete tag library"}, + {"method": "POST", "path": "/sage_utils/tags/library", "description": "Save complete tag library"}, + {"method": "POST", "path": "/sage_utils/tags/category", "description": "Create or update category"}, + {"method": "DELETE", "path": "/sage_utils/tags/category/{category_id}", "description": "Delete category"}, + {"method": "GET", "path": "/sage_utils/tags/search", "description": "Search tags and sets"}, + {"method": "GET", "path": "/sage_utils/tags/defaults", "description": "Get default tag library from assets"} + ]) + + logging.info("Tag management routes registered successfully") + return len(_route_list) + + +def get_route_list(): + """Get list of registered routes for this module.""" + return _route_list.copy() diff --git a/utils/config_manager.py b/utils/config_manager.py index c335f72..a85ccde 100644 --- a/utils/config_manager.py +++ b/utils/config_manager.py @@ -44,6 +44,10 @@ llm_prompts = prompts_manager.load() metadata_template_manager = ConfigManager("metadata_templates") metadata_templates = metadata_template_manager.load() +# Tag library management for prompt builder +tag_library_manager = ConfigManager("default_tag_library") +default_tag_library = tag_library_manager.load() + # Enhanced settings management using the new settings system # This maintains backwards compatibility while providing enhanced functionality try: