diff --git a/tests/js/prompt-list-sort.test.js b/tests/js/prompt-list-sort.test.js new file mode 100644 index 0000000..c530ad0 --- /dev/null +++ b/tests/js/prompt-list-sort.test.js @@ -0,0 +1,66 @@ +// Run with: node --test tests/js/ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const sort = require("../../web/js/prompt-list-sort.js"); + +test("default sort is recently used and is a listed option", () => { + assert.equal(sort.DEFAULT_SORT, "last_used_desc"); + assert.ok(sort.SORT_OPTIONS.some((o) => o.value === sort.DEFAULT_SORT)); +}); + +test("options include Recently used and Most used, each with a label", () => { + const values = sort.SORT_OPTIONS.map((o) => o.value); + assert.ok(values.includes("last_used_desc")); + assert.ok(values.includes("run_count_desc")); + for (const option of sort.SORT_OPTIONS) assert.ok(option.label.trim(), option.value); + assert.equal(new Set(values).size, values.length, "values are unique"); +}); + +test("options cannot be mutated by callers", () => { + assert.throws(() => sort.SORT_OPTIONS.push({ value: "x", label: "x" })); + // Non-strict code fails silently on frozen objects, so check the value survived + const first = sort.SORT_OPTIONS[0].value; + sort.SORT_OPTIONS[0].value = "hacked"; + assert.equal(sort.SORT_OPTIONS[0].value, first); + assert.ok(sort.SORT_OPTIONS.every(Object.isFrozen)); +}); + +test("normalizeSort keeps known values and falls back for anything else", () => { + assert.equal(sort.normalizeSort("run_count_desc"), "run_count_desc"); + for (const bad of [undefined, null, "", "created_at; DROP TABLE prompts", 42, "LAST_USED_DESC"]) { + assert.equal(sort.normalizeSort(bad), sort.DEFAULT_SORT, String(bad)); + } +}); + +test("buildRecentUrl computes offset from page and always sends a valid sort", () => { + assert.equal( + sort.buildRecentUrl({ page: 3, limit: 25, sort: "rating_desc" }), + "/prompt_manager/recent?limit=25&offset=50&page=3&sort=rating_desc", + ); + assert.equal( + sort.buildRecentUrl({ sort: "bogus" }), + "/prompt_manager/recent?limit=50&offset=0&page=1&sort=last_used_desc", + ); +}); + +test("withSort returns a copy with the sort set and leaves the input untouched", () => { + const params = new URLSearchParams({ text: "castle", sort: "text_asc" }); + const result = sort.withSort(params, "run_count_desc"); + assert.equal(result.get("sort"), "run_count_desc"); + assert.equal(result.get("text"), "castle"); + assert.equal(params.get("sort"), "text_asc"); + assert.equal(sort.withSort(new URLSearchParams(), "nope").get("sort"), sort.DEFAULT_SORT); +}); + +test("formatRunCount labels zero, one and many runs", () => { + assert.equal(sort.formatRunCount(0), "Never run"); + assert.equal(sort.formatRunCount(1), "1 run"); + assert.equal(sort.formatRunCount(642), "642 runs"); + assert.equal(sort.formatRunCount(1234), "1,234 runs"); +}); + +test("formatRunCount hides the badge for missing or invalid counts", () => { + for (const bad of [undefined, null, -1, 1.5, "3", NaN]) { + assert.equal(sort.formatRunCount(bad), "", String(bad)); + } +}); diff --git a/tests/test_sort_contract.py b/tests/test_sort_contract.py new file mode 100644 index 0000000..103815f --- /dev/null +++ b/tests/test_sort_contract.py @@ -0,0 +1,39 @@ +"""The UI's sort options must match the server's whitelist exactly.""" + +import os +import re +import sys +import unittest + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, ROOT) + +from database.operations import SORT_ORDERS + +SORT_MODULE = os.path.join(ROOT, "web", "js", "prompt-list-sort.js") +NODE_EXTENSION = os.path.join(ROOT, "web", "prompt_manager.js") + + +class TestSortContract(unittest.TestCase): + def test_ui_sort_options_match_server_whitelist(self): + with open(SORT_MODULE, encoding="utf-8") as fh: + source = fh.read() + ui_values = set(re.findall(r'value:\s*"([a-z_]+)"', source)) + + self.assertTrue(ui_values, "no sort options found in prompt-list-sort.js") + self.assertEqual(ui_values, set(SORT_ORDERS)) + + def test_node_recent_button_requests_recently_used(self): + # The node extension can't import the shared module (ComfyUI load order is + # not guaranteed), so it sends the key directly; it must be a server key. + with open(NODE_EXTENSION, encoding="utf-8") as fh: + source = fh.read() + match = re.search(r"/prompt_manager/recent\?[^`'\"]*sort=([a-z_]+)", source) + + self.assertIsNotNone(match, "node Recent request does not send a sort") + self.assertEqual(match.group(1), "last_used_desc") + self.assertIn(match.group(1), SORT_ORDERS) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/admin.html b/web/admin.html index 6bb850a..6431ebc 100644 --- a/web/admin.html +++ b/web/admin.html @@ -107,12 +107,8 @@
@@ -1391,6 +1387,7 @@ No sentences, no commentary, no captions. Only tags. Keep length short but descr
+ diff --git a/web/js/admin.js b/web/js/admin.js index 8be9f06..651f5cb 100644 --- a/web/js/admin.js +++ b/web/js/admin.js @@ -1,6 +1,7 @@ class PromptAdmin { constructor() { this.prompts = []; + this.listMode = "recent"; // "recent" or "search"; a sort change reloads it this.selectedPrompts = new Set(); this.settings = { resultTimeout: 5, @@ -114,8 +115,13 @@ document.getElementById(id).addEventListener("change", () => this.search()); }); - // Sort dropdown change - document.getElementById("sortBy").addEventListener("change", () => this.renderPrompts()); + // 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() { @@ -355,9 +361,13 @@ async loadRecentPrompts(page = 1) { try { + this.listMode = "recent"; this.pagination.currentPage = page; - const offset = (page - 1) * this.pagination.limit; - const response = await fetch(`/prompt_manager/recent?limit=${this.pagination.limit}&offset=${offset}&page=${page}`); + const response = await fetch(PromptListSort.buildRecentUrl({ + page, + limit: this.pagination.limit, + sort: this.currentSort(), + })); if (response.ok) { const data = await response.json(); if (data.success) { @@ -424,7 +434,8 @@ if (folder) params.append("folder", folder); params.append("limit", "100"); - const response = await fetch(`/prompt_manager/search?${params}`); + this.listMode = "search"; + const response = await fetch(`/prompt_manager/search?${PromptListSort.withSort(params, this.currentSort())}`); if (response.ok) { const data = await response.json(); if (data.success) { @@ -442,36 +453,13 @@ } } - sortPrompts(prompts) { - const sortBy = document.getElementById("sortBy")?.value || "created_desc"; - const [field, direction] = sortBy.split("_"); - - return [...prompts].sort((a, b) => { - let aVal, bVal; - - switch (field) { - case "rating": - aVal = a.rating || 0; - bVal = b.rating || 0; - break; - case "created": - aVal = new Date(a.created_at); - bVal = new Date(b.created_at); - break; - case "text": - aVal = a.text.toLowerCase(); - bVal = b.text.toLowerCase(); - break; - default: - return 0; - } - - if (direction === "asc") { - return aVal > bVal ? 1 : aVal < bVal ? -1 : 0; - } else { - return aVal < bVal ? 1 : aVal > bVal ? -1 : 0; - } - }); + currentSort() { + return PromptListSort.normalizeSort(document.getElementById("sortBy")?.value); + } + + /** Re-fetch the list currently shown, e.g. after the sort changes. */ + reloadPrompts() { + return this.listMode === "search" ? this.search() : this.loadRecentPrompts(1); } renderPrompts() { @@ -493,14 +481,13 @@ return; } - // Sort prompts before rendering - const sortedPrompts = this.sortPrompts(this.prompts); - container.innerHTML = sortedPrompts.map((prompt) => this.renderPromptItem(prompt)).join(""); + // 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 - sortedPrompts.forEach(prompt => { + this.prompts.forEach(prompt => { this.addStarHoverBehavior(prompt.id); }); @@ -514,7 +501,7 @@ }); // Load film strips for each prompt (async, non-blocking) - this.loadAllFilmStrips(sortedPrompts); + this.loadAllFilmStrips(this.prompts); } async loadAllFilmStrips(prompts) { @@ -550,6 +537,10 @@ 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 `
@@ -574,6 +565,10 @@ 📅 ${created}
+ ${runLabel ? `
+ 🔁 + ${this.escapeHtml(runLabel)} +
` : ""}
${this.renderStars(rating, prompt.id)} diff --git a/web/js/prompt-list-sort.js b/web/js/prompt-list-sort.js new file mode 100644 index 0000000..8d10c52 --- /dev/null +++ b/web/js/prompt-list-sort.js @@ -0,0 +1,64 @@ +/** + * Prompt list sorting for the admin dashboard (3.2.4, #90.1). + * + * Sorting happens on the server so it spans every page; this module only owns the + * option list, validation and request URLs. The option values must match + * SORT_ORDERS in database/operations.py (enforced by tests/test_sort_contract.py). + * + * Loadable in the browser (window.PromptListSort) and in Node for tests. + */ +(function (root) { + "use strict"; + + const SORT_OPTIONS = Object.freeze( + [ + { value: "last_used_desc", label: "Recently Used" }, + { value: "created_desc", label: "Newest First" }, + { value: "created_asc", label: "Oldest First" }, + { value: "run_count_desc", label: "Most Used" }, + { value: "rating_desc", label: "Highest Rated" }, + { value: "rating_asc", label: "Lowest Rated" }, + { value: "text_asc", label: "A-Z" }, + { value: "text_desc", label: "Z-A" }, + ].map((option) => Object.freeze(option)), + ); + + const DEFAULT_SORT = "last_used_desc"; + const DEFAULT_LIMIT = 50; + const KNOWN = new Set(SORT_OPTIONS.map((o) => o.value)); + + /** A known sort key, or DEFAULT_SORT for anything else. */ + function normalizeSort(value) { + return typeof value === "string" && KNOWN.has(value) ? value : DEFAULT_SORT; + } + + /** URL for one page of /prompt_manager/recent. */ + function buildRecentUrl({ page = 1, limit = DEFAULT_LIMIT, sort } = {}) { + const params = new URLSearchParams({ + limit: String(limit), + offset: String((page - 1) * limit), + page: String(page), + sort: normalizeSort(sort), + }); + return `/prompt_manager/recent?${params}`; + } + + /** Copy of `params` with a validated sort key; the input is not modified. */ + function withSort(params, sort) { + const copy = new URLSearchParams(params); + copy.set("sort", normalizeSort(sort)); + return copy; + } + + /** Badge text for a prompt's run count; empty when the count is unknown. */ + function formatRunCount(count) { + if (!Number.isInteger(count) || count < 0) return ""; + if (count === 0) return "Never run"; + if (count === 1) return "1 run"; + return `${count.toLocaleString("en-US")} runs`; + } + + const api = { SORT_OPTIONS, DEFAULT_SORT, normalizeSort, buildRecentUrl, withSort, formatRunCount }; + if (typeof module !== "undefined" && module.exports) module.exports = api; + else root.PromptListSort = api; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/web/prompt_manager.js b/web/prompt_manager.js index caa896c..4834a3d 100644 --- a/web/prompt_manager.js +++ b/web/prompt_manager.js @@ -978,7 +978,7 @@ app.registerExtension({ url = `/prompt_manager/search?${queryParams.toString()}`; } else if (method === "get_recent_prompts") { const limit = params.limit || 20; - url = `/prompt_manager/recent?limit=${limit}`; + url = `/prompt_manager/recent?limit=${limit}&sort=last_used_desc`; } else { throw new Error(`Unknown method: ${method}`); }