class PromptAdmin { constructor() { this.prompts = []; this.listMode = "recent"; // "recent" or "search"; a sort change reloads it this.selectedPrompts = new Set(); this.settings = { resultTimeout: 5, webuiDisplayMode: 'popup', workerThreads: 1, cpuCount: 1, infiniteScroll: false, }; this.categories = []; this.tags = []; this.subfolders = []; // Pagination state this.pagination = { currentPage: 1, limit: 50, total: 0, totalPages: 1 }; this.tagsPage = null; this.api = ApiClient.createApiClient(); // Search and page loads share one guard: whichever list request is newest wins this.listRequest = ApiClient.latestOnly(); this.infiniteScroll = false; // session value; seeded from settings, toggled in the header this.infiniteScrollChosen = false; this.loadingMore = false; // The prompt currently edited in place, if any: { promptId, element, originalText } this.activeEdit = null; // Elements rendered with data-action="..." (prompt cards, film strips, log files, // review chips) are dispatched here instead of through inline onclick handlers, // so user-provided strings never land inside JavaScript source. this.actionHandlers = { "copy-prompt": ({ promptId }) => this.copyPromptToClipboard(promptId), "add-tags": ({ promptId }) => this.addTag(promptId), "remove-tag": ({ promptId, tag }) => this.removeTag(promptId, tag), "toggle-tags": ({ promptId }) => this.toggleMainTags(promptId), gallery: ({ promptId }) => this.viewGallery(promptId), edit: ({ promptId }) => this.editPrompt(promptId), delete: ({ promptId }) => this.deletePrompt(promptId), "open-film": ({ promptId, index }) => this.openFilmStripViewer(promptId, index), "download-log": ({ filename }) => this.downloadLogFile(filename), "remove-review-tag": ({ index }) => this.removeReviewTag(index), "pick-tag-suggestion": ({ tag }) => this.pickTagSuggestion(tag), }; this.init(); } init() { this.bindEvents(); this.initRouter(); this.loadInitialData(); this.resumeRunningScan(); this.checkUpdateNotice(); } initRouter() { window.addEventListener('hashchange', () => this.handleRoute()); this.handleRoute(); } handleRoute() { if (ViewRouter.resolveView(window.location.hash) === "tags") { this.showTagsPage(); } else { this.showDashboard(); } } showDashboard() { const dashboard = document.getElementById('dashboardView'); const tagsPage = document.getElementById('tagsPageView'); if (dashboard) dashboard.classList.remove('hidden'); if (tagsPage) tagsPage.classList.add('hidden'); if (this.tagsPage) { this.tagsPage.destroy(); this.tagsPage = null; } } showTagsPage() { const dashboard = document.getElementById('dashboardView'); const tagsPage = document.getElementById('tagsPageView'); if (dashboard) dashboard.classList.add('hidden'); if (tagsPage) tagsPage.classList.remove('hidden'); // Lazy-initialize TagsPageManager if (!this.tagsPage && typeof TagsPageManager !== 'undefined') { this.tagsPage = new TagsPageManager(this); } else if (this.tagsPage) { this.tagsPage.loadTagsList(); } } bindEvents() { // Search document.getElementById("searchBtn").addEventListener("click", () => this.search()); document.getElementById("searchText").addEventListener("keyup", (e) => { if (e.key === "Enter") this.search(); }); // Tag autocomplete: suggestions narrow to tags that co-occur with the ones typed this.tagSuggest = { items: [], index: -1, request: ApiClient.latestOnly(), timer: null }; const tagsInput = document.getElementById("searchTags"); tagsInput.addEventListener("input", () => this.scheduleTagSuggestions()); tagsInput.addEventListener("focus", () => this.scheduleTagSuggestions(0)); tagsInput.addEventListener("keydown", (e) => this.handleTagSuggestKey(e)); tagsInput.addEventListener("blur", () => setTimeout(() => this.hideTagSuggestions(), 150)); // A click on a suggestion must not blur the input before the click lands document.getElementById("searchTagSuggestions").addEventListener("mousedown", (e) => e.preventDefault()); // Bulk actions document.getElementById("selectAll").addEventListener("change", (e) => this.toggleSelectAll(e.target.checked) ); document.getElementById("bulkDeleteBtn").addEventListener("click", () => this.bulkDelete()); document.getElementById("bulkTagBtn").addEventListener("click", () => this.showBulkTagModal()); document.getElementById("bulkCategoryBtn").addEventListener("click", () => this.showBulkCategoryModal()); document.getElementById("exportBtn").addEventListener("click", () => this.exportPrompts()); document.getElementById("settingsBtn").addEventListener("click", () => this.showSettingsModal()); document.getElementById("statsBtn").addEventListener("click", () => this.showStats()); document.getElementById("diagnosticsBtn").addEventListener("click", () => this.showDiagnosticsModal()); document.getElementById("maintenanceBtn").addEventListener("click", () => this.showMaintenanceModal()); document.getElementById("backupBtn").addEventListener("click", () => this.backupDatabase()); document.getElementById("restoreBtn").addEventListener("click", () => this.showRestoreModal()); document.getElementById("scanBtn").addEventListener("click", () => this.showScanModal()); document.getElementById("autoTagBtn").addEventListener("click", () => this.showAutoTagModal()); document.getElementById("addPromptBtn").addEventListener("click", () => this.showAddPromptModal()); document.getElementById("logsBtn").addEventListener("click", () => this.showLogsModal()); document.getElementById("metadataBtn").addEventListener("click", () => this.openMetadataViewer()); document.getElementById("galleryBtn").addEventListener("click", () => this.openGallery()); // Pagination controls document.getElementById("limitSelector").addEventListener("change", (e) => this.changeLimit(parseInt(e.target.value))); document.getElementById("infiniteScrollSession").addEventListener("change", (e) => this.setInfiniteScroll(e.target.checked, { session: true })); document.getElementById("resultsList").addEventListener("scroll", () => this.maybeLoadMorePrompts(), { passive: true }); document.getElementById("firstPageBtn").addEventListener("click", () => this.goToPage(1)); document.getElementById("prevPageBtn").addEventListener("click", () => this.goToPage(this.pagination.currentPage - 1)); document.getElementById("nextPageBtn").addEventListener("click", () => this.goToPage(this.pagination.currentPage + 1)); document.getElementById("lastPageBtn").addEventListener("click", () => this.goToPage(this.pagination.totalPages)); // Modals // Output scan: a server-side job that survives modal dismissal and reloads this.scanState = ScanProgress.initialState(); this.scanSource = null; window.addEventListener("beforeunload", (e) => this.warnIfScanRunning(e)); this.bindModalEvents(); // One delegated click handler for every data-action element; images declare a // data-fallback-src instead of an inline onerror handler document.addEventListener("click", (e) => this.handleActionClick(e)); document.addEventListener("error", (e) => this.applyImageFallback(e.target), true); // In-place prompt editing: one keydown/focusout pair for the whole list, so // listeners are not stacked per edit and a save is sent exactly once const results = document.getElementById("resultsList"); results.addEventListener("keydown", (e) => this.handleEditKeydown(e)); results.addEventListener("focusout", (e) => this.handleEditFocusOut(e)); // Auto-search on filter changes ["searchCategory", "searchFolder"].forEach((id) => { document.getElementById(id).addEventListener("change", () => this.search()); }); // Sort dropdown: options come from PromptListSort; sorting runs on the server const sortSelect = document.getElementById("sortBy"); sortSelect.innerHTML = PromptListSort.SORT_OPTIONS .map((o) => ``) .join(""); sortSelect.value = PromptListSort.DEFAULT_SORT; sortSelect.addEventListener("change", () => this.reloadPrompts()); } bindModalEvents() { // Settings modal document.getElementById("saveSettings").addEventListener("click", () => this.saveSettings()); document.getElementById("cancelSettings").addEventListener("click", () => this.hideModal("settingsModal")); document.getElementById("refreshMonitoringStatus").addEventListener("click", () => this.updateMonitoringStatus()); document.getElementById("addScanPathBtn").addEventListener("click", () => this.addScanPath()); // Bulk tag modal document.getElementById("confirmBulkTag").addEventListener("click", () => this.confirmBulkTag()); document.getElementById("cancelBulkTag").addEventListener("click", () => this.hideModal("bulkTagModal")); // Individual tag modal document.getElementById("confirmIndividualTag").addEventListener("click", () => this.confirmIndividualTag()); document.getElementById("cancelIndividualTag").addEventListener("click", () => this.hideModal("individualTagModal")); // Bulk category modal document.getElementById("confirmBulkCategory").addEventListener("click", () => this.confirmBulkCategory()); document.getElementById("cancelBulkCategory").addEventListener("click", () => this.hideModal("bulkCategoryModal")); // Restore modal document.getElementById("confirmRestore").addEventListener("click", () => this.confirmRestore()); document.getElementById("cancelRestore").addEventListener("click", () => this.hideModal("restoreModal")); // Diagnostics modal document.getElementById("runDiagnosticsBtn").addEventListener("click", () => this.runDiagnostics()); document.getElementById("testImageLinkBtn").addEventListener("click", () => this.testImageLink()); // Maintenance modal document.getElementById("runMaintenanceBtn").addEventListener("click", () => this.runMaintenance()); document.getElementById("selectAllMaintenanceBtn").addEventListener("click", () => this.selectAllMaintenance()); document.getElementById("clearAllMaintenanceBtn").addEventListener("click", () => this.clearAllMaintenance()); // Scan modal document.getElementById("startScan").addEventListener("click", () => this.startScan()); document.getElementById("cancelScan").addEventListener("click", () => this.dismissScanModal()); document.getElementById("scanPill").addEventListener("click", () => this.showScanModal()); document.getElementById("quickBackupBtn").addEventListener("click", () => this.quickBackup()); // Logs modal document.getElementById("refreshLogsBtn").addEventListener("click", () => this.refreshLogs()); document.getElementById("downloadLogsBtn").addEventListener("click", () => this.downloadLogs()); document.getElementById("clearLogsBtn").addEventListener("click", () => this.clearLogs()); document.getElementById("updateLogConfigBtn").addEventListener("click", () => this.updateLogConfig()); // Auto Tag modals document.getElementById("startAutoTagBtn").addEventListener("click", () => this.startAutoTag()); document.getElementById("startReviewBtn").addEventListener("click", () => this.startReview()); document.getElementById("skipReviewBtn").addEventListener("click", () => this.skipReviewImage()); document.getElementById("applyReviewBtn").addEventListener("click", () => this.applyReviewTags()); document.getElementById("cancelAutoTagBtn").addEventListener("click", () => this.cancelAutoTag()); document.getElementById("cancelDownloadBtn").addEventListener("click", () => this.cancelDownload()); // Re-tag confirmation modal buttons document.getElementById("retagSkipBtn").addEventListener("click", () => this.handleRetagChoice('skip')); document.getElementById("retagSkipAllBtn").addEventListener("click", () => this.handleRetagChoice('skipAll')); document.getElementById("retagConfirmBtn").addEventListener("click", () => this.handleRetagChoice('retag')); // WD14 model selection toggle (prompt vs thresholds) document.querySelectorAll('input[name="autoTagModel"]').forEach(radio => { radio.addEventListener('change', (e) => { const isWd14 = e.target.value.startsWith('wd14'); document.getElementById('promptSection').classList.toggle('hidden', isWd14); document.getElementById('wd14ThresholdSection').classList.toggle('hidden', !isWd14); }); }); // WD14 threshold slider value display document.getElementById('wd14GeneralThreshold').addEventListener('input', (e) => { document.getElementById('wd14GeneralThresholdValue').textContent = parseFloat(e.target.value).toFixed(2); }); document.getElementById("workerThreads").addEventListener("input", () => this.renderWorkerThreads()); document.getElementById('wd14CharacterThreshold').addEventListener('input', (e) => { document.getElementById('wd14CharacterThresholdValue').textContent = parseFloat(e.target.value).toFixed(2); }); // Tags accordion toggle document.getElementById("reviewTagsToggle").addEventListener("click", () => this.toggleReviewTags()); // Add Prompt modal document.getElementById("saveNewPromptBtn").addEventListener("click", () => this.saveNewPrompt()); this.setupAddPromptModal(); // Close modals on backdrop click document.querySelectorAll("[id$='Modal']").forEach((modal) => { modal.addEventListener("click", (e) => { if (e.target !== modal) return; // The scan modal only leaves through its own buttons (Cancel / Hide): a click // that merely focuses the window must not close or minimize a long job. if (modal.id === "scanModal") return; modal.classList.add("hidden"); modal.classList.remove("flex"); document.body.style.overflow = ""; }); }); } async loadInitialData() { try { await Promise.all([ this.loadStatistics(), this.loadCategories(), this.loadSubfolders(), this.loadTags(), this.loadRecentPrompts(), this.loadSettings(), ]); } catch (error) { this.showNotification("Failed to load initial data", "error"); console.error("Load error:", error); } } async loadSettings() { try { const response = await ApiClient.fetchUrl("/prompt_manager/settings"); if (response.ok) { const data = await response.json(); if (data.success && data.settings) { this.settings.resultTimeout = data.settings.result_timeout ?? 5; this.settings.webuiDisplayMode = data.settings.webui_display_mode || 'popup'; this.settings.infiniteScroll = data.settings.infinite_scroll === true; // The saved default seeds the session until the header checkbox is used if (!this.infiniteScrollChosen) this.setInfiniteScroll(this.settings.infiniteScroll, { session: false }); this.settings.cpuCount = Math.max(1, parseInt(data.settings.cpu_count, 10) || 1); this.settings.workerThreads = Math.min( this.settings.cpuCount, Math.max(1, parseInt(data.settings.worker_threads, 10) || 1) ); this.settings.monitoredDirectories = data.settings.monitored_directories || []; this.settings.galleryRootPaths = data.settings.gallery_root_paths || []; // Backward compat: if server only returned old field if (!this.settings.galleryRootPaths.length && data.settings.gallery_root_path) { this.settings.galleryRootPaths = [data.settings.gallery_root_path]; } } } } catch (error) { console.error("Settings load error:", error); } } async loadStatistics() { try { const response = await ApiClient.fetchUrl("/prompt_manager/stats"); if (response.ok) { const data = await response.json(); if (data.success) { // Try different possible response structures const stats = data.stats || data; document.getElementById("totalPrompts").textContent = stats.total_prompts || data.total_prompts || 0; document.getElementById("totalCategories").textContent = stats.total_categories || data.total_categories || 0; document.getElementById("totalTags").textContent = stats.total_tags || data.total_tags || 0; document.getElementById("avgRating").textContent = (stats.avg_rating || data.avg_rating) ? (stats.avg_rating || data.avg_rating).toFixed(1) : "N/A"; } else { console.error("Stats API returned success=false:", data); } } else { console.error("Stats API HTTP error:", response.status, response.statusText); } } catch (error) { console.error("Stats error:", error); // Set some default values if API fails document.getElementById("totalPrompts").textContent = this.prompts.length || 0; document.getElementById("totalCategories").textContent = this.categories.length || 0; document.getElementById("totalTags").textContent = this.tags.length || 0; document.getElementById("avgRating").textContent = "N/A"; } } async loadCategories() { try { const response = await ApiClient.fetchUrl("/prompt_manager/categories"); if (response.ok) { const data = await response.json(); if (data.success) { this.categories = data.categories; this.populateCategoryDropdown(); } } } catch (error) { console.error("Categories error:", error); } } async loadTags() { try { const response = await ApiClient.fetchUrl("/prompt_manager/tags"); if (response.ok) { const data = await response.json(); if (data.success) { this.tags = data.tags; } } } catch (error) { console.error("Tags error:", error); } } populateCategoryDropdown() { const select = document.getElementById("searchCategory"); select.innerHTML = ''; this.categories.forEach((category) => { const option = document.createElement("option"); option.value = category; option.textContent = category; select.appendChild(option); }); } async loadSubfolders() { try { const response = await ApiClient.fetchUrl("/prompt_manager/subfolders?include_ancestors=true"); if (response.ok) { const data = await response.json(); if (data.success) { this.subfolders = data.subfolders; this.populateFolderDropdown(); } } } catch (error) { console.error("Subfolders error:", error); } } populateFolderDropdown() { const select = document.getElementById("searchFolder"); const current = select.value; select.textContent = ''; const defaultOpt = document.createElement("option"); defaultOpt.value = ""; defaultOpt.textContent = "All Folders"; select.appendChild(defaultOpt); this.subfolders.forEach((folder) => { const option = document.createElement("option"); option.value = folder; const depth = folder.split("/").length - 1; const indent = "\u00A0\u00A0".repeat(depth); const label = folder.split("/").pop(); option.textContent = indent + label; select.appendChild(option); }); select.value = current; } /** * Load one page of recent prompts. The page is committed to this.pagination only * after the server answered; a newer list request supersedes one still in flight. */ async loadRecentPrompts(page = 1, { append = false } = {}) { this.listMode = "recent"; const url = PromptListSort.buildRecentUrl({ page, limit: this.pagination.limit, sort: this.currentSort(), }); try { const data = await this.listRequest((signal) => this.api.get(url, { signal })); if (data === ApiClient.STALE) return; // a newer list request owns the loading state const next = ListState.nextPageState( { page: this.pagination.currentPage, limit: this.pagination.limit, total: this.pagination.total }, { page: data.pagination.page, total: data.pagination.total }, ); this.prompts = append ? this.prompts.concat(data.results) : data.results; this.pagination = { ...this.pagination, currentPage: next.page, total: next.total, totalPages: next.totalPages }; this.renderPrompts(); this.updatePaginationControls(); document.getElementById("resultsTitle").textContent = "Recent Prompts"; this.maybeLoadMorePrompts(); // a short page may not fill the list } catch (error) { console.error("Recent prompts error:", error); this.showNotification("Failed to load prompts", "error"); } this.showListLoaded(); this.updatePaginationVisibility(); } showListLoaded() { document.getElementById("loadingState").classList.add("hidden"); document.getElementById("resultsList").classList.remove("hidden"); } updateLocalStats() { // Only update local stats if we haven't loaded proper statistics yet // This prevents overwriting the correct total counts from the API const currentTotal = document.getElementById("totalPrompts").textContent; // Only update if the current total is still the default "-" if (currentTotal === "-") { // Show local counts as fallback only when API hasn't loaded yet document.getElementById("totalPrompts").textContent = this.prompts.length; const uniqueCategories = new Set(this.prompts.map(p => p.category).filter(Boolean)); document.getElementById("totalCategories").textContent = uniqueCategories.size; const allTags = this.prompts.flatMap(p => Array.isArray(p.tags) ? p.tags : []); const uniqueTags = new Set(allTags.filter(Boolean)); document.getElementById("totalTags").textContent = uniqueTags.size; const ratings = this.prompts.map(p => p.rating).filter(r => r && r > 0); if (ratings.length > 0) { const avgRating = ratings.reduce((a, b) => a + b, 0) / ratings.length; document.getElementById("avgRating").textContent = avgRating.toFixed(1); } } } // ── Tag autocomplete ───────────────────────────────────────────── scheduleTagSuggestions(delay = 150) { clearTimeout(this.tagSuggest.timer); this.tagSuggest.timer = setTimeout(() => this.loadTagSuggestions(), delay); } async loadTagSuggestions() { const input = document.getElementById("searchTags"); const url = TagAutocomplete.suggestUrl(input.value); try { const data = await this.tagSuggest.request((signal) => this.api.get(url, { signal })); if (data === ApiClient.STALE) return; this.renderTagSuggestions(data.suggestions || []); } catch (_) { this.hideTagSuggestions(); } } renderTagSuggestions(items) { const box = document.getElementById("searchTagSuggestions"); this.tagSuggest.items = items; this.tagSuggest.index = -1; const focused = document.activeElement === document.getElementById("searchTags"); if (!items.length || !focused) { box.classList.add("hidden"); return; } box.innerHTML = items.map((s, i) => `
${escapeHtml(s.name)} ${escapeHtml(String(s.count))}
`).join(""); box.classList.remove("hidden"); } hideTagSuggestions() { document.getElementById("searchTagSuggestions").classList.add("hidden"); this.tagSuggest.index = -1; } highlightTagSuggestion() { const box = document.getElementById("searchTagSuggestions"); box.querySelectorAll("[data-index]").forEach((el) => { const selected = Number(el.dataset.index) === this.tagSuggest.index; el.classList.toggle("bg-pm-hover", selected); if (selected) el.scrollIntoView({ block: "nearest" }); }); } handleTagSuggestKey(e) { const open = !document.getElementById("searchTagSuggestions").classList.contains("hidden"); if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); if (!open) { this.scheduleTagSuggestions(0); return; } const delta = e.key === "ArrowDown" ? 1 : -1; this.tagSuggest.index = TagAutocomplete.moveSelection(this.tagSuggest.index, delta, this.tagSuggest.items.length); this.highlightTagSuggestion(); return; } if ((e.key === "Enter" || e.key === "Tab") && open && this.tagSuggest.index >= 0) { e.preventDefault(); this.pickTagSuggestion(this.tagSuggest.items[this.tagSuggest.index].name); return; } if (e.key === "Enter") { this.hideTagSuggestions(); this.search(); return; } if (e.key === "Escape") this.hideTagSuggestions(); } pickTagSuggestion(tag) { const input = document.getElementById("searchTags"); input.value = TagAutocomplete.applySuggestion(input.value, tag); input.focus(); this.scheduleTagSuggestions(0); // offer the next tag, narrowed by this one } async search(page = 1, { append = false } = {}) { const searchText = document.getElementById("searchText").value; const category = document.getElementById("searchCategory").value; const tags = document.getElementById("searchTags").value; if (!append) { document.getElementById("loadingState").classList.remove("hidden"); document.getElementById("resultsList").classList.add("hidden"); } try { const params = new URLSearchParams(); if (searchText) params.append("text", searchText); if (category) params.append("category", category); if (tags) params.append("tags", tags); const folder = document.getElementById("searchFolder").value; if (folder) params.append("folder", folder); const limit = this.pagination.limit; params.append("limit", String(limit)); params.append("offset", String((page - 1) * limit)); params.append("page", String(page)); this.listMode = "search"; const url = `/prompt_manager/search?${PromptListSort.withSort(params, this.currentSort())}`; const data = await this.listRequest((signal) => this.api.get(url, { signal })); if (data === ApiClient.STALE) return; // a newer list request owns the loading state const reported = data.pagination || {}; const next = ListState.nextPageState( { page: this.pagination.currentPage, limit, total: this.pagination.total }, { page: reported.page ?? page, total: reported.total ?? data.results.length }, ); this.prompts = append ? this.prompts.concat(data.results) : data.results; this.pagination = { ...this.pagination, currentPage: next.page, total: next.total, totalPages: next.totalPages }; this.renderPrompts(); this.updatePaginationControls(); document.getElementById("resultsTitle").textContent = "Search Results"; this.maybeLoadMorePrompts(); } catch (error) { this.showNotification("Search failed", "error"); console.error("Search error:", error); } this.showListLoaded(); this.updatePaginationVisibility(); } currentSort() { return PromptListSort.normalizeSort(document.getElementById("sortBy")?.value); } /** Re-fetch the list currently shown from its first page, e.g. after the sort changes. */ reloadPrompts() { return this.listMode === "search" ? this.search() : this.loadRecentPrompts(1); } /** Re-fetch the page currently shown, e.g. after a prompt was edited, tagged or deleted. */ refreshList() { // With infinite scroll the list holds several pages; re-fetch from the top. const page = this.infiniteScroll ? 1 : this.pagination.currentPage; return this.listMode === "search" ? this.search(page) : this.loadRecentPrompts(page); } // ── Infinite scrolling ─────────────────────────────────────────── setInfiniteScroll(enabled, { session }) { if (session) this.infiniteScrollChosen = true; const changed = this.infiniteScroll !== !!enabled; this.infiniteScroll = !!enabled; document.getElementById("infiniteScrollSession").checked = this.infiniteScroll; this.updatePaginationVisibility(); if (changed && this.prompts.length) this.reloadPrompts(); } updatePaginationVisibility() { document.getElementById("paginationControls").classList.toggle("hidden", this.infiniteScroll); } maybeLoadMorePrompts() { const el = document.getElementById("resultsList"); const wanted = ListState.shouldLoadMore({ infiniteScroll: this.infiniteScroll, loading: this.loadingMore, page: this.pagination.currentPage, limit: this.pagination.limit, total: this.pagination.total, scrollTop: el.scrollTop, clientHeight: el.clientHeight, scrollHeight: el.scrollHeight, }); if (wanted) this.loadMorePrompts(); } async loadMorePrompts() { if (this.loadingMore) return; this.loadingMore = true; try { const page = this.pagination.currentPage + 1; await (this.listMode === "search" ? this.search(page, { append: true }) : this.loadRecentPrompts(page, { append: true })); } finally { this.loadingMore = false; } } handleActionClick(e) { const target = e.target.closest("[data-action]"); if (target) DataActions.dispatch(target.dataset, this.actionHandlers, target); } /** Swap a thumbnail for its full-size image once, when the thumbnail fails to load. */ applyImageFallback(img) { if (!img || img.tagName !== "IMG" || !img.dataset.fallbackSrc) return; const fallback = img.dataset.fallbackSrc; delete img.dataset.fallbackSrc; img.src = fallback; } renderPrompts() { const container = document.getElementById("resultsList"); const count = document.getElementById("resultsCount"); count.textContent = `${this.prompts.length} prompts`; if (this.prompts.length === 0) { container.innerHTML = `
📭

No prompts found

Try adjusting your search criteria or create some prompts in ComfyUI

`; return; } // Already in the selected order: the server sorts across all pages container.innerHTML = this.prompts.map((prompt) => this.renderPromptItem(prompt)).join(""); this.selectedPrompts.clear(); this.updateBulkActionButtons(); // Add hover behavior to all star ratings this.prompts.forEach(prompt => { this.addStarHoverBehavior(prompt.id); }); // Load film strips for each prompt (async, non-blocking) this.loadAllFilmStrips(this.prompts); } async loadAllFilmStrips(prompts) { for (const prompt of prompts) { const container = document.getElementById(`filmStrip-${prompt.id}`); if (!container) continue; // Use pre-loaded images from API response if available if (prompt.images && prompt.images.length > 0) { container.innerHTML = this.createFilmStrip(prompt.images, prompt.id); // trusted internal HTML } else if (prompt.image_count > 0) { // Images exist but weren't pre-loaded — fetch individually this.loadFilmStripForPrompt(prompt.id); } } } async loadFilmStripForPrompt(promptId) { const container = document.getElementById(`filmStrip-${promptId}`); if (!container) return; try { const images = await this.loadFilmStripImages(promptId); container.innerHTML = this.createFilmStrip(images, promptId); // trusted internal HTML } catch (error) { console.error(`Error loading filmstrip for prompt ${promptId}:`, error); container.innerHTML = `
Failed to load images
`; // static HTML } } renderPromptItem(prompt) { const tags = Array.isArray(prompt.tags) ? prompt.tags : []; const category = prompt.category || "No category"; const rating = prompt.rating || 0; const created = new Date(prompt.created_at).toLocaleDateString(); const runLabel = PromptListSort.formatRunCount(prompt.run_count); const lastUsedTitle = prompt.last_used_at ? `Last used ${new Date(prompt.last_used_at).toLocaleString()}` : ""; return `
${escapeHtml(prompt.text)}
📁 ${escapeHtml(category)}
📅 ${created}
${runLabel ? `
🔁 ${escapeHtml(runLabel)}
` : ""}
${this.renderStars(rating, prompt.id)}
${tags.slice(0, 10).map(tag => ` ${escapeHtml(tag)} `).join("")}
${tags.length > 10 ? ` ` : ''}
Loading images...
`; } renderStars(rating, promptId) { return Array.from({ length: 5 }, (_, i) => { const starNum = i + 1; const isActive = starNum <= rating; const starIcon = isActive ? '⭐' : '☆'; // filled star vs outline star const color = isActive ? 'var(--pm-warning)' : 'var(--pm-text-secondary)'; // yellow-400 : gray-400 return ``; }).join(""); } addStarHoverBehavior(promptId) { const starButtons = document.querySelectorAll(`[data-prompt-id="${promptId}"].star-btn`); const ratingElement = document.querySelector(`[data-id="${promptId}"][data-rating]`); const currentRating = parseInt(ratingElement?.dataset.rating || 0); starButtons.forEach((starBtn, index) => { const starNum = index + 1; starBtn.addEventListener('mouseenter', () => { // Light up all stars up to this one on hover starButtons.forEach((btn, i) => { if (i < starNum) { btn.textContent = '⭐'; btn.style.color = 'var(--pm-warning)'; // bright yellow on hover } else { btn.textContent = '☆'; btn.style.color = 'var(--pm-text-secondary)'; // gray } }); }); starBtn.addEventListener('mouseleave', () => { // Reset to current rating starButtons.forEach((btn, i) => { if (i < currentRating) { btn.textContent = '⭐'; btn.style.color = 'var(--pm-warning)'; // yellow } else { btn.textContent = '☆'; btn.style.color = 'var(--pm-text-secondary)'; // gray } }); }); }); } showNotification(message, type = "info") { const notification = document.createElement("div"); // Check if ViewerJS is open and use higher z-index const viewerContainer = document.querySelector('.viewer-container'); const isViewerOpen = viewerContainer && viewerContainer.style.display !== 'none'; const zIndex = isViewerOpen ? 'z-[50000]' : 'z-50'; notification.className = `fixed top-4 right-4 px-4 py-2 rounded-pm-sm shadow-pm text-sm ${zIndex} transition-all duration-300 transform translate-x-full`; const colors = { success: "bg-pm-success text-pm", error: "bg-pm-error text-pm", warning: "bg-pm-warning text-pm", info: "bg-pm-accent text-pm" }; notification.className += ` ${colors[type] || colors.info}`; notification.textContent = message; document.body.appendChild(notification); setTimeout(() => notification.classList.remove("translate-x-full"), 100); setTimeout(() => { notification.classList.add("translate-x-full"); setTimeout(() => { if (notification.parentNode) { document.body.removeChild(notification); } }, 300); }, 3000); } showModal(modalId) { document.getElementById(modalId).classList.remove("hidden"); document.getElementById(modalId).classList.add("flex"); document.body.style.overflow = "hidden"; } hideModal(modalId) { document.getElementById(modalId).classList.add("hidden"); document.getElementById(modalId).classList.remove("flex"); document.body.style.overflow = ""; } checkUpdateNotice() { const NOTICE_VERSION = "3.2.0"; const dismissed = localStorage.getItem("pm_update_notice_dismissed"); if (dismissed === NOTICE_VERSION) return; this.showModal("updateNoticeModal"); document.getElementById("updateNoticeDismiss").addEventListener("click", () => { if (document.getElementById("dismissUpdateNotice").checked) { localStorage.setItem("pm_update_notice_dismissed", NOTICE_VERSION); } this.hideModal("updateNoticeModal"); }); document.getElementById("updateNoticeScan").addEventListener("click", () => { localStorage.setItem("pm_update_notice_dismissed", NOTICE_VERSION); this.hideModal("updateNoticeModal"); this.showScanModal(); }); } renderWorkerThreads() { const threads = document.getElementById("workerThreads"); document.getElementById("workerThreadsValue").textContent = `${threads.value} / ${this.settings.cpuCount} cores`; } showSettingsModal() { document.getElementById("resultTimeout").value = this.settings.resultTimeout; document.getElementById("webuiDisplayMode").value = this.settings.webuiDisplayMode; document.getElementById("infiniteScrollDefault").checked = this.settings.infiniteScroll; const threads = document.getElementById("workerThreads"); threads.max = this.settings.cpuCount; threads.value = this.settings.workerThreads; this.renderWorkerThreads(); this.renderScanPaths(); this.updateMonitoringStatus(); this.detectLoraManager(); this.showModal("settingsModal"); } async updateMonitoringStatus() { const statusEl = document.getElementById("monitoringStatus"); try { const response = await ApiClient.fetchUrl("/prompt_manager/settings"); if (response.ok) { const data = await response.json(); const dirs = data.settings?.monitored_directories || []; if (dirs.length > 0) { statusEl.innerHTML = dirs.map(d => `
✓ ${escapeHtml(d)}
`).join(''); } else { statusEl.textContent = 'No directories being monitored (auto-detect on restart)'; } } } catch (error) { statusEl.textContent = 'Unable to fetch status'; } } // ── LoraManager integration ────────────────────────────── async detectLoraManager() { const badge = document.getElementById("loraDetectionBadge"); const toggle = document.getElementById("loraEnabled"); const settings = document.getElementById("loraSettings"); try { // First check current status const statusRes = await ApiClient.fetchUrl("/prompt_manager/lora/status"); if (statusRes.ok) { const status = await statusRes.json(); if (status.success && status.enabled) { badge.textContent = "enabled"; badge.className = "text-[10px] px-1.5 py-0.5 rounded-full bg-green-500/20 text-green-400"; toggle.checked = true; toggle.disabled = false; document.getElementById("loraManagerPath").value = status.path; document.getElementById("loraTriggerWords").checked = status.trigger_words_enabled; const keyInput = document.getElementById("civitaiApiKey"); keyInput.value = ""; keyInput.placeholder = status.has_civitai_api_key ? "A key is stored — leave empty to keep it" : "Optional — required for NSFW example images"; document.getElementById("clearCivitaiKey").checked = false; settings.classList.remove("hidden"); this._loraPath = status.path; this._bindLoraEvents(); return; } } // Try auto-detection const detectRes = await ApiClient.fetchUrl("/prompt_manager/lora/detect"); if (!detectRes.ok) return; const data = await detectRes.json(); if (data.detected) { badge.textContent = "detected"; badge.className = "text-[10px] px-1.5 py-0.5 rounded-full bg-blue-500/20 text-blue-400"; toggle.disabled = false; document.getElementById("loraManagerPath").value = data.path; this._loraPath = data.path; } else { badge.textContent = "not installed"; badge.className = "text-[10px] px-1.5 py-0.5 rounded-full bg-pm-input text-pm-muted"; toggle.disabled = true; } this._bindLoraEvents(); } catch (e) { badge.textContent = "error"; badge.className = "text-[10px] px-1.5 py-0.5 rounded-full bg-red-500/20 text-red-400"; } } _bindLoraEvents() { if (this._loraBound) return; this._loraBound = true; const toggle = document.getElementById("loraEnabled"); const settings = document.getElementById("loraSettings"); toggle.addEventListener("change", () => { if (toggle.checked) { settings.classList.remove("hidden"); } else { settings.classList.add("hidden"); } }); document.getElementById("loraImportBtn").addEventListener("click", () => { this.runLoraImport(); }); } async saveLoraSettings() { const enabled = document.getElementById("loraEnabled").checked; const triggerWords = document.getElementById("loraTriggerWords").checked; const civitaiKey = document.getElementById("civitaiApiKey").value.trim(); const clearKey = document.getElementById("clearCivitaiKey").checked; const path = this._loraPath || ""; // The server never returns the stored key: an empty field keeps it, the // checkbox removes it, and a non-empty field replaces it const payload = { enabled, path, trigger_words_enabled: triggerWords }; if (clearKey) payload.clear_civitai_api_key = true; else if (civitaiKey) payload.civitai_api_key = civitaiKey; try { const res = await ApiClient.fetchUrl("/prompt_manager/lora/enable", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); const data = await res.json(); if (!data.success) { this.showNotification(data.error || "Failed to save LoRA settings", "error"); return false; } return true; } catch (e) { this.showNotification("Failed to save LoRA settings", "error"); return false; } } async runLoraImport() { const saved = await this.saveLoraSettings(); if (!saved) return; // Close settings and show progress modal this.hideModal("settingsModal"); document.getElementById("loraImportStatus").textContent = "Initializing..."; document.getElementById("loraImportBar").style.width = "0%"; document.getElementById("loraImportPercent").textContent = "0%"; document.getElementById("loraImportProcessed").textContent = "0 processed"; document.getElementById("loraImportImported").textContent = "0 imported"; this.showModal("loraImportModal"); try { const res = await ApiClient.fetchUrl("/prompt_manager/lora/scan", { method: "POST" }); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n\n"); buffer = lines.pop(); for (const line of lines) { if (!line.startsWith("data: ")) continue; try { const data = JSON.parse(line.slice(6)); const pct = data.progress || 0; document.getElementById("loraImportBar").style.width = `${pct}%`; document.getElementById("loraImportPercent").textContent = `${pct}%`; if (data.status) { document.getElementById("loraImportStatus").textContent = data.status; } if (data.processed !== undefined) { document.getElementById("loraImportProcessed").textContent = `${data.processed} processed`; } if (data.imported !== undefined) { document.getElementById("loraImportImported").textContent = `${data.imported} imported`; } if (data.type === "complete") { document.getElementById("loraImportStatus").textContent = `Done — ${data.imported} imported, ${data.skipped} skipped`; this.loadStatistics(); setTimeout(() => this.hideModal("loraImportModal"), 2000); } } catch (e) { /* skip malformed SSE */ } } } } catch (e) { document.getElementById("loraImportStatus").textContent = `Error: ${e.message}`; setTimeout(() => this.hideModal("loraImportModal"), 3000); } } async saveSettings() { const timeout = parseInt(document.getElementById("resultTimeout").value); const displayMode = document.getElementById("webuiDisplayMode").value; const workerThreads = parseInt(document.getElementById("workerThreads").value, 10) || 1; const infiniteScroll = document.getElementById("infiniteScrollDefault").checked; const galleryPaths = this._collectScanPaths().filter(p => p !== ''); this.settings.resultTimeout = timeout; this.settings.webuiDisplayMode = displayMode; this.settings.workerThreads = workerThreads; this.settings.infiniteScroll = infiniteScroll; this.settings.galleryRootPaths = galleryPaths; try { const response = await ApiClient.fetchUrl("/prompt_manager/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ result_timeout: timeout, webui_display_mode: displayMode, worker_threads: workerThreads, infinite_scroll: infiniteScroll, gallery_root_paths: galleryPaths }), }); if (response.ok) { const data = await response.json(); // Save LoRA integration settings (fire-and-forget) await this.saveLoraSettings(); this.setInfiniteScroll(infiniteScroll, { session: false }); if (data.restart_required) { this.showNotification("Settings saved. Restart ComfyUI for gallery path changes to take effect.", "warning"); } else { this.showNotification("Settings saved successfully", "success"); } this.hideModal("settingsModal"); } else { throw new Error("Failed to save settings"); } } catch (error) { this.showNotification("Failed to save settings", "error"); } } renderScanPaths() { const container = document.getElementById("scanPathsList"); const paths = this.settings.galleryRootPaths || []; container.textContent = ''; // Clear safely const toRender = paths.length > 0 ? paths : ['']; toRender.forEach((p, i) => { const row = document.createElement('div'); row.className = 'flex gap-2 items-center'; row.dataset.pathIndex = i; const input = document.createElement('input'); input.type = 'text'; input.value = p; input.placeholder = 'Leave empty for auto-detect'; input.className = 'flex-1 px-2.5 py-1.5 bg-pm-input border border-pm rounded-pm-sm text-pm text-[13px] focus:outline-none scan-path-input'; const removeBtn = document.createElement('button'); removeBtn.type = 'button'; removeBtn.className = 'text-pm-muted hover:text-red-400 p-1 text-lg leading-none'; removeBtn.title = 'Remove'; removeBtn.textContent = '\u00d7'; // multiplication sign as X removeBtn.addEventListener('click', () => this.removeScanPath(i)); row.appendChild(input); row.appendChild(removeBtn); container.appendChild(row); }); } addScanPath() { const paths = this._collectScanPaths(); paths.push(''); this.settings.galleryRootPaths = paths; this.renderScanPaths(); const inputs = document.querySelectorAll('.scan-path-input'); if (inputs.length) inputs[inputs.length - 1].focus(); } removeScanPath(index) { const paths = this._collectScanPaths(); paths.splice(index, 1); this.settings.galleryRootPaths = paths; this.renderScanPaths(); } _collectScanPaths() { return Array.from(document.querySelectorAll('.scan-path-input')) .map(input => input.value.trim()); } toggleSelectAll(checked) { document.querySelectorAll(".prompt-checkbox").forEach((checkbox) => { checkbox.checked = checked; if (checked) { this.selectedPrompts.add(parseInt(checkbox.dataset.id)); } else { this.selectedPrompts.clear(); } }); this.updateBulkActionButtons(); } updateBulkActionButtons() { const hasSelected = this.selectedPrompts.size > 0; document.getElementById("bulkDeleteBtn").disabled = !hasSelected; document.getElementById("bulkTagBtn").disabled = !hasSelected; document.getElementById("bulkCategoryBtn").disabled = !hasSelected; } async setRating(promptId, rating) { // Update UI immediately for better UX const ratingElement = document.querySelector(`[data-id="${promptId}"][data-rating]`); if (ratingElement) { ratingElement.dataset.rating = rating; ratingElement.innerHTML = this.renderStars(rating, promptId); // Add hover behavior to new stars this.addStarHoverBehavior(promptId); } try { const response = await ApiClient.fetchUrl(`/prompt_manager/prompts/${promptId}/rating`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rating }), }); if (response.ok) { this.showNotification("Rating updated", "success"); // Update the prompt data in memory const promptIndex = this.prompts.findIndex(p => p.id === promptId); if (promptIndex !== -1) { this.prompts[promptIndex].rating = rating; } // Refresh stats setTimeout(() => { this.loadStatistics(); }, 500); } else { // Revert UI change on API failure const originalPrompt = this.prompts.find(p => p.id === promptId); const originalRating = originalPrompt ? originalPrompt.rating : 0; if (ratingElement) { ratingElement.dataset.rating = originalRating; ratingElement.innerHTML = this.renderStars(originalRating, promptId); } this.showNotification("Failed to update rating", "error"); } } catch (error) { console.error("Rating update error:", error); // Revert UI change on error const originalPrompt = this.prompts.find(p => p.id === promptId); const originalRating = originalPrompt ? originalPrompt.rating : 0; if (ratingElement) { ratingElement.dataset.rating = originalRating; ratingElement.innerHTML = this.renderStars(originalRating, promptId); } this.showNotification("Failed to update rating", "error"); } } addTag(promptId) { // Store the prompt ID for later use this.currentTagPromptId = promptId; // Clear the input and show the modal document.getElementById("individualTagInput").value = ""; this.showModal("individualTagModal"); } async confirmIndividualTag() { const tags = document.getElementById("individualTagInput").value.split(",") .map((tag) => tag.trim()).filter((tag) => tag); if (tags.length === 0) return; try { const response = await ApiClient.fetchUrl("/prompt_manager/prompts/tags", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt_id: this.currentTagPromptId, tags: tags, }), }); if (response.ok) { this.showNotification(`Tags added to prompt`, "success"); this.hideModal("individualTagModal"); // Refresh both stats and results await this.loadStatistics(); this.refreshList(); } } catch (error) { this.showNotification("Failed to add tags", "error"); } } async removeTag(promptId, tag) { try { const response = await ApiClient.fetchUrl(`/prompt_manager/prompts/${promptId}/tags`, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tag }), }); if (response.ok) { this.showNotification("Tag removed", "success"); // Refresh both stats and results await this.loadStatistics(); this.refreshList(); } } catch (error) { this.showNotification("Failed to remove tag", "error"); } } editPrompt(promptId) { const promptElement = document.querySelector(`[data-id="${promptId}"].prompt-text`); if (!promptElement) return; if (this.activeEdit) this.finishEdit(false); this.activeEdit = { promptId, element: promptElement, originalText: promptElement.textContent }; promptElement.contentEditable = true; promptElement.focus(); promptElement.classList.add("bg-pm-surface", "border", "border-pm-accent", "rounded-pm-sm", "p-3"); } handleEditKeydown(e) { if (!this.activeEdit || e.target !== this.activeEdit.element) return; if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); this.finishEdit(true); } else if (e.key === "Escape") { this.finishEdit(false); } } handleEditFocusOut(e) { if (this.activeEdit && e.target === this.activeEdit.element) this.finishEdit(true); } /** * End the in-place edit exactly once. The edit state is cleared before the element * changes, so the focusout caused by contentEditable=false cannot save a second time. */ async finishEdit(save) { const edit = this.activeEdit; if (!edit) return; this.activeEdit = null; const { element, originalText, promptId } = edit; const newText = element.textContent.trim(); element.contentEditable = false; element.classList.remove("bg-pm-surface", "border", "border-pm-accent", "rounded-pm-sm", "p-3"); if (!save || !ListState.shouldSaveEdit(originalText, newText)) { element.textContent = originalText; return; } try { await this.api.put(`/prompt_manager/prompts/${promptId}`, { text: newText }); this.showNotification("Prompt updated", "success"); await this.loadStatistics(); this.refreshList(); } catch (error) { console.error("Update failed:", error); this.showNotification("Failed to update prompt", "error"); element.textContent = originalText; } } async deletePrompt(promptId) { if (!confirm("Are you sure you want to delete this prompt?")) return; try { await this.api.del(`/prompt_manager/delete/${promptId}`); this.showNotification("Prompt deleted", "success"); await this.loadStatistics(); this.refreshList(); } catch (error) { console.error("Delete failed:", error); this.showNotification("Failed to delete prompt", "error"); } } showBulkTagModal() { this.showModal("bulkTagModal"); } showBulkCategoryModal() { this.showModal("bulkCategoryModal"); } async confirmBulkTag() { const tags = document.getElementById("bulkTagInput").value.split(",") .map((tag) => tag.trim()).filter((tag) => tag); if (tags.length === 0) return; try { const response = await ApiClient.fetchUrl("/prompt_manager/bulk/tags", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt_ids: Array.from(this.selectedPrompts), tags, }), }); if (response.ok) { this.showNotification(`Tags added to ${this.selectedPrompts.size} prompts`, "success"); this.hideModal("bulkTagModal"); // Refresh both stats and results await this.loadStatistics(); this.refreshList(); } } catch (error) { this.showNotification("Failed to add tags", "error"); } } async confirmBulkCategory() { const category = document.getElementById("bulkCategoryInput").value.trim(); if (!category) return; try { const response = await ApiClient.fetchUrl("/prompt_manager/bulk/category", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt_ids: Array.from(this.selectedPrompts), category, }), }); if (response.ok) { this.showNotification(`Category set for ${this.selectedPrompts.size} prompts`, "success"); this.hideModal("bulkCategoryModal"); // Refresh both stats and results await this.loadStatistics(); this.refreshList(); } } catch (error) { this.showNotification("Failed to set category", "error"); } } async bulkDelete() { if (!confirm(`Are you sure you want to delete ${this.selectedPrompts.size} prompts?`)) return; try { const response = await ApiClient.fetchUrl("/prompt_manager/bulk/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt_ids: Array.from(this.selectedPrompts), }), }); if (response.ok) { this.showNotification(`${this.selectedPrompts.size} prompts deleted`, "success"); this.selectedPrompts.clear(); await this.loadStatistics(); this.refreshList(); } } catch (error) { this.showNotification("Failed to delete prompts", "error"); } } async exportPrompts() { try { const response = await ApiClient.fetchUrl("/prompt_manager/export"); if (response.ok) { const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `prompts-${new Date().toISOString().split("T")[0]}.json`; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); this.showNotification("Prompts exported", "success"); } } catch (error) { this.showNotification("Failed to export prompts", "error"); } } async showStats() { await this.loadStatistics(); this.showNotification("📊 Stats refreshed successfully!", "success"); } // Gallery functionality async viewGallery(promptId) { this.showModal("galleryModal"); // Show loading state document.getElementById("galleryLoading").classList.remove("hidden"); document.getElementById("galleryContent").classList.add("hidden"); document.getElementById("galleryEmpty").classList.add("hidden"); try { const response = await ApiClient.fetchUrl(`/prompt_manager/prompts/${promptId}/images`); if (response.ok) { const data = await response.json(); if (data.success) { this.renderGallery(data.images, promptId); } else { throw new Error(data.error || 'Failed to load images'); } } else { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } } catch (error) { console.error('Gallery load error:', error); this.showNotification('Failed to load gallery', 'error'); this.closeGallery(); } finally { document.getElementById("galleryLoading").classList.add("hidden"); } } renderGallery(images, promptId) { const content = document.getElementById("galleryContent"); const empty = document.getElementById("galleryEmpty"); // Update title const prompt = this.prompts.find(p => p.id === promptId); const promptText = prompt ? prompt.text.substring(0, 50) + (prompt.text.length > 50 ? '...' : '') : 'Unknown Prompt'; document.getElementById("galleryTitle").textContent = `Gallery: ${promptText}`; if (!images || images.length === 0) { content.classList.add("hidden"); empty.classList.remove("hidden"); return; } content.classList.remove("hidden"); empty.classList.add("hidden"); const formatFileSize = (bytes) => this.formatFileSize(bytes); content.innerHTML = images.map((image, index) => { const generated = new Date(image.generation_time); const when = escapeHtml(`${generated.toLocaleDateString()} ${generated.toLocaleTimeString()}`); const fileUrl = ApiClient.resolveUrl(`/prompt_manager/images/${escapeHtml(image.id)}/file`); const caption = ImageHelpers.formatImageCaption(image, { formatFileSize }); const details = ImageHelpers.formatImageCaption(image, { formatFileSize, separator: ' • ' }); return `
Generated image ${index + 1}
${when}
${details}
`; }).join(''); // Store images for navigation this.currentGalleryImages = images; // Initialize ViewerJS for the gallery after DOM is updated setTimeout(() => { if (this.galleryViewer) { this.invalidateMetadataRequest(); this.galleryViewer.destroy(); } this.galleryViewer = new Viewer(content, { toolbar: { zoomIn: 1, zoomOut: 1, oneToOne: 1, reset: 1, prev: 1, play: { show: 1, size: 'large', }, next: 1, rotateLeft: 1, rotateRight: 1, flipHorizontal: 1, flipVertical: 1, }, navbar: true, button: true, title: true, transition: true, keyboard: true, backdrop: true, loading: true, loop: true, tooltip: true, zoomRatio: 0.1, minZoomRatio: 0.1, maxZoomRatio: 4, zoomOnTouch: true, zoomOnWheel: true, slideOnTouch: true, toggleOnDblclick: false, className: '', shown: () => { this.addMetadataSidebar(); }, viewed: (event) => { this.loadMetadataForImage(event.detail.image); }, hide: () => this.invalidateMetadataRequest(), hidden: () => { const sidebar = document.getElementById('metadata-sidebar'); if (sidebar) sidebar.remove(); } }); }, 100); } invalidateMetadataRequest() { this.metadataRequest = null; this.currentMetadata = null; } closeGallery() { this.invalidateMetadataRequest(); this.hideModal("galleryModal"); } formatFileSize(bytes) { if (!bytes) return 'Unknown'; const units = ['B', 'KB', 'MB', 'GB']; let size = bytes; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex++; } return `${size.toFixed(1)} ${units[unitIndex]}`; } // Diagnostics functionality showDiagnosticsModal() { this.showModal("diagnosticsModal"); } closeDiagnostics() { this.hideModal("diagnosticsModal"); } // Maintenance functionality showMaintenanceModal() { this.showModal("maintenanceModal"); } closeMaintenance() { this.hideModal("maintenanceModal"); } selectAllMaintenance() { document.querySelectorAll('.maintenance-option').forEach(checkbox => { checkbox.checked = true; }); } clearAllMaintenance() { document.querySelectorAll('.maintenance-option').forEach(checkbox => { checkbox.checked = false; }); } async runMaintenance() { const resultsContainer = document.getElementById("maintenanceResults"); const runButton = document.getElementById("runMaintenanceBtn"); // Get selected operations const selectedOps = Array.from(document.querySelectorAll('.maintenance-option:checked')) .map(checkbox => checkbox.id); if (selectedOps.length === 0) { this.showNotification('Please select at least one maintenance operation', 'warning'); return; } // Show loading state runButton.disabled = true; runButton.innerHTML = '🔄 Running...'; resultsContainer.innerHTML = `

Running maintenance operations...

Operations: ${selectedOps.map(op => op.replace(/_/g, ' ')).join(', ')}

`; try { const response = await ApiClient.fetchUrl('/prompt_manager/maintenance', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ operations: selectedOps }) }); if (response.ok) { const data = await response.json(); if (data.success) { this.renderMaintenanceResults(data); // Show overall success notification if (data.all_successful) { this.showNotification('✅ All maintenance operations completed successfully!', 'success'); } else { this.showNotification('⚠️ Some maintenance operations had issues', 'warning'); } // Refresh stats after maintenance setTimeout(() => this.loadStatistics(), 1000); } else { throw new Error(data.error || 'Maintenance failed'); } } else { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } } catch (error) { console.error('Maintenance error:', error); resultsContainer.innerHTML = `

❌ Maintenance Failed

${escapeHtml(error.message)}

`; this.showNotification('❌ Maintenance failed', 'error'); } finally { runButton.disabled = false; runButton.innerHTML = '🔧 Run Maintenance'; } } renderMaintenanceResults(data) { const resultsContainer = document.getElementById("maintenanceResults"); let html = '
'; // Summary html += '
'; html += '

📋 Maintenance Summary

'; html += '
'; html += `
Operations Completed ${escapeHtml(data.operations_completed)}
`; html += `
Overall Success ${data.all_successful ? '✅ ALL PASSED' : '⚠️ SOME ISSUES'}
`; html += '
'; // Detailed results for (const [operation, result] of Object.entries(data.results)) { const bgColor = result.success ? 'bg-pm-success-tint border-pm-success' : 'bg-pm-error-tint border-pm-error'; const statusIcon = result.success ? '✅' : '❌'; const statusText = result.success ? 'SUCCESS' : 'FAILED'; html += `
`; html += `

`; html += `${statusIcon}`; html += `${escapeHtml(operation.replace(/_/g, ' '))}`; html += `${statusText}`; html += `

`; if (result.message) { html += `

${escapeHtml(result.message)}

`; } // Show specific details if (result.removed_count !== undefined) { html += `
Items removed: ${escapeHtml(result.removed_count)}
`; } if (result.duplicate_hashes !== undefined) { html += `
Duplicate hash groups found: ${escapeHtml(result.duplicate_hashes)}
`; } if (result.issues_found !== undefined) { html += `
Issues found: ${escapeHtml(result.issues_found)}
`; if (result.issues && result.issues.length > 0) { html += ``; } } if (result.info) { html += `
`; html += `

Total prompts: ${escapeHtml(result.info.total_prompts || 'N/A')}

`; html += `

Database size: ${escapeHtml(result.info.database_size_bytes ? this.formatFileSize(result.info.database_size_bytes) : 'N/A')}

`; html += `
`; } if (result.error) { html += `
`; html += `Error: ${escapeHtml(result.error)}`; html += `
`; } html += '
'; } html += '
'; resultsContainer.innerHTML = html; } async runDiagnostics() { const content = document.getElementById("diagnosticsContent"); content.innerHTML = `

Running diagnostics...

`; try { const response = await ApiClient.fetchUrl('/prompt_manager/diagnostics'); if (response.ok) { const data = await response.json(); if (data.success) { this.renderDiagnostics(data.diagnostics); } else { throw new Error(data.error || 'Diagnostics failed'); } } else { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } } catch (error) { console.error('Diagnostics error:', error); content.innerHTML = `

❌ Diagnostics Failed

${escapeHtml(error.message)}

`; } } renderDiagnostics(diagnostics) { const content = document.getElementById("diagnosticsContent"); let html = '
'; // Summary html += '
'; html += '

📋 Diagnostic Summary

'; html += '
'; for (const [category, result] of Object.entries(diagnostics)) { const status = result.status === 'ok' ? '✅ PASS' : (result.status === 'warning' ? '⚠️ WARNING' : '❌ FAIL'); const statusColor = result.status === 'ok' ? 'text-pm-success' : (result.status === 'warning' ? 'text-pm-warning' : 'text-pm-error'); html += `
${escapeHtml(category)} ${status}
`; } html += '
'; // Detailed results for (const [category, result] of Object.entries(diagnostics)) { const bgColor = result.status === 'ok' ? 'bg-pm-success-tint border-pm-success' : (result.status === 'warning' ? 'bg-pm-warning/20 border-pm-warning' : 'bg-pm-error-tint border-pm-error'); html += `
`; html += `

${escapeHtml(category)}

`; if (result.message) { html += `

${escapeHtml(result.message)}

`; } // Show specific details based on category if (category === 'database' && result.status === 'ok') { html += `
`; html += `

Prompts: ${escapeHtml(result.prompt_count || 0)}

`; html += `

Images table: ${result.has_images_table ? 'Yes' : 'No'}

`; html += `
`; } if (category === 'images_table' && result.status === 'ok') { html += `
`; html += `

Images: ${escapeHtml(result.image_count || 0)}

`; if (result.recent_images && result.recent_images.length > 0) { html += `

Recent images:

`; html += `
    `; result.recent_images.slice(0, 3).forEach(img => { html += `
  • ${escapeHtml(img.filename)} → Prompt ${escapeHtml(img.prompt_id)}
  • `; }); html += `
`; } html += `
`; } if (category === 'comfyui_output' && result.output_dirs) { html += `
`; html += `

Output directories found:

`; html += `
    `; result.output_dirs.forEach(dir => { html += `
  • ${escapeHtml(dir)}
  • `; }); html += `
`; html += `
`; } if (category === 'dependencies' && result.dependencies) { html += `
`; for (const [dep, available] of Object.entries(result.dependencies)) { const status = available ? '✅' : '❌'; html += `

${status} ${escapeHtml(dep)}

`; } html += `
`; } html += '
'; } html += '
'; content.innerHTML = html; } async testImageLink() { if (this.prompts.length === 0) { this.showNotification('No prompts available for testing', 'warning'); return; } // Use the first prompt for testing const testPrompt = this.prompts[0]; try { const response = await ApiClient.fetchUrl('/prompt_manager/diagnostics/test-link', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt_id: testPrompt.id, image_path: '/test/fake/image.png' }) }); if (response.ok) { const data = await response.json(); if (data.success) { this.showNotification('✅ Test image link created successfully!', 'success'); // Refresh the gallery to show the test image setTimeout(() => this.refreshList(), 1000); } else { throw new Error(data.result?.message || 'Test failed'); } } else { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } } catch (error) { console.error('Test link error:', error); this.showNotification(`❌ Test failed: ${error.message}`, 'error'); } } // Database backup and restore functionality async backupDatabase() { try { const response = await ApiClient.fetchUrl('/prompt_manager/backup'); if (response.ok) { // Get the filename from the Content-Disposition header const contentDisposition = response.headers.get('Content-Disposition'); let filename = 'prompts_backup.db'; if (contentDisposition) { const filenameMatch = contentDisposition.match(/filename="(.+)"/); if (filenameMatch) { filename = filenameMatch[1]; } } // Create blob and download const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); this.showNotification('💾 Database backup downloaded successfully!', 'success'); } else { const errorData = await response.json(); throw new Error(errorData.error || 'Backup failed'); } } catch (error) { console.error('Backup error:', error); this.showNotification(`❌ Backup failed: ${error.message}`, 'error'); } } showRestoreModal() { // Clear previous file selection document.getElementById('restoreFileInput').value = ''; this.showModal('restoreModal'); } async confirmRestore() { const fileInput = document.getElementById('restoreFileInput'); const file = fileInput.files[0]; if (!file) { this.showNotification('Please select a database file to restore', 'warning'); return; } if (!file.name.endsWith('.db')) { this.showNotification('Please select a valid .db file', 'warning'); return; } // Show confirmation const confirmed = confirm( `Are you sure you want to restore from "${file.name}"?\n\n` + 'This will replace your current database. A backup will be created automatically.\n\n' + 'This action cannot be undone.' ); if (!confirmed) { return; } try { // Disable the restore button and show loading const confirmBtn = document.getElementById('confirmRestore'); confirmBtn.disabled = true; confirmBtn.innerHTML = '🔄 Restoring...'; // Create FormData for file upload const formData = new FormData(); formData.append('database_file', file); const response = await ApiClient.fetchUrl('/prompt_manager/restore', { method: 'POST', body: formData }); const data = await response.json(); if (response.ok && data.success) { this.showNotification( `✅ Database restored successfully! Found ${data.prompt_count} prompts.`, 'success' ); this.hideModal('restoreModal'); // Refresh the interface to show the restored data await this.loadInitialData(); } else { throw new Error(data.error || 'Restore failed'); } } catch (error) { console.error('Restore error:', error); this.showNotification(`❌ Restore failed: ${error.message}`, 'error'); } finally { // Re-enable the restore button const confirmBtn = document.getElementById('confirmRestore'); confirmBtn.disabled = false; confirmBtn.innerHTML = 'Restore Database'; } } // Copy prompt to clipboard functionality async copyPromptToClipboard(promptId) { // Find the prompt text from the prompts array const prompt = this.prompts.find(p => p.id === promptId); if (!prompt) { this.showNotification('❌ Prompt not found', 'error'); return; } const promptText = prompt.text; try { // Use the modern Clipboard API if available if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(promptText); this.showNotification('📋 Prompt copied to clipboard!', 'success'); } else { // Fallback for older browsers or non-secure contexts const textArea = document.createElement('textarea'); textArea.value = promptText; textArea.style.position = 'fixed'; textArea.style.left = '-999999px'; textArea.style.top = '-999999px'; document.body.appendChild(textArea); textArea.focus(); textArea.select(); if (document.execCommand('copy')) { this.showNotification('📋 Prompt copied to clipboard!', 'success'); } else { throw new Error('Copy command failed'); } document.body.removeChild(textArea); } } catch (error) { console.error('Copy to clipboard failed:', error); this.showNotification('❌ Failed to copy prompt to clipboard', 'error'); // Show a fallback modal with the text for manual copying this.showCopyFallbackModal(promptText); } } showCopyFallbackModal(text) { // Create a temporary modal for manual copy const modal = document.createElement('div'); modal.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50'; modal.innerHTML = `

Copy Prompt Text

Please manually copy the text below:

`; document.body.appendChild(modal); document.body.style.overflow = "hidden"; // Auto-select the text in the textarea const textarea = modal.querySelector('textarea'); textarea.value = text; textarea.focus(); textarea.select(); // Close modal when clicking outside modal.addEventListener('click', (e) => { if (e.target === modal) { modal.remove(); document.body.style.overflow = ""; } }); // Also handle the close button modal.querySelector('button').addEventListener('click', () => { document.body.style.overflow = ""; }); } // Scan functionality // // The scan is a background job on the server. The modal shows its progress, // can be hidden to a small pill with the Hide button while it runs (backdrop // clicks are ignored), and re-attaches to a scan still running after a reload. showScanModal() { if (this.scanState.status !== ScanProgress.RUNNING) this.resetScanControls(); document.getElementById("scanPill").classList.add("hidden"); this.showModal("scanModal"); } resetScanControls() { document.getElementById("scanProgress").classList.add("hidden"); const start = document.getElementById("startScan"); start.disabled = false; start.textContent = "Start Scan"; document.getElementById("cancelScan").textContent = "Cancel"; } dismissScanModal() { if (ScanProgress.dismissAction(this.scanState) === "minimize") { this.minimizeScan(); } else { this.hideModal("scanModal"); } } minimizeScan() { this.hideModal("scanModal"); document.getElementById("scanPillText").textContent = ScanProgress.progressLabel(this.scanState); document.getElementById("scanPill").classList.remove("hidden"); } warnIfScanRunning(e) { if (this.scanState.status !== ScanProgress.RUNNING) return; // The scan keeps running on the server; the prompt only guards the progress view. e.preventDefault(); e.returnValue = ""; } startScan() { if (this.scanState.status === ScanProgress.RUNNING) return; this.scanState = ScanProgress.reduceScanEvent(ScanProgress.initialState(), { type: "progress", progress: 0, status: "Initializing scan...", processed: 0, found: 0, }); this.renderScanState(); this.attachScanStream(); } attachScanStream() { if (this.scanSource) this.scanSource.close(); // POST starts a scan, or attaches to the one already running. const source = SseStream.connect("/prompt_manager/scan", { method: "POST", body: "{}" }); this.scanSource = source; source.onmessage = (e) => { let data; try { data = JSON.parse(e.data); } catch (_) { return; } this.applyScanEvent(data); }; source.onerror = () => { if (this.scanSource !== source || this.scanState.status !== ScanProgress.RUNNING) return; this.showNotification("Lost the connection to the scan; it is still running on the server. Reconnecting...", "warning"); setTimeout(() => this.resumeRunningScan(), 2000); }; source.onclose = () => { // The stream ended without a complete/error event (server restart): ask the server. if (this.scanSource === source && this.scanState.status === ScanProgress.RUNNING) this.resumeRunningScan(); }; } async resumeRunningScan() { let status; try { const response = await ApiClient.fetchUrl("/prompt_manager/scan/status"); if (!response.ok) return; status = await response.json(); } catch (_) { return; } const state = ScanProgress.fromStatus(status); if (state.status !== ScanProgress.RUNNING) { if (this.scanState.status === ScanProgress.RUNNING) { // A scan we were following ended while we were disconnected. const last = status.last_event; const terminal = last && (last.type === "complete" || last.type === "cancelled" || last.type === "error"); this.applyScanEvent( terminal ? last : { type: "error", message: "the server no longer reports a running scan" } ); } return; } this.scanState = state; this.renderScanState(); if (document.getElementById("scanModal").classList.contains("hidden")) this.minimizeScan(); this.attachScanStream(); } applyScanEvent(data) { const next = ScanProgress.reduceScanEvent(this.scanState, data); if (next === this.scanState) return; this.scanState = next; this.renderScanState(); if (next.status === ScanProgress.DONE) this.completeScan(next); else if (next.status === ScanProgress.FAILED) this.failScan(next); } renderScanState() { const state = this.scanState; if (state.status === ScanProgress.IDLE) return; const running = state.status === ScanProgress.RUNNING; document.getElementById("scanProgress").classList.remove("hidden"); const start = document.getElementById("startScan"); start.disabled = running; start.textContent = running ? "Scanning..." : "Start New Scan"; document.getElementById("cancelScan").textContent = running ? "Hide" : "Close"; document.getElementById("scanPillText").textContent = ScanProgress.progressLabel(state); this.updateScanProgress(state.progress, state.statusText, state.processed, state.found); } async quickBackup() { try { const response = await ApiClient.fetchUrl("/prompt_manager/backup"); if (response.ok) { const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `prompts-backup-${new Date().toISOString().split("T")[0]}.db`; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); this.showNotification("Database backup created successfully!", "success"); } else { throw new Error("Backup failed"); } } catch (error) { this.showNotification("Failed to create backup", "error"); } } updateScanProgress(progress, status, processed, found) { document.getElementById("scanProgressBar").style.width = `${progress}%`; document.getElementById("scanStatusText").textContent = status; document.getElementById("scanCount").textContent = `${processed} files processed`; document.getElementById("scanFound").textContent = `${found} prompts found`; } completeScan(state) { document.getElementById("scanPill").classList.add("hidden"); this.showNotification(ScanProgress.completionLabel(state), "success"); // Auto-close after a short delay so the completion message is visible setTimeout(() => { this.hideModal("scanModal"); this.loadStatistics(); this.refreshList(); }, 2000); } failScan(state) { document.getElementById("scanPill").classList.add("hidden"); this.showNotification(`Scan failed: ${state.message}`, "error"); } // Logs functionality showLogsModal() { this.showModal("logsModal"); this.loadLogStats(); this.loadLogFiles(); this.loadLogConfig(); } closeLogs() { this.hideModal("logsModal"); } async loadLogStats() { try { const response = await ApiClient.fetchUrl('/prompt_manager/logs/stats'); const data = await response.json(); if (data.success) { const stats = data.stats; document.getElementById("logStatsTotal").textContent = stats.buffer_count || 0; document.getElementById("logStatsErrors").textContent = stats.level_counts?.ERROR || 0; document.getElementById("logStatsWarnings").textContent = stats.level_counts?.WARNING || 0; document.getElementById("logStatsLevel").textContent = stats.current_level || "INFO"; document.getElementById("logStatsSize").textContent = this.formatBytes(stats.total_log_size || 0); } } catch (error) { console.error("Failed to load log stats:", error); } } async loadLogConfig() { try { const response = await ApiClient.fetchUrl('/prompt_manager/logs/config'); const data = await response.json(); if (data.success) { const config = data.config; document.getElementById("setLogLevel").value = config.level || "INFO"; document.getElementById("consoleLogging").checked = config.console_logging !== false; } } catch (error) { console.error("Failed to load log config:", error); } } async loadLogFiles() { try { const response = await ApiClient.fetchUrl('/prompt_manager/logs/files'); const data = await response.json(); if (data.success) { const container = document.getElementById("logFilesList"); if (data.files.length === 0) { container.innerHTML = '
No log files found
'; return; } container.innerHTML = data.files.map(file => `
${escapeHtml(file.filename)} ${file.is_main ? 'Active' : ''}
Size: ${this.formatBytes(file.size)} | Modified: ${new Date(file.modified).toLocaleString()}
`).join(''); } } catch (error) { console.error("Failed to load log files:", error); } } async refreshLogs() { const level = document.getElementById("logLevel").value; const limit = parseInt(document.getElementById("logLimit").value); try { const url = new URL(ApiClient.resolveUrl('/prompt_manager/logs'), window.location.origin); if (level) url.searchParams.set('level', level); url.searchParams.set('limit', limit.toString()); const response = await ApiClient.fetchUrl(url); const data = await response.json(); if (data.success) { this.displayLogs(data.logs); this.showNotification(`Loaded ${data.logs.length} log entries`, "success"); } else { this.showNotification(`Failed to load logs: ${data.error}`, "error"); } } catch (error) { console.error("Failed to refresh logs:", error); this.showNotification("Failed to refresh logs", "error"); } } displayLogs(logs) { const container = document.getElementById("logsContainer"); if (logs.length === 0) { container.innerHTML = '
No logs found
'; return; } container.innerHTML = logs.map(log => { const levelColors = { DEBUG: 'text-pm-secondary', INFO: 'text-pm-accent', WARNING: 'text-pm-warning', ERROR: 'text-pm-error', CRITICAL: 'text-pm-error' }; const levelColor = levelColors[log.level] || 'text-pm-secondary'; const timestamp = new Date(log.timestamp).toLocaleString(); return `
${escapeHtml(timestamp.split(' ')[1])} ${escapeHtml(log.level)} ${escapeHtml(log.logger)} ${escapeHtml(log.message)}
${escapeHtml(log.filename)}:${escapeHtml(log.lineno)}
`; }).join(''); // Auto-scroll to bottom container.scrollTop = container.scrollHeight; } async downloadLogs() { const level = document.getElementById("logLevel").value; const limit = parseInt(document.getElementById("logLimit").value); try { const url = new URL(ApiClient.resolveUrl('/prompt_manager/logs'), window.location.origin); if (level) url.searchParams.set('level', level); url.searchParams.set('limit', limit.toString()); const response = await ApiClient.fetchUrl(url); const data = await response.json(); if (data.success) { const logText = data.logs.map(log => `${log.timestamp} [${log.level}] ${log.logger}: ${log.message} (${log.filename}:${log.lineno})` ).join('\n'); const blob = new Blob([logText], { type: 'text/plain' }); const url2 = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url2; a.download = `prompt_manager_logs_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.txt`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url2); this.showNotification("Logs downloaded successfully", "success"); } else { this.showNotification(`Failed to download logs: ${data.error}`, "error"); } } catch (error) { console.error("Failed to download logs:", error); this.showNotification("Failed to download logs", "error"); } } async downloadLogFile(filename) { try { const response = await ApiClient.fetchUrl(`/prompt_manager/logs/download/${encodeURIComponent(filename)}`); if (response.ok) { const blob = await response.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); this.showNotification(`Downloaded ${filename}`, "success"); } else { this.showNotification(`Failed to download ${filename}`, "error"); } } catch (error) { console.error("Failed to download log file:", error); this.showNotification("Failed to download log file", "error"); } } async clearLogs() { if (!confirm("Are you sure you want to clear all log files? This cannot be undone.")) { return; } try { const response = await ApiClient.fetchUrl('/prompt_manager/logs/truncate', { method: 'POST' }); const data = await response.json(); if (data.success) { this.showNotification(data.message, "success"); this.refreshLogs(); this.loadLogStats(); this.loadLogFiles(); } else { this.showNotification(`Failed to clear logs: ${data.error}`, "error"); } } catch (error) { console.error("Failed to clear logs:", error); this.showNotification("Failed to clear logs", "error"); } } async updateLogConfig() { const level = document.getElementById("setLogLevel").value; const consoleLogging = document.getElementById("consoleLogging").checked; try { const response = await ApiClient.fetchUrl('/prompt_manager/logs/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ level: level, console_logging: consoleLogging }) }); const data = await response.json(); if (data.success) { this.showNotification("Log configuration updated", "success"); this.loadLogStats(); } else { this.showNotification(`Failed to update config: ${data.error}`, "error"); } } catch (error) { console.error("Failed to update log config:", error); this.showNotification("Failed to update log configuration", "error"); } } formatBytes(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; } // Pagination methods updatePaginationControls() { const { currentPage, totalPages, total, limit } = this.pagination; // Update pagination info document.getElementById("currentPage").textContent = currentPage; document.getElementById("totalPages").textContent = totalPages; document.getElementById("totalResults").textContent = total; // Calculate showing range const start = ((currentPage - 1) * limit) + 1; const end = Math.min(currentPage * limit, total); document.getElementById("showingStart").textContent = start; document.getElementById("showingEnd").textContent = end; // Update results count document.getElementById("resultsCount").textContent = `${this.prompts.length} of ${total}`; // Update button states document.getElementById("firstPageBtn").disabled = currentPage === 1; document.getElementById("prevPageBtn").disabled = currentPage === 1; document.getElementById("nextPageBtn").disabled = currentPage === totalPages; document.getElementById("lastPageBtn").disabled = currentPage === totalPages; } async goToPage(page) { if (page < 1 || page > this.pagination.totalPages || page === this.pagination.currentPage) { return; } await (this.listMode === "search" ? this.search(page) : this.loadRecentPrompts(page)); // Scroll to top of results after page loads window.scrollTo({ top: 0, behavior: 'smooth' }); } async changeLimit(newLimit) { this.pagination.limit = newLimit; await this.reloadPrompts(); // back to the first page of the current list } // Metadata functionality openMetadataViewer() { window.open(ApiClient.resolveUrl('/prompt_manager/gallery.html'), '_blank'); } // Gallery functionality openGallery() { window.open(ApiClient.resolveUrl('/prompt_manager/gallery'), '_blank'); } async copyToClipboard(text) { try { await navigator.clipboard.writeText(text); this.showNotification('Copied to clipboard!', 'success'); } catch (err) { console.error('Copy failed:', err); this.showNotification('Copy failed', 'error'); } } parsePNGMetadata(arrayBuffer) { return PngMetadata.parsePngTextChunks(arrayBuffer); } extractComfyUIData(metadata) { // Look for ComfyUI workflow data in various possible fields let workflowData = null; let promptData = null; // Common ComfyUI metadata field names const workflowFields = ['workflow', 'Workflow', 'comfy', 'ComfyUI']; const promptFields = ['prompt', 'Prompt', 'parameters', 'Parameters']; for (const field of workflowFields) { if (metadata[field]) { try { // Clean NaN values from JSON string before parsing let cleanedJson = metadata[field]; cleanedJson = cleanedJson.replace(/:\s*NaN\b/g, ': null'); cleanedJson = cleanedJson.replace(/\bNaN\b/g, 'null'); workflowData = JSON.parse(cleanedJson); break; } catch (e) { console.warn('Failed to parse workflow field:', field); } } } for (const field of promptFields) { if (metadata[field]) { try { // Clean NaN values from JSON string before parsing let cleanedJson = metadata[field]; cleanedJson = cleanedJson.replace(/:\s*NaN\b/g, ': null'); cleanedJson = cleanedJson.replace(/\bNaN\b/g, 'null'); promptData = JSON.parse(cleanedJson); break; } catch (e) { console.warn('Failed to parse prompt field:', field); } } } return { workflow: workflowData, prompt: promptData }; } async extractImageMetadata(imageUrl) { try { // Fetch the image as an array buffer const response = await ApiClient.fetchUrl(imageUrl); const arrayBuffer = await response.arrayBuffer(); // Parse PNG metadata const metadata = await this.parsePNGMetadata(arrayBuffer); const comfyData = this.extractComfyUIData(metadata); return this.parseWorkflowData(comfyData, imageUrl); } catch (error) { console.error('Error extracting metadata:', error); return null; } } parseWorkflowData(comfyData, imagePath) { // Trace prompts, model and sampler settings through the node graph (#75) const { checkpoint, positivePrompt, negativePrompt, steps, cfgScale, sampler, seed, } = window.ComfyMetadata.extractGenerationParams(comfyData); return { positivePrompt, negativePrompt, checkpoint, steps, cfgScale, sampler, seed, workflow: comfyData.workflow, prompt: comfyData.prompt, imagePath }; } addMetadataSidebar() { // Find the active (visible) viewer container — there may be stale // hidden ones from previously opened viewers still in the DOM const containers = document.querySelectorAll('.viewer-container'); let viewerContainer = null; for (const c of containers) { if (c.style.display !== 'none') { viewerContainer = c; } } if (!viewerContainer || document.getElementById('metadata-sidebar')) return; const sidebar = document.createElement('div'); sidebar.id = 'metadata-sidebar'; sidebar.className = 'metadata-sidebar'; sidebar.innerHTML = `

Generation data

Loading Metadata...

Extracting ComfyUI workflow data

`; viewerContainer.appendChild(sidebar); this.attachMetadataEventListeners(); } async loadMetadataForImage(imgElement) { const imageUrl = imgElement.getAttribute('data-original') || imgElement.src; if (!imageUrl) return; const metadataContent = document.getElementById('metadata-content'); if (!metadataContent) return; const request = {}; this.metadataRequest = request; this.currentMetadata = null; const isCurrent = () => this.metadataRequest === request && metadataContent.isConnected !== false; // Show loading state metadataContent.innerHTML = `

Loading Metadata...

Extracting ComfyUI workflow data

`; try { const metadata = await this.extractImageMetadata(imageUrl); if (!isCurrent()) return; this.currentMetadata = metadata; this.updateMetadataSidebar(metadata); } catch (error) { if (!isCurrent()) return; console.error('Failed to load metadata:', error); metadataContent.innerHTML = `

Failed to Load Metadata

Could not extract ComfyUI workflow data

`; } } updateMetadataSidebar(metadata) { const metadataContent = document.getElementById('metadata-content'); if (!metadataContent || !metadata) return; metadataContent.innerHTML = `

File Path

${escapeHtml(metadata.imagePath)}

Resources used

${escapeHtml(metadata.checkpoint)}
ComfyUI Generated
CHECKPOINT

Prompt

COMFYUI
${escapeHtml(metadata.positivePrompt.substring(0, 200))}${metadata.positivePrompt.length > 200 ? '...' : ''}
${metadata.positivePrompt.length > 200 ? '' : ''}

Negative prompt

${escapeHtml(metadata.negativePrompt.substring(0, 200))}${metadata.negativePrompt.length > 200 ? '...' : ''}
${metadata.negativePrompt.length > 200 ? '' : ''}

Other metadata

CFG SCALE: ${escapeHtml(metadata.cfgScale)} STEPS: ${escapeHtml(metadata.steps)} SAMPLER: ${escapeHtml(metadata.sampler)}
SEED: ${escapeHtml(metadata.seed)}

ComfyUI Workflow

`; // Re-attach event listeners after updating content this.attachMetadataEventListeners(); } attachMetadataEventListeners() { const sidebar = document.getElementById('metadata-sidebar'); if (!sidebar) return; // Copy buttons const copyBtns = sidebar.querySelectorAll('[data-copy-type]'); copyBtns.forEach(btn => { btn.addEventListener('click', (e) => { const type = btn.getAttribute('data-copy-type'); if (this.currentMetadata) { const text = type === 'positive' ? this.currentMetadata.positivePrompt : this.currentMetadata.negativePrompt; this.copyToClipboard(text); } }); }); // File path copy const pathEl = sidebar.querySelector('[data-copy-path]'); if (pathEl) { pathEl.addEventListener('click', () => { if (this.currentMetadata) { this.copyToClipboard(this.currentMetadata.imagePath); } }); } // Show more buttons const showMoreBtns = sidebar.querySelectorAll('[data-show-type]'); showMoreBtns.forEach(btn => { btn.addEventListener('click', (e) => { const type = btn.getAttribute('data-show-type'); this.showFullPrompt(type); }); }); // Workflow action buttons const actionBtns = sidebar.querySelectorAll('[data-action]'); actionBtns.forEach(btn => { btn.addEventListener('click', (e) => { const action = btn.getAttribute('data-action'); if (action === 'show-workflow') { this.showWorkflowData(); } else if (action === 'download-workflow') { this.downloadWorkflowJSON(); } }); }); // Copy all button const copyAllBtn = sidebar.querySelector('.metadata-copy-all'); if (copyAllBtn) { copyAllBtn.addEventListener('click', () => { this.copyAllMetadata(); }); } } showFullPrompt(type) { if (this.currentMetadata) { const prompt = type === 'positive' ? this.currentMetadata.positivePrompt : this.currentMetadata.negativePrompt; const safeType = escapeHtml(type.charAt(0).toUpperCase() + type.slice(1)); const newWindow = window.open('', '_blank'); const doc = newWindow.document; doc.open(); doc.write('' + safeType + ' Prompt'); doc.close(); doc.body.style.cssText = 'background:#111;color:#fff;font-family:monospace;padding:20px;'; const h2 = doc.createElement('h2'); h2.textContent = type.charAt(0).toUpperCase() + type.slice(1) + ' Prompt'; doc.body.appendChild(h2); const pre = doc.createElement('pre'); pre.style.cssText = 'background:#222;padding:15px;border-radius:5px;white-space:pre-wrap;line-height:1.5;'; pre.textContent = prompt; doc.body.appendChild(pre); const btn = doc.createElement('button'); btn.textContent = 'Copy to Clipboard'; btn.style.cssText = 'margin-top:20px;padding:10px 20px;background:#444;color:#fff;border:none;border-radius:5px;cursor:pointer;'; btn.addEventListener('click', () => { navigator.clipboard.writeText(pre.textContent).then(() => alert('Copied!')); }); doc.body.appendChild(btn); } } showWorkflowData() { if (this.currentMetadata && this.currentMetadata.workflow) { const newWindow = window.open('', '_blank'); if (!newWindow) { this.showNotification('Popup blocked: allow popups to view the workflow', 'warning'); return; } newWindow.document.write(ImageHelpers.workflowDocumentHtml(this.currentMetadata.workflow)); newWindow.document.close(); } } downloadWorkflowJSON() { if (this.currentMetadata && this.currentMetadata.workflow) { const dataStr = JSON.stringify(this.currentMetadata.workflow, null, 2); const dataBlob = new Blob([dataStr], {type: 'application/json'}); const url = URL.createObjectURL(dataBlob); const link = document.createElement('a'); link.href = url; link.download = 'comfyui_workflow.json'; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } } copyAllMetadata() { if (!this.currentMetadata) return; const allData = `Checkpoint: ${this.currentMetadata.checkpoint || 'Unknown'} Positive Prompt: ${this.currentMetadata.positivePrompt} Negative Prompt: ${this.currentMetadata.negativePrompt} Steps: ${this.currentMetadata.steps || 'Unknown'} CFG Scale: ${this.currentMetadata.cfgScale || 'Unknown'} Sampler: ${this.currentMetadata.sampler || 'Unknown'} Seed: ${this.currentMetadata.seed || 'Unknown'}`; this.copyToClipboard(allData); } // ========================================== // Auto Tag Feature Methods // ========================================== // State for AutoTag feature autoTagState = { eventSource: null, downloadEventSource: null, reviewImages: [], reviewIndex: 0, currentTags: [], cancelled: false, skipAllTagged: false, retagResolve: null, // Promise resolver for retag confirmation tagsExpanded: false // Track accordion state }; // ==================== Add Prompt Modal ==================== showAddPromptModal() { // Clear form document.getElementById('addPromptText').value = ''; document.getElementById('addPromptCategory').value = ''; document.getElementById('addPromptRatingValue').value = ''; document.getElementById('addPromptNotes').value = ''; document.getElementById('addPromptProtected').checked = true; // Clear tags this.addPromptTags = []; this.renderAddPromptTags(); // Reset rating stars this.updateAddPromptRatingStars(0); // Populate categories datalist this.populateAddPromptCategories(); this.showModal('addPromptModal'); } setupAddPromptModal() { this.addPromptTags = []; // Rating stars const ratingContainer = document.getElementById('addPromptRating'); const ratingStars = ratingContainer.querySelectorAll('.rating-star'); ratingStars.forEach(star => { star.addEventListener('click', () => { const rating = parseInt(star.dataset.rating); document.getElementById('addPromptRatingValue').value = rating; this.updateAddPromptRatingStars(rating); }); }); // Clear rating button document.getElementById('clearAddPromptRatingBtn').addEventListener('click', () => { document.getElementById('addPromptRatingValue').value = ''; this.updateAddPromptRatingStars(0); }); // Tag input const tagInput = document.getElementById('addPromptTagInput'); tagInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); const tag = tagInput.value.trim().replace(/,/g, ''); if (tag && !this.addPromptTags.includes(tag)) { this.addPromptTags.push(tag); this.renderAddPromptTags(); } tagInput.value = ''; } }); // Tag suggestions tagInput.addEventListener('input', () => { this.showAddPromptTagSuggestions(tagInput.value); }); } updateAddPromptRatingStars(rating) { const stars = document.querySelectorAll('#addPromptRating .rating-star'); stars.forEach((star, index) => { if (index < rating) { star.textContent = '★'; star.classList.add('text-pm-warning'); star.classList.remove('text-pm-muted'); } else { star.textContent = '☆'; star.classList.remove('text-pm-warning'); star.classList.add('text-pm-muted'); } }); } renderAddPromptTags() { const container = document.getElementById('addPromptTagsContainer'); const input = document.getElementById('addPromptTagInput'); // Remove existing tag chips container.querySelectorAll('.tag-chip').forEach(chip => chip.remove()); // Add tag chips this.addPromptTags.forEach(tag => { const chip = document.createElement('span'); chip.className = 'tag-chip inline-flex items-center px-2 py-1 bg-pm-accent text-pm text-xs rounded cursor-pointer hover:bg-pm-accent-hover'; chip.innerHTML = `${escapeHtml(tag)} ×`; chip.addEventListener('click', () => { this.addPromptTags = this.addPromptTags.filter(t => t !== tag); this.renderAddPromptTags(); }); container.insertBefore(chip, input); }); } showAddPromptTagSuggestions(query) { const suggestionsContainer = document.getElementById('addPromptTagSuggestions'); if (!query) { suggestionsContainer.classList.add('hidden'); return; } const matchingTags = this.tags.filter(tag => tag.toLowerCase().includes(query.toLowerCase()) && !this.addPromptTags.includes(tag) ).slice(0, 10); if (matchingTags.length === 0) { suggestionsContainer.classList.add('hidden'); return; } suggestionsContainer.innerHTML = matchingTags.map(tag => `
${escapeHtml(tag)}
`).join(''); suggestionsContainer.querySelectorAll('[data-tag]').forEach(el => { el.addEventListener('click', () => { const tag = el.dataset.tag; if (!this.addPromptTags.includes(tag)) { this.addPromptTags.push(tag); this.renderAddPromptTags(); } document.getElementById('addPromptTagInput').value = ''; suggestionsContainer.classList.add('hidden'); }); }); suggestionsContainer.classList.remove('hidden'); } populateAddPromptCategories() { const datalist = document.getElementById('addPromptCategoryList'); datalist.innerHTML = this.categories.map(cat => `