entityManager->getFilters(); $filter->disable('softdeleteable'); try { return $this->buildZip($user); } finally { $filter->enable('softdeleteable'); } } private function buildZip(User $user): string { $zipPath = tempnam(sys_get_temp_dir(), 'tvdt_export_'); $tempXlsxFiles = []; $zip = new \ZipArchive(); if (true !== $zip->open($zipPath, \ZipArchive::OVERWRITE)) { unlink($zipPath); throw new \RuntimeException('Could not create the export zip archive.'); } try { $profilePath = $this->writeToTempFile($this->buildProfileWorkbook($user)); $tempXlsxFiles[] = $profilePath; $zip->addFile($profilePath, 'profile.xlsx'); foreach ($user->seasons as $season) { $folder = FilenameSanitizer::sanitize($season->seasonCode.'-'.$season->name).'/'; foreach ($season->quizzes as $quiz) { $quizPath = $this->writeToTempFile($this->buildQuizWorkbook($quiz)); $tempXlsxFiles[] = $quizPath; $zip->addFile($quizPath, $folder.FilenameSanitizer::sanitize($quiz->name).'.xlsx'); } $candidatesPath = $this->writeToTempFile($this->buildCandidatesWorkbook($season)); $tempXlsxFiles[] = $candidatesPath; $zip->addFile($candidatesPath, $folder.'candidates.xlsx'); $questionBankPath = $this->writeToTempFile($this->buildQuestionBankWorkbook($season)); $tempXlsxFiles[] = $questionBankPath; $zip->addFile($questionBankPath, $folder.'question-bank.xlsx'); } if (!$zip->close()) { throw new \RuntimeException('Could not finalize the export zip archive.'); } } catch (\Throwable $throwable) { unlink($zipPath); throw $throwable; } finally { foreach ($tempXlsxFiles as $tempXlsxFile) { unlink($tempXlsxFile); } } return $zipPath; } private function buildProfileWorkbook(User $user): Spreadsheet { $spreadsheet = new Spreadsheet(); $account = $spreadsheet->getActiveSheet(); $account->setTitle('Account'); $account->getStyle('A:A')->getFont()->setBold(true); $account->fromArray([ ['Email', $user->email], ['Roles', implode(', ', $user->getRoles())], ['Email verified', $user->isVerified ? 'Yes' : 'No'], ['Account ID', $user->id->toString()], ], null, 'A1'); $account->getColumnDimension('A')->setAutoSize(true); $account->getColumnDimension('B')->setAutoSize(true); $seasons = $spreadsheet->createSheet(); $seasons->setTitle('Seasons'); $seasons->fromArray(['Season', 'Season code', 'Quizzes', 'Candidates', 'Shared with other owners'], null, 'A1'); $seasons->getStyle('1:1')->getFont()->setBold(true); $row = 2; foreach ($user->seasons as $season) { $seasons->fromArray([ $season->name, $season->seasonCode, $season->quizzes->count(), $season->candidates->count(), $season->owners->count() > 1 ? 'Yes' : 'No', ], null, 'A'.$row); ++$row; } foreach (['A', 'B', 'C', 'D', 'E'] as $column) { $seasons->getColumnDimension($column)->setAutoSize(true); } $spreadsheet->setActiveSheetIndex(0); return $spreadsheet; } private function buildQuizWorkbook(Quiz $quiz): Spreadsheet { $spreadsheet = new Spreadsheet(); $info = $spreadsheet->getActiveSheet(); $info->setTitle('Quiz info'); $this->fillQuizInfoSheet($info, $quiz); $questions = $spreadsheet->createSheet(); $questions->setTitle('Questions'); $this->quizSpreadsheetService->fillQuestionsSheet($questions, $quiz); $rawAnswers = $spreadsheet->createSheet(); $rawAnswers->setTitle('Raw answers'); $this->fillRawAnswersSheet($rawAnswers, $quiz); $results = $spreadsheet->createSheet(); $results->setTitle('Results'); $this->fillResultsSheet($results, $quiz); $eliminations = $spreadsheet->createSheet(); $eliminations->setTitle('Eliminations'); $this->fillEliminationsSheet($eliminations, $quiz); $spreadsheet->setActiveSheetIndex(0); return $spreadsheet; } private function fillQuizInfoSheet(Worksheet $sheet, Quiz $quiz): void { $disabledQuestions = $quiz->questions ->filter(static fn (Question $question): bool => !$question->enabled) ->map(static fn (Question $question): string => $question->question) ->toArray(); $sheet->getStyle('A:A')->getFont()->setBold(true); $sheet->fromArray([ ['Quiz name', $quiz->name], ['Number of dropouts', $quiz->dropouts], ['Finalized', $quiz->isFinalized ? 'Yes' : 'No'], ['Finalized at', $quiz->finalizedAt?->format(\DateTimeInterface::ATOM) ?? ''], ['Disabled questions', implode(', ', $disabledQuestions)], ], null, 'A1'); $sheet->getColumnDimension('A')->setAutoSize(true); $sheet->getColumnDimension('B')->setAutoSize(true); } private function fillResultsSheet(Worksheet $sheet, Quiz $quiz): void { $sheet->fromArray(['Candidate', 'Correct answers', 'Corrections', 'Penalty (s)', 'Score', 'Time', 'Started', 'Active', 'Deleted'], null, 'A1'); $sheet->getStyle('1:1')->getFont()->setBold(true); /** @var array $scoresByCandidateId */ $scoresByCandidateId = []; foreach ($this->quizRepository->getScores($quiz) as $result) { $scoresByCandidateId[$result->id->toString()] = $result; } $row = 2; foreach ($quiz->candidateData as $quizCandidate) { $candidate = $quizCandidate->candidate; $result = $scoresByCandidateId[$candidate->id->toString()] ?? null; $sheet->fromArray([ $candidate->name, $result?->correct, $result?->corrections, $result?->penaltySeconds, $result?->score, $result instanceof Result ? $result->time->format('%i:%S') : null, $quizCandidate->started?->format(\DateTimeInterface::ATOM), $quizCandidate->active ? 'Yes' : 'No', $quizCandidate->getDeletedAt()?->format(\DateTimeInterface::ATOM) ?? '', ], null, 'A'.$row); ++$row; } foreach (range('A', 'I') as $column) { $sheet->getColumnDimension($column)->setAutoSize(true); } } /** Raw crosstab: one row per candidate, one column per question, cell = the answer text they gave. */ private function fillRawAnswersSheet(Worksheet $sheet, Quiz $quiz): void { /** @var list $questions */ $questions = $quiz->questions->toArray(); $header = ['Candidate']; foreach ($questions as $question) { $header[] = $question->question; } $sheet->fromArray($header, null, 'A1'); $sheet->getStyle('1:1')->getFont()->setBold(true); $sheet->getStyle('1:1')->getAlignment()->setWrapText(true); /** @var array> $answersByCandidateAndQuestion */ $answersByCandidateAndQuestion = []; foreach ($questions as $question) { foreach ($question->answers as $answer) { foreach ($answer->givenAnswers as $givenAnswer) { $candidateId = $givenAnswer->candidate->id->toString(); $answersByCandidateAndQuestion[$candidateId][$question->id->toString()] = $answer->text; } } } $row = 2; foreach ($quiz->candidateData as $quizCandidate) { $candidate = $quizCandidate->candidate; $line = [$candidate->name]; foreach ($questions as $question) { $line[] = $answersByCandidateAndQuestion[$candidate->id->toString()][$question->id->toString()] ?? ''; } $sheet->fromArray($line, null, 'A'.$row); ++$row; } $lastColumnIndex = 1 + \count($questions); foreach (range('A', Coordinate::stringFromColumnIndex($lastColumnIndex)) as $column) { $sheet->getColumnDimension($column)->setWidth(30); $sheet->getStyle($column.':'.$column)->getAlignment()->setWrapText(true); } } private function fillEliminationsSheet(Worksheet $sheet, Quiz $quiz): void { /** @var list $candidates */ $candidates = $quiz->season->candidates->toArray(); $header = ['Prepared at', 'Deleted']; foreach ($candidates as $candidate) { $header[] = $candidate->name; } $sheet->fromArray($header, null, 'A1'); $sheet->getStyle('1:1')->getFont()->setBold(true); $row = 2; foreach ($quiz->eliminations as $elimination) { $line = [ $elimination->getCreatedAt()?->format(\DateTimeInterface::ATOM) ?? '', $elimination->getDeletedAt()?->format(\DateTimeInterface::ATOM) ?? '', ]; foreach ($candidates as $candidate) { $line[] = $elimination->getScreenColour($candidate->name) ?? ''; } $sheet->fromArray($line, null, 'A'.$row); ++$row; } foreach (range('A', Coordinate::stringFromColumnIndex(2 + \count($candidates))) as $column) { $sheet->getColumnDimension($column)->setAutoSize(true); } } private function buildCandidatesWorkbook(Season $season): Spreadsheet { $spreadsheet = new Spreadsheet(); $candidatesSheet = $spreadsheet->getActiveSheet(); $candidatesSheet->setTitle('Candidates'); $candidatesSheet->fromArray(['Name'], null, 'A1'); $candidatesSheet->getStyle('1:1')->getFont()->setBold(true); $row = 2; foreach ($season->candidates as $candidate) { $candidatesSheet->fromArray([$candidate->name], null, 'A'.$row); ++$row; } $candidatesSheet->getColumnDimension('A')->setAutoSize(true); $infoSheet = $spreadsheet->createSheet(); $infoSheet->setTitle('Season info'); $infoSheet->getStyle('A:A')->getFont()->setBold(true); $infoSheet->fromArray([ ['Season name', $season->name], ['Season code', $season->seasonCode], ['Number of quizzes', $season->quizzes->count()], ['Number of candidates', $season->candidates->count()], ['Active quiz', $season->activeQuiz instanceof Quiz ? $season->activeQuiz->name : ''], ['Show numbers', $season->settings?->showNumbers ? 'Yes' : 'No'], ['Confirm answers', $season->settings?->confirmAnswers ? 'Yes' : 'No'], ['Shared with other owners', $season->owners->count() > 1 ? 'Yes' : 'No'], ], null, 'A1'); $infoSheet->getColumnDimension('A')->setAutoSize(true); $infoSheet->getColumnDimension('B')->setAutoSize(true); $spreadsheet->setActiveSheetIndex(0); return $spreadsheet; } private function buildQuestionBankWorkbook(Season $season): Spreadsheet { $spreadsheet = new Spreadsheet(); $questions = $spreadsheet->getActiveSheet(); $questions->setTitle('Questions'); $this->fillBankQuestionsSheet($questions, $season); $labels = $spreadsheet->createSheet(); $labels->setTitle('Labels'); $this->fillQuestionLabelsSheet($labels, $season); $spreadsheet->setActiveSheetIndex(0); return $spreadsheet; } private function fillBankQuestionsSheet(Worksheet $sheet, Season $season): void { $metaColumns = ['Question', 'Reusable', 'Complete for quiz', 'Labels', 'Used in quizzes']; $sheet->fromArray($metaColumns, null, 'A1'); $sheet->getStyle('1:1')->getFont()->setBold(true); $answerStartColumnIndex = \count($metaColumns); $maxAnswers = 0; $row = 2; foreach ($season->bankQuestions as $bankQuestion) { $labels = implode(', ', array_map( static fn (QuestionLabel $label): string => $label->name, $bankQuestion->labels->toArray(), )); $usedInQuizzes = implode(', ', array_map( static fn (BankQuestionUsage $usage): string => $usage->quiz->name, $bankQuestion->usages->toArray(), )); $sheet->fromArray([ $bankQuestion->question, $bankQuestion->reusable ? 'Yes' : 'No', $bankQuestion->isCompleteForQuiz ? 'Yes' : 'No', $labels, $usedInQuizzes, ], null, 'A'.$row); $col = 0; foreach ($bankQuestion->answers as $answer) { $sheet->setCellValue(Coordinate::stringFromColumnIndex($answerStartColumnIndex + 1 + 2 * $col).$row, $answer->text); $sheet->setCellValue(Coordinate::stringFromColumnIndex($answerStartColumnIndex + 2 + 2 * $col).$row, $answer->isRightAnswer); ++$col; } $maxAnswers = max($maxAnswers, $col); ++$row; } for ($i = 0; $i < $maxAnswers; ++$i) { $answerCol = Coordinate::stringFromColumnIndex($answerStartColumnIndex + 1 + 2 * $i); $correctCol = Coordinate::stringFromColumnIndex($answerStartColumnIndex + 2 + 2 * $i); $sheet->setCellValue($answerCol.'1', 'Answer '.($i + 1)); $sheet->setCellValue($correctCol.'1', 'Correct'); } $lastColumnIndex = $answerStartColumnIndex + max(1, 2 * $maxAnswers); foreach (range('A', Coordinate::stringFromColumnIndex($lastColumnIndex)) as $column) { $sheet->getColumnDimension($column)->setAutoSize(true); } } private function fillQuestionLabelsSheet(Worksheet $sheet, Season $season): void { $sheet->fromArray(['Name', 'Colour', 'Slug'], null, 'A1'); $sheet->getStyle('1:1')->getFont()->setBold(true); $row = 2; foreach ($season->questionLabels as $label) { $sheet->fromArray([$label->name, $label->colour->name, $label->slug], null, 'A'.$row); ++$row; } foreach (['A', 'B', 'C'] as $column) { $sheet->getColumnDimension($column)->setAutoSize(true); } } /** @throws FilesystemException */ private function writeToTempFile(Spreadsheet $spreadsheet): string { $path = tempnam(sys_get_temp_dir(), 'tvdt_export_sheet_'); try { new Writer\Xlsx($spreadsheet)->save($path); } catch (\Throwable $throwable) { unlink($path); throw $throwable; } return $path; } }