Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6199fd8f9 | ||
|
|
8a6fb5feca | ||
|
|
cccf872642 | ||
|
|
80ba034fbf | ||
|
|
c78788579e |
@@ -13,12 +13,14 @@ from .models import PromptModel, normalize_image_path, utc_now_iso
|
||||
# Import logging system
|
||||
try:
|
||||
from ..utils.logging_config import get_logger
|
||||
from ..utils.hashing import generate_prompt_hash
|
||||
except ImportError:
|
||||
import sys
|
||||
|
||||
current_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, current_dir)
|
||||
from utils.logging_config import get_logger
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
# Subquery to fetch tags from junction table, embedded in SELECT statements
|
||||
@@ -1854,16 +1856,20 @@ class PromptDatabase:
|
||||
}
|
||||
|
||||
def update_prompt_text(self, prompt_id: int, new_text: str) -> bool:
|
||||
"""Update only the text of a prompt.
|
||||
"""Update prompt text and its deduplication hash atomically.
|
||||
|
||||
Returns:
|
||||
True if a row was updated.
|
||||
|
||||
Raises:
|
||||
sqlite3.IntegrityError: Another prompt already has the new hash.
|
||||
"""
|
||||
with self.model.get_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
"UPDATE prompts SET text = ?, updated_at = ? WHERE id = ?",
|
||||
"UPDATE prompts SET text = ?, hash = ?, updated_at = ? WHERE id = ?",
|
||||
(
|
||||
new_text,
|
||||
generate_prompt_hash(new_text),
|
||||
datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
prompt_id,
|
||||
),
|
||||
|
||||
@@ -484,6 +484,22 @@ class AutotagRoutesMixin:
|
||||
if not raw_path or not isinstance(raw_path, str):
|
||||
return None, None, self._bad_request("image_id or path is required")
|
||||
|
||||
if "root_index" in data:
|
||||
root_index = data["root_index"]
|
||||
if isinstance(root_index, bool) or not isinstance(root_index, int):
|
||||
return None, None, self._bad_request("root_index must be an integer")
|
||||
roots = self._get_all_output_dirs()
|
||||
if not 0 <= root_index < len(roots):
|
||||
return None, None, self._forbidden(raw_path)
|
||||
root = roots[root_index]
|
||||
if Path(raw_path).is_absolute() or ".." in raw_path.replace(
|
||||
"\\", "/"
|
||||
).split("/"):
|
||||
return None, None, self._forbidden(raw_path)
|
||||
raw_path = str(Path(root) / raw_path)
|
||||
if not path_is_within(raw_path, root):
|
||||
return None, None, self._forbidden(raw_path)
|
||||
|
||||
resolved = self._resolve_allowed_image_path(raw_path)
|
||||
if resolved is None:
|
||||
return None, None, self._forbidden(raw_path)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
from aiohttp import web
|
||||
from urllib.parse import quote
|
||||
@@ -839,6 +840,11 @@ class PromptRoutesMixin:
|
||||
{"success": False, "error": "Prompt not found"}, status=404
|
||||
)
|
||||
|
||||
except sqlite3.IntegrityError:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Another prompt already has this text"},
|
||||
status=409,
|
||||
)
|
||||
except ValueError:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid prompt ID"}, status=400
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
function fixture() {
|
||||
const panel = { innerHTML: '', isConnected: true };
|
||||
const requests = new Map();
|
||||
const context = vm.createContext({ document: { readyState: 'loading', addEventListener() {},
|
||||
getElementById: id => id === 'metadata-content' ? panel : null },
|
||||
window: {}, console: { error() {} },
|
||||
});
|
||||
const Admin = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/admin.js'), 'utf8') + '\nPromptAdmin;', context);
|
||||
const manager = Object.create(Admin.prototype);
|
||||
manager.extractImageMetadata = url => new Promise((resolve, reject) => requests.set(url, { resolve, reject }));
|
||||
manager.updateMetadataSidebar = metadata => { panel.innerHTML = metadata?.imagePath || 'No metadata'; };
|
||||
manager.hideModal = () => {};
|
||||
const load = url => manager.loadMetadataForImage({ getAttribute: () => url });
|
||||
return { manager, panel, requests, load };
|
||||
}
|
||||
test('admin metadata and copy actions stay on the selected image', async () => {
|
||||
const { manager, panel, requests, load } = fixture();
|
||||
const old = load('old.png');
|
||||
const current = load('current.png');
|
||||
requests.get('current.png').resolve({ imagePath: 'current.png' }); await current;
|
||||
requests.get('old.png').resolve({ imagePath: 'old.png' }); await old;
|
||||
assert.equal(panel.innerHTML, 'current.png');
|
||||
assert.equal(manager.currentMetadata.imagePath, 'current.png');
|
||||
});
|
||||
test('navigation clears old action data and late errors cannot replace current metadata', async () => {
|
||||
const { manager, panel, requests, load } = fixture();
|
||||
manager.currentMetadata = { imagePath: 'previous.png' };
|
||||
const old = load('old.png');
|
||||
assert.equal(manager.currentMetadata, null);
|
||||
const current = load('current.png');
|
||||
requests.get('current.png').resolve({ imagePath: 'current.png' }); await current;
|
||||
requests.get('old.png').reject(new Error('old error')); await old;
|
||||
assert.equal(panel.innerHTML, 'current.png');
|
||||
});
|
||||
for (const close of ['close gallery', 'detach sidebar']) {
|
||||
test(`${close} prevents pending admin metadata from restoring copy state`, async () => {
|
||||
const { manager, panel, requests, load } = fixture();
|
||||
const pending = load('old.png');
|
||||
if (close === 'close gallery') manager.closeGallery();
|
||||
else panel.isConnected = false;
|
||||
requests.get('old.png').resolve({ imagePath: 'old.png' }); await pending;
|
||||
assert.equal(manager.currentMetadata, null);
|
||||
assert.notEqual(panel.innerHTML, 'old.png');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const ImageHelpers = require('../../web/js/image-helpers.js');
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise(yes => { resolve = yes; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
function fixture() {
|
||||
const elements = new Map();
|
||||
const requests = [];
|
||||
const document = { addEventListener() {}, getElementById(id) {
|
||||
if (!elements.has(id)) elements.set(id, {});
|
||||
return elements.get(id);
|
||||
} };
|
||||
const context = vm.createContext({ document, window: {}, ImageHelpers,
|
||||
escapeHtml: value => value, console: { error() {} },
|
||||
ApiClient: { resolveUrl: value => value, fetchUrl: (url, options) => {
|
||||
const request = deferred();
|
||||
requests.push({ url, body: JSON.parse(options.body), ...request });
|
||||
return request.promise;
|
||||
} },
|
||||
});
|
||||
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context);
|
||||
const manager = Object.create(Manager.prototype);
|
||||
const notifications = [];
|
||||
Object.assign(manager, { autoTagState: { reviewImages: [
|
||||
{ id: 'digest', path: 'same.png', root_index: 1, url: '/same.png' },
|
||||
{ id: 'other', path: 'next.png', root_index: 0, url: '/next.png' },
|
||||
], reviewIndex: 0, currentTags: [], modelType: 'gguf' },
|
||||
renderReviewTags() {}, showNotification: (...args) => notifications.push(args), hideModal() {}, loadImages() {},
|
||||
});
|
||||
const reply = (index, data) => requests[index].resolve({ json: async () => data });
|
||||
return { manager, requests, reply, notifications };
|
||||
}
|
||||
test('gallery review retains root identity and applies tags with the returned prompt ID', async () => {
|
||||
const { manager, requests, reply } = fixture();
|
||||
const load = manager.loadNextReviewImage();
|
||||
assert.equal(requests[0].body.root_index, 1);
|
||||
reply(0, { success: true, prompt_id: 42, tags: ['smile'] });
|
||||
await load;
|
||||
manager.loadNextReviewImage = () => {};
|
||||
const apply = manager.applyReviewTags();
|
||||
assert.deepEqual(requests[1].body, { prompt_id: 42, tags: ['smile'] });
|
||||
reply(1, { success: true });
|
||||
await apply;
|
||||
});
|
||||
test('unlinked images do not submit an invalid apply request or advance', async () => {
|
||||
const { manager, requests, reply } = fixture();
|
||||
const load = manager.loadNextReviewImage();
|
||||
reply(0, { success: true, prompt_id: null, tags: ['smile'] });
|
||||
await load;
|
||||
const apply = manager.applyReviewTags();
|
||||
if (requests[1]) reply(1, { success: false, error: "Unexpected apply" });
|
||||
await apply;
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(manager.autoTagState.reviewIndex, 0);
|
||||
});
|
||||
test('a failed apply keeps the current image available for retry', async () => {
|
||||
const { manager, requests, reply } = fixture();
|
||||
const load = manager.loadNextReviewImage();
|
||||
reply(0, { success: true, prompt_id: 42, tags: ['smile'] });
|
||||
await load;
|
||||
const apply = manager.applyReviewTags();
|
||||
reply(1, { success: false, error: 'Try again' });
|
||||
await apply;
|
||||
assert.equal(manager.autoTagState.reviewIndex, 0);
|
||||
assert.equal(requests.length, 2);
|
||||
});
|
||||
test('late generation responses cannot restore tags from a skipped or closed image', async () => {
|
||||
const { manager, reply } = fixture();
|
||||
const first = manager.loadNextReviewImage();
|
||||
manager.autoTagState.reviewIndex = 1;
|
||||
const second = manager.loadNextReviewImage();
|
||||
reply(1, { success: true, prompt_id: 2, tags: ['current'] });
|
||||
await second;
|
||||
reply(0, { success: true, prompt_id: 1, tags: ['stale'] });
|
||||
await first;
|
||||
assert.deepEqual(Array.from(manager.autoTagState.currentTags), ['current']);
|
||||
const third = manager.loadNextReviewImage();
|
||||
manager.cancelReview();
|
||||
reply(2, { success: true, prompt_id: 2, tags: ['closed'] });
|
||||
await third;
|
||||
assert.equal(manager.autoTagState.currentTags.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
function fixture() {
|
||||
const requests = [];
|
||||
const grid = { replaceChildren() {}, appendChild() {} };
|
||||
const context = vm.createContext({ window: {}, URLSearchParams, console: { error() {} },
|
||||
requestAnimationFrame() {}, document: { getElementById: id => id === 'tagPromptsGrid' ? grid : null,
|
||||
querySelector: () => null, createElement: () => ({ appendChild() {} }) },
|
||||
});
|
||||
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/tags-page.js'), 'utf8') + '\nTagsPageManager;', context);
|
||||
const manager = Object.create(Manager.prototype);
|
||||
Object.assign(manager, { selectedTags: ['cats'], filterMode: 'and', promptLimit: 20,
|
||||
promptOffset: 0, isLoadingPrompts: false, prompts: [], hasMorePrompts: false,
|
||||
fetchJson(url) { return new Promise((resolve, reject) => requests.push({ url, resolve, reject })); },
|
||||
renderTagPrompts() {}, appendTagPrompts() {}, updatePromptCountBadge() {}, updateLoadMoreButton() {},
|
||||
});
|
||||
const reply = (index, text, more = false) => requests[index].resolve({ success: true,
|
||||
prompts: [{ text }], pagination: { has_more: more, total: more ? 40 : 1 } });
|
||||
return { manager, requests, reply };
|
||||
}
|
||||
test('changing a filter starts a new request and late results cannot overwrite it', async () => {
|
||||
const { manager, requests, reply } = fixture();
|
||||
const old = manager.loadTagPrompts();
|
||||
manager.selectedTags = ['dogs'];
|
||||
const current = manager.loadTagPrompts();
|
||||
assert.equal(requests.length, 2);
|
||||
assert.match(requests[1].url, /dogs/);
|
||||
reply(1, 'dog prompt'); await current;
|
||||
reply(0, 'cat prompt', true); await old;
|
||||
assert.equal(manager.prompts[0].text, 'dog prompt');
|
||||
assert.equal(manager.promptTotal, 1);
|
||||
assert.equal(manager.hasMorePrompts, false);
|
||||
});
|
||||
test('clearing selection invalidates pending results', async () => {
|
||||
const { manager, reply } = fixture();
|
||||
const old = manager.loadTagPrompts();
|
||||
manager.selectedTags = [];
|
||||
manager.clearPrompts();
|
||||
reply(0, 'cat prompt'); await old;
|
||||
assert.equal(manager.prompts.length, 0);
|
||||
assert.equal(manager.promptTotal, 0);
|
||||
assert.equal(manager.isLoadingPrompts, false);
|
||||
});
|
||||
test('a stale pagination response cannot append rows from the previous filter', async () => {
|
||||
const { manager, requests, reply } = fixture();
|
||||
Object.assign(manager, { prompts: [{ text: 'cat first page' }], hasMorePrompts: true, promptOffset: 20 });
|
||||
const old = manager.loadMoreTagPrompts();
|
||||
manager.selectedTags = ['dogs'];
|
||||
const current = manager.loadTagPrompts();
|
||||
assert.equal(requests.length, 2);
|
||||
reply(1, 'dog prompt'); await current;
|
||||
reply(0, 'cat second page', true); await old;
|
||||
assert.equal(manager.prompts.length, 1);
|
||||
assert.equal(manager.prompts[0].text, 'dog prompt');
|
||||
assert.equal(manager.promptOffset, 20);
|
||||
});
|
||||
test('a stale failure cannot finish the new request or clear its loading state', async () => {
|
||||
const { manager, requests, reply } = fixture();
|
||||
const old = manager.loadTagPrompts();
|
||||
manager.selectedTags = ['dogs'];
|
||||
const current = manager.loadTagPrompts();
|
||||
assert.equal(requests.length, 2);
|
||||
requests[0].reject(new Error('old failure')); await old;
|
||||
assert.equal(manager.isLoadingPrompts, true);
|
||||
reply(1, 'dog prompt'); await current;
|
||||
assert.equal(manager.isLoadingPrompts, false);
|
||||
assert.equal(manager.prompts[0].text, 'dog prompt');
|
||||
});
|
||||
@@ -1049,3 +1049,72 @@ class TestScanOutputDir(AutotagAPITestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestGalleryReviewContract(AutotagAPITestCase):
|
||||
async def test_root_relative_gallery_targets_generate_and_apply_to_their_prompt(
|
||||
self,
|
||||
):
|
||||
second = self.tmp_root / "second-output"
|
||||
second.mkdir()
|
||||
roots = [self.output_dir, second]
|
||||
self.api._get_all_output_dirs = lambda: roots
|
||||
for index, root in enumerate(roots):
|
||||
pid = self._save_prompt(f"root {index}")
|
||||
self._link_image(pid, self._make_image(root, "same.png"))
|
||||
response = await self._post_json(
|
||||
"/prompt_manager/autotag/single",
|
||||
{"path": "same.png", "root_index": index, "model_type": "gguf"},
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
data = await response.json()
|
||||
self.assertEqual(data["prompt_id"], pid)
|
||||
self.assertEqual(
|
||||
self.service.generate_calls[-1][0], str((root / "same.png").resolve())
|
||||
)
|
||||
response = await self._post_json(
|
||||
"/prompt_manager/autotag/apply",
|
||||
{"prompt_id": data["prompt_id"], "tags": data["tags"]},
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(
|
||||
set(self.api.db.get_prompt_by_id(pid)["tags"]), set(data["tags"])
|
||||
)
|
||||
|
||||
async def test_gallery_unlinked_image_returns_no_prompt_id(self):
|
||||
self._make_image(self.output_dir)
|
||||
response = await self._post_json(
|
||||
"/prompt_manager/autotag/single", {"path": "img.png", "root_index": 0}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertIsNone((await response.json())["prompt_id"])
|
||||
|
||||
async def test_gallery_root_selection_rejects_invalid_roots_and_escapes(self):
|
||||
self._make_image(self.output_dir)
|
||||
for root, path, expected in [
|
||||
(9, "img.png", 403),
|
||||
(-1, "img.png", 403),
|
||||
(True, "img.png", 400),
|
||||
([], "img.png", 400),
|
||||
(0, "../secret/img.png", 403),
|
||||
(0, str(self.secret_dir / "img.png"), 403),
|
||||
]:
|
||||
with self.subTest(root=root, path=path):
|
||||
response = await self._post_json(
|
||||
"/prompt_manager/autotag/single", {"path": path, "root_index": root}
|
||||
)
|
||||
self.assertEqual(response.status, expected)
|
||||
|
||||
async def test_gallery_root_selection_rejects_symlink_into_another_root(self):
|
||||
second = self.tmp_root / "second-output"
|
||||
second.mkdir()
|
||||
target = self._make_image(second)
|
||||
try:
|
||||
(self.output_dir / "link.png").symlink_to(target)
|
||||
except (OSError, NotImplementedError):
|
||||
self.skipTest("symlinks unavailable")
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
|
||||
response = await self._post_json(
|
||||
"/prompt_manager/autotag/single", {"path": "link.png", "root_index": 0}
|
||||
)
|
||||
self.assertEqual(response.status, 403)
|
||||
|
||||
@@ -894,3 +894,49 @@ class TestBulkRoutes(RouteCoverageCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestPromptEditHash(PromptAPITestCase):
|
||||
async def test_edit_moves_the_hash_and_original_text_can_be_saved_again(self):
|
||||
original_id = self._save_prompt("Original landscape")
|
||||
response = await self.client.put(
|
||||
f"/prompt_manager/prompts/{original_id}", json={"text": "Edited portrait"}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
row = self.api.db.get_prompt_by_id(original_id)
|
||||
self.assertEqual(row["hash"], generate_prompt_hash("Edited portrait"))
|
||||
self.assertIsNone(
|
||||
self.api.db.get_prompt_by_hash(generate_prompt_hash("Original landscape"))
|
||||
)
|
||||
response = await self.client.post(
|
||||
"/prompt_manager/save", json={"text": "Original landscape"}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertNotEqual((await response.json())["prompt_id"], original_id)
|
||||
response = await self.client.post(
|
||||
"/prompt_manager/save", json={"text": "Edited portrait"}
|
||||
)
|
||||
self.assertEqual((await response.json())["prompt_id"], original_id)
|
||||
|
||||
async def test_duplicate_edit_returns_conflict_without_changing_either_prompt(self):
|
||||
first = self._save_prompt("Original landscape", tags=["landscape"])
|
||||
second = self._save_prompt("Existing portrait", tags=["portrait"])
|
||||
before = [self.api.db.get_prompt_by_id(pid) for pid in (first, second)]
|
||||
response = await self.client.put(
|
||||
f"/prompt_manager/prompts/{first}", json={"text": " EXISTING PORTRAIT "}
|
||||
)
|
||||
self.assertEqual(response.status, 409)
|
||||
self.assertFalse((await response.json())["success"])
|
||||
self.assertEqual(
|
||||
[self.api.db.get_prompt_by_id(pid) for pid in (first, second)], before
|
||||
)
|
||||
|
||||
async def test_edit_with_same_normalized_hash_keeps_its_identity(self):
|
||||
pid = self._save_prompt("Original landscape")
|
||||
response = await self.client.put(
|
||||
f"/prompt_manager/prompts/{pid}", json={"text": "ORIGINAL LANDSCAPE"}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(
|
||||
self.api.db.get_prompt_by_id(pid)["text"], "ORIGINAL LANDSCAPE"
|
||||
)
|
||||
|
||||
Vendored
+62
@@ -206,3 +206,65 @@ test('metadata follows the selected image when responses finish out of order', a
|
||||
await page.evaluate(() => metadataRequests.get('http://viewer.test/original/2.svg')({ positivePrompt: 'Closed prompt' }));
|
||||
expect(await page.evaluate(() => gallery.currentMetadata)).toBeNull();
|
||||
});
|
||||
|
||||
for (const mode of ['gallery', 'filmstrip']) {
|
||||
test(`admin ${mode} metadata ignores stale responses and clears copy state on close`, async ({ page }) => {
|
||||
const urlFor = index => mode === 'gallery' ? `http://viewer.test/prompt_manager/images/${index}/file` : `http://viewer.test/original/${index}.svg`;
|
||||
const adminSource = source('web/js/admin.js');
|
||||
await page.addScriptTag({ content: adminSource.slice(0, adminSource.indexOf(' function startAdmin()')) + '\nwindow.TestAdmin = PromptAdmin;' });
|
||||
await page.evaluate(async mode => {
|
||||
const admin = window.admin = Object.create(TestAdmin.prototype);
|
||||
const images = Array.from({ length: 3 }, (_, i) => ({ id: i, filename: `${i}.svg`, url: `http://viewer.test/original/${i}.svg`, size: 100, created_at: 1 }));
|
||||
window.pendingMetadata = new Map();
|
||||
Object.assign(admin, { prompts: [{ id: 1, text: 'test' }],
|
||||
getImageUrl: image => image.url, loadFilmStripImages: async () => images,
|
||||
extractImageMetadata: url => new Promise(resolve => pendingMetadata.set(url, resolve)),
|
||||
});
|
||||
if (mode === 'gallery') {
|
||||
admin.renderGallery(images, 1);
|
||||
} else {
|
||||
await admin.openFilmStripViewer(1, 0);
|
||||
}
|
||||
}, mode);
|
||||
if (mode === 'gallery') {
|
||||
await expect.poll(() => page.evaluate(() => Boolean(admin.galleryViewer))).toBe(true);
|
||||
await page.evaluate(() => admin.galleryViewer.show());
|
||||
}
|
||||
await expect.poll(() => page.evaluate(url => pendingMetadata.has(url), urlFor(0))).toBe(true);
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect.poll(() => page.evaluate(url => pendingMetadata.has(url), urlFor(1))).toBe(true);
|
||||
await page.evaluate(url => pendingMetadata.get(url)({ positivePrompt: 'Selected prompt', negativePrompt: '', imagePath: '1.svg' }), urlFor(1));
|
||||
await expect(page.locator('#metadata-content')).toContainText('Selected prompt');
|
||||
await page.evaluate(url => pendingMetadata.get(url)({ positivePrompt: 'Stale prompt', negativePrompt: '', imagePath: '0.svg' }), urlFor(0));
|
||||
await expect(page.locator('#metadata-content')).not.toContainText('Stale prompt');
|
||||
expect(await page.evaluate(() => admin.currentMetadata.imagePath)).toBe('1.svg');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect.poll(() => page.evaluate(url => pendingMetadata.has(url), urlFor(2))).toBe(true);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#metadata-sidebar')).toHaveCount(0);
|
||||
await page.evaluate(url => pendingMetadata.get(url)({ positivePrompt: 'Closed prompt', negativePrompt: '', imagePath: '2.svg' }), urlFor(2));
|
||||
expect(await page.evaluate(() => admin.currentMetadata)).toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
test('Tags selection stays current during a delayed prompt response', async ({ page }) => {
|
||||
await page.addScriptTag({ path: join(root, 'web/js/tags-page.js') });
|
||||
await page.evaluate(() => {
|
||||
const grid = document.createElement('div'); grid.id = 'tagPromptsGrid'; document.body.append(grid);
|
||||
const manager = window.tagsManager = Object.create(TagsPageManager.prototype);
|
||||
window.pendingTagPrompts = [];
|
||||
Object.assign(manager, { selectedTags: ['cats'], filterMode: 'and', promptLimit: 20, prompts: [],
|
||||
fetchJson: url => new Promise(resolve => pendingTagPrompts.push({ url, resolve })),
|
||||
renderTagPrompts() { grid.textContent = this.prompts.map(prompt => prompt.text).join(','); },
|
||||
updatePromptCountBadge() {}, updateLoadMoreButton() {}, refreshPromptObserver() {},
|
||||
});
|
||||
manager.loadTagPrompts();
|
||||
manager.selectedTags = ['dogs'];
|
||||
manager.loadTagPrompts();
|
||||
});
|
||||
expect(await page.evaluate(() => pendingTagPrompts.length)).toBe(2);
|
||||
await page.evaluate(() => pendingTagPrompts[1].resolve({ success: true, prompts: [{ text: 'dog prompt' }], pagination: { total: 1, has_more: false } }));
|
||||
await expect(page.locator('#tagPromptsGrid')).toHaveText('dog prompt');
|
||||
await page.evaluate(() => pendingTagPrompts[0].resolve({ success: true, prompts: [{ text: 'cat prompt' }], pagination: { total: 1, has_more: false } }));
|
||||
await expect(page.locator('#tagPromptsGrid')).toHaveText('dog prompt');
|
||||
});
|
||||
|
||||
@@ -1656,6 +1656,7 @@
|
||||
// Initialize ViewerJS for the gallery after DOM is updated
|
||||
setTimeout(() => {
|
||||
if (this.galleryViewer) {
|
||||
this.invalidateMetadataRequest();
|
||||
this.galleryViewer.destroy();
|
||||
}
|
||||
|
||||
@@ -1699,6 +1700,7 @@
|
||||
viewed: (event) => {
|
||||
this.loadMetadataForImage(event.detail.image);
|
||||
},
|
||||
hide: () => this.invalidateMetadataRequest(),
|
||||
hidden: () => {
|
||||
const sidebar = document.getElementById('metadata-sidebar');
|
||||
if (sidebar) sidebar.remove();
|
||||
@@ -1711,7 +1713,13 @@
|
||||
|
||||
|
||||
|
||||
invalidateMetadataRequest() {
|
||||
this.metadataRequest = null;
|
||||
this.currentMetadata = null;
|
||||
}
|
||||
|
||||
closeGallery() {
|
||||
this.invalidateMetadataRequest();
|
||||
this.hideModal("galleryModal");
|
||||
}
|
||||
|
||||
@@ -2896,6 +2904,11 @@
|
||||
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 = `
|
||||
<div class="metadata-loading">
|
||||
@@ -2911,9 +2924,11 @@
|
||||
|
||||
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 = `
|
||||
<div class="text-center text-pm-error py-8">
|
||||
@@ -3515,6 +3530,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
viewed: (event) => {
|
||||
this.loadMetadataForImage(event.detail.image);
|
||||
},
|
||||
hide: () => this.invalidateMetadataRequest(),
|
||||
hidden: () => {
|
||||
const sidebar = document.getElementById('metadata-sidebar');
|
||||
if (sidebar) sidebar.remove();
|
||||
|
||||
+24
-2
@@ -3542,6 +3542,10 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
}
|
||||
|
||||
async loadNextReviewImage() {
|
||||
const request = {};
|
||||
this.reviewRequest = request;
|
||||
this.autoTagState.currentTags = [];
|
||||
this.autoTagState.reviewPromptId = null;
|
||||
if (this.autoTagState.reviewIndex >= this.autoTagState.reviewImages.length) {
|
||||
this.hideModal("autoTagReviewModal");
|
||||
this.showNotification('Review complete!', 'success');
|
||||
@@ -3567,7 +3571,9 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (this.reviewRequest !== request) return;
|
||||
if (data.success) {
|
||||
this.autoTagState.reviewPromptId = data.prompt_id ?? null;
|
||||
this.autoTagState.currentTags = data.tags;
|
||||
this.renderReviewTags();
|
||||
} else {
|
||||
@@ -3575,6 +3581,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
`<div class="text-pm-error">Error: ${escapeHtml(data.error)}</div>`;
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.reviewRequest !== request) return;
|
||||
console.error('Error generating tags:', error);
|
||||
document.getElementById('reviewTagsContainer').innerHTML =
|
||||
'<div class="text-pm-error">Failed to generate tags</div>';
|
||||
@@ -3612,28 +3619,41 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const image = this.autoTagState.reviewImages[this.autoTagState.reviewIndex];
|
||||
const promptId = this.autoTagState.reviewPromptId;
|
||||
if (!promptId) {
|
||||
this.showNotification('This image has no linked prompt. Scan the output folder first.', 'warning');
|
||||
return;
|
||||
}
|
||||
const request = this.reviewRequest;
|
||||
if (this.reviewApplying === request) return;
|
||||
this.reviewApplying = request;
|
||||
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/apply', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
image_path: image.path,
|
||||
prompt_id: promptId,
|
||||
tags: this.autoTagState.currentTags
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (this.reviewRequest !== request) return;
|
||||
if (data.success) {
|
||||
this.showNotification(`Applied ${this.autoTagState.currentTags.length} tags`, 'success');
|
||||
} else {
|
||||
this.showNotification(data.error || 'Failed to apply tags', 'warning');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.reviewRequest !== request) return;
|
||||
console.error('Error applying tags:', error);
|
||||
this.showNotification('Failed to apply tags', 'error');
|
||||
return;
|
||||
} finally {
|
||||
if (this.reviewApplying === request) this.reviewApplying = null;
|
||||
}
|
||||
|
||||
this.autoTagState.reviewIndex++;
|
||||
@@ -3641,6 +3661,8 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
}
|
||||
|
||||
cancelReview() {
|
||||
this.reviewRequest = null;
|
||||
this.autoTagState.reviewPromptId = null;
|
||||
this.autoTagState.reviewImages = [];
|
||||
this.autoTagState.reviewIndex = 0;
|
||||
this.autoTagState.currentTags = [];
|
||||
|
||||
@@ -79,6 +79,9 @@
|
||||
typeof entry.path === "string" && entry.path && !Number.isInteger(entry.id)
|
||||
? { path: entry.path }
|
||||
: { image_id: entry.id };
|
||||
if ("path" in target && Number.isInteger(entry.root_index)) {
|
||||
target.root_index = entry.root_index;
|
||||
}
|
||||
const modelType = String(settings.modelType || "");
|
||||
const params = modelType.startsWith("wd14")
|
||||
? {
|
||||
|
||||
+23
-7
@@ -583,8 +583,12 @@ class TagsPageManager {
|
||||
// ── Prompts Display ───────────────────────────────────────
|
||||
|
||||
async loadTagPrompts() {
|
||||
if (this.isLoadingPrompts) return;
|
||||
if (this.selectedTags.length === 0) return;
|
||||
if (this.selectedTags.length === 0) {
|
||||
this.clearPrompts();
|
||||
return;
|
||||
}
|
||||
const request = {};
|
||||
this.promptRequest = request;
|
||||
|
||||
this.isLoadingPrompts = true;
|
||||
this.promptOffset = 0;
|
||||
@@ -622,6 +626,7 @@ class TagsPageManager {
|
||||
}
|
||||
|
||||
const data = await this.fetchJson(url);
|
||||
if (this.promptRequest !== request) return;
|
||||
|
||||
if (data.success) {
|
||||
this.prompts = data.prompts;
|
||||
@@ -634,6 +639,7 @@ class TagsPageManager {
|
||||
requestAnimationFrame(() => this.refreshPromptObserver());
|
||||
}
|
||||
} catch (err) {
|
||||
if (this.promptRequest !== request) return;
|
||||
console.error('Failed to load tag prompts:', err);
|
||||
if (grid) {
|
||||
grid.replaceChildren();
|
||||
@@ -643,12 +649,14 @@ class TagsPageManager {
|
||||
grid.appendChild(errDiv);
|
||||
}
|
||||
} finally {
|
||||
this.isLoadingPrompts = false;
|
||||
if (this.promptRequest === request) this.isLoadingPrompts = false;
|
||||
}
|
||||
}
|
||||
|
||||
async loadMoreTagPrompts() {
|
||||
if (this.isLoadingPrompts || !this.hasMorePrompts) return;
|
||||
const request = {};
|
||||
this.promptRequest = request;
|
||||
this.isLoadingPrompts = true;
|
||||
|
||||
const spinner = document.querySelector('#tagPromptsLoader .prompts-loader-spinner');
|
||||
@@ -675,6 +683,7 @@ class TagsPageManager {
|
||||
}
|
||||
|
||||
const data = await this.fetchJson(url);
|
||||
if (this.promptRequest !== request) return;
|
||||
|
||||
if (data.success) {
|
||||
const startIndex = this.prompts.length;
|
||||
@@ -685,13 +694,16 @@ class TagsPageManager {
|
||||
this.updatePromptCountBadge();
|
||||
}
|
||||
} catch (err) {
|
||||
if (this.promptRequest !== request) return;
|
||||
console.error('Failed to load more prompts:', err);
|
||||
this.hasMorePrompts = false;
|
||||
} finally {
|
||||
this.isLoadingPrompts = false;
|
||||
if (spinner) spinner.classList.add('hidden');
|
||||
this.updateLoadMoreButton();
|
||||
requestAnimationFrame(() => this.refreshPromptObserver());
|
||||
if (this.promptRequest === request) {
|
||||
this.isLoadingPrompts = false;
|
||||
if (spinner) spinner.classList.add('hidden');
|
||||
this.updateLoadMoreButton();
|
||||
requestAnimationFrame(() => this.refreshPromptObserver());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -987,6 +999,9 @@ class TagsPageManager {
|
||||
}
|
||||
|
||||
clearPrompts() {
|
||||
this.promptRequest = null;
|
||||
this.isLoadingPrompts = false;
|
||||
this.promptOffset = 0;
|
||||
this.prompts = [];
|
||||
this.promptTotal = 0;
|
||||
this.hasMorePrompts = false;
|
||||
@@ -1091,6 +1106,7 @@ class TagsPageManager {
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.promptRequest = null;
|
||||
this.destroyInfiniteScroll();
|
||||
this.hideContextMenu();
|
||||
clearTimeout(this.searchTimer);
|
||||
|
||||
Reference in New Issue
Block a user