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..ea02d3e4b21 100644 --- a/invokeai/app/services/board_records/board_records_sqlite.py +++ b/invokeai/app/services/board_records/board_records_sqlite.py @@ -9,12 +9,121 @@ 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 + """ + + +def get_paginated_list_board_records_queries(include_archived: bool) -> str: + """Gets a query to retrieve a paginated list of board records. The query has placeholders for limit and offset. + + Args: + include_archived: Whether to include archived board records in the results. + + Returns: + 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! + 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 ?; + """ + + return query + + +def get_total_boards_count_query(include_archived: bool) -> str: + """Gets a query to retrieve the total count of board records. + + Args: + include_archived: Whether to include archived board records in the count. + + Returns: + A query to retrieve the total count of board records. + """ + + archived_condition = "WHERE b.archived = 0" if not include_archived else "" + + return f"SELECT COUNT(*) FROM boards {archived_condition};" + + +def get_list_all_board_records_query(include_archived: bool) -> str: + """Gets a query to retrieve all board records. + + Args: + include_archived: Whether to include archived board records in the results. + + Returns: + A query to retrieve all board records. + """ + + archived_condition = "WHERE b.archived = 0" if not include_archived else "" + + 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. The query has a placeholder for the board_id.""" + + return f"{_BASE_BOARD_RECORD_QUERY} WHERE b.board_id = ?;" + class SqliteBoardRecordStorage(BoardRecordStorageBase): _conn: sqlite3.Connection @@ -76,11 +185,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 +197,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 +254,15 @@ 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) + main_query = 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(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) - + total_query = get_total_boards_count_query(include_archived=include_archived) + self._cursor.execute(total_query) count = cast(int, self._cursor.fetchone()[0]) return OffsetPaginatedResults[BoardRecord](items=boards, offset=offset, limit=limit, total=count) @@ -201,26 +276,10 @@ 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) - + query = get_list_all_board_records_query(include_archived=include_archived) + self._cursor.execute(query) 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 +287,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/features/gallery/components/Boards/BoardsList/BoardTooltip.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardTooltip.tsx index 63ba2991cfc..e3b30666b0e 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardTooltip.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardTooltip.tsx @@ -1,27 +1,23 @@ import { Flex, Image, Text } from '@invoke-ai/ui-library'; import { skipToken } from '@reduxjs/toolkit/query'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { useGetBoardAssetsTotalQuery, useGetBoardImagesTotalQuery } from 'services/api/endpoints/boards'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; -import type { BoardDTO } from 'services/api/types'; type Props = { - board: BoardDTO | null; + imageCount: number; + assetCount: number; + isArchived: boolean; + coverImageName?: string | null; }; -export const BoardTooltip = ({ board }: Props) => { +export const BoardTooltip = ({ imageCount, assetCount, isArchived, coverImageName }: Props) => { const { t } = useTranslation(); - const { imagesTotal } = useGetBoardImagesTotalQuery(board?.board_id || 'none', { - selectFromResult: ({ data }) => { - return { imagesTotal: data?.total ?? 0 }; - }, - }); - const { assetsTotal } = useGetBoardAssetsTotalQuery(board?.board_id || 'none', { - selectFromResult: ({ data }) => { - return { assetsTotal: data?.total ?? 0 }; - }, - }); - const { currentData: coverImage } = useGetImageDTOQuery(board?.cover_image_name ?? skipToken); + const { currentData: coverImage } = useGetImageDTOQuery(coverImageName ?? skipToken); + + const totalString = useMemo(() => { + return `${t('boards.imagesWithCount', { count: imageCount })}, ${t('boards.assetsWithCount', { count: assetCount })}${isArchived ? ` (${t('boards.archived')})` : ''}`; + }, [assetCount, imageCount, isArchived, t]); return ( @@ -34,13 +30,11 @@ export const BoardTooltip = ({ board }: Props) => { aspectRatio="1/1" borderRadius="base" borderBottomRadius="lg" + mt={1} /> )} - - {t('boards.imagesWithCount', { count: imagesTotal })}, {t('boards.assetsWithCount', { count: assetsTotal })} - - {board?.archived && ({t('boards.archived')})} + {totalString} ); 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 2bd07ef39cc..75ea1410214 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 @@ -119,7 +119,18 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => { return ( {(ref) => ( - } openDelay={1000} placement="left" closeOnScroll p={2}> + + } + placement="left" + closeOnScroll + > { {autoAddBoardId === board.board_id && !editingDisclosure.isOpen && } {board.archived && !editingDisclosure.isOpen && } - {!editingDisclosure.isOpen && {board.image_count}} + {!editingDisclosure.isOpen && {board.image_count + board.asset_count}} 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 7719c79866f..41d90a30c95 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 @@ -14,7 +14,7 @@ import { 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 { @@ -27,11 +27,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(selectAutoAddBoardId); const autoAssignBoardOnClick = useAppSelector(selectAutoAssignBoardOnClick); const boardSearchText = useAppSelector(selectBoardSearchText); @@ -60,7 +56,13 @@ const NoBoardBoard = memo(({ isSelected }: Props) => { return ( {(ref) => ( - } openDelay={1000} placement="left" closeOnScroll> + + } + placement="left" + closeOnScroll + > { {boardName} {autoAddBoardId === 'none' && } - {imagesTotal} + {(data?.image_count ?? 0) + (data?.asset_count ?? 0)} 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 0b82714d94c..1fa78cc4d7e 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -46,6 +46,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 b34e9589a4c..e483d678759 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -785,6 +785,26 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/boards/uncategorized/counts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 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"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/board_images/": { parameters: { query?: never; @@ -1973,7 +1993,7 @@ export type components = { }; /** * BoardDTO - * @description Deserialized board record with cover image URL and image count. + * @description Deserialized board record. */ BoardDTO: { /** @@ -2003,9 +2023,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. @@ -2021,6 +2041,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 @@ -4345,7 +4370,7 @@ export type components = { }; /** * Core Metadata - * @description Collects core generation metadata into a MetadataField + * @description Used internally by Invoke to collect metadata for generations. */ CoreMetadataInvocation: { /** @@ -16391,6 +16416,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 @@ -18845,6 +18883,26 @@ export interface operations { }; }; }; + get_uncategorized_image_counts: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UncategorizedImageCounts"]; + }; + }; + }; + }; add_image_to_board: { parameters: { query?: never; diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts index 647f141f4f6..b0860d6678c 100644 --- a/invokeai/frontend/web/src/services/api/types.ts +++ b/invokeai/frontend/web/src/services/api/types.ts @@ -37,7 +37,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/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx index f6fae1ec4a8..fc16c01cdc8 100644 --- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx +++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx @@ -6,7 +6,6 @@ import { stagingAreaImageStaged } from 'features/controlLayers/store/canvasStagi import { boardIdSelected, galleryViewChanged, imageSelected, offsetChanged } from 'features/gallery/store/gallerySlice'; import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useExecutionState'; import { zNodeStatus } from 'features/nodes/types/invocation'; -import { boardsApi } from 'services/api/endpoints/boards'; import { getImageDTOSafe, imagesApi } from 'services/api/endpoints/images'; import type { ImageDTO, S } from 'services/api/types'; import { getCategories, getListImagesUrl } from 'services/api/util'; @@ -31,13 +30,6 @@ export const buildOnInvocationComplete = (getState: () => RootState, dispatch: A return; } - // update the total images for the board - dispatch( - boardsApi.util.updateQueryData('getBoardImagesTotal', imageDTO.board_id ?? 'none', (draft) => { - draft.total += 1; - }) - ); - dispatch( imagesApi.util.invalidateTags([ { type: 'Board', id: imageDTO.board_id ?? 'none' }, 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)