Compare commits

...

6 Commits

Author SHA1 Message Date
psychedelicious
1a6d80e829 feat(ui): remove openDelay on board tooltip
Now that the counts are already available and the tooltip does not make a network request, we can remove the delay (which was added to prevent network thrashing as you moved the mouse over the boards list).
2024-09-25 19:39:22 +10:00
psychedelicious
a0919423f1 fix(ui): use correct BoardTooltip component w/ thumbnail image 2024-09-25 11:19:39 +10:00
psychedelicious
9a36b3debd feat(ui): use updated boards data
- Update tooltips to use counts in the DTO
- Remove unused `getBoardImagesTotal` and `getBoardAssetsTotal` queries, which were just abusing the list endpoint to get totals...
- Remove extraneous optimistic update in invocation complete listener
2024-09-25 11:19:39 +10:00
psychedelicious
d2f81811a1 chore(ui): typegen 2024-09-25 11:19:17 +10:00
psychedelicious
fcac493afe feat(app): optimize boards queries
Use SQL instead of python to retrieve image count, asset count and board cover image.

This reduces the number of SQL queries needed to list all boards. Previously, we did `1 + 2 * board_count` queries::
- 1 query to get the list of board records
- 1 query per board to get its total count
- 1 query per board to get its cover image

Then, on the frontend, we made two additional network requests to get each board's counts:
- 1 request (== 1 SQL query) for image count
- 1 request (== 1 SQL query) for asset count

All of this information is now retrieved in a single SQL query, and provided via single network request.

As part of this change, `BoardRecord` now includes `image_count`, `asset_count` and `cover_image_name`. This makes `BoardDTO` redundant, but removing it is a deeper change...
2024-09-25 11:19:17 +10:00
psychedelicious
845039e544 feat(app): add method & route to get uncategorized image counts 2024-09-25 11:19:17 +10:00
15 changed files with 311 additions and 209 deletions

View File

@@ -5,7 +5,7 @@ from fastapi.routing import APIRouter
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()

View File

@@ -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 @@ class BoardRecordStorageBase(ABC):
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

View File

@@ -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.")

View File

@@ -9,12 +9,121 @@ from invokeai.app.services.board_records.board_records_common import (
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 @@ class SqliteBoardRecordStorage(BoardRecordStorageBase):
try:
self._lock.acquire()
self._cursor.execute(
"""--sql
SELECT *
FROM boards
WHERE board_id = ?;
""",
get_board_record_query(),
(board_id,),
)
@@ -92,7 +197,7 @@ class SqliteBoardRecordStorage(BoardRecordStorageBase):
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 @@ class SqliteBoardRecordStorage(BoardRecordStorageBase):
try:
self._lock.acquire()
# Build base query
base_query = """
SELECT *
FROM boards
{archived_filter}
ORDER BY created_at DESC
LIMIT ? OFFSET ?;
"""
main_query = get_paginated_list_board_records_queries(include_archived=include_archived)
# Determine archived filter condition
if include_archived:
archived_filter = ""
else:
archived_filter = "WHERE archived = 0"
final_query = base_query.format(archived_filter=archived_filter)
# 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 @@ class SqliteBoardRecordStorage(BoardRecordStorageBase):
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 @@ class SqliteBoardRecordStorage(BoardRecordStorageBase):
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()

View File

@@ -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."""
"""Deserialized board record."""
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."""
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

View File

@@ -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 @@ class BoardService(BoardServiceABC):
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 @@ class BoardService(BoardServiceABC):
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 @@ class BoardService(BoardServiceABC):
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

View File

@@ -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 (
<Flex flexDir="column" alignItems="center" gap={1}>
@@ -34,13 +30,11 @@ export const BoardTooltip = ({ board }: Props) => {
aspectRatio="1/1"
borderRadius="base"
borderBottomRadius="lg"
mt={1}
/>
)}
<Flex flexDir="column" alignItems="center">
<Text noOfLines={1}>
{t('boards.imagesWithCount', { count: imagesTotal })}, {t('boards.assetsWithCount', { count: assetsTotal })}
</Text>
{board?.archived && <Text>({t('boards.archived')})</Text>}
<Text noOfLines={1}>{totalString}</Text>
</Flex>
</Flex>
);

View File

@@ -119,7 +119,18 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => {
return (
<BoardContextMenu board={board}>
{(ref) => (
<Tooltip label={<BoardTooltip board={board} />} openDelay={1000} placement="left" closeOnScroll p={2}>
<Tooltip
label={
<BoardTooltip
imageCount={board.image_count}
assetCount={board.asset_count}
isArchived={board.archived}
coverImageName={board.cover_image_name}
/>
}
placement="left"
closeOnScroll
>
<Flex
position="relative"
ref={ref}
@@ -167,7 +178,7 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => {
</Editable>
{autoAddBoardId === board.board_id && !editingDisclosure.isOpen && <AutoAddBadge />}
{board.archived && !editingDisclosure.isOpen && <Icon as={PiArchiveBold} fill="base.300" />}
{!editingDisclosure.isOpen && <Text variant="subtext">{board.image_count}</Text>}
{!editingDisclosure.isOpen && <Text variant="subtext">{board.image_count + board.asset_count}</Text>}
<IAIDroppable data={droppableData} dropLabel={t('unifiedCanvas.move')} />
</Flex>

View File

@@ -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 (
<NoBoardBoardContextMenu>
{(ref) => (
<Tooltip label={<BoardTooltip board={null} />} openDelay={1000} placement="left" closeOnScroll>
<Tooltip
label={
<BoardTooltip imageCount={data?.image_count ?? 0} assetCount={data?.asset_count ?? 0} isArchived={false} />
}
placement="left"
closeOnScroll
>
<Flex
position="relative"
ref={ref}
@@ -91,7 +93,7 @@ const NoBoardBoard = memo(({ isSelected }: Props) => {
{boardName}
</Text>
{autoAddBoardId === 'none' && <AutoAddBadge />}
<Text variant="subtext">{imagesTotal}</Text>
<Text variant="subtext">{(data?.image_count ?? 0) + (data?.asset_count ?? 0)}</Text>
<IAIDroppable data={droppableData} dropLabel={t('unifiedCanvas.move')} />
</Flex>
</Tooltip>

View File

@@ -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<S['UncategorizedImageCounts'], void>({
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;

View File

@@ -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';

View File

@@ -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;

View File

@@ -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'];

View File

@@ -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' },

View File

@@ -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)