Skip to content

Commit a0fb318

Browse files
committed
feat: sync submissions to export file
Signed-off-by: Christian Hartmann <chris-hartmann@gmx.de>
1 parent 853a8ca commit a0fb318

3 files changed

Lines changed: 231 additions & 46 deletions

File tree

lib/Service/SubmissionService.php

Lines changed: 112 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -239,9 +239,10 @@ public function getSubmissionsData(Form $form, string $fileFormat, ?File $file =
239239

240240
// Process initial header
241241
$header = [];
242-
$header[] = $this->l10n->t('User ID');
243-
$header[] = $this->l10n->t('User display name');
244-
$header[] = $this->l10n->t('Timestamp');
242+
$header[] = ['id' => 'submission_id', 'title' => $this->l10n->t('Submission ID')];
243+
$header[] = ['id' => 'user_id', 'title' => $this->l10n->t('User ID')];
244+
$header[] = ['id' => 'user_display_name', 'title' => $this->l10n->t('User display name')];
245+
$header[] = ['id' => 'timestamp', 'title' => $this->l10n->t('Timestamp')];
245246
/** @var array<int, Question> $questionPerQuestionId */
246247
$questionPerQuestionId = [];
247248
/** @var array<int, array<int, string>> $gridRowsPerQuestionId */
@@ -269,12 +270,12 @@ public function getSubmissionsData(Form $form, string $fileFormat, ?File $file =
269270

270271
foreach ($gridRowsPerQuestionId[$question->getId()] as $rowId) {
271272
if ($gridCellType === Constants::ANSWER_GRID_TYPE_CHECKBOX || $gridCellType === Constants::ANSWER_GRID_TYPE_RADIO) {
272-
$header[] = $question->getText() . ' (' . $optionPerOptionId[$rowId]->getText() . ')';
273+
$header[] = ['id' => 'question-id-' . $question->getId() . '-' . $rowId, 'title' => $question->getText() . ' (' . $optionPerOptionId[$rowId]->getText() . ')'];
273274
}
274275

275276
if ($gridCellType === Constants::ANSWER_GRID_TYPE_NUMBER) {
276277
foreach ($gridColumnsPerQuestionId[$question->getId()] as $columnId) {
277-
$header[] = $question->getText() . ' (' . $optionPerOptionId[$rowId]->getText() . ' - ' . $optionPerOptionId[$columnId]->getText() . ')';
278+
$header[] = ['id' => 'question-id-' . $question->getId() . '-' . $rowId . '-' . $columnId, 'title' => $question->getText() . ' (' . $optionPerOptionId[$rowId]->getText() . ' - ' . $optionPerOptionId[$columnId]->getText() . ')'];
278279
}
279280
}
280281
}
@@ -285,10 +286,10 @@ public function getSubmissionsData(Form $form, string $fileFormat, ?File $file =
285286
$rankingOptionsPerQuestionId[$question->getId()][] = $option->getId();
286287
}
287288
foreach ($rankingOptionsPerQuestionId[$question->getId()] as $optionId) {
288-
$header[] = $question->getText() . ' (' . $optionPerOptionId[$optionId]->getText() . ')';
289+
$header[] = ['id' => 'question-id-' . $question->getId() . '-' . $optionId, 'title' => $question->getText() . ' (' . $optionPerOptionId[$optionId]->getText() . ')'];
289290
}
290291
} else {
291-
$header[] = $question->getText();
292+
$header[] = ['id' => 'question-id-' . $question->getId(), 'title' => $question->getText()];
292293
}
293294

294295
$questionPerQuestionId[$question->getId()] = $question;
@@ -301,6 +302,8 @@ public function getSubmissionsData(Form $form, string $fileFormat, ?File $file =
301302
foreach ($submissionEntities as $submission) {
302303
$row = [];
303304

305+
$row[] = $submission->getId();
306+
304307
// User
305308
$user = $this->userManager->get($submission->getUserId());
306309
if ($user === null) {
@@ -388,7 +391,7 @@ function (array $carry, Answer $answer) use ($questionPerQuestionId, $gridRowsPe
388391
}
389392

390393
/**
391-
* @param array<int, string> $header
394+
* @param array<int, array{id: string, title: string}> $header
392395
* @param list<non-empty-list<array{columns?: list<mixed|string>, label?: string, url?: string}|mixed|null|string>> $data
393396
*/
394397
private function exportData(array $header, array $data, string $fileFormat, ?File $file = null): string {
@@ -411,34 +414,120 @@ private function exportData(array $header, array $data, string $fileFormat, ?Fil
411414
}
412415

413416
$activeWorksheet = $spreadsheet->getSheet(0);
414-
foreach ($header as $columnIndex => $value) {
415-
$activeWorksheet->setCellValue([$columnIndex + 1, 1], $value);
417+
418+
// Set column IDs in a hidden row
419+
$activeWorksheet->getRowDimension(2)->setVisible(false);
420+
421+
// Get existing header
422+
$existingHeaderIds = [];
423+
$highestColumn = $activeWorksheet->getHighestColumn();
424+
$highestColumnIndex = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($highestColumn);
425+
for ($col = 1; $col <= $highestColumnIndex; $col++) {
426+
$id = $activeWorksheet->getCell([$col, 2])->getValue();
427+
if ($id) {
428+
$existingHeaderIds[$id] = $col;
429+
}
430+
}
431+
432+
$newHeaderIds = array_column($header, 'id');
433+
$newHeaderTitles = array_column($header, 'title');
434+
435+
// Sync Columns
436+
$colsToDelete = array_diff(array_keys($existingHeaderIds), $newHeaderIds);
437+
// Sort columns to delete by index descending
438+
$colsToDeleteIndices = [];
439+
foreach ($colsToDelete as $colId) {
440+
if (isset($existingHeaderIds[$colId])) {
441+
$colsToDeleteIndices[] = $existingHeaderIds[$colId];
442+
}
443+
}
444+
rsort($colsToDeleteIndices);
445+
446+
foreach ($colsToDeleteIndices as $colIndex) {
447+
$activeWorksheet->removeColumnByIndex($colIndex, 1);
448+
}
449+
450+
// Write header
451+
foreach ($header as $columnIndex => $headerItem) {
452+
$activeWorksheet->setCellValue([$columnIndex + 1, 2], $headerItem['id']);
453+
$activeWorksheet->setCellValue([$columnIndex + 1, 1], $headerItem['title']);
454+
}
455+
456+
// Get existing submissions
457+
$existingSubmissionIds = [];
458+
$highestRow = $activeWorksheet->getHighestRow();
459+
$submissionIdColIndex = array_search('submission_id', $newHeaderIds);
460+
$submissionIdCol = $submissionIdColIndex !== false ? $submissionIdColIndex + 1 : 0;
461+
462+
if ($submissionIdCol) {
463+
for ($row = 3; $row <= $highestRow; $row++) {
464+
$submissionId = $activeWorksheet->getCell([$submissionIdCol, $row])->getValue();
465+
if ($submissionId) {
466+
$existingSubmissionIds[(string)$submissionId] = $row;
467+
}
468+
}
416469
}
417-
foreach ($data as $rowIndex => $rowData) {
418-
$column = 1;
419-
foreach ($rowData as $value) {
420-
$row = $rowIndex + 2;
470+
471+
$newSubmissionIds = [];
472+
if ($submissionIdColIndex !== false) {
473+
$newSubmissionIds = array_map('strval', array_column($data, $submissionIdColIndex));
474+
}
475+
476+
// Sync Rows
477+
$rowsToDelete = array_diff(array_keys($existingSubmissionIds), $newSubmissionIds);
478+
$deletedCount = 0;
479+
foreach ($rowsToDelete as $submissionId) {
480+
$rowIndex = $existingSubmissionIds[$submissionId];
481+
$activeWorksheet->removeRow($rowIndex - $deletedCount, 1);
482+
$deletedCount++;
483+
}
484+
485+
// Re-map existing submission rows after deletion
486+
$existingSubmissionIds = [];
487+
$highestRow = $activeWorksheet->getHighestRow();
488+
if ($submissionIdCol) {
489+
for ($row = 3; $row <= $highestRow; $row++) {
490+
$submissionId = $activeWorksheet->getCell([$submissionIdCol, $row])->getValue();
491+
if ($submissionId) {
492+
$existingSubmissionIds[(string)$submissionId] = $row;
493+
}
494+
}
495+
}
496+
497+
// Update/Append data
498+
foreach ($data as $rowData) {
499+
$submissionId = (string)$rowData[$submissionIdColIndex];
500+
$row = $existingSubmissionIds[$submissionId] ?? null;
501+
502+
if ($row === null) {
503+
// Append new row
504+
$row = $activeWorksheet->getHighestRow() + 1;
505+
}
506+
507+
$dataIndex = 0;
508+
$columnIndex = 1;
509+
while ($dataIndex < count($rowData)) {
510+
$value = $rowData[$dataIndex];
421511

422512
if (is_array($value) && isset($value['label'])) { // file question type
423-
$activeWorksheet->getCell([$column, $row])
513+
$activeWorksheet->getCell([$columnIndex, $row])
424514
->setValueExplicit($value['label'])
425515
->getHyperlink()
426516
->setUrl($value['url']);
427-
428-
$activeWorksheet->getStyle([$column, $row])
517+
$activeWorksheet->getStyle([$columnIndex, $row])
429518
->getAlignment()
430519
->setWrapText(true);
520+
$columnIndex++;
431521
} elseif (is_array($value) && isset($value['columns'])) { // grid question type
432522
foreach ($value['columns'] as $nestedValue) {
433-
$this->setCellValue($activeWorksheet, $column, $row, $nestedValue, $fileFormat);
434-
$column++;
523+
$this->setCellValue($activeWorksheet, $columnIndex, $row, $nestedValue, $fileFormat);
524+
$columnIndex++;
435525
}
436-
continue; // no need to increment the column one more time
437526
} else {
438-
$this->setCellValue($activeWorksheet, $column, $row, $value, $fileFormat);
527+
$this->setCellValue($activeWorksheet, $columnIndex, $row, $value, $fileFormat);
528+
$columnIndex++;
439529
}
440-
441-
$column++;
530+
$dataIndex++;
442531
}
443532
}
444533

tests/Integration/Api/ApiV3Test.php

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
*/
2020
class ApiV3Test extends IntegrationBase {
2121
private Client $http;
22+
private ?\OCP\Files\IRootFolder $rootFolder = null;
23+
private ?\OCP\Files\Folder $userFolder = null;
2224

2325
protected array $users = [
2426
'test' => 'Test user',
@@ -267,6 +269,9 @@ public function setUp(): void {
267269

268270
parent::setUp();
269271

272+
$this->rootFolder = \OC::$server->getRootFolder();
273+
$this->userFolder = $this->rootFolder->getUserFolder('test');
274+
270275
// Set up http Client
271276
$this->http = new Client([
272277
'base_uri' => 'http://localhost:8080/ocs/v2.php/apps/forms/',
@@ -1484,6 +1489,87 @@ public function testExportToCloud() {
14841489
$this->assertEquals('Title of a Form (responses).csv', $data);
14851490
}
14861491

1492+
public function testExportSubmissionsWithSync() {
1493+
// 1. Create form
1494+
$resp = $this->http->request('POST', 'api/v3/forms', ['json' => ['fromId' => null]]);
1495+
$form = $this->OcsResponse2Data($resp);
1496+
$formId = $form['id'];
1497+
1498+
// 2. Add questions
1499+
$resp = $this->http->request('POST', "api/v3/forms/{$formId}/questions", ['json' => ['type' => 'short', 'text' => 'Question 1']]);
1500+
$q1 = $this->OcsResponse2Data($resp);
1501+
$resp = $this->http->request('POST', "api/v3/forms/{$formId}/questions", ['json' => ['type' => 'short', 'text' => 'Question 2']]);
1502+
$q2 = $this->OcsResponse2Data($resp);
1503+
1504+
// 3. Add submissions
1505+
$this->http->request('POST', "api/v3/forms/{$formId}/submissions", ['json' => ['answers' => [$q1['id'] => ['Answer 1.1'], $q2['id'] => ['Answer 2.1']]]]);
1506+
$this->http->request('POST', "api/v3/forms/{$formId}/submissions", ['json' => ['answers' => [$q1['id'] => ['Answer 1.2'], $q2['id'] => ['Answer 2.2']]]]);
1507+
$resp = $this->http->request('GET', "api/v3/forms/{$formId}/submissions");
1508+
$submissions = $this->OcsResponse2Data($resp)['submissions'];
1509+
$s1_id = $submissions[0]['id'];
1510+
$s2_id = $submissions[1]['id'];
1511+
1512+
// 4. Export
1513+
$exportPath = '/test_export.csv';
1514+
$this->http->request('POST', "api/v3/forms/{$formId}/submissions/export", ['json' => ['path' => $exportPath, 'fileFormat' => 'csv']]);
1515+
1516+
// 5. Verify
1517+
$content = $this->userFolder->get($exportPath)->getContent();
1518+
$data = array_map('str_getcsv', explode("\n", trim($content)));
1519+
1520+
$this->assertStringContainsString('Submission ID', $data[0][0]);
1521+
$this->assertStringContainsString('Question 1', $data[0][4]);
1522+
$this->assertStringContainsString('Question 2', $data[0][5]);
1523+
1524+
$this->assertEquals('submission_id', $data[1][0]);
1525+
$this->assertEquals('question-id-' . $q1['id'], $data[1][4]);
1526+
$this->assertEquals('question-id-' . $q2['id'], $data[1][5]);
1527+
1528+
$this->assertEquals($s1_id, $data[2][0]);
1529+
$this->assertEquals('Answer 1.1', $data[2][4]);
1530+
$this->assertEquals('Answer 2.1', $data[2][5]);
1531+
$this->assertEquals($s2_id, $data[3][0]);
1532+
$this->assertEquals('Answer 1.2', $data[3][4]);
1533+
$this->assertEquals('Answer 2.2', $data[3][5]);
1534+
$this->assertCount(4, $data); // Header, hidden header, 2 rows
1535+
1536+
// 6. Delete submission
1537+
$this->http->request('DELETE', "api/v3/forms/{$formId}/submissions/{$s1_id}");
1538+
1539+
// 7. Delete question
1540+
$this->http->request('DELETE', "api/v3/forms/{$formId}/questions/{$q1['id']}");
1541+
1542+
// 8. Export again
1543+
$this->http->request('POST', "api/v3/forms/{$formId}/submissions/export", ['json' => ['path' => $exportPath, 'fileFormat' => 'csv']]);
1544+
1545+
// 9. Verify
1546+
$content = $this->userFolder->get($exportPath)->getContent();
1547+
$data = array_map('str_getcsv', explode("\n", trim($content)));
1548+
1549+
$this->assertStringContainsString('Submission ID', $data[0][0]);
1550+
$this->assertStringNotContainsString('Question 1', isset($data[0][4]) ? $data[0][4] : '');
1551+
$this->assertStringContainsString('Question 2', $data[0][4]);
1552+
1553+
$this->assertEquals('submission_id', $data[1][0]);
1554+
$this->assertEquals('question-id-' . $q2['id'], $data[1][4]);
1555+
1556+
$this->assertEquals($s2_id, $data[2][0]);
1557+
$this->assertEquals('Answer 2.2', $data[2][4]);
1558+
$this->assertCount(3, $data); // Header, hidden header, 1 row
1559+
1560+
// 10. Update submission
1561+
$this->http->request('PUT', "api/v3/forms/{$formId}/submissions/{$s2_id}", ['json' => ['answers' => [$q2['id'] => ['Answer 2.2 updated']]]]);
1562+
1563+
// 11. Export again
1564+
$this->http->request('POST', "api/v3/forms/{$formId}/submissions/export", ['json' => ['path' => $exportPath, 'fileFormat' => 'csv']]);
1565+
1566+
// 12. Verify
1567+
$content = $this->userFolder->get($exportPath)->getContent();
1568+
$data = array_map('str_getcsv', explode("\n", trim($content)));
1569+
1570+
$this->assertEquals('Answer 2.2 updated', $data[2][4]);
1571+
}
1572+
14871573
public static function dataDeleteSubmissions() {
14881574
$submissionsExpected = self::dataGetSubmissions()['getSubmissions']['expected'];
14891575
$submissionsExpected['submissions'] = [];

0 commit comments

Comments
 (0)