diff --git a/.comfyignore b/.comfyignore index 02f6ebab..eb91d968 100644 --- a/.comfyignore +++ b/.comfyignore @@ -19,7 +19,3 @@ /.flake8 /.pre-commit-config.yaml /.gitignore - -# Stray images no code uses -/image_meta_data.png -/test_metadata_file.png diff --git a/README.md b/README.md index cc783519..d268ce91 100644 --- a/README.md +++ b/README.md @@ -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 | | --- | --- | diff --git a/TESTING.md b/TESTING.md index 948c70b5..1b127eda 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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. diff --git a/database/annotations.py b/database/annotations.py index fbf755a6..21882865 100644 --- a/database/annotations.py +++ b/database/annotations.py @@ -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. diff --git a/database/health.py b/database/health.py index e065046c..83ffb61d 100644 --- a/database/health.py +++ b/database/health.py @@ -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})") diff --git a/database/library.py b/database/library.py index 25ba6247..67528ada 100644 --- a/database/library.py +++ b/database/library.py @@ -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. diff --git a/database/library_move.py b/database/library_move.py index b4451a79..c84a9673 100644 --- a/database/library_move.py +++ b/database/library_move.py @@ -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) diff --git a/database/models.py b/database/models.py index 4c506962..ba8d9653 100644 --- a/database/models.py +++ b/database/models.py @@ -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 diff --git a/database/restore_stage.py b/database/restore_stage.py index 1a3a3762..c204f1f2 100644 --- a/database/restore_stage.py +++ b/database/restore_stage.py @@ -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: diff --git a/database/saved_views.py b/database/saved_views.py index 9d691f43..d4ddfbb0 100644 --- a/database/saved_views.py +++ b/database/saved_views.py @@ -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). diff --git a/database/sqlite_uri.py b/database/sqlite_uri.py new file mode 100644 index 00000000..07e22362 --- /dev/null +++ b/database/sqlite_uri.py @@ -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" diff --git a/image_meta_data.png b/image_meta_data.png deleted file mode 100644 index e9515346..00000000 Binary files a/image_meta_data.png and /dev/null differ diff --git a/images/pm-lora-results.png b/images/pm-lora-results.png deleted file mode 100644 index a4e3b089..00000000 Binary files a/images/pm-lora-results.png and /dev/null differ diff --git a/prompt_manager.py b/prompt_manager.py index 72d8e327..27d77fdf 100644 --- a/prompt_manager.py +++ b/prompt_manager.py @@ -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 ) diff --git a/prompt_manager_base.py b/prompt_manager_base.py index 4d624b4e..02f13743 100644 --- a/prompt_manager_base.py +++ b/prompt_manager_base.py @@ -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. diff --git a/prompt_manager_pair.py b/prompt_manager_pair.py index 4dcb5d2b..a98170b9 100644 --- a/prompt_manager_pair.py +++ b/prompt_manager_pair.py @@ -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, diff --git a/prompt_manager_text.py b/prompt_manager_text.py index 310eef7a..a43631aa 100644 --- a/prompt_manager_text.py +++ b/prompt_manager_text.py @@ -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 ) diff --git a/py/api/__init__.py b/py/api/__init__.py index 6997f686..14614349 100644 --- a/py/api/__init__.py +++ b/py/api/__init__.py @@ -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) diff --git a/py/api/admin.py b/py/api/admin.py index f0ea05f6..93833709 100644 --- a/py/api/admin.py +++ b/py/api/admin.py @@ -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: diff --git a/py/api/autotag_routes.py b/py/api/autotag_routes.py index c3962b7e..d751763c 100644 --- a/py/api/autotag_routes.py +++ b/py/api/autotag_routes.py @@ -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) diff --git a/py/api/backup_restore.py b/py/api/backup_restore.py index ab3d030a..0e765c92 100644 --- a/py/api/backup_restore.py +++ b/py/api/backup_restore.py @@ -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: diff --git a/py/api/library.py b/py/api/library.py index 02ca0088..356c36ef 100644 --- a/py/api/library.py +++ b/py/api/library.py @@ -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 diff --git a/py/api/prompts.py b/py/api/prompts.py index de2c6716..3ce25cd2 100644 --- a/py/api/prompts.py +++ b/py/api/prompts.py @@ -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: diff --git a/py/api/thumbnail_auto.py b/py/api/thumbnail_auto.py index 59a80a67..63cca7bc 100644 --- a/py/api/thumbnail_auto.py +++ b/py/api/thumbnail_auto.py @@ -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.""" diff --git a/py/api/thumbnail_jobs.py b/py/api/thumbnail_jobs.py index 7d7c8004..faf230b8 100644 --- a/py/api/thumbnail_jobs.py +++ b/py/api/thumbnail_jobs.py @@ -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: diff --git a/py/config.py b/py/config.py index 91db4dae..09e29678 100644 --- a/py/config.py +++ b/py/config.py @@ -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. diff --git a/test_metadata_file.png b/test_metadata_file.png deleted file mode 100644 index 4fb73a63..00000000 Binary files a/test_metadata_file.png and /dev/null differ diff --git a/tests/browser/library-concept.spec.mjs b/tests/browser/library-concept.spec.mjs index 19536b7a..941f603c 100644 --- a/tests/browser/library-concept.spec.mjs +++ b/tests/browser/library-concept.spec.mjs @@ -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; diff --git a/tests/browser/system-concept.spec.mjs b/tests/browser/system-concept.spec.mjs index 33b453bd..82bf6d55 100644 --- a/tests/browser/system-concept.spec.mjs +++ b/tests/browser/system-concept.spec.mjs @@ -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 }) => { diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index 703f3568..cfe4f467 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -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); diff --git a/tests/browser/workspace-saved-views.spec.mjs b/tests/browser/workspace-saved-views.spec.mjs index 69258546..b784f437 100644 --- a/tests/browser/workspace-saved-views.spec.mjs +++ b/tests/browser/workspace-saved-views.spec.mjs @@ -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: '' } }), diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index db06e7ec..901e62a1 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -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'); diff --git a/tests/js/workspace-library-state.test.js b/tests/js/workspace-library-state.test.js index a31063d0..a84b3c8d 100644 --- a/tests/js/workspace-library-state.test.js +++ b/tests/js/workspace-library-state.test.js @@ -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'); diff --git a/tests/js/workspace-saved-views.test.js b/tests/js/workspace-saved-views.test.js index bb9e0a50..df1ad177 100644 --- a/tests/js/workspace-saved-views.test.js +++ b/tests/js/workspace-saved-views.test.js @@ -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); + } }); diff --git a/tests/js/workspace-state.test.js b/tests/js/workspace-state.test.js index c4711f12..e6f0c51f 100644 --- a/tests/js/workspace-state.test.js +++ b/tests/js/workspace-state.test.js @@ -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); diff --git a/tests/test_api_autotag.py b/tests/test_api_autotag.py index ae643f9f..a3bffda0 100644 --- a/tests/test_api_autotag.py +++ b/tests/test_api_autotag.py @@ -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() diff --git a/tests/test_api_core.py b/tests/test_api_core.py index 087fa3c9..baf02178 100644 --- a/tests/test_api_core.py +++ b/tests/test_api_core.py @@ -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() diff --git a/tests/test_api_library.py b/tests/test_api_library.py index e457a2cd..bd255d71 100644 --- a/tests/test_api_library.py +++ b/tests/test_api_library.py @@ -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) diff --git a/tests/test_api_prompts.py b/tests/test_api_prompts.py index c8625c25..895d2bad 100644 --- a/tests/test_api_prompts.py +++ b/tests/test_api_prompts.py @@ -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} diff --git a/tests/test_api_thumbnail_location.py b/tests/test_api_thumbnail_location.py index fd78be7c..1bf5b9c6 100644 --- a/tests/test_api_thumbnail_location.py +++ b/tests/test_api_thumbnail_location.py @@ -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 diff --git a/tests/test_cached_runs.py b/tests/test_cached_runs.py index ed726220..9dbbc64c 100644 --- a/tests/test_cached_runs.py +++ b/tests/test_cached_runs.py @@ -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 diff --git a/tests/test_comfyui_integration.py b/tests/test_comfyui_integration.py deleted file mode 100644 index 67473cd0..00000000 --- a/tests/test_comfyui_integration.py +++ /dev/null @@ -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() diff --git a/tests/test_config.py b/tests/test_config.py index 945aa5ab..8d505cc4 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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) diff --git a/tests/test_empty_text_handling.py b/tests/test_empty_text_handling.py index 630cc3a3..7355797b 100644 --- a/tests/test_empty_text_handling.py +++ b/tests/test_empty_text_handling.py @@ -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") diff --git a/tests/test_execution_context_capture.py b/tests/test_execution_context_capture.py index a1151cd7..a4723414 100644 --- a/tests/test_execution_context_capture.py +++ b/tests/test_execution_context_capture.py @@ -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 diff --git a/tests/test_focused_node.py b/tests/test_focused_node.py index 3e7bbd8f..eb63ed74 100644 --- a/tests/test_focused_node.py +++ b/tests/test_focused_node.py @@ -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() diff --git a/tests/test_hashing.py b/tests/test_hashing.py index 0f4d41a2..bf5f3fdf 100644 --- a/tests/test_hashing.py +++ b/tests/test_hashing.py @@ -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.""" diff --git a/tests/test_image_monitor_singleton.py b/tests/test_image_monitor_singleton.py index aec62710..59550177 100644 --- a/tests/test_image_monitor_singleton.py +++ b/tests/test_image_monitor_singleton.py @@ -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.""" diff --git a/tests/test_job_admission.py b/tests/test_job_admission.py index 4e8e5584..73fb33b3 100644 --- a/tests/test_job_admission.py +++ b/tests/test_job_admission.py @@ -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 diff --git a/tests/test_node_execution_contract.py b/tests/test_node_execution_contract.py index a8af10ac..c9e5f9c0 100644 --- a/tests/test_node_execution_contract.py +++ b/tests/test_node_execution_contract.py @@ -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")) diff --git a/tests/test_node_tracking_wiring.py b/tests/test_node_tracking_wiring.py index e880cf5c..b9fe45bc 100644 --- a/tests/test_node_tracking_wiring.py +++ b/tests/test_node_tracking_wiring.py @@ -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 diff --git a/tests/test_package_contents.py b/tests/test_package_contents.py index 0de4c60f..b878ab03 100644 --- a/tests/test_package_contents.py +++ b/tests/test_package_contents.py @@ -22,8 +22,6 @@ DEVELOPMENT = ( "images/x.png", "TESTING.md", "requirements-dev.txt", - "image_meta_data.png", - "test_metadata_file.png", ) KEPT = ( "__init__.py", diff --git a/tests/test_pair_execution.py b/tests/test_pair_execution.py index a81399ef..f55d44b4 100644 --- a/tests/test_pair_execution.py +++ b/tests/test_pair_execution.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}} diff --git a/tests/test_pair_usage_tracking.py b/tests/test_pair_usage_tracking.py index 50d806c0..57fd4850 100644 --- a/tests/test_pair_usage_tracking.py +++ b/tests/test_pair_usage_tracking.py @@ -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"): diff --git a/tests/test_prompt_attribution.py b/tests/test_prompt_attribution.py index 229ff860..b10a5973 100644 --- a/tests/test_prompt_attribution.py +++ b/tests/test_prompt_attribution.py @@ -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() diff --git a/tests/test_prompt_pair_node.py b/tests/test_prompt_pair_node.py index fff212aa..d0a17214 100644 --- a/tests/test_prompt_pair_node.py +++ b/tests/test_prompt_pair_node.py @@ -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")) diff --git a/tests/test_prompt_usage.py b/tests/test_prompt_usage.py index 4c85b637..fa5cb921 100644 --- a/tests/test_prompt_usage.py +++ b/tests/test_prompt_usage.py @@ -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 diff --git a/tests/test_save_to_library.py b/tests/test_save_to_library.py index 0a28a15c..076a9e35 100644 --- a/tests/test_save_to_library.py +++ b/tests/test_save_to_library.py @@ -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: diff --git a/tests/test_saved_views.py b/tests/test_saved_views.py index 010c64cb..6d6e4827 100644 --- a/tests/test_saved_views.py +++ b/tests/test_saved_views.py @@ -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() diff --git a/tests/test_split_node.py b/tests/test_split_node.py index 9a94bfd1..68981a01 100644 --- a/tests/test_split_node.py +++ b/tests/test_split_node.py @@ -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() diff --git a/tests/test_sqlite_uri.py b/tests/test_sqlite_uri.py new file mode 100644 index 00000000..bc8ed98d --- /dev/null +++ b/tests/test_sqlite_uri.py @@ -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() diff --git a/tests/test_thumbnail_settings.py b/tests/test_thumbnail_settings.py index 0b6c011f..222db472 100644 --- a/tests/test_thumbnail_settings.py +++ b/tests/test_thumbnail_settings.py @@ -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( diff --git a/tests/test_validators.py b/tests/test_validators.py index d95407e2..892003e2 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -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.""" diff --git a/utils/comfyui_integration.py b/utils/comfyui_integration.py deleted file mode 100644 index cc2b4d2a..00000000 --- a/utils/comfyui_integration.py +++ /dev/null @@ -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 diff --git a/utils/hashing.py b/utils/hashing.py index ccf714e4..a6077892 100644 --- a/utils/hashing.py +++ b/utils/hashing.py @@ -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 diff --git a/utils/history_read_fence.py b/utils/history_read_fence.py index dd78c61b..2213aba5 100644 --- a/utils/history_read_fence.py +++ b/utils/history_read_fence.py @@ -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 " diff --git a/utils/validators.py b/utils/validators.py index 107b5edf..3349e907 100644 --- a/utils/validators.py +++ b/utils/validators.py @@ -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 diff --git a/web/js/workspace/library-state.mjs b/web/js/workspace/library-state.mjs index ebaa2760..ca742f47 100644 --- a/web/js/workspace/library-state.mjs +++ b/web/js/workspace/library-state.mjs @@ -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 ?? ''; diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index 164f49d7..94347c64 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -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). diff --git a/web/js/workspace/saved-views.mjs b/web/js/workspace/saved-views.mjs index 4711c332..dc72f84c 100644 --- a/web/js/workspace/saved-views.mjs +++ b/web/js/workspace/saved-views.mjs @@ -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), diff --git a/web/js/workspace/state.mjs b/web/js/workspace/state.mjs index 7fe28a6d..2405382e 100644 --- a/web/js/workspace/state.mjs +++ b/web/js/workspace/state.mjs @@ -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']); diff --git a/web/js/workspace/system.mjs b/web/js/workspace/system.mjs index 1a5e8129..659c103f 100644 --- a/web/js/workspace/system.mjs +++ b/web/js/workspace/system.mjs @@ -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); diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index 11053668..b6ce41f5 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -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 diff --git a/web/workspace.html b/web/workspace.html index 5de9998c..e8a87f90 100644 --- a/web/workspace.html +++ b/web/workspace.html @@ -60,12 +60,8 @@
The workspace couldn't load. Reload the page, or use a legacy page: - Dashboard · - Tags · - Gallery · - Metadata viewer
+The workspace couldn't load. Reload the page, or go back to ComfyUI.