Skip to content

Commit 215b9bb

Browse files
committed
feat: sync submissions to export file
Signed-off-by: Christian Hartmann <chris-hartmann@gmx.de>
1 parent 3d1ee5f commit 215b9bb

3 files changed

Lines changed: 241 additions & 74 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: 96 additions & 29 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 = \OCP\Server::get(\OCP\Files\IRootFolder::class);
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/',
@@ -1337,35 +1342,6 @@ public function testGetSubmissions(array $expected) {
13371342
$this->assertEquals($expected, $data);
13381343
}
13391344

1340-
public static function dataExportSubmissions() {
1341-
return [
1342-
'exportSubmissions' => [
1343-
'expected' => <<<'CSV'
1344-
"User ID","User display name","Timestamp","First Question?","Second Question?","File Question?"
1345-
"","Anonymous user","1970-01-01T00:20:34+00:00","","",""
1346-
"","Anonymous user","1970-01-01T03:25:45+00:00","This is another short answer.","Option 2",""
1347-
"user1","User No. 1","1970-01-02T10:17:36+00:00","This is a short answer.","Option 1",""
1348-
CSV
1349-
]
1350-
];
1351-
}
1352-
/**
1353-
* @dataProvider dataExportSubmissions
1354-
*
1355-
* @param array $expected
1356-
*/
1357-
public function testExportSubmissions(string $expected) {
1358-
$resp = $this->http->request('GET', "api/v3/forms/{$this->testForms[0]['id']}/submissions?fileFormat=csv");
1359-
$data = substr((string)$resp->getBody()->getContents(), 3); // Some strange Character removed at the beginning
1360-
1361-
$this->assertEquals(200, $resp->getStatusCode());
1362-
$this->assertEquals('attachment; filename="Title of a Form (responses).csv"', $resp->getHeaders()['Content-Disposition'][0]);
1363-
$this->assertEquals('text/csv;charset=UTF-8', $resp->getHeaders()['Content-type'][0]);
1364-
$arr_txt_expected = preg_split('/,/', str_replace(["\t", "\n"], '', $expected));
1365-
$arr_txt_data = preg_split('/,/', str_replace(["\t", "\n"], '', $data));
1366-
$this->assertEquals($arr_txt_expected, $arr_txt_data);
1367-
}
1368-
13691345
public function testDeleteSubmission() {
13701346
// Get submissions first to find a submission ID
13711347
$resp = $this->http->request('GET', "api/v3/forms/{$this->testForms[0]['id']}/submissions");
@@ -1484,6 +1460,97 @@ public function testExportToCloud() {
14841460
$this->assertEquals('Title of a Form (responses).csv', $data);
14851461
}
14861462

1463+
public function testExportSubmissionsWithSync() {
1464+
$form = $this->testForms[0];
1465+
$formId = $form['id'];
1466+
$question1 = $form['questions'][0];
1467+
$question2 = $form['questions'][1];
1468+
1469+
// To be sure about IDs and order, let's fetch from API (newest first)
1470+
$resp = $this->http->request('GET', "api/v3/forms/{$formId}/submissions");
1471+
$submissionsData = $this->OcsResponse2Data($resp);
1472+
$submissions = $submissionsData['submissions'];
1473+
$this->assertCount(3, $submissions, 'Pre-condition: Form should have 3 submissions');
1474+
1475+
$submission1_id = $submissions[0]['id']; // newest, user1
1476+
$submission2_id = $submissions[1]['id']; // middle, user2
1477+
$submission3_id = $submissions[2]['id']; // oldest, user3
1478+
1479+
// 1. Initial Export
1480+
$exportPath = '/Title of a Form (responses).csv';
1481+
$this->http->request('POST', "api/v3/forms/{$formId}/submissions/export", ['json' => ['path' => '']]);
1482+
1483+
// 2. Verify Initial Export
1484+
$content = $this->userFolder->get($exportPath)->getContent();
1485+
$data = array_map('str_getcsv', explode("\n", trim($content)));
1486+
1487+
$this->assertCount(5, $data, 'Expected 5 rows: header, hidden-header, 3 data rows');
1488+
// Check headers
1489+
$this->assertStringContainsString('Submission ID', $data[0][0]);
1490+
$this->assertStringContainsString($question1['text'], $data[0][4]);
1491+
$this->assertStringContainsString($question2['text'], $data[0][5]);
1492+
1493+
// Check hidden headers for IDs
1494+
$this->assertEquals('submission_id', $data[1][0], 'Hidden header for submission ID is missing or incorrect');
1495+
$this->assertEquals('question-id-' . $question1['id'], $data[1][4]);
1496+
$this->assertEquals('question-id-' . $question2['id'], $data[1][5]);
1497+
1498+
// Check data rows (oldest first: user3, user2, user1)
1499+
$this->assertEquals($submission3_id, $data[2][0]);
1500+
$this->assertEquals('', $data[2][4]);
1501+
$this->assertTrue(!isset($data[2][5]) || $data[2][5] === '');
1502+
1503+
$this->assertEquals($submission2_id, $data[3][0]);
1504+
$this->assertEquals('This is another short answer.', $data[3][4]);
1505+
$this->assertEquals('Option 2', $data[3][5]);
1506+
1507+
$this->assertEquals($submission1_id, $data[4][0]);
1508+
$this->assertEquals('This is a short answer.', $data[4][4]);
1509+
$this->assertEquals('Option 1', $data[4][5]);
1510+
1511+
// 3. Delete a submission and a question
1512+
$this->http->request('DELETE', "api/v3/forms/{$formId}/submissions/{$submission1_id}");
1513+
$this->http->request('DELETE', "api/v3/forms/{$formId}/questions/{$question1['id']}");
1514+
1515+
// 4. Export again
1516+
$this->http->request('POST', "api/v3/forms/{$formId}/submissions/export", ['json' => ['path' => $exportPath, 'fileFormat' => 'csv']]);
1517+
1518+
// 5. Verify Export after deletions
1519+
$content = $this->userFolder->get($exportPath)->getContent();
1520+
$data = array_map('str_getcsv', explode("\n", trim($content)));
1521+
1522+
$this->assertCount(4, $data, 'Expected 4 rows after deletion: header, hidden-header, 2 data rows');
1523+
$this->assertStringNotContainsString($question1['text'], implode(',', $data[0]));
1524+
$this->assertStringContainsString($question2['text'], $data[0][4]);
1525+
1526+
$this->assertEquals('question-id-' . $question2['id'], $data[1][4]);
1527+
// Check data rows (oldest first: user3, user2)
1528+
$this->assertEquals($submission3_id, $data[2][0]);
1529+
$this->assertTrue(!isset($data[2][4]) || $data[2][4] === ''); // No answer for q2
1530+
$this->assertEquals($submission2_id, $data[3][0]);
1531+
$this->assertEquals('Option 2', $data[3][4]);
1532+
1533+
// 6. Update a submission
1534+
$this->http->request('PUT', "api/v3/forms/{$formId}/submissions/{$submission2_id}", ['json' => ['answers' => [$question2['id'] => ['Option 1']]]]);
1535+
1536+
// 7. Export again
1537+
$this->http->request('POST', "api/v3/forms/{$formId}/submissions/export", ['json' => ['path' => $exportPath, 'fileFormat' => 'csv']]);
1538+
1539+
// 8. Verify export after update
1540+
$content = $this->userFolder->get($exportPath)->getContent();
1541+
$data = array_map('str_getcsv', explode("\n", trim($content)));
1542+
1543+
$updatedRow = null;
1544+
foreach (array_slice($data, 2) as $row) {
1545+
if ($row[0] == $submission2_id) {
1546+
$updatedRow = $row;
1547+
break;
1548+
}
1549+
}
1550+
$this->assertNotNull($updatedRow, 'Updated submission not found in export');
1551+
$this->assertEquals('Option 1', $updatedRow[4]);
1552+
}
1553+
14871554
public static function dataDeleteSubmissions() {
14881555
$submissionsExpected = self::dataGetSubmissions()['getSubmissions']['expected'];
14891556
$submissionsExpected['submissions'] = [];

0 commit comments

Comments
 (0)