Skip to content

Commit e2aa9a6

Browse files
committed
feat: Add CSV import/export with card ID support and Nextcloud file picker
Signed-off-by: Anna Larch <anna@nextcloud.com> AI-Assisted-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cfd69d2 commit e2aa9a6

20 files changed

+1591
-31
lines changed

appinfo/routes.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
['name' => 'board#transferOwner', 'url' => '/boards/{boardId}/transferOwner', 'verb' => 'PUT'],
3232
['name' => 'board#export', 'url' => '/boards/{boardId}/export', 'verb' => 'GET'],
3333
['name' => 'board#import', 'url' => '/boards/import', 'verb' => 'POST'],
34+
['name' => 'board#importCsv', 'url' => '/boards/{boardId}/importCsv', 'verb' => 'POST'],
3435

3536
// stacks
3637
['name' => 'stack#index', 'url' => '/stacks/{boardId}', 'verb' => 'GET'],

lib/Controller/AttachmentOcsController.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
use OCA\Deck\NotImplementedException;
1212
use OCA\Deck\Service\AttachmentService;
1313
use OCA\Deck\Service\BoardService;
14+
use OCP\AppFramework\Http\Attribute\CORS;
1415
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
16+
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
1517
use OCP\AppFramework\Http\DataResponse;
1618
use OCP\AppFramework\OCSController;
1719
use OCP\IRequest;
@@ -36,34 +38,44 @@ private function ensureLocalBoard(?int $boardId): void {
3638
}
3739

3840
#[NoAdminRequired]
41+
#[CORS]
42+
#[NoCSRFRequired]
3943
public function getAll(int $cardId, ?int $boardId = null): DataResponse {
4044
$this->ensureLocalBoard($boardId);
4145
$attachment = $this->attachmentService->findAll($cardId, true);
4246
return new DataResponse($attachment);
4347
}
4448

4549
#[NoAdminRequired]
50+
#[CORS]
51+
#[NoCSRFRequired]
4652
public function create(int $cardId, string $type, string $data = '', ?int $boardId = null): DataResponse {
4753
$this->ensureLocalBoard($boardId);
4854
$attachment = $this->attachmentService->create($cardId, $type, $data);
4955
return new DataResponse($attachment);
5056
}
5157

5258
#[NoAdminRequired]
59+
#[CORS]
60+
#[NoCSRFRequired]
5361
public function update(int $cardId, int $attachmentId, string $data, string $type = 'file', ?int $boardId = null): DataResponse {
5462
$this->ensureLocalBoard($boardId);
5563
$attachment = $this->attachmentService->update($cardId, $attachmentId, $data, $type);
5664
return new DataResponse($attachment);
5765
}
5866

5967
#[NoAdminRequired]
68+
#[CORS]
69+
#[NoCSRFRequired]
6070
public function delete(int $cardId, int $attachmentId, string $type = 'file', ?int $boardId = null): DataResponse {
6171
$this->ensureLocalBoard($boardId);
6272
$attachment = $this->attachmentService->delete($cardId, $attachmentId, $type);
6373
return new DataResponse($attachment);
6474
}
6575

6676
#[NoAdminRequired]
77+
#[CORS]
78+
#[NoCSRFRequired]
6779
public function restore(int $cardId, int $attachmentId, string $type = 'file', ?int $boardId = null): DataResponse {
6880
$this->ensureLocalBoard($boardId);
6981
$attachment = $this->attachmentService->restore($cardId, $attachmentId, $type);

lib/Controller/BoardController.php

Lines changed: 98 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use OCA\Deck\Db\Board;
1212
use OCA\Deck\NoPermissionException;
1313
use OCA\Deck\Service\BoardService;
14+
use OCA\Deck\Service\CsvImportService;
1415
use OCA\Deck\Service\ExternalBoardService;
1516
use OCA\Deck\Service\Importer\BoardImportService;
1617
use OCA\Deck\Service\PermissionService;
@@ -29,6 +30,7 @@ public function __construct(
2930
private ExternalBoardService $externalBoardService,
3031
private PermissionService $permissionService,
3132
private BoardImportService $boardImportService,
33+
private CsvImportService $csvImportService,
3234
private IL10N $l10n,
3335
private $userId,
3436
) {
@@ -168,8 +170,9 @@ public function import(): DataResponse {
168170
if (!empty($file) && array_key_exists('error', $file) && $file['error'] !== UPLOAD_ERR_OK) {
169171
$error = $phpFileUploadErrors[$file['error']];
170172
}
171-
if (!empty($file) && $file['error'] === UPLOAD_ERR_OK && !in_array($file['type'], ['application/json', 'text/plain'], true)) {
172-
$error = $this->l10n->t('Invalid file type. Only JSON files are allowed.');
173+
$isCsv = $this->isCsvFile($file);
174+
if (!empty($file) && $file['error'] === UPLOAD_ERR_OK && !$isCsv && !in_array($file['type'], ['application/json', 'text/plain'], true)) {
175+
$error = $this->l10n->t('Invalid file type. Only JSON and CSV files are allowed.');
173176
}
174177
if ($error !== null) {
175178
return new DataResponse([
@@ -180,20 +183,45 @@ public function import(): DataResponse {
180183

181184
try {
182185
$fileContent = file_get_contents($file['tmp_name']);
183-
$this->boardImportService->setSystem('DeckJson');
184-
$config = new \stdClass();
185-
$config->owner = $this->userId;
186-
$this->boardImportService->setConfigInstance($config);
187-
$this->boardImportService->setData(json_decode($fileContent));
186+
187+
if ($isCsv) {
188+
$boardTitle = pathinfo($file['name'] ?? 'Imported Board', PATHINFO_FILENAME) ?: 'Imported Board';
189+
$this->boardImportService->setSystem('DeckCsv');
190+
$config = new \stdClass();
191+
$config->owner = $this->userId;
192+
$config->boardTitle = $boardTitle;
193+
$this->boardImportService->setConfigInstance($config);
194+
$data = new \stdClass();
195+
$data->rawCsvContent = $fileContent;
196+
$data->title = $boardTitle;
197+
$this->boardImportService->setData($data);
198+
} else {
199+
$this->boardImportService->setSystem('DeckJson');
200+
$config = new \stdClass();
201+
$config->owner = $this->userId;
202+
$this->boardImportService->setConfigInstance($config);
203+
$this->boardImportService->setData(json_decode($fileContent));
204+
}
205+
206+
$importErrors = [];
207+
$this->boardImportService->registerErrorCollector(function (string $message) use (&$importErrors) {
208+
$importErrors[] = $message;
209+
});
210+
188211
$this->boardImportService->import();
189212
$importedBoard = $this->boardImportService->getBoard();
190213
$board = $this->boardService->find($importedBoard->getId());
191214

192-
return new DataResponse($board, Http::STATUS_OK);
215+
return new DataResponse([
216+
'board' => $board,
217+
'import' => [
218+
'errors' => $importErrors,
219+
],
220+
], Http::STATUS_OK);
193221
} catch (\TypeError $e) {
194222
return new DataResponse([
195223
'status' => 'error',
196-
'message' => $this->l10n->t('Invalid JSON data'),
224+
'message' => $this->l10n->t('Invalid import data'),
197225
], Http::STATUS_BAD_REQUEST);
198226
} catch (\Exception $e) {
199227
return new DataResponse([
@@ -202,4 +230,65 @@ public function import(): DataResponse {
202230
], Http::STATUS_BAD_REQUEST);
203231
}
204232
}
233+
234+
/**
235+
* @NoAdminRequired
236+
*/
237+
public function importCsv(int $boardId): DataResponse {
238+
$file = $this->request->getUploadedFile('file');
239+
$error = null;
240+
$phpFileUploadErrors = [
241+
UPLOAD_ERR_OK => $this->l10n->t('The file was uploaded'),
242+
UPLOAD_ERR_INI_SIZE => $this->l10n->t('The uploaded file exceeds the upload_max_filesize directive in php.ini'),
243+
UPLOAD_ERR_FORM_SIZE => $this->l10n->t('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form'),
244+
UPLOAD_ERR_PARTIAL => $this->l10n->t('The file was only partially uploaded'),
245+
UPLOAD_ERR_NO_FILE => $this->l10n->t('No file was uploaded'),
246+
UPLOAD_ERR_NO_TMP_DIR => $this->l10n->t('Missing a temporary folder'),
247+
UPLOAD_ERR_CANT_WRITE => $this->l10n->t('Could not write file to disk'),
248+
UPLOAD_ERR_EXTENSION => $this->l10n->t('A PHP extension stopped the file upload'),
249+
];
250+
251+
if (empty($file)) {
252+
$error = $this->l10n->t('No file uploaded or file size exceeds maximum of %s', [\OCP\Util::humanFileSize(\OCP\Util::uploadLimit())]);
253+
}
254+
if (!empty($file) && array_key_exists('error', $file) && $file['error'] !== UPLOAD_ERR_OK) {
255+
$error = $phpFileUploadErrors[$file['error']];
256+
}
257+
if (!empty($file) && $file['error'] === UPLOAD_ERR_OK && !$this->isCsvFile($file)) {
258+
$error = $this->l10n->t('Invalid file type. Only CSV files are allowed.');
259+
}
260+
if ($error !== null) {
261+
return new DataResponse([
262+
'status' => 'error',
263+
'message' => $error,
264+
], Http::STATUS_BAD_REQUEST);
265+
}
266+
267+
try {
268+
$fileContent = file_get_contents($file['tmp_name']);
269+
$importResult = $this->csvImportService->importToBoard($boardId, $fileContent, $this->userId);
270+
$board = $this->boardService->find($boardId);
271+
272+
return new DataResponse([
273+
'board' => $board,
274+
'import' => $importResult,
275+
], Http::STATUS_OK);
276+
} catch (\Exception $e) {
277+
return new DataResponse([
278+
'status' => 'error',
279+
'message' => $this->l10n->t('Failed to import cards from CSV'),
280+
], Http::STATUS_BAD_REQUEST);
281+
}
282+
}
283+
284+
private function isCsvFile(?array $file): bool {
285+
if (empty($file)) {
286+
return false;
287+
}
288+
if (in_array($file['type'] ?? '', ['text/csv', 'application/csv'], true)) {
289+
return true;
290+
}
291+
$name = $file['name'] ?? '';
292+
return str_ends_with(strtolower($name), '.csv');
293+
}
205294
}

lib/Controller/BoardOcsController.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
use OCA\Deck\Service\BoardService;
1111
use OCA\Deck\Service\ExternalBoardService;
1212
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
13+
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
1314
use OCP\AppFramework\Http\Attribute\PublicPage;
15+
use OCP\AppFramework\Http\Attribute\RequestHeader;
1416
use OCP\AppFramework\Http\DataResponse;
1517
use OCP\AppFramework\OCSController;
1618
use OCP\IRequest;
@@ -36,6 +38,8 @@ public function index(): DataResponse {
3638

3739
#[NoAdminRequired]
3840
#[PublicPage]
41+
#[NoCSRFRequired]
42+
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
3943
public function read(int $boardId): DataResponse {
4044
$localBoard = $this->boardService->find($boardId, true, true);
4145
if ($localBoard->getExternalId() !== null) {
@@ -45,16 +49,19 @@ public function read(int $boardId): DataResponse {
4549
}
4650

4751
#[NoAdminRequired]
52+
#[NoCSRFRequired]
4853
public function create(string $title, string $color): DataResponse {
4954
return new DataResponse($this->boardService->create($title, $this->userId, $color));
5055
}
5156

5257
#[NoAdminRequired]
58+
#[NoCSRFRequired]
5359
public function addAcl(int $boardId, int $type, string $participant, bool $permissionEdit, bool $permissionShare, bool $permissionManage, ?string $remote = null): DataResponse {
5460
return new DataResponse($this->boardService->addAcl($boardId, $type, $participant, $permissionEdit, $permissionShare, $permissionManage));
5561
}
5662

5763
#[NoAdminRequired]
64+
#[NoCSRFRequired]
5865
public function updateAcl(int $id, bool $permissionEdit, bool $permissionShare, bool $permissionManage): DataResponse {
5966
return new DataResponse($this->boardService->updateAcl($id, $permissionEdit, $permissionShare, $permissionManage));
6067
}

lib/Controller/CardOcsController.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
use OCA\Deck\Service\ExternalBoardService;
1515
use OCA\Deck\Service\StackService;
1616
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
17+
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
1718
use OCP\AppFramework\Http\Attribute\PublicPage;
19+
use OCP\AppFramework\Http\Attribute\RequestHeader;
1820
use OCP\AppFramework\Http\DataResponse;
1921
use OCP\AppFramework\OCSController;
2022
use OCP\IRequest;
@@ -35,6 +37,8 @@ public function __construct(
3537

3638
#[NoAdminRequired]
3739
#[PublicPage]
40+
#[NoCSRFRequired]
41+
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
3842
public function create(string $title, int $stackId, ?int $boardId = null, ?string $type = 'plain', ?string $owner = null, ?int $order = 999, ?string $description = '', $duedate = null, $startdate = null, ?array $labels = [], ?array $users = []) {
3943
if ($boardId) {
4044
$board = $this->boardService->find($boardId, false);
@@ -63,6 +67,7 @@ public function create(string $title, int $stackId, ?int $boardId = null, ?strin
6367

6468
#[NoAdminRequired]
6569
#[PublicPage]
70+
#[NoCSRFRequired]
6671
public function assignLabel(?int $boardId, int $cardId, int $labelId): DataResponse {
6772
if ($boardId) {
6873
$board = $this->boardService->find($boardId, false);
@@ -76,6 +81,7 @@ public function assignLabel(?int $boardId, int $cardId, int $labelId): DataRespo
7681

7782
#[NoAdminRequired]
7883
#[PublicPage]
84+
#[NoCSRFRequired]
7985
public function assignUser(?int $boardId, int $cardId, string $userId, int $type = 0): DataResponse {
8086
if ($boardId) {
8187
$localBoard = $this->boardService->find($boardId, false);
@@ -88,6 +94,7 @@ public function assignUser(?int $boardId, int $cardId, string $userId, int $type
8894

8995
#[NoAdminRequired]
9096
#[PublicPage]
97+
#[NoCSRFRequired]
9198
public function unAssignUser(?int $boardId, int $cardId, string $userId, int $type = 0): DataResponse {
9299
if ($boardId) {
93100
$localBoard = $this->boardService->find($boardId, false);
@@ -100,6 +107,7 @@ public function unAssignUser(?int $boardId, int $cardId, string $userId, int $ty
100107

101108
#[NoAdminRequired]
102109
#[PublicPage]
110+
#[NoCSRFRequired]
103111
public function removeLabel(?int $boardId, int $cardId, int $labelId): DataResponse {
104112
if ($boardId) {
105113
$board = $this->boardService->find($boardId, false);
@@ -113,6 +121,8 @@ public function removeLabel(?int $boardId, int $cardId, int $labelId): DataRespo
113121

114122
#[NoAdminRequired]
115123
#[PublicPage]
124+
#[NoCSRFRequired]
125+
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
116126
public function update(int $id, string $title, int $stackId, string $type, int $order, string $description, $duedate, $deletedAt, int $boardId, array|string|null $owner = null, $archived = null, $startdate = null): DataResponse {
117127
$done = array_key_exists('done', $this->request->getParams())
118128
? new OptionalNullableValue($this->request->getParam('done', null))
@@ -160,6 +170,7 @@ public function update(int $id, string $title, int $stackId, string $type, int $
160170

161171
#[NoAdminRequired]
162172
#[PublicPage]
173+
#[NoCSRFRequired]
163174
public function reorder(int $cardId, int $stackId, int $order, ?int $boardId): DataResponse {
164175
if ($boardId) {
165176
$board = $this->boardService->find($boardId, false);

lib/Controller/StackOcsController.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
use OCA\Deck\Service\ExternalBoardService;
1212
use OCA\Deck\Service\StackService;
1313
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
14+
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
1415
use OCP\AppFramework\Http\Attribute\PublicPage;
16+
use OCP\AppFramework\Http\Attribute\RequestHeader;
1517
use OCP\AppFramework\Http\DataResponse;
1618
use OCP\AppFramework\OCSController;
1719
use OCP\IRequest;
@@ -29,6 +31,8 @@ public function __construct(
2931

3032
#[NoAdminRequired]
3133
#[PublicPage]
34+
#[NoCSRFRequired]
35+
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
3236
public function index(int $boardId): DataResponse {
3337
$localBoard = $this->boardService->find($boardId, true, true);
3438
if ($localBoard->getExternalId() !== null) {
@@ -40,6 +44,8 @@ public function index(int $boardId): DataResponse {
4044

4145
#[NoAdminRequired]
4246
#[PublicPage]
47+
#[NoCSRFRequired]
48+
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
4349
public function create(string $title, int $boardId, int $order = 0):DataResponse {
4450
$board = $this->boardService->find($boardId, false);
4551
if ($board->getExternalId()) {
@@ -53,6 +59,8 @@ public function create(string $title, int $boardId, int $order = 0):DataResponse
5359

5460
#[NoAdminRequired]
5561
#[PublicPage]
62+
#[NoCSRFRequired]
63+
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
5664
public function setDoneStack(int $stackId, int $boardId, bool $isDone): DataResponse {
5765
$board = $this->boardService->find($boardId, false);
5866
if ($board->getExternalId()) {
@@ -65,6 +73,8 @@ public function setDoneStack(int $stackId, int $boardId, bool $isDone): DataResp
6573

6674
#[NoAdminRequired]
6775
#[PublicPage]
76+
#[NoCSRFRequired]
77+
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
6878
public function delete(int $stackId, ?int $boardId = null):DataResponse {
6979
if ($boardId) {
7080
$board = $this->boardService->find($boardId, false);
@@ -80,6 +90,8 @@ public function delete(int $stackId, ?int $boardId = null):DataResponse {
8090

8191
#[NoAdminRequired]
8292
#[PublicPage]
93+
#[NoCSRFRequired]
94+
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
8395
public function reorder(int $stackId, int $order, ?int $boardId):DataResponse {
8496
if ($boardId !== null) {
8597
$board = $this->boardService->find($boardId, false);

0 commit comments

Comments
 (0)