diff --git a/invokeai/app/api/routers/boards.py b/invokeai/app/api/routers/boards.py index 926c0f7fd22..d5b1acb5144 100644 --- a/invokeai/app/api/routers/boards.py +++ b/invokeai/app/api/routers/boards.py @@ -5,7 +5,7 @@ from pydantic import BaseModel, Field from invokeai.app.api.dependencies import ApiDependencies -from invokeai.app.services.board_records.board_records_common import BoardChanges +from invokeai.app.services.board_records.board_records_common import BoardChanges, UncategorizedImageCounts from invokeai.app.services.boards.boards_common import BoardDTO from invokeai.app.services.shared.pagination import OffsetPaginatedResults @@ -146,3 +146,14 @@ async def list_all_board_image_names( board_id, ) return image_names + + +@boards_router.get( + "/uncategorized/counts", + operation_id="get_uncategorized_image_counts", + response_model=UncategorizedImageCounts, +) +async def get_uncategorized_image_counts() -> UncategorizedImageCounts: + """Gets count of images and assets for uncategorized images (images with no board assocation)""" + + return ApiDependencies.invoker.services.board_records.get_uncategorized_image_counts() diff --git a/invokeai/app/services/board_records/board_records_base.py b/invokeai/app/services/board_records/board_records_base.py index 9d16dacf60b..7bfe6ada6fd 100644 --- a/invokeai/app/services/board_records/board_records_base.py +++ b/invokeai/app/services/board_records/board_records_base.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod -from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecord +from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecord, UncategorizedImageCounts from invokeai.app.services.shared.pagination import OffsetPaginatedResults @@ -48,3 +48,8 @@ def get_many( def get_all(self, include_archived: bool = False) -> list[BoardRecord]: """Gets all board records.""" pass + + @abstractmethod + def get_uncategorized_image_counts(self) -> UncategorizedImageCounts: + """Gets count of images and assets for uncategorized images (images with no board assocation).""" + pass diff --git a/invokeai/app/services/board_records/board_records_common.py b/invokeai/app/services/board_records/board_records_common.py index 0dda8a8b6b6..3478746536f 100644 --- a/invokeai/app/services/board_records/board_records_common.py +++ b/invokeai/app/services/board_records/board_records_common.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Optional, Union +from typing import Any, Optional, Union from pydantic import BaseModel, Field @@ -26,21 +26,25 @@ class BoardRecord(BaseModelExcludeNull): """Whether or not the board is archived.""" is_private: Optional[bool] = Field(default=None, description="Whether the board is private.") """Whether the board is private.""" + image_count: int = Field(description="The number of images in the board.") + asset_count: int = Field(description="The number of assets in the board.") -def deserialize_board_record(board_dict: dict) -> BoardRecord: +def deserialize_board_record(board_dict: dict[str, Any]) -> BoardRecord: """Deserializes a board record.""" # Retrieve all the values, setting "reasonable" defaults if they are not present. board_id = board_dict.get("board_id", "unknown") board_name = board_dict.get("board_name", "unknown") - cover_image_name = board_dict.get("cover_image_name", "unknown") + cover_image_name = board_dict.get("cover_image_name", None) created_at = board_dict.get("created_at", get_iso_timestamp()) updated_at = board_dict.get("updated_at", get_iso_timestamp()) deleted_at = board_dict.get("deleted_at", get_iso_timestamp()) archived = board_dict.get("archived", False) is_private = board_dict.get("is_private", False) + image_count = board_dict.get("image_count", 0) + asset_count = board_dict.get("asset_count", 0) return BoardRecord( board_id=board_id, @@ -51,6 +55,8 @@ def deserialize_board_record(board_dict: dict) -> BoardRecord: deleted_at=deleted_at, archived=archived, is_private=is_private, + image_count=image_count, + asset_count=asset_count, ) @@ -63,19 +69,24 @@ class BoardChanges(BaseModel, extra="forbid"): class BoardRecordNotFoundException(Exception): """Raised when an board record is not found.""" - def __init__(self, message="Board record not found"): + def __init__(self, message: str = "Board record not found"): super().__init__(message) class BoardRecordSaveException(Exception): """Raised when an board record cannot be saved.""" - def __init__(self, message="Board record not saved"): + def __init__(self, message: str = "Board record not saved"): super().__init__(message) class BoardRecordDeleteException(Exception): """Raised when an board record cannot be deleted.""" - def __init__(self, message="Board record not deleted"): + def __init__(self, message: str = "Board record not deleted"): super().__init__(message) + + +class UncategorizedImageCounts(BaseModel): + image_count: int = Field(description="The number of uncategorized images.") + asset_count: int = Field(description="The number of uncategorized assets.") diff --git a/invokeai/app/services/board_records/board_records_sqlite.py b/invokeai/app/services/board_records/board_records_sqlite.py index c64e060b953..10f0e283863 100644 --- a/invokeai/app/services/board_records/board_records_sqlite.py +++ b/invokeai/app/services/board_records/board_records_sqlite.py @@ -1,5 +1,6 @@ import sqlite3 import threading +from dataclasses import dataclass from typing import Union, cast from invokeai.app.services.board_records.board_records_base import BoardRecordStorageBase @@ -9,12 +10,108 @@ BoardRecordDeleteException, BoardRecordNotFoundException, BoardRecordSaveException, + UncategorizedImageCounts, deserialize_board_record, ) from invokeai.app.services.shared.pagination import OffsetPaginatedResults from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase from invokeai.app.util.misc import uuid_string +BASE_BOARD_RECORD_QUERY = """ + -- This query retrieves board records, joining with the board_images and images tables to get image counts and cover image names. + -- It is not a complete query, as it is missing a GROUP BY or WHERE clause (and is unterminated). + SELECT b.board_id, + b.board_name, + b.created_at, + b.updated_at, + b.archived, + -- Count the number of images in the board, alias image_count + COUNT( + CASE + WHEN i.image_category in ('general') -- "Images" are images in the 'general' category + AND i.is_intermediate = 0 THEN 1 -- Intermediates are not counted + END + ) AS image_count, + -- Count the number of assets in the board, alias asset_count + COUNT( + CASE + WHEN i.image_category in ('control', 'mask', 'user', 'other') -- "Assets" are images in any of the other categories ('control', 'mask', 'user', 'other') + AND i.is_intermediate = 0 THEN 1 -- Intermediates are not counted + END + ) AS asset_count, + -- Get the name of the the most recent image in the board, alias cover_image_name + ( + SELECT bi.image_name + FROM board_images bi + JOIN images i ON bi.image_name = i.image_name + WHERE bi.board_id = b.board_id + AND i.is_intermediate = 0 -- Intermediates cannot be cover images + ORDER BY i.created_at DESC -- Sort by created_at to get the most recent image + LIMIT 1 + ) AS cover_image_name + FROM boards b + LEFT JOIN board_images bi ON b.board_id = bi.board_id + LEFT JOIN images i ON bi.image_name = i.image_name + """ + + +@dataclass +class PaginatedBoardRecordsQueries: + main_query: str + total_count_query: str + + +def get_paginated_list_board_records_queries(include_archived: bool) -> PaginatedBoardRecordsQueries: + """Gets a query to retrieve a paginated list of board records.""" + + archived_condition = "WHERE b.archived = 0" if not include_archived else "" + + # The GROUP BY must be added _after_ the WHERE clause! + main_query = f""" + {BASE_BOARD_RECORD_QUERY} + {archived_condition} + GROUP BY b.board_id, + b.board_name, + b.created_at, + b.updated_at + ORDER BY b.created_at DESC + LIMIT ? OFFSET ?; + """ + + total_count_query = f""" + SELECT COUNT(*) + FROM boards b + {archived_condition}; + """ + + return PaginatedBoardRecordsQueries(main_query=main_query, total_count_query=total_count_query) + + +def get_list_all_board_records_query(include_archived: bool) -> str: + """Gets a query to retrieve all board records.""" + + archived_condition = "WHERE b.archived = 0" if not include_archived else "" + + # The GROUP BY must be added _after_ the WHERE clause! + return f""" + {BASE_BOARD_RECORD_QUERY} + {archived_condition} + GROUP BY b.board_id, + b.board_name, + b.created_at, + b.updated_at + ORDER BY b.created_at DESC; + """ + + +def get_board_record_query() -> str: + """Gets a query to retrieve a board record.""" + + return f""" + {BASE_BOARD_RECORD_QUERY} + WHERE b.board_id = ?; + """ + class SqliteBoardRecordStorage(BoardRecordStorageBase): _conn: sqlite3.Connection @@ -76,11 +173,7 @@ def get( try: self._lock.acquire() self._cursor.execute( - """--sql - SELECT * - FROM boards - WHERE board_id = ?; - """, + get_board_record_query(), (board_id,), ) @@ -92,7 +185,7 @@ def get( self._lock.release() if result is None: raise BoardRecordNotFoundException - return BoardRecord(**dict(result)) + return deserialize_board_record(dict(result)) def update( self, @@ -149,45 +242,17 @@ def get_many( try: self._lock.acquire() - # Build base query - base_query = """ - SELECT * - FROM boards - {archived_filter} - ORDER BY created_at DESC - LIMIT ? OFFSET ?; - """ - - # Determine archived filter condition - if include_archived: - archived_filter = "" - else: - archived_filter = "WHERE archived = 0" - - final_query = base_query.format(archived_filter=archived_filter) + queries = get_paginated_list_board_records_queries(include_archived=include_archived) - # Execute query to fetch boards - self._cursor.execute(final_query, (limit, offset)) + self._cursor.execute( + queries.main_query, + (limit, offset), + ) result = cast(list[sqlite3.Row], self._cursor.fetchall()) boards = [deserialize_board_record(dict(r)) for r in result] - # Determine count query - if include_archived: - count_query = """ - SELECT COUNT(*) - FROM boards; - """ - else: - count_query = """ - SELECT COUNT(*) - FROM boards - WHERE archived = 0; - """ - - # Execute count query - self._cursor.execute(count_query) - + self._cursor.execute(queries.total_count_query) count = cast(int, self._cursor.fetchone()[0]) return OffsetPaginatedResults[BoardRecord](items=boards, offset=offset, limit=limit, total=count) @@ -201,26 +266,9 @@ def get_many( def get_all(self, include_archived: bool = False) -> list[BoardRecord]: try: self._lock.acquire() - - base_query = """ - SELECT * - FROM boards - {archived_filter} - ORDER BY created_at DESC - """ - - if include_archived: - archived_filter = "" - else: - archived_filter = "WHERE archived = 0" - - final_query = base_query.format(archived_filter=archived_filter) - - self._cursor.execute(final_query) - + self._cursor.execute(get_list_all_board_records_query(include_archived=include_archived)) result = cast(list[sqlite3.Row], self._cursor.fetchall()) boards = [deserialize_board_record(dict(r)) for r in result] - return boards except sqlite3.Error as e: @@ -228,3 +276,28 @@ def get_all(self, include_archived: bool = False) -> list[BoardRecord]: raise e finally: self._lock.release() + + def get_uncategorized_image_counts(self) -> UncategorizedImageCounts: + try: + self._lock.acquire() + query = """ + -- Get the count of uncategorized images and assets. + SELECT + CASE + WHEN i.image_category = 'general' THEN 'image_count' -- "Images" are images in the 'general' category + ELSE 'asset_count' -- "Assets" are images in any of the other categories ('control', 'mask', 'user', 'other') + END AS category_type, + COUNT(*) AS unassigned_count + FROM images i + LEFT JOIN board_images bi ON i.image_name = bi.image_name + WHERE bi.board_id IS NULL -- Uncategorized images have no board association + AND i.is_intermediate = 0 -- Omit intermediates from the counts + GROUP BY category_type; -- Group by category_type alias, as derived from the image_category column earlier + """ + self._cursor.execute(query) + results = self._cursor.fetchall() + image_count = dict(results)["image_count"] + asset_count = dict(results)["asset_count"] + return UncategorizedImageCounts(image_count=image_count, asset_count=asset_count) + finally: + self._lock.release() diff --git a/invokeai/app/services/boards/boards_common.py b/invokeai/app/services/boards/boards_common.py index 15d0b3c37f5..1e9337a3edf 100644 --- a/invokeai/app/services/boards/boards_common.py +++ b/invokeai/app/services/boards/boards_common.py @@ -1,23 +1,8 @@ -from typing import Optional - -from pydantic import Field - from invokeai.app.services.board_records.board_records_common import BoardRecord +# TODO(psyche): BoardDTO is now identical to BoardRecord. We should consider removing it. class BoardDTO(BoardRecord): - """Deserialized board record with cover image URL and image count.""" - - cover_image_name: Optional[str] = Field(description="The name of the board's cover image.") - """The URL of the thumbnail of the most recent image in the board.""" - image_count: int = Field(description="The number of images in the board.") - """The number of images in the board.""" - + """Deserialized board record.""" -def board_record_to_dto(board_record: BoardRecord, cover_image_name: Optional[str], image_count: int) -> BoardDTO: - """Converts a board record to a board DTO.""" - return BoardDTO( - **board_record.model_dump(exclude={"cover_image_name"}), - cover_image_name=cover_image_name, - image_count=image_count, - ) + pass diff --git a/invokeai/app/services/boards/boards_default.py b/invokeai/app/services/boards/boards_default.py index 97fd3059a93..abf38e8ea71 100644 --- a/invokeai/app/services/boards/boards_default.py +++ b/invokeai/app/services/boards/boards_default.py @@ -1,6 +1,6 @@ from invokeai.app.services.board_records.board_records_common import BoardChanges from invokeai.app.services.boards.boards_base import BoardServiceABC -from invokeai.app.services.boards.boards_common import BoardDTO, board_record_to_dto +from invokeai.app.services.boards.boards_common import BoardDTO from invokeai.app.services.invoker import Invoker from invokeai.app.services.shared.pagination import OffsetPaginatedResults @@ -16,17 +16,11 @@ def create( board_name: str, ) -> BoardDTO: board_record = self.__invoker.services.board_records.save(board_name) - return board_record_to_dto(board_record, None, 0) + return BoardDTO.model_validate(board_record.model_dump()) def get_dto(self, board_id: str) -> BoardDTO: board_record = self.__invoker.services.board_records.get(board_id) - cover_image = self.__invoker.services.image_records.get_most_recent_image_for_board(board_record.board_id) - if cover_image: - cover_image_name = cover_image.image_name - else: - cover_image_name = None - image_count = self.__invoker.services.board_image_records.get_image_count_for_board(board_id) - return board_record_to_dto(board_record, cover_image_name, image_count) + return BoardDTO.model_validate(board_record.model_dump()) def update( self, @@ -34,14 +28,7 @@ def update( changes: BoardChanges, ) -> BoardDTO: board_record = self.__invoker.services.board_records.update(board_id, changes) - cover_image = self.__invoker.services.image_records.get_most_recent_image_for_board(board_record.board_id) - if cover_image: - cover_image_name = cover_image.image_name - else: - cover_image_name = None - - image_count = self.__invoker.services.board_image_records.get_image_count_for_board(board_id) - return board_record_to_dto(board_record, cover_image_name, image_count) + return BoardDTO.model_validate(board_record.model_dump()) def delete(self, board_id: str) -> None: self.__invoker.services.board_records.delete(board_id) @@ -50,30 +37,10 @@ def get_many( self, offset: int = 0, limit: int = 10, include_archived: bool = False ) -> OffsetPaginatedResults[BoardDTO]: board_records = self.__invoker.services.board_records.get_many(offset, limit, include_archived) - board_dtos = [] - for r in board_records.items: - cover_image = self.__invoker.services.image_records.get_most_recent_image_for_board(r.board_id) - if cover_image: - cover_image_name = cover_image.image_name - else: - cover_image_name = None - - image_count = self.__invoker.services.board_image_records.get_image_count_for_board(r.board_id) - board_dtos.append(board_record_to_dto(r, cover_image_name, image_count)) - + board_dtos = [BoardDTO.model_validate(r.model_dump()) for r in board_records.items] return OffsetPaginatedResults[BoardDTO](items=board_dtos, offset=offset, limit=limit, total=len(board_dtos)) def get_all(self, include_archived: bool = False) -> list[BoardDTO]: board_records = self.__invoker.services.board_records.get_all(include_archived) - board_dtos = [] - for r in board_records: - cover_image = self.__invoker.services.image_records.get_most_recent_image_for_board(r.board_id) - if cover_image: - cover_image_name = cover_image.image_name - else: - cover_image_name = None - - image_count = self.__invoker.services.board_image_records.get_image_count_for_board(r.board_id) - board_dtos.append(board_record_to_dto(r, cover_image_name, image_count)) - + board_dtos = [BoardDTO.model_validate(r.model_dump()) for r in board_records] return board_dtos diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts index 2fc6210397b..9ddbb7ed373 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts @@ -13,7 +13,6 @@ import { import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useExecutionState'; import { zNodeStatus } from 'features/nodes/types/invocation'; import { CANVAS_OUTPUT } from 'features/nodes/util/graph/constants'; -import { boardsApi } from 'services/api/endpoints/boards'; import { imagesApi } from 'services/api/endpoints/images'; import { getCategories, getListImagesUrl } from 'services/api/util'; import { socketInvocationComplete } from 'services/events/actions'; @@ -52,14 +51,6 @@ export const addInvocationCompleteEventListener = (startAppListening: AppStartLi } if (!imageDTO.is_intermediate) { - // update the total images for the board - dispatch( - boardsApi.util.updateQueryData('getBoardImagesTotal', imageDTO.board_id ?? 'none', (draft) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - draft.total += 1; - }) - ); - dispatch( imagesApi.util.invalidateTags([ { type: 'Board', id: imageDTO.board_id ?? 'none' }, diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardTotalsTooltip.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardTotalsTooltip.tsx index b4c89a002df..01d6c226dd1 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardTotalsTooltip.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardTotalsTooltip.tsx @@ -1,22 +1,12 @@ import { useTranslation } from 'react-i18next'; -import { useGetBoardAssetsTotalQuery, useGetBoardImagesTotalQuery } from 'services/api/endpoints/boards'; type Props = { - board_id: string; + imageCount: number; + assetCount: number; isArchived: boolean; }; -export const BoardTotalsTooltip = ({ board_id, isArchived }: Props) => { +export const BoardTotalsTooltip = ({ imageCount, assetCount, isArchived }: Props) => { const { t } = useTranslation(); - const { imagesTotal } = useGetBoardImagesTotalQuery(board_id, { - selectFromResult: ({ data }) => { - return { imagesTotal: data?.total ?? 0 }; - }, - }); - const { assetsTotal } = useGetBoardAssetsTotalQuery(board_id, { - selectFromResult: ({ data }) => { - return { assetsTotal: data?.total ?? 0 }; - }, - }); - return `${t('boards.imagesWithCount', { count: imagesTotal })}, ${t('boards.assetsWithCount', { count: assetsTotal })}${isArchived ? ` (${t('boards.archived')})` : ''}`; + return `${t('boards.imagesWithCount', { count: imageCount })}, ${t('boards.assetsWithCount', { count: assetCount })}${isArchived ? ` (${t('boards.archived')})` : ''}`; }; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx index 8f348b5c415..584372d70ad 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx @@ -116,7 +116,13 @@ const GalleryBoard = ({ board, isSelected, setBoardToDelete }: GalleryBoardProps {(ref) => ( } + label={ + + } openDelay={1000} placement="left" closeOnScroll @@ -166,7 +172,7 @@ const GalleryBoard = ({ board, isSelected, setBoardToDelete }: GalleryBoardProps {autoAddBoardId === board.board_id && !editingDisclosure.isOpen && } {board.archived && !editingDisclosure.isOpen && } - {!editingDisclosure.isOpen && {board.image_count}} + {!editingDisclosure.isOpen && {board.image_count + board.asset_count}} {t('unifiedCanvas.move')}} /> diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx index 14bf3d5742d..5ab53b78d58 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx @@ -9,7 +9,7 @@ import NoBoardBoardContextMenu from 'features/gallery/components/Boards/NoBoardB import { autoAddBoardIdChanged, boardIdSelected } from 'features/gallery/store/gallerySlice'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { useGetBoardImagesTotalQuery } from 'services/api/endpoints/boards'; +import { useGetUncategorizedImageCountsQuery } from 'services/api/endpoints/boards'; import { useBoardName } from 'services/api/hooks/useBoardName'; interface Props { @@ -22,11 +22,7 @@ const _hover: SystemStyleObject = { const NoBoardBoard = memo(({ isSelected }: Props) => { const dispatch = useAppDispatch(); - const { imagesTotal } = useGetBoardImagesTotalQuery('none', { - selectFromResult: ({ data }) => { - return { imagesTotal: data?.total ?? 0 }; - }, - }); + const { data } = useGetUncategorizedImageCountsQuery(); const autoAddBoardId = useAppSelector((s) => s.gallery.autoAddBoardId); const autoAssignBoardOnClick = useAppSelector((s) => s.gallery.autoAssignBoardOnClick); const boardSearchText = useAppSelector((s) => s.gallery.boardSearchText); @@ -60,7 +56,13 @@ const NoBoardBoard = memo(({ isSelected }: Props) => { {(ref) => ( } + label={ + + } openDelay={1000} placement="left" closeOnScroll @@ -99,7 +101,7 @@ const NoBoardBoard = memo(({ isSelected }: Props) => { {boardName} {autoAddBoardId === 'none' && } - {imagesTotal} + {(data?.image_count ?? 0) + (data?.asset_count ?? 0)} {t('unifiedCanvas.move')}} /> diff --git a/invokeai/frontend/web/src/services/api/endpoints/boards.ts b/invokeai/frontend/web/src/services/api/endpoints/boards.ts index 55ebeab3188..2b33a0a603f 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/boards.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/boards.ts @@ -1,12 +1,4 @@ -import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types'; -import type { - BoardDTO, - CreateBoardArg, - ListBoardsArgs, - OffsetPaginatedResults_ImageDTO_, - UpdateBoardArg, -} from 'services/api/types'; -import { getListImagesUrl } from 'services/api/util'; +import type { BoardDTO, CreateBoardArg, ListBoardsArgs, S, UpdateBoardArg } from 'services/api/types'; import type { ApiTagDescription } from '..'; import { api, buildV1Url, LIST_TAG } from '..'; @@ -55,38 +47,11 @@ export const boardsApi = api.injectEndpoints({ keepUnusedDataFor: 0, }), - getBoardImagesTotal: build.query<{ total: number }, string | undefined>({ - query: (board_id) => ({ - url: getListImagesUrl({ - board_id: board_id ?? 'none', - categories: IMAGE_CATEGORIES, - is_intermediate: false, - limit: 0, - offset: 0, - }), - method: 'GET', + getUncategorizedImageCounts: build.query({ + query: () => ({ + url: buildBoardsUrl('uncategorized/counts'), }), - providesTags: (result, error, arg) => [{ type: 'BoardImagesTotal', id: arg ?? 'none' }, 'FetchOnReconnect'], - transformResponse: (response: OffsetPaginatedResults_ImageDTO_) => { - return { total: response.total }; - }, - }), - - getBoardAssetsTotal: build.query<{ total: number }, string | undefined>({ - query: (board_id) => ({ - url: getListImagesUrl({ - board_id: board_id ?? 'none', - categories: ASSETS_CATEGORIES, - is_intermediate: false, - limit: 0, - offset: 0, - }), - method: 'GET', - }), - providesTags: (result, error, arg) => [{ type: 'BoardAssetsTotal', id: arg ?? 'none' }, 'FetchOnReconnect'], - transformResponse: (response: OffsetPaginatedResults_ImageDTO_) => { - return { total: response.total }; - }, + providesTags: ['UncategorizedImageCounts', { type: 'Board', id: LIST_TAG }, { type: 'Board', id: 'none' }], }), /** @@ -124,9 +89,8 @@ export const boardsApi = api.injectEndpoints({ export const { useListAllBoardsQuery, - useGetBoardImagesTotalQuery, - useGetBoardAssetsTotalQuery, useCreateBoardMutation, useUpdateBoardMutation, useListAllImageNamesForBoardQuery, + useGetUncategorizedImageCountsQuery, } = boardsApi; diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts index 79ea662717b..e9db2b4caae 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -44,6 +44,7 @@ const tagTypes = [ // This is invalidated on reconnect. It should be used for queries that have changing data, // especially related to the queue and generation. 'FetchOnReconnect', + 'UncategorizedImageCounts', ] as const; export type ApiTagDescription = TagDescription<(typeof tagTypes)[number]>; export const LIST_TAG = 'LIST'; diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 7780ccbdc85..2fa360140f3 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -333,6 +333,13 @@ export type paths = { */ get: operations["list_all_board_image_names"]; }; + "/api/v1/boards/uncategorized/counts": { + /** + * Get Uncategorized Image Counts + * @description Gets count of images and assets for uncategorized images (images with no board assocation) + */ + get: operations["get_uncategorized_image_counts"]; + }; "/api/v1/board_images/": { /** * Add Image To Board @@ -1020,7 +1027,7 @@ export type components = { }; /** * BoardDTO - * @description Deserialized board record with cover image URL and image count. + * @description Deserialized board record. */ BoardDTO: { /** @@ -1050,9 +1057,9 @@ export type components = { deleted_at?: string | null; /** * Cover Image Name - * @description The name of the board's cover image. + * @description The name of the cover image of the board. */ - cover_image_name: string | null; + cover_image_name?: string | null; /** * Archived * @description Whether or not the board is archived. @@ -1068,6 +1075,11 @@ export type components = { * @description The number of images in the board. */ image_count: number; + /** + * Asset Count + * @description The number of assets in the board. + */ + asset_count: number; }; /** * BoardField @@ -7304,145 +7316,145 @@ export type components = { project_id: string | null; }; InvocationOutputMap: { - rectangle_mask: components["schemas"]["MaskOutput"]; - hed_image_processor: components["schemas"]["ImageOutput"]; - compel: components["schemas"]["ConditioningOutput"]; - img_resize: components["schemas"]["ImageOutput"]; - ideal_size: components["schemas"]["IdealSizeOutput"]; - rand_int: components["schemas"]["IntegerOutput"]; + img_channel_offset: components["schemas"]["ImageOutput"]; + metadata: components["schemas"]["MetadataOutput"]; clip_skip: components["schemas"]["CLIPSkipInvocationOutput"]; - string_collection: components["schemas"]["StringCollectionOutput"]; - create_gradient_mask: components["schemas"]["GradientMaskOutput"]; - round_float: components["schemas"]["FloatOutput"]; - scheduler: components["schemas"]["SchedulerOutput"]; - main_model_loader: components["schemas"]["ModelLoaderOutput"]; - string_split: components["schemas"]["String2Output"]; - mask_from_id: components["schemas"]["ImageOutput"]; - collect: components["schemas"]["CollectInvocationOutput"]; - heuristic_resize: components["schemas"]["ImageOutput"]; - tomask: components["schemas"]["ImageOutput"]; - boolean_collection: components["schemas"]["BooleanCollectionOutput"]; - core_metadata: components["schemas"]["MetadataOutput"]; - canny_image_processor: components["schemas"]["ImageOutput"]; - string_replace: components["schemas"]["StringOutput"]; - face_mask_detection: components["schemas"]["FaceMaskOutput"]; - integer: components["schemas"]["IntegerOutput"]; - img_watermark: components["schemas"]["ImageOutput"]; - img_crop: components["schemas"]["ImageOutput"]; - t2i_adapter: components["schemas"]["T2IAdapterOutput"]; - create_denoise_mask: components["schemas"]["DenoiseMaskOutput"]; + canvas_paste_back: components["schemas"]["ImageOutput"]; + seamless: components["schemas"]["SeamlessModeOutput"]; + blank_image: components["schemas"]["ImageOutput"]; + dynamic_prompt: components["schemas"]["StringCollectionOutput"]; + step_param_easing: components["schemas"]["FloatCollectionOutput"]; + latents_collection: components["schemas"]["LatentsCollectionOutput"]; + normalbae_image_processor: components["schemas"]["ImageOutput"]; rand_float: components["schemas"]["FloatOutput"]; + lora_loader: components["schemas"]["LoRALoaderOutput"]; + collect: components["schemas"]["CollectInvocationOutput"]; + infill_rgba: components["schemas"]["ImageOutput"]; + img_lerp: components["schemas"]["ImageOutput"]; + integer_math: components["schemas"]["IntegerOutput"]; + conditioning_collection: components["schemas"]["ConditioningCollectionOutput"]; + mask_from_id: components["schemas"]["ImageOutput"]; + mlsd_image_processor: components["schemas"]["ImageOutput"]; zoe_depth_image_processor: components["schemas"]["ImageOutput"]; - face_off: components["schemas"]["FaceOffOutput"]; + ideal_size: components["schemas"]["IdealSizeOutput"]; + conditioning: components["schemas"]["ConditioningOutput"]; + img_resize: components["schemas"]["ImageOutput"]; + integer_collection: components["schemas"]["IntegerCollectionOutput"]; + float_range: components["schemas"]["FloatCollectionOutput"]; tile_to_properties: components["schemas"]["TileToPropertiesOutput"]; + alpha_mask_to_tensor: components["schemas"]["MaskOutput"]; + img_watermark: components["schemas"]["ImageOutput"]; + merge_tiles_to_image: components["schemas"]["ImageOutput"]; + merge_metadata: components["schemas"]["MetadataOutput"]; + round_float: components["schemas"]["FloatOutput"]; + denoise_latents: components["schemas"]["LatentsOutput"]; + string_join_three: components["schemas"]["StringOutput"]; + img_blur: components["schemas"]["ImageOutput"]; color_map_image_processor: components["schemas"]["ImageOutput"]; - lineart_anime_image_processor: components["schemas"]["ImageOutput"]; - face_identifier: components["schemas"]["ImageOutput"]; - float_math: components["schemas"]["FloatOutput"]; - mediapipe_face_processor: components["schemas"]["ImageOutput"]; - img_channel_multiply: components["schemas"]["ImageOutput"]; - metadata_item: components["schemas"]["MetadataItemOutput"]; - img_ilerp: components["schemas"]["ImageOutput"]; - conditioning: components["schemas"]["ConditioningOutput"]; - pidi_image_processor: components["schemas"]["ImageOutput"]; - seamless: components["schemas"]["SeamlessModeOutput"]; - latents: components["schemas"]["LatentsOutput"]; - img_chan: components["schemas"]["ImageOutput"]; + img_scale: components["schemas"]["ImageOutput"]; + infill_tile: components["schemas"]["ImageOutput"]; + add: components["schemas"]["IntegerOutput"]; + img_paste: components["schemas"]["ImageOutput"]; + img_crop: components["schemas"]["ImageOutput"]; + cv_inpaint: components["schemas"]["ImageOutput"]; + image_collection: components["schemas"]["ImageCollectionOutput"]; + img_pad_crop: components["schemas"]["ImageOutput"]; + canny_image_processor: components["schemas"]["ImageOutput"]; model_identifier: components["schemas"]["ModelIdentifierOutput"]; - noise: components["schemas"]["NoiseOutput"]; - string_join: components["schemas"]["StringOutput"]; - blank_image: components["schemas"]["ImageOutput"]; - calculate_image_tiles: components["schemas"]["CalculateImageTilesOutput"]; - invert_tensor_mask: components["schemas"]["MaskOutput"]; - save_image: components["schemas"]["ImageOutput"]; - unsharp_mask: components["schemas"]["ImageOutput"]; + i2l: components["schemas"]["LatentsOutput"]; + face_mask_detection: components["schemas"]["FaceMaskOutput"]; + img_channel_multiply: components["schemas"]["ImageOutput"]; + sdxl_model_loader: components["schemas"]["SDXLModelLoaderOutput"]; + img_mul: components["schemas"]["ImageOutput"]; + tomask: components["schemas"]["ImageOutput"]; image_mask_to_tensor: components["schemas"]["MaskOutput"]; - step_param_easing: components["schemas"]["FloatCollectionOutput"]; - merge_tiles_to_image: components["schemas"]["ImageOutput"]; - integer_collection: components["schemas"]["IntegerCollectionOutput"]; - calculate_image_tiles_even_split: components["schemas"]["CalculateImageTilesOutput"]; - integer_math: components["schemas"]["IntegerOutput"]; - range: components["schemas"]["IntegerCollectionOutput"]; - prompt_from_file: components["schemas"]["StringCollectionOutput"]; - segment_anything_processor: components["schemas"]["ImageOutput"]; - freeu: components["schemas"]["UNetOutput"]; - sub: components["schemas"]["IntegerOutput"]; - lresize: components["schemas"]["LatentsOutput"]; - float: components["schemas"]["FloatOutput"]; - float_collection: components["schemas"]["FloatCollectionOutput"]; - dynamic_prompt: components["schemas"]["StringCollectionOutput"]; - infill_lama: components["schemas"]["ImageOutput"]; + face_identifier: components["schemas"]["ImageOutput"]; + noise: components["schemas"]["NoiseOutput"]; l2i: components["schemas"]["ImageOutput"]; - img_lerp: components["schemas"]["ImageOutput"]; + mul: components["schemas"]["IntegerOutput"]; + sub: components["schemas"]["IntegerOutput"]; + main_model_loader: components["schemas"]["ModelLoaderOutput"]; + controlnet: components["schemas"]["ControlOutput"]; ip_adapter: components["schemas"]["IPAdapterOutput"]; - lora_collection_loader: components["schemas"]["LoRALoaderOutput"]; - color: components["schemas"]["ColorOutput"]; - tiled_multi_diffusion_denoise_latents: components["schemas"]["LatentsOutput"]; - cv_inpaint: components["schemas"]["ImageOutput"]; lscale: components["schemas"]["LatentsOutput"]; - string: components["schemas"]["StringOutput"]; + sdxl_lora_collection_loader: components["schemas"]["SDXLLoRALoaderOutput"]; + latents: components["schemas"]["LatentsOutput"]; + string_split: components["schemas"]["String2Output"]; sdxl_refiner_compel_prompt: components["schemas"]["ConditioningOutput"]; - string_join_three: components["schemas"]["StringOutput"]; - midas_depth_image_processor: components["schemas"]["ImageOutput"]; esrgan: components["schemas"]["ImageOutput"]; - sdxl_refiner_model_loader: components["schemas"]["SDXLRefinerModelLoaderOutput"]; - mul: components["schemas"]["IntegerOutput"]; - normalbae_image_processor: components["schemas"]["ImageOutput"]; - infill_rgba: components["schemas"]["ImageOutput"]; - sdxl_model_loader: components["schemas"]["SDXLModelLoaderOutput"]; - vae_loader: components["schemas"]["VAEOutput"]; - float_to_int: components["schemas"]["IntegerOutput"]; + dw_openpose_image_processor: components["schemas"]["ImageOutput"]; + compel: components["schemas"]["ConditioningOutput"]; + sdxl_lora_loader: components["schemas"]["SDXLLoRALoaderOutput"]; + sdxl_compel_prompt: components["schemas"]["ConditioningOutput"]; + tile_image_processor: components["schemas"]["ImageOutput"]; + mediapipe_face_processor: components["schemas"]["ImageOutput"]; + metadata_item: components["schemas"]["MetadataItemOutput"]; + float_math: components["schemas"]["FloatOutput"]; + prompt_from_file: components["schemas"]["StringCollectionOutput"]; + pidi_image_processor: components["schemas"]["ImageOutput"]; + content_shuffle_image_processor: components["schemas"]["ImageOutput"]; + lineart_anime_image_processor: components["schemas"]["ImageOutput"]; + t2i_adapter: components["schemas"]["T2IAdapterOutput"]; + integer: components["schemas"]["IntegerOutput"]; + unsharp_mask: components["schemas"]["ImageOutput"]; + range: components["schemas"]["IntegerCollectionOutput"]; + string: components["schemas"]["StringOutput"]; + show_image: components["schemas"]["ImageOutput"]; + image: components["schemas"]["ImageOutput"]; + heuristic_resize: components["schemas"]["ImageOutput"]; + div: components["schemas"]["IntegerOutput"]; + rand_int: components["schemas"]["IntegerOutput"]; + float: components["schemas"]["FloatOutput"]; + img_conv: components["schemas"]["ImageOutput"]; + mask_combine: components["schemas"]["ImageOutput"]; + random_range: components["schemas"]["IntegerCollectionOutput"]; + boolean_collection: components["schemas"]["BooleanCollectionOutput"]; + pair_tile_image: components["schemas"]["PairTileImageOutput"]; + save_image: components["schemas"]["ImageOutput"]; lora_selector: components["schemas"]["LoRASelectorOutput"]; - crop_latents: components["schemas"]["LatentsOutput"]; - img_mul: components["schemas"]["ImageOutput"]; - float_range: components["schemas"]["FloatCollectionOutput"]; - merge_metadata: components["schemas"]["MetadataOutput"]; - img_blur: components["schemas"]["ImageOutput"]; boolean: components["schemas"]["BooleanOutput"]; - tile_image_processor: components["schemas"]["ImageOutput"]; - mlsd_image_processor: components["schemas"]["ImageOutput"]; + tiled_multi_diffusion_denoise_latents: components["schemas"]["LatentsOutput"]; + rectangle_mask: components["schemas"]["MaskOutput"]; + lineart_image_processor: components["schemas"]["ImageOutput"]; + midas_depth_image_processor: components["schemas"]["ImageOutput"]; + img_nsfw: components["schemas"]["ImageOutput"]; infill_patchmatch: components["schemas"]["ImageOutput"]; - img_pad_crop: components["schemas"]["ImageOutput"]; - leres_image_processor: components["schemas"]["ImageOutput"]; - sdxl_lora_loader: components["schemas"]["SDXLLoRALoaderOutput"]; - dw_openpose_image_processor: components["schemas"]["ImageOutput"]; - img_scale: components["schemas"]["ImageOutput"]; - pair_tile_image: components["schemas"]["PairTileImageOutput"]; - lblend: components["schemas"]["LatentsOutput"]; - range_of_size: components["schemas"]["IntegerCollectionOutput"]; - image_collection: components["schemas"]["ImageCollectionOutput"]; - calculate_image_tiles_min_overlap: components["schemas"]["CalculateImageTilesOutput"]; - img_channel_offset: components["schemas"]["ImageOutput"]; - alpha_mask_to_tensor: components["schemas"]["MaskOutput"]; + infill_lama: components["schemas"]["ImageOutput"]; infill_cv2: components["schemas"]["ImageOutput"]; - mask_combine: components["schemas"]["ImageOutput"]; + float_to_int: components["schemas"]["IntegerOutput"]; + color: components["schemas"]["ColorOutput"]; + lora_collection_loader: components["schemas"]["LoRALoaderOutput"]; + vae_loader: components["schemas"]["VAEOutput"]; string_split_neg: components["schemas"]["StringPosNegOutput"]; - sdxl_lora_collection_loader: components["schemas"]["SDXLLoRALoaderOutput"]; - lineart_image_processor: components["schemas"]["ImageOutput"]; - img_nsfw: components["schemas"]["ImageOutput"]; - image: components["schemas"]["ImageOutput"]; - content_shuffle_image_processor: components["schemas"]["ImageOutput"]; - canvas_paste_back: components["schemas"]["ImageOutput"]; - iterate: components["schemas"]["IterateInvocationOutput"]; - div: components["schemas"]["IntegerOutput"]; - latents_collection: components["schemas"]["LatentsCollectionOutput"]; - img_conv: components["schemas"]["ImageOutput"]; + lresize: components["schemas"]["LatentsOutput"]; + string_collection: components["schemas"]["StringCollectionOutput"]; + invert_tensor_mask: components["schemas"]["MaskOutput"]; + depth_anything_image_processor: components["schemas"]["ImageOutput"]; + hed_image_processor: components["schemas"]["ImageOutput"]; + leres_image_processor: components["schemas"]["ImageOutput"]; + img_ilerp: components["schemas"]["ImageOutput"]; + freeu: components["schemas"]["UNetOutput"]; mask_edge: components["schemas"]["ImageOutput"]; - conditioning_collection: components["schemas"]["ConditioningCollectionOutput"]; + string_join: components["schemas"]["StringOutput"]; img_hue_adjust: components["schemas"]["ImageOutput"]; - depth_anything_image_processor: components["schemas"]["ImageOutput"]; - lora_loader: components["schemas"]["LoRALoaderOutput"]; - sdxl_compel_prompt: components["schemas"]["ConditioningOutput"]; - add: components["schemas"]["IntegerOutput"]; - controlnet: components["schemas"]["ControlOutput"]; color_correct: components["schemas"]["ImageOutput"]; - random_range: components["schemas"]["IntegerCollectionOutput"]; - denoise_latents: components["schemas"]["LatentsOutput"]; - metadata: components["schemas"]["MetadataOutput"]; - i2l: components["schemas"]["LatentsOutput"]; - show_image: components["schemas"]["ImageOutput"]; - img_paste: components["schemas"]["ImageOutput"]; - infill_tile: components["schemas"]["ImageOutput"]; + calculate_image_tiles_min_overlap: components["schemas"]["CalculateImageTilesOutput"]; + img_chan: components["schemas"]["ImageOutput"]; + calculate_image_tiles_even_split: components["schemas"]["CalculateImageTilesOutput"]; + create_denoise_mask: components["schemas"]["DenoiseMaskOutput"]; + lblend: components["schemas"]["LatentsOutput"]; + crop_latents: components["schemas"]["LatentsOutput"]; + string_replace: components["schemas"]["StringOutput"]; + range_of_size: components["schemas"]["IntegerCollectionOutput"]; + calculate_image_tiles: components["schemas"]["CalculateImageTilesOutput"]; + iterate: components["schemas"]["IterateInvocationOutput"]; + create_gradient_mask: components["schemas"]["GradientMaskOutput"]; + face_off: components["schemas"]["FaceOffOutput"]; + sdxl_refiner_model_loader: components["schemas"]["SDXLRefinerModelLoaderOutput"]; + scheduler: components["schemas"]["SchedulerOutput"]; + float_collection: components["schemas"]["FloatCollectionOutput"]; + core_metadata: components["schemas"]["MetadataOutput"]; + segment_anything_processor: components["schemas"]["ImageOutput"]; }; /** * InvocationStartedEvent @@ -13206,6 +13218,19 @@ export type components = { */ type?: "url"; }; + /** UncategorizedImageCounts */ + UncategorizedImageCounts: { + /** + * Image Count + * @description The number of uncategorized images. + */ + image_count: number; + /** + * Asset Count + * @description The number of uncategorized assets. + */ + asset_count: number; + }; /** * Unsharp Mask * @description Applies an unsharp mask filter to an image @@ -15163,6 +15188,20 @@ export type operations = { }; }; }; + /** + * Get Uncategorized Image Counts + * @description Gets count of images and assets for uncategorized images (images with no board assocation) + */ + get_uncategorized_image_counts: { + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["UncategorizedImageCounts"]; + }; + }; + }; + }; /** * Add Image To Board * @description Creates a board_image diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts index 5beb5cbbf5d..34855a9c82a 100644 --- a/invokeai/frontend/web/src/services/api/types.ts +++ b/invokeai/frontend/web/src/services/api/types.ts @@ -36,7 +36,6 @@ export type AppDependencyVersions = S['AppDependencyVersions']; export type ImageDTO = S['ImageDTO']; export type BoardDTO = S['BoardDTO']; export type ImageCategory = S['ImageCategory']; -export type OffsetPaginatedResults_ImageDTO_ = S['OffsetPaginatedResults_ImageDTO_']; // Models export type ModelType = S['ModelType']; diff --git a/tests/app/services/bulk_download/test_bulk_download.py b/tests/app/services/bulk_download/test_bulk_download.py index 223ecc88632..48842d9a4bd 100644 --- a/tests/app/services/bulk_download/test_bulk_download.py +++ b/tests/app/services/bulk_download/test_bulk_download.py @@ -127,7 +127,16 @@ def test_generate_id_with_board_id(monkeypatch: Any, mock_invoker: Invoker): def mock_board_get(*args, **kwargs): return BoardRecord( - board_id="12345", board_name="test_board_name", created_at="None", updated_at="None", archived=False + board_id="12345", + board_name="test_board_name", + created_at="None", + updated_at="None", + archived=False, + asset_count=0, + image_count=0, + cover_image_name="asdf.png", + deleted_at=None, + is_private=False, ) monkeypatch.setattr(mock_invoker.services.board_records, "get", mock_board_get) @@ -156,7 +165,16 @@ def test_handler_board_id(tmp_path: Path, monkeypatch: Any, mock_image_dto: Imag def mock_board_get(*args, **kwargs): return BoardRecord( - board_id="12345", board_name="test_board_name", created_at="None", updated_at="None", archived=False + board_id="12345", + board_name="test_board_name", + created_at="None", + updated_at="None", + archived=False, + asset_count=0, + image_count=0, + cover_image_name="asdf.png", + deleted_at=None, + is_private=False, ) monkeypatch.setattr(mock_invoker.services.board_records, "get", mock_board_get)