Merge pull request #826 from cbcoutinho/feat/deck-compact-retrieval

feat(deck): compact card/comment retrieval (summaries, filters, board overview)
This commit is contained in:
Chris Coutinho
2026-06-01 17:56:54 +02:00
committed by GitHub
5 changed files with 882 additions and 157 deletions
+48
View File
@@ -4,6 +4,16 @@
| Tool | Description | | Tool | Description |
|------|-------------| |------|-------------|
| `deck_get_boards` | List all Deck boards |
| `deck_get_board` | Get a board (toggle `include_acl` / `include_users` / `include_labels`) |
| `deck_get_board_overview` | **Compact whole-board snapshot** — board → stacks → summary card rows in one call |
| `deck_get_stacks` | List stacks in a board (cards as compact summaries by default) |
| `deck_get_stack` | Get a single stack (cards as compact summaries by default) |
| `deck_get_archived_stacks` | List archived stacks and their cards |
| `deck_get_cards` | List cards in a stack (compact summaries by default) |
| `deck_get_card` | Get a single card in full detail |
| `deck_get_labels` / `deck_get_label` | List / get board labels |
| `deck_get_card_comments` | List card comments (compact, newest-first by default) |
| `deck_create_board` | Create a new Deck board with title and color | | `deck_create_board` | Create a new Deck board with title and color |
| `deck_create_stack` | Create a new stack in a board | | `deck_create_stack` | Create a new stack in a board |
| `deck_update_stack` | Update stack title and order | | `deck_update_stack` | Update stack title and order |
@@ -36,6 +46,44 @@
### Compact Retrieval (token efficiency)
On large boards the full card objects (description, nested labels, assigned
users, attachments, etags) make `deck_get_stacks` responses too large to be
practical. The read tools therefore return **compact card summaries by
default** and support filtering so you fetch only what you need.
**Shared knobs** on `deck_get_cards`, `deck_get_stacks`, `deck_get_stack`
(and `deck_get_archived_stacks`, minus `status`):
| Parameter | Default | Effect |
|-----------|---------|--------|
| `detail` | `summary` | `summary` returns compact rows (id, title, stackId, labels as titles, assignee UIDs, due/done, counts, a short `descriptionPreview`); `full` returns the complete card objects (the pre-0.92 shape). |
| `status` | `open` | Filter before serialization: `open`, `done`, `archived`, or `all`. The first three **partition** the board (no overlap) — a card that is both done and archived is reported only under `archived`. |
| `label` | | Only cards carrying a label with this exact title. |
| `assigned_to` | | Only cards assigned to this user UID. |
| `description_max_length` | | In `detail="full"`, truncate each description. |
| `description_preview_length` | `140` | In `detail="summary"`, length of the preview. |
**`deck_get_board_overview(board_id, status="open", label=…, assigned_to=…)`**
is the token-efficient way to see a whole board: it returns the board title,
its label legend, and every stack with compact card rows in a single call —
prefer it over `deck_get_board` + `deck_get_stacks` for "show me the board"
requests. Use `deck_get_card` for the full body of a specific card.
**Comments**`deck_get_card_comments` returns compact comments
(`id`, `actorId`, `message`, `creationDateTime`) by default. Use
`detail="full"` for the complete objects, `message_max_length` to truncate,
`order` (`newest`/`oldest`) to sort the page, and `limit`/`offset` to page.
> **Breaking change:** list tools now default to `detail="summary"`
> and `status="open"`. The previous `include_archived_cards` parameter has
> been replaced by `status` (`status="all"` includes archived cards, matching
> `include_archived_cards=True`). Pass `detail="full"` to restore the old
> per-card shape.
### Deck Project Management ### Deck Project Management
The server provides complete Nextcloud Deck integration, enabling you to manage projects, tasks, and workflows: The server provides complete Nextcloud Deck integration, enabling you to manage projects, tasks, and workflows:
+91 -3
View File
@@ -129,6 +129,44 @@ class DeckCard(BaseModel):
return validated_users return validated_users
class DeckCardSummary(BaseModel):
"""Compact projection of a :class:`DeckCard` for list/overview views.
Drops the heavy fields that dominate token cost when many cards are
returned at once — the full ``description`` (kept only as a short
``descriptionPreview``), the nested ``labels``/``assignedUsers``/
``attachments`` objects (kept as flat title/uid lists + counts), and the
``etag``/``order``/``*Modified``/``createdAt``/``deletedAt``/``overdue``/
``type``/``owner`` bookkeeping fields. Fetch a single card with
``deck_get_card`` (or ``detail="full"``) when the full body is needed.
"""
id: int
title: str
stackId: int
archived: bool = False
duedate: datetime | None = None
done: datetime | None = None
labels: list[str] = Field(
default_factory=list, description="Label titles assigned to the card"
)
assignedUsers: list[str] = Field(
default_factory=list, description="UIDs of users assigned to the card"
)
attachmentCount: int | None = Field(
default=None, description="Number of attachments on the card"
)
commentsUnread: int | None = Field(
default=None, description="Number of unread comments (Deck exposes no total)"
)
hasDescription: bool = Field(
default=False, description="Whether the card has a non-empty description"
)
descriptionPreview: str | None = Field(
default=None, description="Truncated preview of the card description"
)
class DeckStack(BaseModel): class DeckStack(BaseModel):
id: int id: int
title: str title: str
@@ -136,7 +174,9 @@ class DeckStack(BaseModel):
order: int order: int
deletedAt: int deletedAt: int
lastModified: Optional[int] = None lastModified: Optional[int] = None
cards: Optional[List[DeckCard]] = None # Cards may be projected to DeckCardSummary when a tool is called with
# detail="summary" (the default for list tools).
cards: list[DeckCard | DeckCardSummary] | None = None
etag: Optional[str] = Field(default=None, alias="ETag") etag: Optional[str] = Field(default=None, alias="ETag")
@@ -175,6 +215,20 @@ class DeckComment(BaseModel):
replyTo: Optional[Any] = None # Self-referencing, handle later if needed replyTo: Optional[Any] = None # Self-referencing, handle later if needed
class DeckCommentSummary(BaseModel):
"""Compact projection of a :class:`DeckComment` for list views.
Drops ``mentions``, ``actorType``, ``actorDisplayName`` and ``replyTo``,
keeping only the fields needed to read the conversation. Long messages are
truncated by the tool layer via ``message_max_length``.
"""
id: int
actorId: str
message: str
creationDateTime: datetime
class DeckSession(BaseModel): class DeckSession(BaseModel):
token: str token: str
@@ -220,6 +274,36 @@ class ListStacksResponse(BaseResponse):
total: int = Field(description="Total number of stacks") total: int = Field(description="Total number of stacks")
class StackOverview(BaseModel):
"""A stack plus its compact card rows, for the board-overview tool."""
id: int = Field(description="Stack ID")
title: str = Field(description="Stack title")
order: int | None = Field(default=None, description="Stack sort order")
card_count: int = Field(description="Number of cards returned for this stack")
cards: list[DeckCardSummary] = Field(
default_factory=list, description="Compact card rows in this stack"
)
class BoardOverviewResponse(BaseResponse):
"""Compact whole-board snapshot: board → stacks → summary card rows.
A single-call replacement for deck_get_board + deck_get_stacks that keeps
the response small enough to fit the token budget on large boards.
"""
board_id: int = Field(description="Board ID")
title: str = Field(description="Board title")
labels: list[str] = Field(
default_factory=list, description="Board label titles (legend)"
)
stacks: list[StackOverview] = Field(
default_factory=list, description="Stacks with compact card rows"
)
total_cards: int = Field(description="Total cards across all returned stacks")
class CreateStackResponse(BaseResponse): class CreateStackResponse(BaseResponse):
"""Response model for stack creation.""" """Response model for stack creation."""
@@ -269,7 +353,9 @@ class CreateLabelResponse(BaseResponse):
class ListCardsResponse(BaseResponse): class ListCardsResponse(BaseResponse):
"""Response model for listing deck cards.""" """Response model for listing deck cards."""
cards: list[DeckCard] = Field(description="List of deck cards") cards: list[DeckCard | DeckCardSummary] = Field(
description="List of deck cards (summaries unless detail='full')"
)
total: int = Field(description="Total number of cards") total: int = Field(description="Total number of cards")
@@ -293,7 +379,9 @@ class LabelOperationResponse(StatusResponse):
class ListCardCommentsResponse(BaseResponse): class ListCardCommentsResponse(BaseResponse):
"""Response model for listing card comments.""" """Response model for listing card comments."""
results: list[DeckComment] = Field(description="Card comments in this page") results: list[DeckComment | DeckCommentSummary] = Field(
description="Card comments in this page (summaries unless detail='full')"
)
count: int = Field( count: int = Field(
description=( description=(
"Number of comments returned in this page (page size, not the " "Number of comments returned in this page (page size, not the "
+389 -67
View File
@@ -1,4 +1,5 @@
import logging import logging
from typing import Literal, cast
import anyio import anyio
from mcp.server.fastmcp import Context, FastMCP from mcp.server.fastmcp import Context, FastMCP
@@ -9,6 +10,7 @@ from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.deck import ( from nextcloud_mcp_server.models.deck import (
AttachFileResponse, AttachFileResponse,
AttachmentOperationResponse, AttachmentOperationResponse,
BoardOverviewResponse,
CardCommentOperationResponse, CardCommentOperationResponse,
CardCommentResponse, CardCommentResponse,
CardOperationResponse, CardOperationResponse,
@@ -16,10 +18,15 @@ from nextcloud_mcp_server.models.deck import (
CreateCardResponse, CreateCardResponse,
CreateLabelResponse, CreateLabelResponse,
CreateStackResponse, CreateStackResponse,
DeckAssignedUser,
DeckBoard, DeckBoard,
DeckCard, DeckCard,
DeckCardSummary,
DeckComment,
DeckCommentSummary,
DeckLabel, DeckLabel,
DeckStack, DeckStack,
DeckUser,
LabelOperationResponse, LabelOperationResponse,
ListAttachmentsResponse, ListAttachmentsResponse,
ListBoardsResponse, ListBoardsResponse,
@@ -28,18 +35,33 @@ from nextcloud_mcp_server.models.deck import (
ListLabelsResponse, ListLabelsResponse,
ListStacksResponse, ListStacksResponse,
StackOperationResponse, StackOperationResponse,
StackOverview,
) )
from nextcloud_mcp_server.observability.metrics import instrument_tool from nextcloud_mcp_server.observability.metrics import instrument_tool
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Card status filter applied before serialization. "open" (the default for
# list tools) hides archived and explicitly-done cards — the actionable set.
CardStatus = Literal["all", "open", "done", "archived"]
# Per-card detail level. "summary" (the default for list tools) projects each
# card to a compact DeckCardSummary; "full" returns the heavy DeckCard.
DetailLevel = Literal["summary", "full"]
# Default length for the description preview carried in card summaries.
_DEFAULT_DESCRIPTION_PREVIEW = 140
def _validate_description_max_length(description_max_length: int | None) -> None:
"""Tool-layer guard: reject zero/negative truncation thresholds.""" def _validate_positive_length(
if description_max_length is not None and description_max_length <= 0: value: int | None, name: str = "description_max_length"
raise ValueError( ) -> None:
f"description_max_length must be positive, got {description_max_length}" """Tool-layer guard: reject zero/negative length thresholds.
)
Reused for every positive-length knob (description truncation/preview,
comment message truncation); ``name`` keeps the error message pointed at
the parameter the caller actually passed.
"""
if value is not None and value <= 0:
raise ValueError(f"{name} must be positive, got {value}")
def _truncate_card_descriptions( def _truncate_card_descriptions(
@@ -71,38 +93,161 @@ def _apply_board_filters(
return board return board
def _extract_uid(user: "DeckUser | DeckAssignedUser") -> str | None:
"""Pull the bare UID out of either assigned-user shape the API returns."""
if isinstance(user, DeckAssignedUser):
return user.participant.uid
if isinstance(user, DeckUser):
return user.uid
return None
def _filter_cards(
cards: list[DeckCard],
*,
status: CardStatus,
label: str | None,
assigned_to: str | None,
) -> list[DeckCard]:
"""Narrow a flat card list by status/label/assignee before serialization.
The upstream Deck API returns every card (including archived ones) inline,
so this filtering reduces the tokens the caller sees but not network
bandwidth.
``open``/``done``/``archived`` partition the cards (no overlap): a card
that is both done and archived is reported only under ``archived``, since
archiving is the stronger "off the active board" state.
"""
if status == "open":
cards = [c for c in cards if not c.archived and c.done is None]
elif status == "done":
cards = [c for c in cards if c.done is not None and not c.archived]
elif status == "archived":
cards = [c for c in cards if c.archived]
# status == "all": no status filter
if label is not None:
cards = [
c for c in cards if any(lbl.title == label for lbl in (c.labels or []))
]
if assigned_to is not None:
cards = [
c
for c in cards
if assigned_to in {_extract_uid(u) for u in (c.assignedUsers or [])}
]
return cards
def _summarize_card(card: DeckCard, description_preview_length: int) -> DeckCardSummary:
"""Project a full DeckCard down to its compact DeckCardSummary."""
description = card.description or ""
has_description = bool(description.strip())
preview: str | None = None
if has_description:
preview = description[:description_preview_length]
if len(description) > description_preview_length:
preview += ""
assignees = [
uid for u in (card.assignedUsers or []) if (uid := _extract_uid(u)) is not None
]
return DeckCardSummary(
id=card.id,
title=card.title,
stackId=card.stackId,
archived=card.archived,
duedate=card.duedate,
done=card.done,
labels=[lbl.title for lbl in (card.labels or [])],
assignedUsers=assignees,
attachmentCount=card.attachmentCount,
commentsUnread=card.commentsUnread,
hasDescription=has_description,
descriptionPreview=preview,
)
def _shape_cards(
cards: list[DeckCard],
*,
detail: DetailLevel,
status: CardStatus,
label: str | None,
assigned_to: str | None,
description_max_length: int | None,
description_preview_length: int,
) -> list[DeckCard | DeckCardSummary]:
"""Filter then project a card list according to the requested detail level."""
filtered = _filter_cards(cards, status=status, label=label, assigned_to=assigned_to)
if detail == "full":
_truncate_card_descriptions(filtered, description_max_length)
return list(filtered)
return [_summarize_card(c, description_preview_length) for c in filtered]
def _apply_stack_filters( def _apply_stack_filters(
stack: DeckStack, stack: DeckStack,
*, *,
include_cards: bool, include_cards: bool,
include_archived_cards: bool, detail: DetailLevel,
status: CardStatus,
label: str | None,
assigned_to: str | None,
description_max_length: int | None, description_max_length: int | None,
description_preview_length: int,
) -> DeckStack: ) -> DeckStack:
"""Apply card-shaping filters to a single stack; returns the stack.""" """Apply card filtering + projection to a single stack; returns the stack."""
# Note: the upstream Deck API returns archived cards inline within
# active stacks (the Deck UI filters them frontend-side). Defaulting
# include_archived_cards to False mirrors that UI behavior — this is
# the breaking change called out in the PR description.
if not include_cards: if not include_cards:
stack.cards = None stack.cards = None
elif stack.cards: elif stack.cards:
if not include_archived_cards: # Cards come straight from the client as DeckCard; the field type is a
stack.cards = [c for c in stack.cards if not c.archived] # union only because summary projection writes summaries back into it.
_truncate_card_descriptions(stack.cards, description_max_length) stack.cards = _shape_cards(
cast(list[DeckCard], stack.cards),
detail=detail,
status=status,
label=label,
assigned_to=assigned_to,
description_max_length=description_max_length,
description_preview_length=description_preview_length,
)
return stack return stack
def _apply_card_filters( def _truncate_comment_message(message: str, message_max_length: int | None) -> str:
cards: list[DeckCard], """Truncate a comment strictly longer than the limit; appends ""."""
if message_max_length is not None and len(message) > message_max_length:
return message[:message_max_length] + ""
return message
def _shape_comments(
comments: list[DeckComment],
*, *,
include_archived_cards: bool, detail: DetailLevel,
description_max_length: int | None, message_max_length: int | None,
) -> list[DeckCard]: order: Literal["newest", "oldest"],
"""Apply filters to a flat list of cards; returns the (possibly new) list.""" ) -> list[DeckComment | DeckCommentSummary]:
if not include_archived_cards: """Order, truncate and (optionally) project a page of card comments."""
cards = [c for c in cards if not c.archived] ordered = sorted(
_truncate_card_descriptions(cards, description_max_length) comments, key=lambda c: c.creationDateTime, reverse=(order == "newest")
return cards )
if detail == "full":
for comment in ordered:
comment.message = _truncate_comment_message(
comment.message, message_max_length
)
return list(ordered)
return [
DeckCommentSummary(
id=c.id,
actorId=c.actorId,
message=_truncate_comment_message(c.message, message_max_length),
creationDateTime=c.creationDateTime,
)
for c in ordered
]
# Card attachments — file shares ("Share from Files" picker in the Deck UI). # Card attachments — file shares ("Share from Files" picker in the Deck UI).
@@ -311,31 +456,53 @@ def configure_deck_tools(mcp: FastMCP):
ctx: Context, ctx: Context,
board_id: int, board_id: int,
include_cards: bool = True, include_cards: bool = True,
include_archived_cards: bool = False, detail: DetailLevel = "summary",
status: CardStatus = "open",
label: str | None = None,
assigned_to: str | None = None,
description_max_length: int | None = None, description_max_length: int | None = None,
description_preview_length: int = _DEFAULT_DESCRIPTION_PREVIEW,
) -> ListStacksResponse: ) -> ListStacksResponse:
"""Get all stacks in a Nextcloud Deck board. """Get all stacks in a Nextcloud Deck board.
Cards are returned as compact summaries by default to keep the
response small on large boards. Filtering/projection happen
client-side after the API returns the full board, so they reduce the
tokens the caller sees but not network bandwidth.
Args: Args:
board_id: The ID of the board board_id: The ID of the board
include_cards: Include cards inside each stack (default True). Set include_cards: Include cards inside each stack (default True). Set
False for a lightweight stack listing; fetch cards separately False for a lightweight stack listing; fetch cards separately
via deck_get_cards. via deck_get_cards.
include_archived_cards: Include archived cards (default False). detail: "summary" (default) returns compact card rows; "full"
Only relevant when include_cards is True. returns the complete card objects (the old behavior).
description_max_length: If set, truncate each card's description status: Which cards to include — "open" (default), "done",
to this many characters. Useful for keeping responses compact "archived", or "all". The first three partition the board
on boards with long card specs. (a card that is both done and archived counts as "archived").
label: If set, only cards carrying a label with this exact title.
assigned_to: If set, only cards assigned to this user UID.
description_max_length: In detail="full", truncate each card's
description to this many characters.
description_preview_length: In detail="summary", length of the
description preview carried on each card (default 140).
""" """
_validate_description_max_length(description_max_length) _validate_positive_length(description_max_length)
_validate_positive_length(
description_preview_length, "description_preview_length"
)
client = await get_client(ctx) client = await get_client(ctx)
stacks = await client.deck.get_stacks(board_id) stacks = await client.deck.get_stacks(board_id)
stacks = [ stacks = [
_apply_stack_filters( _apply_stack_filters(
stack, stack,
include_cards=include_cards, include_cards=include_cards,
include_archived_cards=include_archived_cards, detail=detail,
status=status,
label=label,
assigned_to=assigned_to,
description_max_length=description_max_length, description_max_length=description_max_length,
description_preview_length=description_preview_length,
) )
for stack in stacks for stack in stacks
] ]
@@ -352,28 +519,45 @@ def configure_deck_tools(mcp: FastMCP):
board_id: int, board_id: int,
stack_id: int, stack_id: int,
include_cards: bool = True, include_cards: bool = True,
include_archived_cards: bool = False, detail: DetailLevel = "summary",
status: CardStatus = "open",
label: str | None = None,
assigned_to: str | None = None,
description_max_length: int | None = None, description_max_length: int | None = None,
description_preview_length: int = _DEFAULT_DESCRIPTION_PREVIEW,
) -> DeckStack: ) -> DeckStack:
"""Get details of a specific Nextcloud Deck stack. """Get details of a specific Nextcloud Deck stack.
Cards are returned as compact summaries by default; see
deck_get_stacks for the shared parameter semantics.
Args: Args:
board_id: The ID of the board board_id: The ID of the board
stack_id: The ID of the stack stack_id: The ID of the stack
include_cards: Include cards in the stack (default True). include_cards: Include cards in the stack (default True).
include_archived_cards: Include archived cards (default False). detail: "summary" (default) or "full".
Only relevant when include_cards is True. status: "open" (default), "done", "archived", or "all"
description_max_length: If set, truncate each card's description (non-overlapping; a done+archived card counts as "archived").
to this many characters. label: If set, only cards carrying a label with this exact title.
assigned_to: If set, only cards assigned to this user UID.
description_max_length: In detail="full", truncate descriptions.
description_preview_length: In detail="summary", preview length.
""" """
_validate_description_max_length(description_max_length) _validate_positive_length(description_max_length)
_validate_positive_length(
description_preview_length, "description_preview_length"
)
client = await get_client(ctx) client = await get_client(ctx)
stack = await client.deck.get_stack(board_id, stack_id) stack = await client.deck.get_stack(board_id, stack_id)
return _apply_stack_filters( return _apply_stack_filters(
stack, stack,
include_cards=include_cards, include_cards=include_cards,
include_archived_cards=include_archived_cards, detail=detail,
status=status,
label=label,
assigned_to=assigned_to,
description_max_length=description_max_length, description_max_length=description_max_length,
description_preview_length=description_preview_length,
) )
@mcp.tool( @mcp.tool(
@@ -385,7 +569,11 @@ def configure_deck_tools(mcp: FastMCP):
async def deck_get_archived_stacks( async def deck_get_archived_stacks(
ctx: Context, ctx: Context,
board_id: int, board_id: int,
detail: DetailLevel = "summary",
label: str | None = None,
assigned_to: str | None = None,
description_max_length: int | None = None, description_max_length: int | None = None,
description_preview_length: int = _DEFAULT_DESCRIPTION_PREVIEW,
) -> ListStacksResponse: ) -> ListStacksResponse:
"""List archived stacks (with their archived cards) for a Nextcloud """List archived stacks (with their archived cards) for a Nextcloud
Deck board. Deck board.
@@ -395,26 +583,38 @@ def configure_deck_tools(mcp: FastMCP):
archived via deck_archive_card). The shape mirrors deck_get_stacks. archived via deck_archive_card). The shape mirrors deck_get_stacks.
Cards are always included on the returned stacks (an archived stack Cards are always included on the returned stacks (an archived stack
without its cards would have no audit value); pass without its cards would have no audit value) and returned as compact
``description_max_length`` if you need to keep the response compact. summaries by default. There is no ``status`` filter — every card here
is archived by definition — but ``label``/``assigned_to`` narrow the
set just like the active-stack tools.
Args: Args:
board_id: The ID of the board board_id: The ID of the board
description_max_length: If set, truncate each card's description detail: "summary" (default) or "full".
to this many characters. label: If set, only cards carrying a label with this exact title.
assigned_to: If set, only cards assigned to this user UID.
description_max_length: In detail="full", truncate descriptions.
description_preview_length: In detail="summary", preview length.
""" """
_validate_description_max_length(description_max_length) _validate_positive_length(description_max_length)
_validate_positive_length(
description_preview_length, "description_preview_length"
)
client = await get_client(ctx) client = await get_client(ctx)
stacks = await client.deck.get_archived_stacks(board_id) stacks = await client.deck.get_archived_stacks(board_id)
# All cards in archived stacks are themselves archived; route through # All cards in archived stacks are themselves archived; status="all"
# the same helper as the active-stack path so future filter additions # keeps them (an "open"/"done" filter would drop the whole point).
# apply uniformly. # label/assigned_to still apply for targeted audits.
stacks = [ stacks = [
_apply_stack_filters( _apply_stack_filters(
stack, stack,
include_cards=True, include_cards=True,
include_archived_cards=True, detail=detail,
status="all",
label=label,
assigned_to=assigned_to,
description_max_length=description_max_length, description_max_length=description_max_length,
description_preview_length=description_preview_length,
) )
for stack in stacks for stack in stacks
] ]
@@ -430,36 +630,136 @@ def configure_deck_tools(mcp: FastMCP):
ctx: Context, ctx: Context,
board_id: int, board_id: int,
stack_id: int, stack_id: int,
include_archived_cards: bool = False, detail: DetailLevel = "summary",
status: CardStatus = "open",
label: str | None = None,
assigned_to: str | None = None,
description_max_length: int | None = None, description_max_length: int | None = None,
description_preview_length: int = _DEFAULT_DESCRIPTION_PREVIEW,
) -> ListCardsResponse: ) -> ListCardsResponse:
"""Get all cards in a Nextcloud Deck stack. """Get all cards in a Nextcloud Deck stack.
Filtering is applied client-side after the API returns the full Cards are returned as compact summaries by default. Filtering and
stack, so ``include_archived_cards=False`` and projection are applied client-side after the API returns the full
``description_max_length`` reduce response size visible to the stack, so they reduce the tokens the caller sees but not network
caller but not network bandwidth — network-wise this tool is bandwidth — network-wise this tool is equivalent to
equivalent to deck_get_stack(include_cards=True). deck_get_stack(include_cards=True).
Args: Args:
board_id: The ID of the board board_id: The ID of the board
stack_id: The ID of the stack stack_id: The ID of the stack
include_archived_cards: Include archived cards (default False). detail: "summary" (default) returns compact card rows; "full"
Archived cards can also be retrieved per-board via returns the complete card objects.
deck_get_archived_stacks. status: "open" (default), "done", "archived", or "all". The first
description_max_length: If set, truncate each card's description three partition the board (a done+archived card counts as
to this many characters. "archived").
label: If set, only cards carrying a label with this exact title.
assigned_to: If set, only cards assigned to this user UID.
description_max_length: In detail="full", truncate descriptions.
description_preview_length: In detail="summary", preview length.
""" """
_validate_description_max_length(description_max_length) _validate_positive_length(description_max_length)
_validate_positive_length(
description_preview_length, "description_preview_length"
)
client = await get_client(ctx) client = await get_client(ctx)
stack = await client.deck.get_stack(board_id, stack_id) stack = await client.deck.get_stack(board_id, stack_id)
cards = _apply_card_filters( cards = _shape_cards(
stack.cards or [], cast(list[DeckCard], stack.cards or []),
include_archived_cards=include_archived_cards, detail=detail,
status=status,
label=label,
assigned_to=assigned_to,
description_max_length=description_max_length, description_max_length=description_max_length,
description_preview_length=description_preview_length,
) )
return ListCardsResponse(cards=cards, total=len(cards)) return ListCardsResponse(cards=cards, total=len(cards))
@mcp.tool(
title="Get Deck Board Overview",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("deck.read")
@instrument_tool
async def deck_get_board_overview(
ctx: Context,
board_id: int,
status: CardStatus = "open",
label: str | None = None,
assigned_to: str | None = None,
description_preview_length: int = _DEFAULT_DESCRIPTION_PREVIEW,
) -> BoardOverviewResponse:
"""Get a compact, whole-board snapshot in a single call.
Returns the board title, its label legend, and every stack with its
cards projected to compact summary rows. Prefer it for "show me the
board" / "what's in progress" style requests on large boards — it is
the token-efficient way to view board *state*. It intentionally omits
the board-management fields (ACL, user list, full label objects) that
deck_get_board exposes; reach for deck_get_board when you need those.
Args:
board_id: The ID of the board
status: Which cards to include — "open" (default), "done",
"archived", or "all". The first three partition the board
(a card that is both done and archived counts as "archived").
label: If set, only cards carrying a label with this exact title.
assigned_to: If set, only cards assigned to this user UID.
description_preview_length: Length of the description preview
carried on each card summary (default 140).
"""
_validate_positive_length(
description_preview_length, "description_preview_length"
)
client = await get_client(ctx)
board_holder: list[DeckBoard] = []
stacks_holder: list[list[DeckStack]] = []
async def _get_board() -> None:
board_holder.append(await client.deck.get_board(board_id))
async def _get_stacks() -> None:
stacks_holder.append(await client.deck.get_stacks(board_id))
async with anyio.create_task_group() as tg:
tg.start_soon(_get_board)
tg.start_soon(_get_stacks)
board = board_holder[0]
stacks = stacks_holder[0]
stack_overviews: list[StackOverview] = []
total_cards = 0
for stack in stacks:
summaries = [
_summarize_card(c, description_preview_length)
for c in _filter_cards(
cast(list[DeckCard], stack.cards or []),
status=status,
label=label,
assigned_to=assigned_to,
)
]
total_cards += len(summaries)
stack_overviews.append(
StackOverview(
id=stack.id,
title=stack.title,
order=stack.order,
card_count=len(summaries),
cards=summaries,
)
)
return BoardOverviewResponse(
board_id=board.id,
title=board.title,
labels=[lbl.title for lbl in (board.labels or [])],
stacks=stack_overviews,
total_cards=total_cards,
)
@mcp.tool( @mcp.tool(
title="Get Deck Card", title="Get Deck Card",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True), annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
@@ -1016,18 +1316,40 @@ def configure_deck_tools(mcp: FastMCP):
@require_scopes("deck.read") @require_scopes("deck.read")
@instrument_tool @instrument_tool
async def deck_get_card_comments( async def deck_get_card_comments(
ctx: Context, card_id: int, limit: int = 20, offset: int = 0 ctx: Context,
card_id: int,
limit: int = 20,
offset: int = 0,
detail: DetailLevel = "summary",
message_max_length: int | None = None,
order: Literal["newest", "oldest"] = "newest",
) -> ListCardCommentsResponse: ) -> ListCardCommentsResponse:
"""List comments on a Nextcloud Deck card """List comments on a Nextcloud Deck card.
Returns compact comments by default (dropping mentions, actor type and
display name). Ordering and truncation apply within the returned page.
Args: Args:
card_id: The ID of the card card_id: The ID of the card
limit: Maximum number of comments to return (default 20, max 200) limit: Maximum number of comments to return (default 20, max 200)
offset: Pagination offset (default 0) offset: Pagination offset (default 0)
detail: "summary" (default) returns compact comments; "full"
returns the complete comment objects.
message_max_length: If set, truncate each comment message to this
many characters.
order: "newest" (default) or "oldest" — sort the page by creation
time.
""" """
_validate_positive_length(message_max_length, "message_max_length")
client = await get_client(ctx) client = await get_client(ctx)
comments = await client.deck.get_comments(card_id, limit=limit, offset=offset) comments = await client.deck.get_comments(card_id, limit=limit, offset=offset)
return ListCardCommentsResponse(results=comments, count=len(comments)) shaped = _shape_comments(
comments,
detail=detail,
message_max_length=message_max_length,
order=order,
)
return ListCardCommentsResponse(results=shaped, count=len(shaped))
@mcp.tool( @mcp.tool(
title="Create Deck Card Comment", title="Create Deck Card Comment",
+68
View File
@@ -353,3 +353,71 @@ async def test_deck_card_comment_message_too_long_mcp(
{"card_id": card_id, "message": too_long}, {"card_id": card_id, "message": too_long},
) )
assert result.isError is True, "Expected validation error for >1000 char message" assert result.isError is True, "Expected validation error for >1000 char message"
# Compact retrieval (summary projection + board overview)
async def test_deck_get_stacks_summary_default_omits_full_card_fields_mcp(
nc_mcp_client: ClientSession, temporary_board_with_card: tuple
):
"""deck_get_stacks defaults to compact summaries: the card row carries
title/labels but not the heavy full-card fields (owner/type/etag)."""
board_data, stack_data, card_data = temporary_board_with_card
board_id = board_data["id"]
result = await nc_mcp_client.call_tool("deck_get_stacks", {"board_id": board_id})
assert result.isError is False, f"deck_get_stacks failed: {result.content}"
payload = json.loads(result.content[0].text)
cards = [c for stack in payload["stacks"] for c in (stack.get("cards") or [])]
card = next(c for c in cards if c["id"] == card_data["id"])
# Summary fields present...
assert card["title"] == card_data["title"]
assert "hasDescription" in card
assert "labels" in card and isinstance(card["labels"], list)
# ...heavy full-card fields absent.
assert "owner" not in card
assert "type" not in card
async def test_deck_get_stacks_detail_full_keeps_card_fields_mcp(
nc_mcp_client: ClientSession, temporary_board_with_card: tuple
):
"""detail="full" restores the complete card objects (owner/type present)."""
board_data, _, card_data = temporary_board_with_card
board_id = board_data["id"]
result = await nc_mcp_client.call_tool(
"deck_get_stacks", {"board_id": board_id, "detail": "full"}
)
assert result.isError is False, f"deck_get_stacks(full) failed: {result.content}"
payload = json.loads(result.content[0].text)
cards = [c for stack in payload["stacks"] for c in (stack.get("cards") or [])]
card = next(c for c in cards if c["id"] == card_data["id"])
assert "owner" in card
assert "type" in card
async def test_deck_get_board_overview_mcp(
nc_mcp_client: ClientSession, temporary_board_with_card: tuple
):
"""deck_get_board_overview returns board title + stacks with compact rows."""
board_data, stack_data, card_data = temporary_board_with_card
board_id = board_data["id"]
result = await nc_mcp_client.call_tool(
"deck_get_board_overview", {"board_id": board_id}
)
assert result.isError is False, f"board overview failed: {result.content}"
payload = json.loads(result.content[0].text)
assert payload["board_id"] == board_id
assert payload["title"] == board_data["title"]
assert payload["total_cards"] >= 1
stack = next(s for s in payload["stacks"] if s["id"] == stack_data["id"])
assert stack["card_count"] == len(stack["cards"])
card_ids = [c["id"] for c in stack["cards"]]
assert card_data["id"] in card_ids
+286 -87
View File
@@ -1,9 +1,15 @@
from datetime import datetime, timezone
import pytest import pytest
from nextcloud_mcp_server.models.deck import ( from nextcloud_mcp_server.models.deck import (
DeckACL, DeckACL,
DeckAssignedUser,
DeckBoard, DeckBoard,
DeckCard, DeckCard,
DeckCardSummary,
DeckComment,
DeckCommentSummary,
DeckLabel, DeckLabel,
DeckPermissions, DeckPermissions,
DeckStack, DeckStack,
@@ -12,12 +18,17 @@ from nextcloud_mcp_server.models.deck import (
from nextcloud_mcp_server.server.deck import ( from nextcloud_mcp_server.server.deck import (
_SHARE_TYPE_DECK, _SHARE_TYPE_DECK,
_apply_board_filters, _apply_board_filters,
_apply_card_filters,
_apply_stack_filters, _apply_stack_filters,
_extract_uid,
_filter_cards,
_resolve_note_attach_path, _resolve_note_attach_path,
_resolve_note_path, _resolve_note_path,
_shape_cards,
_shape_comments,
_summarize_card,
_truncate_card_descriptions, _truncate_card_descriptions,
_validate_description_max_length, _truncate_comment_message,
_validate_positive_length,
) )
pytestmark = pytest.mark.unit pytestmark = pytest.mark.unit
@@ -30,6 +41,12 @@ def _make_card(
card_id: int, card_id: int,
description: str | None = "desc", description: str | None = "desc",
archived: bool = False, archived: bool = False,
*,
done: datetime | None = None,
labels: list[DeckLabel] | None = None,
assigned_users: list | None = None,
attachment_count: int | None = None,
comments_unread: int | None = None,
) -> DeckCard: ) -> DeckCard:
return DeckCard( return DeckCard(
id=card_id, id=card_id,
@@ -40,6 +57,30 @@ def _make_card(
archived=archived, archived=archived,
owner="testuser", owner="testuser",
description=description, description=description,
done=done,
labels=labels,
assignedUsers=assigned_users,
attachmentCount=attachment_count,
commentsUnread=comments_unread,
)
def _make_comment(
comment_id: int,
message: str = "hello",
*,
actor: str = "alice",
created: datetime | None = None,
) -> DeckComment:
return DeckComment(
id=comment_id,
objectId=1,
message=message,
actorId=actor,
actorType="users",
actorDisplayName=actor.title(),
creationDateTime=created or datetime(2024, 1, comment_id, tzinfo=timezone.utc),
mentions=[],
) )
@@ -141,30 +182,30 @@ def test_truncate_card_descriptions_shorter_than_limit_no_ellipsis():
assert cards[0].description == "hello" assert cards[0].description == "hello"
# _validate_description_max_length ---------------------------------------- # _validate_positive_length ----------------------------------------
def test_validate_description_max_length_accepts_none(): def test_validate_positive_length_accepts_none():
"""None is the documented sentinel for "no truncation".""" """None is the documented sentinel for "no truncation"."""
_validate_description_max_length(None) _validate_positive_length(None)
def test_validate_description_max_length_accepts_positive(): def test_validate_positive_length_accepts_positive():
"""Positive values pass through silently.""" """Positive values pass through silently."""
_validate_description_max_length(1) _validate_positive_length(1)
_validate_description_max_length(1000) _validate_positive_length(1000)
def test_validate_description_max_length_rejects_zero(): def test_validate_positive_length_rejects_zero():
"""Zero would wipe descriptions to a single ellipsis — reject at the boundary.""" """Zero would wipe descriptions to a single ellipsis — reject at the boundary."""
with pytest.raises(ValueError, match="must be positive"): with pytest.raises(ValueError, match="must be positive"):
_validate_description_max_length(0) _validate_positive_length(0)
def test_validate_description_max_length_rejects_negative(): def test_validate_positive_length_rejects_negative():
"""Negative values produce surprising slice semantics — reject at the boundary.""" """Negative values produce surprising slice semantics — reject at the boundary."""
with pytest.raises(ValueError, match="must be positive"): with pytest.raises(ValueError, match="must be positive"):
_validate_description_max_length(-10) _validate_positive_length(-10)
# _apply_board_filters ------------------------------------------------------ # _apply_board_filters ------------------------------------------------------
@@ -231,67 +272,231 @@ def test_apply_board_filters_excludes_all():
assert result.labels == [] assert result.labels == []
# Shared default kwargs for _apply_stack_filters in summary mode ------------
_STACK_DEFAULTS = dict(
detail="summary",
status="open",
label=None,
assigned_to=None,
description_max_length=None,
description_preview_length=140,
)
# _filter_cards -------------------------------------------------------------
def test_filter_cards_open_excludes_archived_and_done():
"""status="open" drops both archived and explicitly-done cards."""
done_at = datetime(2024, 1, 1, tzinfo=timezone.utc)
cards = [
_make_card(1),
_make_card(2, archived=True),
_make_card(3, done=done_at),
]
result = _filter_cards(cards, status="open", label=None, assigned_to=None)
assert [c.id for c in result] == [1]
def test_filter_cards_done_keeps_only_done_and_not_archived():
"""status="done" keeps done cards that are not archived."""
done_at = datetime(2024, 1, 1, tzinfo=timezone.utc)
cards = [_make_card(1), _make_card(2, done=done_at)]
result = _filter_cards(cards, status="done", label=None, assigned_to=None)
assert [c.id for c in result] == [2]
def test_filter_cards_archived_keeps_only_archived():
"""status="archived" keeps only archived cards."""
cards = [_make_card(1), _make_card(2, archived=True)]
result = _filter_cards(cards, status="archived", label=None, assigned_to=None)
assert [c.id for c in result] == [2]
def test_filter_cards_statuses_partition_the_board():
"""open/done/archived are non-overlapping; a done+archived card counts
only as "archived", not "done"."""
done_at = datetime(2024, 1, 1, tzinfo=timezone.utc)
open_card = _make_card(1)
done_card = _make_card(2, done=done_at)
done_and_archived = _make_card(3, done=done_at, archived=True)
cards = [open_card, done_card, done_and_archived]
assert [
c.id for c in _filter_cards(cards, status="open", label=None, assigned_to=None)
] == [1]
assert [
c.id for c in _filter_cards(cards, status="done", label=None, assigned_to=None)
] == [2]
assert [
c.id
for c in _filter_cards(cards, status="archived", label=None, assigned_to=None)
] == [3]
def test_filter_cards_all_keeps_everything():
"""status="all" applies no status filter."""
cards = [_make_card(1), _make_card(2, archived=True)]
result = _filter_cards(cards, status="all", label=None, assigned_to=None)
assert [c.id for c in result] == [1, 2]
def test_filter_cards_by_label_matches_title_exactly():
"""label filtering matches the exact label title."""
a = _make_card(1, labels=[DeckLabel(id=1, title="bug", color="f00")])
b = _make_card(2, labels=[DeckLabel(id=2, title="feature", color="0f0")])
result = _filter_cards([a, b], status="all", label="bug", assigned_to=None)
assert [c.id for c in result] == [1]
def test_filter_cards_by_assignee_handles_both_user_shapes():
"""assigned_to matches DeckUser and DeckAssignedUser shapes alike."""
direct = _make_card(1, assigned_users=[_make_user("alice")])
wrapped = _make_card(
2,
assigned_users=[
DeckAssignedUser(id=9, participant=_make_user("bob"), cardId=2, type=0)
],
)
result = _filter_cards(
[direct, wrapped], status="all", label=None, assigned_to="bob"
)
assert [c.id for c in result] == [2]
# _extract_uid --------------------------------------------------------------
def test_extract_uid_from_deck_user():
assert _extract_uid(_make_user("alice")) == "alice"
def test_extract_uid_from_assigned_user():
assigned = DeckAssignedUser(id=1, participant=_make_user("bob"), cardId=1, type=0)
assert _extract_uid(assigned) == "bob"
# _summarize_card -----------------------------------------------------------
def test_summarize_card_projects_compact_fields():
"""Summary carries flat label titles, assignee uids, counts, and a preview."""
card = _make_card(
1,
description="x" * 50,
labels=[DeckLabel(id=1, title="bug", color="f00")],
assigned_users=[_make_user("alice")],
attachment_count=3,
comments_unread=2,
)
summary = _summarize_card(card, description_preview_length=10)
assert isinstance(summary, DeckCardSummary)
assert summary.labels == ["bug"]
assert summary.assignedUsers == ["alice"]
assert summary.attachmentCount == 3
assert summary.commentsUnread == 2
assert summary.hasDescription is True
assert summary.descriptionPreview is not None
assert summary.descriptionPreview.endswith("")
assert len(summary.descriptionPreview) == 11 # 10 chars + ellipsis
def test_summarize_card_short_description_has_no_ellipsis():
"""A description within the preview length is carried verbatim."""
summary = _summarize_card(_make_card(1, description="hi"), 140)
assert summary.descriptionPreview == "hi"
assert summary.hasDescription is True
def test_summarize_card_empty_description():
"""An empty/whitespace description yields hasDescription=False, no preview."""
summary = _summarize_card(_make_card(1, description=" "), 140)
assert summary.hasDescription is False
assert summary.descriptionPreview is None
# _shape_cards --------------------------------------------------------------
def test_shape_cards_summary_returns_summaries():
"""detail="summary" projects every (filtered) card to a DeckCardSummary."""
cards = [_make_card(1), _make_card(2, archived=True)]
result = _shape_cards(
cards,
detail="summary",
status="open",
label=None,
assigned_to=None,
description_max_length=None,
description_preview_length=140,
)
assert [type(c) for c in result] == [DeckCardSummary]
assert result[0].id == 1
def test_shape_cards_full_returns_truncated_full_cards():
"""detail="full" returns DeckCard objects with descriptions truncated."""
cards = [_make_card(1, description="x" * 50)]
result = _shape_cards(
cards,
detail="full",
status="all",
label=None,
assigned_to=None,
description_max_length=10,
description_preview_length=140,
)
assert isinstance(result[0], DeckCard)
assert result[0].description is not None
assert result[0].description.endswith("")
# _apply_stack_filters ------------------------------------------------------ # _apply_stack_filters ------------------------------------------------------
def test_apply_stack_filters_include_cards_false_strips_cards(): def test_apply_stack_filters_include_cards_false_strips_cards():
"""include_cards=False sets cards to None regardless of other flags.""" """include_cards=False sets cards to None regardless of other flags."""
stack = _make_stack(cards=[_make_card(1), _make_card(2, archived=True)]) stack = _make_stack(cards=[_make_card(1), _make_card(2, archived=True)])
result = _apply_stack_filters( result = _apply_stack_filters(stack, include_cards=False, **_STACK_DEFAULTS)
stack,
include_cards=False,
include_archived_cards=True,
description_max_length=None,
)
assert result.cards is None assert result.cards is None
def test_apply_stack_filters_excludes_archived_by_default(): def test_apply_stack_filters_summary_excludes_archived_by_default():
"""include_archived_cards=False filters out archived cards.""" """Default status="open" filters out archived cards and returns summaries."""
stack = _make_stack( stack = _make_stack(
cards=[_make_card(1, archived=False), _make_card(2, archived=True)] cards=[_make_card(1, archived=False), _make_card(2, archived=True)]
) )
result = _apply_stack_filters( result = _apply_stack_filters(stack, include_cards=True, **_STACK_DEFAULTS)
stack,
include_cards=True,
include_archived_cards=False,
description_max_length=None,
)
assert result.cards is not None assert result.cards is not None
assert [c.id for c in result.cards] == [1] assert [c.id for c in result.cards] == [1]
assert all(isinstance(c, DeckCardSummary) for c in result.cards)
def test_apply_stack_filters_keeps_archived_when_requested(): def test_apply_stack_filters_status_all_keeps_archived():
"""include_archived_cards=True retains archived cards.""" """status="all" retains archived cards."""
stack = _make_stack( stack = _make_stack(
cards=[_make_card(1, archived=False), _make_card(2, archived=True)] cards=[_make_card(1, archived=False), _make_card(2, archived=True)]
) )
result = _apply_stack_filters( kwargs = {**_STACK_DEFAULTS, "status": "all"}
stack, result = _apply_stack_filters(stack, include_cards=True, **kwargs)
include_cards=True,
include_archived_cards=True,
description_max_length=None,
)
assert result.cards is not None assert result.cards is not None
assert [c.id for c in result.cards] == [1, 2] assert [c.id for c in result.cards] == [1, 2]
def test_apply_stack_filters_truncates_descriptions_after_archive_filter(): def test_apply_stack_filters_full_truncates_descriptions_after_filter():
"""Truncation runs on the post-archive-filter card set.""" """detail="full" truncation runs on the post-status-filter card set."""
stack = _make_stack( stack = _make_stack(
cards=[ cards=[
_make_card(1, description="x" * 50, archived=False), _make_card(1, description="x" * 50, archived=False),
_make_card(2, description="y" * 50, archived=True), _make_card(2, description="y" * 50, archived=True),
] ]
) )
result = _apply_stack_filters( kwargs = {**_STACK_DEFAULTS, "detail": "full", "description_max_length": 10}
stack, result = _apply_stack_filters(stack, include_cards=True, **kwargs)
include_cards=True,
include_archived_cards=False,
description_max_length=10,
)
assert result.cards is not None assert result.cards is not None
assert len(result.cards) == 1 assert len(result.cards) == 1
assert isinstance(result.cards[0], DeckCard)
assert result.cards[0].description is not None assert result.cards[0].description is not None
assert result.cards[0].description.endswith("") assert result.cards[0].description.endswith("")
@@ -299,78 +504,72 @@ def test_apply_stack_filters_truncates_descriptions_after_archive_filter():
def test_apply_stack_filters_handles_none_cards(): def test_apply_stack_filters_handles_none_cards():
"""A stack with no cards (cards=None) is left untouched.""" """A stack with no cards (cards=None) is left untouched."""
stack = _make_stack(cards=None) stack = _make_stack(cards=None)
result = _apply_stack_filters( result = _apply_stack_filters(stack, include_cards=True, **_STACK_DEFAULTS)
stack,
include_cards=True,
include_archived_cards=False,
description_max_length=10,
)
assert result.cards is None assert result.cards is None
def test_apply_stack_filters_all_archived_yields_empty_list_not_none(): def test_apply_stack_filters_all_filtered_yields_empty_list_not_none():
"""A stack whose cards are all archived yields cards == [], not None. """A stack whose cards are all filtered out yields cards == [], not None.
Pin the contract: include_cards=True with all cards filtered out Pin the contract: include_cards=True with all cards filtered out
means "the stack was loaded but had nothing to show", which is means "the stack was loaded but had nothing to show", which is
semantically distinct from include_cards=False (cards=None, semantically distinct from include_cards=False (cards=None,
"explicitly suppressed"). Callers checking ``stack.cards is None`` "explicitly suppressed").
can use that to distinguish the two states.
""" """
stack = _make_stack( stack = _make_stack(
cards=[_make_card(1, archived=True), _make_card(2, archived=True)] cards=[_make_card(1, archived=True), _make_card(2, archived=True)]
) )
result = _apply_stack_filters( result = _apply_stack_filters(stack, include_cards=True, **_STACK_DEFAULTS)
stack,
include_cards=True,
include_archived_cards=False,
description_max_length=None,
)
assert result.cards == [] assert result.cards == []
assert result.cards is not None assert result.cards is not None
# _apply_card_filters ------------------------------------------------------- # _shape_comments -----------------------------------------------------------
def test_apply_card_filters_excludes_archived_by_default(): def test_shape_comments_summary_drops_actor_metadata():
"""include_archived_cards=False filters archived cards out of the flat list.""" """detail="summary" projects comments to compact DeckCommentSummary rows."""
cards = [ comments = [_make_comment(1, "hi", actor="alice")]
_make_card(1, archived=False), result = _shape_comments(
_make_card(2, archived=True), comments, detail="summary", message_max_length=None, order="newest"
_make_card(3, archived=False),
]
result = _apply_card_filters(
cards, include_archived_cards=False, description_max_length=None
) )
assert [c.id for c in result] == [1, 3] assert isinstance(result[0], DeckCommentSummary)
assert result[0].actorId == "alice"
assert result[0].message == "hi"
def test_apply_card_filters_keeps_archived_when_requested(): def test_shape_comments_newest_first():
"""include_archived_cards=True retains archived cards.""" """order="newest" sorts the page by creation time descending."""
cards = [_make_card(1, archived=False), _make_card(2, archived=True)] comments = [_make_comment(1), _make_comment(3), _make_comment(2)]
result = _apply_card_filters( result = _shape_comments(
cards, include_archived_cards=True, description_max_length=None comments, detail="summary", message_max_length=None, order="newest"
) )
assert [c.id for c in result] == [1, 2] assert [c.id for c in result] == [3, 2, 1]
def test_apply_card_filters_truncates_descriptions(): def test_shape_comments_oldest_first():
"""description_max_length is honored on the returned cards.""" """order="oldest" sorts the page by creation time ascending."""
cards = [_make_card(1, description="x" * 50)] comments = [_make_comment(3), _make_comment(1), _make_comment(2)]
result = _apply_card_filters( result = _shape_comments(
cards, include_archived_cards=True, description_max_length=10 comments, detail="summary", message_max_length=None, order="oldest"
) )
assert result[0].description is not None assert [c.id for c in result] == [1, 2, 3]
assert result[0].description.endswith("")
def test_apply_card_filters_empty_list_is_noop(): def test_shape_comments_full_truncates_message():
"""An empty input returns an empty output.""" """detail="full" keeps DeckComment but truncates long messages when asked."""
result = _apply_card_filters( comments = [_make_comment(1, "x" * 50)]
[], include_archived_cards=False, description_max_length=10 result = _shape_comments(
comments, detail="full", message_max_length=10, order="newest"
) )
assert result == [] assert isinstance(result[0], DeckComment)
assert result[0].message.endswith("")
assert len(result[0].message) == 11
def test_truncate_comment_message_no_op_when_within_limit():
assert _truncate_comment_message("short", 100) == "short"
assert _truncate_comment_message("short", None) == "short"
# _resolve_note_path ------------------------------------------------------- # _resolve_note_path -------------------------------------------------------