@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -1049,3 +1049,70 @@ 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"))
|
||||
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)
|
||||
|
||||
+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")
|
||||
? {
|
||||
|
||||
Reference in New Issue
Block a user