Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save CodeShakingSheep/e6efe69f2f7082ceb590e8ce68fa2bfc to your computer and use it in GitHub Desktop.
Save CodeShakingSheep/e6efe69f2f7082ceb590e8ce68fa2bfc to your computer and use it in GitHub Desktop.
Nextcloud Deck Export/Import (Note that all comments will be created from the user account specified in the first lines of the script)
# You need to have the 'requests' module installed, see here: https://pypi.org/project/requests/
import requests
# Note regarding 2FA
# You can either disable 'Enforce 2FA' setting and disable '2FA'. Then you can just use your regular user password.
# Or you can just use an app password, e.g. named 'migration' which you can create in 'Personal settings' --> 'Security'. After successful migration you can delete the app password.
urlFrom = 'https://nextcloud.domainfrom.tld'
authFrom = ('username', 'user password or app password')
urlTo = 'https://nextcloud.domainto.tld'
authTo = ('username', 'user password or app password')
# Deck API documentation: https://deck.readthedocs.io/en/latest/API/
# Use API v1.1 with Deck >= 1.3.0
# For Deck >= 1.0.0 and < 1.3.0 change API version in deckApiPath to v1.0 (leave ocsApiPath unchanged)
# Note that exporting / importing attachments only works with API v.1.1
deckApiPath='index.php/apps/deck/api/v1.1'
ocsApiPath='ocs/v2.php/apps/deck/api/v1.0'
headers={'OCS-APIRequest': 'true', 'Content-Type': 'application/json'}
headersOcsJson={'OCS-APIRequest': 'true', 'Accept': 'application/json'}
def getBoards():
response = requests.get(
f'{urlFrom}/{deckApiPath}/boards',
auth=authFrom,
headers=headers)
response.raise_for_status()
return response.json()
def getBoardDetails(boardId):
response = requests.get(
f'{urlFrom}/{deckApiPath}/boards/{boardId}',
auth=authFrom,
headers=headers)
response.raise_for_status()
return response.json()
def getStacks(boardId):
response = requests.get(
f'{urlFrom}/{deckApiPath}/boards/{boardId}/stacks',
auth=authFrom,
headers=headers)
response.raise_for_status()
return response.json()
def getStacksArchived(boardId):
response = requests.get(
f'{urlFrom}/{deckApiPath}/boards/{boardId}/stacks/archived',
auth=authFrom,
headers=headers)
response.raise_for_status()
return response.json()
def getAttachments(boardId, stackId, cardId):
response = requests.get(
f'{urlFrom}/{deckApiPath}/boards/{boardId}/stacks/{stackId}/cards/{cardId}/attachments',
auth=authFrom,
headers=headers)
response.raise_for_status()
return response.json()
def getAttachment(path):
response = requests.get(
f'{urlFrom}/{path}',
auth=authFrom,
headers=headers)
response.raise_for_status()
return response
def getComments(cardId):
response = requests.get(
f'{urlFrom}/{ocsApiPath}/cards/{cardId}/comments',
auth=authFrom,
headers=headersOcsJson)
response.raise_for_status()
return response.json()
def createBoard(title, color):
response = requests.post(
f'{urlTo}/{deckApiPath}/boards',
auth=authTo,
json={
'title': title,
'color': color
},
headers=headers)
response.raise_for_status()
board = response.json()
boardId = board['id']
# remove all default labels
for label in board['labels']:
labelId = label['id']
response = requests.delete(
f'{urlTo}/{deckApiPath}/boards/{boardId}/labels/{labelId}',
auth=authTo,
headers=headers)
response.raise_for_status()
return board
def createLabel(title, color, boardId):
response = requests.post(
f'{urlTo}/{deckApiPath}/boards/{boardId}/labels',
auth=authTo,
json={
'title': title,
'color': color
},
headers=headers)
response.raise_for_status()
return response.json()
def createStack(title, order, boardId):
response = requests.post(
f'{urlTo}/{deckApiPath}/boards/{boardId}/stacks',
auth=authTo,
json={
'title': title,
'order': order
},
headers=headers)
response.raise_for_status()
return response.json()
def createCard(title, ctype, order, description, duedate, boardId, stackId):
response = requests.post(
f'{urlTo}/{deckApiPath}/boards/{boardId}/stacks/{stackId}/cards',
auth=authTo,
json={
'title': title,
'type': ctype,
'order': order,
'description': description,
'duedate': duedate
},
headers=headers)
response.raise_for_status()
return response.json()
def assignLabel(labelId, cardId, boardId, stackId):
response = requests.put(
f'{urlTo}/{deckApiPath}/boards/{boardId}/stacks/{stackId}/cards/{cardId}/assignLabel',
auth=authTo,
json={
'labelId': labelId
},
headers=headers)
response.raise_for_status()
def createAttachment(boardId, stackId, cardId, fileType, fileContent, mimetype, fileName):
url = f'{urlTo}/{deckApiPath}/boards/{boardId}/stacks/{stackId}/cards/{cardId}/attachments'
payload = {'type' : fileType}
files=[
('file',(fileName, fileContent, mimetype))
]
response = requests.post( url, auth=authTo, data=payload, files=files)
response.raise_for_status()
return response.json()
def createComment(cardId, message):
response = requests.post(
f'{urlTo}/{ocsApiPath}/cards/{cardId}/comments',
auth=authTo,
json={
'message': message
},
headers=headersOcsJson)
response.raise_for_status()
return response.json()
def archiveCard(card, boardId, stackId):
cardId = card['id']
card['archived'] = True
response = requests.put(
f'{urlTo}/{deckApiPath}/boards/{boardId}/stacks/{stackId}/cards/{cardId}',
auth=authTo,
json=card,
headers=headers)
response.raise_for_status()
def copyCard(card, boardIdTo, stackIdTo, labelsMap, boardIdFrom):
createdCard = createCard(
card['title'],
card['type'],
card['order'],
card['description'],
card['duedate'],
boardIdTo,
stackIdTo
)
# copy attachments
attachments = getAttachments(boardIdFrom, card['stackId'], card['id'])
for attachment in attachments:
fileName = attachment['data']
owner = attachment['createdBy']
mimetype = attachment['extendedData']['mimetype']
attachmentPath = attachment['extendedData']['path']
path = f'remote.php/dav/files/{owner}{attachmentPath}'
fileContent = getAttachment(path).content
createAttachment(boardIdTo, stackIdTo, createdCard['id'], attachment['type'], fileContent, mimetype, fileName)
# copy card labels
if card['labels']:
for label in card['labels']:
assignLabel(labelsMap[label['id']], createdCard['id'], boardIdTo, stackIdTo)
if card['archived']:
archiveCard(createdCard, boardIdTo, stackIdTo)
# copy card comments
comments = getComments(card['id'])
if(comments['ocs']['data']):
for comment in comments['ocs']['data']:
createComment(createdCard['id'], comment['message'])
def archiveBoard(boardId, title, color):
response = requests.put(
f'{urlTo}/{deckApiPath}/boards/{boardId}',
auth=authTo,
json={
'title': title,
'color': color,
'archived': True
},
headers=headers)
response.raise_for_status()
# get boards list
print('Starting script')
boards = getBoards()
# create boards
for board in boards:
boardIdFrom = board['id']
# create board
createdBoard = createBoard(board['title'], board['color'])
boardIdTo = createdBoard['id']
print('Created board', board['title'])
# create labels
boardDetails = getBoardDetails(board['id'])
labelsMap = {}
for label in boardDetails['labels']:
createdLabel = createLabel(label['title'], label['color'], boardIdTo)
labelsMap[label['id']] = createdLabel['id']
# copy stacks
stacks = getStacks(boardIdFrom)
stacksMap = {}
for stack in stacks:
createdStack = createStack(stack['title'], stack['order'], boardIdTo)
stackIdTo = createdStack['id']
stacksMap[stack['id']] = stackIdTo
print(' Created stack', stack['title'])
# copy cards
if not 'cards' in stack:
continue
for card in stack['cards']:
copyCard(card, boardIdTo, stackIdTo, labelsMap, boardIdFrom)
print(' Created', len(stack['cards']), 'cards')
# copy archived stacks
stacks = getStacksArchived(boardIdFrom)
for stack in stacks:
# copy cards
if not 'cards' in stack:
continue
print(' Stack', stack['title'])
for card in stack['cards']:
copyCard(card, boardIdTo, stacksMap[stack['id']], labelsMap, boardIdFrom)
print(' Created', len(stack['cards']), 'archived cards')
# archive board if it was archived
if(board['archived']):
archiveBoard(board['id'], board['title'], board['color'])
print(' Archived board')
@T-Lancer
Copy link

Hi, I am using this script to transfer form my old instance to an AIO instance, both currently running parallel in Docker, the old one is obviously only running locally, with no domain and direct IP connection and works as it did before. the AIO is already running in production mode, with a domain.

the script however is painfully slow. I'm talking many minutes just to create a board, and it's probably going to be hours or days to do all the cards and other boards I have. Is this normal to be so slow? both instances a running Deck 1.12.2 so that is API 1.1.

the original script was much faster but had issues with many cards and would crash.

@T-Lancer
Copy link

T-Lancer commented Apr 16, 2024

I am also still getting errors when trying to import certain boards. not all, but some:

Created board 02_Hallways
Traceback (most recent call last):
File "/home/user/Downloads/nextcloud-deck-export-import/nextcloud-deck-export-import2.py", line 265, in
createdStack = createStack(stack['title'], stack['order'], boardIdTo)
File "/home/user/Downloads/nextcloud-deck-export-import/nextcloud-deck-export-import2.py", line 127, in createStack
response.raise_for_status()
File "/usr/lib/python3/dist-packages/requests/models.py", line 943, in raise_for_status
raise HTTPError(http_error_msg, response=self)
requests.exceptions.HTTPError: 400 Client Error: Bad Request for url: https://nextcloud.domain.tld/index.php/apps/deck/api/v1.1/boards/76/stacks

not really sure what is going on there.

@CodeShakingSheep
Copy link
Author

Hey.. thx for this very very helpfull script!

Hi, Thanks for your feedback!

When i was using it, after several boards, i got this error:

Traceback (most recent call last):
  File "/tmp/lala/nextcloud-deck-export-import.py", line 262, in <module>
    copyCard(card, boardIdTo, stackIdTo, labelsMap, boardIdFrom)
  File "/tmp/lala/nextcloud-deck-export-import.py", line 199, in copyCard
    attachmentPath = attachment['extendedData']['path']
                     ~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^
KeyError: 'path'

Any idea about this? It seems to happen on every card with an attachment. Maybe, because those Decks are shared with me and attachments are not uploaded by me? Or maybe because this is an old nextcloud (v22.2.10) with old deck-app (v1.5.8)? Browser-inspector says, for example this url works to download the file:

GET
	https://cloud.foo.bar/apps/deck/cards/76/attachment/1

This is interesting. I haven't encountered this yet. I'm not sure if your specific error is related to another user having initially uploaded the file. In theory, key path should always exist from my understanding. Not sure what's going on here.

Though, you are right about the attachment owner issue. That slipped through. I will push a fix for this in a few minutes.

EDIT: This works for me: Changing this

def getAttachment(path):
    response = requests.get(
            f'{urlFrom}/remote.php/dav/files/{authFrom[0]}{path}',
            auth=authFrom,
            headers=headers)
    response.raise_for_status()
    return response

to this:

def getAttachment(cardId, attachmentId):
    response = requests.get(
            f'{urlFrom}/apps/deck/cards/{cardId}/attachment/{attachmentId}',
            auth=authFrom,
            headers=headers)
    response.raise_for_status()
    return response

And also changing this:

    # copy attachments
    attachments = getAttachments(boardIdFrom, card['stackId'], card['id'])
    for attachment in attachments:
        fileName = attachment['data']
        mimetype = attachment['extendedData']['mimetype']
        attachmentPath = attachment['extendedData']['path']
        path = f'{urlFrom}/remote.php/dav/files/{authFrom[0]}{attachmentPath}'
        fileContent = getAttachment(attachmentPath).content
        createAttachment(boardIdTo, stackIdTo, createdCard['id'], attachment['type'], fileContent, mimetype, fileName)

to this:

    # copy attachments
    attachments = getAttachments(boardIdFrom, card['stackId'], card['id'])
    for attachment in attachments:
        fileName = attachment['data']
        mimetype = attachment['extendedData']['mimetype']
        fileContent = getAttachment(card['id'], attachment['id']).content
        createAttachment(boardIdTo, stackIdTo, createdCard['id'], attachment['type'], fileContent, mimetype, fileName)

I'm happy that you found a solution for this error. I just checked the script with your changes and in my case unfortunately, I receive a HTTP 403 response when using https://mynextcloud.tld/apps/deck/cards/11/attachment/1.

{
  "status": 403,
  "message": "Permission denied"
}

So, for now I prefer to leave the URLs as they are as in my case the changes aren't working. Unfortunately, I don't have the capacity to investigate this further at the moment. If more people report this issue, I will consider changing it. Thanks for your investigation on this so far!

Second: What do you think about commandline options for include/exclude boards? Sometimes People don't want to migrate all boards at the same time..

Yes, I'm in for that. I have been wanting to do that for some time as the request to exclude boards already came up. I will see if I can find time next week to implement this.

@CodeShakingSheep
Copy link
Author

Hi, I am using this script to transfer form my old instance to an AIO instance, both currently running parallel in Docker, the old one is obviously only running locally, with no domain and direct IP connection and works as it did before. the AIO is already running in production mode, with a domain.

the script however is painfully slow. I'm talking many minutes just to create a board, and it's probably going to be hours or days to do all the cards and other boards I have. Is this normal to be so slow? both instances a running Deck 1.12.2 so that is API 1.1.

the original script was much faster but had issues with many cards and would crash.

Yes, the script is slow. I just took over the base logic from the original script and didn't look specifically into performance optimization as I just used this script one time for my own migration. The original script has fewer requests as it doesn't migrate attachments and comments. So, it makes sense for this script to be slower.

I think a possible approach to speed it up would be to use connection pooling or async programming, see here. Unfortunately, I don't have the capacity to implement and test this at the moment. If you want to jump into it, you're more than welcome to fork this gist, do the optimization and post the link here. I would happily review and apply the changes then.

@CodeShakingSheep
Copy link
Author

I am also still getting errors when trying to import certain boards. not all, but some:

Created board 02_Hallways
Traceback (most recent call last):
File "/home/user/Downloads/nextcloud-deck-export-import/nextcloud-deck-export-import2.py", line 265, in
createdStack = createStack(stack['title'], stack['order'], boardIdTo)
File "/home/user/Downloads/nextcloud-deck-export-import/nextcloud-deck-export-import2.py", line 127, in createStack
response.raise_for_status()
File "/usr/lib/python3/dist-packages/requests/models.py", line 943, in raise_for_status
raise HTTPError(http_error_msg, response=self)
requests.exceptions.HTTPError: 400 Client Error: Bad Request for url: https://nextcloud.domain.tld/index.php/apps/deck/api/v1.1/boards/76/stacks

not really sure what is going on there.

Hm, not sure either. You could try to omit /index.php from the URL. Also, I just updated the script. So, perhaps you can try with the new version, although I don't think the changes will affect your error.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment