Skip to content

Commit a17c56f

Browse files
committed
Merge remote-tracking branch 'origin/main'
2 parents d69df23 + e77c0d1 commit a17c56f

21 files changed

Lines changed: 735 additions & 11 deletions

File tree

docs-old/features/gallery.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,26 @@ The settings button opens a list of options.
3434
Below these two buttons, you'll see the Search Boards text entry area. You use this to search for specific boards by the name of the board.
3535
Next to it is the Add Board (+) button which lets you add new boards. Boards can be renamed by clicking on the name of the board under its thumbnail and typing in the new name.
3636

37+
### Virtual Boards by Date
38+
39+
In addition to the regular user-created boards, the Gallery can show **virtual boards** that group your images automatically by their creation date. Virtual boards are not stored in the database — they are computed on the fly from existing image metadata, so enabling or disabling them never moves or modifies your images.
40+
41+
#### Enabling Virtual Boards
42+
43+
Open the boards settings popover (the gear icon next to the boards search field) and toggle **Show Virtual Boards**. A new collapsible **By Date** section then appears in the boards list, with one entry per day on which images were generated (e.g. `2026-03-18`).
44+
45+
Each virtual board entry shows:
46+
47+
- a cover thumbnail (the most recent image of that day)
48+
- the number of generated **images** on that date
49+
- the number of uploaded **assets** on that date
50+
51+
Selecting a virtual board filters the gallery to show only the images from that day. Search, category filters (Images / Assets), starred-first sorting and sort direction all work the same way as on regular boards.
52+
53+
!!! note "Read-only"
54+
55+
Virtual boards are a view over your existing images. You cannot rename, delete or auto-assign to them, and images cannot be "moved into" a virtual board — they appear there automatically based on their creation date. To organize images permanently, use regular boards.
56+
3757
### Board Thumbnail Menu
3858

3959
Each board has a context menu (ctrl+click / right-click).
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from fastapi import HTTPException, Path, Query
2+
from fastapi.routing import APIRouter
3+
4+
from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
5+
from invokeai.app.api.dependencies import ApiDependencies
6+
from invokeai.app.services.image_records.image_records_common import ImageCategory, ImageNamesResult
7+
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
8+
from invokeai.app.services.virtual_boards.virtual_boards_common import VirtualSubBoardDTO
9+
10+
virtual_boards_router = APIRouter(prefix="/v1/virtual_boards", tags=["virtual_boards"])
11+
12+
13+
@virtual_boards_router.get(
14+
"/by_date",
15+
operation_id="list_virtual_boards_by_date",
16+
response_model=list[VirtualSubBoardDTO],
17+
)
18+
async def list_virtual_boards_by_date(
19+
current_user: CurrentUserOrDefault,
20+
) -> list[VirtualSubBoardDTO]:
21+
"""Gets a list of virtual sub-boards grouped by date."""
22+
try:
23+
return ApiDependencies.invoker.services.image_records.get_image_dates(
24+
user_id=current_user.user_id,
25+
is_admin=current_user.is_admin,
26+
)
27+
except Exception:
28+
raise HTTPException(status_code=500, detail="Failed to get virtual boards by date")
29+
30+
31+
@virtual_boards_router.get(
32+
"/by_date/{date}/image_names",
33+
operation_id="list_virtual_board_image_names_by_date",
34+
response_model=ImageNamesResult,
35+
)
36+
async def list_virtual_board_image_names_by_date(
37+
current_user: CurrentUserOrDefault,
38+
date: str = Path(description="The ISO date string, e.g. '2026-03-18'"),
39+
starred_first: bool = Query(default=True, description="Whether to sort starred images first"),
40+
order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The sort direction"),
41+
categories: list[ImageCategory] | None = Query(default=None, description="The categories of images to include"),
42+
search_term: str | None = Query(default=None, description="Search term to filter images"),
43+
) -> ImageNamesResult:
44+
"""Gets ordered image names for a specific date."""
45+
try:
46+
return ApiDependencies.invoker.services.image_records.get_image_names_by_date(
47+
date=date,
48+
starred_first=starred_first,
49+
order_dir=order_dir,
50+
categories=categories,
51+
search_term=search_term,
52+
user_id=current_user.user_id,
53+
is_admin=current_user.is_admin,
54+
)
55+
except Exception:
56+
raise HTTPException(status_code=500, detail="Failed to get image names for date")

invokeai/app/api_app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
session_queue,
3030
style_presets,
3131
utilities,
32+
virtual_boards,
3233
workflows,
3334
)
3435
from invokeai.app.api.sockets import SocketIO
@@ -177,6 +178,7 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint):
177178
app.include_router(images.images_router, prefix="/api")
178179
app.include_router(boards.boards_router, prefix="/api")
179180
app.include_router(board_images.board_images_router, prefix="/api")
181+
app.include_router(virtual_boards.virtual_boards_router, prefix="/api")
180182
app.include_router(model_relationships.model_relationships_router, prefix="/api")
181183
app.include_router(app_info.app_router, prefix="/api")
182184
app.include_router(session_queue.session_queue_router, prefix="/api")

invokeai/app/services/image_records/image_records_base.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
)
1313
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
1414
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
15+
from invokeai.app.services.virtual_boards.virtual_boards_common import VirtualSubBoardDTO
1516

1617

1718
class ImageRecordStorageBase(ABC):
@@ -122,3 +123,26 @@ def get_image_names(
122123
) -> ImageNamesResult:
123124
"""Gets ordered list of image names with metadata for optimistic updates."""
124125
pass
126+
127+
@abstractmethod
128+
def get_image_dates(
129+
self,
130+
user_id: Optional[str] = None,
131+
is_admin: bool = False,
132+
) -> list[VirtualSubBoardDTO]:
133+
"""Gets a list of dates with image counts, grouped by DATE(created_at)."""
134+
pass
135+
136+
@abstractmethod
137+
def get_image_names_by_date(
138+
self,
139+
date: str,
140+
starred_first: bool = True,
141+
order_dir: SQLiteDirection = SQLiteDirection.Descending,
142+
categories: Optional[list[ImageCategory]] = None,
143+
search_term: Optional[str] = None,
144+
user_id: Optional[str] = None,
145+
is_admin: bool = False,
146+
) -> ImageNamesResult:
147+
"""Gets ordered list of image names for a specific date."""
148+
pass

invokeai/app/services/image_records/image_records_sqlite.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
2020
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
2121
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
22+
from invokeai.app.services.virtual_boards.virtual_boards_common import VirtualSubBoardDTO
2223

2324

2425
class SqliteImageRecordStorage(ImageRecordStorageBase):
@@ -503,3 +504,141 @@ def get_image_names(
503504
image_names = [row[0] for row in result]
504505

505506
return ImageNamesResult(image_names=image_names, starred_count=starred_count, total_count=len(image_names))
507+
508+
def get_image_dates(
509+
self,
510+
user_id: Optional[str] = None,
511+
is_admin: bool = False,
512+
) -> list[VirtualSubBoardDTO]:
513+
with self._db.transaction() as cursor:
514+
query_conditions = ""
515+
query_params: list[Union[int, str, bool]] = []
516+
517+
# Only non-intermediate images
518+
query_conditions += """--sql
519+
AND images.is_intermediate = 0
520+
"""
521+
522+
# User isolation for non-admin users
523+
if user_id is not None and not is_admin:
524+
query_conditions += """--sql
525+
AND images.user_id = ?
526+
"""
527+
query_params.append(user_id)
528+
529+
query = f"""--sql
530+
SELECT
531+
DATE(images.created_at) as date,
532+
SUM(CASE WHEN images.image_category = 'general' THEN 1 ELSE 0 END) as image_count,
533+
SUM(CASE WHEN images.image_category != 'general' THEN 1 ELSE 0 END) as asset_count,
534+
(
535+
SELECT i2.image_name FROM images i2
536+
WHERE DATE(i2.created_at) = DATE(images.created_at)
537+
AND i2.is_intermediate = 0
538+
ORDER BY i2.created_at DESC LIMIT 1
539+
) as cover_image_name
540+
FROM images
541+
WHERE 1=1
542+
{query_conditions}
543+
GROUP BY DATE(images.created_at)
544+
ORDER BY date DESC;
545+
"""
546+
547+
cursor.execute(query, query_params)
548+
result = cast(list[sqlite3.Row], cursor.fetchall())
549+
550+
return [
551+
VirtualSubBoardDTO(
552+
virtual_board_id=f"by_date:{dict(row)['date']}",
553+
board_name=dict(row)["date"],
554+
date=dict(row)["date"],
555+
image_count=dict(row)["image_count"],
556+
asset_count=dict(row)["asset_count"],
557+
cover_image_name=dict(row)["cover_image_name"],
558+
)
559+
for row in result
560+
]
561+
562+
def get_image_names_by_date(
563+
self,
564+
date: str,
565+
starred_first: bool = True,
566+
order_dir: SQLiteDirection = SQLiteDirection.Descending,
567+
categories: Optional[list[ImageCategory]] = None,
568+
search_term: Optional[str] = None,
569+
user_id: Optional[str] = None,
570+
is_admin: bool = False,
571+
) -> ImageNamesResult:
572+
with self._db.transaction() as cursor:
573+
query_conditions = ""
574+
query_params: list[Union[int, str, bool]] = []
575+
576+
# Filter by date
577+
query_conditions += """--sql
578+
AND DATE(images.created_at) = ?
579+
"""
580+
query_params.append(date)
581+
582+
# Only non-intermediate images
583+
query_conditions += """--sql
584+
AND images.is_intermediate = 0
585+
"""
586+
587+
if categories is not None:
588+
category_strings = [c.value for c in set(categories)]
589+
placeholders = ",".join("?" * len(category_strings))
590+
query_conditions += f"""--sql
591+
AND images.image_category IN ( {placeholders} )
592+
"""
593+
for c in category_strings:
594+
query_params.append(c)
595+
596+
# User isolation for non-admin users
597+
if user_id is not None and not is_admin:
598+
query_conditions += """--sql
599+
AND images.user_id = ?
600+
"""
601+
query_params.append(user_id)
602+
603+
if search_term:
604+
query_conditions += """--sql
605+
AND (
606+
images.metadata LIKE ?
607+
OR images.created_at LIKE ?
608+
)
609+
"""
610+
query_params.append(f"%{search_term.lower()}%")
611+
query_params.append(f"%{search_term.lower()}%")
612+
613+
# Get starred count if starred_first is enabled
614+
starred_count = 0
615+
if starred_first:
616+
starred_count_query = f"""--sql
617+
SELECT COUNT(*)
618+
FROM images
619+
WHERE images.starred = TRUE AND (1=1{query_conditions})
620+
"""
621+
cursor.execute(starred_count_query, query_params)
622+
starred_count = cast(int, cursor.fetchone()[0])
623+
624+
# Get all image names with proper ordering
625+
if starred_first:
626+
names_query = f"""--sql
627+
SELECT images.image_name
628+
FROM images
629+
WHERE 1=1{query_conditions}
630+
ORDER BY images.starred DESC, images.created_at {order_dir.value}
631+
"""
632+
else:
633+
names_query = f"""--sql
634+
SELECT images.image_name
635+
FROM images
636+
WHERE 1=1{query_conditions}
637+
ORDER BY images.created_at {order_dir.value}
638+
"""
639+
640+
cursor.execute(names_query, query_params)
641+
result = cast(list[sqlite3.Row], cursor.fetchall())
642+
image_names = [row[0] for row in result]
643+
644+
return ImageNamesResult(image_names=image_names, starred_count=starred_count, total_count=len(image_names))

invokeai/app/services/virtual_boards/__init__.py

Whitespace-only changes.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from typing import Optional
2+
3+
from pydantic import BaseModel, Field
4+
5+
6+
class VirtualSubBoardDTO(BaseModel):
7+
"""A virtual sub-board computed from image metadata, not stored in the database."""
8+
9+
virtual_board_id: str = Field(description="The virtual board ID, e.g. 'by_date:2026-03-18'.")
10+
board_name: str = Field(description="The display name of the virtual sub-board, e.g. '2026-03-18'.")
11+
date: str = Field(description="The ISO date string, e.g. '2026-03-18'.")
12+
image_count: int = Field(description="The number of general images for this date.")
13+
asset_count: int = Field(description="The number of asset images for this date.")
14+
cover_image_name: Optional[str] = Field(default=None, description="The most recent image name for this date.")

invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsList.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
1414
import AddBoardButton from './AddBoardButton';
1515
import GalleryBoard from './GalleryBoard';
1616
import NoBoardBoard from './NoBoardBoard';
17+
import { VirtualBoardSection } from './VirtualBoardSection';
1718

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

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

4547
filteredBoards.forEach((board) => {

0 commit comments

Comments
 (0)