diff options
| author | Thomas Hackl <hackl@data-quest.de> | 2025-10-01 14:33:20 +0200 |
|---|---|---|
| committer | Thomas Hackl <hackl@data-quest.de> | 2025-10-01 14:33:20 +0200 |
| commit | 97abcd222884db2dddb88889e253663082e759f5 (patch) | |
| tree | 82e7390e049b50c450649671ef5e1d99df2d1dee | |
| parent | c1a659702122401c9c197aa5fb64d62767c4dead (diff) | |
Resolve "RTF-Export als Word-Dokument wiederbeleben"
Closes #5887
Merge request studip/studip!4489
| -rw-r--r-- | app/controllers/course/members.php | 52 | ||||
| -rw-r--r-- | app/controllers/course/statusgroups.php | 44 | ||||
| -rw-r--r-- | composer.json | 3 | ||||
| -rw-r--r-- | composer.lock | 162 | ||||
| -rw-r--r-- | lib/classes/Services/Export/CourseMemberService.php | 297 | ||||
| -rw-r--r-- | lib/classes/Services/Export/StatusGroupsService.php | 291 |
6 files changed, 840 insertions, 9 deletions
diff --git a/app/controllers/course/members.php b/app/controllers/course/members.php index adeb4a8..d016862 100644 --- a/app/controllers/course/members.php +++ b/app/controllers/course/members.php @@ -15,6 +15,9 @@ * @since 2.5 */ +use PhpOffice\PhpWord\PhpWord; +use Services\Export\CourseMemberService; + require_once 'lib/messaging.inc.php'; //Funktionen des Nachrichtensystems class Course_MembersController extends AuthenticatedController @@ -1766,7 +1769,7 @@ class Course_MembersController extends AuthenticatedController $widget->addLink( _('Als Excel-Datei exportieren'), - URLHelper::getURL('dispatch.php/course/members/export', [ + $this->exportURL([ 'course_id' => $this->course_id, 'format' => 'xlsx', ]), @@ -1775,17 +1778,25 @@ class Course_MembersController extends AuthenticatedController $widget->addLink( _('Als CSV-Datei exportieren'), - URLHelper::getURL('dispatch.php/course/members/export', [ + $this->exportURL([ 'course_id' => $this->course_id, 'format' => 'csv', ]), Icon::create('export') ); + $widget->addLink( + _('Als Word-Datei exportieren'), + $this->export_wordURL([ + 'course_id' => $this->course_id + ]), + Icon::create('export') + ); + if (count($this->awaiting) > 0) { $widget->addLink( _('Warteliste als Excel-Datei exportieren'), - URLHelper::getURL('dispatch.php/course/members/export', [ + $this->exportURL([ 'course_id' => $this->course_id, 'format' => 'xlsx', 'status' => $this->waiting_type, @@ -1794,13 +1805,22 @@ class Course_MembersController extends AuthenticatedController ); $widget->addLink( _('Warteliste als CSV-Datei exportieren'), - URLHelper::getURL('dispatch.php/course/members/export', [ + $this->exportURL([ 'course_id' => $this->course_id, 'format' => 'csv', 'status' => $this->waiting_type, ]), Icon::create('export') ); + + $widget->addLink( + _('Als Word-Datei exportieren'), + $this->export_wordURL([ + 'course_id' => $this->course_id, + 'status' => $this->waiting_type + ]), + Icon::create('export') + ); } } @@ -1840,12 +1860,34 @@ class Course_MembersController extends AuthenticatedController } } + /** + * Handles the export of the course member list as a Word document. + * + * @return void + * @throws \PhpOffice\PhpWord\Exception\Exception + */ + public function export_word_action(): void + { + $status = Request::get('status', ''); + $course = Course::findCurrent(); + + $file = new CourseMemberService($course, $status); + $file->save(); + + $this->response->add_header('Cache-Control', 'cache, must-revalidate'); + $this->render_temporary_file( + $file->getFilePath(), + $file->getFilename(), + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ); + } + public function export_action() { $export_format = Request::get('format'); $status = Request::get('status'); - if ($export_format !== 'csv' && $export_format !== 'xlsx') { + if (!in_array($export_format, ['csv', 'xlsx'])) { throw new Exception('Wrong format'); } $header = [ diff --git a/app/controllers/course/statusgroups.php b/app/controllers/course/statusgroups.php index 7b0a62c..f768f9e 100644 --- a/app/controllers/course/statusgroups.php +++ b/app/controllers/course/statusgroups.php @@ -247,7 +247,7 @@ class Course_StatusgroupsController extends AuthenticatedController $export = new ExportWidget(); $export->addLink( _('Als Excel-Datei exportieren'), - URLHelper::getURL('dispatch.php/course/statusgroups/export', [ + $this->exportURL([ 'course_id' => $this->course_id, 'format' => 'xlsx', ]), @@ -256,7 +256,26 @@ class Course_StatusgroupsController extends AuthenticatedController $export->addLink( _('Als CSV-Datei exportieren'), - URLHelper::getURL('dispatch.php/course/statusgroups/export', [ + $this->exportURL([ + 'course_id' => $this->course_id, + 'format' => 'csv', + ]), + Icon::create('export') + ); + + $export->addLink( + _('Als CSV-Datei exportieren'), + $this->exportURL([ + 'course_id' => $this->course_id, + 'format' => 'csv', + ]), + Icon::create('export') + ); + + + $export->addLink( + _('Als Word-Datei exportieren'), + $this->export_wordURL([ 'course_id' => $this->course_id, 'format' => 'csv', ]), @@ -292,6 +311,27 @@ class Course_StatusgroupsController extends AuthenticatedController } /** + * Handles the export of the course member list as a Word document. + * + * @return void + * @throws \PhpOffice\PhpWord\Exception\Exception + */ + public function export_word_action(): void + { + $course = Course::findCurrent(); + + $file = new \Services\Export\StatusGroupsService($course); + $file->save(); + + $this->response->add_header('Cache-Control', 'cache, must-revalidate'); + $this->render_temporary_file( + $file->getFilePath(), + $file->getFilename(), + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ); + } + + /** * */ public function export_action() diff --git a/composer.json b/composer.json index eb634cd..a7dfd07 100644 --- a/composer.json +++ b/composer.json @@ -137,7 +137,8 @@ "phpseclib/phpseclib2_compat": "1.0.6", "oat-sa/lib-lti1p3-deep-linking": "4.1.0", "lcobucci/jwt": "^4.3", - "guzzlehttp/guzzle": "^7.9.2" + "guzzlehttp/guzzle": "^7.9.2", + "phpoffice/phpword": "^1.4" }, "replace": { "symfony/polyfill-php54": "*", diff --git a/composer.lock b/composer.lock index f053425..40facba 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d4ed744c2eaa2b8762e33fab3a5ae288", + "content-hash": "c0e4cb48905267aa9a02647b5c741d74", "packages": [ { "name": "algo26-matthias/idna-convert", @@ -3474,6 +3474,58 @@ "time": "2025-04-22T08:53:15+00:00" }, { + "name": "phpoffice/math", + "version": "0.3.0", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/Math.git", + "reference": "fc31c8f57a7a81f962cbf389fd89f4d9d06fc99a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/Math/zipball/fc31c8f57a7a81f962cbf389fd89f4d9d06fc99a", + "reference": "fc31c8f57a7a81f962cbf389fd89f4d9d06fc99a", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-xml": "*", + "php": "^7.1|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.88 || ^1.0.0", + "phpunit/phpunit": "^7.0 || ^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\Math\\": "src/Math/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Progi1984", + "homepage": "https://lefevre.dev" + } + ], + "description": "Math - Manipulate Math Formula", + "homepage": "https://phpoffice.github.io/Math/", + "keywords": [ + "MathML", + "officemathml", + "php" + ], + "support": { + "issues": "https://github.com/PHPOffice/Math/issues", + "source": "https://github.com/PHPOffice/Math/tree/0.3.0" + }, + "time": "2025-05-29T08:31:49+00:00" + }, + { "name": "phpoffice/phpspreadsheet", "version": "5.0.0", "source": { @@ -3580,6 +3632,114 @@ "time": "2025-08-10T06:18:27+00:00" }, { + "name": "phpoffice/phpword", + "version": "1.4.0", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PHPWord.git", + "reference": "6d75328229bc93790b37e93741adf70646cea958" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PHPWord/zipball/6d75328229bc93790b37e93741adf70646cea958", + "reference": "6d75328229bc93790b37e93741adf70646cea958", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-gd": "*", + "ext-json": "*", + "ext-xml": "*", + "ext-zip": "*", + "php": "^7.1|^8.0", + "phpoffice/math": "^0.3" + }, + "require-dev": { + "dompdf/dompdf": "^2.0 || ^3.0", + "ext-libxml": "*", + "friendsofphp/php-cs-fixer": "^3.3", + "mpdf/mpdf": "^7.0 || ^8.0", + "phpmd/phpmd": "^2.13", + "phpstan/phpstan": "^0.12.88 || ^1.0.0", + "phpstan/phpstan-phpunit": "^1.0 || ^2.0", + "phpunit/phpunit": ">=7.0", + "symfony/process": "^4.4 || ^5.0", + "tecnickcom/tcpdf": "^6.5" + }, + "suggest": { + "dompdf/dompdf": "Allows writing PDF", + "ext-xmlwriter": "Allows writing OOXML and ODF", + "ext-xsl": "Allows applying XSL style sheet to headers, to main document part, and to footers of an OOXML template" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\PhpWord\\": "src/PhpWord" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-only" + ], + "authors": [ + { + "name": "Mark Baker" + }, + { + "name": "Gabriel Bull", + "email": "me@gabrielbull.com", + "homepage": "http://gabrielbull.com/" + }, + { + "name": "Franck Lefevre", + "homepage": "https://rootslabs.net/blog/" + }, + { + "name": "Ivan Lanin", + "homepage": "http://ivan.lanin.org" + }, + { + "name": "Roman Syroeshko", + "homepage": "http://ru.linkedin.com/pub/roman-syroeshko/34/a53/994/" + }, + { + "name": "Antoine de Troostembergh" + } + ], + "description": "PHPWord - A pure PHP library for reading and writing word processing documents (OOXML, ODF, RTF, HTML, PDF)", + "homepage": "https://phpoffice.github.io/PHPWord/", + "keywords": [ + "ISO IEC 29500", + "OOXML", + "Office Open XML", + "OpenDocument", + "OpenXML", + "PhpOffice", + "PhpWord", + "Rich Text Format", + "WordprocessingML", + "doc", + "docx", + "html", + "odf", + "odt", + "office", + "pdf", + "php", + "reader", + "rtf", + "template", + "template processor", + "word", + "writer" + ], + "support": { + "issues": "https://github.com/PHPOffice/PHPWord/issues", + "source": "https://github.com/PHPOffice/PHPWord/tree/1.4.0" + }, + "time": "2025-06-05T10:32:36+00:00" + }, + { "name": "phpoption/phpoption", "version": "1.9.3", "source": { diff --git a/lib/classes/Services/Export/CourseMemberService.php b/lib/classes/Services/Export/CourseMemberService.php new file mode 100644 index 0000000..4806ab8 --- /dev/null +++ b/lib/classes/Services/Export/CourseMemberService.php @@ -0,0 +1,297 @@ +<?php + +namespace Services\Export; + +use PhpOffice\PhpWord\Exception\Exception; +use PhpOffice\PhpWord\SimpleType\Jc; +use PhpOffice\PhpWord\Style\Language; +use PhpOffice\PhpWord\Writer\WriterInterface; +use PhpOffice\PhpWord\PhpWord; +use PhpOffice\PhpWord\IOFactory; +use Course; + +final class CourseMemberService +{ + public const WORD = 'docx'; + public const EXCEL = 'xlsx'; + public const CSV = 'csv'; + + protected string $filepath; + protected string $export_format = CourseMemberService::WORD; + + public function __construct( + protected Course $course, + protected string $status = '' + ) + { + $this->filepath = tempnam($GLOBALS['TMP_PATH'], 'documents'); + } + + /** + * Generates a Word document containing the course member list. + * + * @return WriterInterface + * @throws Exception + */ + public function getWordFile(): WriterInterface + { + $members = $this->extractMembers(); + + $word = new PhpWord(); + $section = $word->addSection(); + + $footer = $section->addFooter(); + $footer->addText( + sprintf(_('Erstellt am: %s'), date('d.m.Y H:i')), + ['italic' => true, 'size' => 8], + ['alignment' => Jc::END] + ); + $properties = $word->getSettings(); + $properties->setThemeFontLang(new Language($GLOBALS['_language'])); + + $section->addText( + $this->getHeadline(), + ['bold' => true, 'size' => 14] + ); + $section->addTextBreak(); + + $section->addText( + $this->getListType(), + ['bold' => true, 'size' => 12], + ); + + $table_style = [ + 'borderSize' => 4, + 'borderColor' => '000000', + 'cellMarginTop' => 0, + 'cellMarginBottom' => 0, + 'cellMarginLeft' => 40, + 'cellMarginRight' => 40, + ]; + + $cell_style = [ + 'spaceBefore' => 0, + 'spaceAfter' => 0 + ]; + + $text_style = ['size' => 8]; + $firstRowStyle = ['tblHeader' => true]; + + $word->addTableStyle('MembersTable', $table_style, $firstRowStyle); + + $word->addNumberingStyle('degree_bullets', [ + 'type' => 'multilevel', + 'levels' => [ + [ + 'format' => 'bullet', + 'text' => '•', + 'left' => 200, + 'hanging' => 200, + 'tabPos' => 200, + ] + ], + ]); + + $table = $section->addTable('MembersTable'); + + $headers = [ + _('Name'), + _('E-Mail'), + _('Telefon'), + _('Studiengänge') + ]; + + $table->addRow(); + foreach ($headers as $h) { + $table->addCell( + 2500, + [ + 'valign' => 'center', + 'bgColor' => 'BFBFBF' + ] + )->addText( + $h, + [ + 'bold' => true, + 'size' => 10, + ], + $cell_style + ); + } + + foreach ($members as $status => $users) { + $table->addRow(); + $table->addCell( + 14000, + [ + 'gridSpan' => 4, + 'bgColor' => 'D9D9D9' + ] + )->addText( + get_title_for_status($status, count($users)), + [ + 'bold' => true, + 'size' => 10 + ], + $cell_style + ); + + if (!empty($users)) { + foreach ($users as $user) { + $table->addRow(); + $table->addCell(3000, ['noWrap' => true]) + ->addText( + htmlReady($user['Nachname'] . ', ' . $user['Vorname']), + $text_style, + $cell_style + ); + $table->addCell(4500, ['noWrap' => true]) + ->addText( + htmlReady($user['Email']), + $text_style, + $cell_style + ); + $table->addCell(2000) + ->addText( + htmlReady($user['privatnr']), + $text_style, + $cell_style + ); + + $cell = $table->addCell(2400); + if (!empty($user['studiengaenge'])) { + $degrees = explode(';', $user['studiengaenge']); + if (count($degrees) > 1) { + foreach ($degrees as $degree) { + $cell->addListItem( + trim($degree), + 0, + $text_style, + 'degree_bullets', + [ + 'spaceBefore' => 0, + 'spaceAfter' => 60, + ] + ); + } + } else { + $cell->addText($user['studiengaenge'], $text_style, $cell_style); + } + } else { + $cell->addText('', $text_style, $cell_style); + } + } + } + } + + return IOFactory::createWriter($word); + } + + /** + * Extracts and organizes course member data by status. + * + * @return array + */ + public function extractMembers(): array + { + $members = $this->course->getMembersData($this->status); + $_members = []; + foreach ($members as $user_id => $data) { + $_members[$data['status']][$user_id] = $data; + }; + return $_members; + } + + /** + * Saves the generated Word document to the configured file path. + * + * @return void + * @throws Exception + */ + + public function save(): void + { + if ($this->export_format === CourseMemberService::WORD) { + $this->getWordFile() + ->save($this->filepath); + } + } + + /** + * @param string $filepath + * @return $this + */ + public function setFilePath(string $filepath): self + { + $this->filepath = $filepath; + return $this; + } + + /** + * Returns the full path where the Word document will be saved. + * + * @return string The absolute file path for the generated document. + */ + public function getFilePath(): string + { + return $this->filepath; + } + + /** + * Generates the headline for the Word document. + * + * @return string + */ + private function getHeadline(): string + { + return sprintf('%s: %s', $this->getListType(), $this->course->getFullName()); + } + + /** + * Returns the type of list based on the current status. + * + * @return string + */ + public function getListType(): string + { + if (in_array($this->status, ['awaiting', 'claiming'])) { + return _('Warteliste'); + } + return _('Teilnehmendenliste'); + } + + /** + * Generates the filename for the exported Word document. + * + * @return string + */ + public function getFilename(): string + { + $file_name = _('Teilnehmendenexport'); + if (in_array($this->status, ['awaiting', 'claiming'])) { + $file_name = _('Wartelistenexport'); + } + return $file_name . '.' . $this->export_format; + } + + + /** + * @return string + */ + public function getExportFormat(): string + { + return $this->export_format; + } + + /** + * @param string $format + * @return $this + */ + public function setExportFormat(string $format): self + { + $this->export_format = $format; + + return $this; + } + +} diff --git a/lib/classes/Services/Export/StatusGroupsService.php b/lib/classes/Services/Export/StatusGroupsService.php new file mode 100644 index 0000000..d13f21f --- /dev/null +++ b/lib/classes/Services/Export/StatusGroupsService.php @@ -0,0 +1,291 @@ +<?php + +namespace Services\Export; + +use PhpOffice\PhpWord\Exception\Exception; +use PhpOffice\PhpWord\SimpleType\Jc; +use PhpOffice\PhpWord\Style\Language; +use PhpOffice\PhpWord\Writer\WriterInterface; +use PhpOffice\PhpWord\PhpWord; +use PhpOffice\PhpWord\IOFactory; +use Course; + +final class StatusGroupsService +{ + public const WORD = 'docx'; + public const EXCEL = 'xlsx'; + public const CSV = 'csv'; + + protected string $filepath; + protected string $export_format = CourseMemberService::WORD; + + public function __construct( + protected Course $course + ) { + $this->filepath = tempnam($GLOBALS['TMP_PATH'], 'documents'); + } + + /** + * Generates a Word document containing the course member list. + * + * @return WriterInterface + * @throws Exception + */ + public function getWordFile(): WriterInterface + { + $members = $this->extractMembers(); + + $word = new PhpWord(); + $section = $word->addSection(); + $footer = $section->addFooter(); + $footer->addText( + sprintf(_('Erstellt am: %s'), date('d.m.Y H:i')), + ['italic' => true, 'size' => 8], + ['alignment' => Jc::END] + ); + $properties = $word->getSettings(); + $properties->setThemeFontLang(new Language($GLOBALS['_language'])); + + $section->addText( + $this->course->getFullName(), + ['bold' => true, 'size' => 14] + ); + $section->addTextBreak(); + + $section->addText( + $this->getListType(), + ['bold' => true, 'size' => 12], + ); + + $table_style = [ + 'borderSize' => 4, + 'borderColor' => '000000', + 'cellMarginTop' => 0, + 'cellMarginBottom' => 0, + 'cellMarginLeft' => 40, + 'cellMarginRight' => 40, + ]; + + $cell_style = [ + 'spaceBefore' => 0, + 'spaceAfter' => 0 + ]; + + $text_style = ['size' => 8]; + $firstRowStyle = ['tblHeader' => true]; + + $word->addTableStyle('MembersTable', $table_style, $firstRowStyle); + + $word->addNumberingStyle('degree_bullets', [ + 'type' => 'multilevel', + 'levels' => [ + [ + 'format' => 'bullet', + 'text' => '•', + 'left' => 200, + 'hanging' => 200, + 'tabPos' => 200, + ] + ], + ]); + + $table = $section->addTable('MembersTable'); + + $headers = [ + _('Name'), + _('E-Mail'), + _('Telefon'), + _('Studiengänge') + ]; + + $table->addRow(); + foreach ($headers as $h) { + $table->addCell( + 2500, + [ + 'valign' => 'center', + 'bgColor' => 'BFBFBF' + ] + )->addText( + $h, + [ + 'bold' => true, + 'size' => 10, + ], + $cell_style + ); + } + + foreach ($members as $group_name => $users) { + $table->addRow(); + $table->addCell( + 14000, + [ + 'gridSpan' => 4, + 'bgColor' => 'D9D9D9' + ] + )->addText( + htmlReady($group_name), + [ + 'bold' => true, + 'size' => 10 + ], + $cell_style + ); + + if (!empty($users)) { + foreach ($users as $user) { + $table->addRow(); + $table->addCell(3000, ['noWrap' => true]) + ->addText( + htmlReady($user['Nachname'] . ', ' . $user['Vorname']), + $text_style, + $cell_style + ); + $table->addCell(4500, ['noWrap' => true]) + ->addText( + htmlReady($user['Email']), + $text_style, + $cell_style + ); + $table->addCell(2000) + ->addText( + htmlReady($user['privatnr']), + $text_style, + $cell_style + ); + + $cell = $table->addCell(2400); + if (!empty($user['studiengaenge'])) { + $degrees = explode(';', $user['studiengaenge']); + if (count($degrees) > 1) { + foreach ($degrees as $degree) { + $cell->addListItem( + trim($degree), + 0, + $text_style, + 'degree_bullets', + [ + 'spaceBefore' => 0, + 'spaceAfter' => 60, + ] + ); + } + } else { + $cell->addText($user['studiengaenge'], $text_style, $cell_style); + } + } else { + $cell->addText('', $text_style, $cell_style); + } + } + } + } + + return IOFactory::createWriter($word); + } + + /** + * Extracts and organizes course member data by status. + * + * @return array + */ + public function extractMembers(): array + { + $groups = $this->course->statusgruppen; + $result = []; + if ($groups) { + $assigned_with_group = []; + foreach ($groups as $group) { + foreach ($group->members->orderBy('nachname,vorname') as $member) { + $assigned_with_group[$member->user_id] = true; + $result[(string)$group->name][$member->user_id] = $member->getExportData(); + } + } + $members = $this->course->members->filter(function ($group_member) use ($assigned_with_group) { + return !array_key_exists($group_member->user_id, $assigned_with_group); + })->orderBy('nachname,vorname'); + + foreach ($members as $member) { + $result[_('keiner Funktion oder Gruppe zugeordnet')][$member->user_id] = $member->getExportData(); + } + } + + return $result; + } + + /** + * Saves the generated Word document to the configured file path. + * + * @return void + * @throws Exception + */ + + public function save(): void + { + if ($this->export_format === CourseMemberService::WORD) { + $this->getWordFile() + ->save($this->filepath); + } + } + + /** + * @param string $filepath + * @return $this + */ + public function setFilePath(string $filepath): self + { + $this->filepath = $filepath; + return $this; + } + + /** + * Returns the full path where the Word document will be saved. + * + * @return string The absolute file path for the generated document. + */ + public function getFilePath(): string + { + return $this->filepath; + } + + /** + * Returns the type of list based on the current status. + * + * @return string + */ + public function getListType(): string + { + return _('Gruppenliste'); + } + + /** + * Generates the filename for the exported Word document. + * + * @return string + */ + public function getFilename(): string + { + return _('Gruppenliste') . '.' . $this->export_format; + } + + + /** + * @return string + */ + public function getExportFormat(): string + { + return $this->export_format; + } + + /** + * @param string $format + * @return $this + */ + public function setExportFormat(string $format): self + { + $this->export_format = $format; + + return $this; + } + +} |
