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:
co-authored by
Claude Opus 4.8
parent
ec075b4ed9
commit
437eaa0872
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user