From b4da296dc280e8177ca1f6578465bb33f4737f50 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Thu, 11 Jun 2026 22:30:39 -0400 Subject: [PATCH 1/2] feat: albums backed by InvokeAI image-gallery boards Adds a second album source type: instead of scanning a local directory tree, an album can mirror one or more boards on a running InvokeAI instance. Board contents are fetched via the InvokeAI HTTP API and resolved to local files under /outputs/images; the existing Embeddings pipeline indexes the explicit file list unchanged, so re-indexing naturally tracks board additions/removals. Backend: - New photomap/backend/invokeai_client.py: auth/token-cache helpers extracted from routers/invoke.py plus board-listing, image-name and image-delete wrappers usable outside the router layer. - Album model: source_type discriminator + per-album InvokeAI connection fields. image_paths and the index path are derived server-side (index lives in user_data_dir/indexes//, album key sanitized against traversal). Legacy YAML loads unchanged. - POST /invokeai/probe_status and /probe_boards let the album form validate a URL and list boards before anything is saved; stored passwords are resolved server-side, only ever paired with their own username, and never sent to the browser (album endpoints expose has_invokeai_password instead). - Deletion in board albums goes through the InvokeAI API so its database stays consistent; move_images is rejected; deleting a board album cleans up its derived index directory. - Indexing failures (unreachable backend, wrong root) fail fast with pointed progress errors and leave any existing index untouched. - Log lines that previously interpolated the full image-path list now summarize explicit lists (count + common parent). Frontend: - Add/edit album forms get a Directory / InvokeAI Board source toggle (immutable after creation), connection check, root-dir filetree picker, optional credentials, and a board multi-select checklist. Username/password defaults from the settings panel are surfaced in the form and cleared when the URL points elsewhere. - The add-form entrance animation no longer clamps the section at 600px (the taller board form previously overflowed it, hiding the Add Album button). - Indexing progress pollers re-resolve the album card on every tick, fixing a race where card rebuilds mid-indexing left the UI frozen at "Indexing in progress..."; freshly added albums are guarded against a duplicate, racing auto-index kickoff (stray 409 alerts). Tests: board-album CRUD/round-trip/password-leak guards, probe endpoints incl. credential-pairing rules, end-to-end board indexing / membership-update / deletion / move-rejection against a stubbed InvokeAI client, and Jest coverage for the form payloads, checklist, animation clamp and poller card resolution. Co-Authored-By: Claude Fable 5 --- photomap/backend/config.py | 138 ++++- photomap/backend/embeddings.py | 28 +- photomap/backend/invokeai_client.py | 380 +++++++++++++ photomap/backend/routers/album.py | 93 ++- photomap/backend/routers/index.py | 81 ++- photomap/backend/routers/invoke.py | 291 +++------- .../frontend/static/css/album-manager.css | 64 ++- photomap/frontend/static/css/animations.css | 7 + .../static/javascript/album-manager.js | 532 ++++++++++++++++-- .../javascript/invokeai-album-source.js | 62 ++ .../templates/modules/album-manager.html | 140 +++-- tests/backend/test_albums.py | 155 +++++ tests/backend/test_invoke_router.py | 243 +++++++- tests/backend/test_invokeai_board_index.py | 233 ++++++++ tests/frontend/invokeai-album-source.test.js | 483 ++++++++++++++++ 15 files changed, 2589 insertions(+), 341 deletions(-) create mode 100644 photomap/backend/invokeai_client.py create mode 100644 photomap/frontend/static/javascript/invokeai-album-source.js create mode 100644 tests/backend/test_invokeai_board_index.py create mode 100644 tests/frontend/invokeai-album-source.test.js diff --git a/photomap/backend/config.py b/photomap/backend/config.py index 07812856..240480a2 100644 --- a/photomap/backend/config.py +++ b/photomap/backend/config.py @@ -9,10 +9,10 @@ import threading from functools import lru_cache from pathlib import Path -from typing import Any +from typing import Any, Literal import yaml -from platformdirs import user_config_dir +from platformdirs import user_config_dir, user_data_dir from pydantic import BaseModel, Field, field_validator, model_validator from .encoders import LEGACY_ENCODER_SPEC, default_encoder_spec @@ -21,15 +21,70 @@ logger = logging.getLogger(__name__) +def default_board_index_path(album_key: str) -> Path: + """Index location for albums that have no image directory of their own. + + InvokeAI-board albums can't store ``photomap_index`` next to their + images (the images belong to InvokeAI), so their .npz lives in the + per-user data directory instead, keyed by album. + """ + # The key lands in a filesystem path, and keys are user input — refuse + # anything that could escape the indexes directory. + if ( + not album_key + or "/" in album_key + or "\\" in album_key + or "\x00" in album_key + or ".." in album_key + ): + raise ValueError(f"Album key not usable as a directory name: {album_key!r}") + data_dir = Path(user_data_dir("photomap", "photomap")) + return data_dir / "indexes" / album_key / "embeddings.npz" + + class Album(BaseModel): """Represents a photo album configuration.""" key: str = Field(..., description="Unique album identifier") name: str = Field(..., description="Display name for the album") + source_type: Literal["directory", "invokeai_board"] = Field( + default="directory", + description=( + "Where the album's images come from: a local directory tree " + "('directory', the default) or one or more InvokeAI gallery " + "boards ('invokeai_board')." + ), + ) image_paths: list[str] = Field( ..., min_length=1, description="List of paths containing images" ) index: str = Field(..., description="Path to the embeddings index file") + invokeai_url: str | None = Field( + default=None, + description="Base URL of the InvokeAI backend serving this album's boards", + ) + invokeai_username: str | None = Field( + default=None, + description="Username for the InvokeAI backend (multi-user mode only)", + ) + invokeai_password: str | None = Field( + default=None, + description="Password for the InvokeAI backend (multi-user mode only)", + ) + invokeai_root: str | None = Field( + default=None, + description=( + "Locally-accessible InvokeAI root directory; images resolve to " + "/outputs/images/" + ), + ) + invokeai_board_ids: list[str] = Field( + default_factory=list, + description=( + "InvokeAI board ids whose images make up this album. The special " + "id 'none' is InvokeAI's Uncategorized bucket." + ), + ) umap_eps: float = Field(default=0.2, description="UMAP epsilon parameter") description: str = Field(default="", description="Album description") encoder_spec: str = Field( @@ -72,6 +127,27 @@ class Album(BaseModel): ), ) + @model_validator(mode="before") + @classmethod + def _derive_board_album_fields(cls, data: Any) -> Any: + """Fill in `image_paths` and `index` for InvokeAI-board albums. + + Board albums have no user-chosen image directory: their images live + under `/outputs/images` and their index in the user + data directory. Both must be derived *before* field validation + because `image_paths` has `min_length=1` and `index` is required. + """ + if not isinstance(data, dict) or data.get("source_type") != "invokeai_board": + return data + root = data.get("invokeai_root") + if root and not data.get("image_paths"): + data["image_paths"] = [ + str(Path(root).expanduser() / "outputs" / "images") + ] + if not data.get("index") and data.get("key"): + data["index"] = default_board_index_path(str(data["key"])).as_posix() + return data + @model_validator(mode="after") def _resolve_min_search_score(self) -> "Album": if self.min_search_score is None: @@ -80,6 +156,20 @@ def _resolve_min_search_score(self) -> "Album": ) return self + @model_validator(mode="after") + def _validate_board_fields(self) -> "Album": + if self.source_type != "invokeai_board": + return self + if not self.invokeai_url: + raise ValueError("InvokeAI-board albums require invokeai_url") + if not self.invokeai_url.startswith(("http://", "https://")): + raise ValueError("invokeai_url must use http:// or https://") + if not self.invokeai_root: + raise ValueError("InvokeAI-board albums require invokeai_root") + if not self.invokeai_board_ids: + raise ValueError("InvokeAI-board albums require at least one board id") + return self + @field_validator("image_paths") @classmethod def expand_and_validate_image_paths(cls, v: list[str]) -> list[str]: @@ -101,8 +191,9 @@ def validate_index_path(cls, v: str) -> str: def to_dict(self) -> dict[str, Any]: """Convert album to dictionary format for YAML.""" - return { + data = { "name": self.name, + "source_type": self.source_type, "image_paths": self.image_paths, "index": self.index, "umap_eps": self.umap_eps, @@ -113,6 +204,14 @@ def to_dict(self) -> dict[str, Any]: "use_query_optimization": self.use_query_optimization, "min_image_dimension": self.min_image_dimension, } + # Keep directory-album YAML free of irrelevant InvokeAI keys. + if self.source_type == "invokeai_board": + data["invokeai_url"] = self.invokeai_url + data["invokeai_username"] = self.invokeai_username + data["invokeai_password"] = self.invokeai_password + data["invokeai_root"] = self.invokeai_root + data["invokeai_board_ids"] = self.invokeai_board_ids + return data @classmethod def from_dict(cls, key: str, data: dict[str, Any]) -> "Album": @@ -120,6 +219,8 @@ def from_dict(cls, key: str, data: dict[str, Any]) -> "Album": return cls( key=key, name=data.get("name", key.capitalize()), + # Albums written before this field existed are directory albums. + source_type=data.get("source_type", "directory"), image_paths=data.get("image_paths", []), index=data["index"], umap_eps=data.get("umap_eps", 0.07), @@ -134,6 +235,11 @@ def from_dict(cls, key: str, data: dict[str, Any]) -> "Album": max_search_results=data.get("max_search_results", 100), use_query_optimization=data.get("use_query_optimization", True), min_image_dimension=data.get("min_image_dimension", 256), + invokeai_url=data.get("invokeai_url"), + invokeai_username=data.get("invokeai_username"), + invokeai_password=data.get("invokeai_password"), + invokeai_root=data.get("invokeai_root"), + invokeai_board_ids=data.get("invokeai_board_ids", []), ) @@ -548,8 +654,8 @@ def reload_config(self) -> Config: def create_album( key: str, name: str, - image_paths: list[str], - index: str, + image_paths: list[str] | None, + index: str | None, umap_eps: float, description: str = "", encoder_spec: str | None = None, @@ -557,22 +663,36 @@ def create_album( max_search_results: int | None = None, use_query_optimization: bool | None = None, min_image_dimension: int | None = None, + source_type: str = "directory", + invokeai_url: str | None = None, + invokeai_username: str | None = None, + invokeai_password: str | None = None, + invokeai_root: str | None = None, + invokeai_board_ids: list[str] | None = None, ) -> Album: """Create a new Album instance with validation. Each optional parameter falls through to the Album default when omitted, - so existing callers don't have to thread every field. + so existing callers don't have to thread every field. ``image_paths`` + and ``index`` may be None for InvokeAI-board albums — the Album model + derives them from ``invokeai_root`` and the album key. """ - image_paths = [str(Path(x).expanduser().resolve()) for x in image_paths] - index = str(Path(index).expanduser().resolve()) + image_paths = [str(Path(x).expanduser().resolve()) for x in image_paths or []] fields: dict[str, object] = { "key": key, "name": name, + "source_type": source_type, "image_paths": image_paths, - "index": index, "umap_eps": umap_eps, "description": description, + "invokeai_url": invokeai_url, + "invokeai_username": invokeai_username, + "invokeai_password": invokeai_password, + "invokeai_root": invokeai_root, + "invokeai_board_ids": invokeai_board_ids or [], } + if index is not None: + fields["index"] = str(Path(index).expanduser().resolve()) if encoder_spec is not None: fields["encoder_spec"] = encoder_spec if min_search_score is not None: diff --git a/photomap/backend/embeddings.py b/photomap/backend/embeddings.py index 54d7f3b5..1d3e64fe 100644 --- a/photomap/backend/embeddings.py +++ b/photomap/backend/embeddings.py @@ -121,6 +121,26 @@ def _normalized_filtered_embeddings( } +def describe_image_source(image_paths_or_dir: list[Path] | Path, limit: int = 3) -> str: + """Compact, log-safe description of an indexing source. + + Directory albums pass one or a few directories, which are worth printing + verbatim. InvokeAI-board albums pass thousands of explicit file paths — + summarize those as a count plus their common parent instead of flooding + the log. + """ + if isinstance(image_paths_or_dir, Path): + return str(image_paths_or_dir) + paths = list(image_paths_or_dir) + if len(paths) <= limit: + return ", ".join(str(p) for p in paths) + try: + parent = os.path.commonpath([str(p) for p in paths]) + except ValueError: # mixed drives / empty — no common anchor + parent = "multiple locations" + return f"{len(paths)} explicit paths under {parent}" + + # ========================================================================= # FPS with Exclusion Support # ========================================================================= @@ -978,7 +998,9 @@ def traversal_callback(count, message): progress_callback=traversal_callback, ) total_images = len(image_paths) - logger.info(f"Found {total_images} image files in {image_paths_or_dir}") + logger.info( + f"Found {total_images} image files in {describe_image_source(image_paths_or_dir)}" + ) if total_images == 0: progress_tracker.set_error( album_key, "No image files found in album directory(ies)" @@ -1112,7 +1134,9 @@ def update_index( try: existing = self._load_existing_index_arrays() - logger.info(f"Scanning for new images in {image_paths_or_dir}...") + logger.info( + f"Scanning for new images in {describe_image_source(image_paths_or_dir)}..." + ) new_image_paths, missing_image_paths = self._get_new_and_missing_images( image_paths_or_dir, existing.filenames, diff --git a/photomap/backend/invokeai_client.py b/photomap/backend/invokeai_client.py new file mode 100644 index 00000000..3f04a222 --- /dev/null +++ b/photomap/backend/invokeai_client.py @@ -0,0 +1,380 @@ +"""Shared HTTP client helpers for talking to an InvokeAI backend. + +This module owns everything needed to make authenticated calls against a +running InvokeAI instance: URL validation, the JWT token cache with its +single-user/multi-user fallback logic, and thin wrappers around the +InvokeAI REST endpoints PhotoMap consumes (version probe, board listing, +board image names, image deletion). + +It deliberately lives outside ``routers/`` so that non-router code (the +indexing pipeline, curation) can use it without importing a FastAPI router +module. ``routers/invoke.py`` re-exports the auth helpers for backward +compatibility with existing tests. + +The token cache holds a single entry keyed by ``(base_url, username)``. +Per-album credentials that differ from the global settings will therefore +thrash it — each switch costs one extra login round-trip. That is +acceptable for the access patterns here (indexing and deletion are not +high-frequency), so no multi-entry cache is kept. +""" + +from __future__ import annotations + +import logging +import time +from collections.abc import Awaitable, Callable +from urllib.parse import urlsplit + +import httpx +from fastapi import HTTPException + +logger = logging.getLogger(__name__) + +# 5 seconds is plenty for a local loopback call; anything slower almost +# certainly means the backend is unreachable rather than genuinely busy. +_HTTP_TIMEOUT = 5.0 + +# Listing the image names of a very large board can legitimately take +# longer than the snappy 5s used for control-plane calls. +_BOARD_FETCH_TIMEOUT = 30.0 + +# ── InvokeAI JWT token cache ────────────────────────────────────────── +_cached_token: str | None = None +_token_expires_at: float = 0.0 +_token_base_url: str | None = None +_token_username: str | None = None + + +def _cached_auth_headers(base_url: str, username: str | None) -> dict[str, str]: + """Return ``{"Authorization": "Bearer ..."}`` if we still hold a valid + cached token for this ``(base_url, username)`` pair, else ``{}``. + + This never talks to the network. Deliberate: the first attempt at any + request always uses whatever auth we already have (or none), so that a + backend that has since been reconfigured into single-user mode is given + a chance to accept the call anonymously. + """ + if ( + _cached_token + and time.monotonic() < _token_expires_at + and _token_base_url == base_url + and _token_username == username + ): + return {"Authorization": f"Bearer {_cached_token}"} + return {} + + +async def _login(base_url: str, username: str, password: str) -> dict[str, str]: + """Exchange ``username``/``password`` for a JWT via the InvokeAI auth + endpoint, cache the token, and return the ``Authorization`` header. + """ + global _cached_token, _token_expires_at, _token_base_url, _token_username # noqa: PLW0603 + + login_url = f"{base_url.rstrip('/')}/api/v1/auth/login" + try: + async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT) as client: + resp = await client.post(login_url, json={"email": username, "password": password}) + except httpx.RequestError as exc: + logger.warning("InvokeAI auth request failed: %s", exc) + raise HTTPException( + status_code=502, + detail=f"Could not reach InvokeAI backend for authentication: {exc}", + ) from exc + + if resp.status_code != 200: + detail = resp.json().get("detail", resp.text[:200]) if resp.headers.get("content-type", "").startswith("application/json") else resp.text[:200] + raise HTTPException( + status_code=502, + detail=f"InvokeAI authentication failed ({resp.status_code}): {detail}", + ) + + data = resp.json() + _cached_token = data["token"] + _token_expires_at = time.monotonic() + data.get("expires_in", 86400) - 60 # refresh 60s early + _token_base_url = base_url + _token_username = username + return {"Authorization": f"Bearer {_cached_token}"} + + +def _invalidate_token_cache() -> None: + """Clear the cached token so the next request re-authenticates.""" + global _cached_token, _token_expires_at, _token_base_url, _token_username # noqa: PLW0603 + _cached_token = None + _token_expires_at = 0.0 + _token_base_url = None + _token_username = None + + +async def _request_with_auth_fallback( + base_url: str, + username: str | None, + password: str | None, + request_fn: Callable[[dict[str, str]], Awaitable[httpx.Response]], +) -> httpx.Response: + """Perform an InvokeAI request with graceful handling of auth transitions. + + ``request_fn`` is an async callable that takes a headers dict and + performs the HTTP call — using a factory lets the caller re-open file + streams (needed for multipart uploads) on a retry. + + Three-step flow: + + 1. First attempt uses whatever token we have cached (or no auth at all). + A freshly-restarted single-user backend then accepts the call even + if credentials are stored in PhotoMap. + 2. If the first attempt returns **401**, the backend demands + authentication: if credentials are configured we log in, cache a + fresh token, and retry. + 3. If the first attempt was made *with* a token and returns **403** + (most commonly "Multiuser mode is disabled. Authentication is not + required…"), the backend was reconfigured to single-user mode — we + invalidate the cached token and retry anonymously. + """ + auth_headers = _cached_auth_headers(base_url, username) + response = await request_fn(auth_headers) + + if response.status_code == 401 and username and password: + _invalidate_token_cache() + auth_headers = await _login(base_url, username, password) + response = await request_fn(auth_headers) + elif response.status_code == 403 and auth_headers: + _invalidate_token_cache() + response = await request_fn({}) + + return response + + +def _validate_invokeai_url(url: str | None) -> str | None: + """Reject non-http(s) schemes so configured URLs cannot be used for SSRF. + + The configured URL is later concatenated into outbound requests; ``httpx`` + already refuses non-http(s) schemes, but validating up front returns + a clean 400 to the caller rather than a 502 at call time, and blocks + obviously-wrong values like ``file://`` or ``javascript:`` from ever + reaching the config file. + + Empty / None is allowed — that's "not configured yet". + """ + if not url: + return url + try: + parts = urlsplit(url) + except ValueError as exc: + raise HTTPException( + status_code=400, detail=f"Invalid InvokeAI URL: {exc}" + ) from exc + if parts.scheme not in {"http", "https"}: + raise HTTPException( + status_code=400, + detail="InvokeAI URL must use http:// or https://", + ) + if not parts.netloc: + raise HTTPException( + status_code=400, detail="InvokeAI URL must include a host" + ) + return url + + +async def check_status(base_url: str | None) -> dict: + """Report whether ``base_url`` is reachable and looks like InvokeAI. + + Probes the unauthenticated ``/api/v1/app/version`` endpoint. Returns + ``{"reachable": True, "version": ...}`` on success and + ``{"reachable": False, "detail": ...}`` for any network or HTTP failure + rather than raising, so callers can render a neutral hint instead of an + error banner while the user is still typing. + """ + if not base_url: + return {"reachable": False, "detail": "No InvokeAI URL configured"} + + version_url = f"{base_url.rstrip('/')}/api/v1/app/version" + try: + async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT) as client: + resp = await client.get(version_url) + except httpx.RequestError as exc: + return {"reachable": False, "detail": f"Could not reach backend: {exc}"} + + if resp.status_code != 200: + return { + "reachable": False, + "detail": f"Backend returned HTTP {resp.status_code}", + } + not_invokeai = "Server is reachable but doesn't appear to be an InvokeAI backend" + try: + payload = resp.json() + except ValueError: + return {"reachable": False, "detail": not_invokeai} + version = payload.get("version") + if not version: + # A non-InvokeAI server happening to have /api/v1/app/version would + # almost certainly not return a version field. + return {"reachable": False, "detail": not_invokeai} + return {"reachable": True, "version": version} + + +async def list_boards( + base_url: str, + username: str | None, + password: str | None, +) -> list[dict]: + """Return the boards available on ``base_url``. + + Uses the same auth-fallback pattern as the other wrappers. Returns a + flat ``[{"board_id": ..., "board_name": ...}]`` list. Any failure + (unreachable, auth, 5xx) raises 502. + """ + boards_url = f"{base_url.rstrip('/')}/api/v1/boards/" + + try: + async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT) as client: + + async def _do(headers: dict[str, str]) -> httpx.Response: + return await client.get( + boards_url, params={"all": "true"}, headers=headers + ) + + response = await _request_with_auth_fallback( + base_url, username, password, _do + ) + except httpx.RequestError as exc: + logger.warning("InvokeAI boards request failed: %s", exc) + raise HTTPException( + status_code=502, + detail=f"Could not reach InvokeAI backend at {base_url}: {exc}", + ) from exc + + if response.status_code >= 400: + raise HTTPException( + status_code=502, + detail=( + f"InvokeAI backend returned {response.status_code}: " + f"{response.text[:200]}" + ), + ) + + try: + raw = response.json() + except ValueError as exc: + raise HTTPException( + status_code=502, detail="Boards endpoint did not return JSON" + ) from exc + + # ``?all=true`` returns a flat list; without it InvokeAI returns + # ``{"items": [...], "offset": ..., "total": ...}``. Handle both shapes + # so an accidentally-paginated response doesn't blank out the dropdown. + items = raw if isinstance(raw, list) else raw.get("items", []) + return [ + { + "board_id": item.get("board_id"), + "board_name": item.get("board_name") or "(unnamed board)", + } + for item in items + if isinstance(item, dict) and item.get("board_id") + ] + + +async def fetch_board_image_names( + base_url: str, + board_ids: list[str], + username: str | None, + password: str | None, +) -> list[str]: + """Return the image names belonging to ``board_ids``, deduplicated. + + Calls ``GET /api/v1/boards/{board_id}/image_names`` for each board. + The special board id ``"none"`` is InvokeAI's Uncategorized bucket. + Returned names include their file extension (``{uuid}.png`` style). + Raises 502 on any network error or non-200 response. + """ + all_names: list[str] = [] + try: + async with httpx.AsyncClient(timeout=_BOARD_FETCH_TIMEOUT) as client: + for board_id in board_ids: + names_url = ( + f"{base_url.rstrip('/')}/api/v1/boards/{board_id}/image_names" + ) + + async def _do( + headers: dict[str, str], url: str = names_url + ) -> httpx.Response: + return await client.get(url, headers=headers) + + response = await _request_with_auth_fallback( + base_url, username, password, _do + ) + if response.status_code >= 400: + raise HTTPException( + status_code=502, + detail=( + f"InvokeAI backend returned {response.status_code} for " + f"board {board_id!r}: {response.text[:200]}" + ), + ) + try: + names = response.json() + except ValueError as exc: + raise HTTPException( + status_code=502, + detail=f"Image-names endpoint for board {board_id!r} did not return JSON", + ) from exc + if not isinstance(names, list): + raise HTTPException( + status_code=502, + detail=f"Image-names endpoint for board {board_id!r} returned an unexpected shape", + ) + all_names.extend(str(name) for name in names) + except httpx.RequestError as exc: + logger.warning("InvokeAI image-names request failed: %s", exc) + raise HTTPException( + status_code=502, + detail=f"Could not reach InvokeAI backend at {base_url}: {exc}", + ) from exc + + # An image can belong to only one board, but guard against overlapping + # selections (e.g. "none" plus a board) — dedupe preserving order. + return list(dict.fromkeys(all_names)) + + +async def delete_image( + base_url: str, + image_name: str, + username: str | None, + password: str | None, +) -> None: + """Delete ``image_name`` on the InvokeAI backend. + + A 404 means InvokeAI no longer knows the image — log and return so the + caller can still drop it from the local index. Any other failure + raises 502. + """ + url = f"{base_url.rstrip('/')}/api/v1/images/i/{image_name}" + try: + async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT) as client: + + async def _do(headers: dict[str, str]) -> httpx.Response: + return await client.delete(url, headers=headers) + + response = await _request_with_auth_fallback( + base_url, username, password, _do + ) + except httpx.RequestError as exc: + logger.warning("InvokeAI image delete request failed: %s", exc) + raise HTTPException( + status_code=502, + detail=f"Could not reach InvokeAI backend at {base_url}: {exc}", + ) from exc + + if response.status_code == 404: + logger.warning( + "InvokeAI no longer has image %s; removing from index anyway", + image_name, + ) + return + if response.status_code >= 400: + raise HTTPException( + status_code=502, + detail=( + f"InvokeAI image delete returned {response.status_code}: " + f"{response.text[:200]}" + ), + ) diff --git a/photomap/backend/routers/album.py b/photomap/backend/routers/album.py index 7b540b8c..0a8e5ab8 100644 --- a/photomap/backend/routers/album.py +++ b/photomap/backend/routers/album.py @@ -1,5 +1,6 @@ import logging import os +import shutil from pathlib import Path from typing import Annotated, Any @@ -7,7 +8,7 @@ from fastapi.responses import JSONResponse from pydantic import BaseModel -from ..config import Album, create_album, get_config_manager +from ..config import Album, create_album, default_board_index_path, get_config_manager from ..embeddings import Embeddings from ..encoders import default_encoder_spec @@ -168,6 +169,56 @@ def require_no_lock() -> None: check_album_lock() +def _cleanup_derived_index(album: Album | None) -> None: + """Remove a board album's backend-derived index directory. + + Board-album indexes live in the per-user data directory + (``.../indexes//``) rather than next to the images, so nothing + else cleans them up when the album goes away. Only the derived + location is touched — a custom index path is left alone. + """ + if album is None or album.source_type != "invokeai_board": + return + try: + derived_dir = default_board_index_path(album.key).parent + except ValueError: + return + if Path(album.index).parent != derived_dir or not derived_dir.is_dir(): + return + try: + shutil.rmtree(derived_dir) + except OSError as e: + logger.warning(f"Could not remove index directory {derived_dir}: {e}") + + +def _album_public_dict(album: Album) -> dict[str, Any]: + """Album fields as exposed to the frontend. + + Deliberately omits ``invokeai_password`` — the stored per-album password + must never leave the backend. ``has_invokeai_password`` tells the edit + form whether one is saved. + """ + return { + "key": album.key, + "name": album.name, + "description": album.description, + "source_type": album.source_type, + "index": album.index, + "umap_eps": album.umap_eps, + "image_paths": album.image_paths, + "encoder_spec": album.encoder_spec, + "min_search_score": album.min_search_score, + "max_search_results": album.max_search_results, + "use_query_optimization": album.use_query_optimization, + "min_image_dimension": album.min_image_dimension, + "invokeai_url": album.invokeai_url, + "invokeai_username": album.invokeai_username, + "invokeai_root": album.invokeai_root, + "invokeai_board_ids": album.invokeai_board_ids, + "has_invokeai_password": bool(album.invokeai_password), + } + + # Album Management Routes @album_router.get("/available_albums/", tags=["Albums"]) async def get_available_albums() -> list[dict[str, Any]]: @@ -181,19 +232,7 @@ async def get_available_albums() -> list[dict[str, Any]]: locked_albums = get_locked_albums() return [ - { - "key": key, - "name": album.name, - "description": album.description, - "index": album.index, - "umap_eps": album.umap_eps, - "image_paths": album.image_paths, - "encoder_spec": album.encoder_spec, - "min_search_score": album.min_search_score, - "max_search_results": album.max_search_results, - "use_query_optimization": album.use_query_optimization, - "min_image_dimension": album.min_image_dimension, - } + _album_public_dict(album) for key, album in albums.items() if locked_albums is None or key in locked_albums ] @@ -214,9 +253,9 @@ async def get_default_encoder() -> dict[str, str]: @album_router.get("/album/{album_key}/", tags=["Albums"]) -async def get_album(album: AlbumDep) -> Album: - """Get details of a specific album.""" - return album +async def get_album(album: AlbumDep) -> dict[str, Any]: + """Get details of a specific album (passwords omitted).""" + return _album_public_dict(album) # TO DO: Replace album_data dict with a proper Pydantic model @@ -251,11 +290,19 @@ async def add_album(album: Album) -> JSONResponse: async def update_album(album_data: dict) -> JSONResponse: """Update an existing album in the configuration.""" try: + existing = config_manager.get_album(album_data["key"]) + # Edits never relocate an index: when the payload omits it, keep the + # stored path. Likewise the edit form never sees the saved InvokeAI + # password, so a blank/omitted one means "keep what's stored". + index = album_data.get("index") or (existing.index if existing else None) + password = album_data.get("invokeai_password") or ( + existing.invokeai_password if existing else None + ) album = create_album( key=album_data["key"], name=album_data["name"], - image_paths=album_data["image_paths"], - index=album_data["index"], + image_paths=album_data.get("image_paths"), + index=index, umap_eps=album_data.get("umap_eps", 0.07), description=album_data.get("description", ""), encoder_spec=album_data.get("encoder_spec"), @@ -263,6 +310,12 @@ async def update_album(album_data: dict) -> JSONResponse: max_search_results=album_data.get("max_search_results"), use_query_optimization=album_data.get("use_query_optimization"), min_image_dimension=album_data.get("min_image_dimension"), + source_type=album_data.get("source_type", "directory"), + invokeai_url=album_data.get("invokeai_url"), + invokeai_username=album_data.get("invokeai_username"), + invokeai_password=password, + invokeai_root=album_data.get("invokeai_root"), + invokeai_board_ids=album_data.get("invokeai_board_ids"), ) logger.info(f"Updating album: {album.key} with index {album.index}") @@ -290,7 +343,9 @@ async def update_album(album_data: dict) -> JSONResponse: async def delete_album(album_key: str) -> JSONResponse: """Delete an album from the configuration.""" try: + album = config_manager.get_album(album_key) if config_manager.delete_album(album_key): + _cleanup_derived_index(album) return JSONResponse( content={ "success": True, diff --git a/photomap/backend/routers/index.py b/photomap/backend/routers/index.py index c1692639..a56a2e4c 100644 --- a/photomap/backend/routers/index.py +++ b/photomap/backend/routers/index.py @@ -14,6 +14,7 @@ from pydantic import BaseModel from send2trash import send2trash +from .. import invokeai_client from ..config import get_config_manager from ..embeddings import Embeddings, peek_encoder_spec from ..progress import IndexingCancelled, progress_tracker @@ -279,6 +280,26 @@ async def delete_image( if not validate_image_access(album_config, image_path): raise HTTPException(status_code=403, detail="Access denied") + if album_config.source_type == "invokeai_board": + # Board images belong to InvokeAI: deleting the file directly + # would leave a dangling row in InvokeAI's database, so route + # the deletion through its API (which also removes the file). + # ``move_to_trash`` has no meaning here and is ignored. + await invokeai_client.delete_image( + album_config.invokeai_url, + image_path.name, + album_config.invokeai_username, + album_config.invokeai_password, + ) + embeddings.remove_image_from_embeddings(index) + return JSONResponse( + content={ + "success": True, + "message": f"Deleted {image_path.name} via InvokeAI", + }, + status_code=200, + ) + if not image_path.exists() or not image_path.is_file(): raise HTTPException(status_code=404, detail="File not found") @@ -315,6 +336,14 @@ async def move_images( ) -> JSONResponse: """Move multiple images to a different directory.""" try: + if album_config.source_type == "invokeai_board": + # Moving files out of InvokeAI's outputs/images would leave its + # database pointing at missing files. + raise HTTPException( + status_code=400, + detail="Moving images is not supported for InvokeAI board albums", + ) + target_dir = Path(req.target_directory) # Validate target directory exists and is writable @@ -471,11 +500,61 @@ async def copy_images( raise HTTPException(status_code=500, detail=f"Failed to copy images: {str(e)}") from e +async def _resolve_board_album_files(album_config) -> list[Path]: + """Resolve an InvokeAI-board album's images to local file paths. + + Fetches the selected boards' image names from the InvokeAI API and maps + them to ``/outputs/images/``. Names the API lists + but that don't exist locally are skipped with a warning; if *none* of + them exist the InvokeAI root is almost certainly wrong, which deserves + a pointed error instead of a generic "no images found". + """ + names = await invokeai_client.fetch_board_image_names( + album_config.invokeai_url, + album_config.invokeai_board_ids, + album_config.invokeai_username, + album_config.invokeai_password, + ) + images_dir = Path(album_config.invokeai_root).expanduser() / "outputs" / "images" + paths = [images_dir / name for name in names] + existing = [p for p in paths if p.is_file()] + missing = len(paths) - len(existing) + if missing and not existing: + raise HTTPException( + status_code=502, + detail=( + f"None of the {len(paths)} board images were found under " + f"{images_dir} — check the InvokeAI root directory." + ), + ) + if missing: + logger.warning( + f"{missing} of {len(paths)} board images not found under {images_dir}; skipping them." + ) + return existing + + # Background Tasks async def _update_index_background_async(album_key: str, album_config): """Background task for updating index with async support.""" try: - image_paths = [Path(path) for path in album_config.image_paths] + if getattr(album_config, "source_type", "directory") == "invokeai_board": + try: + image_paths = await _resolve_board_album_files(album_config) + except HTTPException as e: + progress_tracker.set_error( + album_key, + f"Could not fetch board contents from InvokeAI at " + f"{album_config.invokeai_url}: {e.detail}", + ) + return + if not image_paths: + progress_tracker.set_error( + album_key, "Selected InvokeAI board(s) contain no images" + ) + return + else: + image_paths = [Path(path) for path in album_config.image_paths] index_path = Path(album_config.index) embeddings = Embeddings( diff --git a/photomap/backend/routers/invoke.py b/photomap/backend/routers/invoke.py index 359145e8..43c78017 100644 --- a/photomap/backend/routers/invoke.py +++ b/photomap/backend/routers/invoke.py @@ -27,135 +27,30 @@ import logging import mimetypes import re -import time -from collections.abc import Awaitable, Callable from pathlib import Path -from urllib.parse import urlsplit import httpx -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field, ValidationError +from .. import invokeai_client from ..config import get_config_manager +from ..invokeai_client import ( # noqa: F401 (re-exported for tests/backward compat) + _HTTP_TIMEOUT, + _invalidate_token_cache, + _request_with_auth_fallback, + _validate_invokeai_url, +) from ..metadata_modules.invoke.invoke_metadata_view import InvokeMetadataView from ..metadata_modules.invokemetadata import GenerationMetadataAdapter -from .album import get_embeddings_for_album +from .album import get_embeddings_for_album, require_no_lock logger = logging.getLogger(__name__) invoke_router = APIRouter(prefix="/invokeai", tags=["InvokeAI"]) -# 5 seconds is plenty for a local loopback call; anything slower almost -# certainly means the backend is unreachable rather than genuinely busy. -_HTTP_TIMEOUT = 5.0 - config_manager = get_config_manager() -# ── InvokeAI JWT token cache ────────────────────────────────────────── -_cached_token: str | None = None -_token_expires_at: float = 0.0 -_token_base_url: str | None = None -_token_username: str | None = None - - -def _cached_auth_headers(base_url: str, username: str | None) -> dict[str, str]: - """Return ``{"Authorization": "Bearer ..."}`` if we still hold a valid - cached token for this ``(base_url, username)`` pair, else ``{}``. - - This never talks to the network. Deliberate: the first attempt at any - request always uses whatever auth we already have (or none), so that a - backend that has since been reconfigured into single-user mode is given - a chance to accept the call anonymously. - """ - if ( - _cached_token - and time.monotonic() < _token_expires_at - and _token_base_url == base_url - and _token_username == username - ): - return {"Authorization": f"Bearer {_cached_token}"} - return {} - - -async def _login(base_url: str, username: str, password: str) -> dict[str, str]: - """Exchange ``username``/``password`` for a JWT via the InvokeAI auth - endpoint, cache the token, and return the ``Authorization`` header. - """ - global _cached_token, _token_expires_at, _token_base_url, _token_username # noqa: PLW0603 - - login_url = f"{base_url.rstrip('/')}/api/v1/auth/login" - try: - async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT) as client: - resp = await client.post(login_url, json={"email": username, "password": password}) - except httpx.RequestError as exc: - logger.warning("InvokeAI auth request failed: %s", exc) - raise HTTPException( - status_code=502, - detail=f"Could not reach InvokeAI backend for authentication: {exc}", - ) from exc - - if resp.status_code != 200: - detail = resp.json().get("detail", resp.text[:200]) if resp.headers.get("content-type", "").startswith("application/json") else resp.text[:200] - raise HTTPException( - status_code=502, - detail=f"InvokeAI authentication failed ({resp.status_code}): {detail}", - ) - - data = resp.json() - _cached_token = data["token"] - _token_expires_at = time.monotonic() + data.get("expires_in", 86400) - 60 # refresh 60s early - _token_base_url = base_url - _token_username = username - return {"Authorization": f"Bearer {_cached_token}"} - - -def _invalidate_token_cache() -> None: - """Clear the cached token so the next request re-authenticates.""" - global _cached_token, _token_expires_at, _token_base_url, _token_username # noqa: PLW0603 - _cached_token = None - _token_expires_at = 0.0 - _token_base_url = None - _token_username = None - - -async def _request_with_auth_fallback( - base_url: str, - username: str | None, - password: str | None, - request_fn: Callable[[dict[str, str]], Awaitable[httpx.Response]], -) -> httpx.Response: - """Perform an InvokeAI request with graceful handling of auth transitions. - - ``request_fn`` is an async callable that takes a headers dict and - performs the HTTP call — using a factory lets the caller re-open file - streams (needed for multipart uploads) on a retry. - - Three-step flow: - - 1. First attempt uses whatever token we have cached (or no auth at all). - A freshly-restarted single-user backend then accepts the call even - if credentials are stored in PhotoMap. - 2. If the first attempt returns **401**, the backend demands - authentication: if credentials are configured we log in, cache a - fresh token, and retry. - 3. If the first attempt was made *with* a token and returns **403** - (most commonly "Multiuser mode is disabled. Authentication is not - required…"), the backend was reconfigured to single-user mode — we - invalidate the cached token and retry anonymously. - """ - auth_headers = _cached_auth_headers(base_url, username) - response = await request_fn(auth_headers) - - if response.status_code == 401 and username and password: - _invalidate_token_cache() - auth_headers = await _login(base_url, username, password) - response = await request_fn(auth_headers) - elif response.status_code == 403 and auth_headers: - _invalidate_token_cache() - response = await request_fn({}) - - return response - # InvokeAI stores images on disk as ``{uuid}.{ext}``; a filename matching this # shape is a strong signal the file was originally produced by InvokeAI and @@ -213,38 +108,6 @@ async def _do(headers: dict[str, str]) -> httpx.Response: return resp.status_code == 200 -def _validate_invokeai_url(url: str | None) -> str | None: - """Reject non-http(s) schemes so configured URLs cannot be used for SSRF. - - The configured URL is later concatenated into outbound requests for - ``/status``, ``/boards``, ``/recall`` and ``/use_ref_image``; ``httpx`` - already refuses non-http(s) schemes, but validating up front returns - a clean 400 to the settings panel rather than a 502 at call time, and - blocks obviously-wrong values like ``file://`` or ``javascript:`` from - ever reaching the config file. - - Empty / None is allowed — that's "not configured yet". - """ - if not url: - return url - try: - parts = urlsplit(url) - except ValueError as exc: - raise HTTPException( - status_code=400, detail=f"Invalid InvokeAI URL: {exc}" - ) from exc - if parts.scheme not in {"http", "https"}: - raise HTTPException( - status_code=400, - detail="InvokeAI URL must use http:// or https://", - ) - if not parts.netloc: - raise HTTPException( - status_code=400, detail="InvokeAI URL must include a host" - ) - return url - - # InvokeAI queue ids are short opaque tokens (e.g. ``default``); restrict # the pattern so a caller can't splice ``../`` into the outbound URL path # and reach an arbitrary endpoint on the configured backend. @@ -344,33 +207,7 @@ async def invokeai_status() -> dict: an error banner while the user is still typing. """ settings = config_manager.get_invokeai_settings() - base_url = settings["url"] - if not base_url: - return {"reachable": False, "detail": "No InvokeAI URL configured"} - - version_url = f"{base_url.rstrip('/')}/api/v1/app/version" - try: - async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT) as client: - resp = await client.get(version_url) - except httpx.RequestError as exc: - return {"reachable": False, "detail": f"Could not reach backend: {exc}"} - - if resp.status_code != 200: - return { - "reachable": False, - "detail": f"Backend returned HTTP {resp.status_code}", - } - not_invokeai = "Server is reachable but doesn't appear to be an InvokeAI backend" - try: - payload = resp.json() - except ValueError: - return {"reachable": False, "detail": not_invokeai} - version = payload.get("version") - if not version: - # A non-InvokeAI server happening to have /api/v1/app/version would - # almost certainly not return a version field. - return {"reachable": False, "detail": not_invokeai} - return {"reachable": True, "version": version} + return await invokeai_client.check_status(settings["url"]) @invoke_router.get("/boards") @@ -389,57 +226,79 @@ async def invokeai_boards() -> list[dict]: raise HTTPException( status_code=400, detail="InvokeAI backend URL is not configured." ) + return await invokeai_client.list_boards( + base_url, settings["username"], settings["password"] + ) - boards_url = f"{base_url.rstrip('/')}/api/v1/boards/" - username = settings["username"] - password = settings["password"] - try: - async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT) as client: +class ProbeStatusRequest(BaseModel): + """Connection probe for a URL that may not be saved in settings yet.""" - async def _do(headers: dict[str, str]) -> httpx.Response: - return await client.get( - boards_url, params={"all": "true"}, headers=headers - ) + url: str - response = await _request_with_auth_fallback( - base_url, username, password, _do - ) - except httpx.RequestError as exc: - logger.warning("InvokeAI boards request failed: %s", exc) - raise HTTPException( - status_code=502, - detail=f"Could not reach InvokeAI backend at {base_url}: {exc}", - ) from exc - if response.status_code >= 400: - raise HTTPException( - status_code=502, - detail=( - f"InvokeAI backend returned {response.status_code}: " - f"{response.text[:200]}" - ), - ) +class ProbeBoardsRequest(BaseModel): + """Board listing for per-album connection values from the album form.""" - try: - raw = response.json() - except ValueError as exc: + url: str + username: str | None = None + password: str | None = None + # Edit flow: the form never receives the stored album password, so it + # sends the album key instead and we look the password up server-side. + album_key: str | None = None + + +@invoke_router.post("/probe_status", dependencies=[Depends(require_no_lock)]) +async def probe_invokeai_status(request: ProbeStatusRequest) -> dict: + """Like ``GET /invokeai/status`` but for an explicit, possibly-unsaved URL. + + Used by the album form to validate a per-album InvokeAI URL before the + album exists. + """ + _validate_invokeai_url(request.url) + return await invokeai_client.check_status(request.url) + + +@invoke_router.post("/probe_boards", dependencies=[Depends(require_no_lock)]) +async def probe_invokeai_boards(request: ProbeBoardsRequest) -> list[dict]: + """Like ``GET /invokeai/boards`` but for explicit connection values. + + Password resolution: an explicit password wins; otherwise fall back to + the named album's stored password, then to the global settings password + when the URL matches the globally-configured backend. + """ + _validate_invokeai_url(request.url) + if not request.url: raise HTTPException( - status_code=502, detail="Boards endpoint did not return JSON" - ) from exc + status_code=400, detail="InvokeAI backend URL is required." + ) - # ``?all=true`` returns a flat list; without it InvokeAI returns - # ``{"items": [...], "offset": ..., "total": ...}``. Handle both shapes - # so an accidentally-paginated response doesn't blank out the dropdown. - items = raw if isinstance(raw, list) else raw.get("items", []) - return [ - { - "board_id": item.get("board_id"), - "board_name": item.get("board_name") or "(unnamed board)", - } - for item in items - if isinstance(item, dict) and item.get("board_id") - ] + username = request.username + password = request.password + # A stored password is only ever paired with its own username: if the + # caller typed a *different* username, the fallbacks don't apply and + # the request proceeds without a password (failing cleanly upstream) + # rather than submitting someone else's credentials. + if not password and request.album_key: + album = config_manager.get_albums().get(request.album_key) + if ( + album is not None + and album.invokeai_password + and (not username or username == album.invokeai_username) + ): + password = album.invokeai_password + username = album.invokeai_username + if not password: + settings = config_manager.get_invokeai_settings() + if ( + settings["url"] + and settings["url"].rstrip("/") == request.url.rstrip("/") + and (not username or username == settings["username"]) + ): + password = settings["password"] + username = settings["username"] + + return await invokeai_client.list_boards(request.url, username, password) def _load_raw_metadata(album_key: str, index: int) -> dict: diff --git a/photomap/frontend/static/css/album-manager.css b/photomap/frontend/static/css/album-manager.css index 066add7f..aaa70164 100644 --- a/photomap/frontend/static/css/album-manager.css +++ b/photomap/frontend/static/css/album-manager.css @@ -140,7 +140,9 @@ border-radius: 8px; margin-bottom: 1em; padding: 1em; - transition: transform 0.2s ease, box-shadow 0.2s ease; + transition: + transform 0.2s ease, + box-shadow 0.2s ease; } .album-card:hover { @@ -325,12 +327,7 @@ left: -100%; width: 100%; height: 100%; - background: linear-gradient( - 90deg, - transparent, - rgba(255, 255, 255, 0.2), - transparent - ); + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); animation: shimmer 2s infinite; } @@ -451,3 +448,56 @@ border-radius: 4px; padding: 4px; } + +/* ── InvokeAI board-album source controls ────────────────────────────── */ + +.album-source-radios { + display: flex; + gap: 1.5em; + flex-wrap: wrap; +} + +.album-source-radios .radio-label { + display: flex; + align-items: center; + gap: 0.4em; + margin-bottom: 0; + font-weight: normal; + color: #fff; +} + +/* Radios/checkboxes must not inherit the 100%-width applied to every + input inside .form-group. */ +.album-source-radios input[type="radio"], +.invoke-board-checklist input[type="checkbox"] { + width: auto; +} + +.invoke-root-row { + display: flex; + align-items: center; + gap: 0.5em; +} + +.invoke-board-checklist { + max-height: 10em; + overflow-y: auto; + border: 1px solid #888; + border-radius: 4px; + background: #444; + padding: 0.5em; +} + +.invoke-board-checklist .board-checkbox-label { + display: flex; + align-items: center; + gap: 0.5em; + margin-bottom: 0.25em; + font-weight: normal; + color: #fff; +} + +.invoke-board-checklist .board-checklist-empty { + color: #bbb; + font-style: italic; +} diff --git a/photomap/frontend/static/css/animations.css b/photomap/frontend/static/css/animations.css index 96ce6b64..bb4b4f6d 100644 --- a/photomap/frontend/static/css/animations.css +++ b/photomap/frontend/static/css/animations.css @@ -34,10 +34,17 @@ } } +/* The max-height keyframes clamp the box while it animates; overflow must + be hidden or content taller than the clamp paints outside the section + mid-animation. album-manager.js removes .slide-down on animationend so + the section returns to natural (unclamped) height once open — the + InvokeAI board form is taller than the 600px the keyframes assume. */ .slide-down { animation: slideDown 0.3s ease forwards; + overflow: hidden; } .slide-up { animation: slideUp 0.3s ease forwards; + overflow: hidden; } diff --git a/photomap/frontend/static/javascript/album-manager.js b/photomap/frontend/static/javascript/album-manager.js index 835e6282..c9eed328 100644 --- a/photomap/frontend/static/javascript/album-manager.js +++ b/photomap/frontend/static/javascript/album-manager.js @@ -1,6 +1,12 @@ // album-management.js import { createSimpleDirectoryPicker } from "./filetree.js"; // Add this import import { getIndexMetadata, removeIndex, updateIndex } from "./index.js"; +import { + collectSelectedBoardIds, + fetchInvokeAIBoards, + probeInvokeAI, + renderBoardChecklist, +} from "./invokeai-album-source.js"; import { exitSearchMode } from "./search-ui.js"; import { closeSettingsModal, loadAvailableAlbums, openSettingsModal } from "./settings.js"; import { setAlbum, state } from "./state.js"; @@ -103,6 +109,17 @@ export class AlbumManager { albumSelect: document.getElementById("albumSelect"), slideshowTitle: document.getElementById("slideshow_title"), albumManagementContent: document.querySelector("#albumManagementContent"), + // InvokeAI board-album source controls (add form) + newAlbumDirectorySection: document.getElementById("newAlbumDirectorySection"), + newAlbumInvokeAISection: document.getElementById("newAlbumInvokeAISection"), + newAlbumInvokeUrl: document.getElementById("newAlbumInvokeUrl"), + newAlbumInvokeRootRow: document.getElementById("newAlbumInvokeRootRow"), + newAlbumInvokeAuth: document.getElementById("newAlbumInvokeAuth"), + newAlbumInvokeUsername: document.getElementById("newAlbumInvokeUsername"), + newAlbumInvokePassword: document.getElementById("newAlbumInvokePassword"), + newAlbumInvokeConnectBtn: document.getElementById("newAlbumInvokeConnectBtn"), + newAlbumInvokeStatusHint: document.getElementById("newAlbumInvokeStatusHint"), + newAlbumInvokeBoards: document.getElementById("newAlbumInvokeBoards"), }; this.progressPollers = new Map(); @@ -156,6 +173,20 @@ export class AlbumManager { this.addAlbum(); }); + // The slide-down entrance animation fills forwards with a 600px + // max-height clamp sized for the directory form. The InvokeAI board + // form is taller, so drop the class once the animation finishes to + // return the section to its natural height. + if (this.addAlbumSection) { + this.addAlbumSection.addEventListener("animationend", (event) => { + if (event.animationName === "slideDown") { + this.addAlbumSection.classList.remove("slide-down"); + } + }); + } + + this.setupNewAlbumSourceControls(); + // Click outside to close this.overlay.addEventListener("click", (e) => { if (e.target === this.overlay) { @@ -277,7 +308,249 @@ export class AlbumManager { description: this.elements.newAlbumDescription.value.trim(), paths: this.collectNewAlbumPathFields(), // Changed this line encoder_spec: this.elements.newAlbumEncoder?.value || DEFAULT_ENCODER_SPEC, + source_type: this.getNewAlbumSourceType(), + invokeai_url: this.elements.newAlbumInvokeUrl?.value.trim() || "", + invokeai_root: this.elements.newAlbumInvokeRootRow?.querySelector(".invoke-root-input")?.value.trim() || "", + invokeai_username: this.elements.newAlbumInvokeUsername?.value.trim() || "", + invokeai_password: this.elements.newAlbumInvokePassword?.value || "", + invokeai_board_ids: collectSelectedBoardIds(this.elements.newAlbumInvokeBoards), + }; + } + + // InvokeAI board-album source controls + getNewAlbumSourceType() { + const checked = document.querySelector('input[name="newAlbumSourceType"]:checked'); + return checked ? checked.value : "directory"; + } + + setupNewAlbumSourceControls() { + document.querySelectorAll('input[name="newAlbumSourceType"]').forEach((radio) => { + radio.addEventListener("change", () => this.toggleNewAlbumSourceSections()); + }); + + // Keep the surfaced settings credentials in sync as the URL changes: + // matching the settings backend shows them, leaving it clears them. + if (this.elements.newAlbumInvokeUrl) { + this.elements.newAlbumInvokeUrl.addEventListener("input", () => this._applySettingsCredentialDefaults()); + } + // A username the user edits by hand is theirs — stop treating it as + // auto-filled so URL changes no longer overwrite or clear it. + if (this.elements.newAlbumInvokeUsername) { + this.elements.newAlbumInvokeUsername.addEventListener("input", () => { + delete this.elements.newAlbumInvokeUsername.dataset.autofilled; + }); + } + + if (this.elements.newAlbumInvokeConnectBtn) { + this.elements.newAlbumInvokeConnectBtn.addEventListener("click", () => { + this.connectAndLoadBoards({ + urlInput: this.elements.newAlbumInvokeUrl, + usernameInput: this.elements.newAlbumInvokeUsername, + passwordInput: this.elements.newAlbumInvokePassword, + authSection: this.elements.newAlbumInvokeAuth, + hintElement: this.elements.newAlbumInvokeStatusHint, + boardsContainer: this.elements.newAlbumInvokeBoards, + selectedIds: collectSelectedBoardIds(this.elements.newAlbumInvokeBoards), + }); + }); + } + } + + toggleNewAlbumSourceSections() { + const isBoard = this.getNewAlbumSourceType() === "invokeai_board"; + if (this.elements.newAlbumDirectorySection) { + this.elements.newAlbumDirectorySection.hidden = isBoard; + } + if (this.elements.newAlbumInvokeAISection) { + this.elements.newAlbumInvokeAISection.hidden = !isBoard; + } + } + + // Build the InvokeAI-root input row: a text input plus the same 📁 + // filetree-picker button used by the image-path rows. + _createInvokeRootRow(container, initialValue = "") { + if (!container) { + return; + } + container.innerHTML = ""; + + const input = document.createElement("input"); + input.type = "text"; + input.className = "invoke-root-input"; + input.value = initialValue; + input.placeholder = "Path to the InvokeAI root directory"; + + const folderBtn = document.createElement("button"); + folderBtn.type = "button"; + folderBtn.className = "open-folder-btn"; + folderBtn.title = "Select folder"; + folderBtn.innerHTML = "📁"; + folderBtn.style.cssText = "background: none; border: none; font-size: 1.2em; cursor: pointer; padding: 4px;"; + folderBtn.onclick = () => { + createSimpleDirectoryPicker( + (selectedPath) => { + input.value = selectedPath; + }, + input.value.trim(), + { showCreateFolder: false } + ); }; + + container.appendChild(input); + container.appendChild(folderBtn); + } + + _setInvokeHint(hintElement, message, isError = false) { + if (!hintElement) { + return; + } + hintElement.textContent = message || ""; + hintElement.style.color = isError ? "#c0392b" : "#7bd47b"; + } + + // Shared probe-then-load-boards flow for the add and edit forms. On + // success the auth section is revealed (mirrors the settings panel: the + // username/password rows only matter once the backend answers) and the + // board checklist is rendered. + async connectAndLoadBoards({ + urlInput, + usernameInput, + passwordInput, + authSection, + hintElement, + boardsContainer, + selectedIds = [], + albumKey = null, + }) { + const url = urlInput?.value.trim(); + if (!url) { + this._setInvokeHint(hintElement, "Enter the InvokeAI backend URL first.", true); + return false; + } + + this._setInvokeHint(hintElement, "Checking connection…"); + let status; + try { + status = await probeInvokeAI(url); + } catch (error) { + this._setInvokeHint(hintElement, error.body?.detail || "Could not contact the PhotoMap backend.", true); + return false; + } + if (!status.reachable) { + this._setInvokeHint(hintElement, status.detail || "InvokeAI backend is not reachable.", true); + if (authSection) { + authSection.hidden = true; + } + return false; + } + + if (authSection) { + authSection.hidden = false; + } + + let boards; + try { + boards = await fetchInvokeAIBoards({ + url, + username: usernameInput?.value.trim() || "", + password: passwordInput?.value || "", + albumKey, + }); + } catch (error) { + this._setInvokeHint( + hintElement, + error.body?.detail || "Connected, but the board list could not be fetched. Check the credentials.", + true + ); + return false; + } + + renderBoardChecklist(boardsContainer, boards, selectedIds); + if (boardsContainer) { + boardsContainer.hidden = false; + // Marks the checklist as authoritative: until a fetch succeeds, the + // edit-save path keeps the album's stored board selection instead of + // trusting a placeholder render. + boardsContainer.dataset.loaded = "true"; + boardsContainer.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + this._setInvokeHint(hintElement, `Connected to InvokeAI ${status.version || ""} — select one or more boards.`); + return true; + } + + async initializeNewAlbumInvokeSection() { + this._setInvokeHint(this.elements.newAlbumInvokeStatusHint, ""); + if (this.elements.newAlbumInvokeAuth) { + this.elements.newAlbumInvokeAuth.hidden = true; + } + if (this.elements.newAlbumInvokeBoards) { + this.elements.newAlbumInvokeBoards.innerHTML = ""; + this.elements.newAlbumInvokeBoards.hidden = true; + } + if (this.elements.newAlbumInvokeUsername) { + this.elements.newAlbumInvokeUsername.value = ""; + delete this.elements.newAlbumInvokeUsername.dataset.autofilled; + } + if (this.elements.newAlbumInvokePassword) { + this.elements.newAlbumInvokePassword.value = ""; + } + this._createInvokeRootRow(this.elements.newAlbumInvokeRootRow); + + // Prefill the URL — and surface the credential fallback — from the + // global InvokeAI settings when available. + this._settingsInvokeDefaults = null; + if (this.elements.newAlbumInvokeUrl) { + this.elements.newAlbumInvokeUrl.value = ""; + try { + const config = await fetchJson("invokeai/config"); + if (config.url) { + this.elements.newAlbumInvokeUrl.value = config.url; + } + this._settingsInvokeDefaults = { + url: config.url || "", + username: config.username || "", + hasPassword: !!config.has_password, + }; + } catch { + // No prefill — the field stays empty. + } + this._applySettingsCredentialDefaults(); + } + } + + // The backend reuses the settings-panel credentials when the album form + // targets the same URL as the settings panel (and only then). Surface + // that: show the stored username and say the saved password will be + // used. When the user points the form at a different backend — where + // stored credentials are never sent — the auto-filled username is + // cleared again. A username the user typed themselves is left alone. + _applySettingsCredentialDefaults() { + const defaults = this._settingsInvokeDefaults; + const urlInput = this.elements.newAlbumInvokeUrl; + const usernameInput = this.elements.newAlbumInvokeUsername; + const passwordInput = this.elements.newAlbumInvokePassword; + if (!urlInput || !usernameInput || !passwordInput) { + return; + } + + const normalize = (url) => (url || "").trim().replace(/\/+$/, ""); + const matchesSettings = !!defaults && !!defaults.url && normalize(urlInput.value) === normalize(defaults.url); + + if (matchesSettings) { + if (defaults.username && (!usernameInput.value || usernameInput.dataset.autofilled === "true")) { + usernameInput.value = defaults.username; + usernameInput.dataset.autofilled = "true"; + } + passwordInput.placeholder = defaults.hasPassword + ? "(password saved in Settings — leave blank to use it)" + : "(optional, multi-user mode)"; + } else { + if (usernameInput.dataset.autofilled === "true") { + usernameInput.value = ""; + delete usernameInput.dataset.autofilled; + } + passwordInput.placeholder = "(optional, multi-user mode)"; + } } clearAddAlbumForm() { @@ -290,6 +563,13 @@ export class AlbumManager { this.elements.newAlbumPathsContainer.innerHTML = ""; } + // Reset the source selector to the directory default + const directoryRadio = document.querySelector('input[name="newAlbumSourceType"][value="directory"]'); + if (directoryRadio) { + directoryRadio.checked = true; + } + this.toggleNewAlbumSourceSections(); + // Reset encoder dropdown to the host-resolved default getServerDefaultEncoderSpec().then((spec) => populateEncoderSelect(this.elements.newAlbumEncoder, spec)); } @@ -303,6 +583,10 @@ export class AlbumManager { // Initialize path fields for the add album form this.initializeNewAlbumPathFields(); + // Reset the InvokeAI source section (prefills the URL from settings) + this.toggleNewAlbumSourceSections(); + this.initializeNewAlbumInvokeSection(); + // Initialize encoder dropdown to the host-resolved default getServerDefaultEncoderSpec().then((spec) => populateEncoderSelect(this.elements.newAlbumEncoder, spec)); @@ -672,8 +956,14 @@ export class AlbumManager { card.querySelector(".album-key").textContent = `Key: ${album.key || "Unknown"}`; card.querySelector(".album-description").textContent = album.description || "No description"; - const imagePaths = album.image_paths || []; - card.querySelector(".album-paths").textContent = `Paths: ${imagePaths.join(", ") || "No paths configured"}`; + if (album.source_type === "invokeai_board") { + const boardCount = (album.invokeai_board_ids || []).length; + card.querySelector(".album-paths").textContent = + `Source: ${boardCount} InvokeAI board${boardCount === 1 ? "" : "s"} @ ${album.invokeai_url || "?"}`; + } else { + const imagePaths = album.image_paths || []; + card.querySelector(".album-paths").textContent = `Paths: ${imagePaths.join(", ") || "No paths configured"}`; + } // Set up event listeners const cardElement = card.querySelector(".album-card"); @@ -742,16 +1032,28 @@ export class AlbumManager { async addAlbum() { const formData = this.getNewAlbumFormData(); + const isBoardAlbum = formData.source_type === "invokeai_board"; // Map field names to their corresponding elements const requiredFields = [ { value: formData.key, element: this.elements.newAlbumKey }, { value: formData.name, element: this.elements.newAlbumName }, - { + ]; + if (isBoardAlbum) { + requiredFields.push( + { value: formData.invokeai_url, element: this.elements.newAlbumInvokeUrl }, + { value: formData.invokeai_root, element: this.elements.newAlbumInvokeRootRow }, + { + value: formData.invokeai_board_ids.length > 0 ? "has boards" : "", + element: this.elements.newAlbumInvokeBoards, + } + ); + } else { + requiredFields.push({ value: formData.paths.length > 0 ? "has paths" : "", element: this.elements.newAlbumPathsContainer, - }, - ]; + }); + } let hasError = false; @@ -765,7 +1067,11 @@ export class AlbumManager { }); if (hasError) { - alert("Please fill in all required fields"); + alert( + isBoardAlbum + ? "Please fill in all required fields and select at least one board" + : "Please fill in all required fields" + ); return; } @@ -778,21 +1084,40 @@ export class AlbumManager { return; } - // Use the collected paths directly - const paths = formData.paths; - - // Always set index path based on first path - const indexPath = paths.length > 0 ? `${paths[0]}/photomap_index/embeddings.npz` : ""; - - const newAlbum = { - key: formData.key, - name: formData.name, - image_paths: paths, - index: indexPath, - umap_eps: 0.1, - description: formData.description, - encoder_spec: formData.encoder_spec, - }; + let newAlbum; + if (isBoardAlbum) { + // image_paths and index are deliberately omitted: the backend derives + // them from the InvokeAI root and the album key. + newAlbum = { + key: formData.key, + name: formData.name, + umap_eps: 0.1, + description: formData.description, + encoder_spec: formData.encoder_spec, + source_type: "invokeai_board", + invokeai_url: formData.invokeai_url, + invokeai_root: formData.invokeai_root, + invokeai_username: formData.invokeai_username || null, + invokeai_password: formData.invokeai_password || null, + invokeai_board_ids: formData.invokeai_board_ids, + }; + } else { + // Use the collected paths directly + const paths = formData.paths; + + // Always set index path based on first path + const indexPath = paths.length > 0 ? `${paths[0]}/photomap_index/embeddings.npz` : ""; + + newAlbum = { + key: formData.key, + name: formData.name, + image_paths: paths, + index: indexPath, + umap_eps: 0.1, + description: formData.description, + encoder_spec: formData.encoder_spec, + }; + } try { await fetchJson("add_album/", { json: newAlbum }); @@ -805,6 +1130,12 @@ export class AlbumManager { async handleSuccessfulAlbumAdd(albumKey) { this.hideAddAlbumForm(); + // Mark the album as auto-indexing BEFORE rebuilding the cards: the + // rebuild's index-metadata probe 404s for the brand-new album and + // fires albumIndexError, whose handler would otherwise start a + // second, racing kickoff (a duplicate POST that 409s, and a progress + // poller bound to a card the rebuild has already detached). + this.autoIndexingAlbums.add(albumKey); await this.loadAlbums(); // Set state.album directly to avoid triggering slideshow before indexing @@ -928,8 +1259,26 @@ export class AlbumManager { editForm.querySelector(".edit-album-name").value = album.name; editForm.querySelector(".edit-album-description").value = album.description || ""; - // Initialize the dynamic path fields for THIS specific card - this.initializePathFields(album.image_paths || [], cardElement); + // Reflect the (immutable) source type and show the matching section + const isBoardAlbum = album.source_type === "invokeai_board"; + editForm.querySelectorAll(".edit-album-source-radio").forEach((radio) => { + radio.checked = radio.value === (album.source_type || "directory"); + }); + const directorySection = editForm.querySelector(".edit-album-directory-section"); + const invokeSection = editForm.querySelector(".edit-album-invokeai-section"); + if (directorySection) { + directorySection.hidden = isBoardAlbum; + } + if (invokeSection) { + invokeSection.hidden = !isBoardAlbum; + } + + if (isBoardAlbum) { + this.populateBoardAlbumEditForm(editForm, album); + } else { + // Initialize the dynamic path fields for THIS specific card + this.initializePathFields(album.image_paths || [], cardElement); + } // Minimum-pixel-dimension gate. Fall back to 256 if the album predates // the field — matches the backend default (Album.min_image_dimension). @@ -961,6 +1310,58 @@ export class AlbumManager { cardElement.scrollIntoView({ behavior: "smooth", block: "end" }); } + // Populate the InvokeAI fields of the edit form and auto-load the board + // checklist with the album's saved selection. + populateBoardAlbumEditForm(editForm, album) { + const urlInput = editForm.querySelector(".edit-album-invoke-url"); + const usernameInput = editForm.querySelector(".edit-album-invoke-username"); + const passwordInput = editForm.querySelector(".edit-album-invoke-password"); + const hintElement = editForm.querySelector(".edit-album-invoke-status-hint"); + const boardsContainer = editForm.querySelector(".edit-album-invoke-boards"); + + if (urlInput) { + urlInput.value = album.invokeai_url || ""; + } + if (usernameInput) { + usernameInput.value = album.invokeai_username || ""; + } + if (passwordInput) { + // Never echo passwords; indicate if one is stored. + passwordInput.value = ""; + passwordInput.placeholder = album.has_invokeai_password + ? "(password saved — leave blank to keep)" + : "(optional, multi-user mode)"; + } + this._createInvokeRootRow(editForm.querySelector(".edit-album-invoke-root-row"), album.invokeai_root || ""); + + const loadBoards = () => + this.connectAndLoadBoards({ + urlInput, + usernameInput, + passwordInput, + authSection: null, + hintElement, + boardsContainer, + selectedIds: collectSelectedBoardIds(boardsContainer).length + ? collectSelectedBoardIds(boardsContainer) + : album.invokeai_board_ids || [], + albumKey: album.key, + }); + + const connectBtn = editForm.querySelector(".edit-album-invoke-connect-btn"); + if (connectBtn) { + connectBtn.onclick = loadBoards; + } + + // Show the saved selection immediately (board names unresolved until + // the fetch returns), then refresh from the backend. + if (boardsContainer) { + delete boardsContainer.dataset.loaded; + } + renderBoardChecklist(boardsContainer, [], album.invokeai_board_ids || []); + loadBoards(); + } + // Path field methods addPathField(path = "", cardElement) { const container = cardElement.querySelector(".edit-album-paths-container"); @@ -992,12 +1393,7 @@ export class AlbumManager { async saveAlbumChanges(cardElement, album) { const editForm = cardElement.querySelector(".edit-form"); - - // Collect paths from dynamic fields for THIS specific card - const updatedPaths = this.collectPathFields(cardElement); - - // Always set index path based on first path - const indexPath = updatedPaths.length > 0 ? `${updatedPaths[0]}/photomap_index/embeddings.npz` : ""; + const isBoardAlbum = album.source_type === "invokeai_board"; // Parse the dimension input. Invalid / non-positive values fall back to // the saved value, then to the backend default (256). The backend's @@ -1011,20 +1407,69 @@ export class AlbumManager { key: album.key, name: editForm.querySelector(".edit-album-name").value, description: editForm.querySelector(".edit-album-description").value, - image_paths: updatedPaths, - index: indexPath, encoder_spec: editForm.querySelector(".edit-album-encoder")?.value || album.encoder_spec || DEFAULT_ENCODER_SPEC, min_image_dimension: minDim, }; - // Compare old and new paths (order and content) - const oldPaths = Array.isArray(album.image_paths) ? album.image_paths : []; - const pathsChanged = oldPaths.length !== updatedPaths.length || oldPaths.some((p, i) => p !== updatedPaths[i]); + let sourceChanged = false; + if (isBoardAlbum) { + const boardsContainer = editForm.querySelector(".edit-album-invoke-boards"); + // Only trust the checklist once a board fetch has succeeded — + // otherwise (e.g. InvokeAI unreachable during this edit) keep the + // album's saved selection. + const boardIds = + boardsContainer?.dataset.loaded === "true" + ? collectSelectedBoardIds(boardsContainer) + : album.invokeai_board_ids || []; + if (boardsContainer?.dataset.loaded === "true" && boardIds.length === 0) { + alert("Please select at least one board"); + return; + } + const url = editForm.querySelector(".edit-album-invoke-url")?.value.trim() || ""; + const root = editForm.querySelector(".edit-album-invoke-root-row .invoke-root-input")?.value.trim() || ""; + if (!url || !root) { + alert("The InvokeAI backend URL and root directory are required"); + return; + } + + updatedAlbum.source_type = "invokeai_board"; + updatedAlbum.invokeai_url = url; + updatedAlbum.invokeai_root = root; + updatedAlbum.invokeai_username = editForm.querySelector(".edit-album-invoke-username")?.value.trim() || null; + // Blank password means "keep the stored one" — omit it entirely. + const password = editForm.querySelector(".edit-album-invoke-password")?.value; + if (password) { + updatedAlbum.invokeai_password = password; + } + updatedAlbum.invokeai_board_ids = boardIds; + // index and image_paths are omitted: the backend keeps the stored + // index and re-derives image_paths from the InvokeAI root. + + const oldBoardIds = album.invokeai_board_ids || []; + sourceChanged = + url !== (album.invokeai_url || "") || + root !== (album.invokeai_root || "") || + oldBoardIds.length !== boardIds.length || + oldBoardIds.some((id) => !boardIds.includes(id)); + } else { + // Collect paths from dynamic fields for THIS specific card + const updatedPaths = this.collectPathFields(cardElement); + + // Always set index path based on first path + const indexPath = updatedPaths.length > 0 ? `${updatedPaths[0]}/photomap_index/embeddings.npz` : ""; + + updatedAlbum.image_paths = updatedPaths; + updatedAlbum.index = indexPath; + + // Compare old and new paths (order and content) + const oldPaths = Array.isArray(album.image_paths) ? album.image_paths : []; + sourceChanged = oldPaths.length !== updatedPaths.length || oldPaths.some((p, i) => p !== updatedPaths[i]); + } try { await fetchJson("update_album/", { json: updatedAlbum }); await this.refreshAlbumsAndDropdown(); - if (pathsChanged) { + if (sourceChanged) { this.send_update_index_event(updatedAlbum.key); } } catch (error) { @@ -1133,6 +1578,16 @@ export class AlbumManager { cancelBtn.style.display = "none"; } + // loadAlbums() rebuilds the card list wholesale, and several flows do + // that while indexing is running (album add, edit save, the auto-index + // event handler). A card element captured at kickoff may therefore be + // detached by the time a poll tick fires — updating it paints a node + // nobody sees while the on-screen card stays frozen. Always resolve the + // album's live card by key, falling back to the captured element. + _liveCardFor(albumKey, fallback = null) { + return document.querySelector(`.album-card[data-album-key="${albumKey}"]`) || fallback; + } + startProgressPolling(albumKey, cardElement) { if (this.progressPollers.has(albumKey)) { console.log(`Already polling progress for album: ${albumKey}`); @@ -1143,18 +1598,19 @@ export class AlbumManager { try { const progress = await fetchJson(`index_progress/${albumKey}`); - this.updateProgress(cardElement, progress); + const liveCard = this._liveCardFor(albumKey, cardElement); + this.updateProgress(liveCard, progress); if (progress.status === "completed" || progress.status === "error") { clearInterval(interval); this.progressPollers.delete(albumKey); if (progress.status === "completed") { - await this.handleIndexingCompletion(albumKey, cardElement); + await this.handleIndexingCompletion(albumKey, liveCard); } setTimeout(() => { - this.hideProgressUI(cardElement); + this.hideProgressUI(this._liveCardFor(albumKey, liveCard)); }, AlbumManager.PROGRESS_HIDE_DELAY); } } catch (error) { diff --git a/photomap/frontend/static/javascript/invokeai-album-source.js b/photomap/frontend/static/javascript/invokeai-album-source.js new file mode 100644 index 00000000..124e92f5 --- /dev/null +++ b/photomap/frontend/static/javascript/invokeai-album-source.js @@ -0,0 +1,62 @@ +// invokeai-album-source.js +// +// Helpers for the "An InvokeAI Image Gallery Board" album source in the +// album manager: probing a (possibly unsaved) backend URL, fetching its +// board list, and rendering/reading the board multi-select checklist. +import { fetchJson } from "./utils.js"; + +// Probe an explicit InvokeAI URL. Returns {reachable, version?, detail?}. +export async function probeInvokeAI(url) { + return await fetchJson("invokeai/probe_status", { json: { url } }); +} + +// Fetch the board list for explicit connection values. `albumKey` lets the +// backend fall back to the album's stored password when the edit form +// leaves the password field blank. +export async function fetchInvokeAIBoards({ url, username, password, albumKey }) { + const body = { url }; + if (username) { + body.username = username; + } + if (password) { + body.password = password; + } + if (albumKey) { + body.album_key = albumKey; + } + return await fetchJson("invokeai/probe_boards", { json: body }); +} + +// Render one checkbox per board into `container`, prepending InvokeAI's +// implicit "Uncategorized" bucket (board_id "none"). `selectedIds` marks +// boxes as checked. +export function renderBoardChecklist(container, boards, selectedIds = []) { + if (!container) { + return; + } + container.innerHTML = ""; + const selected = new Set(selectedIds); + const allBoards = [{ board_id: "none", board_name: "Uncategorized" }, ...(boards || [])]; + allBoards.forEach((board) => { + const label = document.createElement("label"); + label.className = "board-checkbox-label"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.className = "board-checkbox"; + checkbox.value = board.board_id; + checkbox.checked = selected.has(board.board_id); + + label.appendChild(checkbox); + label.appendChild(document.createTextNode(board.board_name)); + container.appendChild(label); + }); +} + +// Read the checked board ids back out of a checklist container. +export function collectSelectedBoardIds(container) { + if (!container) { + return []; + } + return Array.from(container.querySelectorAll(".board-checkbox:checked")).map((checkbox) => checkbox.value); +} diff --git a/photomap/frontend/templates/modules/album-manager.html b/photomap/frontend/templates/modules/album-manager.html index 19af146b..f97e2678 100644 --- a/photomap/frontend/templates/modules/album-manager.html +++ b/photomap/frontend/templates/modules/album-manager.html @@ -3,15 +3,11 @@
+ +
+ + +
+ An album's source type cannot be changed after creation. +
+
+
- +
- Changing the encoder rebuilds the index from scratch on the next - index update. + Changing the encoder rebuilds the index from scratch on the next index update.
diff --git a/tests/backend/test_albums.py b/tests/backend/test_albums.py index a7b8f340..0cc844e7 100644 --- a/tests/backend/test_albums.py +++ b/tests/backend/test_albums.py @@ -378,3 +378,158 @@ def test_min_image_dimension_round_trips(client, tmp_path): assert listing["dim_default"]["min_image_dimension"] == 512 client.delete("/delete_album/dim_default") + + +# ── InvokeAI board-backed albums ────────────────────────────────────────── + + +def _board_album_payload(key="board_album", **overrides): + payload = { + "key": key, + "name": "Board Album", + "description": "Backed by InvokeAI boards", + "source_type": "invokeai_board", + "invokeai_url": "http://localhost:9090", + "invokeai_username": "alice", + "invokeai_password": "secret", + "invokeai_root": "/srv/invokeai", + "invokeai_board_ids": ["b1", "none"], + "encoder_spec": "openai-clip:ViT-B/32", + } + payload.update(overrides) + return payload + + +def test_add_board_album_derives_paths_and_index(client): + """POSTing a board album without index/image_paths derives both.""" + from photomap.backend.config import default_board_index_path + + response = client.post("/add_album/", json=_board_album_payload()) + assert response.status_code == 201, response.text + + try: + manager = get_config_manager() + manager.reload_config() + album = manager.get_album("board_album") + assert album is not None + assert album.source_type == "invokeai_board" + assert album.image_paths == [ + str(Path("/srv/invokeai") / "outputs" / "images") + ] + assert album.index == default_board_index_path("board_album").as_posix() + assert album.invokeai_board_ids == ["b1", "none"] + assert album.invokeai_password == "secret" + finally: + client.delete("/delete_album/board_album") + + +def test_board_album_yaml_round_trip(client): + """All board fields survive a save/reload cycle of the YAML config.""" + response = client.post("/add_album/", json=_board_album_payload()) + assert response.status_code == 201, response.text + try: + manager = get_config_manager() + before = manager.get_album("board_album") + manager.reload_config() + after = manager.get_album("board_album") + assert after == before + finally: + client.delete("/delete_album/board_album") + + +def test_album_endpoints_never_leak_password(client): + """Neither /album/{key}/ nor /available_albums/ may expose the stored + per-album InvokeAI password.""" + response = client.post("/add_album/", json=_board_album_payload()) + assert response.status_code == 201, response.text + try: + single = client.get("/album/board_album/").json() + assert "invokeai_password" not in single + assert single["has_invokeai_password"] is True + assert single["source_type"] == "invokeai_board" + assert single["invokeai_board_ids"] == ["b1", "none"] + + listing = client.get("/available_albums/").json() + entry = next(a for a in listing if a["key"] == "board_album") + assert "invokeai_password" not in entry + assert entry["has_invokeai_password"] is True + assert entry["invokeai_url"] == "http://localhost:9090" + finally: + client.delete("/delete_album/board_album") + + +def test_update_board_album_keeps_password_and_index_when_omitted(client): + """The edit form omits the password (never echoed) and the index — both + must survive an update untouched.""" + from photomap.backend.config import default_board_index_path + + response = client.post("/add_album/", json=_board_album_payload()) + assert response.status_code == 201, response.text + try: + update = { + "key": "board_album", + "name": "Renamed Board Album", + "source_type": "invokeai_board", + "invokeai_url": "http://localhost:9090", + "invokeai_username": "alice", + "invokeai_root": "/srv/invokeai", + "invokeai_board_ids": ["b2"], + } + response = client.post("/update_album/", json=update) + assert response.status_code == 200, response.text + + manager = get_config_manager() + manager.reload_config() + album = manager.get_album("board_album") + assert album.name == "Renamed Board Album" + assert album.invokeai_board_ids == ["b2"] + assert album.invokeai_password == "secret" # kept + assert album.index == default_board_index_path("board_album").as_posix() + finally: + client.delete("/delete_album/board_album") + + +def test_board_album_requires_connection_fields(client): + """Board albums without url/root/board ids are rejected.""" + for missing in ("invokeai_url", "invokeai_root", "invokeai_board_ids"): + payload = _board_album_payload(**{missing: None}) + response = client.post("/add_album/", json=payload) + assert response.status_code >= 400, ( + f"album missing {missing} was accepted: {response.text}" + ) + + +def test_board_album_key_cannot_traverse_paths(): + """Album keys land in a filesystem path — traversal must be rejected.""" + import pytest as _pytest + from pydantic import ValidationError + + from photomap.backend.config import Album, default_board_index_path + + for bad_key in ("../evil", "a/b", "a\\b"): + with _pytest.raises(ValueError): + default_board_index_path(bad_key) + with _pytest.raises(ValidationError): + Album( + key=bad_key, + name="Bad", + source_type="invokeai_board", + invokeai_url="http://localhost:9090", + invokeai_root="/srv/invokeai", + invokeai_board_ids=["b1"], + ) + + +def test_legacy_album_dict_loads_as_directory_album(): + """YAML written before source_type existed must load unchanged.""" + legacy = { + "name": "Old Album", + "image_paths": ["/tmp/somewhere"], + "index": "/tmp/somewhere/embeddings.npz", + } + album = Album.from_dict("old_album", legacy) + assert album.source_type == "directory" + assert album.invokeai_url is None + assert album.invokeai_board_ids == [] + # And directory albums keep their YAML free of InvokeAI keys. + assert not any(k.startswith("invokeai") for k in album.to_dict()) diff --git a/tests/backend/test_invoke_router.py b/tests/backend/test_invoke_router.py index 34a1b364..9f282cca 100644 --- a/tests/backend/test_invoke_router.py +++ b/tests/backend/test_invoke_router.py @@ -548,12 +548,13 @@ def test_recall_sends_cached_token_on_subsequent_requests( # Pre-seed the token cache as though a previous login had succeeded. import time as _time + from photomap.backend import invokeai_client from photomap.backend.routers import invoke as invoke_module - invoke_module._cached_token = "cached-token" - invoke_module._token_expires_at = _time.monotonic() + 3600 - invoke_module._token_base_url = "http://localhost:9090" - invoke_module._token_username = "alice" + invokeai_client._cached_token = "cached-token" + invokeai_client._token_expires_at = _time.monotonic() + 3600 + invokeai_client._token_base_url = "http://localhost:9090" + invokeai_client._token_username = "alice" _install_recall_stub(monkeypatch) @@ -590,13 +591,14 @@ def test_recall_403_with_cached_token_retries_anonymously_and_forgets_token( import time as _time + from photomap.backend import invokeai_client from photomap.backend.routers import invoke as invoke_module # Pre-seed a cached token. - invoke_module._cached_token = "stale-token" - invoke_module._token_expires_at = _time.monotonic() + 3600 - invoke_module._token_base_url = "http://localhost:9090" - invoke_module._token_username = "alice" + invokeai_client._cached_token = "stale-token" + invokeai_client._token_expires_at = _time.monotonic() + 3600 + invokeai_client._token_base_url = "http://localhost:9090" + invokeai_client._token_username = "alice" _install_recall_stub(monkeypatch) @@ -630,7 +632,7 @@ def _route(url, kwargs): assert "Authorization" not in stub.calls[1]["headers"] # Cache must have been cleared. - assert invoke_module._cached_token is None + assert invokeai_client._cached_token is None def test_recall_anonymous_403_is_not_retried( @@ -674,12 +676,13 @@ def test_use_ref_image_403_with_token_retries_anonymously( import time as _time + from photomap.backend import invokeai_client from photomap.backend.routers import invoke as invoke_module - invoke_module._cached_token = "stale-token" - invoke_module._token_expires_at = _time.monotonic() + 3600 - invoke_module._token_base_url = "http://localhost:9090" - invoke_module._token_username = "alice" + invokeai_client._cached_token = "stale-token" + invokeai_client._token_expires_at = _time.monotonic() + 3600 + invokeai_client._token_base_url = "http://localhost:9090" + invokeai_client._token_username = "alice" image_file = tmp_path / "pic.png" image_file.write_bytes(b"\x89PNG\r\n\x1a\nfake") @@ -756,7 +759,7 @@ async def post(self, url, **kwargs): assert "Authorization" not in recall_calls[-1]["headers"] # Cache cleared. - assert invoke_module._cached_token is None + assert invokeai_client._cached_token is None # ── board_id round-trip + /invokeai/status + /invokeai/boards ────────── @@ -1462,3 +1465,215 @@ def test_use_ref_image_rejects_unsafe_queue_id( json={"album_key": "any", "index": 0, "queue_id": bad_queue}, ) assert response.status_code == 422, response.text + + +# ── /invokeai/probe_status and /invokeai/probe_boards ───────────────────── + + +def test_probe_status_reachable(client, monkeypatch): + from photomap.backend.routers import invoke as invoke_module + + stub = _ScriptedClient([_Resp(200, {"version": "5.1.0"})]) + monkeypatch.setattr(invoke_module.httpx, "AsyncClient", stub) + + response = client.post( + "/invokeai/probe_status", json={"url": "http://elsewhere:9090"} + ) + assert response.status_code == 200 + body = response.json() + assert body["reachable"] is True + assert body["version"] == "5.1.0" + # The probe hit the explicit URL, not whatever is stored in settings. + assert stub.calls[0]["url"] == "http://elsewhere:9090/api/v1/app/version" + + +def test_probe_status_unreachable(client, monkeypatch): + from photomap.backend.routers import invoke as invoke_module + + def _raise(url, kwargs): + raise httpx.ConnectError("refused") + + stub = _ScriptedClient([_raise]) + monkeypatch.setattr(invoke_module.httpx, "AsyncClient", stub) + + response = client.post( + "/invokeai/probe_status", json={"url": "http://down:9090"} + ) + assert response.status_code == 200 + body = response.json() + assert body["reachable"] is False + assert "Could not reach" in body["detail"] + + +def test_probe_status_rejects_bad_scheme(client): + response = client.post( + "/invokeai/probe_status", json={"url": "file:///etc/passwd"} + ) + assert response.status_code == 400 + + +def test_probe_boards_returns_board_list(client, clear_invokeai_config, monkeypatch): + from photomap.backend.routers import invoke as invoke_module + + boards = [ + {"board_id": "b1", "board_name": "Portraits"}, + {"board_id": "b2", "board_name": "Landscapes"}, + ] + stub = _ScriptedClient([_Resp(200, boards)]) + monkeypatch.setattr(invoke_module.httpx, "AsyncClient", stub) + + response = client.post( + "/invokeai/probe_boards", json={"url": "http://elsewhere:9090"} + ) + assert response.status_code == 200 + assert response.json() == [ + {"board_id": "b1", "board_name": "Portraits"}, + {"board_id": "b2", "board_name": "Landscapes"}, + ] + assert stub.calls[0]["url"] == "http://elsewhere:9090/api/v1/boards/" + + +def test_probe_boards_logs_in_on_401( + client, clear_invokeai_config, clear_token_cache, monkeypatch +): + """Explicit credentials drive the standard 401 → login → retry dance.""" + from photomap.backend.routers import invoke as invoke_module + + def _route(url, kwargs): + if url.endswith("/api/v1/auth/login"): + return _Resp(200, {"token": "tok-123", "expires_in": 3600}) + headers = kwargs.get("headers") or {} + if headers.get("Authorization") == "Bearer tok-123": + return _Resp(200, [{"board_id": "b1", "board_name": "Mine"}]) + return _Resp(401, {"detail": "auth required"}) + + stub = _ScriptedClient([_route, _route, _route]) + monkeypatch.setattr(invoke_module.httpx, "AsyncClient", stub) + + response = client.post( + "/invokeai/probe_boards", + json={"url": "http://multi:9090", "username": "alice", "password": "pw"}, + ) + assert response.status_code == 200 + assert response.json() == [{"board_id": "b1", "board_name": "Mine"}] + + +def test_probe_boards_falls_back_to_album_password( + client, clear_invokeai_config, clear_token_cache, monkeypatch +): + """With album_key and no explicit password, the stored album credentials + are used for the login retry.""" + from photomap.backend.config import Album + + manager = get_config_manager() + album = Album( + key="probe_board_album", + name="Probe Board Album", + source_type="invokeai_board", + invokeai_url="http://multi:9090", + invokeai_username="bob", + invokeai_password="album-secret", + invokeai_root="/tmp/invokeai", + invokeai_board_ids=["b1"], + ) + assert manager.add_album(album) + + try: + from photomap.backend.routers import invoke as invoke_module + + captured_login = {} + + def _route(url, kwargs): + if url.endswith("/api/v1/auth/login"): + captured_login.update(kwargs.get("json") or {}) + return _Resp(200, {"token": "tok-xyz", "expires_in": 3600}) + headers = kwargs.get("headers") or {} + if headers.get("Authorization") == "Bearer tok-xyz": + return _Resp(200, [{"board_id": "b1", "board_name": "Mine"}]) + return _Resp(401, {"detail": "auth required"}) + + stub = _ScriptedClient([_route, _route, _route]) + monkeypatch.setattr(invoke_module.httpx, "AsyncClient", stub) + + response = client.post( + "/invokeai/probe_boards", + json={"url": "http://multi:9090", "album_key": "probe_board_album"}, + ) + assert response.status_code == 200 + assert captured_login == {"email": "bob", "password": "album-secret"} + finally: + manager.delete_album("probe_board_album") + + +def test_probe_boards_upstream_error_returns_502( + client, clear_invokeai_config, monkeypatch +): + from photomap.backend.routers import invoke as invoke_module + + stub = _ScriptedClient([_Resp(500, text="boom")]) + monkeypatch.setattr(invoke_module.httpx, "AsyncClient", stub) + + response = client.post( + "/invokeai/probe_boards", json={"url": "http://elsewhere:9090"} + ) + assert response.status_code == 502 + + +def test_probe_boards_uses_settings_password_for_matching_username( + client, clear_invokeai_config, clear_token_cache, monkeypatch +): + """When the probed URL matches the settings backend and the username is + the stored one (or omitted), the stored password drives the login.""" + client.post( + "/invokeai/config", + json={"url": "http://localhost:9090", "username": "alice", "password": "pw"}, + ) + + from photomap.backend.routers import invoke as invoke_module + + captured_login = {} + + def _route(url, kwargs): + if url.endswith("/api/v1/auth/login"): + captured_login.update(kwargs.get("json") or {}) + return _Resp(200, {"token": "tok-1", "expires_in": 3600}) + headers = kwargs.get("headers") or {} + if headers.get("Authorization") == "Bearer tok-1": + return _Resp(200, [{"board_id": "b1", "board_name": "Mine"}]) + return _Resp(401, {"detail": "auth required"}) + + stub = _ScriptedClient([_route, _route, _route]) + monkeypatch.setattr(invoke_module.httpx, "AsyncClient", stub) + + response = client.post( + "/invokeai/probe_boards", + json={"url": "http://localhost:9090", "username": "alice"}, + ) + assert response.status_code == 200 + assert captured_login == {"email": "alice", "password": "pw"} + + +def test_probe_boards_never_pairs_settings_password_with_other_username( + client, clear_invokeai_config, clear_token_cache, monkeypatch +): + """A different typed username must NOT borrow the stored password — the + request goes out unauthenticated and the upstream 401 surfaces as 502 + with no login attempt.""" + client.post( + "/invokeai/config", + json={"url": "http://localhost:9090", "username": "alice", "password": "pw"}, + ) + + from photomap.backend.routers import invoke as invoke_module + + stub = _ScriptedClient([_Resp(401, {"detail": "auth required"})]) + monkeypatch.setattr(invoke_module.httpx, "AsyncClient", stub) + + response = client.post( + "/invokeai/probe_boards", + json={"url": "http://localhost:9090", "username": "mallory"}, + ) + assert response.status_code == 502 + # Exactly one upstream call: the boards GET. No login was attempted. + assert len(stub.calls) == 1 + assert stub.calls[0]["url"].endswith("/api/v1/boards/") diff --git a/tests/backend/test_invokeai_board_index.py b/tests/backend/test_invokeai_board_index.py new file mode 100644 index 00000000..87dbd8e8 --- /dev/null +++ b/tests/backend/test_invokeai_board_index.py @@ -0,0 +1,233 @@ +"""Tests for indexing and curating InvokeAI board-backed albums. + +The InvokeAI HTTP API is stubbed at the ``invokeai_client`` layer: a fake +``fetch_board_image_names`` serves a mutable board → image-name mapping, and +the images themselves are UUID-named copies of the bundled test images laid +out under a fake ``/outputs/images`` directory. +""" + +import shutil +import time +import uuid +from pathlib import Path + +import numpy as np +import pytest +from fastapi import HTTPException + +from photomap.backend import invokeai_client + +ALBUM_KEY = "board_index_album" + + +def _index_filenames(index_path: Path) -> set[str]: + data = np.load(index_path, allow_pickle=True) + return {Path(str(f)).name for f in data["filenames"]} + + +def _poll_until(client, album_key, statuses, timeout=60): + start = time.time() + while True: + progress = client.get(f"/index_progress/{album_key}").json() + if progress["status"] in statuses: + return progress + if time.time() - start > timeout: + raise TimeoutError(f"Indexing stuck in status {progress['status']!r}") + time.sleep(0.5) + + +@pytest.fixture +def board_album(client, tmp_path, monkeypatch): + """A board album whose boards are served by a stubbed InvokeAI client. + + Yields a dict with the fake root, the mutable board mapping, and the + album's (explicit, tmp_path-local) index path. The index is passed + explicitly so tests never write into the real per-user data directory. + """ + images_dir = tmp_path / "invokeai" / "outputs" / "images" + images_dir.mkdir(parents=True) + src_images = sorted( + p for p in (Path(__file__).parent / "test_images").iterdir() if p.is_file() + )[:4] + assert len(src_images) == 4, "expected at least 4 bundled test images" + names = [] + for img in src_images: + name = f"{uuid.uuid4()}{img.suffix.lower()}" + shutil.copy(img, images_dir / name) + names.append(name) + + boards = {"b1": list(names)} + + async def fake_fetch(base_url, board_ids, username, password): + merged = [] + for board_id in board_ids: + merged.extend(boards.get(board_id, [])) + return list(dict.fromkeys(merged)) + + monkeypatch.setattr(invokeai_client, "fetch_board_image_names", fake_fetch) + + index_path = tmp_path / "index" / "embeddings.npz" + album = { + "key": ALBUM_KEY, + "name": "Board Index Album", + "source_type": "invokeai_board", + "invokeai_url": "http://localhost:9090", + "invokeai_root": (tmp_path / "invokeai").as_posix(), + "invokeai_board_ids": ["b1"], + "index": index_path.as_posix(), + "encoder_spec": "openai-clip:ViT-B/32", + } + response = client.post("/add_album/", json=album) + assert response.status_code == 201, response.text + + yield { + "album": album, + "boards": boards, + "images_dir": images_dir, + "index_path": index_path, + "src_images": src_images, + } + + client.delete(f"/delete_album/{ALBUM_KEY}") + + +def _build_index(client): + response = client.post("/update_index_async", json={"album_key": ALBUM_KEY}) + assert response.status_code == 202, response.text + progress = _poll_until(client, ALBUM_KEY, {"completed", "error"}) + assert progress["status"] == "completed", progress.get("error_message") + + +def test_board_album_index_contains_board_images(client, board_album): + _build_index(client) + + metadata = client.get(f"/index_metadata/{ALBUM_KEY}").json() + assert metadata["filename_count"] == 4 + assert _index_filenames(board_album["index_path"]) == set( + board_album["boards"]["b1"] + ) + + +def test_board_membership_changes_flow_through_update(client, board_album): + _build_index(client) + + # Drop one image from the board and add a brand-new one. + removed = board_album["boards"]["b1"].pop(0) + new_name = f"{uuid.uuid4()}{board_album['src_images'][0].suffix.lower()}" + shutil.copy( + board_album["src_images"][0], board_album["images_dir"] / new_name + ) + board_album["boards"]["b1"].append(new_name) + + _build_index(client) + + filenames = _index_filenames(board_album["index_path"]) + assert removed not in filenames + assert new_name in filenames + assert len(filenames) == 4 + + +def test_unreachable_invokeai_fails_indexing_and_keeps_old_index( + client, board_album, monkeypatch +): + _build_index(client) + before = board_album["index_path"].read_bytes() + + async def broken_fetch(base_url, board_ids, username, password): + raise HTTPException(status_code=502, detail="connection refused") + + monkeypatch.setattr(invokeai_client, "fetch_board_image_names", broken_fetch) + + response = client.post("/update_index_async", json={"album_key": ALBUM_KEY}) + assert response.status_code == 202 + progress = _poll_until(client, ALBUM_KEY, {"completed", "error"}) + assert progress["status"] == "error" + assert "InvokeAI" in progress["error_message"] + # The failure happened before any write — the old index is untouched. + assert board_album["index_path"].read_bytes() == before + + +def test_wrong_invokeai_root_gives_pointed_error(client, board_album, monkeypatch): + """If none of the board's images exist locally the error must point at + the root directory rather than a generic 'no images found'.""" + shutil.rmtree(board_album["images_dir"]) + + response = client.post("/update_index_async", json={"album_key": ALBUM_KEY}) + assert response.status_code == 202 + progress = _poll_until(client, ALBUM_KEY, {"completed", "error"}) + assert progress["status"] == "error" + assert "root directory" in progress["error_message"] + + +def test_delete_image_routes_through_invokeai(client, board_album, monkeypatch): + _build_index(client) + + captured = {} + + async def fake_delete(base_url, image_name, username, password): + captured["base_url"] = base_url + captured["image_name"] = image_name + + monkeypatch.setattr(invokeai_client, "delete_image", fake_delete) + + # Resolve which file sorted-index 0 refers to before deleting it. + first_file = client.get(f"/retrieve_image/{ALBUM_KEY}/0").json()["filename"] + + response = client.delete(f"/delete_image/{ALBUM_KEY}/0") + assert response.status_code == 200, response.text + + assert captured["base_url"] == "http://localhost:9090" + assert captured["image_name"] == Path(first_file).name + # The local file is NOT touched directly — InvokeAI owns it. + assert (board_album["images_dir"] / Path(first_file).name).exists() + # But the index row is gone. + metadata = client.get(f"/index_metadata/{ALBUM_KEY}").json() + assert metadata["filename_count"] == 3 + assert Path(first_file).name not in _index_filenames(board_album["index_path"]) + + +def test_delete_image_failure_leaves_index_intact(client, board_album, monkeypatch): + _build_index(client) + + async def broken_delete(base_url, image_name, username, password): + raise HTTPException(status_code=502, detail="backend down") + + monkeypatch.setattr(invokeai_client, "delete_image", broken_delete) + + response = client.delete(f"/delete_image/{ALBUM_KEY}/0") + assert response.status_code == 502 + + metadata = client.get(f"/index_metadata/{ALBUM_KEY}").json() + assert metadata["filename_count"] == 4 + + +def test_move_images_rejected_for_board_albums(client, board_album, tmp_path): + _build_index(client) + + target = tmp_path / "elsewhere" + target.mkdir() + response = client.post( + f"/move_images/{ALBUM_KEY}", + json={"indices": [0], "target_directory": target.as_posix()}, + ) + assert response.status_code == 400 + assert "not supported" in response.json()["detail"] + + +def test_describe_image_source_keeps_logs_compact(): + """Board albums feed thousands of explicit file paths into indexing; + log lines must summarize them, not dump the whole list.""" + from photomap.backend.embeddings import describe_image_source + + # Single directory and short lists print verbatim. + assert describe_image_source(Path("/photos/vacation")) == "/photos/vacation" + assert ( + describe_image_source([Path("/photos/a"), Path("/photos/b")]) + == "/photos/a, /photos/b" + ) + + # Long explicit lists collapse to a count + common parent. + many = [Path(f"/root/outputs/images/{i:04d}.png") for i in range(3666)] + description = describe_image_source(many) + assert description == "3666 explicit paths under /root/outputs/images" + assert len(description) < 120 diff --git a/tests/frontend/invokeai-album-source.test.js b/tests/frontend/invokeai-album-source.test.js new file mode 100644 index 00000000..2e30feaf --- /dev/null +++ b/tests/frontend/invokeai-album-source.test.js @@ -0,0 +1,483 @@ +/** + * Tests for the InvokeAI board-album source: the invokeai-album-source.js + * helpers plus the AlbumManager form paths that build add/update payloads. + * + * album-manager.js pulls in a large sibling graph whose modules touch the DOM + * at import time, so the direct imports are mocked and the modules under test + * are loaded dynamically — the same pattern as album-manager-progress.test.js. + */ +import { beforeAll, beforeEach, describe, expect, jest, test } from "@jest/globals"; + +const M = "../../photomap/frontend/static/javascript"; + +const fetchJson = jest.fn(); + +jest.unstable_mockModule(`${M}/filetree.js`, () => ({ + createSimpleDirectoryPicker: jest.fn(), +})); +jest.unstable_mockModule(`${M}/index.js`, () => ({ + getIndexMetadata: jest.fn(), + removeIndex: jest.fn(), + updateIndex: jest.fn(), +})); +jest.unstable_mockModule(`${M}/search-ui.js`, () => ({ + exitSearchMode: jest.fn(), +})); +jest.unstable_mockModule(`${M}/settings.js`, () => ({ + closeSettingsModal: jest.fn(), + loadAvailableAlbums: jest.fn(), + openSettingsModal: jest.fn(), +})); +jest.unstable_mockModule(`${M}/state.js`, () => ({ + setAlbum: jest.fn(), + state: {}, +})); +jest.unstable_mockModule(`${M}/utils.js`, () => ({ + fetchJson, + hideSpinner: jest.fn(), + showSpinner: jest.fn(), +})); + +let AlbumManager; +let probeInvokeAI; +let fetchInvokeAIBoards; +let renderBoardChecklist; +let collectSelectedBoardIds; + +beforeAll(async () => { + // album-manager.js instantiates `new AlbumManager()` at module load and + // wires click handlers on these buttons without null-guards. + document.body.innerHTML = + `
` + + ["addAlbumBtn", "cancelAddAlbumBtn", "cancelAddAlbumBtn2", "closeAlbumManagementBtn", "showAddAlbumBtn"] + .map((id) => ``) + .join(""); + + ({ probeInvokeAI, fetchInvokeAIBoards, renderBoardChecklist, collectSelectedBoardIds } = await import( + `${M}/invokeai-album-source.js` + )); + ({ AlbumManager } = await import(`${M}/album-manager.js`)); +}); + +beforeEach(() => { + fetchJson.mockReset(); +}); + +describe("invokeai-album-source helpers", () => { + test("probeInvokeAI posts the explicit URL", async () => { + fetchJson.mockResolvedValue({ reachable: true, version: "5.1.0" }); + const result = await probeInvokeAI("http://elsewhere:9090"); + expect(fetchJson).toHaveBeenCalledWith("invokeai/probe_status", { + json: { url: "http://elsewhere:9090" }, + }); + expect(result.reachable).toBe(true); + }); + + test("fetchInvokeAIBoards omits empty credentials and maps albumKey", async () => { + fetchJson.mockResolvedValue([]); + await fetchInvokeAIBoards({ url: "http://x:9090", username: "", password: "", albumKey: "my_album" }); + expect(fetchJson).toHaveBeenCalledWith("invokeai/probe_boards", { + json: { url: "http://x:9090", album_key: "my_album" }, + }); + + await fetchInvokeAIBoards({ url: "http://x:9090", username: "alice", password: "pw" }); + expect(fetchJson).toHaveBeenLastCalledWith("invokeai/probe_boards", { + json: { url: "http://x:9090", username: "alice", password: "pw" }, + }); + }); + + test("renderBoardChecklist prepends Uncategorized and preselects ids", () => { + const container = document.createElement("div"); + renderBoardChecklist( + container, + [ + { board_id: "b1", board_name: "Portraits" }, + { board_id: "b2", board_name: "Landscapes" }, + ], + ["b2", "none"] + ); + + const checkboxes = Array.from(container.querySelectorAll(".board-checkbox")); + expect(checkboxes.map((c) => c.value)).toEqual(["none", "b1", "b2"]); + expect(container.textContent).toContain("Uncategorized"); + expect(checkboxes.map((c) => c.checked)).toEqual([true, false, true]); + + expect(collectSelectedBoardIds(container)).toEqual(["none", "b2"]); + }); +}); + +// Build the add-album form DOM the AlbumManager methods read from. +function buildAddAlbumDom({ sourceType, boardIds = [] } = {}) { + document.body.innerHTML = ` + + + + + +
+
+ +
+
+
+ +
+ + +
+ ${boardIds + .map((id) => ``) + .join("")} +
+
+ + `; + + const elements = {}; + [ + "newAlbumKey", + "newAlbumName", + "newAlbumDescription", + "newAlbumPathsContainer", + "newAlbumEncoder", + "newAlbumDirectorySection", + "newAlbumInvokeAISection", + "newAlbumInvokeUrl", + "newAlbumInvokeRootRow", + "newAlbumInvokeUsername", + "newAlbumInvokePassword", + "newAlbumInvokeBoards", + ].forEach((id) => { + elements[id] = document.getElementById(id); + }); + return elements; +} + +function makeManagerStub(elements) { + return { + elements, + getNewAlbumFormData: AlbumManager.prototype.getNewAlbumFormData, + getNewAlbumSourceType: AlbumManager.prototype.getNewAlbumSourceType, + collectNewAlbumPathFields: AlbumManager.prototype.collectNewAlbumPathFields, + toggleNewAlbumSourceSections: AlbumManager.prototype.toggleNewAlbumSourceSections, + fetchAvailableAlbums: jest.fn().mockResolvedValue([]), + handleSuccessfulAlbumAdd: jest.fn(), + }; +} + +describe("AlbumManager add-album payloads", () => { + test("board-source albums omit index/image_paths and carry InvokeAI fields", async () => { + const elements = buildAddAlbumDom({ sourceType: "invokeai_board", boardIds: ["b1", "none"] }); + const manager = makeManagerStub(elements); + fetchJson.mockResolvedValue({ success: true }); + + await AlbumManager.prototype.addAlbum.call(manager); + + expect(fetchJson).toHaveBeenCalledTimes(1); + const [route, options] = fetchJson.mock.calls[0]; + expect(route).toBe("add_album/"); + const payload = options.json; + expect(payload).not.toHaveProperty("index"); + expect(payload).not.toHaveProperty("image_paths"); + expect(payload.source_type).toBe("invokeai_board"); + expect(payload.invokeai_url).toBe("http://localhost:9090"); + expect(payload.invokeai_root).toBe("/srv/invokeai"); + expect(payload.invokeai_username).toBe("alice"); + expect(payload.invokeai_password).toBe("secret"); + expect(payload.invokeai_board_ids).toEqual(["b1", "none"]); + expect(manager.handleSuccessfulAlbumAdd).toHaveBeenCalledWith("my_album"); + }); + + test("directory albums keep the original payload shape (regression)", async () => { + const elements = buildAddAlbumDom({ sourceType: "directory" }); + const manager = makeManagerStub(elements); + fetchJson.mockResolvedValue({ success: true }); + + await AlbumManager.prototype.addAlbum.call(manager); + + const payload = fetchJson.mock.calls[0][1].json; + expect(payload.image_paths).toEqual(["/photos/vacation"]); + expect(payload.index).toBe("/photos/vacation/photomap_index/embeddings.npz"); + expect(payload).not.toHaveProperty("source_type"); + expect(payload).not.toHaveProperty("invokeai_url"); + }); + + test("board source without a selected board blocks submission", async () => { + const elements = buildAddAlbumDom({ sourceType: "invokeai_board", boardIds: [] }); + const manager = makeManagerStub(elements); + window.alert = jest.fn(); + + await AlbumManager.prototype.addAlbum.call(manager); + + expect(fetchJson).not.toHaveBeenCalled(); + expect(window.alert).toHaveBeenCalled(); + }); +}); + +describe("AlbumManager settings-credential surfacing", () => { + function makeInvokeSectionStub() { + document.body.innerHTML = ` + + + + + +
+ + `; + const elements = {}; + [ + "newAlbumInvokeUrl", + "newAlbumInvokeUsername", + "newAlbumInvokePassword", + "newAlbumInvokeAuth", + "newAlbumInvokeBoards", + "newAlbumInvokeRootRow", + "newAlbumInvokeStatusHint", + ].forEach((id) => { + elements[id] = document.getElementById(id); + }); + return { + elements, + _setInvokeHint: AlbumManager.prototype._setInvokeHint, + _createInvokeRootRow: AlbumManager.prototype._createInvokeRootRow, + _applySettingsCredentialDefaults: AlbumManager.prototype._applySettingsCredentialDefaults, + initializeNewAlbumInvokeSection: AlbumManager.prototype.initializeNewAlbumInvokeSection, + }; + } + + test("prefills URL and username from settings and flags the saved password", async () => { + const manager = makeInvokeSectionStub(); + fetchJson.mockResolvedValue({ + url: "http://localhost:9090", + username: "alice", + has_password: true, + board_id: "", + }); + + await manager.initializeNewAlbumInvokeSection(); + + expect(manager.elements.newAlbumInvokeUrl.value).toBe("http://localhost:9090"); + expect(manager.elements.newAlbumInvokeUsername.value).toBe("alice"); + expect(manager.elements.newAlbumInvokePassword.placeholder).toContain("saved in Settings"); + }); + + test("repointing the URL at a different backend clears the auto-filled username", async () => { + const manager = makeInvokeSectionStub(); + fetchJson.mockResolvedValue({ + url: "http://localhost:9090", + username: "alice", + has_password: true, + board_id: "", + }); + await manager.initializeNewAlbumInvokeSection(); + + manager.elements.newAlbumInvokeUrl.value = "http://other-host:9090"; + manager._applySettingsCredentialDefaults(); + expect(manager.elements.newAlbumInvokeUsername.value).toBe(""); + expect(manager.elements.newAlbumInvokePassword.placeholder).not.toContain("saved in Settings"); + + // Returning to the settings URL restores the surfaced credentials. + manager.elements.newAlbumInvokeUrl.value = "http://localhost:9090/"; + manager._applySettingsCredentialDefaults(); + expect(manager.elements.newAlbumInvokeUsername.value).toBe("alice"); + expect(manager.elements.newAlbumInvokePassword.placeholder).toContain("saved in Settings"); + }); + + test("a hand-typed username is never overwritten or cleared", async () => { + const manager = makeInvokeSectionStub(); + fetchJson.mockResolvedValue({ + url: "http://localhost:9090", + username: "alice", + has_password: true, + board_id: "", + }); + await manager.initializeNewAlbumInvokeSection(); + + // Simulate the user replacing the username (the input listener drops + // the autofilled marker on real keystrokes). + manager.elements.newAlbumInvokeUsername.value = "bob"; + delete manager.elements.newAlbumInvokeUsername.dataset.autofilled; + + manager.elements.newAlbumInvokeUrl.value = "http://other-host:9090"; + manager._applySettingsCredentialDefaults(); + expect(manager.elements.newAlbumInvokeUsername.value).toBe("bob"); + + manager.elements.newAlbumInvokeUrl.value = "http://localhost:9090"; + manager._applySettingsCredentialDefaults(); + expect(manager.elements.newAlbumInvokeUsername.value).toBe("bob"); + }); +}); + +describe("AlbumManager indexing-progress robustness", () => { + test("progress poller follows the live card after a rebuild detaches the original", async () => { + // loadAlbums() rebuilds all cards mid-indexing (album add/edit flows); + // the poller must update the album's *current* card, not the detached + // one captured at kickoff — that zombie binding froze the UI at + // "Indexing in progress..." while the backend completed. + jest.useFakeTimers(); + try { + document.body.innerHTML = `
`; + const detachedCard = document.createElement("div"); + const manager = { + progressPollers: new Map(), + _liveCardFor: AlbumManager.prototype._liveCardFor, + updateProgress: jest.fn(), + handleIndexingCompletion: jest.fn(), + hideProgressUI: jest.fn(), + }; + fetchJson.mockResolvedValue({ + status: "indexing", + current_step: "Processing x.png", + progress_percentage: 50, + images_processed: 1, + total_images: 2, + }); + + AlbumManager.prototype.startProgressPolling.call(manager, "k", detachedCard); + await jest.advanceTimersByTimeAsync(1100); + + expect(manager.updateProgress).toHaveBeenCalledWith(document.getElementById("liveCard"), expect.anything()); + clearInterval(manager.progressPollers.get("k")); + } finally { + jest.useRealTimers(); + } + }); + + test("freshly added albums are marked auto-indexing before the card rebuild", async () => { + // The rebuild's 404 metadata probe fires albumIndexError; the guard set + // must already contain the new album or a second kickoff races the + // first (duplicate POST → 409 alert, poller bound to a detached card). + const autoIndexing = new Set(); + const setDuringRebuild = []; + const manager = { + autoIndexingAlbums: autoIndexing, + isSetupMode: false, + hideAddAlbumForm: jest.fn(), + loadAlbums: jest.fn(async () => { + setDuringRebuild.push(autoIndexing.has("k")); + }), + startAutoIndexing: jest.fn(), + }; + + await AlbumManager.prototype.handleSuccessfulAlbumAdd.call(manager, "k"); + + expect(setDuringRebuild).toEqual([true]); + expect(manager.startAutoIndexing).toHaveBeenCalledWith("k"); + }); +}); + +describe("AlbumManager add-form entrance animation", () => { + test("slide-down clamp is dropped when the entrance animation ends", () => { + // The slideDown keyframes fill forwards with a 600px max-height clamp + // sized for the directory form; the constructor must remove the class + // on animationend or the taller InvokeAI board form overflows and the + // Add Album button becomes unreachable. + document.body.innerHTML = + `
` + + `
` + + ["addAlbumBtn", "cancelAddAlbumBtn", "cancelAddAlbumBtn2", "closeAlbumManagementBtn", "showAddAlbumBtn"] + .map((id) => ``) + .join(""); + new AlbumManager(); + + const section = document.getElementById("addAlbumSection"); + section.dispatchEvent(Object.assign(new Event("animationend"), { animationName: "slideDown" })); + expect(section.classList.contains("slide-down")).toBe(false); + + // The exit animation's class must survive its own animationend. + section.classList.add("slide-up"); + section.dispatchEvent(Object.assign(new Event("animationend"), { animationName: "slideUp" })); + expect(section.classList.contains("slide-up")).toBe(true); + }); +}); + +describe("AlbumManager source-section toggle", () => { + test("radio selection shows the matching section", () => { + const elements = buildAddAlbumDom({ sourceType: "directory" }); + const manager = makeManagerStub(elements); + + AlbumManager.prototype.toggleNewAlbumSourceSections.call(manager); + expect(elements.newAlbumDirectorySection.hidden).toBe(false); + expect(elements.newAlbumInvokeAISection.hidden).toBe(true); + + document.querySelector('input[name="newAlbumSourceType"][value="invokeai_board"]').checked = true; + AlbumManager.prototype.toggleNewAlbumSourceSections.call(manager); + expect(elements.newAlbumDirectorySection.hidden).toBe(true); + expect(elements.newAlbumInvokeAISection.hidden).toBe(false); + }); +}); + +describe("AlbumManager edit-save payloads for board albums", () => { + function buildEditDom({ loaded = true, boardIds = ["b1"] } = {}) { + document.body.innerHTML = ` +
+
+ + + + + +
+ + +
+ ${boardIds + .map((id) => ``) + .join("")} +
+
+
+ `; + return document.querySelector(".album-card"); + } + + const album = { + key: "board_album", + source_type: "invokeai_board", + invokeai_url: "http://localhost:9090", + invokeai_root: "/srv/invokeai", + invokeai_board_ids: ["b1", "b2"], + has_invokeai_password: true, + }; + + function makeEditStub() { + return { + refreshAlbumsAndDropdown: jest.fn().mockResolvedValue(undefined), + send_update_index_event: jest.fn(), + collectPathFields: AlbumManager.prototype.collectPathFields, + }; + } + + test("blank password is omitted and index/image_paths are not sent", async () => { + const card = buildEditDom(); + const manager = makeEditStub(); + fetchJson.mockResolvedValue({ success: true }); + + await AlbumManager.prototype.saveAlbumChanges.call(manager, card, album); + + const payload = fetchJson.mock.calls[0][1].json; + expect(payload).not.toHaveProperty("invokeai_password"); + expect(payload).not.toHaveProperty("index"); + expect(payload).not.toHaveProperty("image_paths"); + expect(payload.invokeai_board_ids).toEqual(["b1"]); + // Board selection changed (b2 dropped) → re-index event fires. + expect(manager.send_update_index_event).toHaveBeenCalledWith("board_album"); + }); + + test("unloaded board checklist falls back to the saved selection", async () => { + const card = buildEditDom({ loaded: false, boardIds: [] }); + const manager = makeEditStub(); + fetchJson.mockResolvedValue({ success: true }); + + await AlbumManager.prototype.saveAlbumChanges.call(manager, card, album); + + const payload = fetchJson.mock.calls[0][1].json; + expect(payload.invokeai_board_ids).toEqual(["b1", "b2"]); + expect(manager.send_update_index_event).not.toHaveBeenCalled(); + }); +}); From 49e1f6a558e26f5438c4585a92bf2c24a32f4dac Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Thu, 11 Jun 2026 22:45:24 -0400 Subject: [PATCH 2/2] test: make describe_image_source test separator-agnostic for Windows The assertions hardcoded POSIX separators, but str(Path(...)) and os.path.commonpath use backslashes on Windows. Build the expected strings from the same Path round-trips instead of literals. Co-Authored-By: Claude Fable 5 --- tests/backend/test_invokeai_board_index.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/backend/test_invokeai_board_index.py b/tests/backend/test_invokeai_board_index.py index 87dbd8e8..9335f6ce 100644 --- a/tests/backend/test_invokeai_board_index.py +++ b/tests/backend/test_invokeai_board_index.py @@ -216,18 +216,21 @@ def test_move_images_rejected_for_board_albums(client, board_album, tmp_path): def test_describe_image_source_keeps_logs_compact(): """Board albums feed thousands of explicit file paths into indexing; - log lines must summarize them, not dump the whole list.""" + log lines must summarize them, not dump the whole list. + + Expectations are built from Path/str round-trips rather than literals so + the test holds on Windows, where str(Path(...)) uses backslashes. + """ from photomap.backend.embeddings import describe_image_source - # Single directory and short lists print verbatim. - assert describe_image_source(Path("/photos/vacation")) == "/photos/vacation" - assert ( - describe_image_source([Path("/photos/a"), Path("/photos/b")]) - == "/photos/a, /photos/b" - ) + # Single directory and short lists print verbatim (native separators). + vacation = Path("/photos/vacation") + assert describe_image_source(vacation) == str(vacation) + short = [Path("/photos/a"), Path("/photos/b")] + assert describe_image_source(short) == f"{short[0]}, {short[1]}" # Long explicit lists collapse to a count + common parent. many = [Path(f"/root/outputs/images/{i:04d}.png") for i in range(3666)] description = describe_image_source(many) - assert description == "3666 explicit paths under /root/outputs/images" + assert description == f"3666 explicit paths under {Path('/root/outputs/images')}" assert len(description) < 120