Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71b55bc630 | ||
|
|
4c9cb7f5ea |
@@ -19,7 +19,3 @@
|
||||
/.flake8
|
||||
/.pre-commit-config.yaml
|
||||
/.gitignore
|
||||
|
||||
# Stray images no code uses
|
||||
/image_meta_data.png
|
||||
/test_metadata_file.png
|
||||
|
||||
@@ -12,10 +12,10 @@ Prompt Manager saves your prompts as you generate, links each image back to the
|
||||
|
||||
## What's new: rebuilt from the ground up
|
||||
|
||||
- **A new UI.** One app, the workspace, replaces the separate dashboard, gallery and metadata pages. The old pages still work if you want them.
|
||||
- **A new UI.** One app, the workspace, replaces the separate dashboard, gallery and metadata pages.
|
||||
- **Classic and Nodes 2.0.** The nodes work in both ComfyUI renderers. CI starts a real ComfyUI and checks both.
|
||||
- **Tested end to end.** Unit tests, browser tests in Chromium, Firefox, WebKit and touch emulation, and tests against a real ComfyUI.
|
||||
- **Mac, Linux and Windows.** The Python suite runs on all three, on Python 3.12, 3.13 and 3.14.
|
||||
- **Linux and Windows.** The Python suite runs on both, on Python 3.12, 3.13 and 3.14. macOS should work but is not tested.
|
||||
|
||||
## See it
|
||||
|
||||
@@ -212,9 +212,9 @@ Set `PROMPT_MANAGER_DATA_DIR` to an absolute folder to keep the library somewher
|
||||
- **Older images:** a one-time notice offers a metadata scan that links them to prompts and negatives. Scans don't count as new runs.
|
||||
- **Going back isn't supported.** Workflows are now stored once, and an older version shows new records without their workflow or prompt graph. Back up first in System > Backup & export. See [the old README's changelog](README-legacy.md#changelog) for the manual way back.
|
||||
|
||||
## The old pages still work
|
||||
## The old pages will be removed
|
||||
|
||||
They stay at their URLs, and System's Overview section links to them:
|
||||
They still work at their URLs for now and will be removed in a future release. Everything they do is moving to the workspace.
|
||||
|
||||
| Page | URL |
|
||||
| --- | --- |
|
||||
|
||||
+11
-7
@@ -77,7 +77,7 @@ PM_TEST_WORKERS=8 npm run --prefix tools/vendor test:coverage # whole
|
||||
npm run --prefix tools/vendor test:coverage:shard -- 1/4 # node units, viewer suite, workflows 1/4
|
||||
npm run --prefix tools/vendor test:coverage:shard -- 2/4 # workflows 2/4 (likewise 3/4, 4/4)
|
||||
npm run --prefix tools/vendor coverage:merge # report and baseline check of every raw file
|
||||
npm run --prefix tools/vendor test:workflows -- --project webkit --shard 2/2
|
||||
npm run --prefix tools/vendor test:workflows -- --project webkit --shard 2/3
|
||||
```
|
||||
|
||||
`coverage:merge` refuses an empty raw directory; it does not count shards, so run
|
||||
@@ -193,8 +193,8 @@ waiting for the `frontend coverage` jobs.
|
||||
|
||||
The `viewerjs` check runs on every PR, including backend-only changes. It is an
|
||||
aggregate job. It requires the four `frontend coverage (shard N/4)` jobs and
|
||||
`frontend coverage (merge)` to succeed on every event. The four
|
||||
`page workflows (firefox|webkit, shard N/2)` jobs run only in the merge queue,
|
||||
`frontend coverage (merge)` to succeed on every event. The six
|
||||
`page workflows (firefox|webkit, shard N/3)` jobs run only in the merge queue,
|
||||
on `main` and on `workflow_dispatch` (see "Pull requests and the merge queue").
|
||||
`viewerjs` requires them to be skipped on a pull request and to succeed
|
||||
everywhere else. Any other failed, cancelled or skipped job fails it. Every browser job runs with `PM_TEST_WORKERS: 3` (the public
|
||||
@@ -2735,7 +2735,7 @@ the grid) and D43 (sorted chips, checked against the concept with its chips sort
|
||||
D44 needs no mapping: the comparison runs with no Refine filter active, so the button
|
||||
reads "Filters" as the concept's does. Nothing else follows the grid: the legacy
|
||||
"Current prompt library" link is removed from the shelf; the legacy Library stays at
|
||||
`/prompt_manager/admin` by direct URL, linked only from System (#525, D165)
|
||||
`/prompt_manager/admin` by direct URL (#525, #620)
|
||||
(`tests/browser/workspace.spec.mjs`). `library comparison detects
|
||||
mutations` injects `.card-footer { padding: 2px }` and removes `.card-meta .chip.red`.
|
||||
|
||||
@@ -3243,7 +3243,7 @@ npm run --prefix tools/vendor test:workflows -- history-concept.spec.mjs \
|
||||
|
||||
`#/inspect` ("Inspect a file") is a native workspace route; it no longer hands off to
|
||||
the legacy metadata page, which stays at its URL
|
||||
(`/prompt_manager/gallery.html`) linked only from System (#525, D165). The page is the concept's `#metadata`
|
||||
(`/prompt_manager/gallery.html`) at its URL only (#525, #620). The page is the concept's `#metadata`
|
||||
(app.js `renderMetadata` a740–748, selection a1715–1729; style.css 709–722, 1180–1194,
|
||||
1543–1545, 1559–1564) in the concept's own classes (`.surface`, `.file-drop`,
|
||||
`.spaced-20`, `.local-file-preview`, all from the synced `concept.css`), with no
|
||||
@@ -4665,6 +4665,9 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
|
||||
concept row's), `skipped tags add, preview, remove, save and reload (D202)`, `skipped
|
||||
tags preview checks library tags only …` (workspace-system-tagging.spec) and
|
||||
`suggestions omit skipped tags and say how many were hidden` (workspace-tagging.spec).
|
||||
- **D205** (Prompt library, #620, beyond the concept): the sort menu appends Recently used, Most used and Name Z–A
|
||||
after Oldest first. These are the old Dashboard's sorts, kept before its removal because the community asked for
|
||||
them. A prompt never run sorts last in both use orders.
|
||||
- **D203** (Auto-tagging, #431, beyond the concept): "Saved instructions" replaces the
|
||||
concept's "LLM tag template" field (the mock's state 2): a `.setting-block`
|
||||
(`data-setting="tag-instructions"`, `data-key="autotag_instructions"`) with the label,
|
||||
@@ -7492,7 +7495,7 @@ Deploying this backend needs a ComfyUI restart (Python and route changes).
|
||||
|
||||
`#/tags` ("Tags & categories", page title "Tags") is a native workspace route; it no
|
||||
longer hands off to the legacy tag manager. That page stays at
|
||||
`/prompt_manager/admin#/tags` linked only from System (#525, D165); tag search and sort, the untagged
|
||||
`/prompt_manager/admin#/tags` at its URL only (#525, #620); tag search and sort, the untagged
|
||||
entry (D149) and multi-tag OR (the Library's Tag match, D150) moved to the workspace
|
||||
(#444); bulk prompt delete stays on the legacy admin page (bulk tags: the Prompt
|
||||
library's selection bar, D151). A prompt's category is changed in the Prompt library
|
||||
@@ -7624,7 +7627,8 @@ The legacy pages (`/prompt_manager/admin`, `admin#/tags`, `gallery`, `gallery.ht
|
||||
their URLs, and admin's Metadata and Gallery buttons still open their legacy pages (maintainer
|
||||
ruling; pinned by `legacy-system-links.spec.mjs`).
|
||||
|
||||
- **D165** (beyond the concept, maintainer ruling 2026-10-30): System's Overview section ends
|
||||
- **D165 retired** (#620): the old pages show their removal notice since 3.5.1, so System no longer links them and the load failure below offers a reload and ComfyUI. The original ruling:
|
||||
**D165** (beyond the concept, maintainer ruling 2026-10-30): System's Overview section ends
|
||||
with one small line, "Legacy pages: Dashboard · Tags · Gallery · Metadata viewer"
|
||||
(`.legacy-pages`, new tabs), outside `.settings-layout`. The concept has no such line;
|
||||
`mapOverviewConcept` adds it to the page tree.
|
||||
|
||||
@@ -291,13 +291,6 @@ class AnnotationOperations:
|
||||
conn.execute(_BUMP_INDEX)
|
||||
return conn.execute(_REVISION, (byte_id,)).fetchone()[0]
|
||||
|
||||
def _current_set(self, conn, sha256, byte_id, revision) -> dict:
|
||||
return {
|
||||
"sha256": sha256,
|
||||
"tags": self._byte_tags(conn, byte_id),
|
||||
"tag_revision": revision,
|
||||
}
|
||||
|
||||
def read_image_tags(self, locator: str, sha256: str) -> dict:
|
||||
"""The stored set of these exact bytes, or an empty set at revision 0.
|
||||
|
||||
|
||||
+2
-22
@@ -16,18 +16,17 @@ check, and none of them carries a server path.
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import ntpath
|
||||
import os
|
||||
import sqlite3
|
||||
from pathlib import PurePosixPath, PureWindowsPath
|
||||
from urllib.parse import quote
|
||||
|
||||
try:
|
||||
from ..utils.hashing import register_prompt_functions
|
||||
from .models import normalize_image_path
|
||||
from .sqlite_uri import readonly_uri
|
||||
except ImportError:
|
||||
from utils.hashing import register_prompt_functions
|
||||
from database.models import normalize_image_path
|
||||
from database.sqlite_uri import readonly_uri
|
||||
|
||||
logger = logging.getLogger("prompt_manager.database.health")
|
||||
|
||||
@@ -89,25 +88,6 @@ def _counted(lines):
|
||||
return _result(count, detail)
|
||||
|
||||
|
||||
def readonly_uri(db_path, *, windows=None):
|
||||
"""The ``mode=ro`` SQLite URI of *db_path*, on this platform or the given one.
|
||||
|
||||
pathlib spells a UNC path ``\\\\server\\share\\x.db`` as
|
||||
``file://server/share/x.db``, a remote authority SQLite refuses to open.
|
||||
With an empty authority (``file:////server/share/x.db``) SQLite hands
|
||||
Windows the ``//server/share`` path, which it opens as the UNC path.
|
||||
"""
|
||||
if windows is None:
|
||||
windows = os.name == "nt"
|
||||
db_path = os.path.abspath(db_path)
|
||||
if not windows:
|
||||
return PurePosixPath(db_path).as_uri() + "?mode=ro"
|
||||
path = PureWindowsPath(db_path)
|
||||
if ntpath.splitdrive(str(path))[0].startswith("\\\\"):
|
||||
return "file:" + quote("//" + path.as_posix()) + "?mode=ro"
|
||||
return path.as_uri() + "?mode=ro"
|
||||
|
||||
|
||||
def _integrity(conn, _roots):
|
||||
rows = [
|
||||
row[0] for row in conn.execute(f"PRAGMA integrity_check({INTEGRITY_LIMIT})")
|
||||
|
||||
@@ -97,6 +97,9 @@ _ORDERS = {
|
||||
"newest": ("COALESCE(julianday(p.created_at),0)", "DESC"),
|
||||
"oldest": ("COALESCE(julianday(p.created_at),0)", "ASC"),
|
||||
"name": (_NAME, "ASC"),
|
||||
"name-desc": (_NAME, "DESC"),
|
||||
"most-used": ("COALESCE(p.run_count,0)", "DESC"),
|
||||
"last-used": ("COALESCE(julianday(p.last_used_at),0)", "DESC"),
|
||||
"rating": ("COALESCE(p.rating,0)", "DESC"),
|
||||
}
|
||||
# Pair archive scopes for variant and image reads, as conditions on alias pp.
|
||||
|
||||
@@ -47,11 +47,11 @@ import sqlite3
|
||||
import time
|
||||
import types
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from .models import BUSY_TIMEOUT_SECONDS, PromptModel
|
||||
from .sqlite_uri import readonly_uri
|
||||
|
||||
try:
|
||||
from ..utils import library_location as ll
|
||||
@@ -322,7 +322,7 @@ def _verify_copy(temp: str, counts, documents) -> None:
|
||||
"verify-failed",
|
||||
f"{MESSAGES['verify-failed']} {detail}" if detail else None,
|
||||
)
|
||||
uri = Path(os.path.abspath(temp)).as_uri() + "?mode=ro"
|
||||
uri = readonly_uri(temp)
|
||||
try:
|
||||
with contextlib.closing(sqlite3.connect(uri, uri=True)) as conn:
|
||||
copied = _describe(conn)
|
||||
|
||||
+3
-4
@@ -14,6 +14,7 @@ from contextlib import closing
|
||||
from pathlib import Path
|
||||
from typing import Optional, Set, Tuple
|
||||
|
||||
from .sqlite_uri import readonly_uri
|
||||
from .managed_connection import (
|
||||
ManagedConnection,
|
||||
await_closed,
|
||||
@@ -1199,7 +1200,7 @@ class PromptModel:
|
||||
if header != SQLITE_HEADER:
|
||||
return False, "Not a SQLite database file"
|
||||
|
||||
uri = Path(os.path.abspath(path)).as_uri() + "?mode=ro"
|
||||
uri = readonly_uri(path)
|
||||
try:
|
||||
conn = sqlite3.connect(uri, uri=True)
|
||||
except sqlite3.Error as e:
|
||||
@@ -1306,9 +1307,7 @@ class PromptModel:
|
||||
replacement (for example, incompatible page sizes in WAL mode),
|
||||
propagate the error rather than copy files under other open owners.
|
||||
"""
|
||||
source = sqlite3.connect(
|
||||
Path(src_path).resolve().as_uri() + "?mode=ro", uri=True
|
||||
)
|
||||
source = sqlite3.connect(readonly_uri(Path(src_path).resolve()), uri=True)
|
||||
try:
|
||||
target = self.get_connection()
|
||||
# The target is written under backup's step too: mark it in use
|
||||
|
||||
@@ -42,6 +42,7 @@ from typing import Dict, Optional
|
||||
from . import workflow_documents as wd
|
||||
from .execution_history_schema import rotate_history_epoch
|
||||
from .safety_backup import take_safety_backup
|
||||
from .sqlite_uri import readonly_uri
|
||||
|
||||
logger = logging.getLogger("prompt_manager.database.restore_stage")
|
||||
|
||||
@@ -114,7 +115,7 @@ def file_digest(path: str) -> str:
|
||||
|
||||
|
||||
def _readonly(path: str) -> sqlite3.Connection:
|
||||
return sqlite3.connect(Path(path).resolve().as_uri() + "?mode=ro", uri=True)
|
||||
return sqlite3.connect(readonly_uri(Path(path).resolve()), uri=True)
|
||||
|
||||
|
||||
def _schema(path: str) -> list:
|
||||
|
||||
@@ -31,7 +31,15 @@ MAX_STATE_BYTES = 4096
|
||||
MAX_TAGS = 20
|
||||
MAX_TAG_LENGTH = 50
|
||||
_IMAGE_SORTS = ("newest", "oldest", "name", "name-desc", "rating")
|
||||
_LIBRARY_SORTS = ("newest", "oldest", "name", "rating")
|
||||
_LIBRARY_SORTS = (
|
||||
"newest",
|
||||
"oldest",
|
||||
"name",
|
||||
"name-desc",
|
||||
"most-used",
|
||||
"last-used",
|
||||
"rating",
|
||||
)
|
||||
_MEDIA = ("all", "image", "video")
|
||||
# How a Library view's Tags match (#444): every tag, any of them, or parents
|
||||
# without tags ("untagged" never goes with tags; the listing refuses both).
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""The read-only SQLite URI of a path, safe for Windows network shares."""
|
||||
|
||||
import ntpath
|
||||
import os
|
||||
from pathlib import PurePosixPath, PureWindowsPath
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
def readonly_uri(db_path, *, windows=None):
|
||||
"""The ``mode=ro`` SQLite URI of *db_path*, on this platform or the given one.
|
||||
|
||||
pathlib spells a UNC path ``\\\\server\\share\\x.db`` as
|
||||
``file://server/share/x.db``, a remote authority SQLite refuses to open.
|
||||
With an empty authority (``file:////server/share/x.db``) SQLite hands
|
||||
Windows the ``//server/share`` path, which it opens as the UNC path.
|
||||
"""
|
||||
if windows is None:
|
||||
windows = os.name == "nt"
|
||||
db_path = os.path.abspath(db_path)
|
||||
if not windows:
|
||||
return PurePosixPath(db_path).as_uri() + "?mode=ro"
|
||||
path = PureWindowsPath(db_path)
|
||||
if ntpath.splitdrive(str(path))[0].startswith("\\\\"):
|
||||
return "file:" + quote("//" + path.as_posix()) + "?mode=ro"
|
||||
return path.as_uri() + "?mode=ro"
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 34 KiB |
@@ -3,7 +3,6 @@ PromptManager: Main custom node implementation that extends CLIPTextEncode
|
||||
with persistent prompt storage and search capabilities.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
if __package__:
|
||||
@@ -259,19 +258,6 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
if not save_to_library:
|
||||
return result # #90: no metadata registration, no history text
|
||||
|
||||
# Register with ComfyUI integration for standard metadata compatibility
|
||||
node_id = f"promptmanager_{int(time.time() * 1000)}"
|
||||
self.comfyui_integration.register_prompt(
|
||||
node_id,
|
||||
encoding_text.strip(),
|
||||
{
|
||||
"category": category.strip() if category else None,
|
||||
"tags": extended_tags,
|
||||
"prompt_id": prompt_id,
|
||||
"prepend_text": prepend_text.strip() if prepend_text else None,
|
||||
"append_text": append_text.strip() if append_text else None,
|
||||
},
|
||||
)
|
||||
record_runtime_context(
|
||||
type(self).__name__, text, prepend_text, append_text, encoding_text
|
||||
)
|
||||
|
||||
@@ -19,7 +19,6 @@ except ImportError:
|
||||
|
||||
try:
|
||||
from .database.operations import PromptDatabase
|
||||
from .utils.comfyui_integration import get_comfyui_integration
|
||||
from .utils.image_monitor import get_image_monitor
|
||||
from .utils.prompt_tracker import get_prompt_tracker
|
||||
from .utils.hashing import generate_prompt_hash
|
||||
@@ -38,7 +37,6 @@ except ImportError:
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from database.operations import PromptDatabase
|
||||
from utils.comfyui_integration import get_comfyui_integration
|
||||
from utils.image_monitor import get_image_monitor
|
||||
from utils.prompt_tracker import get_prompt_tracker
|
||||
from utils.hashing import generate_prompt_hash
|
||||
@@ -86,7 +84,6 @@ class PromptManagerBase:
|
||||
self.db = PromptDatabase()
|
||||
self.prompt_tracker = get_prompt_tracker(self.db)
|
||||
self.image_monitor = get_image_monitor(self.db, self.prompt_tracker)
|
||||
self.comfyui_integration = get_comfyui_integration()
|
||||
|
||||
self._start_gallery_system()
|
||||
self.logger.debug(f"{self.__class__.__name__} node initialization completed")
|
||||
@@ -433,18 +430,6 @@ class PromptManagerBase:
|
||||
self.logger.error(f"Error searching prompts: {e}")
|
||||
return []
|
||||
|
||||
def search_prompts_api(self, search_text: str = "") -> List[Dict[str, Any]]:
|
||||
"""API method for JavaScript UI to search prompts."""
|
||||
return self._search_prompts(search_text=search_text)
|
||||
|
||||
def get_recent_prompts_api(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""API method for JavaScript UI to get recent prompts."""
|
||||
try:
|
||||
return self.db.get_recent_prompts(limit=limit)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting recent prompts: {e}")
|
||||
return []
|
||||
|
||||
def _start_gallery_system(self):
|
||||
"""Initialize and start the gallery monitoring system."""
|
||||
try:
|
||||
@@ -455,13 +440,6 @@ class PromptManagerBase:
|
||||
self.logger.error(f"Failed to start gallery system: {e}")
|
||||
self.logger.warning("Gallery features will be disabled")
|
||||
|
||||
def get_gallery_status(self) -> Dict[str, Any]:
|
||||
"""Get status of the gallery system."""
|
||||
return {
|
||||
"image_monitor": self.image_monitor.get_status(),
|
||||
"prompt_tracker": self.prompt_tracker.get_status(),
|
||||
}
|
||||
|
||||
def cleanup_gallery_system(self):
|
||||
"""Clean up gallery system resources.
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
if __package__:
|
||||
@@ -173,19 +172,6 @@ class PromptManagerPair(PromptManagerFocused):
|
||||
self.logger.warning(f"Could not save or track paired prompt: {error}")
|
||||
if is_positive:
|
||||
self._mark_untracked(prompt, unique_id)
|
||||
try:
|
||||
self.comfyui_integration.register_prompt(
|
||||
f"promptmanager_pair_{time.time_ns()}",
|
||||
positive,
|
||||
{
|
||||
"prompt_id": prompt_id,
|
||||
"pair_id": pair_id if pair_used else None,
|
||||
"negative_text": negative_text,
|
||||
"category": category.strip() or None,
|
||||
},
|
||||
)
|
||||
except Exception as error:
|
||||
self.logger.warning(f"Could not register paired metadata: {error}")
|
||||
record_runtime_context(
|
||||
type(self).__name__,
|
||||
text,
|
||||
|
||||
@@ -3,7 +3,6 @@ PromptManagerText: A text-only version of PromptManager that outputs STRING
|
||||
without CLIP encoding, while maintaining all database and search features.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
if __package__:
|
||||
@@ -230,19 +229,6 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
|
||||
if not save_to_library:
|
||||
return result # #90: no metadata registration, no history text
|
||||
|
||||
# Register with ComfyUI integration for standard metadata compatibility
|
||||
node_id = f"promptmanagertext_{int(time.time() * 1000)}"
|
||||
self.comfyui_integration.register_prompt(
|
||||
node_id,
|
||||
final_text.strip(),
|
||||
{
|
||||
"category": category.strip() if category else None,
|
||||
"tags": extended_tags,
|
||||
"prompt_id": prompt_id,
|
||||
"prepend_text": prepend_text.strip() if prepend_text else None,
|
||||
"append_text": append_text.strip() if append_text else None,
|
||||
},
|
||||
)
|
||||
record_runtime_context(
|
||||
type(self).__name__, text, prepend_text, append_text, final_text
|
||||
)
|
||||
|
||||
@@ -89,7 +89,6 @@ from .source_rescope import SourceRescopeMixin
|
||||
|
||||
try:
|
||||
from ...database.operations import PromptDatabase
|
||||
from ...utils.file_prompt import parse_comfyui_prompt
|
||||
from ...utils.logging_config import get_logger
|
||||
from ...utils.native_memory import release_free_memory
|
||||
from ...utils.png_text import png_text
|
||||
@@ -101,7 +100,6 @@ except ImportError:
|
||||
0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
)
|
||||
from database.operations import PromptDatabase
|
||||
from utils.file_prompt import parse_comfyui_prompt
|
||||
from utils.logging_config import get_logger
|
||||
from utils.native_memory import release_free_memory
|
||||
from utils.png_text import png_text
|
||||
@@ -254,23 +252,6 @@ async def _read_json_body(request, max_bytes=JSON_BODY_MAX_BYTES):
|
||||
return data, None
|
||||
|
||||
|
||||
class _LongJobGuard:
|
||||
"""Async context manager handed out by ``_acquire_long_job``."""
|
||||
|
||||
def __init__(self, lock):
|
||||
self._lock = lock
|
||||
|
||||
async def __aenter__(self):
|
||||
# The caller checked ``locked()`` on this same loop iteration, so this
|
||||
# acquire never waits.
|
||||
await self._lock.acquire()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
self._lock.release()
|
||||
return False
|
||||
|
||||
|
||||
# ── Gzip compression middleware ────────────────────────────────────────
|
||||
_GZIP_MIN_SIZE = 1024 # Only compress bodies larger than 1 KB
|
||||
_GZIP_TYPES = frozenset(
|
||||
@@ -426,10 +407,6 @@ class PromptManagerAPI(
|
||||
self._workspace_card_cache = {}
|
||||
# Favorite keys of each root's cached scan (images._favorite_listing).
|
||||
self._favorite_listing_cache = {}
|
||||
# Single-flight locks for long jobs, keyed by job family; see
|
||||
# _acquire_long_job(). Created lazily so no lock binds to an event
|
||||
# loop before the server's loop exists.
|
||||
self._long_jobs: dict = {}
|
||||
# This session's jobs (py/api/jobs.py): the metadata scan, and later
|
||||
# every other long operation the Activity page tracks.
|
||||
self._jobs = JobRegistry(self.logger)
|
||||
@@ -486,26 +463,6 @@ class PromptManagerAPI(
|
||||
_public_error = staticmethod(_public_error)
|
||||
_read_json_body = staticmethod(_read_json_body)
|
||||
|
||||
def _acquire_long_job(self, name):
|
||||
"""Claim the single-flight slot for a long job family, or None when busy.
|
||||
|
||||
Usage in a handler::
|
||||
|
||||
job = self._acquire_long_job("duplicates")
|
||||
if job is None:
|
||||
return self._long_job_busy_response("Duplicate scan")
|
||||
async with job:
|
||||
...
|
||||
|
||||
Families are free-form strings ("duplicates", "thumbnails",
|
||||
"autotag", ...); the lock is released when the ``async with`` block
|
||||
exits, including on exceptions. Must be called from the event loop.
|
||||
"""
|
||||
lock = self._long_jobs.setdefault(name, asyncio.Lock())
|
||||
if lock.locked():
|
||||
return None
|
||||
return _LongJobGuard(lock)
|
||||
|
||||
@staticmethod
|
||||
def _long_job_busy_response(label):
|
||||
"""409 envelope for a long job that is already running."""
|
||||
@@ -1079,7 +1036,3 @@ class PromptManagerAPI(
|
||||
f"Unreadable image {os.path.basename(str(image_path))}: {e}"
|
||||
)
|
||||
return {}
|
||||
|
||||
def _parse_comfyui_prompt(self, metadata):
|
||||
"""Parse ComfyUI and A1111 prompt data from metadata."""
|
||||
return parse_comfyui_prompt(metadata)
|
||||
|
||||
+3
-1
@@ -30,8 +30,10 @@ from .jobs import (
|
||||
)
|
||||
|
||||
try:
|
||||
from ...database.sqlite_uri import readonly_uri
|
||||
from ...utils.parallel import map_parallel
|
||||
except ImportError:
|
||||
from database.sqlite_uri import readonly_uri
|
||||
from utils.parallel import map_parallel
|
||||
|
||||
try:
|
||||
@@ -399,7 +401,7 @@ def _inspect_restore_upload(path):
|
||||
Returns:
|
||||
None when acceptable, otherwise a human-readable reason (blocking).
|
||||
"""
|
||||
uri = Path(os.path.abspath(path)).as_uri() + "?mode=ro"
|
||||
uri = readonly_uri(path)
|
||||
try:
|
||||
conn = sqlite3.connect(uri, uri=True)
|
||||
except sqlite3.Error as e:
|
||||
|
||||
@@ -943,16 +943,6 @@ class AutotagRoutesMixin:
|
||||
return refusal.response
|
||||
return web.json_response({"success": True, "job": job.summary()}, status=202)
|
||||
|
||||
def _gated_runner(self, owner):
|
||||
"""Executor calls under the model gate *owner* holds (plain when None)."""
|
||||
if owner is None:
|
||||
return self._run_in_executor
|
||||
|
||||
async def run(func, *args, **kwargs):
|
||||
return await model_gate.run(owner, func, *args, **kwargs)
|
||||
|
||||
return run
|
||||
|
||||
def _resolve_allowed_image_path(self, raw_path: str) -> Optional[str]:
|
||||
"""Return the real path when it is a media file inside an output directory."""
|
||||
if Path(str(raw_path)).suffix.lower() not in _MEDIA_EXTENSIONS:
|
||||
@@ -1421,7 +1411,8 @@ class AutotagRoutesMixin:
|
||||
)
|
||||
|
||||
model_type = service.get_loaded_model_type()
|
||||
service.unload_model()
|
||||
# Off the loop; a dropped request keeps the gate until it ends.
|
||||
await model_gate.run(owner, service.unload_model)
|
||||
finally:
|
||||
model_gate.release(owner)
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
@@ -56,6 +55,7 @@ from .maintenance_plans import PlanExpired, PlanUsed
|
||||
from .restore_plans import RestorePlanStore
|
||||
|
||||
try:
|
||||
from ...database.sqlite_uri import readonly_uri
|
||||
from ...database import restore_stage
|
||||
from ...database.operations import PromptDatabase
|
||||
from ...database.safety_backup import (
|
||||
@@ -66,6 +66,7 @@ try:
|
||||
safety_temp,
|
||||
)
|
||||
except ImportError:
|
||||
from database.sqlite_uri import readonly_uri
|
||||
from database import restore_stage
|
||||
from database.operations import PromptDatabase
|
||||
from database.safety_backup import (
|
||||
@@ -186,7 +187,7 @@ def snapshot_corruption(path):
|
||||
with open(path, "rb") as handle:
|
||||
if handle.read(len(SQLITE_HEADER)) != SQLITE_HEADER:
|
||||
return "Not a SQLite database file"
|
||||
uri = Path(os.path.abspath(path)).as_uri() + "?mode=ro"
|
||||
uri = readonly_uri(path)
|
||||
with closing(sqlite3.connect(uri, uri=True)) as conn:
|
||||
row = conn.execute("PRAGMA integrity_check").fetchone()
|
||||
except (OSError, sqlite3.Error) as error:
|
||||
|
||||
+3
-1
@@ -377,7 +377,9 @@ class LibraryRoutesMixin:
|
||||
[parent["id"] for parent in result["items"]]
|
||||
)
|
||||
result["next_cursor"] = _encode(
|
||||
result.pop("next_key"), scope, name=options["sort"] == "name"
|
||||
result.pop("next_key"),
|
||||
scope,
|
||||
name=options["sort"] in ("name", "name-desc"),
|
||||
)
|
||||
return result, records
|
||||
|
||||
|
||||
+9
-1
@@ -727,7 +727,15 @@ class PromptRoutesMixin:
|
||||
async def save_prompt(self, request):
|
||||
"""Save a new prompt with metadata and duplicate detection."""
|
||||
try:
|
||||
data = await request.json()
|
||||
data, error = await self._read_json_body(request)
|
||||
if error is not None:
|
||||
return error
|
||||
for field in ("text", "category", "notes"):
|
||||
if data.get(field) is not None and not isinstance(data[field], str):
|
||||
return web.json_response(
|
||||
{"success": False, "error": f"{field} must be text"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
text = (data.get("text") or "").strip()
|
||||
if not text:
|
||||
|
||||
@@ -632,10 +632,6 @@ class AutoThumbnailer:
|
||||
"""True once the worker has nothing queued or in hand."""
|
||||
return self._wait_for(lambda: not self._busy, timeout)
|
||||
|
||||
def wait_batch(self, size, timeout=None):
|
||||
"""True once the worker holds a batch of *size* files."""
|
||||
return self._wait_for(lambda: self._batch == size, timeout)
|
||||
|
||||
|
||||
class _PreviewGate:
|
||||
"""Admission of view requests on the event loop (#249 I2).
|
||||
@@ -967,15 +963,6 @@ class AutoThumbnailMixin:
|
||||
return "queued"
|
||||
return "skipped"
|
||||
|
||||
def _capture_target(self, image_path):
|
||||
"""``(src, dst, is_video)`` of a new output's preview now, or None."""
|
||||
located = self._capture_source(image_path)
|
||||
if located is None:
|
||||
return None
|
||||
src, root, rel, is_video = located
|
||||
dst = DeferredPreview(root, rel).resolve()
|
||||
return None if dst is None else (src, dst, is_video)
|
||||
|
||||
def _capture_source(self, image_path):
|
||||
"""``(src, root, rel, is_video)`` of a media file under a current
|
||||
root (not a preview, a link or in a trash folder), or None."""
|
||||
|
||||
@@ -90,7 +90,7 @@ class ThumbnailOptions:
|
||||
}
|
||||
|
||||
|
||||
def thumbnail_options(raw, *, from_query=False):
|
||||
def thumbnail_options(raw):
|
||||
"""``(ThumbnailOptions, None)`` from a body or query, or ``(None, message)``.
|
||||
|
||||
An absent mode is ``missing``; the settings are the server's now.
|
||||
@@ -136,7 +136,7 @@ class ThumbnailJobsMixin:
|
||||
``mode=prune`` lists the removed sources' thumbnails (#320)."""
|
||||
if request.query.get("mode") == "prune":
|
||||
return await self.get_prune_plan()
|
||||
options, error = thumbnail_options(request.query, from_query=True)
|
||||
options, error = thumbnail_options(request.query)
|
||||
if error:
|
||||
return _invalid(error)
|
||||
try:
|
||||
|
||||
@@ -78,27 +78,6 @@ def _canonical_path(path: str) -> str:
|
||||
return os.path.normcase(os.path.realpath(path))
|
||||
|
||||
|
||||
def write_private_json(path: str, data: Any) -> None:
|
||||
"""Write ``data`` as JSON to ``path`` readable by the current user only.
|
||||
|
||||
config.json can hold an API key (integrations.lora_manager), so it is
|
||||
created with mode 0600 and, on POSIX, an existing more open file is
|
||||
tightened too. Windows has no POSIX modes; there the chmod is a best
|
||||
effort that only affects the read-only bit. Parent directories are
|
||||
created as needed. Raises OSError when the file cannot be written.
|
||||
"""
|
||||
import json
|
||||
|
||||
parent_dir = os.path.dirname(path)
|
||||
if parent_dir:
|
||||
os.makedirs(parent_dir, exist_ok=True)
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
if os.name == "posix":
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
def _lexical_path(path: str) -> str:
|
||||
"""``path`` normalised for comparison without touching the filesystem.
|
||||
|
||||
@@ -717,12 +696,6 @@ class AutotagConfig:
|
||||
def suggestion_limit(cls) -> int:
|
||||
return cls.SUGGESTION_LIMIT
|
||||
|
||||
@staticmethod
|
||||
def valid_suggestion_limit(value: Any) -> bool:
|
||||
return (
|
||||
type(value) is int and 1 <= value <= MAX_SUGGESTION_LIMIT
|
||||
) # bool, float and str are rejected
|
||||
|
||||
@classmethod
|
||||
def all_instructions(cls) -> List[Dict[str, str]]:
|
||||
"""Built-in first, then the saved entries (copies)."""
|
||||
@@ -1063,35 +1036,6 @@ class PromptManagerConfig:
|
||||
else:
|
||||
config_logger.info(f"Config file not found: {config_path}, using defaults")
|
||||
|
||||
@classmethod
|
||||
def save_to_file(cls, config_path: Optional[str] = None):
|
||||
"""Save the current configuration to a JSON file.
|
||||
|
||||
Serializes the complete configuration (including gallery settings) to
|
||||
a JSON file. Creates the directory structure if it doesn't exist.
|
||||
|
||||
Args:
|
||||
config_path (str): Path where the JSON configuration file should be saved.
|
||||
Parent directories will be created if they don't exist.
|
||||
Defaults to :meth:`get_config_path`.
|
||||
|
||||
Raises:
|
||||
The method handles all exceptions internally and logs errors rather
|
||||
than propagating them.
|
||||
|
||||
Example:
|
||||
PromptManagerConfig.save_to_file('backup_config.json')
|
||||
PromptManagerConfig.save_to_file('/etc/comfyui/prompt_manager.json')
|
||||
"""
|
||||
if config_path is None:
|
||||
config_path = cls.get_config_path()
|
||||
|
||||
try:
|
||||
write_private_json(config_path, cls.get_config())
|
||||
config_logger.info(f"Saved configuration to {config_path}")
|
||||
except Exception as e:
|
||||
config_logger.error(f"Error saving config to {config_path}: {e}")
|
||||
|
||||
@classmethod
|
||||
def update_config(cls, new_config: Dict[str, Any]):
|
||||
"""Update configuration attributes from a dictionary.
|
||||
|
||||
+2
-1
@@ -1,10 +1,11 @@
|
||||
[project]
|
||||
name = "promptmanager"
|
||||
description = "A powerful ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, and an automatic image gallery system using SQLite."
|
||||
version = "3.5.1"
|
||||
version = "3.6.0"
|
||||
license = {file = "LICENSE"}
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["watchdog>=2.1.0", "Pillow>=10.1.0"]
|
||||
classifiers = ["Operating System :: OS Independent"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=84.2.1"]
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 MiB |
@@ -21,8 +21,9 @@ export function mapLibraryConcept(reference) {
|
||||
// Save view is the concept's own (saved views, #249 D16 retired).
|
||||
const toolbar={...library.toolbar};
|
||||
toolbar.items=toolbar.items.map(item=>{
|
||||
// D14: the Library appends its Oldest first after the concept's sorts; D153 (#447) drops "Highest rated".
|
||||
if(item.id==='sort')return {...item,options:[...item.options.filter(option=>option!=='Highest rated'),'Oldest first']};
|
||||
// D14: the Library appends its Oldest first after the concept's sorts; D153 (#447) drops "Highest rated"; D205
|
||||
// (#620) appends the old Dashboard's three sorts.
|
||||
if(item.id==='sort')return {...item,options:[...item.options.filter(option=>option!=='Highest rated'),'Oldest first','Recently used','Most used','Name Z–A']};
|
||||
// D36: the category list is the Library's real list, sorted alphabetically; the concept lists prompt order.
|
||||
if(item.id==='category-filter')return {...item,options:[item.options[0],...item.options.slice(1).sort(nocase)]};
|
||||
return item;
|
||||
|
||||
@@ -167,8 +167,6 @@ const IMAGES_COUNT_URL = '/prompt_manager/images/output?mode=workspace&limit=1';
|
||||
|
||||
/** The concept's shell and Overview with the recorded deviations applied, given the server's real overview. */
|
||||
function mapOverviewConcept(reference, overview, images) {
|
||||
// D165: the discreet legacy-pages line follows the settings layout.
|
||||
reference = { ...reference, tree: [...reference.tree, 'p.legacy-pages.small'] };
|
||||
const relabel = { 'Database integrity': 'Not checked', 'Positive prompts': number(overview.library.positive_prompts),
|
||||
'Negative variants': number(overview.library.negative_variants), Images: number(images) };
|
||||
const rows = {
|
||||
@@ -265,7 +263,7 @@ const UNMAPPED_PATHS = [
|
||||
'columns.surfaces.0.rows.length', 'columns.surfaces.0.tree.4', 'columns.surfaces.0.tree.length',
|
||||
'columns.surfaces.1.rows.0.description', 'columns.surfaces.1.rows.1.title', 'columns.surfaces.1.rows.2.description',
|
||||
'heading.actions.0', 'heading.actions.length', 'metrics.items.3.value', 'nav.label',
|
||||
'save.button.cursor', 'save.button.opacity', 'save.status.text', 'tree.3', 'tree.length',
|
||||
'save.button.cursor', 'save.button.opacity', 'save.status.text',
|
||||
].sort();
|
||||
|
||||
test('system comparison detects mutations', async ({ page, request }) => {
|
||||
|
||||
@@ -191,7 +191,7 @@ test('library toolbar matches the concept and tags live in Refine this view',asy
|
||||
expect(await toolbar.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.id?`#${child.id}`:'')+(child.className?`.${child.className.split(' ').filter(name=>!name.startsWith('ca-')).join('.')}`:'')))).toEqual(['label.search-field','select#category-filter','select#sort','button','button']);
|
||||
await expect(toolbar.locator('#search')).toHaveAttribute('placeholder','Search prompts, tags, filenames…');
|
||||
// D153 (#447): no "Highest rated".
|
||||
await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Oldest first']);
|
||||
await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Oldest first','Recently used','Most used','Name Z–A']);
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']);
|
||||
await expect(page.getByRole('button',{name:'Refresh library',exact:true})).toHaveCount(0);
|
||||
await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveCount(0);
|
||||
|
||||
@@ -205,7 +205,7 @@ test('a view stored under older rules still lists and opens with its usable valu
|
||||
return (await response.json()).id;
|
||||
};
|
||||
const imagesId = await raw({ page: 'images', name: 'Old cats', state: JSON.stringify({ q: 'cat', sort: 'shuffle', layout: 'grid' }) });
|
||||
await raw({ page: 'library', name: '', state: JSON.stringify({ q: 'dog', sort: 'name-desc' }) });
|
||||
await raw({ page: 'library', name: '', state: JSON.stringify({ q: 'dog', sort: 'shuffle' }) });
|
||||
expect(await listed(request)).toEqual([
|
||||
{ id: imagesId, page: 'images', name: 'Old cats', state: { ...IMAGE_DEFAULTS, q: 'cat' } },
|
||||
expect.objectContaining({ page: 'library', name: 'Saved view', state: { q: 'dog', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } }),
|
||||
|
||||
@@ -135,34 +135,11 @@ test('/prompt_manager/web lands on the workspace Library and Back does not bounc
|
||||
await expect(main.getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
// D165 (#525, maintainer ruling): a discreet line under System is the workspace's only visible way to the legacy pages.
|
||||
test('System Overview links the legacy pages discreetly, and each one loads (#525)', async ({ page, context }) => {
|
||||
// #620: the old pages are on their way out (notice since 3.5.1), so the workspace no longer links them.
|
||||
test('System no longer links the old pages (#620)', async ({ page }) => {
|
||||
await page.goto(`${workspace}#/system/overview`);
|
||||
const line = page.locator('#workspace-main .legacy-pages');
|
||||
await expect(line).toBeVisible();
|
||||
await expect(line).toContainText('Legacy pages');
|
||||
const links = line.getByRole('link');
|
||||
await expect(links).toHaveCount(4);
|
||||
for (const [name, url, ready] of [
|
||||
['Dashboard', '/prompt_manager/admin', '#addPromptBtn'],
|
||||
['Tags', '/prompt_manager/admin#/tags', '#addPromptBtn'],
|
||||
['Gallery', '/prompt_manager/gallery', 'body'],
|
||||
['Metadata viewer', '/prompt_manager/gallery.html', 'body'],
|
||||
]) {
|
||||
const opened = context.waitForEvent('page');
|
||||
await line.getByRole('link', { name, exact: true }).click();
|
||||
const tab = await opened;
|
||||
await tab.waitForLoadState('domcontentloaded');
|
||||
const target = new URL(tab.url());
|
||||
expect(target.pathname + target.hash).toBe(url);
|
||||
await expect(tab.locator(ready).first()).toBeAttached();
|
||||
await tab.close();
|
||||
}
|
||||
// Only on System's Overview section, and gone with the page.
|
||||
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Appearance', exact: true }).click();
|
||||
await expect(line).toBeHidden();
|
||||
await page.goto(`${workspace}#/library`);
|
||||
await expect(page.locator('#workspace-main .legacy-pages')).toHaveCount(0);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('System');
|
||||
await expect(page.locator('#workspace-main a[href*="/admin"], #workspace-main a[href*="/gallery"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
const bootFailure = page => page.locator('#workspace-boot-failure');
|
||||
@@ -175,7 +152,7 @@ test('a healthy workspace never shows the load failure message (#525)', async ({
|
||||
});
|
||||
|
||||
for (const [label, pattern] of [['the entry module', '**/js/workspace/index.mjs*'], ['a dependency', '**/js/workspace/state.mjs*']]) {
|
||||
test(`when ${label} fails to load the page still offers the legacy pages (#525)`, async ({ page }) => {
|
||||
test(`when ${label} fails to load the page offers a reload and ComfyUI (#525, #620)`, async ({ page }) => {
|
||||
const pageErrors = [];
|
||||
page.on('pageerror', error => pageErrors.push(error.message));
|
||||
await page.route(pattern, route => route.abort());
|
||||
@@ -183,19 +160,17 @@ for (const [label, pattern] of [['the entry module', '**/js/workspace/index.mjs*
|
||||
const failure = bootFailure(page);
|
||||
await expect(failure).toBeVisible();
|
||||
await expect(failure).toHaveAttribute('role', 'alert');
|
||||
const dashboard = failure.getByRole('link', { name: 'Dashboard', exact: true });
|
||||
await expect(dashboard).toBeVisible();
|
||||
await expect(failure.getByRole('link')).toHaveCount(4);
|
||||
await dashboard.click();
|
||||
await expect(page).toHaveURL(/\/prompt_manager\/admin$/);
|
||||
await expect(failure.getByRole('link', { name: 'Reload the page', exact: true })).toHaveAttribute('href', 'workspace');
|
||||
await expect(failure.getByRole('link', { name: 'ComfyUI', exact: true })).toHaveAttribute('href', '../');
|
||||
await expect(failure.getByRole('link')).toHaveCount(2);
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test('when a module throws while starting the page still offers the legacy pages (#525)', async ({ page }) => {
|
||||
test('when a module throws while starting the page still offers a reload (#525, #620)', async ({ page }) => {
|
||||
await page.route('**/js/workspace/state.mjs*', route => route.fulfill({ contentType: 'text/javascript', body: "throw new Error('boot failure');" }));
|
||||
await page.goto(`${workspace}#/library`);
|
||||
await expect(bootFailure(page).getByRole('link', { name: 'Dashboard', exact: true })).toBeVisible();
|
||||
await expect(bootFailure(page).getByRole('link', { name: 'Reload the page', exact: true })).toBeVisible();
|
||||
// The broken module's own error is this test's premise, not a failure of the page.
|
||||
page.workspaceErrors.length = 0;
|
||||
});
|
||||
@@ -569,7 +544,7 @@ test('System is native: the concept shell, section navigation, sections and the
|
||||
// D92: no "Sample configuration" chip.
|
||||
await expect(main.locator('.heading .chip')).toHaveCount(0);
|
||||
expect(await main.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`)))
|
||||
.toEqual(['div.heading', 'div.section-nav', 'div.settings-layout', 'p.small legacy-pages']);
|
||||
.toEqual(['div.heading', 'div.section-nav', 'div.settings-layout']);
|
||||
const nav = main.getByRole('group', { name: 'System sections', exact: true });
|
||||
await expect(nav.getByRole('button')).toHaveText(SYSTEM_SECTION_LABELS);
|
||||
await expect(nav.getByRole('button', { name: 'Overview', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
@@ -143,7 +143,7 @@ test('the shelf no longer sorts by rating (D153)',async()=>{
|
||||
const {createLibraryStream}=await source;const s=createLibraryStream({api:{get:async()=>page([])}});
|
||||
await assert.rejects(s.reset({sort:'rating'}),/Invalid library sort/);
|
||||
assert.throws(()=>createLibraryStream({api:{get:async()=>page([])},restore:{filters:{sort:'rating'},state:{}}}),/Invalid library sort/);
|
||||
for(const sort of ['newest','oldest','name']) assert.equal(await s.reset({sort}),0);s.dispose();
|
||||
for(const sort of ['newest','oldest','name','name-desc','most-used','last-used']) assert.equal(await s.reset({sort}),0);s.dispose();
|
||||
});
|
||||
test('Refine › Folder nests each folder under its exact parent, by path segment (#444 live B1)',async()=>{
|
||||
const {libraryFolderOptions}=await source;assert.equal(typeof libraryFolderOptions,'function');
|
||||
|
||||
@@ -32,7 +32,7 @@ test('a saved state always holds every page key and keeps only values its page a
|
||||
{ ...full, q: '', folder: '', tags: [] });
|
||||
assert.deepEqual(normalizeViewState('images', { ...full, category: 'bad\u0007', tags: [' '] }), { ...full, category: '', tags: [] });
|
||||
// Library has no source or media, and no Name Z–A; its folder (#444) is the Library's own.
|
||||
assert.deepEqual(normalizeViewState('library', { ...full, sort: 'name-desc' }), { q: 'castle', category: 'Portraits', sort: 'newest', tags: ['moody', 'fog'], tag_mode: 'all', folder: 'a/b' });
|
||||
assert.deepEqual(normalizeViewState('library', { ...full, sort: 'rating' }), { q: 'castle', category: 'Portraits', sort: 'newest', tags: ['moody', 'fog'], tag_mode: 'all', folder: 'a/b' });
|
||||
assert.deepEqual(normalizeViewState('library', { sort: 'oldest' }), { ...LIBRARY_DEFAULTS, sort: 'oldest' });
|
||||
// The returned tags are a copy.
|
||||
const tags = ['one'];
|
||||
@@ -244,5 +244,7 @@ test('a view saved under the retired Highest rated sort opens on Recently added
|
||||
assert.equal(imagesFilters({ sort: 'rating' }).sort, 'newest');
|
||||
assert.equal(libraryFilters({ sort: 'rating' }).sort, 'newest');
|
||||
assert.equal(normalizeViewState('images', { sort: 'name-desc' }).sort, 'name-desc');
|
||||
assert.equal(normalizeViewState('library', { sort: 'oldest' }).sort, 'oldest');
|
||||
for (const sort of ['oldest', 'name-desc', 'most-used', 'last-used']) {
|
||||
assert.equal(normalizeViewState('library', { sort }).sort, sort);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -93,13 +93,6 @@ test('every Overview card opens its own native page, never a legacy one (#525)',
|
||||
}
|
||||
});
|
||||
|
||||
test('the legacy pages System links are the four that stay at their URLs (#525 D165)', async () => {
|
||||
const { LEGACY_PAGES } = await source;
|
||||
assert.deepEqual(LEGACY_PAGES.map(page => page.href), ['/prompt_manager/admin', '/prompt_manager/admin#/tags',
|
||||
'/prompt_manager/gallery', '/prompt_manager/gallery.html']);
|
||||
for (const page of LEGACY_PAGES) assert.ok(page.label);
|
||||
});
|
||||
|
||||
test('a job of the Activity page is its own deep link', async () => {
|
||||
const { resolveRoute, routeTitle, isHandoff } = await source;
|
||||
const id = '0123456789abcdef'.repeat(2);
|
||||
|
||||
@@ -732,6 +732,32 @@ class TestAutotagErrorBodiesHideServerPaths(AutotagAPITestCase):
|
||||
|
||||
class TestUnloadAutotag(AutotagAPITestCase):
|
||||
|
||||
async def test_unload_runs_off_the_loop_and_holds_the_gate_until_done(self):
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
self.addCleanup(release.set)
|
||||
threads = []
|
||||
|
||||
def unload():
|
||||
threads.append(threading.get_ident())
|
||||
started.set()
|
||||
release.wait(5)
|
||||
self.service._loaded_type = None
|
||||
|
||||
self.service._loaded_type = "gguf"
|
||||
self.service.unload_model = unload
|
||||
first = asyncio.ensure_future(
|
||||
self.client.request("POST", "/prompt_manager/autotag/unload")
|
||||
)
|
||||
self.assertTrue(await asyncio.to_thread(started.wait, 5))
|
||||
self.assertNotEqual(threads, [threading.get_ident()])
|
||||
second = await self.client.request("POST", "/prompt_manager/autotag/unload")
|
||||
self.assertEqual(second.status, 409)
|
||||
release.set()
|
||||
data = await (await first).json()
|
||||
self.assertEqual(data["message"], "GGUF model unloaded successfully")
|
||||
self.assertIsNone(model_gate.holder())
|
||||
|
||||
async def test_nothing_loaded(self):
|
||||
resp = await self.client.request("POST", "/prompt_manager/autotag/unload")
|
||||
data = await resp.json()
|
||||
|
||||
+1
-54
@@ -343,7 +343,7 @@ class TestReadJsonBody(AioHTTPTestCase):
|
||||
|
||||
|
||||
class TestLongJobLock(unittest.TestCase):
|
||||
"""_acquire_long_job: one job per family at a time, released on exit."""
|
||||
"""The refusal a busy long job answers with."""
|
||||
|
||||
def setUp(self):
|
||||
database = MagicMock()
|
||||
@@ -353,33 +353,6 @@ class TestLongJobLock(unittest.TestCase):
|
||||
def _run(self, coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
def test_second_acquire_is_refused_until_first_releases(self):
|
||||
async def scenario():
|
||||
first = self.api._acquire_long_job("duplicates")
|
||||
self.assertIsNotNone(first)
|
||||
async with first:
|
||||
self.assertIsNone(self.api._acquire_long_job("duplicates"))
|
||||
# Other families are independent
|
||||
other = self.api._acquire_long_job("thumbnails")
|
||||
self.assertIsNotNone(other)
|
||||
async with other:
|
||||
pass
|
||||
again = self.api._acquire_long_job("duplicates")
|
||||
self.assertIsNotNone(again)
|
||||
async with again:
|
||||
pass
|
||||
|
||||
self._run(scenario())
|
||||
|
||||
def test_lock_is_released_when_the_job_raises(self):
|
||||
async def scenario():
|
||||
with self.assertRaises(RuntimeError):
|
||||
async with self.api._acquire_long_job("duplicates"):
|
||||
raise RuntimeError("boom")
|
||||
self.assertIsNotNone(self.api._acquire_long_job("duplicates"))
|
||||
|
||||
self._run(scenario())
|
||||
|
||||
def test_busy_response_is_409_with_envelope(self):
|
||||
resp = self.api._long_job_busy_response("Duplicate scan")
|
||||
self.assertEqual(resp.status, 409)
|
||||
@@ -953,32 +926,6 @@ class TestMetadataParsing(unittest.TestCase):
|
||||
f.write(b"nope")
|
||||
self.assertEqual(self.api._extract_comfyui_metadata(garbage), {})
|
||||
|
||||
def test_parse_a1111_parameters(self):
|
||||
meta = {"parameters": "a cat\nNegative prompt: dog\nSteps: 20"}
|
||||
parsed = self.api._parse_comfyui_prompt(meta)
|
||||
self.assertEqual(parsed["positive_prompt"], "a cat")
|
||||
self.assertEqual(parsed["negative_prompt"], "dog")
|
||||
self.assertEqual(parsed["parameters"]["parameters"], meta["parameters"])
|
||||
|
||||
def test_parse_comfyui_fields(self):
|
||||
meta = {
|
||||
"prompt": json.dumps({"1": {"class_type": "X", "inputs": {}}}),
|
||||
"workflow": "not json",
|
||||
"steps": "20",
|
||||
"cfg": "not-json-either",
|
||||
}
|
||||
parsed = self.api._parse_comfyui_prompt(meta)
|
||||
self.assertIsInstance(parsed["prompt"], dict)
|
||||
self.assertEqual(parsed["workflow"], "not json")
|
||||
self.assertEqual(parsed["parameters"]["steps"], 20)
|
||||
self.assertEqual(parsed["parameters"]["cfg"], "not-json-either")
|
||||
|
||||
parsed = self.api._parse_comfyui_prompt(
|
||||
{"prompt": "plain text", "workflow": json.dumps({"nodes": []})}
|
||||
)
|
||||
self.assertEqual(parsed["prompt"], "plain text")
|
||||
self.assertEqual(parsed["workflow"], {"nodes": []})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -42,6 +42,37 @@ class TestLibraryAPI(ImageAPITestCase):
|
||||
self.assertEqual(len(media["images"]), 1)
|
||||
self.assertEqual(media["images"][0]["path"], "same.png")
|
||||
|
||||
async def test_used_and_reverse_name_sorts_page_through_every_prompt(self):
|
||||
ids = {text: self._save_prompt(text) for text in ("alpha", "beta", "gamma")}
|
||||
with self.api.db.model.get_connection() as conn:
|
||||
for text, runs, used in (
|
||||
("alpha", 1, "2026-01-01T00:00:00+00:00"),
|
||||
("beta", 9, "2026-02-01T00:00:00+00:00"),
|
||||
("gamma", 0, None),
|
||||
):
|
||||
conn.execute(
|
||||
"UPDATE prompts SET run_count=?, last_used_at=? WHERE id=?",
|
||||
(runs, used, ids[text]),
|
||||
)
|
||||
conn.commit()
|
||||
for sort, expected in (
|
||||
("most-used", ["beta", "alpha", "gamma"]),
|
||||
("last-used", ["beta", "alpha", "gamma"]),
|
||||
("name-desc", ["gamma", "beta", "alpha"]),
|
||||
):
|
||||
with self.subTest(sort=sort):
|
||||
seen, cursor = [], None
|
||||
for _ in expected:
|
||||
params = {"sort": sort, "limit": 1}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
response, page = await self.get(**params)
|
||||
self.assertEqual(response.status, 200)
|
||||
seen.extend(item["text"] for item in page["items"])
|
||||
cursor = page["next_cursor"]
|
||||
self.assertEqual(seen, expected)
|
||||
self.assertIsNone(cursor)
|
||||
|
||||
async def test_scoped_cursor_tampering_filters_and_continuation(self):
|
||||
for text in ("one", "two", "three"):
|
||||
self._save_prompt(text)
|
||||
|
||||
@@ -684,6 +684,28 @@ class TestTagMutationRoutes(RouteCoverageCase):
|
||||
|
||||
class TestSaveRoute(RouteCoverageCase):
|
||||
|
||||
async def test_a_malformed_body_is_refused_and_saves_nothing(self):
|
||||
for body in (
|
||||
[],
|
||||
"text",
|
||||
{"text": 123},
|
||||
{"text": "fixture", "category": 123},
|
||||
{"text": "fixture", "notes": 123},
|
||||
{"text": "fixture", "tags": 123},
|
||||
{"text": "fixture", "rating": "high"},
|
||||
):
|
||||
with self.subTest(body=body):
|
||||
status, data = await self._json(
|
||||
"POST", "/prompt_manager/save", json=body
|
||||
)
|
||||
self.assertEqual(status, 400)
|
||||
self.assertFalse(data["success"])
|
||||
status, data = await self._json(
|
||||
"POST", "/prompt_manager/save", data=b"{not json"
|
||||
)
|
||||
self.assertEqual(status, 400)
|
||||
self.assertEqual(self.api.db.search_prompts(text="fixture"), [])
|
||||
|
||||
async def test_invalid_rating_is_400(self):
|
||||
status, data = await self._json(
|
||||
"POST", "/prompt_manager/save", json={"text": "ok", "rating": 99}
|
||||
|
||||
@@ -9,7 +9,6 @@ import json
|
||||
import ntpath
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
@@ -378,25 +377,6 @@ class TestValidation(unittest.TestCase):
|
||||
self.assertTrue(text.endswith("."))
|
||||
|
||||
|
||||
class TestNoLegacySaveCallers(unittest.TestCase):
|
||||
def test_save_to_file_has_no_callers(self):
|
||||
offenders = []
|
||||
for folder in ("py", "utils", "database"):
|
||||
for path in (REPO / folder).rglob("*.py"):
|
||||
for number, line in enumerate(
|
||||
path.read_text(encoding="utf-8").splitlines(), 1
|
||||
):
|
||||
code = line.split("#", 1)[0]
|
||||
if (
|
||||
re.search(r"\bsave_to_file\(", code)
|
||||
and "def save_to_file" not in code
|
||||
):
|
||||
if "PromptManagerConfig.save_to_file('" in line:
|
||||
continue # its own docstring example
|
||||
offenders.append(f"{path.relative_to(REPO)}:{number}")
|
||||
self.assertEqual(offenders, [])
|
||||
|
||||
|
||||
try:
|
||||
from tests.test_api_images import ImageAPITestCase
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
|
||||
@@ -197,7 +197,6 @@ class JobCase(unittest.TestCase):
|
||||
node.logger = logging.getLogger("test.cached_runs")
|
||||
node.prompt_tracker = mock.Mock()
|
||||
self.trackers.append(node.prompt_tracker)
|
||||
node.comfyui_integration = mock.Mock()
|
||||
node._inject_lora_trigger_words = lambda text: text
|
||||
node.pending_first_use = self.pending
|
||||
node.pending_pair_first_use = self.pair_pending
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
"""Tests for utils.comfyui_integration."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils.comfyui_integration import ComfyUIMetadataIntegration
|
||||
|
||||
|
||||
class TestSaveImageNotPatched(unittest.TestCase):
|
||||
"""Regression for #75: SaveImage must save each node's own prompt inputs.
|
||||
|
||||
The old SaveImage patch wrote the last-run PromptManager prompt into every
|
||||
PromptManager node, so negative prompts were saved as copies of the positive.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
def save_images(
|
||||
self_node,
|
||||
images,
|
||||
filename_prefix="ComfyUI",
|
||||
prompt=None,
|
||||
extra_pnginfo=None,
|
||||
):
|
||||
return {"prompt": prompt}
|
||||
|
||||
self.original_save_images = save_images
|
||||
fake_nodes = types.ModuleType("nodes")
|
||||
fake_nodes.SaveImage = type("SaveImage", (), {"save_images": save_images})
|
||||
self._saved_nodes = sys.modules.get("nodes")
|
||||
sys.modules["nodes"] = fake_nodes
|
||||
self.fake_nodes = fake_nodes
|
||||
ComfyUIMetadataIntegration._instance = None
|
||||
|
||||
def tearDown(self):
|
||||
ComfyUIMetadataIntegration._instance = None
|
||||
if self._saved_nodes is None:
|
||||
sys.modules.pop("nodes", None)
|
||||
else:
|
||||
sys.modules["nodes"] = self._saved_nodes
|
||||
|
||||
def test_save_images_is_left_untouched(self):
|
||||
ComfyUIMetadataIntegration()
|
||||
self.assertIs(self.fake_nodes.SaveImage.save_images, self.original_save_images)
|
||||
|
||||
def test_registered_prompt_does_not_rewrite_saved_metadata(self):
|
||||
integration = ComfyUIMetadataIntegration()
|
||||
integration.register_prompt("pm_1", "positive final text", {})
|
||||
prompt = {
|
||||
"39": {"class_type": "PromptManager", "inputs": {"text": "a cat"}},
|
||||
"40": {"class_type": "PromptManager", "inputs": {"text": "blurry"}},
|
||||
}
|
||||
|
||||
result = self.fake_nodes.SaveImage().save_images([], prompt=prompt)
|
||||
|
||||
self.assertEqual(result["prompt"]["39"]["inputs"]["text"], "a cat")
|
||||
self.assertEqual(result["prompt"]["40"]["inputs"]["text"], "blurry")
|
||||
|
||||
def test_prompt_registry_still_available_to_nodes(self):
|
||||
integration = ComfyUIMetadataIntegration()
|
||||
integration.register_prompt("pm_1", "hello", {})
|
||||
self.assertEqual(integration.get_current_prompt_text("pm_1"), "hello")
|
||||
|
||||
|
||||
import threading # noqa: E402
|
||||
import unittest.mock as mock # noqa: E402
|
||||
|
||||
from utils import comfyui_integration # noqa: E402
|
||||
from utils.comfyui_integration import get_comfyui_integration # noqa: E402
|
||||
|
||||
|
||||
class IntegrationTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
ComfyUIMetadataIntegration._instance = None
|
||||
comfyui_integration._integration_instance = None
|
||||
# Patch the module's own ``time`` name rather than ``time.time`` globally:
|
||||
# background threads from other tests (image monitor, watchdog) would
|
||||
# otherwise tick this clock and make fresh registrations look stale.
|
||||
self.clock = mock.patch.object(
|
||||
comfyui_integration, "time", types.SimpleNamespace(time=self._now)
|
||||
)
|
||||
self.clock.start()
|
||||
self.addCleanup(self.clock.stop)
|
||||
self._ticks = 1000.0
|
||||
self.integration = get_comfyui_integration()
|
||||
|
||||
def tearDown(self):
|
||||
ComfyUIMetadataIntegration._instance = None
|
||||
comfyui_integration._integration_instance = None
|
||||
|
||||
def _now(self):
|
||||
self._ticks += 1.0
|
||||
return self._ticks
|
||||
|
||||
|
||||
class TestSingletonAndRegistry(IntegrationTestCase):
|
||||
def test_singleton_keeps_registrations_across_constructions(self):
|
||||
self.assertIs(get_comfyui_integration(), self.integration)
|
||||
self.assertIs(ComfyUIMetadataIntegration(), self.integration)
|
||||
self.integration.register_prompt("n1", "hello", {"category": "x"})
|
||||
ComfyUIMetadataIntegration() # __init__ runs again: must not reset state
|
||||
self.assertEqual(self.integration.get_current_prompt_text("n1"), "hello")
|
||||
|
||||
def test_latest_prompt_of_this_thread_wins_without_a_node_id(self):
|
||||
self.integration.register_prompt("n1", "first", {})
|
||||
self.integration.register_prompt("n2", "second", {})
|
||||
self.assertEqual(self.integration.get_current_prompt_text(), "second")
|
||||
self.assertEqual(self.integration.get_current_prompt_text("missing"), "second")
|
||||
self.assertEqual(self.integration.get_current_prompt_text("n1"), "first")
|
||||
|
||||
def test_nothing_registered_returns_none(self):
|
||||
self.assertIsNone(self.integration.get_current_prompt_text())
|
||||
self.assertIsNone(self.integration.get_current_prompt_text("n1"))
|
||||
|
||||
|
||||
class TestCrossThreadLookup(IntegrationTestCase):
|
||||
def _register_in_thread(self, text):
|
||||
worker = threading.Thread(
|
||||
target=self.integration.register_prompt, args=("w1", text, {})
|
||||
)
|
||||
worker.start()
|
||||
worker.join()
|
||||
|
||||
def test_recent_prompt_from_another_thread_is_used(self):
|
||||
self._register_in_thread("from worker")
|
||||
self.assertEqual(self.integration.get_current_prompt_text(), "from worker")
|
||||
|
||||
def test_stale_prompt_from_another_thread_is_ignored(self):
|
||||
self._register_in_thread("from worker")
|
||||
self._ticks += 400.0
|
||||
self.assertIsNone(self.integration.get_current_prompt_text())
|
||||
|
||||
def test_global_entry_of_this_thread_is_found_without_thread_local(self):
|
||||
self.integration.register_prompt("n1", "mine", {})
|
||||
self.integration._thread_local = threading.local()
|
||||
self.assertEqual(self.integration.get_current_prompt_text(), "mine")
|
||||
|
||||
def test_cleanup_removes_old_registrations_only(self):
|
||||
self._register_in_thread("old")
|
||||
self._ticks += 700.0
|
||||
self.integration.register_prompt("n2", "new", {})
|
||||
self.integration.cleanup_old_prompts(max_age_seconds=600)
|
||||
texts = sorted(v["text"] for v in self.integration._current_prompts.values())
|
||||
self.assertEqual(texts, ["new"])
|
||||
self.integration.cleanup_old_prompts(max_age_seconds=600) # nothing to do
|
||||
self.assertEqual(len(self.integration._current_prompts), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+4
-63
@@ -213,7 +213,8 @@ class TestPromptManagerConfig(unittest.TestCase):
|
||||
try:
|
||||
# Modify a value
|
||||
PromptManagerConfig.MAX_SEARCH_RESULTS = 42
|
||||
PromptManagerConfig.save_to_file(tmp.name)
|
||||
with open(tmp.name, "w", encoding="utf-8") as handle:
|
||||
json.dump(PromptManagerConfig.get_config(), handle)
|
||||
|
||||
# Reset and reload
|
||||
PromptManagerConfig.MAX_SEARCH_RESULTS = 100
|
||||
@@ -240,55 +241,6 @@ class TestPromptManagerConfig(unittest.TestCase):
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
def test_save_creates_directory(self):
|
||||
tmp_dir = tempfile.mkdtemp()
|
||||
nested_path = os.path.join(tmp_dir, "subdir", "config.json")
|
||||
try:
|
||||
PromptManagerConfig.save_to_file(nested_path)
|
||||
self.assertTrue(os.path.exists(nested_path))
|
||||
with open(nested_path) as f:
|
||||
data = json.load(f)
|
||||
self.assertIn("database", data)
|
||||
finally:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(tmp_dir)
|
||||
|
||||
def test_saved_file_is_private_to_the_user(self):
|
||||
import stat
|
||||
|
||||
tmp_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
path = os.path.join(tmp_dir, "config.json")
|
||||
PromptManagerConfig.save_to_file(path)
|
||||
mode = stat.S_IMODE(os.stat(path).st_mode)
|
||||
if os.name == "posix":
|
||||
self.assertEqual(mode, 0o600)
|
||||
with open(path) as f:
|
||||
self.assertIn("database", json.load(f))
|
||||
# Overwriting an existing, more open file tightens it as well
|
||||
os.chmod(path, 0o644)
|
||||
PromptManagerConfig.save_to_file(path)
|
||||
if os.name == "posix":
|
||||
self.assertEqual(stat.S_IMODE(os.stat(path).st_mode), 0o600)
|
||||
finally:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
def test_saved_file_is_valid_json(self):
|
||||
tmp = tempfile.NamedTemporaryFile(
|
||||
delete=False, suffix=".json", dir=tempfile.gettempdir()
|
||||
)
|
||||
tmp.close()
|
||||
try:
|
||||
PromptManagerConfig.save_to_file(tmp.name)
|
||||
with open(tmp.name) as f:
|
||||
data = json.load(f)
|
||||
self.assertIsInstance(data, dict)
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
|
||||
class TestIntegrationConfig(unittest.TestCase):
|
||||
"""Test IntegrationConfig for LoRA Manager settings."""
|
||||
@@ -404,18 +356,6 @@ class TestConfigPathOverride(unittest.TestCase):
|
||||
os.environ[self.ENV] = custom
|
||||
self.assertEqual(PromptManagerConfig.get_config_path(), custom)
|
||||
|
||||
def test_save_without_path_uses_env_override(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
custom = os.path.join(tmpdir, "nested", "custom.json")
|
||||
os.environ[self.ENV] = custom
|
||||
PromptManagerConfig.RESULT_TIMEOUT = 42
|
||||
|
||||
PromptManagerConfig.save_to_file()
|
||||
|
||||
with open(custom) as f:
|
||||
saved = json.load(f)
|
||||
self.assertEqual(saved["web_ui"]["result_timeout"], 42)
|
||||
|
||||
def test_load_without_path_uses_env_override(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
custom = os.path.join(tmpdir, "custom.json")
|
||||
@@ -503,7 +443,8 @@ class TestAutotagConfig(unittest.TestCase):
|
||||
PromptManagerConfig.update_config(
|
||||
{"autotag": {"defaults": {"suggestion_limit": 25}}}
|
||||
)
|
||||
PromptManagerConfig.save_to_file(path)
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
json.dump(PromptManagerConfig.get_config(), handle)
|
||||
self.config_class.SUGGESTION_LIMIT = 10
|
||||
PromptManagerConfig.load_from_file(path)
|
||||
self.assertEqual(self.config_class.suggestion_limit(), 25)
|
||||
|
||||
@@ -15,12 +15,11 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
@patch("prompt_manager_base.get_image_monitor")
|
||||
@patch("prompt_manager_base.get_prompt_tracker")
|
||||
@patch("prompt_manager_base.get_comfyui_integration")
|
||||
@patch("prompt_manager_base.PromptDatabase")
|
||||
class TestPromptManagerTextEmptyInput(unittest.TestCase):
|
||||
"""Test PromptManagerText with empty/blank text inputs (issue #123)."""
|
||||
|
||||
def _make_node(self, mock_db_class, mock_integration, mock_tracker, mock_monitor):
|
||||
def _make_node(self, mock_db_class, mock_tracker, mock_monitor):
|
||||
mock_db = Mock()
|
||||
mock_db_class.return_value = mock_db
|
||||
mock_db.save_prompt.return_value = 1
|
||||
@@ -30,73 +29,64 @@ class TestPromptManagerTextEmptyInput(unittest.TestCase):
|
||||
|
||||
return PromptManagerText()
|
||||
|
||||
def test_empty_string(self, mock_db, mock_integ, mock_tracker, mock_monitor):
|
||||
def test_empty_string(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""Empty string should not raise UnboundLocalError."""
|
||||
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
|
||||
node = self._make_node(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.process_text(text="")
|
||||
self.assertIsInstance(result, tuple)
|
||||
self.assertEqual(result[0], "")
|
||||
|
||||
def test_whitespace_only(self, mock_db, mock_integ, mock_tracker, mock_monitor):
|
||||
def test_whitespace_only(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""Whitespace-only string should not raise UnboundLocalError."""
|
||||
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
|
||||
node = self._make_node(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.process_text(text=" ")
|
||||
self.assertIsInstance(result, tuple)
|
||||
|
||||
def test_none_coerced_empty(self, mock_db, mock_integ, mock_tracker, mock_monitor):
|
||||
def test_none_coerced_empty(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""Falsy text value should not raise UnboundLocalError."""
|
||||
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
|
||||
node = self._make_node(mock_db, mock_tracker, mock_monitor)
|
||||
# ComfyUI may pass empty string for unconnected inputs
|
||||
result = node.process_text(text="", category="", tags="")
|
||||
self.assertIsInstance(result, tuple)
|
||||
|
||||
def test_empty_text_with_prepend_append(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
):
|
||||
def test_empty_text_with_prepend_append(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""Empty main text with prepend/append should produce single space, not
|
||||
double."""
|
||||
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
|
||||
node = self._make_node(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.process_text(text="", prepend_text="before", append_text="after")
|
||||
self.assertIsInstance(result, tuple)
|
||||
self.assertEqual(result[0], "before after")
|
||||
self.assertNotIn(" ", result[0])
|
||||
|
||||
def test_valid_text_still_works(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
):
|
||||
def test_valid_text_still_works(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""Normal text input should continue to work correctly."""
|
||||
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
|
||||
node = self._make_node(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.process_text(text="a beautiful sunset")
|
||||
self.assertIsInstance(result, tuple)
|
||||
self.assertEqual(result[0], "a beautiful sunset")
|
||||
|
||||
def test_prepend_text_append_all_present(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
):
|
||||
def test_prepend_text_append_all_present(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""All three parts should combine with single spaces."""
|
||||
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
|
||||
node = self._make_node(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.process_text(
|
||||
text="main prompt", prepend_text="before", append_text="after"
|
||||
)
|
||||
self.assertEqual(result[0], "before main prompt after")
|
||||
|
||||
def test_only_prepend(self, mock_db, mock_integ, mock_tracker, mock_monitor):
|
||||
def test_only_prepend(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""Prepend with empty text should output just prepend."""
|
||||
node = self._make_node(mock_db, mock_integ, mock_tracker, mock_monitor)
|
||||
node = self._make_node(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.process_text(text="", prepend_text="before")
|
||||
self.assertEqual(result[0], "before")
|
||||
|
||||
|
||||
@patch("prompt_manager_base.get_image_monitor")
|
||||
@patch("prompt_manager_base.get_prompt_tracker")
|
||||
@patch("prompt_manager_base.get_comfyui_integration")
|
||||
@patch("prompt_manager_base.PromptDatabase")
|
||||
class TestPromptManagerEmptyInput(unittest.TestCase):
|
||||
"""Test PromptManager (CLIP variant) with empty/blank text inputs (issue #123)."""
|
||||
|
||||
def _make_node_and_clip(
|
||||
self, mock_db_class, mock_integration, mock_tracker, mock_monitor
|
||||
):
|
||||
def _make_node_and_clip(self, mock_db_class, mock_tracker, mock_monitor):
|
||||
mock_db = Mock()
|
||||
mock_db_class.return_value = mock_db
|
||||
mock_db.save_prompt.return_value = 1
|
||||
@@ -110,30 +100,22 @@ class TestPromptManagerEmptyInput(unittest.TestCase):
|
||||
|
||||
return PromptManager(), mock_clip
|
||||
|
||||
def test_empty_string(self, mock_db, mock_integ, mock_tracker, mock_monitor):
|
||||
def test_empty_string(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""Empty string should not raise UnboundLocalError."""
|
||||
node, clip = self._make_node_and_clip(
|
||||
mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
)
|
||||
node, clip = self._make_node_and_clip(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.encode_prompt(clip=clip, text="")
|
||||
self.assertIsInstance(result, tuple)
|
||||
|
||||
def test_whitespace_only(self, mock_db, mock_integ, mock_tracker, mock_monitor):
|
||||
def test_whitespace_only(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""Whitespace-only string should not raise UnboundLocalError."""
|
||||
node, clip = self._make_node_and_clip(
|
||||
mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
)
|
||||
node, clip = self._make_node_and_clip(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.encode_prompt(clip=clip, text=" ")
|
||||
self.assertIsInstance(result, tuple)
|
||||
|
||||
def test_empty_text_with_prepend_append(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
):
|
||||
def test_empty_text_with_prepend_append(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""Empty main text with prepend/append should produce single space, not
|
||||
double."""
|
||||
node, clip = self._make_node_and_clip(
|
||||
mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
)
|
||||
node, clip = self._make_node_and_clip(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.encode_prompt(
|
||||
clip=clip, text="", prepend_text="before", append_text="after"
|
||||
)
|
||||
@@ -141,24 +123,16 @@ class TestPromptManagerEmptyInput(unittest.TestCase):
|
||||
self.assertEqual(result[1], "before after")
|
||||
self.assertNotIn(" ", result[1])
|
||||
|
||||
def test_valid_text_still_works(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
):
|
||||
def test_valid_text_still_works(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""Normal text input should continue to work correctly."""
|
||||
node, clip = self._make_node_and_clip(
|
||||
mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
)
|
||||
node, clip = self._make_node_and_clip(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.encode_prompt(clip=clip, text="a beautiful sunset")
|
||||
self.assertIsInstance(result, tuple)
|
||||
self.assertEqual(result[1], "a beautiful sunset")
|
||||
|
||||
def test_prepend_text_append_all_present(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
):
|
||||
def test_prepend_text_append_all_present(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""All three parts should combine with single spaces."""
|
||||
node, clip = self._make_node_and_clip(
|
||||
mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
)
|
||||
node, clip = self._make_node_and_clip(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.encode_prompt(
|
||||
clip=clip, text="main prompt", prepend_text="before", append_text="after"
|
||||
)
|
||||
@@ -167,7 +141,6 @@ class TestPromptManagerEmptyInput(unittest.TestCase):
|
||||
|
||||
@patch("prompt_manager_base.get_image_monitor")
|
||||
@patch("prompt_manager_base.get_prompt_tracker")
|
||||
@patch("prompt_manager_base.get_comfyui_integration")
|
||||
@patch("prompt_manager_base.PromptDatabase")
|
||||
class TestConnectedTextInput(unittest.TestCase):
|
||||
"""Test that externally connected text is preserved in output (issue #123).
|
||||
@@ -177,7 +150,7 @@ class TestConnectedTextInput(unittest.TestCase):
|
||||
value reaches Python; these tests verify Python handles it correctly.
|
||||
"""
|
||||
|
||||
def _make_text_node(self, mock_db_class, mock_integ, mock_tracker, mock_monitor):
|
||||
def _make_text_node(self, mock_db_class, mock_tracker, mock_monitor):
|
||||
mock_db = Mock()
|
||||
mock_db_class.return_value = mock_db
|
||||
mock_db.save_prompt.return_value = 1
|
||||
@@ -186,7 +159,7 @@ class TestConnectedTextInput(unittest.TestCase):
|
||||
|
||||
return PromptManagerText()
|
||||
|
||||
def _make_clip_node(self, mock_db_class, mock_integ, mock_tracker, mock_monitor):
|
||||
def _make_clip_node(self, mock_db_class, mock_tracker, mock_monitor):
|
||||
mock_db = Mock()
|
||||
mock_db_class.return_value = mock_db
|
||||
mock_db.save_prompt.return_value = 1
|
||||
@@ -199,10 +172,10 @@ class TestConnectedTextInput(unittest.TestCase):
|
||||
return PromptManager(), mock_clip
|
||||
|
||||
def test_text_node_connected_text_with_prepend_append(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
self, mock_db, mock_tracker, mock_monitor
|
||||
):
|
||||
"""Simulates screenshot: connected text + prepend + append must all appear."""
|
||||
node = self._make_text_node(mock_db, mock_integ, mock_tracker, mock_monitor)
|
||||
node = self._make_text_node(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.process_text(
|
||||
text="This is the prompt",
|
||||
prepend_text="This is prepend text",
|
||||
@@ -213,21 +186,17 @@ class TestConnectedTextInput(unittest.TestCase):
|
||||
"This is prepend text This is the prompt This is append text",
|
||||
)
|
||||
|
||||
def test_text_node_connected_text_only(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
):
|
||||
def test_text_node_connected_text_only(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""Connected text without prepend/append passes through unchanged."""
|
||||
node = self._make_text_node(mock_db, mock_integ, mock_tracker, mock_monitor)
|
||||
node = self._make_text_node(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.process_text(text="This is the prompt")
|
||||
self.assertEqual(result[0], "This is the prompt")
|
||||
|
||||
def test_clip_node_connected_text_with_prepend_append(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
self, mock_db, mock_tracker, mock_monitor
|
||||
):
|
||||
"""Same scenario for PromptManager (CLIP variant)."""
|
||||
node, clip = self._make_clip_node(
|
||||
mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
)
|
||||
node, clip = self._make_clip_node(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.encode_prompt(
|
||||
clip=clip,
|
||||
text="This is the prompt",
|
||||
@@ -239,13 +208,9 @@ class TestConnectedTextInput(unittest.TestCase):
|
||||
"This is prepend text This is the prompt This is append text",
|
||||
)
|
||||
|
||||
def test_clip_node_connected_text_only(
|
||||
self, mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
):
|
||||
def test_clip_node_connected_text_only(self, mock_db, mock_tracker, mock_monitor):
|
||||
"""Connected text without prepend/append passes through unchanged."""
|
||||
node, clip = self._make_clip_node(
|
||||
mock_db, mock_integ, mock_tracker, mock_monitor
|
||||
)
|
||||
node, clip = self._make_clip_node(mock_db, mock_tracker, mock_monitor)
|
||||
result = node.encode_prompt(clip=clip, text="This is the prompt")
|
||||
self.assertEqual(result[1], "This is the prompt")
|
||||
|
||||
|
||||
@@ -324,7 +324,6 @@ class CaptureTests(unittest.TestCase):
|
||||
node.db.save_prompt_pair.side_effect = RuntimeError("offline")
|
||||
node.logger = logging.getLogger("test.capture.nodes")
|
||||
node.prompt_tracker = Mock()
|
||||
node.comfyui_integration = Mock()
|
||||
node._inject_lora_trigger_words = lambda text: text + " TRIGGER"
|
||||
clip = Mock()
|
||||
clip.tokenize.side_effect = lambda text: text
|
||||
|
||||
@@ -24,7 +24,6 @@ class TestFocusedNode(unittest.TestCase):
|
||||
def test_focused_encodes_composition_and_tracks_main_text_and_graph(self):
|
||||
node = PromptManagerFocused.__new__(PromptManagerFocused)
|
||||
node.logger = logging.getLogger("test.focused")
|
||||
node.comfyui_integration = mock.Mock()
|
||||
node._inject_lora_trigger_words = lambda text: text
|
||||
node._track_prompt_execution = mock.Mock(return_value=42)
|
||||
clip = mock.Mock()
|
||||
|
||||
+1
-29
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Tests for hashing utilities.
|
||||
|
||||
Tests is_duplicate_prompt and generate_prompt_hash, which are
|
||||
Tests and generate_prompt_hash, which are
|
||||
currently untested. generate_prompt_hash is covered in test_basic.py
|
||||
but we add edge cases here.
|
||||
"""
|
||||
@@ -15,7 +15,6 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from tests.background_stops import stop_tracker # noqa: E402
|
||||
from utils.hashing import (
|
||||
generate_prompt_hash,
|
||||
is_duplicate_prompt,
|
||||
STRIPPED_CODE_POINTS,
|
||||
prompt_key,
|
||||
register_prompt_functions,
|
||||
@@ -69,33 +68,6 @@ class TestSingleHashImplementation(unittest.TestCase):
|
||||
self.assertFalse(os.path.exists(os.path.join(ROOT, "restart_gallery.py")))
|
||||
|
||||
|
||||
class TestIsDuplicatePrompt(unittest.TestCase):
|
||||
"""Test is_duplicate_prompt function."""
|
||||
|
||||
def test_identical_texts_are_duplicates(self):
|
||||
self.assertTrue(is_duplicate_prompt("hello", "hello"))
|
||||
|
||||
def test_case_insensitive(self):
|
||||
self.assertTrue(is_duplicate_prompt("Hello World", "hello world"))
|
||||
|
||||
def test_whitespace_insensitive(self):
|
||||
self.assertTrue(is_duplicate_prompt(" hello ", "hello"))
|
||||
|
||||
def test_different_texts_not_duplicates(self):
|
||||
self.assertFalse(is_duplicate_prompt("text A", "text B"))
|
||||
|
||||
def test_threshold_parameter_accepted(self):
|
||||
# threshold is unused but should not cause errors
|
||||
self.assertTrue(is_duplicate_prompt("same", "same", threshold=0.5))
|
||||
|
||||
def test_empty_strings_are_duplicates(self):
|
||||
self.assertTrue(is_duplicate_prompt("", ""))
|
||||
|
||||
def test_unicode_comparison(self):
|
||||
self.assertTrue(is_duplicate_prompt("日本語", "日本語"))
|
||||
self.assertFalse(is_duplicate_prompt("日本語", "中文"))
|
||||
|
||||
|
||||
class TestGeneratePromptHashEdgeCases(unittest.TestCase):
|
||||
"""Additional edge cases for generate_prompt_hash."""
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# Mock ComfyUI server before importing anything that touches config. Only the
|
||||
# first test module to run installs the stub; nothing else in sys.modules is
|
||||
# replaced, so later modules (test_comfyui_integration) see the real package.
|
||||
# replaced, so later modules see the real package.
|
||||
_mock_server = MagicMock()
|
||||
_mock_server.PromptServer.instance.routes = MagicMock()
|
||||
sys.modules.setdefault("server", _mock_server)
|
||||
@@ -881,17 +881,6 @@ class TestOutputListener(unittest.TestCase):
|
||||
self.assertTrue(monitor.stop_monitoring())
|
||||
|
||||
|
||||
class TestModuleIsolation(unittest.TestCase):
|
||||
"""This module must not leave mocks behind for later test modules."""
|
||||
|
||||
def test_comfyui_integration_is_not_replaced_by_a_mock(self):
|
||||
module = sys.modules.get("utils.comfyui_integration")
|
||||
self.assertFalse(
|
||||
isinstance(module, MagicMock),
|
||||
"utils.comfyui_integration was replaced by a MagicMock in sys.modules",
|
||||
)
|
||||
|
||||
|
||||
class TestMonitorThreadSafety(unittest.TestCase):
|
||||
"""Singleton creation must be thread-safe."""
|
||||
|
||||
|
||||
@@ -406,8 +406,6 @@ class TestLegacyRoutesHoldTheRegistry(_LegacyHoldCase):
|
||||
)
|
||||
await self._busy(resp)
|
||||
self.assertTrue(victim.exists())
|
||||
# The duplicate family lock was not taken by the refused request.
|
||||
self.assertIsNotNone(self.api._acquire_long_job(self.api.LONG_JOB_DUPLICATES))
|
||||
|
||||
async def test_lora_scan_refused_while_scan_runs(self):
|
||||
from py.config import IntegrationConfig
|
||||
|
||||
@@ -179,7 +179,6 @@ class NodeTestCase(unittest.TestCase):
|
||||
self.node.logger = logging.getLogger("test.node")
|
||||
self.node.prompt_tracker = mock.Mock()
|
||||
self.node.image_monitor = mock.Mock()
|
||||
self.node.comfyui_integration = mock.Mock()
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close_all() # Windows cannot unlink an open database file
|
||||
@@ -189,20 +188,16 @@ class NodeTestCase(unittest.TestCase):
|
||||
|
||||
|
||||
class TestBaseSaveAndSearch(NodeTestCase):
|
||||
def test_init_wires_database_tracker_monitor_and_integration(self):
|
||||
def test_init_wires_database_tracker_and_monitor(self):
|
||||
with (
|
||||
mock.patch.object(prompt_manager_base, "PromptDatabase") as db_cls,
|
||||
mock.patch.object(prompt_manager_base, "get_prompt_tracker") as tracker,
|
||||
mock.patch.object(prompt_manager_base, "get_image_monitor") as monitor,
|
||||
mock.patch.object(
|
||||
prompt_manager_base, "get_comfyui_integration"
|
||||
) as integration,
|
||||
):
|
||||
node = PromptManagerBase()
|
||||
self.assertIs(node.db, db_cls.return_value)
|
||||
tracker.assert_called_once_with(db_cls.return_value)
|
||||
monitor.assert_called_once_with(db_cls.return_value, tracker.return_value)
|
||||
self.assertIs(node.comfyui_integration, integration.return_value)
|
||||
monitor.return_value.start_monitoring.assert_called_once_with()
|
||||
|
||||
def test_saving_an_existing_prompt_updates_its_metadata(self):
|
||||
@@ -242,31 +237,18 @@ class TestBaseSaveAndSearch(NodeTestCase):
|
||||
self.node._save_prompt_to_database("a cat on a mat")
|
||||
self.assertEqual(self.node._search_prompts(""), [])
|
||||
self.assertEqual(self.node._search_prompts(" "), [])
|
||||
self.assertEqual(len(self.node.search_prompts_api("cat")), 1)
|
||||
self.assertEqual(len(self.node.get_recent_prompts_api(limit=5)["prompts"]), 1)
|
||||
self.assertEqual(len(self.node._search_prompts("cat")), 1)
|
||||
|
||||
def test_search_and_recent_errors_return_empty_lists(self):
|
||||
self.node.db = mock.Mock()
|
||||
self.node.db.search_prompts.side_effect = RuntimeError("locked")
|
||||
self.node.db.get_recent_prompts.side_effect = RuntimeError("locked")
|
||||
with self.assertLogs("test.node", level="ERROR"):
|
||||
self.assertEqual(self.node._search_prompts("cat"), [])
|
||||
with self.assertLogs("test.node", level="ERROR"):
|
||||
self.assertEqual(self.node.get_recent_prompts_api(), [])
|
||||
|
||||
def test_gallery_system_start_failure_is_logged(self):
|
||||
self.node.image_monitor.start_monitoring.side_effect = OSError("no inotify")
|
||||
with self.assertLogs("test.node", level="ERROR"):
|
||||
self.node._start_gallery_system()
|
||||
self.node.image_monitor.get_status.return_value = {"running": False}
|
||||
self.node.prompt_tracker.get_status.return_value = {"active_prompts_count": 0}
|
||||
self.assertEqual(
|
||||
self.node.get_gallery_status(),
|
||||
{
|
||||
"image_monitor": {"running": False},
|
||||
"prompt_tracker": {"active_prompts_count": 0},
|
||||
},
|
||||
)
|
||||
self.node.cleanup_gallery_system()
|
||||
self.node.image_monitor.stop_monitoring.assert_not_called()
|
||||
|
||||
@@ -376,7 +358,6 @@ class TestPromptManagerEncoding(NodeTestCase):
|
||||
self.assertEqual(saved["text"], "a cat")
|
||||
self.assertIn("prepend:best", saved["tags"])
|
||||
self.assertIn("append:8k", saved["tags"])
|
||||
self.node.comfyui_integration.register_prompt.assert_called_once()
|
||||
|
||||
def test_tracking_failure_does_not_break_encoding(self):
|
||||
self.node._track_prompt_execution = mock.Mock(side_effect=RuntimeError("db"))
|
||||
|
||||
@@ -19,7 +19,6 @@ def bare(node_cls):
|
||||
"""A node without its database, tracker or gallery side effects."""
|
||||
node = node_cls.__new__(node_cls)
|
||||
node.logger = __import__("logging").getLogger("test.wiring")
|
||||
node.comfyui_integration = mock.Mock()
|
||||
node._inject_lora_trigger_words = lambda text: text
|
||||
node._track_prompt_execution = mock.Mock(return_value=42)
|
||||
return node
|
||||
|
||||
@@ -22,8 +22,6 @@ DEVELOPMENT = (
|
||||
"images/x.png",
|
||||
"TESTING.md",
|
||||
"requirements-dev.txt",
|
||||
"image_meta_data.png",
|
||||
"test_metadata_file.png",
|
||||
)
|
||||
KEPT = (
|
||||
"__init__.py",
|
||||
|
||||
@@ -47,7 +47,6 @@ class TestPairExecution(unittest.TestCase):
|
||||
self.node.db = self.db
|
||||
self.node.logger = logging.getLogger("test.execution")
|
||||
self.node.prompt_tracker = Mock()
|
||||
self.node.comfyui_integration = Mock()
|
||||
self.node._inject_lora_trigger_words = lambda text: text + " LORA"
|
||||
self.extra = {"workflow": {"unchanged": True}, "sibling": {"keep": True}}
|
||||
|
||||
|
||||
@@ -64,7 +64,6 @@ class TestPairUsageTracking(unittest.TestCase):
|
||||
node.pending_pair_first_use = self.pair_pending
|
||||
node._inject_lora_trigger_words = lambda text: text
|
||||
node.prompt_tracker = Mock()
|
||||
node.comfyui_integration = Mock()
|
||||
return node
|
||||
|
||||
def execute(self, graph, text="cat", negative="blur"):
|
||||
|
||||
@@ -162,7 +162,6 @@ class NodeCase(DatabaseCase):
|
||||
node.db = self.db
|
||||
node.logger = logging.getLogger("test.prompt_attribution.node")
|
||||
node.prompt_tracker = self.tracker
|
||||
node.comfyui_integration = mock.Mock()
|
||||
node._inject_lora_trigger_words = lambda text: text
|
||||
node.pending_first_use = PendingFirstUse()
|
||||
node.pending_pair_first_use = PendingFirstUse()
|
||||
|
||||
@@ -45,7 +45,6 @@ class TestPromptPairNode(unittest.TestCase):
|
||||
self.node.db = self.db
|
||||
self.node.logger = logging.getLogger("test.pair_node")
|
||||
self.node.prompt_tracker = Mock()
|
||||
self.node.comfyui_integration = Mock()
|
||||
self.node._inject_lora_trigger_words = Mock(
|
||||
side_effect=lambda text: text + " LORA"
|
||||
)
|
||||
@@ -241,12 +240,9 @@ class TestPromptPairNode(unittest.TestCase):
|
||||
)
|
||||
self.node.prompt_tracker.set_current_prompt.assert_not_called()
|
||||
|
||||
def test_invalid_text_does_not_write_and_integration_failures_do_not_block(self):
|
||||
def test_invalid_text_does_not_write(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.node.encode_prompt(self.clip, [], negative_text="blur")
|
||||
self.node.comfyui_integration.register_prompt.side_effect = RuntimeError(
|
||||
"metadata unavailable"
|
||||
)
|
||||
result = self.node.encode_prompt(self.clip, "cat", negative_text="blur")
|
||||
self.assertEqual(result[2:], ("cat LORA", "blur"))
|
||||
|
||||
|
||||
@@ -294,7 +294,6 @@ class TestNodeRecordsCachedRunReceipt(UsageTestCase):
|
||||
node.db = self.db
|
||||
node.logger = __import__("logging").getLogger("test.prompt_usage")
|
||||
node.prompt_tracker = mock.Mock()
|
||||
node.comfyui_integration = mock.Mock()
|
||||
node._inject_lora_trigger_words = lambda text: text
|
||||
node.cached_runs = mock.Mock()
|
||||
node.cached_runs.replayed.return_value = False
|
||||
|
||||
@@ -243,7 +243,6 @@ class TestNodesSaveNothing(NodeCase):
|
||||
self.assertEqual(self.count_rows("prompt_pairs"), 0)
|
||||
self.assertEqual(self.tracker.keyed_snapshot(), {})
|
||||
self.assertEqual(self.tracker._prompt_queue, [])
|
||||
node.comfyui_integration.register_prompt.assert_not_called()
|
||||
if cls is PromptManagerText:
|
||||
self.assertEqual(result, (POSITIVE,))
|
||||
elif cls is PromptManagerPair:
|
||||
|
||||
@@ -204,7 +204,7 @@ class TestSavedViewsApi(ImageAPITestCase):
|
||||
{
|
||||
"page": "library",
|
||||
"name": "L",
|
||||
"state": {**LIBRARY_STATE, "sort": "name-desc"},
|
||||
"state": {**LIBRARY_STATE, "sort": "shuffle"},
|
||||
},
|
||||
]
|
||||
for body in cases:
|
||||
@@ -562,7 +562,7 @@ class TestSavedViewsSchema(unittest.TestCase):
|
||||
conn.execute(
|
||||
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
|
||||
"updated_at) VALUES ('library', ?, ?, 6, 'x', 'x')",
|
||||
(" " + "n" * 120, json.dumps({"sort": "name-desc", "q": 4})),
|
||||
(" " + "n" * 120, json.dumps({"sort": "shuffle", "q": 4})),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ class TestSplitNode(unittest.TestCase):
|
||||
def test_split_encodes_composition_and_persists_main_text_with_metadata(self):
|
||||
node = PromptManagerSplit.__new__(PromptManagerSplit)
|
||||
node.logger = logging.getLogger("test.split")
|
||||
node.comfyui_integration = unittest.mock.Mock()
|
||||
node._inject_lora_trigger_words = lambda text: text
|
||||
node._track_prompt_execution = unittest.mock.Mock(return_value=42)
|
||||
clip = unittest.mock.Mock()
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Every read-only SQLite open uses one URI helper (#620).
|
||||
|
||||
pathlib writes a Windows network path as ``file://server/share/x.db``, which
|
||||
SQLite refuses. A reader that builds its own URI breaks backups, restores,
|
||||
library moves or history reads for a library on a network share.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from database.sqlite_uri import readonly_uri # noqa: E402
|
||||
|
||||
|
||||
class TestReadonlyUri(unittest.TestCase):
|
||||
def test_a_network_path_has_an_empty_authority(self):
|
||||
self.assertEqual(
|
||||
readonly_uri(r"\\server\share\Comfy UI\prompts.db", windows=True),
|
||||
"file:////server/share/Comfy%20UI/prompts.db?mode=ro",
|
||||
)
|
||||
|
||||
def test_no_reader_builds_its_own_uri(self):
|
||||
offenders = []
|
||||
for folder in ("database", "py", "utils"):
|
||||
for path in (ROOT / folder).rglob("*.py"):
|
||||
if path.name == "sqlite_uri.py":
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if re.search(r"as_uri\(\)\s*\+\s*[\"']\?mode=ro", text):
|
||||
offenders.append(path.relative_to(ROOT).as_posix())
|
||||
self.assertEqual(offenders, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -78,7 +78,7 @@ class TestDefaultsAndConfig(ConfigCase):
|
||||
self.assertEqual(ts.current().format, "webp")
|
||||
|
||||
def test_the_never_read_gallery_thumbnail_size_is_gone(self):
|
||||
# R1: older save_to_file wrote its default 256 into config.json; it
|
||||
# R1: older versions wrote its default 256 into config.json; it
|
||||
# must never size a preview.
|
||||
self.assertFalse(hasattr(GalleryConfig, "THUMBNAIL_SIZE"))
|
||||
PromptManagerConfig.update_config(
|
||||
|
||||
@@ -17,7 +17,6 @@ from utils.validators import (
|
||||
validate_tags,
|
||||
validate_category,
|
||||
sanitize_input,
|
||||
parse_tags_string,
|
||||
validate_result_timeout,
|
||||
validate_worker_threads,
|
||||
)
|
||||
@@ -125,48 +124,6 @@ class TestSanitizeInput(unittest.TestCase):
|
||||
self.assertEqual(result, "a\nb\nc\nd")
|
||||
|
||||
|
||||
class TestParseTagsString(unittest.TestCase):
|
||||
"""Test parse_tags_string function."""
|
||||
|
||||
def test_simple_comma_separated(self):
|
||||
result = parse_tags_string("tag1, tag2, tag3")
|
||||
self.assertEqual(result, ["tag1", "tag2", "tag3"])
|
||||
|
||||
def test_extra_whitespace(self):
|
||||
result = parse_tags_string(" tag1 , tag2 ")
|
||||
self.assertEqual(result, ["tag1", "tag2"])
|
||||
|
||||
def test_empty_string_returns_empty_list(self):
|
||||
self.assertEqual(parse_tags_string(""), [])
|
||||
|
||||
def test_none_returns_empty_list(self):
|
||||
self.assertEqual(parse_tags_string(None), [])
|
||||
|
||||
def test_non_string_returns_empty_list(self):
|
||||
self.assertEqual(parse_tags_string(123), [])
|
||||
|
||||
def test_single_tag(self):
|
||||
self.assertEqual(parse_tags_string("solo"), ["solo"])
|
||||
|
||||
def test_deduplication(self):
|
||||
result = parse_tags_string("tag1, tag1, tag2, tag1")
|
||||
self.assertEqual(result, ["tag1", "tag2"])
|
||||
|
||||
def test_empty_segments_skipped(self):
|
||||
result = parse_tags_string("tag1,,tag2,,,tag3")
|
||||
self.assertEqual(result, ["tag1", "tag2", "tag3"])
|
||||
|
||||
def test_max_20_tags(self):
|
||||
tags = ", ".join(f"tag{i}" for i in range(25))
|
||||
result = parse_tags_string(tags)
|
||||
self.assertEqual(len(result), 20)
|
||||
|
||||
def test_strips_null_bytes_from_tags(self):
|
||||
result = parse_tags_string("clean\x00tag, normal")
|
||||
self.assertIn("cleantag", result)
|
||||
self.assertIn("normal", result)
|
||||
|
||||
|
||||
class TestValidatePromptTextEdgeCases(unittest.TestCase):
|
||||
"""Additional edge cases beyond test_basic.py coverage."""
|
||||
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
"""ComfyUI integration utilities for PromptManager.
|
||||
|
||||
This module tracks the prompts PromptManager nodes encode during execution so
|
||||
other components (e.g. image/prompt linking) can look them up.
|
||||
|
||||
ComfyUI already writes every node's own inputs into saved image metadata, so
|
||||
SaveImage is deliberately NOT patched. An earlier patch wrote the last-run prompt
|
||||
into every PromptManager node, saving negative prompts as copies of the
|
||||
positive (#75).
|
||||
|
||||
Key features:
|
||||
- Thread-safe prompt registration and retrieval
|
||||
- Cross-thread prompt context sharing
|
||||
- Standard metadata format compatibility
|
||||
- Automatic cleanup of old prompt registrations
|
||||
|
||||
The integration works by:
|
||||
1. PromptManager nodes register their prompts during execution
|
||||
2. Callers retrieve the current prompt text by node id or recency
|
||||
|
||||
Typical usage:
|
||||
from utils.comfyui_integration import get_comfyui_integration
|
||||
|
||||
integration = get_comfyui_integration()
|
||||
integration.register_prompt(node_id, prompt_text, metadata)
|
||||
text = integration.get_current_prompt_text(node_id)
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
try:
|
||||
from .logging_config import get_logger
|
||||
except ImportError:
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
|
||||
class ComfyUIMetadataIntegration:
|
||||
"""Integrates PromptManager with ComfyUI's standard metadata system.
|
||||
|
||||
This singleton class manages the integration between PromptManager custom nodes
|
||||
and ComfyUI's standard metadata system. It ensures that prompts generated by
|
||||
PromptManager appear in the standard ComfyUI metadata format for compatibility
|
||||
with third-party tools and parsers.
|
||||
|
||||
The integration uses thread-local storage combined with global tracking to
|
||||
handle prompt context across different execution threads, which is necessary
|
||||
because ComfyUI executions may span multiple threads.
|
||||
|
||||
Key responsibilities:
|
||||
- Register prompts from PromptManager nodes during execution
|
||||
- Manage prompt lifecycle and cleanup
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __new__(cls):
|
||||
"""Ensure singleton pattern with thread safety.
|
||||
|
||||
Returns:
|
||||
The single instance of ComfyUIMetadataIntegration
|
||||
"""
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the ComfyUI integration system.
|
||||
|
||||
Sets up prompt tracking data structures. Uses _initialized flag to
|
||||
prevent duplicate initialization.
|
||||
"""
|
||||
if hasattr(self, "_initialized"):
|
||||
return
|
||||
|
||||
self.logger = get_logger("prompt_manager.comfyui_integration")
|
||||
self._current_prompts = {}
|
||||
self._thread_local = threading.local()
|
||||
self._initialized = True
|
||||
|
||||
def register_prompt(self, node_id: str, prompt_text: str, metadata: Dict[str, Any]):
|
||||
"""
|
||||
Register a prompt from PromptManager for inclusion in ComfyUI metadata.
|
||||
|
||||
This method is called by PromptManager nodes during execution to register
|
||||
their prompt text for later inclusion in image metadata. The prompt is stored
|
||||
in both thread-local and global storage to handle cross-thread access scenarios.
|
||||
|
||||
Args:
|
||||
node_id: Unique identifier for this prompt (typically the node ID)
|
||||
prompt_text: The actual prompt text that was encoded by PromptManager
|
||||
metadata: Additional metadata from PromptManager (category, tags, etc.)
|
||||
"""
|
||||
thread_id = threading.current_thread().ident
|
||||
|
||||
# Store in thread-local storage
|
||||
if not hasattr(self._thread_local, "prompts"):
|
||||
self._thread_local.prompts = {}
|
||||
|
||||
self._thread_local.prompts[node_id] = {
|
||||
"text": prompt_text,
|
||||
"metadata": metadata,
|
||||
"timestamp": time.time(),
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
|
||||
# Also store globally for cross-thread access
|
||||
with self._lock:
|
||||
self._current_prompts[f"{thread_id}_{node_id}"] = {
|
||||
"text": prompt_text,
|
||||
"metadata": metadata,
|
||||
"timestamp": time.time(),
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
|
||||
self.logger.debug(
|
||||
f"Registered prompt for node {node_id}: {prompt_text[:50]}..."
|
||||
)
|
||||
|
||||
def get_current_prompt_text(self, node_id: str = None) -> Optional[str]:
|
||||
"""
|
||||
Get the current prompt text for metadata inclusion.
|
||||
|
||||
Retrieves the most appropriate prompt text for inclusion in image metadata.
|
||||
Uses a fallback strategy: thread-local storage first, then global storage,
|
||||
with recency checks to avoid stale prompts.
|
||||
|
||||
Args:
|
||||
node_id: Optional specific node ID to retrieve prompt for
|
||||
|
||||
Returns:
|
||||
The prompt text string if available, None if no suitable prompt found
|
||||
"""
|
||||
# First try thread-local storage
|
||||
if hasattr(self._thread_local, "prompts"):
|
||||
if node_id and node_id in self._thread_local.prompts:
|
||||
return self._thread_local.prompts[node_id]["text"]
|
||||
elif self._thread_local.prompts:
|
||||
# Return the most recent prompt from this thread
|
||||
latest = max(
|
||||
self._thread_local.prompts.values(), key=lambda x: x["timestamp"]
|
||||
)
|
||||
return latest["text"]
|
||||
|
||||
# Fallback to global storage
|
||||
thread_id = threading.current_thread().ident
|
||||
with self._lock:
|
||||
# Look for prompts from current thread
|
||||
thread_prompts = {
|
||||
k: v
|
||||
for k, v in self._current_prompts.items()
|
||||
if v["thread_id"] == thread_id
|
||||
}
|
||||
|
||||
if thread_prompts:
|
||||
latest = max(thread_prompts.values(), key=lambda x: x["timestamp"])
|
||||
return latest["text"]
|
||||
|
||||
# Last resort: return the most recent prompt from any thread
|
||||
if self._current_prompts:
|
||||
latest = max(
|
||||
self._current_prompts.values(), key=lambda x: x["timestamp"]
|
||||
)
|
||||
# Only return if it's recent (within last 5 minutes)
|
||||
if time.time() - latest["timestamp"] < 300:
|
||||
return latest["text"]
|
||||
|
||||
return None
|
||||
|
||||
def cleanup_old_prompts(self, max_age_seconds: int = 600):
|
||||
"""
|
||||
Clean up old prompt registrations.
|
||||
|
||||
Removes prompt registrations that are older than the specified age to
|
||||
prevent memory leaks and ensure that only recent, relevant prompts are
|
||||
used for metadata inclusion.
|
||||
|
||||
Args:
|
||||
max_age_seconds: Maximum age in seconds before a prompt registration
|
||||
is considered stale and removed (default: 600
|
||||
seconds/10 minutes)
|
||||
"""
|
||||
current_time = time.time()
|
||||
|
||||
with self._lock:
|
||||
old_keys = [
|
||||
key
|
||||
for key, prompt_data in self._current_prompts.items()
|
||||
if current_time - prompt_data["timestamp"] > max_age_seconds
|
||||
]
|
||||
|
||||
for key in old_keys:
|
||||
del self._current_prompts[key]
|
||||
|
||||
if old_keys:
|
||||
self.logger.debug(f"Cleaned up {len(old_keys)} old prompt registrations")
|
||||
|
||||
|
||||
# Global instance
|
||||
_integration_instance = None
|
||||
|
||||
|
||||
def get_comfyui_integration() -> ComfyUIMetadataIntegration:
|
||||
"""Get the global ComfyUI integration instance.
|
||||
|
||||
Returns:
|
||||
The singleton ComfyUIMetadataIntegration instance, creating it if necessary
|
||||
"""
|
||||
global _integration_instance
|
||||
if _integration_instance is None:
|
||||
_integration_instance = ComfyUIMetadataIntegration()
|
||||
return _integration_instance
|
||||
+1
-33
@@ -8,19 +8,15 @@ regardless of minor formatting differences.
|
||||
Key features:
|
||||
- SHA256-based prompt text hashing with normalization
|
||||
- Content hashing for complex prompt metadata structures
|
||||
- Duplicate detection utilities
|
||||
- Consistent normalization (lowercase, trimmed whitespace)
|
||||
|
||||
Typical usage:
|
||||
from utils.hashing import generate_prompt_hash, is_duplicate_prompt
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
hash1 = generate_prompt_hash("Beautiful landscape")
|
||||
hash2 = generate_prompt_hash(" beautiful landscape ")
|
||||
# hash1 == hash2 (normalization makes them identical)
|
||||
|
||||
if is_duplicate_prompt(text1, text2):
|
||||
print("Duplicate prompts detected")
|
||||
|
||||
The hashing is designed for:
|
||||
- Database deduplication (preventing duplicate prompt storage)
|
||||
- Quick similarity checking
|
||||
@@ -124,31 +120,3 @@ def stored_prompt_text_sql(expression: str) -> str:
|
||||
character of Y, multi-byte ones included, so it strips what Python does.
|
||||
"""
|
||||
return f"trim({expression}, char({_STRIP_CODES}))"
|
||||
|
||||
|
||||
def is_duplicate_prompt(text1: str, text2: str, threshold: float = 0.95) -> bool:
|
||||
"""
|
||||
Check if two prompts are likely duplicates using hash comparison.
|
||||
|
||||
Compares the normalized hashes of two prompt texts to determine if
|
||||
they are functionally identical. This is an exact match after normalization,
|
||||
not a similarity measure.
|
||||
|
||||
Args:
|
||||
text1: First prompt text to compare
|
||||
text2: Second prompt text to compare
|
||||
threshold: Similarity threshold (not used - kept for API compatibility)
|
||||
|
||||
Returns:
|
||||
True if the prompts have identical normalized hashes (i.e., are duplicates),
|
||||
False otherwise
|
||||
|
||||
Note:
|
||||
The threshold parameter is not used in the current implementation as
|
||||
this performs exact hash matching rather than similarity scoring.
|
||||
It's maintained for potential future fuzzy matching capabilities.
|
||||
"""
|
||||
hash1 = generate_prompt_hash(text1)
|
||||
hash2 = generate_prompt_hash(text2)
|
||||
|
||||
return hash1 == hash2
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Restore publication checks for snapshot-owned history reads."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import threading
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -10,11 +9,13 @@ if TYPE_CHECKING:
|
||||
from ..database.operations import PromptDatabase
|
||||
|
||||
try:
|
||||
from ..database.sqlite_uri import readonly_uri
|
||||
from ..database.execution_history import (
|
||||
HistoryEpochChanged,
|
||||
subscribe_history_restore,
|
||||
)
|
||||
except ImportError:
|
||||
from database.sqlite_uri import readonly_uri
|
||||
from database.execution_history import (
|
||||
HistoryEpochChanged,
|
||||
subscribe_history_restore,
|
||||
@@ -53,9 +54,7 @@ class HistoryReadFence:
|
||||
self._check(generation, epoch)
|
||||
# An API restore retires its previous model. Never reopen that owner,
|
||||
# and never reuse the projection transaction's view of the epoch.
|
||||
conn = sqlite3.connect(
|
||||
Path(self._path).as_uri() + "?mode=ro", uri=True, timeout=0.25
|
||||
)
|
||||
conn = sqlite3.connect(readonly_uri(self._path), uri=True, timeout=0.25)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT storage_epoch FROM execution_history_metadata "
|
||||
|
||||
@@ -295,30 +295,3 @@ def sanitize_input(text: str) -> str:
|
||||
result_lines.append(line)
|
||||
|
||||
return "\n".join(result_lines).strip()
|
||||
|
||||
|
||||
def parse_tags_string(tags_string: str) -> List[str]:
|
||||
"""
|
||||
Parse a comma-separated tags string into a clean list.
|
||||
|
||||
Converts comma-separated tag strings into a clean, deduplicated list
|
||||
of tags. Each tag is sanitized and trimmed.
|
||||
|
||||
Args:
|
||||
tags_string: Comma-separated tags string (e.g., "tag1, tag2, tag3")
|
||||
|
||||
Returns:
|
||||
List of unique, cleaned tag strings. Empty input returns empty list.
|
||||
Limited to maximum 20 tags to prevent abuse.
|
||||
"""
|
||||
if not tags_string or not isinstance(tags_string, str):
|
||||
return []
|
||||
|
||||
# Split by comma and clean each tag
|
||||
tags = []
|
||||
for tag in tags_string.split(","):
|
||||
clean_tag = sanitize_input(tag).strip()
|
||||
if clean_tag and clean_tag not in tags: # Avoid duplicates
|
||||
tags.append(clean_tag)
|
||||
|
||||
return tags[:20] # Limit to 20 tags
|
||||
|
||||
@@ -11,7 +11,7 @@ function parameters(filters,limit) {
|
||||
if(filters.tags!==undefined && (!Array.isArray(filters.tags) || filters.tags.some(tag=>typeof tag!=='string'))) throw new TypeError('Invalid library tags');
|
||||
for(const tag of filters.tags || []) if(tag.trim()) query.append('tag',tag.trim());
|
||||
const sort=filters.sort || 'newest';
|
||||
if(!['newest','oldest','name'].includes(sort)) throw new TypeError('Invalid library sort');
|
||||
if(!['newest','oldest','name','name-desc','most-used','last-used'].includes(sort)) throw new TypeError('Invalid library sort');
|
||||
// #444: how Tags match (every tag, any of them, or parents no tag's Browse returns, which never goes with tags) and
|
||||
// the output folder below a root.
|
||||
const tagMode=filters.tagMode ?? 'all',folder=filters.folder ?? '';
|
||||
|
||||
@@ -36,7 +36,7 @@ const selectedPairs=new Map();
|
||||
const metadataDrafts=new Map();
|
||||
const archivedVisible=new Map();
|
||||
// The concept's sorts first, then the Library's appended Oldest first (D14); D153 (#447) drops "Highest rated".
|
||||
const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['oldest','Oldest first']]);
|
||||
const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['oldest','Oldest first'],['last-used','Recently used'],['most-used','Most used'],['name-desc','Name Z–A']]);
|
||||
// Refine › Tag match (#444 D149, D150), in LIBRARY_TAG_MODES order.
|
||||
const TAG_MATCH_OPTIONS=Object.freeze([['all','All of these tags'],['any','Any of these tags'],['untagged','Prompts without tags']]);
|
||||
// A card's focusable controls in order: the D148 selection checkbox, then its buttons (#437).
|
||||
|
||||
@@ -46,7 +46,7 @@ const FIELDS = Object.freeze({
|
||||
collection: { fallback: '', accepts: value => typeof value === 'string' && /^(|[1-9][0-9]{0,8})$/.test(value) },
|
||||
}),
|
||||
library: Object.freeze({
|
||||
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name'], 'newest'), tags,
|
||||
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'name-desc', 'most-used', 'last-used'], 'newest'), tags,
|
||||
// #444: how the Tags match ('untagged' never goes with tags; the listing refuses both) and the output folder
|
||||
// ('/'-joined, below a root).
|
||||
tag_mode: choice(['all', 'any', 'untagged'], 'all'), folder: text(1024),
|
||||
|
||||
@@ -25,14 +25,6 @@ export const routes = [
|
||||
description: 'Configuration, maintenance and recovery—with browsing kept in the workspace.', action: 'Open System' },
|
||||
];
|
||||
|
||||
/** D165 (#525): the legacy pages, which stay at their URLs; System's Overview section links them discreetly. */
|
||||
export const LEGACY_PAGES = Object.freeze([
|
||||
{ label: 'Dashboard', href: '/prompt_manager/admin' },
|
||||
{ label: 'Tags', href: '/prompt_manager/admin#/tags' },
|
||||
{ label: 'Gallery', href: '/prompt_manager/gallery' },
|
||||
{ label: 'Metadata viewer', href: '/prompt_manager/gallery.html' },
|
||||
].map(page => Object.freeze(page)));
|
||||
|
||||
const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'inspect', 'overview', 'saved', 'favorites', 'activity', 'review', 'system',
|
||||
'collection']);
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* listeners and timers never outlive it. The drafts are module-level (Q10): they live for the page session.
|
||||
*/
|
||||
import { createButton, createScope } from '../shared/toolkit/core.js';
|
||||
import { LEGACY_PAGES, SYSTEM_SECTIONS, navigateTo } from './state.mjs';
|
||||
import { SYSTEM_SECTIONS, navigateTo } from './state.mjs';
|
||||
import { createSystemDrafts, DRAFT_DOMAINS, INVALID_SETTING_CODE } from './system-state.mjs';
|
||||
import { sectionNav } from './system-controls.mjs';
|
||||
import { mountOverview } from './system-overview.mjs';
|
||||
@@ -145,22 +145,6 @@ export const DELIVERED_SECTIONS = Object.freeze({
|
||||
* under one of this tab's drafts flags that draft (D92's conflict status), including a change made while System was
|
||||
* not mounted (the drafts outlive it, Q10).
|
||||
*/
|
||||
/** D165 (#525): one small line under System's Overview, the workspace's only visible way to the legacy pages. */
|
||||
function legacyPagesLine() {
|
||||
const line = el('p', 'small legacy-pages');
|
||||
line.append('Legacy pages: ');
|
||||
LEGACY_PAGES.forEach((page, index) => {
|
||||
const link = document.createElement('a');
|
||||
link.textContent = page.label;
|
||||
link.href = window.ApiClient.resolveUrl(page.href);
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener';
|
||||
if (index) line.append(' · ');
|
||||
line.append(link);
|
||||
});
|
||||
return line;
|
||||
}
|
||||
|
||||
export function mountSystem(main, api, scope, { section, activity, counts = null, sections = DELIVERED_SECTIONS,
|
||||
navigate = navigateTo }) {
|
||||
const store = systemDrafts(api);
|
||||
@@ -187,7 +171,6 @@ export function mountSystem(main, api, scope, { section, activity, counts = null
|
||||
saveRow.append(status, save);
|
||||
layout.append(saveRow);
|
||||
main.append(nav, layout);
|
||||
const legacyPages = legacyPagesLine();
|
||||
|
||||
const paintStatus = () => {
|
||||
status.textContent = store.statusText();
|
||||
@@ -213,8 +196,6 @@ export function mountSystem(main, api, scope, { section, activity, counts = null
|
||||
// Every section is delivered (S12), and the router only names known sections (state.mjs).
|
||||
const nodes = sections[next](layout, { api, activity, scope: sectionScope, drafts: store, counts });
|
||||
layout.prepend(...nodes);
|
||||
if (next === 'overview') main.append(legacyPages);
|
||||
else legacyPages.remove();
|
||||
paintStatus();
|
||||
}
|
||||
show(section);
|
||||
|
||||
@@ -575,7 +575,6 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
|
||||
}
|
||||
.more-menu-list[hidden] { display: none; }
|
||||
.more-menu-list > button { justify-content: flex-start; text-align: left; width: 100%; white-space: nowrap; }
|
||||
.legacy-pages { margin: 16px 0 0; color: var(--muted); /* D165 */ }
|
||||
|
||||
/* D164 (#524): the prompt page has four heading actions and titles of any length, which the concept never shows. From
|
||||
the concept's sidebar breakpoint up the actions keep one row and the title takes what is left, clamped to two lines
|
||||
|
||||
+2
-6
@@ -60,12 +60,8 @@
|
||||
</div>
|
||||
<div id="workspace-toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
<div id="workspace-boot-failure" role="alert" hidden style="position: fixed; inset: 16px 16px auto; z-index: 1000; padding: 16px; border: 1px solid #888; border-radius: 8px; background: #1b1b1f; color: #eee; font: 14px/1.5 system-ui, sans-serif;">
|
||||
<p style="margin: 0;">The workspace couldn't load. Reload the page, or use a legacy page:
|
||||
<a style="color: inherit;" href="admin">Dashboard</a> ·
|
||||
<a style="color: inherit;" href="admin#/tags">Tags</a> ·
|
||||
<a style="color: inherit;" href="gallery">Gallery</a> ·
|
||||
<a style="color: inherit;" href="gallery.html">Metadata viewer</a></p>
|
||||
<p style="margin: 0;">The workspace couldn't load. <a style="color: inherit;" href="workspace">Reload the page</a>, or go back to <a style="color: inherit;" href="../">ComfyUI</a>.</p>
|
||||
</div>
|
||||
<noscript><p>This workspace needs JavaScript. <a href="admin">Open the existing dashboard</a>.</p></noscript>
|
||||
<noscript><p>This workspace needs JavaScript.</p></noscript>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user