Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions invokeai/app/api/routers/virtual_boards.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from fastapi import HTTPException, Path, Query
from fastapi.routing import APIRouter

from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.services.image_records.image_records_common import ImageCategory, ImageNamesResult
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
from invokeai.app.services.virtual_boards.virtual_boards_common import VirtualSubBoardDTO

virtual_boards_router = APIRouter(prefix="/v1/virtual_boards", tags=["virtual_boards"])


@virtual_boards_router.get(
"/by_date",
operation_id="list_virtual_boards_by_date",
response_model=list[VirtualSubBoardDTO],
)
async def list_virtual_boards_by_date(
current_user: CurrentUserOrDefault,
) -> list[VirtualSubBoardDTO]:
"""Gets a list of virtual sub-boards grouped by date."""
try:
return ApiDependencies.invoker.services.image_records.get_image_dates(
user_id=current_user.user_id,
is_admin=current_user.is_admin,
)
except Exception:
raise HTTPException(status_code=500, detail="Failed to get virtual boards by date")


@virtual_boards_router.get(
"/by_date/{date}/image_names",
operation_id="list_virtual_board_image_names_by_date",
response_model=ImageNamesResult,
)
async def list_virtual_board_image_names_by_date(
current_user: CurrentUserOrDefault,
date: str = Path(description="The ISO date string, e.g. '2026-03-18'"),
starred_first: bool = Query(default=True, description="Whether to sort starred images first"),
order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The sort direction"),
categories: list[ImageCategory] | None = Query(default=None, description="The categories of images to include"),
search_term: str | None = Query(default=None, description="Search term to filter images"),
) -> ImageNamesResult:
"""Gets ordered image names for a specific date."""
try:
return ApiDependencies.invoker.services.image_records.get_image_names_by_date(
date=date,
starred_first=starred_first,
order_dir=order_dir,
categories=categories,
search_term=search_term,
user_id=current_user.user_id,
is_admin=current_user.is_admin,
)
except Exception:
raise HTTPException(status_code=500, detail="Failed to get image names for date")
2 changes: 2 additions & 0 deletions invokeai/app/api_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
session_queue,
style_presets,
utilities,
virtual_boards,
workflows,
)
from invokeai.app.api.sockets import SocketIO
Expand Down Expand Up @@ -131,6 +132,7 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint):
app.include_router(images.images_router, prefix="/api")
app.include_router(boards.boards_router, prefix="/api")
app.include_router(board_images.board_images_router, prefix="/api")
app.include_router(virtual_boards.virtual_boards_router, prefix="/api")
app.include_router(model_relationships.model_relationships_router, prefix="/api")
app.include_router(app_info.app_router, prefix="/api")
app.include_router(session_queue.session_queue_router, prefix="/api")
Expand Down
24 changes: 24 additions & 0 deletions invokeai/app/services/image_records/image_records_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
)
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
from invokeai.app.services.virtual_boards.virtual_boards_common import VirtualSubBoardDTO


class ImageRecordStorageBase(ABC):
Expand Down Expand Up @@ -117,3 +118,26 @@ def get_image_names(
) -> ImageNamesResult:
"""Gets ordered list of image names with metadata for optimistic updates."""
pass

@abstractmethod
def get_image_dates(
self,
user_id: Optional[str] = None,
is_admin: bool = False,
) -> list[VirtualSubBoardDTO]:
"""Gets a list of dates with image counts, grouped by DATE(created_at)."""
pass

@abstractmethod
def get_image_names_by_date(
self,
date: str,
starred_first: bool = True,
order_dir: SQLiteDirection = SQLiteDirection.Descending,
categories: Optional[list[ImageCategory]] = None,
search_term: Optional[str] = None,
user_id: Optional[str] = None,
is_admin: bool = False,
) -> ImageNamesResult:
"""Gets ordered list of image names for a specific date."""
pass
139 changes: 139 additions & 0 deletions invokeai/app/services/image_records/image_records_sqlite.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
from invokeai.app.services.virtual_boards.virtual_boards_common import VirtualSubBoardDTO


class SqliteImageRecordStorage(ImageRecordStorageBase):
Expand Down Expand Up @@ -489,3 +490,141 @@ def get_image_names(
image_names = [row[0] for row in result]

return ImageNamesResult(image_names=image_names, starred_count=starred_count, total_count=len(image_names))

def get_image_dates(
self,
user_id: Optional[str] = None,
is_admin: bool = False,
) -> list[VirtualSubBoardDTO]:
with self._db.transaction() as cursor:
query_conditions = ""
query_params: list[Union[int, str, bool]] = []

# Only non-intermediate images
query_conditions += """--sql
AND images.is_intermediate = 0
"""

# User isolation for non-admin users
if user_id is not None and not is_admin:
query_conditions += """--sql
AND images.user_id = ?
"""
query_params.append(user_id)

query = f"""--sql
SELECT
DATE(images.created_at) as date,
SUM(CASE WHEN images.image_category = 'general' THEN 1 ELSE 0 END) as image_count,
SUM(CASE WHEN images.image_category != 'general' THEN 1 ELSE 0 END) as asset_count,
(
SELECT i2.image_name FROM images i2
WHERE DATE(i2.created_at) = DATE(images.created_at)
AND i2.is_intermediate = 0
ORDER BY i2.created_at DESC LIMIT 1
) as cover_image_name
FROM images
WHERE 1=1
{query_conditions}
GROUP BY DATE(images.created_at)
ORDER BY date DESC;
"""

cursor.execute(query, query_params)
result = cast(list[sqlite3.Row], cursor.fetchall())

return [
VirtualSubBoardDTO(
virtual_board_id=f"by_date:{dict(row)['date']}",
board_name=dict(row)["date"],
date=dict(row)["date"],
image_count=dict(row)["image_count"],
asset_count=dict(row)["asset_count"],
cover_image_name=dict(row)["cover_image_name"],
)
for row in result
]

def get_image_names_by_date(
self,
date: str,
starred_first: bool = True,
order_dir: SQLiteDirection = SQLiteDirection.Descending,
categories: Optional[list[ImageCategory]] = None,
search_term: Optional[str] = None,
user_id: Optional[str] = None,
is_admin: bool = False,
) -> ImageNamesResult:
with self._db.transaction() as cursor:
query_conditions = ""
query_params: list[Union[int, str, bool]] = []

# Filter by date
query_conditions += """--sql
AND DATE(images.created_at) = ?
"""
query_params.append(date)

# Only non-intermediate images
query_conditions += """--sql
AND images.is_intermediate = 0
"""

if categories is not None:
category_strings = [c.value for c in set(categories)]
placeholders = ",".join("?" * len(category_strings))
query_conditions += f"""--sql
AND images.image_category IN ( {placeholders} )
"""
for c in category_strings:
query_params.append(c)

# User isolation for non-admin users
if user_id is not None and not is_admin:
query_conditions += """--sql
AND images.user_id = ?
"""
query_params.append(user_id)

if search_term:
query_conditions += """--sql
AND (
images.metadata LIKE ?
OR images.created_at LIKE ?
)
"""
query_params.append(f"%{search_term.lower()}%")
query_params.append(f"%{search_term.lower()}%")

# Get starred count if starred_first is enabled
starred_count = 0
if starred_first:
starred_count_query = f"""--sql
SELECT COUNT(*)
FROM images
WHERE images.starred = TRUE AND (1=1{query_conditions})
"""
cursor.execute(starred_count_query, query_params)
starred_count = cast(int, cursor.fetchone()[0])

# Get all image names with proper ordering
if starred_first:
names_query = f"""--sql
SELECT images.image_name
FROM images
WHERE 1=1{query_conditions}
ORDER BY images.starred DESC, images.created_at {order_dir.value}
"""
else:
names_query = f"""--sql
SELECT images.image_name
FROM images
WHERE 1=1{query_conditions}
ORDER BY images.created_at {order_dir.value}
"""

cursor.execute(names_query, query_params)
result = cast(list[sqlite3.Row], cursor.fetchall())
image_names = [row[0] for row in result]

return ImageNamesResult(image_names=image_names, starred_count=starred_count, total_count=len(image_names))
Empty file.
14 changes: 14 additions & 0 deletions invokeai/app/services/virtual_boards/virtual_boards_common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from typing import Optional

from pydantic import BaseModel, Field


class VirtualSubBoardDTO(BaseModel):
"""A virtual sub-board computed from image metadata, not stored in the database."""

virtual_board_id: str = Field(description="The virtual board ID, e.g. 'by_date:2026-03-18'.")
board_name: str = Field(description="The display name of the virtual sub-board, e.g. '2026-03-18'.")
date: str = Field(description="The ISO date string, e.g. '2026-03-18'.")
image_count: int = Field(description="The number of general images for this date.")
asset_count: int = Field(description="The number of asset images for this date.")
cover_image_name: Optional[str] = Field(default=None, description="The most recent image name for this date.")
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import AddBoardButton from './AddBoardButton';
import GalleryBoard from './GalleryBoard';
import NoBoardBoard from './NoBoardBoard';
import { VirtualBoardSection } from './VirtualBoardSection';

export const BoardsList = memo(() => {
const { t } = useTranslation();
Expand All @@ -40,6 +41,7 @@ export const BoardsList = memo(() => {

if (!boardSearchText.length) {
elements.push(<NoBoardBoard key="none" isSelected={selectedBoardId === 'none'} />);
elements.push(<VirtualBoardSection key="virtual-boards" />);
}

filteredBoards.forEach((board) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Box, Flex, Icon, Image, Text, Tooltip } from '@invoke-ai/ui-library';
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectSelectedBoardId } from 'features/gallery/store/gallerySelectors';
import { boardIdSelected } from 'features/gallery/store/gallerySlice';
import { memo, useCallback } from 'react';
import { PiCalendarBold, PiImageSquare } from 'react-icons/pi';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import type { VirtualSubBoard } from 'services/api/endpoints/virtual_boards';

const _hover: SystemStyleObject = {
bg: 'base.850',
};

interface VirtualBoardItemProps {
board: VirtualSubBoard;
}

const VirtualBoardItem = ({ board }: VirtualBoardItemProps) => {
const dispatch = useAppDispatch();
const selectedBoardId = useAppSelector(selectSelectedBoardId);
const isSelected = selectedBoardId === board.virtual_board_id;

const onClick = useCallback(() => {
if (selectedBoardId !== board.virtual_board_id) {
dispatch(boardIdSelected({ boardId: board.virtual_board_id }));
}
}, [selectedBoardId, board.virtual_board_id, dispatch]);

return (
<Box position="relative" w="full" h={12}>
<Tooltip
label={`${board.date} — ${board.image_count} images, ${board.asset_count} assets`}
openDelay={1000}
placement="right"
closeOnScroll
p={2}
>
<Flex
onClick={onClick}
alignItems="center"
borderRadius="base"
cursor="pointer"
py={1}
ps={1}
pe={4}
gap={4}
bg={isSelected ? 'base.850' : undefined}
_hover={_hover}
w="full"
h="full"
>
<CoverImage coverImageName={board.cover_image_name} />
<Flex flex={1} direction="column" minW={0}>
<Text fontSize="sm" noOfLines={1} fontWeight={isSelected ? 'bold' : 'normal'}>
{board.board_name}
</Text>
</Flex>
<Icon as={PiCalendarBold} fill="base.500" boxSize={4} />
<Flex justifyContent="flex-end">
<Text variant="subtext">
{board.image_count} | {board.asset_count}
</Text>
</Flex>
</Flex>
</Tooltip>
</Box>
);
};

export default memo(VirtualBoardItem);

const CoverImage = ({ coverImageName }: { coverImageName: string | null }) => {
const { currentData: coverImage } = useGetImageDTOQuery(coverImageName ?? skipToken);

if (coverImage) {
return (
<Image
src={coverImage.thumbnail_url}
draggable={false}
objectFit="cover"
w={10}
h={10}
borderRadius="base"
borderBottomRadius="lg"
/>
);
}

return (
<Flex w={10} h={10} justifyContent="center" alignItems="center">
<Icon boxSize={10} as={PiImageSquare} opacity={0.7} color="base.500" />
</Flex>
);
};
Loading
Loading