From 10a2db09388469d10a1325f9dc7a742d85c8eb83 Mon Sep 17 00:00:00 2001 From: labmecanicatec Date: Tue, 7 Apr 2026 20:54:09 -0500 Subject: [PATCH 1/2] refactor(reservation): migrate PDF generation to pdfmake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove global `allAttributes` variable; attribute data now flows as an explicit parameter through LoadCustomAttributesData() → BuildReservationPdfDefinition() - Replace $.ajax and jQuery event binding with async/await, fetch, URLSearchParams and addEventListener - Extract section builder functions: buildHeader(), buildDetails(), buildAttributes() - Fix invalid Smarty modifier escape:'json' → |json_encode - Replace magic number '5' (DATETIME) and 4 (CHECKBOX) - Expose DayNamesFull to templates for repeat weekday names in PDF - The use of the embedded_datetime format is removed and schedule_daily and dashboard are used - An error is corrected that the dates were not displayed correctly when the repeatability was of the custom type --- Pages/Ajax/ReservationAttributesPrintPage.php | 1 + Pages/Reservation/ReservationPage.php | 6 + Web/scripts/reservation-pdf.js | 490 ++++++++++++++++++ .../reservation_attributes_print.tpl | 14 +- tpl/Reservation/create.tpl | 6 +- tpl/Reservation/pdf.tpl | 424 +++++---------- tpl/Reservation/pdf_libraries.tpl | 4 +- tpl/Reservation/view.tpl | 5 +- 8 files changed, 635 insertions(+), 315 deletions(-) create mode 100644 Web/scripts/reservation-pdf.js diff --git a/Pages/Ajax/ReservationAttributesPrintPage.php b/Pages/Ajax/ReservationAttributesPrintPage.php index e7192c410..71caff8c3 100644 --- a/Pages/Ajax/ReservationAttributesPrintPage.php +++ b/Pages/Ajax/ReservationAttributesPrintPage.php @@ -51,6 +51,7 @@ public function PageLoad() $userSession = ServiceLocator::GetServer()->GetUserSession(); $this->presenter->PageLoad($userSession); $this->Set('ReadOnly', BooleanConverter::ConvertValue($this->GetIsReadOnly())); + $this->Set('CustomAttributeTypeDateTime', CustomAttributeTypes::DATETIME); $this->Display('Ajax/reservation/reservation_attributes_print.tpl'); } diff --git a/Pages/Reservation/ReservationPage.php b/Pages/Reservation/ReservationPage.php index 64281ce0e..8ca2505c0 100644 --- a/Pages/Reservation/ReservationPage.php +++ b/Pages/Reservation/ReservationPage.php @@ -219,6 +219,7 @@ public function PageLoad() 'weekly' => ['key' => 'Weekly', 'everyKey' => 'weeks'], 'monthly' => ['key' => 'Monthly', 'everyKey' => 'months'], 'yearly' => ['key' => 'Yearly', 'everyKey' => 'years'], + 'custom' => ['key' => 'Custom', 'everyKey' => ''], ] ); $this->Set( @@ -233,6 +234,11 @@ public function PageLoad() 6 => 'DaySaturdayAbbr', ] ); + $this->Set( + 'DayNamesFull', + Resources::GetInstance()->GetDays('full') + ); + $this->Set('CustomAttributeTypeCheckbox', CustomAttributeTypes::CHECKBOX); $this->Set('TitleRequired', $config->GetKey(ConfigKeys::RESERVATION_TITLE_REQUIRED, new BooleanConverter())); $this->Set('DescriptionRequired', $config->GetKey(ConfigKeys::RESERVATION_DESCRIPTION_REQUIRED, new BooleanConverter())); diff --git a/Web/scripts/reservation-pdf.js b/Web/scripts/reservation-pdf.js new file mode 100644 index 000000000..442c6c191 --- /dev/null +++ b/Web/scripts/reservation-pdf.js @@ -0,0 +1,490 @@ +(function () { + 'use strict'; + + function getConfig() { + return window.ReservationPdfConfig || null; + } + + function getCheckboxAttributeType(config) { + return Number(config.customAttributeTypeCheckbox); + } + + function getSelectedResourceIds() { + const resourceIds = []; + const primaryResource = document.getElementById('primaryResourceId'); + if (primaryResource) { + resourceIds.push(parseInt(primaryResource.value, 10)); + } + + document.querySelectorAll('#additionalResources .resourceId').forEach((element) => { + resourceIds.push(parseInt(element.value, 10)); + }); + + return resourceIds.filter((resourceId) => !isNaN(resourceId)); + } + + async function loadCustomAttributesData() { + const uid = document.getElementById('userId'); + const rn = document.getElementById('referenceNumber'); + const ro = document.getElementById('reservation-box'); + const params = new URLSearchParams({ + uid: uid ? uid.value : '', + rn: rn ? rn.value : '', + ro: String(ro ? ro.classList.contains('readonly') : false), + }); + + getSelectedResourceIds().forEach((resourceId) => params.append('rid[]', resourceId)); + + try { + const response = await fetch('ajax/reservation_attributes_print.php?' + params.toString(), { + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error('fetch failed'); + } + + return (await response.json()) || {}; + } catch (error) { + console.warn('Error loading attributes', error); + return {}; + } + } + + function loadImageDataUrl(url) { + return new Promise((resolve) => { + const image = new Image(); + + image.crossOrigin = 'anonymous'; + image.onload = function () { + try { + const canvas = document.createElement('canvas'); + canvas.width = image.naturalWidth || image.width; + canvas.height = image.naturalHeight || image.height; + canvas.getContext('2d').drawImage(image, 0, 0); + resolve(canvas.toDataURL('image/png')); + } catch { + resolve(null); + } + }; + image.onerror = function () { + resolve(null); + }; + image.src = url; + }); + } + + function pdfCell(text, style, extra) { + const cell = { + text: text == null ? '' : String(text), + }; + + if (style) { + cell.style = style; + } + + if (extra) { + Object.assign(cell, extra); + } + + return cell; + } + + function pdfSpanCell(text, style, span, extra) { + const cell = pdfCell(text, style, extra); + cell.colSpan = span; + return cell; + } + + function pdfEmptyCells(count) { + return Array.from({ length: count }, function () { + return {}; + }); + } + + function pdfTable(body, widths, headerRows) { + return { + margin: [0, 0, 0, 6], + layout: { + hLineWidth: function () { + return 0.4; + }, + vLineWidth: function () { + return 0.4; + }, + hLineColor: function () { + return '#c9d1dc'; + }, + vLineColor: function () { + return '#c9d1dc'; + }, + paddingLeft: function () { + return 6; + }, + paddingRight: function () { + return 6; + }, + paddingTop: function () { + return 4; + }, + paddingBottom: function () { + return 4; + }, + }, + table: { + headerRows: headerRows || 0, + widths: widths, + body: body, + }, + }; + } + + function buildHeader(config, logoDataUrl) { + const content = []; + + content.push({ + columns: [ + logoDataUrl ? { image: logoDataUrl, fit: [64, 40], width: 64, margin: [0, 0, 10, 0] } : { text: '' }, + { + width: '*', + stack: [ + { text: config.appTitle, style: 'documentTitle' }, + { text: config.reservationDetailsTitle, style: 'documentSubtitle' }, + ], + margin: [0, 2, 0, 0], + }, + ], + columnGap: 10, + margin: [0, 0, 0, 4], + }); + + content.push({ + canvas: [ + { + type: 'line', + x1: 0, + y1: 0, + x2: 530, + y2: 0, + lineWidth: 0.8, + lineColor: '#8b99ad', + }, + ], + margin: [0, 0, 0, 8], + }); + + content.push({ + text: config.referenceNumberLabel + ': ' + config.referenceNumber, + style: 'sectionHeading', + alignment: 'left', + }); + + if (config.showUserDetailsAndReservationDetails) { + content.push( + pdfTable( + [[pdfCell(config.userLabel, 'labelCell'), pdfCell(config.reservationUserName, 'valueCell')]], + [150, '*'] + ) + ); + } + + return content; + } + + function buildDetails(config, durationText) { + const isCustomRepeat = config.isRecurring && config.isCustomRepeat; + const detailsBody = [ + [ + pdfCell(config.beginDateLabel, 'labelCell'), + pdfCell(config.beginDateValue, 'valueCell'), + pdfCell(config.endDateLabel, 'labelCell'), + pdfCell(config.endDateValue, 'valueCell'), + ], + [pdfCell(config.reservationLengthLabel, 'labelCell'), pdfSpanCell(durationText, 'valueCell', 3)].concat( + pdfEmptyCells(2) + ), + [pdfCell(config.repeatPromptLabel, 'labelCell')] + .concat( + config.isRecurring && !isCustomRepeat + ? [ + pdfCell(config.repeatTypeLabel, 'valueCell'), + pdfCell(config.repeatInterval, 'valueCell'), + pdfCell(config.repeatEveryLabel, 'valueCell'), + ] + : [pdfSpanCell(config.repeatTypeLabel, 'valueCell', 3)] + ) + .concat(config.isRecurring && !isCustomRepeat ? [] : pdfEmptyCells(2)), + ]; + + if (config.isRecurring) { + if (isCustomRepeat && config.repeatCustomDates.length > 0) { + detailsBody.push( + [ + pdfCell(config.repeatOnLabel, 'labelCell'), + pdfSpanCell(config.repeatCustomDates.join(', '), 'valueCell', 3), + ].concat(pdfEmptyCells(2)) + ); + } + + if (!isCustomRepeat && config.repeatMonthlyTypeLabel) { + detailsBody.push( + [pdfCell(config.typeLabel, 'labelCell'), pdfSpanCell(config.repeatMonthlyTypeLabel, 'valueCell', 3)].concat( + pdfEmptyCells(2) + ) + ); + } + + if (!isCustomRepeat && config.repeatWeekdays.length > 0) { + detailsBody.push( + [ + pdfCell(config.daysLabel, 'labelCell'), + pdfSpanCell(config.repeatWeekdays.join(', '), 'valueCell', 3), + ].concat(pdfEmptyCells(2)) + ); + } + + if (!isCustomRepeat && config.repeatUntilDate) { + detailsBody.push( + [ + pdfCell(config.repeatUntilPromptLabel, 'labelCell'), + pdfSpanCell(config.repeatUntilDate, 'valueCell', 3), + ].concat(pdfEmptyCells(2)) + ); + } + } + + return pdfTable(detailsBody, [95, '*', 85, '*']); + } + + function buildAttributes(config, attributesData) { + if (!attributesData || Object.keys(attributesData).length === 0) { + return null; + } + + const attributesBody = [ + [pdfSpanCell(config.additionalAttributesLabel, 'tableHeader', 2, { alignment: 'left' }), {}], + ]; + const checkboxAttributeType = getCheckboxAttributeType(config); + + Object.values(attributesData).forEach((attribute) => { + const attributeType = Number(attribute[0]); + const attributeValue = String(attribute[2] ?? '').toLowerCase(); + const isChecked = attributeValue === '1' || attributeValue === 'true' || attributeValue === 'on'; + + if (attributeType === checkboxAttributeType) { + attributesBody.push([ + pdfCell(attribute[1], 'labelCell'), + pdfCell(isChecked ? 'X' : '', 'valueCell', { alignment: 'left' }), + ]); + return; + } + + attributesBody.push([pdfCell(attribute[1], 'labelCell'), pdfCell(attribute[2], 'valueCell')]); + }); + + return pdfTable(attributesBody, [220, '*'], 1); + } + + function buildReservationPdfDefinition(config, logoDataUrl, attributesData) { + const durationElement = document.getElementsByClassName('durationText').item(0); + const durationText = durationElement ? durationElement.innerText : ''; + const reminders = []; + const content = []; + + Array.prototype.push.apply(content, buildHeader(config, logoDataUrl)); + content.push(buildDetails(config, durationText)); + + const resourcesBody = [ + [ + pdfCell(config.resourcesHeaderLabel, 'tableHeader'), + pdfCell(config.requiresApprovalLabel, 'tableHeaderCenter'), + pdfCell(config.requiresCheckInNotificationLabel, 'tableHeaderCenter'), + pdfCell(config.releasedInLabel, 'tableHeaderCenter'), + ], + ]; + + config.resources.forEach(function (resource) { + resourcesBody.push([ + pdfCell(resource.name, 'valueCell'), + pdfCell(resource.requiresApproval ? 'X' : '', 'centerCell'), + pdfCell(resource.requiresCheckIn ? 'X' : '', 'centerCell'), + pdfCell(resource.releasedIn, 'centerCell'), + ]); + }); + + content.push(pdfTable(resourcesBody, ['*', 72, 88, 72], 1)); + + if (config.showAccessories && config.accessories.length > 0) { + const accessoriesBody = [ + [pdfCell(config.accessoriesHeaderLabel, 'tableHeader'), pdfCell(config.quantityLabel, 'tableHeaderCenter')], + ]; + + config.accessories.forEach(function (accessory) { + accessoriesBody.push([pdfCell(accessory.name, 'valueCell'), pdfCell(accessory.quantity, 'centerCell')]); + }); + + content.push(pdfTable(accessoriesBody, ['*', 75], 1)); + } + + if (config.showParticipants && config.participants.length > 0) { + const participantsBody = [ + [pdfCell(config.participantsHeaderLabel, 'tableHeader'), pdfCell(config.emailLabel, 'tableHeader')], + ]; + + config.participants.forEach(function (participant) { + participantsBody.push([pdfCell(participant.fullName, 'valueCell'), pdfCell(participant.email, 'valueCell')]); + }); + + content.push(pdfTable(participantsBody, ['*', '*'], 1)); + } + + if (config.showInvitees && config.invitees.length > 0) { + const inviteesBody = [ + [pdfCell(config.invitationListHeaderLabel, 'tableHeader'), pdfCell(config.emailLabel, 'tableHeader')], + ]; + + config.invitees.forEach(function (invitee) { + inviteesBody.push([pdfCell(invitee.fullName, 'valueCell'), pdfCell(invitee.email, 'valueCell')]); + }); + + content.push(pdfTable(inviteesBody, ['*', '*'], 1)); + } + + content.push( + pdfTable( + [ + [pdfCell(config.reservationTitleLabel, 'tableHeader')], + [pdfCell(config.reservationTitle, 'valueCell')], + [pdfCell(config.reservationDescriptionLabel, 'tableHeader')], + [pdfCell(config.reservationDescription, 'valueCell')], + ], + ['*'] + ) + ); + + const attributesTable = buildAttributes(config, attributesData); + if (attributesTable) { + content.push(attributesTable); + } + + if (config.remindersEnabled && config.reminders.length > 0) { + config.reminders.forEach(function (reminder) { + reminders.push([reminder.time, reminder.interval, reminder.text].join(' ')); + }); + + content.push( + pdfTable( + [[pdfCell(config.sendReminderLabel, 'labelCell'), pdfCell(reminders.join('\n'), 'valueCell')]], + [150, '*'] + ) + ); + } + + if (config.attachments.length > 0) { + const attachmentsBody = [ + [pdfCell(config.attachmentsLabel + ' (' + config.attachments.length + ')', 'tableHeader')], + ]; + + config.attachments.forEach(function (attachmentName) { + attachmentsBody.push([pdfCell(attachmentName, 'valueCell')]); + }); + + content.push(pdfTable(attachmentsBody, ['*'])); + } + + if (config.showTermsAcceptance) { + content.push(pdfTable([[pdfCell(config.acceptTermsLabel, 'labelCell'), pdfCell('X', 'centerCell')]], ['*', 40])); + } + + return { + pageSize: 'A4', + pageMargins: [24, 24, 24, 24], + content: content, + defaultStyle: { + fontSize: 9, + lineHeight: 1.15, + }, + styles: { + documentTitle: { + fontSize: 14, + bold: true, + color: '#1f2a3d', + }, + documentSubtitle: { + fontSize: 9, + color: '#51627a', + margin: [0, 2, 0, 0], + }, + sectionHeading: { + fontSize: 10, + bold: true, + color: '#2e3b4e', + margin: [0, 2, 0, 6], + }, + labelCell: { + bold: true, + fillColor: '#f1f4f8', + color: '#2e3b4e', + }, + valueCell: { + color: '#1f2a3d', + }, + tableHeader: { + bold: true, + fillColor: '#dfe6ef', + color: '#1f2a3d', + }, + tableHeaderCenter: { + bold: true, + alignment: 'center', + fillColor: '#dfe6ef', + color: '#1f2a3d', + fontSize: 8, + }, + centerCell: { + alignment: 'center', + color: '#1f2a3d', + }, + }, + info: { + title: 'Reservation ' + config.referenceNumber, + author: config.appTitle, + subject: config.reservationDetailsTitle, + creator: 'pdfmake', + }, + }; + } + + function init() { + const config = getConfig(); + const pdfMakeInstance = window.pdfMake; + + if (!config || !pdfMakeInstance) { + return; + } + + document.querySelectorAll('.btnPDF').forEach(function (btn) { + btn.addEventListener('click', async function (e) { + e.preventDefault(); + const previewWindow = window.open('', '_blank'); + const attributesData = await loadCustomAttributesData(); + const logoDataUrl = await loadImageDataUrl(config.logoUrl); + const documentDefinition = buildReservationPdfDefinition(config, logoDataUrl, attributesData); + + if (previewWindow) { + pdfMakeInstance.createPdf(documentDefinition).open({}, previewWindow); + return; + } + + pdfMakeInstance.createPdf(documentDefinition).open(); + }); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/tpl/Ajax/reservation/reservation_attributes_print.tpl b/tpl/Ajax/reservation/reservation_attributes_print.tpl index 71bec15c6..90aedf6aa 100644 --- a/tpl/Ajax/reservation/reservation_attributes_print.tpl +++ b/tpl/Ajax/reservation/reservation_attributes_print.tpl @@ -1,14 +1,14 @@ {ldelim} {if $Attributes|default:array()|count > 0} {foreach from=$Attributes item=attribute name=attributes} - "{$attribute->Id()}" : - [ "{$attribute->Type()}" , - "{$attribute->Label()|default:''|escape:'json'}" , - {if $attribute->Type() eq '5'} - "{if $attribute->Value() neq null}{formatdate date=$attribute->Value() key=embedded_datetime}{/if}" ] {if $smarty.foreach.attributes.last}{else},{/if} - {else} - "{$attribute->Value()|default:''|escape:'json'}" ] {if $smarty.foreach.attributes.last}{else},{/if} + {assign var='attributeValue' value=$attribute->Value()|default:''} + {if $attribute->Type() eq $CustomAttributeTypeDateTime && $attribute->Value() neq null} + {capture assign='attributeValue'}{formatdate date=$attribute->Value() key=general_datetime}{/capture} {/if} + {$attribute->Id()|cat:''|json_encode} : + [ {$attribute->Type()|json_encode} , + {$attribute->Label()|default:''|json_encode} , + {$attributeValue|json_encode} ] {if $smarty.foreach.attributes.last}{else},{/if} {/foreach} {/if} {rdelim} diff --git a/tpl/Reservation/create.tpl b/tpl/Reservation/create.tpl index 0217199d2..d46acd006 100644 --- a/tpl/Reservation/create.tpl +++ b/tpl/Reservation/create.tpl @@ -538,9 +538,11 @@ {jsfile src="force-numeric.js"} {jsfile src="reservation-reminder.js"} {jsfile src="ajax-helpers.js"} +{jsfile src="reservation-pdf.js"} {vendor_js src="jqtree/1.8.11/js/tree.jquery.js"} {include file="Reservation/pdf_libraries.tpl"} +{include file="Reservation/pdf.tpl"} diff --git a/tpl/Reservation/pdf_libraries.tpl b/tpl/Reservation/pdf_libraries.tpl index 00e343a86..4e3800511 100644 --- a/tpl/Reservation/pdf_libraries.tpl +++ b/tpl/Reservation/pdf_libraries.tpl @@ -1,2 +1,2 @@ -{vendor_js src="jspdf/2.3.0/js/jspdf.umd.min.js"} -{vendor_js src="jspdf-autotable/3.5.14/js/jspdf.plugin.autotable.min.js"} \ No newline at end of file +{vendor_js src="pdfmake/0.1.53/js/pdfmake.min.js"} +{vendor_js src="pdfmake/0.1.53/js/vfs_fonts.js"} diff --git a/tpl/Reservation/view.tpl b/tpl/Reservation/view.tpl index d7237d883..6a0ef328c 100644 --- a/tpl/Reservation/view.tpl +++ b/tpl/Reservation/view.tpl @@ -377,9 +377,11 @@ {jsfile src="force-numeric.js"} {jsfile src="reservation-reminder.js"} {jsfile src="ajax-helpers.js"} +{jsfile src="reservation-pdf.js"} {vendor_js src="jqtree/1.8.11/js/tree.jquery.js"} {include file="Reservation/pdf_libraries.tpl"} +{include file="Reservation/pdf.tpl"} {include file='globalfooter.tpl'} From 5d7466680fbda177a2c4f3ad3e5c38fc4e1704f5 Mon Sep 17 00:00:00 2001 From: "John L. Villalovos" Date: Sat, 11 Apr 2026 13:44:57 -0700 Subject: [PATCH 2/2] refactor(reservation): build PDF config in PHP Move the ReservationPdfConfig data structure out of the Smarty template and build it as a PHP array before encoding it to JSON. This keeps the template responsible only for emitting the already-structured config and avoids hand-building a large JavaScript object with mixed escaping rules. Delegate PDF date formatting to the existing Smarty date formatter so the generated config stays aligned with template date behavior. Keep the temporary SmartyVar bridge private and explicit while the reservation page continues to receive most view data through existing Smarty assignments. Use JSON_HEX_* encoding flags when emitting the config to keep the inline script safe for values containing quotes, ampersands, or script-like text. Assisted-by: OpenAI Codex (GPT-5) --- Pages/Reservation/ReservationPage.php | 214 +++++++++++++++++++- tests/Pages/ReservationPageTest.php | 268 ++++++++++++++++++++++++++ tpl/Reservation/pdf.tpl | 124 +----------- 3 files changed, 478 insertions(+), 128 deletions(-) create mode 100644 tests/Pages/ReservationPageTest.php diff --git a/Pages/Reservation/ReservationPage.php b/Pages/Reservation/ReservationPage.php index 8ca2505c0..f98977682 100644 --- a/Pages/Reservation/ReservationPage.php +++ b/Pages/Reservation/ReservationPage.php @@ -234,11 +234,6 @@ public function PageLoad() 6 => 'DaySaturdayAbbr', ] ); - $this->Set( - 'DayNamesFull', - Resources::GetInstance()->GetDays('full') - ); - $this->Set('CustomAttributeTypeCheckbox', CustomAttributeTypes::CHECKBOX); $this->Set('TitleRequired', $config->GetKey(ConfigKeys::RESERVATION_TITLE_REQUIRED, new BooleanConverter())); $this->Set('DescriptionRequired', $config->GetKey(ConfigKeys::RESERVATION_DESCRIPTION_REQUIRED, new BooleanConverter())); @@ -250,9 +245,218 @@ public function PageLoad() return; } + $this->SetReservationPdfConfig(); $this->Display($this->GetTemplateName()); } + private function SetReservationPdfConfig(): void + { + $repeatOptions = $this->SmartyVar('RepeatOptions', []); + $repeatType = $this->SmartyVar('RepeatType', RepeatType::None); + $repeatMonthlyType = $this->SmartyVar('RepeatMonthlyType', ''); + $showReservationDetails = (bool)$this->SmartyVar('ShowReservationDetails', false); + $showParticipation = (bool)$this->SmartyVar('ShowParticipation', false); + $accessories = $this->SmartyVar('Accessories', []); + $participants = $this->SmartyVar('Participants', []); + $invitees = $this->SmartyVar('Invitees', []); + $attachments = $this->SmartyVar('Attachments', []); + $repeatWeekdays = $this->SmartyVar('RepeatWeekdays', []); + $customRepeatDates = $this->SmartyVar('CustomRepeatDates', []); + $dayNames = Resources::GetInstance()->GetDays('full'); + $reminders = []; + + if ($this->SmartyVar('ReminderTimeStart', '') !== '') { + $reminders[] = [ + 'time' => (string)$this->SmartyVar('ReminderTimeStart'), + 'interval' => $this->Translate($this->SmartyVar('ReminderIntervalStart', '')), + 'text' => $this->Translate('ReminderBeforeStart'), + ]; + } + + if ($this->SmartyVar('ReminderTimeEnd', '') !== '') { + $reminders[] = [ + 'time' => (string)$this->SmartyVar('ReminderTimeEnd'), + 'interval' => $this->Translate($this->SmartyVar('ReminderIntervalEnd', '')), + 'text' => $this->Translate('ReminderBeforeEnd'), + ]; + } + + $config = [ + 'appTitle' => $this->SmartyVar('AppTitle', ''), + 'reservationDetailsTitle' => $this->SmartyCapitalize($this->Translate('ReservationDetails')), + 'referenceNumberLabel' => $this->Translate('ReferenceNumber'), + 'referenceNumber' => $this->SmartyVar('ReferenceNumber', ''), + 'userLabel' => $this->Translate('User'), + 'reservationUserName' => $this->DecodeHtml($this->SmartyVar('ReservationUserName', '')), + 'showUserDetailsAndReservationDetails' => (bool)$this->SmartyVar('ShowUserDetails', false) && $showReservationDetails, + + 'beginDateLabel' => $this->Translate('BeginDate'), + 'beginDateValue' => $this->FormatPdfDate($this->SmartyVar('StartDate'), 'dashboard'), + 'endDateLabel' => $this->Translate('EndDate'), + 'endDateValue' => $this->FormatPdfDate($this->SmartyVar('EndDate'), 'dashboard'), + 'reservationLengthLabel' => $this->Translate('ReservationLength'), + 'repeatPromptLabel' => $this->Translate('RepeatPrompt'), + 'isRecurring' => (bool)$this->SmartyVar('IsRecurring', false), + 'isCustomRepeat' => $repeatType === RepeatType::Custom, + 'repeatTypeLabel' => $this->Translate($repeatOptions[$repeatType]['key'] ?? ''), + 'repeatInterval' => (string)$this->SmartyVar('RepeatInterval', ''), + 'repeatEveryLabel' => $repeatType === RepeatType::Custom ? '' : $this->Translate($repeatOptions[$repeatType]['everyKey'] ?? ''), + 'repeatOnLabel' => $this->Translate('RepeatOn'), + 'typeLabel' => $this->Translate('Type'), + 'repeatMonthlyTypeLabel' => $repeatMonthlyType === '' ? '' : $this->Translate($repeatMonthlyType === RepeatMonthlyType::DayOfMonth ? 'repeatDayOfMonth' : 'repeatDayOfWeek'), + 'daysLabel' => $this->SmartyCapitalize($this->Translate('RepeatDaysPrompt')), + 'repeatWeekdays' => array_map( + fn ($day) => $dayNames[$day] ?? '', + is_array($repeatWeekdays) ? $repeatWeekdays : [] + ), + 'repeatCustomDates' => array_map( + fn ($date) => $this->FormatPdfDate($date, 'schedule_daily', $this->SmartyVar('Timezone')), + is_array($customRepeatDates) ? $customRepeatDates : [] + ), + 'repeatUntilPromptLabel' => $this->Translate('RepeatUntilPrompt'), + 'repeatUntilDate' => $this->FormatPdfDate($this->SmartyVar('RepeatTerminationDate'), 'dashboard'), + + 'additionalAttributesLabel' => $this->Translate('AdditionalAttributes'), + 'customAttributeTypeCheckbox' => CustomAttributeTypes::CHECKBOX, + + 'resourcesHeaderLabel' => $this->Translate('Resources'), + 'requiresApprovalLabel' => $this->Translate('RequiresApproval'), + 'requiresCheckInNotificationLabel' => $this->Translate('RequiresCheckInNotification'), + 'releasedInLabel' => sprintf('%s (%s)', $this->Translate('ReleasedIn'), $this->Translate('minutes')), + 'resources' => $this->PdfResources(), + + 'showAccessories' => $showReservationDetails && count($accessories) > 0, + 'accessoriesHeaderLabel' => $this->Translate('Accessories'), + 'quantityLabel' => $this->Translate('Quantity'), + 'accessories' => array_map( + fn ($accessory) => [ + 'name' => (string)$accessory->Name, + 'quantity' => (string)$accessory->QuantityReserved, + ], + $accessories + ), + + 'showParticipants' => $showReservationDetails && $showParticipation && count($participants) > 0, + 'participantsHeaderLabel' => $this->Translate('Participants'), + 'emailLabel' => $this->Translate('Email'), + 'participants' => $this->PdfUsers($participants), + + 'showInvitees' => $showReservationDetails && $showParticipation && count($invitees) > 0, + 'invitationListHeaderLabel' => $this->Translate('InvitationList'), + 'invitees' => $this->PdfUsers($invitees), + + 'reservationTitleLabel' => $this->Translate('ReservationTitle'), + 'reservationTitle' => $this->DecodeHtml($this->SmartyVar('ReservationTitle', '')), + 'reservationDescriptionLabel' => $this->Translate('ReservationDescription'), + 'reservationDescription' => $this->DecodeHtml($this->SmartyVar('Description', '')), + + 'remindersEnabled' => (bool)$this->SmartyVar('RemindersEnabled', false), + 'sendReminderLabel' => $this->Translate('SendReminder'), + 'reminders' => $reminders, + + 'attachmentsLabel' => $this->Translate('Attachments'), + 'attachments' => array_map( + fn ($attachment) => $attachment->FileName(), + $attachments + ), + + 'showTermsAcceptance' => $this->SmartyVar('Terms') !== null && (bool)$this->SmartyVar('TermsAccepted', false), + 'acceptTermsLabel' => sprintf('%s %s', $this->Translate('IAccept'), $this->Translate('TheTermsOfService')), + + 'logoUrl' => sprintf('%s/img/%s', $this->SmartyVar('ScriptUrl', ''), $this->SmartyVar('LogoUrl', '')), + ]; + + $this->Set('ReservationPdfConfigJson', json_encode( + $config, + // Keep the inline script assignment safe and fail fast if encoding ever breaks. + JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE + )); + } + + private function SmartyVar(string $name, mixed $default = null): mixed + { + // Bridge existing presenter-to-template assignments while building the PDF config in PHP. + $value = $this->smarty->getTemplateVars($name); + return $value === null ? $default : $value; + } + + private function Translate(string $key): string + { + if ($key === '') { + return ''; + } + + return Resources::GetInstance()->GetString($key, ''); + } + + private function SmartyCapitalize(string $value): string + { + $capitalize = $this->smarty->getModifierCallback('capitalize'); + + return $capitalize === null ? $value : (string)$capitalize($value); + } + + private function DecodeHtml(mixed $value): string + { + return html_entity_decode((string)$value, ENT_QUOTES | ENT_HTML5); + } + + private function FormatPdfDate(mixed $date, string $key, ?string $timezone = null): string + { + $params = [ + 'date' => $date, + 'key' => $key, + ]; + + if ($timezone !== null) { + $params['timezone'] = $timezone; + } + + return $this->smarty->FormatDate($params, $this->smarty); + } + + private function PdfResources(): array + { + $resources = []; + $primaryResource = $this->SmartyVar('Resource'); + + if ($primaryResource !== null) { + $resources[] = $this->PdfResource($primaryResource, ''); + } + + $additionalResourceIds = $this->SmartyVar('AdditionalResourceIds', []); + $availableResources = $this->SmartyVar('AvailableResources', []); + + foreach ($availableResources as $resource) { + if (in_array($resource->Id, is_array($additionalResourceIds) ? $additionalResourceIds : [])) { + $resources[] = $this->PdfResource($resource, ' - '); + } + } + + return $resources; + } + + private function PdfResource(IBookableResource $resource, string $emptyReleasedIn): array + { + return [ + 'name' => $resource->GetName(), + 'requiresApproval' => $resource->GetRequiresApproval(), + 'requiresCheckIn' => $resource->IsCheckInEnabled(), + 'releasedIn' => $resource->IsAutoReleased() ? (string)$resource->GetAutoReleaseMinutes() : $emptyReleasedIn, + ]; + } + + private function PdfUsers(array $users): array + { + return array_map( + fn ($user) => [ + 'fullName' => $this->DecodeHtml($user->FullName), + 'email' => (string)$user->Email, + ], + $users + ); + } + public function BindPeriods($startPeriods, $endPeriods, $lockPeriods) { $this->Set('StartPeriods', $startPeriods); diff --git a/tests/Pages/ReservationPageTest.php b/tests/Pages/ReservationPageTest.php new file mode 100644 index 000000000..8a0adf740 --- /dev/null +++ b/tests/Pages/ReservationPageTest.php @@ -0,0 +1,268 @@ +Set('AppTitle', 'Libre & "PDF"'); + $page->Set('ReferenceNumber', 'rn-1'); + // Accented strings exercise Unicode behavior for HTML decoding and JSON encoding. + $page->Set('ReservationUserName', 'Renée O'Connor'); + $page->Set('ReservationTitle', 'Title </script> x' . "\u{2028}" . 'y' . "\u{2029}" . 'z'); + $page->Set('Description', 'Description & details'); + $page->Set('Resource', new ReservationPagePdfConfigResource()); + $page->Set('Terms', new stdClass()); + $page->Set('TermsAccepted', true); + $page->Set('RepeatOptions', [ + RepeatType::None => ['key' => 'DoesNotRepeat', 'everyKey' => ''], + ]); + $page->Set('RepeatType', RepeatType::None); + $page->Set('StartDate', Date::Create(2026, 4, 8, 14, 30, 0, 'America/Chicago')); + $page->Set('EndDate', Date::Create(2026, 4, 8, 15, 30, 0, 'America/Chicago')); + + $json = $page->BuildReservationPdfConfigJson(); + $config = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + + $this->assertStringNotContainsString('', $json); + $this->assertStringContainsString('\u003C/script\u003E', $json); + $this->assertStringContainsString('\u2028', $json); + $this->assertStringContainsString('\u2029', $json); + $this->assertStringNotContainsString("\u{2028}", $json); + $this->assertStringNotContainsString("\u{2029}", $json); + + $this->assertSame('Renée O\'Connor', $config['reservationUserName']); + $this->assertSame('Title x' . "\u{2028}" . 'y' . "\u{2029}" . 'z', $config['reservationTitle']); + $this->assertSame('Description & details', $config['reservationDescription']); + $this->assertTrue($config['showTermsAcceptance']); + $this->assertSame(CustomAttributeTypes::CHECKBOX, $config['customAttributeTypeCheckbox']); + } + + public function testReservationPdfConfigKeepsSmartyFormattingBehavior(): void + { + $page = new TestableReservationPage(); + $page->Set('Resource', new ReservationPagePdfConfigResource()); + $page->Set('RepeatOptions', [ + RepeatType::Weekly => ['key' => 'Weekly', 'everyKey' => 'weeks'], + ]); + $page->Set('RepeatType', RepeatType::Weekly); + $page->Set('IsRecurring', true); + $page->Set('RepeatWeekdays', [3]); + $page->Set('StartDate', Date::Create(2026, 4, 8, 14, 30, 0, 'America/Chicago')); + $page->Set('EndDate', Date::Create(2026, 4, 8, 15, 30, 0, 'America/Chicago')); + $page->Set('RepeatTerminationDate', Date::Create(2026, 4, 29, 15, 30, 0, 'America/Chicago')); + + $config = json_decode($page->BuildReservationPdfConfigJson(), true, 512, JSON_THROW_ON_ERROR); + + $this->assertSame('Réservation Spéciale', $config['reservationDetailsTitle']); + $this->assertSame('Días Repetidos', $config['daysLabel']); + $this->assertSame('Miércoles 2026-04-08 14:30', $config['beginDateValue']); + $this->assertSame(['Miércoles'], $config['repeatWeekdays']); + $this->assertSame('semanal', $config['repeatTypeLabel']); + $this->assertSame('semanas', $config['repeatEveryLabel']); + } + + public function testReservationPdfConfigIncludesExpectedResourceData(): void + { + $page = new TestableReservationPage(); + $page->Set('Resource', new ReservationPagePdfConfigResource('Main Room', true, true, true, 15)); + $page->Set('AdditionalResourceIds', [2]); + $page->Set('AvailableResources', [ + new ReservationPagePdfConfigResource('Ignored Room', false, false, false, null, 1), + new ReservationPagePdfConfigResource('Overflow Room', false, true, false, null, 2), + ]); + $page->Set('RepeatOptions', [ + RepeatType::None => ['key' => 'DoesNotRepeat', 'everyKey' => ''], + ]); + $page->Set('RepeatType', RepeatType::None); + + $config = json_decode($page->BuildReservationPdfConfigJson(), true, 512, JSON_THROW_ON_ERROR); + + $this->assertCount(2, $config['resources']); + $this->assertSame([ + 'name' => 'Main Room', + 'requiresApproval' => true, + 'requiresCheckIn' => true, + 'releasedIn' => '15', + ], $config['resources'][0]); + $this->assertSame([ + 'name' => 'Overflow Room', + 'requiresApproval' => false, + 'requiresCheckIn' => true, + 'releasedIn' => ' - ', + ], $config['resources'][1]); + } +} + +class TestableReservationPage extends ReservationPage +{ + public function BuildReservationPdfConfigJson(): string + { + $method = new ReflectionMethod(ReservationPage::class, 'SetReservationPdfConfig'); + $method->invoke($this); + + return (string)$this->smarty->getTemplateVars('ReservationPdfConfigJson'); + } + + protected function GetPresenter() + { + return new class () implements IReservationPresenter { + public function PageLoad() + { + } + }; + } + + protected function GetTemplateName() + { + return 'Reservation/pdf.tpl'; + } + + protected function GetReservationAction() + { + return ''; + } + + public function SetTermsAccepted($accepted) + { + $this->Set('TermsAccepted', $accepted); + } +} + +class ReservationPagePdfConfigResources extends FakeResources +{ + // Accented strings exercise Unicode behavior for Smarty capitalization and localized day names. + /** + * @var array + */ + private array $strings = [ + 'DoesNotRepeat' => 'no se repite', + 'ReservationDetails' => 'réservation spéciale', + 'RepeatDaysPrompt' => 'días repetidos', + 'Weekly' => 'semanal', + 'weeks' => 'semanas', + ]; + + public function GetString($key, $args = []): string + { + return $this->strings[$key] ?? parent::GetString($key, $args); + } + + public function GetDateFormat($key) + { + if ($key === 'dashboard') { + return 'l Y-m-d H:i'; + } + + if ($key === 'schedule_daily') { + return 'l Y-m-d'; + } + + return parent::GetDateFormat($key); + } + + public function GetDays($key) + { + return ['Domingo', 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado']; + } +} + +class ReservationPagePdfConfigResource implements IBookableResource +{ + public function __construct( + private string $name = 'Conference Room', + private bool $requiresApproval = false, + private bool $checkInEnabled = false, + private bool $autoReleased = false, + private ?int $autoReleaseMinutes = null, + public int $Id = 1 + ) { + } + + public function GetId() + { + return $this->Id; + } + + public function GetName() + { + return $this->name; + } + + public function GetResourceId() + { + return $this->Id; + } + + public function GetAdminGroupId() + { + return 0; + } + + public function GetScheduleId() + { + return 0; + } + + public function GetScheduleAdminGroupId() + { + return 0; + } + + public function GetStatusId() + { + return 0; + } + + public function GetMinimumLength() + { + return TimeInterval::None(); + } + + public function GetRequiresApproval() + { + return $this->requiresApproval; + } + + public function IsCheckInEnabled() + { + return $this->checkInEnabled; + } + + public function IsAutoReleased() + { + return $this->autoReleased; + } + + public function GetAutoReleaseMinutes() + { + return $this->autoReleaseMinutes; + } + + public function GetResourceTypeId() + { + return 0; + } + + public function GetColor() + { + return null; + } + + public function GetTextColor() + { + return null; + } +} diff --git a/tpl/Reservation/pdf.tpl b/tpl/Reservation/pdf.tpl index 49c49aa11..a65e6aaeb 100644 --- a/tpl/Reservation/pdf.tpl +++ b/tpl/Reservation/pdf.tpl @@ -1,125 +1,3 @@