feat(deck): add deck_move_card_to_board tool for cross-board moves

deck_reorder_card only relocated a card between stacks on the same board.
Moving a card to another board now has a dedicated tool that goes through
Deck's card-update route (CardService::update), which remaps the card's
board-scoped labels to the destination board by title instead of leaving
orphaned labels behind. Card identity (id, comments, attachments) is
preserved.

reorder_card is now restricted to same-board moves: it rejects a
target_stack_id on another board (which Deck's reorder route would accept
but with orphaned labels), steering clients to deck_move_card_to_board.

Verified empirically against Deck 1.15.9: the reorder route leaves a moved
card carrying its source board's label (boardId mismatch); the update route
remaps it to the destination board's same-titled label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-10 23:25:15 +02:00
co-authored by Claude Opus 4.8
parent ec075b4ed9
commit 437eaa0872
5 changed files with 452 additions and 4 deletions
+66
View File
@@ -386,6 +386,20 @@ class DeckClient(BaseNextcloudClient):
order: int,
target_stack_id: int,
) -> None:
# Reorder is intentionally restricted to moves *within* a single board.
# Deck's reorder route (CardService::reorder) only reassigns stackId and
# does NOT remap board-scoped labels, so handing it a stack on another
# board silently orphans the card's labels (they keep the old boardId).
# Cross-board moves must go through move_card_to_board(), which uses the
# card-update route (CardService::update) and remaps labels by title.
board_stack_ids = {stack.id for stack in await self.get_stacks(board_id)}
if target_stack_id not in board_stack_ids:
raise ValueError(
f"target_stack_id {target_stack_id} is not a stack on board "
f"{board_id}; reorder_card only moves cards within a board. "
"Use move_card_to_board() to move a card to another board."
)
# Use the non-API route /cards/{cardId}/reorder which correctly reads
# stackId from the body. The API route /api/.../stacks/{stackId}/cards/...
# has a parameter conflict where URL stackId overrides body stackId.
@@ -399,6 +413,58 @@ class DeckClient(BaseNextcloudClient):
headers=headers,
)
async def move_card_to_board(
self,
source_board_id: int,
source_stack_id: int,
card_id: int,
target_stack_id: int,
order: int = 0,
) -> DeckCard:
"""Move a card to a stack on a different (or the same) board.
Unlike :meth:`reorder_card`, this goes through the card-update route
(``CardService::update``, which honours ``stackId`` in the body). When
the destination stack is on a different board, Deck remaps the card's
board-scoped labels by title — assigning the same-titled label on the
destination board, or cloning it there when the user has board-manage
permission — instead of leaving orphaned labels behind. This mirrors
Deck's native "Move/copy card" action. Card identity (id, comments,
attachments) is preserved.
The card-update route is a full replacement, so the current card is
fetched first to preserve fields that are not being changed. The
internal ``/apps/deck/cards/{cardId}`` route is used (rather than the
board/stack-scoped API route) because the latter reads ``stackId`` from
the URL, which would override the target stack in the body — the same
parameter conflict that affects reorder (issue #469). The internal
route derives ``owner`` from the session user server-side.
"""
current = await self.get_card(source_board_id, source_stack_id, card_id)
json_data: Dict[str, Any] = {
# The route placeholder is {cardId} but the controller reads the
# card id from the body, so it must be sent explicitly.
"id": card_id,
"title": current.title,
"type": current.type,
"stackId": target_stack_id,
"order": order,
"description": current.description or "",
# ISO string preserves the due date; None leaves it cleared
"duedate": current.duedate.isoformat() if current.duedate else None,
# 0 keeps the card live (a positive value would soft-delete it)
"deletedAt": current.deletedAt or 0,
}
headers = self._get_deck_headers()
response = await self._make_request(
"PUT",
f"/apps/deck/cards/{card_id}",
json=json_data,
headers=headers,
)
return DeckCard(**response.json())
# Labels
async def get_label(self, board_id: int, label_id: int) -> DeckLabel:
headers = self._get_deck_headers()
+54 -3
View File
@@ -1212,7 +1212,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool(
title="Reorder/Move Deck Card",
title="Reorder Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck.write")
@@ -1225,14 +1225,19 @@ def configure_deck_tools(mcp: FastMCP):
order: int,
target_stack_id: int,
) -> CardOperationResponse:
"""Reorder/move a Nextcloud Deck card
"""Reorder a Nextcloud Deck card within a board.
Moves a card to a new position, optionally into a different stack on
the SAME board. To move a card to a stack on a DIFFERENT board, use
deck_move_card_to_board instead — reordering across boards is rejected
because it would orphan the card's board-scoped labels.
Args:
board_id: The ID of the board
stack_id: The ID of the current stack
card_id: The ID of the card
order: New position in the target stack
target_stack_id: The ID of the target stack
target_stack_id: The ID of the target stack (must be on board_id)
"""
client = await get_client(ctx)
await client.deck.reorder_card(
@@ -1246,6 +1251,52 @@ def configure_deck_tools(mcp: FastMCP):
board_id=board_id,
)
@mcp.tool(
title="Move Deck Card to Another Board",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck.write")
@instrument_tool
async def deck_move_card_to_board(
ctx: Context,
source_board_id: int,
source_stack_id: int,
card_id: int,
target_board_id: int,
target_stack_id: int,
order: int = 0,
) -> CardOperationResponse:
"""Move a Nextcloud Deck card to a stack on a different board.
The card keeps its identity (same id, comments, attachments). Deck
remaps the card's board-scoped labels to the destination board by
title — reusing a same-titled label there, or cloning it when you have
board-manage permission. Use deck_reorder_card for moves within a
single board.
The destination is determined by target_stack_id; target_board_id must
be the board that owns it (it is used to report the resulting location).
Args:
source_board_id: The ID of the board the card currently lives on
source_stack_id: The ID of the stack the card currently lives in
card_id: The ID of the card to move
target_board_id: The ID of the destination board (must own target_stack_id)
target_stack_id: The ID of the destination stack
order: Position within the destination stack (default 0 = top)
"""
client = await get_client(ctx)
await client.deck.move_card_to_board(
source_board_id, source_stack_id, card_id, target_stack_id, order
)
return CardOperationResponse(
success=True,
message="Card moved to board successfully",
card_id=card_id,
stack_id=target_stack_id,
board_id=target_board_id,
)
# Label Tools
@mcp.tool(
title="Create Deck Label",