包含:登录失败锁定、90天密码有效期、30分钟会话超时、 强制改密、登录审计日志、屏幕水印、企业背景图、 备案信息固定底部、favicon、JS空集合保护、 会话过期体验优化(403 JSON)、display_errors 关闭、 固定 key 根治 Integrity check failed 等全部改动 注意:config.inc.php/.htaccess/.user.ini 含敏感信息, 通过 .gitignore 排除,勿推送到公开仓库。
2001 lines
59 KiB
PHP
2001 lines
59 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
namespace MRBS;
|
|
|
|
use DateInterval;
|
|
use MRBS\Form\Element;
|
|
use MRBS\Form\ElementFieldset;
|
|
use MRBS\Form\ElementInputHidden;
|
|
use MRBS\Form\Field;
|
|
use MRBS\Form\FieldInputDatalist;
|
|
use MRBS\Form\FieldInputDate;
|
|
use MRBS\Form\FieldInputRadioGroup;
|
|
use MRBS\Form\FieldInputSubmit;
|
|
use MRBS\Form\FieldInputText;
|
|
use MRBS\Form\FieldSelect;
|
|
use MRBS\Form\Form;
|
|
use MRBS\ICalendar\Calendar;
|
|
use MRBS\Utf8\Utf8String;
|
|
|
|
|
|
require "defaultincludes.inc";
|
|
|
|
|
|
function get_csv_charset() : string
|
|
{
|
|
global $csv_charset;
|
|
|
|
return (empty($csv_charset)) ? Language::MRBS_CHARSET : $csv_charset;
|
|
}
|
|
|
|
|
|
function get_field_from_date(array $data) : FieldInputDate
|
|
{
|
|
$label = get_vocab('report_start');
|
|
|
|
$field = new FieldInputDate();
|
|
$field->setAttribute('id', 'div_report_start')
|
|
->setLabel($label)
|
|
->setControlAttributes(array(
|
|
'name' => 'from_date',
|
|
'value' => $data['from_date'],
|
|
'aria-label' => $label,
|
|
'required' => true)
|
|
);
|
|
|
|
return $field;
|
|
}
|
|
|
|
|
|
function get_field_to_date(array $data) : FieldInputDate
|
|
{
|
|
$label = get_vocab('report_end');
|
|
|
|
$field = new FieldInputDate();
|
|
$field->setAttribute('id', 'div_report_end')
|
|
->setLabel($label)
|
|
->setControlAttributes(array(
|
|
'name' => 'to_date',
|
|
'value' => $data['to_date'],
|
|
'aria-label' => $label,
|
|
'required' => true)
|
|
);
|
|
|
|
return $field;
|
|
}
|
|
|
|
|
|
function get_field_areamatch(array $data) : FieldInputDatalist
|
|
{
|
|
$label = get_vocab('match_area');
|
|
|
|
$field = new FieldInputDatalist();
|
|
$options = get_area_names(true);
|
|
$field->setAttribute('id', 'div_areamatch')
|
|
->setLabel($label)
|
|
->setControlAttributes(array(
|
|
'name' => 'areamatch',
|
|
'value' => $data['areamatch'],
|
|
'aria-label' => $label))
|
|
->addDatalistOptions($options, false);
|
|
|
|
return $field;
|
|
}
|
|
|
|
|
|
function get_field_roommatch(array $data) : FieldInputDatalist
|
|
{
|
|
$label = get_vocab('match_room');
|
|
|
|
$field = new FieldInputDatalist();
|
|
|
|
// (We need DISTINCT because it's possible to have two rooms of the same name
|
|
// in different areas)
|
|
$sql = "SELECT DISTINCT room_name FROM " . _tbl('room');
|
|
|
|
// Don't show the invisible rooms
|
|
$invisible_room_ids = get_invisible_room_ids();
|
|
if (count($invisible_room_ids) > 0)
|
|
{
|
|
$sql .= " WHERE id NOT IN (" . implode(',', $invisible_room_ids) . ")";
|
|
}
|
|
|
|
$sql .= " ORDER BY room_name";
|
|
|
|
$options = db()->query_array($sql);
|
|
|
|
$field->setAttribute('id', 'div_roommatch')
|
|
->setLabel($label)
|
|
->setControlAttributes(array(
|
|
'name' => 'roommatch',
|
|
'value' => $data['roommatch'],
|
|
'aria-label' => $label))
|
|
->addDatalistOptions($options, false);
|
|
|
|
return $field;
|
|
}
|
|
|
|
|
|
function get_field_typematch(array $data) : ?FieldSelect
|
|
{
|
|
$options = get_type_options(true);
|
|
|
|
if (count($options) < 2)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
$field = new FieldSelect();
|
|
$field->setAttributes(array('id' => 'div_typematch',
|
|
'class' => 'multiline'))
|
|
->setLabel(get_vocab('match_type'))
|
|
->setLabelAttribute('title', get_vocab('ctrl_click_type'))
|
|
->setControlAttributes(array('id' => 'typematch',
|
|
'name' => 'typematch[]',
|
|
'multiple' => true,
|
|
'size' => '5'))
|
|
->addSelectOptions($options, $data['typematch'], true);
|
|
|
|
return $field;
|
|
}
|
|
|
|
|
|
function get_field_match_private(array $data) : ?Element
|
|
{
|
|
global $mrbs_user, $private_somewhere;
|
|
|
|
// Only show this part of the form if there are areas that allow private bookings
|
|
if (!$private_somewhere)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// If they're not logged in then there's no point in showing this part of the form because
|
|
// they'll only be able to see public bookings anyway (and we don't want to alert them to
|
|
// the existence of private bookings)
|
|
if (!isset($mrbs_user) || ($mrbs_user->level == 0))
|
|
{
|
|
$field = new ElementInputHidden();
|
|
$field->setAttributes(array('name' => 'match_private',
|
|
'value' => BOOLEAN_MATCH_FALSE));
|
|
}
|
|
else
|
|
{
|
|
$options = array(BOOLEAN_MATCH_BOTH => get_vocab('both'),
|
|
BOOLEAN_MATCH_FALSE => get_vocab('default_public'),
|
|
BOOLEAN_MATCH_TRUE => get_vocab('default_private'));
|
|
$field = new FieldInputRadioGroup();
|
|
$field->setAttribute('id', 'div_privacystatus')
|
|
->setLabel(get_vocab('privacy_status'))
|
|
->addRadioOptions($options, 'match_private', $data['match_private'], true);
|
|
}
|
|
|
|
return $field;
|
|
}
|
|
|
|
|
|
function get_field_match_confirmed(array $data) : ?FieldInputRadioGroup
|
|
{
|
|
global $confirmation_somewhere;
|
|
|
|
// Only show this part of the form if there are areas that allow tentative bookings
|
|
if (!$confirmation_somewhere)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
$options = array(BOOLEAN_MATCH_BOTH => get_vocab('both'),
|
|
BOOLEAN_MATCH_TRUE => get_vocab('confirmed'),
|
|
BOOLEAN_MATCH_FALSE => get_vocab('tentative'));
|
|
$field = new FieldInputRadioGroup();
|
|
$field->setAttribute('id', 'div_confirmationstatus')
|
|
->setLabel(get_vocab('confirmation_status'))
|
|
->addRadioOptions($options, 'match_confirmed', $data['match_confirmed'], true);
|
|
|
|
return $field;
|
|
}
|
|
|
|
|
|
function get_field_match_approved(array $data) : ?FieldInputRadioGroup
|
|
{
|
|
global $approval_somewhere;
|
|
|
|
// Only show this part of the form if there are areas that require approval
|
|
if (!$approval_somewhere)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
$options = array(BOOLEAN_MATCH_BOTH => get_vocab('both'),
|
|
BOOLEAN_MATCH_TRUE => get_vocab('approved'),
|
|
BOOLEAN_MATCH_FALSE => get_vocab('awaiting_approval'));
|
|
$field = new FieldInputRadioGroup();
|
|
$field->setAttribute('id', 'div_approvalstatus')
|
|
->setLabel(get_vocab('approval_status'))
|
|
->addRadioOptions($options, 'match_approved', $data['match_approved'], true);
|
|
|
|
return $field;
|
|
}
|
|
|
|
|
|
function get_field_custom(array $data, string $key) : Field
|
|
{
|
|
$var = "match_$key";
|
|
global $$var;
|
|
|
|
global $field_natures, $field_lengths;
|
|
|
|
$name = $var;
|
|
$label = get_loc_field_name(_tbl('entry'), $key);
|
|
|
|
// Output a radio group if it's a boolean or integer <= 2 bytes (which we will
|
|
// assume are intended to be booleans)
|
|
if (($field_natures[$key] == 'boolean') ||
|
|
(($field_natures[$key] == 'integer') && isset($field_lengths[$key]) && ($field_lengths[$key] <= 2)) )
|
|
{
|
|
$options = array(BOOLEAN_MATCH_BOTH => get_vocab('both'),
|
|
BOOLEAN_MATCH_TRUE => get_vocab('with'),
|
|
BOOLEAN_MATCH_FALSE => get_vocab('without'));
|
|
$value = (isset($$var)) ? $$var : BOOLEAN_MATCH_BOTH;
|
|
$field = new FieldInputRadioGroup();
|
|
$field->setAttribute('id', "div_$name")
|
|
->setLabel($label)
|
|
->addRadioOptions($options, $name, $value, true);
|
|
}
|
|
else
|
|
{
|
|
$params = array('label' => $label,
|
|
'name' => $name,
|
|
'value' => (isset($$var)) ? $$var : null,
|
|
'field' => "entry.$key");
|
|
$field = get_field_report_input($params);
|
|
}
|
|
|
|
return $field;
|
|
}
|
|
|
|
|
|
// Generates a text input field of some kind. If $select_options or $datalist_options
|
|
// is set then it will be a datalist, otherwise it will be a simple input field
|
|
// $params an array indexed by 'label', 'name', 'value' and 'field'
|
|
function get_field_report_input(array $params) : Field
|
|
{
|
|
global $select_options, $datalist_options;
|
|
|
|
// If $select_options is defined we want to force a <datalist> and not a
|
|
// <select>. That's because if we have options such as
|
|
// ('tea', 'white coffee', 'black coffee') we want the user to be able to type
|
|
// 'coffee' which will match both 'white coffee' and 'black coffee'.
|
|
if (isset($params['field']))
|
|
{
|
|
if (!empty($select_options[$params['field']]))
|
|
{
|
|
$options = $select_options[$params['field']];
|
|
}
|
|
elseif (!empty($datalist_options[$params['field']]))
|
|
{
|
|
$options = $datalist_options[$params['field']];
|
|
}
|
|
}
|
|
|
|
if (isset($options))
|
|
{
|
|
// Remove any element with an empty key. This will just be associated with a
|
|
// required select element and the value will be meaningless for a search.
|
|
unset($options['']);
|
|
// Remove any elements with an empty value. These will be meaningless for a search.
|
|
$options = array_diff($options, array(''));
|
|
$field = new FieldInputDatalist();
|
|
// We force the values to be used and not the keys. We will convert
|
|
// back to values when we construct the SQL query.
|
|
$field->addDatalistOptions($options, false);
|
|
}
|
|
else
|
|
{
|
|
$field = new FieldInputText();
|
|
}
|
|
|
|
$field->setAttribute('id', 'div_' . $params['name'])
|
|
->setLabel($params['label'])
|
|
->setControlAttributes(array('name' => $params['name'],
|
|
'value' => $params['value']));
|
|
|
|
return $field;
|
|
}
|
|
|
|
|
|
function get_fieldset_search_criteria(array $data) : ElementFieldset
|
|
{
|
|
global $report_search_field_order;
|
|
|
|
$fieldset = new ElementFieldset();
|
|
$fieldset->addLegend(get_vocab('search_criteria'));
|
|
|
|
foreach ($report_search_field_order as $key)
|
|
{
|
|
switch ($key)
|
|
{
|
|
case 'report_start':
|
|
$fieldset->addElement(get_field_from_date($data));
|
|
break;
|
|
|
|
case 'report_end':
|
|
$fieldset->addElement(get_field_to_date($data));
|
|
break;
|
|
|
|
case 'areamatch':
|
|
$fieldset->addElement(get_field_areamatch($data));
|
|
break;
|
|
|
|
case 'roommatch':
|
|
$fieldset->addElement(get_field_roommatch($data));
|
|
break;
|
|
|
|
case 'typematch':
|
|
$fieldset->addElement(get_field_typematch($data));
|
|
break;
|
|
|
|
case 'namematch':
|
|
$params = array('label' => get_vocab('match_entry'),
|
|
'name' => 'namematch',
|
|
'value' => $data['namematch'],
|
|
'field' => 'entry.name');
|
|
$fieldset->addElement(get_field_report_input($params));
|
|
break;
|
|
|
|
case 'descrmatch':
|
|
$params = array('label' => get_vocab('match_descr'),
|
|
'name' => 'descrmatch',
|
|
'value' => $data['descrmatch'],
|
|
'field' => 'entry.description');
|
|
$fieldset->addElement(get_field_report_input($params));
|
|
break;
|
|
|
|
case 'creatormatch':
|
|
$params = array('label' => get_vocab('createdby'),
|
|
'name' => 'creatormatch',
|
|
'value' => $data['creatormatch'],
|
|
'field' => 'entry.create_by');
|
|
$fieldset->addElement(get_field_report_input($params));
|
|
break;
|
|
|
|
case 'match_private':
|
|
$fieldset->addElement(get_field_match_private($data));
|
|
break;
|
|
|
|
case 'match_confirmed':
|
|
$fieldset->addElement(get_field_match_confirmed($data));
|
|
break;
|
|
|
|
case 'match_approved':
|
|
$fieldset->addElement(get_field_match_approved($data));
|
|
break;
|
|
|
|
default:
|
|
// Must be a custom field
|
|
$fieldset->addElement(get_field_custom($data, $key));
|
|
break;
|
|
} // switch
|
|
} // foreach
|
|
|
|
return $fieldset;
|
|
}
|
|
|
|
|
|
function get_field_output(array $data) : FieldInputRadioGroup
|
|
{
|
|
$options = array(REPORT => get_vocab('report'),
|
|
SUMMARY => get_vocab('summary'));
|
|
$field = new FieldInputRadioGroup();
|
|
$field->setLabel(get_vocab('output'))
|
|
->addRadioOptions($options, 'output', $data['output'], true);
|
|
return $field;
|
|
}
|
|
|
|
|
|
function get_field_output_format(array $data) : FieldInputRadioGroup
|
|
{
|
|
global $times_somewhere;
|
|
|
|
// Note that it's useful to have the server-side CSV option here even though we've also got the
|
|
// DataTables client-side CSV button, because report.php can be run as a cron job to produce regular
|
|
// reports in CSV format.
|
|
$options = array(OUTPUT_HTML => get_vocab('html'),
|
|
OUTPUT_CSV => get_vocab('csv'));
|
|
// The iCal output button
|
|
if ($times_somewhere) // We can't do iCalendars for periods yet
|
|
{
|
|
$options[OUTPUT_ICAL] = get_vocab('ical');
|
|
}
|
|
|
|
$field = new FieldInputRadioGroup();
|
|
$field->setLabel(get_vocab('format'))
|
|
->addRadioOptions($options, 'output_format', $data['output_format'], true);
|
|
return $field;
|
|
}
|
|
|
|
|
|
function get_field_sortby(array $data) : FieldInputRadioGroup
|
|
{
|
|
$options = array('r' => get_vocab('sort_room'),
|
|
's' => get_vocab('sort_rep_time'));
|
|
$field = new FieldInputRadioGroup();
|
|
$field->setLabel(get_vocab('sort_rep'))
|
|
->addRadioOptions($options, 'sortby', $data['sortby'], true);
|
|
return $field;
|
|
}
|
|
|
|
|
|
function get_field_sumby(array $data) : FieldInputRadioGroup
|
|
{
|
|
$options = array('d' => get_vocab('sum_by_descrip'),
|
|
'c' => get_vocab('sum_by_creator'),
|
|
't' => get_vocab('sum_by_type'));
|
|
$field = new FieldInputRadioGroup();
|
|
$field->setLabel(get_vocab('summarize_by'))
|
|
->addRadioOptions($options, 'sumby', $data['sumby'], true);
|
|
return $field;
|
|
}
|
|
|
|
|
|
function get_fieldset_presentation_options(array $data) : ElementFieldset
|
|
{
|
|
global $report_presentation_field_order;
|
|
|
|
$fieldset = new ElementFieldset();
|
|
$fieldset->addLegend(get_vocab('presentation_options'));
|
|
|
|
foreach ($report_presentation_field_order as $key)
|
|
{
|
|
switch ($key)
|
|
{
|
|
case 'output':
|
|
$fieldset->addElement(get_field_output($data));
|
|
break;
|
|
|
|
case 'output_format':
|
|
$fieldset->addElement(get_field_output_format($data));
|
|
break;
|
|
|
|
case 'sortby':
|
|
$fieldset->addElement(get_field_sortby($data));
|
|
break;
|
|
|
|
case 'sumby':
|
|
$fieldset->addElement(get_field_sumby($data));
|
|
break;
|
|
|
|
default:
|
|
break;
|
|
} // switch
|
|
} // foreach
|
|
|
|
return $fieldset;
|
|
}
|
|
|
|
|
|
function get_fieldset_submit_buttons() : ElementFieldset
|
|
{
|
|
$fieldset = new ElementFieldset();
|
|
|
|
$field = new FieldInputSubmit();
|
|
$field->setControlAttribute('value', get_vocab('submitquery'));
|
|
|
|
$fieldset->addElement($field);
|
|
|
|
return $fieldset;
|
|
}
|
|
|
|
|
|
// Converts a string from the standard MRBS character set to the character set
|
|
// to be used for CSV files
|
|
function csv_conv(string $string) : string
|
|
{
|
|
$in_charset = mb_strtoupper(Language::MRBS_CHARSET);
|
|
$out_charset = mb_strtoupper(get_csv_charset());
|
|
|
|
if ($in_charset == $out_charset)
|
|
{
|
|
return $string;
|
|
}
|
|
|
|
if (($in_charset == 'UTF-8') && (str_starts_with($out_charset, 'UTF-16')))
|
|
{
|
|
$suffix = substr($out_charset, strlen('UTF-16'));
|
|
|
|
switch ($suffix)
|
|
{
|
|
case '':
|
|
$endianness = null;
|
|
break;
|
|
case 'BE':
|
|
$endianness = System::BIG_ENDIAN;
|
|
break;
|
|
case 'LE':
|
|
$endianness = System::LITTLE_ENDIAN;
|
|
break;
|
|
default:
|
|
throw new Exception("Unsupported charset '$out_charset'.");
|
|
break;
|
|
}
|
|
|
|
// iconv() will add in a BOM if the output encoding requires one, but as we are only
|
|
// dealing with parts of a file we don't want any BOMs because we add them separately
|
|
// at the beginning of the file. So strip off anything that looks like a BOM.
|
|
return (new Utf8String($string))->toUtf16($endianness, true);
|
|
}
|
|
|
|
throw new Exception("Cannot convert from '$in_charset' to '$out_charset'");
|
|
}
|
|
|
|
|
|
// Escape a string for output
|
|
function escape(string $string) : string
|
|
{
|
|
global $output_format;
|
|
|
|
switch ($output_format)
|
|
{
|
|
case OUTPUT_HTML:
|
|
$string = mrbs_nl2br(escape_html($string));
|
|
break;
|
|
case OUTPUT_CSV:
|
|
$string = str_replace('"', '""', $string);
|
|
break;
|
|
default: // do nothing
|
|
break;
|
|
}
|
|
|
|
return $string;
|
|
}
|
|
|
|
|
|
// Wraps $string in a span with a data-type value of $data_type - but only for HTML output
|
|
function type_wrap(string $string, string $data_type) : string
|
|
{
|
|
global $output_format;
|
|
|
|
if ($output_format == OUTPUT_HTML)
|
|
{
|
|
return '<span class="normal" data-type="' . $data_type . '">' . $string . '</span>';
|
|
}
|
|
else
|
|
{
|
|
return $string;
|
|
}
|
|
}
|
|
|
|
|
|
function clean_row(array $row) : array
|
|
{
|
|
row_cast_columns($row, 'entry');
|
|
// These won't have been covered by row_cast_columns()
|
|
$row['area_id'] = (int) $row['area_id'];
|
|
$row['last_updated'] = (int) $row['last_updated'];
|
|
$row['approval_enabled'] = (bool) $row['approval_enabled'];
|
|
$row['confirmation_enabled'] = (bool) $row['confirmation_enabled'];
|
|
$row['enable_periods'] = (bool) $row['enable_periods'];
|
|
unpack_status($row);
|
|
|
|
return $row;
|
|
}
|
|
|
|
|
|
// Gets the indices of the columns that should be sorted
|
|
function get_sort_columns(string $sortby) : array
|
|
{
|
|
global $field_order_list;
|
|
|
|
$indices = [];
|
|
$i = 0;
|
|
$keys = ['area_name', 'room_name', 'start_time'];
|
|
|
|
foreach ($field_order_list as $field)
|
|
{
|
|
if (in_array($field, $keys))
|
|
{
|
|
$indices[$field] = $i;
|
|
}
|
|
|
|
if (count($indices) == 3)
|
|
{
|
|
break; // We've got all of them
|
|
}
|
|
|
|
$i++;
|
|
// End_time is a special case because it uses up two columns: one for
|
|
// the end time, and another for the duration.
|
|
if ($field == 'end_time')
|
|
{
|
|
$i++;
|
|
}
|
|
}
|
|
|
|
switch($sortby)
|
|
{
|
|
case 'r':
|
|
return [$indices['area_name'], $indices['room_name'], $indices['start_time']];
|
|
break;
|
|
default:
|
|
return [$indices['start_time'], $indices['area_name'], $indices['room_name']];
|
|
break;
|
|
}
|
|
}
|
|
|
|
|
|
// Output the first row (header row) for CSV reports
|
|
function report_header() : void
|
|
{
|
|
global $output_format, $is_ajax;
|
|
global $custom_fields;
|
|
global $approval_somewhere, $confirmation_somewhere, $registration_somewhere;
|
|
global $field_order_list, $booking_types;
|
|
|
|
// Don't do anything if this is an Ajax request: we only want to send the data
|
|
if ($is_ajax)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Build an array of values to go into the header row
|
|
$values = array();
|
|
|
|
foreach ($field_order_list as $field)
|
|
{
|
|
// We give some columns a type data value so that the JavaScript knows how to sort them
|
|
// TODO: the 'title-*' plugins are now deprecated in DataTables. Use the replacement.
|
|
// TODO: is 'string' really necessary or does DataTables auto-detect? (But it is necessary
|
|
// TODO: if there's HTML.)
|
|
switch ($field)
|
|
{
|
|
case 'name':
|
|
$values[$field] = type_wrap(get_vocab("namebooker"), 'string');
|
|
break;
|
|
case 'area_name':
|
|
$values[$field] = type_wrap(get_vocab("area"), 'string');
|
|
break;
|
|
case 'room_name':
|
|
$values[$field] = type_wrap(get_vocab("room"), 'string');
|
|
break;
|
|
case 'start_time':
|
|
$values[$field] = type_wrap(get_vocab("start_date"), 'title-numeric');
|
|
break;
|
|
case 'end_time':
|
|
$values[$field] = type_wrap(get_vocab("end_date"), 'title-numeric');
|
|
$values['duration'] = type_wrap(get_vocab("duration"), 'title-numeric');
|
|
break;
|
|
case 'description':
|
|
$values[$field] = type_wrap(get_vocab("fulldescription_short"), 'string');
|
|
break;
|
|
case 'type':
|
|
if (isset($booking_types) && (count($booking_types) > 1))
|
|
{
|
|
$values[$field] = get_vocab("type");
|
|
}
|
|
break;
|
|
case 'allow_registration':
|
|
if ($registration_somewhere)
|
|
{
|
|
$values[$field] = get_vocab("registered");
|
|
}
|
|
break;
|
|
case 'create_by':
|
|
$values[$field] = type_wrap(get_vocab("createdby"), 'string');
|
|
break;
|
|
case 'confirmation_enabled':
|
|
if ($confirmation_somewhere)
|
|
{
|
|
$values[$field] = get_vocab("confirmation_status");
|
|
}
|
|
break;
|
|
case 'approval_enabled':
|
|
if ($approval_somewhere)
|
|
{
|
|
$values[$field] = get_vocab("approval_status");
|
|
}
|
|
break;
|
|
case 'last_updated':
|
|
$values[$field] = type_wrap(get_vocab("lastupdate"), 'title-numeric');
|
|
break;
|
|
default:
|
|
// the custom fields
|
|
if (array_key_exists($field, $custom_fields))
|
|
{
|
|
$values[$field] = get_loc_field_name(_tbl('entry'), $field);
|
|
}
|
|
break;
|
|
} // switch
|
|
} // foreach
|
|
|
|
|
|
// Find out what the non-breaking space is in this character set
|
|
$charset = Language::MRBS_CHARSET;
|
|
$nbsp = html_entity_decode(' ', ENT_NOQUOTES, $charset);
|
|
foreach($values as $key => $value)
|
|
{
|
|
if ($output_format != OUTPUT_HTML)
|
|
{
|
|
// Remove any HTML entities from the values
|
|
$value = html_entity_decode($value, ENT_NOQUOTES, $charset);
|
|
// Trim non-breaking spaces from the string
|
|
$value = trim($value, $nbsp);
|
|
// And do an ordinary trim
|
|
$value = trim($value);
|
|
// We don't escape HTML output here because the vocab strings are trusted.
|
|
// And some of them contain HTML entities such as on purpose
|
|
$values[$key] = escape($value);
|
|
}
|
|
|
|
}
|
|
|
|
$head_rows = array();
|
|
$head_rows[] = $values;
|
|
output_head_rows($head_rows, $output_format);
|
|
}
|
|
|
|
|
|
function open_report() : void
|
|
{
|
|
global $output_format, $is_ajax, $sortby;
|
|
|
|
if ($output_format == OUTPUT_HTML && !$is_ajax)
|
|
{
|
|
echo "<div id=\"report_output\" class=\"datatable_container\">\n";
|
|
echo '<table class="admin_table display" id="report_table"';
|
|
// Add the index numbers of the columns that have to be sorted as a data
|
|
// attribute in order to help the JavaScript.
|
|
$sort_columns = get_sort_columns($sortby);
|
|
if (!empty($sort_columns))
|
|
{
|
|
echo ' data-sort-columns="' . escape_html(json_encode($sort_columns)) . '"';
|
|
}
|
|
echo ">\n";
|
|
}
|
|
}
|
|
|
|
|
|
function close_report() : void
|
|
{
|
|
global $output_format, $is_ajax, $json_data;
|
|
|
|
// If this is an Ajax request, we can now send the JSON data
|
|
if ($is_ajax)
|
|
{
|
|
http_headers(array("Content-Type: application/json"));
|
|
echo json_encode($json_data);
|
|
}
|
|
elseif ($output_format == OUTPUT_HTML)
|
|
{
|
|
echo "</table>\n";
|
|
echo "</div>\n";
|
|
}
|
|
}
|
|
|
|
|
|
function open_summary() : void
|
|
{
|
|
global $output_format, $times_somewhere, $periods_somewhere;
|
|
|
|
if ($output_format == OUTPUT_HTML)
|
|
{
|
|
echo "<div id=\"div_summary\" class=\"js_hidden\">\n";
|
|
echo "<h1>";
|
|
if ($times_somewhere)
|
|
{
|
|
echo ($periods_somewhere) ? get_vocab("summary_header_both") : get_vocab("summary_header");
|
|
}
|
|
else
|
|
{
|
|
echo get_vocab("summary_header_per");
|
|
}
|
|
echo "</h1>\n";
|
|
echo "<table>\n";
|
|
}
|
|
}
|
|
|
|
|
|
function close_summary() : void
|
|
{
|
|
global $output_format;
|
|
|
|
if ($output_format == OUTPUT_HTML)
|
|
{
|
|
echo "</table>\n";
|
|
echo "</div>\n";
|
|
}
|
|
}
|
|
|
|
|
|
// Output a table row.
|
|
function output_row(array $values, int $output_format, bool $body_row = true) : void
|
|
{
|
|
global $json_data, $is_ajax, $csv_col_sep, $csv_row_sep;
|
|
|
|
if ($is_ajax && $body_row)
|
|
{
|
|
$json_data['aaData'][] = $values;
|
|
}
|
|
else
|
|
{
|
|
if ($output_format == OUTPUT_CSV)
|
|
{
|
|
$line = '"';
|
|
$line .= implode("\"$csv_col_sep\"", $values);
|
|
$line .= '"' . $csv_row_sep;
|
|
$line = csv_conv($line);
|
|
}
|
|
elseif ($output_format == OUTPUT_HTML)
|
|
{
|
|
$line = '<tr>';
|
|
foreach ($values as $key => $value)
|
|
{
|
|
$line .= ($body_row) ? '<td>' : '<th id="'. escape_html("col_$key") . '">';
|
|
$line .= $value;
|
|
$line .= ($body_row) ? '</td>' : '</th>';
|
|
$line .= "\n";
|
|
}
|
|
}
|
|
echo $line;
|
|
}
|
|
}
|
|
|
|
|
|
function output_head_rows(array $rows, int $format) : void
|
|
{
|
|
if (count($rows) == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if ($format == OUTPUT_HTML)
|
|
{
|
|
echo "<colgroup>";
|
|
foreach ($rows[0] as $cell)
|
|
{
|
|
echo "<col>";
|
|
}
|
|
echo "</colgroup>\n";
|
|
}
|
|
echo ($format == OUTPUT_HTML) ? "<thead>\n" : "";
|
|
foreach ($rows as $row)
|
|
{
|
|
output_row($row, $format, FALSE);
|
|
}
|
|
echo ($format == OUTPUT_HTML) ? "</thead>\n" : "";
|
|
}
|
|
|
|
|
|
function output_body_rows(array $rows, int $format) : void
|
|
{
|
|
global $is_ajax;
|
|
|
|
if (count($rows) == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
echo (($format == OUTPUT_HTML) && !$is_ajax) ? "<tbody>\n" : "";
|
|
foreach ($rows as $row)
|
|
{
|
|
output_row($row, $format, TRUE);
|
|
}
|
|
echo (($format == OUTPUT_HTML) && !$is_ajax) ? "</tbody>\n" : "";
|
|
}
|
|
|
|
|
|
function output_foot_rows(array $rows, int $format) : void
|
|
{
|
|
if (count($rows) == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
echo ($format == OUTPUT_HTML) ? "<tfoot>\n" : "";
|
|
foreach ($rows as $row)
|
|
{
|
|
output_row($row, $format, FALSE);
|
|
}
|
|
echo ($format == OUTPUT_HTML) ? "</tfoot>\n" : "";
|
|
}
|
|
|
|
|
|
function report_row(&$rows, $data)
|
|
{
|
|
global $output_format, $is_ajax, $ajax_capable;
|
|
global $custom_fields, $field_natures, $field_lengths;
|
|
global $approval_somewhere, $confirmation_somewhere, $registration_somewhere;
|
|
global $select_options, $booking_types;
|
|
global $field_order_list;
|
|
global $include_registered_by, $include_registrant_username;
|
|
global $datetime_formats;
|
|
|
|
// If we're capable of delivering an Ajax request and this is not Ajax request,
|
|
// then don't do anything. We're going to save sending the data until we actually
|
|
// get the Ajax request; we just send the rest of the page at this stage.
|
|
if (($output_format == OUTPUT_HTML) && $ajax_capable && !$is_ajax)
|
|
{
|
|
return;
|
|
}
|
|
|
|
$values = array();
|
|
|
|
foreach ($field_order_list as $field)
|
|
{
|
|
$value = $data[$field];
|
|
|
|
// Some fields need some special processing to turn the raw value into something
|
|
// more meaningful
|
|
switch ($field)
|
|
{
|
|
case 'create_by':
|
|
$create_by = $value; // We will need it later
|
|
$value = get_compound_name($create_by);
|
|
break;
|
|
case 'end_time':
|
|
// Calculate the duration and then fall through to calculating the end date
|
|
// Need the duration in seconds for sorting. Have to correct it for DST
|
|
// changes so that the user sees what he expects to see
|
|
$duration_seconds = $data['end_time'] - $data['start_time'];
|
|
$duration_seconds -= cross_dst($data['start_time'], $data['end_time']);
|
|
$d = get_duration($data['start_time'], $data['end_time'], $data['enable_periods'], $data['area_id']);
|
|
$d_string = $d['duration'] . ' ' . $d['dur_units'];
|
|
$d_string = escape($d_string);
|
|
case 'start_time':
|
|
if ($data['enable_periods'])
|
|
{
|
|
// If we've fallen through from 'end_time' we need to set previous_period to TRUE
|
|
// so that we get the name of the previous period, not this one.
|
|
$date = period_date_string($value, $data['area_id'], ($field == 'end_time'));
|
|
}
|
|
else
|
|
{
|
|
$date = datetime_format($datetime_formats['date_and_time_report'], $value);
|
|
}
|
|
$value = $date;
|
|
break;
|
|
case 'type':
|
|
$value = get_type_vocab($value);
|
|
break;
|
|
case 'confirmation_enabled':
|
|
// Translate the status field bit into meaningful text
|
|
if ($data['confirmation_enabled'])
|
|
{
|
|
$value = ($data['tentative']) ? get_vocab('tentative') : get_vocab('confirmed');
|
|
}
|
|
else
|
|
{
|
|
$value = '';
|
|
}
|
|
break;
|
|
case 'approval_enabled':
|
|
// Translate the status field bit into meaningful text
|
|
if ($data['approval_enabled'])
|
|
{
|
|
$value = ($data['awaiting_approval']) ? get_vocab('awaiting_approval') : get_vocab('approved');
|
|
}
|
|
else
|
|
{
|
|
$value = '';
|
|
}
|
|
break;
|
|
case 'last_updated':
|
|
$value = datetime_format($datetime_formats['date_and_time_report'], $value);
|
|
break;
|
|
case 'allow_registration':
|
|
if ($data['allow_registration'])
|
|
{
|
|
$value = implode(', ', auth()->getRegistrantsDisplayNames($data, $include_registered_by, $include_registrant_username));
|
|
}
|
|
else
|
|
{
|
|
$value = get_vocab('na');
|
|
}
|
|
break;
|
|
default:
|
|
// Custom fields
|
|
if (array_key_exists($field, $custom_fields))
|
|
{
|
|
// Output a yes/no if it's a boolean or integer <= 2 bytes (which we will
|
|
// assume are intended to be booleans)
|
|
if (($field_natures[$field] == 'boolean') ||
|
|
(($field_natures[$field] == 'integer') && isset($field_lengths[$field]) && ($field_lengths[$field] <= 2)) )
|
|
{
|
|
$value = empty($value) ? get_vocab("no") : get_vocab("yes");
|
|
}
|
|
// Otherwise output a string
|
|
elseif (isset($value))
|
|
{
|
|
// If the custom field is an associative array then we want
|
|
// the value rather than the array key (provided the key is not
|
|
// an empty string)
|
|
if (isset($select_options["entry.$field"]) &&
|
|
is_assoc($select_options["entry.$field"]) &&
|
|
array_key_exists($value, $select_options["entry.$field"]) &&
|
|
($value !== ''))
|
|
{
|
|
$value = $select_options["entry.$field"][$value];
|
|
}
|
|
}
|
|
else
|
|
{
|
|
$value = '';
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
$value = escape(strval($value ?? ''));
|
|
|
|
// For HTML output we take special action for some fields
|
|
if ($output_format == OUTPUT_HTML)
|
|
{
|
|
switch ($field)
|
|
{
|
|
case 'create_by':
|
|
$text = $value;
|
|
// Add a mailto: link if we can and only if the current user is an admin (for privacy reasons)
|
|
if (is_admin())
|
|
{
|
|
$user = auth()->getUser($create_by);
|
|
if (isset($user->email) && ($user->email !== ''))
|
|
{
|
|
$value = '<a href="mailto:' . escape_html($user->email) . '">' . "$text</a>";
|
|
}
|
|
}
|
|
// Put an invisible <span> at the beginning for sorting.
|
|
// $text and $value are already escaped.
|
|
// TODO Use orthogonal data
|
|
$value = "<span>$text</span>$value";
|
|
break;
|
|
case 'name':
|
|
// Add a link to the entry and also a data-id value for the Bulk Delete JavaScript
|
|
$href = multisite('view_entry.php?id=' . intval($data['id'])); // Cast to int as a precaution
|
|
// Put an invisible <span> at the beginning for sorting.
|
|
// TODO This should really be done by putting a data-order attribute in the <td> element,
|
|
// TODO but we can't do that with Ajax loading. The solution is to switch to use DataTables'
|
|
// TODO orthogonal data.
|
|
$value = "<span>$value</span>" .
|
|
'<a href="' . escape_html($href) . '"' .
|
|
' data-id="' . intval($data['id']) . '"' . // Cast to int as a precaution
|
|
$value . '">' . $value . '</a>'; // $value already escaped
|
|
break;
|
|
case 'end_time':
|
|
// Process the duration and then fall through to the end_time
|
|
// Include the duration in a seconds as a title in an empty span so
|
|
// that the column can be sorted and filtered properly
|
|
$d_string = '<span title="' . intval($duration_seconds) . '"></span>' . $d_string; // Cast to int as a precaution
|
|
case 'start_time':
|
|
case 'last_updated':
|
|
// Include the numeric time as a title in an empty span so
|
|
// that the column can be sorted and filtered properly
|
|
$value = '<span title="' . intval($data[$field]) . '"></span>' . $value;
|
|
break;
|
|
case 'area_name':
|
|
// TODO Use orthogonal data instead of a span
|
|
$value = '<span>' . escape_html($data['area_sort_key']) . '</span>' . $value;
|
|
break;
|
|
case 'room_name':
|
|
// TODO Use orthogonal data instead of a span
|
|
$value = '<span>' . escape_html($data['room_sort_key']) . '</span>' . $value;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Add the value to the array. We don't bother with some fields if
|
|
// they are going to be irrelevant
|
|
if (($confirmation_somewhere || ($field != 'confirmation_enabled')) &&
|
|
($approval_somewhere || ($field != 'approval_enabled')) &&
|
|
($registration_somewhere || ($field != 'allow_registration')) &&
|
|
((isset($booking_types) && (count($booking_types) > 1)) || ($field != 'type')))
|
|
{
|
|
$values[] = $value;
|
|
}
|
|
|
|
// Special action for the duration
|
|
if ($field == 'end_time')
|
|
{
|
|
$values[] = $d_string;
|
|
}
|
|
|
|
} // foreach
|
|
|
|
$rows[] = $values;
|
|
}
|
|
|
|
|
|
function get_sumby_name_from_row(array $row) : string
|
|
{
|
|
global $sumby;
|
|
|
|
// Use brief description, created by or type as the name:
|
|
switch ($sumby)
|
|
{
|
|
case 'd':
|
|
$name = $row['name'];
|
|
break;
|
|
case 't':
|
|
$name = get_type_vocab($row['type']);
|
|
break;
|
|
default:
|
|
$name = $row['create_by'];
|
|
break;
|
|
}
|
|
|
|
return escape($name);
|
|
}
|
|
|
|
|
|
// Increments a two-dimensional array by $increment
|
|
function increment_count(&$array, $index1, $index2, $increment)
|
|
{
|
|
if (!isset($array[$index1]))
|
|
{
|
|
$array[$index1] = array();
|
|
}
|
|
if (!isset($array[$index1][$index2]))
|
|
{
|
|
$array[$index1][$index2] = 0;
|
|
}
|
|
$array[$index1][$index2] += $increment;
|
|
}
|
|
|
|
// Collect summary statistics on one entry.
|
|
// This also builds hash tables of all unique names and rooms. When sorted,
|
|
// these will become the column and row headers of the summary table.
|
|
function accumulate_summary($row, &$count, &$hours, $report_start, $report_end,
|
|
&$room_hash, &$name_hash)
|
|
{
|
|
global $output_format, $periods;
|
|
|
|
$periods_per_day = count($periods);
|
|
|
|
// Use brief description, created by or type as the name:
|
|
$name = get_sumby_name_from_row($row);
|
|
// Area and room separated by break (if HTML):
|
|
$room = escape($row['area_name']);
|
|
$room .= ($output_format == OUTPUT_HTML) ? "<br>" : '/';
|
|
$room .= escape($row['room_name']);
|
|
// Accumulate the number of bookings for this room and name:
|
|
increment_count($count, $room, $name, 1);
|
|
// Accumulate hours/periods used, clipped to report range dates:
|
|
if ($row['enable_periods'])
|
|
{
|
|
// We need to set the start and end of the report to the start and end of periods
|
|
// on those days. Otherwise if the report starts or ends in the middle of a multi-day
|
|
// booking we'll get all those spurious minutes before noon or between the end of
|
|
// the last period and midnight
|
|
|
|
// Need to use the MRBS version of DateTime to get round a bug in modify()
|
|
// in PHP before 5.3.6. As we are in the MRBS namespace we will get the MRBS version.
|
|
$startDate = new DateTime();
|
|
$startDate->setTimestamp($report_start)->modify('12:00');
|
|
|
|
$endDate = new DateTime();
|
|
$endDate->setTimestamp($report_end)->modify('12:00');
|
|
$endDate->sub(new DateInterval('P1D')); // Go back one day because the $report_end is at 00:00 the day after
|
|
$endDate->add(new DateInterval('PT' . $periods_per_day . 'M'));
|
|
$increment = get_period_interval(
|
|
max($row['start_time'], $startDate->getTimestamp()),
|
|
min($row['end_time'], $endDate->getTimestamp()),
|
|
$row['area_id']
|
|
);
|
|
$room_hash[$room] = MODE_PERIODS;
|
|
}
|
|
else
|
|
{
|
|
$increment = (min((int)$row['end_time'], $report_end) -
|
|
max((int)$row['start_time'], $report_start)) / SECONDS_PER_HOUR;
|
|
$room_hash[$room] = MODE_TIMES;
|
|
}
|
|
increment_count($hours, $room, $name, $increment);
|
|
$name_hash[$name] = 1;
|
|
}
|
|
|
|
|
|
// Format an entries value depending on whether it's destined for a CSV file or
|
|
// HTML output. If it's HTML output then we enclose it in parentheses.
|
|
function entries_format($str)
|
|
{
|
|
global $output_format;
|
|
|
|
if ($output_format == OUTPUT_HTML)
|
|
{
|
|
return "($str)";
|
|
}
|
|
else
|
|
{
|
|
return $str;
|
|
}
|
|
}
|
|
|
|
|
|
// Output the summary table (a "cross-tab report"). $count and $hours are
|
|
// 2-dimensional sparse arrays indexed by [area/room][name].
|
|
// $room_hash & $name_hash are arrays with indexes naming unique rooms and names.
|
|
function do_summary($count, $hours, &$room_hash, &$name_hash)
|
|
{
|
|
global $output_format, $csv_col_sep;
|
|
global $times_somewhere, $periods_somewhere;
|
|
|
|
// Sort the room and name arrays
|
|
ksort($room_hash);
|
|
ksort($name_hash);
|
|
// Initialise grand total counters
|
|
foreach (array(MODE_TIMES, MODE_PERIODS) as $m)
|
|
{
|
|
$grand_count_total[$m] = 0;
|
|
$grand_hours_total[$m] = 0;
|
|
}
|
|
|
|
|
|
// TABLE HEAD
|
|
// ----------
|
|
$head_rows = array();
|
|
$row1_cells = array('');
|
|
$row2_cells = array('');
|
|
|
|
foreach ($room_hash as $room => $mode)
|
|
{
|
|
$col_count_total[$room] = 0;
|
|
$col_hours_total[$room] = 0.0;
|
|
$mode_text = ($mode == MODE_TIMES) ? get_vocab("mode_times") : get_vocab("mode_periods");
|
|
$row1_cells[] = $room;
|
|
$row1_cells[] = ''; // The cell before is really spanning two columns. We'll sort it out with JavaScript
|
|
$row2_cells[] = get_vocab("entries");
|
|
$row2_cells[] = ($mode == MODE_PERIODS) ? get_vocab("periods") : get_vocab("hours");
|
|
}
|
|
|
|
// Add the total column(s) onto the end
|
|
if ($times_somewhere)
|
|
{
|
|
$row1_cells[] = get_vocab("total") . " (" . get_vocab("mode_times") . ")";
|
|
$row1_cells[] = '';
|
|
$row2_cells[] = get_vocab("entries");
|
|
$row2_cells[] = get_vocab("hours");
|
|
}
|
|
if ($periods_somewhere)
|
|
{
|
|
$row1_cells[] = get_vocab("total") . " (" . get_vocab("mode_periods") . ")";
|
|
$row1_cells[] = '';
|
|
$row2_cells[] = get_vocab("entries");
|
|
$row2_cells[] = get_vocab("periods");
|
|
}
|
|
|
|
// Add the rows to the array of header rows, for output later
|
|
$head_rows[] = $row1_cells;
|
|
$head_rows[] = $row2_cells;
|
|
|
|
|
|
// TABLE BODY
|
|
// ----------
|
|
$body_rows = array();
|
|
foreach ($name_hash as $name => $is_present)
|
|
{
|
|
foreach (array(MODE_TIMES, MODE_PERIODS) as $m)
|
|
{
|
|
$row_count_total[$m] = 0;
|
|
$row_hours_total[$m] = 0;
|
|
}
|
|
$cells = array();
|
|
$cells[] = $name;
|
|
foreach ($room_hash as $room => $mode)
|
|
{
|
|
if (isset($count[$room][$name]))
|
|
{
|
|
$count_val = $count[$room][$name];
|
|
$hours_val = $hours[$room][$name];
|
|
$cells[] = entries_format($count_val);
|
|
$format = ($mode == MODE_TIMES) ? FORMAT_TIMES : FORMAT_PERIODS;
|
|
$cells[] = sprintf($format, $hours_val);
|
|
$row_count_total[$mode] += $count_val;
|
|
$row_hours_total[$mode] += $hours_val;
|
|
$col_count_total[$room] += $count_val;
|
|
$col_hours_total[$room] += $hours_val;
|
|
}
|
|
else
|
|
{
|
|
$cells[] = ($output_format == OUTPUT_HTML) ? " " : '';
|
|
$cells[] = ($output_format == OUTPUT_HTML) ? " " : '';
|
|
}
|
|
}
|
|
// Add the total column(s) onto the end
|
|
if ($times_somewhere)
|
|
{
|
|
$cells[] = entries_format($row_count_total[MODE_TIMES]);
|
|
$cells[] = sprintf(FORMAT_TIMES, $row_hours_total[MODE_TIMES]);
|
|
}
|
|
if ($periods_somewhere)
|
|
{
|
|
$cells[] = entries_format($row_count_total[MODE_PERIODS]);
|
|
$cells[] = sprintf(FORMAT_PERIODS, $row_hours_total[MODE_PERIODS]);
|
|
}
|
|
$body_rows[] = $cells;
|
|
foreach (array(MODE_TIMES, MODE_PERIODS) as $m)
|
|
{
|
|
$grand_count_total[$m] += $row_count_total[$m];
|
|
$grand_hours_total[$m] += $row_hours_total[$m];
|
|
}
|
|
}
|
|
|
|
|
|
// TABLE FOOT
|
|
// ----------
|
|
$foot_rows = array();
|
|
$cells = array();
|
|
$cells[] = get_vocab("total");
|
|
foreach ($room_hash as $room => $mode)
|
|
{
|
|
$cells[] = entries_format($col_count_total[$room]);
|
|
$format = ($mode == MODE_TIMES) ? FORMAT_TIMES : FORMAT_PERIODS;
|
|
$cells[] = sprintf($format, $col_hours_total[$room]);
|
|
}
|
|
// Add the total column(s) onto the end
|
|
if ($times_somewhere)
|
|
{
|
|
$cells[] = entries_format($grand_count_total[MODE_TIMES]);
|
|
$cells[] = sprintf(FORMAT_TIMES, $grand_hours_total[MODE_TIMES]);
|
|
}
|
|
if ($periods_somewhere)
|
|
{
|
|
$cells[] = entries_format($grand_count_total[MODE_PERIODS]);
|
|
$cells[] = sprintf(FORMAT_PERIODS, $grand_hours_total[MODE_PERIODS]);
|
|
}
|
|
$foot_rows[] = $cells;
|
|
|
|
|
|
// OUTPUT THE TABLE
|
|
// ----------------
|
|
if ($output_format == OUTPUT_HTML)
|
|
{
|
|
// <tfoot> has to come before <tbody>
|
|
output_head_rows($head_rows, $output_format);
|
|
output_foot_rows($foot_rows, $output_format);
|
|
output_body_rows($body_rows, $output_format);
|
|
}
|
|
else
|
|
{
|
|
output_head_rows($head_rows, $output_format);
|
|
output_body_rows($body_rows, $output_format);
|
|
output_foot_rows($foot_rows, $output_format);
|
|
}
|
|
}
|
|
|
|
|
|
function get_match_condition(string $full_column_name, ?string $match, array &$sql_params) : string
|
|
{
|
|
global $select_options, $field_natures, $field_lengths;
|
|
|
|
$sql = '';
|
|
|
|
// First simple case: no match required
|
|
if (!isset($match) || $match === '')
|
|
{
|
|
return $sql;
|
|
}
|
|
|
|
// Get the table name and alias
|
|
$aliases = array('area' => 'A',
|
|
'entry' => 'E',
|
|
'room' => 'R');
|
|
|
|
list($table_alias, $column) = explode('.', $full_column_name, 2);
|
|
$table = array_search($table_alias, $aliases);
|
|
|
|
// Next simple case: for tables other than the entry table we are not going to
|
|
// bother with complicated things such as custom fields or select_options
|
|
if ($table != 'entry')
|
|
{
|
|
// syntax_caseless_contains() modifies the SQL params array too
|
|
$sql .= " AND " . db()->syntax_caseless_contains("$full_column_name", $match, $sql_params);
|
|
return $sql;
|
|
}
|
|
|
|
// More complicated cases:
|
|
|
|
// (1) Associative arrays (we can't just test for the string, because the database
|
|
// contains the keys, not the values. So we have to go through each key testing
|
|
// for a possible match)
|
|
if (isset($select_options["$table.$column"]) &&
|
|
is_assoc($select_options["$table.$column"]))
|
|
{
|
|
$sql .= " AND ";
|
|
$or_array = array();
|
|
foreach($select_options["$table.$column"] as $option_key => $option_value)
|
|
{
|
|
// We have to use strpos() rather than stripos() because we cannot
|
|
// assume PHP5
|
|
if (($option_key !== '') &&
|
|
(mb_strpos(mb_strtolower($option_value), mb_strtolower($match)) !== FALSE))
|
|
{
|
|
$or_array[] = "$full_column_name=?";
|
|
$sql_params[] = $option_key;
|
|
}
|
|
}
|
|
if (count($or_array) > 0)
|
|
{
|
|
$sql .= "(". implode( " OR ", $or_array ) .")";
|
|
}
|
|
else
|
|
{
|
|
$sql .= "FALSE";
|
|
}
|
|
}
|
|
// (2) Booleans (or integers <= 2 bytes which we assume are intended to be booleans)
|
|
elseif (($field_natures[$column] == 'boolean') ||
|
|
(($field_natures[$column] == 'integer') && isset($field_lengths[$column]) && ($field_lengths[$column] <= 2)) )
|
|
{
|
|
if (($match != BOOLEAN_MATCH_BOTH) && ($match !== ''))
|
|
{
|
|
$sql .= " AND ($full_column_name ";
|
|
$sql .= ($match == BOOLEAN_MATCH_TRUE) ? "IS NOT NULL AND $full_column_name != 0" : "IS NULL OR $full_column_name = 0";
|
|
$sql .= ")";
|
|
}
|
|
}
|
|
// (3) Integers
|
|
elseif (($field_natures[$column] == 'integer') &&
|
|
isset($field_lengths[$column]) &&
|
|
($field_lengths[$column] > 2))
|
|
{
|
|
$sql .= " AND $full_column_name=" . $match;
|
|
}
|
|
// (4) Strings
|
|
else
|
|
{
|
|
// syntax_caseless_contains() modifies the SQL params array too
|
|
$sql .= " AND " . db()->syntax_caseless_contains("$full_column_name", $match, $sql_params);
|
|
}
|
|
|
|
return $sql;
|
|
}
|
|
|
|
// Work out whether we are running from the command line
|
|
$cli_mode = is_cli();
|
|
|
|
if ($cli_mode)
|
|
{
|
|
// Need to set include path if we're running in CLI mode
|
|
// (because otherwise PHP looks in the current directory rather
|
|
// than the directory from which the script was called)
|
|
ini_set("include_path", dirname(url_path()));
|
|
}
|
|
|
|
$default_from_time = mktime(0, 0, 0, $month, $day, $year);
|
|
$default_to_time = mktime(0, 0, 0, $month, $day + $default_report_days, $year);
|
|
$default_from_date = date(DateTime::ISO8601_DATE, $default_from_time);
|
|
$default_to_date = date(DateTime::ISO8601_DATE, $default_to_time);
|
|
|
|
// Get non-standard form variables
|
|
$from_date = get_form_var('from_date', 'string', $default_from_date);
|
|
$to_date = get_form_var('to_date', 'string', $default_to_date);
|
|
$creatormatch = get_form_var('creatormatch', 'string');
|
|
$areamatch = get_form_var('areamatch', 'string');
|
|
$roommatch = get_form_var('roommatch', 'string');
|
|
$namematch = get_form_var('namematch', 'string');
|
|
$descrmatch = get_form_var('descrmatch', 'string');
|
|
$output = get_form_var('output', 'int', REPORT);
|
|
$output_format = get_form_var('output_format', 'int', (($cli_mode) ? OUTPUT_CSV : OUTPUT_HTML));
|
|
$typematch = get_form_var('typematch', 'array');
|
|
$sortby = get_form_var('sortby', 'string', $default_sortby ?? FALLBACK_SORTBY); // $sortby: r=room, s=start date/time.
|
|
$sumby = get_form_var('sumby', 'string', $default_sumby ?? FALLBACK_SUMBY); // $sumby: d=by brief description, c=by creator, t=by type.
|
|
$match_approved = get_form_var('match_approved', 'int', BOOLEAN_MATCH_BOTH);
|
|
$match_confirmed = get_form_var('match_confirmed', 'int', BOOLEAN_MATCH_BOTH);
|
|
$match_private = get_form_var('match_private', 'int', BOOLEAN_MATCH_BOTH);
|
|
$phase = get_form_var('phase', 'int', 1);
|
|
$datatable = get_form_var('datatable', 'int'); // Will only be set if we're using DataTables
|
|
|
|
// Validate form variables
|
|
if (!in_array($sortby, ['r', 's']))
|
|
{
|
|
trigger_error("Unknown sort code '$sortby'; using '" . FALLBACK_SORTBY . "'.", E_USER_NOTICE);
|
|
$sortby = FALLBACK_SORTBY;
|
|
}
|
|
|
|
if (!in_array($sumby, ['c', 'd', 't']))
|
|
{
|
|
trigger_error("Unknown sumby code '$sumby'; using '" . FALLBACK_SUMBY ."'.", E_USER_NOTICE);
|
|
$sumby = FALLBACK_SUMBY;
|
|
}
|
|
|
|
$is_ajax = is_ajax();
|
|
|
|
if ($cli_mode)
|
|
{
|
|
// If we're running in CLI mode we're passing the parameters in from the command line
|
|
// not the form and we want to go straight to Phase 2 (producing the report)
|
|
$phase = 2;
|
|
}
|
|
else
|
|
{
|
|
// Check the CSRF token if this is Phase 2
|
|
if ($phase == 2)
|
|
{
|
|
Form::checkToken(true);
|
|
}
|
|
|
|
// Check the user is authorised for this page.
|
|
if ($report_unauthenticated_get_enabled && is_get_request() && ($phase == 2))
|
|
{
|
|
if ($report_keys_enabled)
|
|
{
|
|
$key = get_form_var('key', 'string');
|
|
if (!in_array($key, $report_keys))
|
|
{
|
|
http_response_code(403);
|
|
exit;
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
checkAuthorised(this_page());
|
|
$mrbs_user = session()->getCurrentUser();
|
|
}
|
|
}
|
|
|
|
// Set up for Ajax. We need to know whether we're capable of dealing with Ajax
|
|
// requests, which will only be if the browser is using DataTables. We also need
|
|
// to initialise the JSON data array.
|
|
$ajax_capable = $datatable;
|
|
|
|
if ($is_ajax)
|
|
{
|
|
$json_data['aaData'] = array();
|
|
}
|
|
|
|
$private_somewhere = some_area('private_enabled') || some_area('private_mandatory');
|
|
$approval_somewhere = some_area('approval_enabled');
|
|
$confirmation_somewhere = some_area('confirmation_enabled');
|
|
$registration_somewhere = registration_somewhere();
|
|
$times_somewhere = times_somewhere();
|
|
$periods_somewhere = periods_somewhere();
|
|
|
|
|
|
// Build the report search field order
|
|
$report_presentation_fields = array('output', 'output_format', 'sortby', 'sumby');
|
|
|
|
foreach ($report_presentation_fields as $field)
|
|
{
|
|
if (!in_array($field, $report_presentation_field_order))
|
|
{
|
|
$report_presentation_field_order[] = $field;
|
|
}
|
|
}
|
|
|
|
// Build the report search field order
|
|
$report_search_fields = array('report_start', 'report_end',
|
|
'areamatch', 'roommatch',
|
|
'typematch', 'namematch', 'descrmatch', 'creatormatch',
|
|
'match_private', 'match_confirmed', 'match_approved');
|
|
|
|
foreach ($report_search_fields as $field)
|
|
{
|
|
if (!in_array($field, $report_search_field_order))
|
|
{
|
|
$report_search_field_order[] = $field;
|
|
}
|
|
}
|
|
|
|
// Get information about custom fields
|
|
$fields = db()->field_info(_tbl('entry'));
|
|
$custom_fields = array();
|
|
$field_natures = array();
|
|
$field_lengths = array();
|
|
foreach ($fields as $field)
|
|
{
|
|
$key = $field['name'];
|
|
if (!in_array($key, $standard_fields['entry']))
|
|
{
|
|
$custom_fields[$key] = '';
|
|
// Add the field to the end of the report search field order, if it's
|
|
// not already present
|
|
if (!in_array($key, $report_search_field_order))
|
|
{
|
|
$report_search_field_order[] = $key;
|
|
}
|
|
}
|
|
$field_natures[$key] = $field['nature'];
|
|
$field_lengths[$key] = $field['length'];
|
|
}
|
|
|
|
// Get the custom form inputs
|
|
foreach ($custom_fields as $key => $value)
|
|
{
|
|
$var = "match_$key";
|
|
// Get the field as a string to check whether it is empty (ie match anything)
|
|
$$var = get_form_var($var, 'string');
|
|
if (($field_natures[$key] == 'integer') &&
|
|
($field_lengths[$key] > 2) &&
|
|
isset($$var))
|
|
{
|
|
$$var = trim($$var);
|
|
if ($$var !== '')
|
|
{
|
|
$$var = intval($$var);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set the field order list
|
|
$field_order_list = array('name', 'area_name', 'room_name', 'start_time', 'end_time',
|
|
'description', 'type', 'allow_registration', 'create_by', 'confirmation_enabled',
|
|
'approval_enabled');
|
|
foreach ($custom_fields as $key => $value)
|
|
{
|
|
$field_order_list[] = $key;
|
|
}
|
|
$field_order_list[] = 'last_updated';
|
|
|
|
|
|
|
|
// PHASE 2: SQL QUERY. We do the SQL query now to see if there's anything there
|
|
if ($phase == 2)
|
|
{
|
|
// Start and end times are also used to clip the times for summary info.
|
|
// createFromFormat() gives the current time. We want the report to
|
|
// start at the beginning of the start day and end of the day, so set the
|
|
// times accordingly.
|
|
if (false === ($report_start = DateTime::createFromFormat(DateTime::ISO8601_DATE, $from_date)))
|
|
{
|
|
throw new Exception("Invalid from_date '$from_date'");
|
|
}
|
|
$report_start = $report_start->setTime(0, 0)->getTimestamp();
|
|
|
|
if (false === ($report_end = DateTime::createFromFormat(DateTime::ISO8601_DATE, $to_date)))
|
|
{
|
|
throw new Exception("Invalid to_date '$to_date'");
|
|
}
|
|
$report_end = $report_end->setTime(24, 0)->getTimestamp();
|
|
|
|
|
|
// Construct the SQL query
|
|
$sql_params = array();
|
|
$sql = "SELECT E.*, "
|
|
. db()->syntax_timestamp_to_unix("E.timestamp") . " AS last_updated, "
|
|
. "A.area_name, A.sort_key AS area_sort_key, R.room_name, R.sort_key AS room_sort_key, R.area_id, "
|
|
. "A.approval_enabled, A.confirmation_enabled, A.enable_periods, A.timezone";
|
|
if ($output_format == OUTPUT_ICAL)
|
|
{
|
|
// If we're producing an iCalendar then we'll also need the repeat
|
|
// information in order to construct the recurrence rule
|
|
$sql .= ", T.rep_type, T.end_date, T.rep_opt, T.rep_interval, T.month_absolute, T.month_relative";
|
|
}
|
|
$sql .= " FROM " . _tbl('entry') . " E
|
|
LEFT JOIN " . _tbl('room') . " R
|
|
ON E.room_id = R.id
|
|
LEFT JOIN " . _tbl('area') . " A
|
|
ON R.area_id = A.id";
|
|
|
|
if ($output_format == OUTPUT_ICAL)
|
|
{
|
|
// We do a LEFT JOIN because we still want the single entries, ie the ones
|
|
// that won't have a match in the repeat table
|
|
$sql .= " LEFT JOIN " . _tbl('repeat') . " T
|
|
ON E.repeat_id=T.id";
|
|
}
|
|
|
|
$sql .= " WHERE E.start_time < ? AND E.end_time > ?";
|
|
$sql_params[] = $report_end;
|
|
$sql_params[] = $report_start;
|
|
|
|
// Get the match conditions for the simple cases
|
|
$match_columns = array('A.area_name' => $areamatch,
|
|
'R.room_name' => $roommatch,
|
|
'E.name' => $namematch,
|
|
'E.description' => $descrmatch,
|
|
'E.create_by' => $creatormatch);
|
|
|
|
foreach ($match_columns as $column => $match)
|
|
{
|
|
$sql .= get_match_condition($column, $match, $sql_params);
|
|
}
|
|
|
|
// Then do the special cases
|
|
if (!empty($typematch))
|
|
{
|
|
$sql .= " AND ";
|
|
$or_array = array();
|
|
foreach ( $typematch as $type )
|
|
{
|
|
// syntax_casesensitive_equals() modifies our SQL params array for us
|
|
$or_array[] = db()->syntax_casesensitive_equals('E.type', $type, $sql_params);
|
|
}
|
|
$sql .= "(". implode(" OR ", $or_array ) .")";
|
|
}
|
|
|
|
// (In the next three cases, you will get the empty string if that part
|
|
// of the form was not displayed - which means that you need all bookings)
|
|
// Note that although you can say eg "status&STATUS_PRIVATE" in MySQL, you get
|
|
// an error in PostgreSQL as the expression is of the wrong type.
|
|
|
|
// Match the privacy status
|
|
if (($match_private != BOOLEAN_MATCH_BOTH) && ($match_private !== ''))
|
|
{
|
|
$sql .= " AND ";
|
|
$sql .= "(E.status&" . STATUS_PRIVATE;
|
|
$sql .= ($match_private) ? "!=0)" : "=0)"; // Note that private works the other way round to the next two
|
|
}
|
|
|
|
// Match the confirmation status
|
|
if (($match_confirmed != BOOLEAN_MATCH_BOTH) && ($match_confirmed !== ''))
|
|
{
|
|
$sql .= " AND ";
|
|
$sql .= "(E.status&" . STATUS_TENTATIVE;
|
|
$sql .= ($match_confirmed) ? "=0)" : "!=0)";
|
|
}
|
|
// Match the approval status
|
|
if (($match_approved != BOOLEAN_MATCH_BOTH) && ($match_approved !== ''))
|
|
{
|
|
$sql .= " AND ";
|
|
$sql .= "(E.status&" . STATUS_AWAITING_APPROVAL;
|
|
$sql .= ($match_approved) ? "=0)" : "!=0)";
|
|
}
|
|
|
|
// Then do the custom fields
|
|
foreach ($custom_fields as $key => $value)
|
|
{
|
|
$var = "match_$key";
|
|
$sql .= get_match_condition("E.$key", $$var, $sql_params);
|
|
}
|
|
|
|
// We only want the bookings for rooms that are visible
|
|
$invisible_room_ids = get_invisible_room_ids();
|
|
if (count($invisible_room_ids) > 0)
|
|
{
|
|
$sql .= " AND (E.room_id NOT IN (" . implode(',', $invisible_room_ids) . "))";
|
|
}
|
|
|
|
// If we're not an admin (they are allowed to see everything), then we need
|
|
// to make sure we respect the privacy settings. (We rely on the privacy fields
|
|
// in the area table being not NULL. If they are by some chance NULL, then no
|
|
// entries will be found, which is at least safe from the privacy viewpoint)
|
|
if (!$cli_mode && !is_book_admin())
|
|
{
|
|
if (isset($mrbs_user))
|
|
{
|
|
// if the user is logged in they can see:
|
|
// - all bookings, if private_override is set to 'public'
|
|
// - their own bookings, and others' public bookings if private_override is set to 'none'
|
|
// - just their own bookings, if private_override is set to 'private'
|
|
$sql .= " AND ((A.private_override='public') OR
|
|
((A.private_override='none') AND ((E.status&" . STATUS_PRIVATE . "=0) OR (E.create_by = ?))) OR
|
|
((A.private_override='private') AND (E.create_by = ?)))";
|
|
$sql_params[] = $mrbs_user->username;
|
|
$sql_params[] = $mrbs_user->username;
|
|
}
|
|
else
|
|
{
|
|
// if the user is not logged in they can see:
|
|
// - all bookings, if private_override is set to 'public'
|
|
// - public bookings if private_override is set to 'none'
|
|
$sql .= " AND ((A.private_override='public') OR
|
|
((A.private_override='none') AND (E.status&" . STATUS_PRIVATE . "=0)))";
|
|
}
|
|
}
|
|
|
|
// If we're allowing unauthenticated requests and this is one then check that the
|
|
// room is in the list of open rooms or the area is in the list of open areas.
|
|
if ($report_unauthenticated_get_enabled && is_get_request())
|
|
{
|
|
$sql .= " AND (";
|
|
$sql .= db()->syntax_in_list('A.id', $report_open_areas, $sql_params) . " OR ";
|
|
$sql .= db()->syntax_in_list('R.id', $report_open_rooms, $sql_params);
|
|
$sql .= ")";
|
|
}
|
|
|
|
if ($output_format == OUTPUT_ICAL)
|
|
{
|
|
// If we're producing an iCalendar then we'll want the entries ordered by
|
|
// repeat_id and then start_time
|
|
// Note that by default NULLs are low in MySQL and high in PostgreSQL and as it's
|
|
// important that the NULLs (ie non-series entries) are at the beginning then we
|
|
// need to be explicit about the sort order of NULLS.
|
|
$sql .= " ORDER BY repeat_id IS NULL DESC, repeat_id, start_time";
|
|
}
|
|
elseif ($sortby == "r")
|
|
{
|
|
// Order by Area, Room, Start date/time
|
|
$sql .= " ORDER BY A.sort_key, R.sort_key, start_time";
|
|
}
|
|
else
|
|
{
|
|
// Order by Start date/time, Area, Room
|
|
$sql .= " ORDER BY start_time, A.sort_key, R.sort_key";
|
|
}
|
|
|
|
// echo "<p>DEBUG: SQL: <tt> $sql </tt></p>\n";
|
|
|
|
$res = db()->query($sql, $sql_params);
|
|
$nmatch = $res->count();
|
|
}
|
|
|
|
$combination_not_supported = ($output == SUMMARY) && ($output_format == OUTPUT_ICAL);
|
|
|
|
$output_form = (($output_format == OUTPUT_HTML) && !$is_ajax &&!$cli_mode) ||
|
|
$combination_not_supported;
|
|
|
|
|
|
// print the page header
|
|
if ($is_ajax)
|
|
{
|
|
// don't do anything if this is an Ajax request: we only want the data
|
|
}
|
|
elseif ($output_form)
|
|
{
|
|
$context = array(
|
|
'view' => $view,
|
|
'view_all' => $view_all,
|
|
'year' => $year,
|
|
'month' => $month,
|
|
'day' => $day,
|
|
'area' => $area,
|
|
'room' => $room ?? null
|
|
);
|
|
|
|
print_header($context);
|
|
}
|
|
else
|
|
{
|
|
$filename = ($output == REPORT) ? $report_filename : $summary_filename;
|
|
switch ($output_format)
|
|
{
|
|
case OUTPUT_CSV:
|
|
$filename .= '.csv';
|
|
$content_type = "text/csv; charset=" . get_csv_charset();
|
|
break;
|
|
default:
|
|
$filename .= '.ics';
|
|
$content_type = "application/ics; charset=" . Language::MRBS_CHARSET . "; name=\"$filename\"";
|
|
break;
|
|
}
|
|
if (!$cli_mode)
|
|
{
|
|
http_headers(array("Content-Type: $content_type",
|
|
"Content-Disposition: attachment; filename=\"$filename\""));
|
|
}
|
|
|
|
if (($output_format == OUTPUT_CSV) && $csv_bom)
|
|
{
|
|
echo get_bom(get_csv_charset());
|
|
}
|
|
}
|
|
|
|
|
|
// Upper part: The form.
|
|
if ($output_form)
|
|
{
|
|
echo "<div class=\"screenonly\">\n";
|
|
|
|
$form = new Form(Form::METHOD_POST);
|
|
|
|
// Search variables
|
|
$search_var_keys = array('from_date', 'to_date',
|
|
'areamatch', 'roommatch',
|
|
'typematch', 'namematch', 'descrmatch', 'creatormatch',
|
|
'match_private', 'match_confirmed', 'match_approved',
|
|
'custom_fields');
|
|
$search_vars = array();
|
|
foreach($search_var_keys as $var)
|
|
{
|
|
$search_vars[$var] = $$var;
|
|
}
|
|
|
|
// Presentation variables
|
|
$presentation_var_keys = array('output', 'output_format',
|
|
'sortby', 'sumby');
|
|
$presentation_vars = array();
|
|
foreach($presentation_var_keys as $var)
|
|
{
|
|
$presentation_vars[$var] = $$var;
|
|
}
|
|
|
|
$attributes = array('id' => 'report_form',
|
|
'class' => 'standard',
|
|
'action' => multisite(this_page()));
|
|
|
|
$form->setAttributes($attributes)
|
|
->addHiddenInput('phase', '2');
|
|
|
|
$outer_fieldset = new ElementFieldset();
|
|
$outer_fieldset->addLegend(get_vocab('report_on'))
|
|
->addElement(get_fieldset_search_criteria($search_vars))
|
|
->addElement(get_fieldset_presentation_options($presentation_vars))
|
|
->addElement(get_fieldset_submit_buttons());
|
|
|
|
$form->addElement($outer_fieldset);
|
|
|
|
$form->render();
|
|
|
|
echo "</div>\n";
|
|
}
|
|
|
|
// PHASE 2: Output the results, if called with parameters:
|
|
if ($phase == 2)
|
|
{
|
|
if (($nmatch == 0) && !$cli_mode && ($output_format == OUTPUT_HTML))
|
|
{
|
|
if ($is_ajax)
|
|
{
|
|
http_headers(array("Content-Type: application/json"));
|
|
echo json_encode($json_data);
|
|
}
|
|
else
|
|
{
|
|
echo "<p class=\"report_entries\">" . get_vocab("nothing_found") . "</p>\n";
|
|
}
|
|
unset($res);
|
|
}
|
|
elseif ($combination_not_supported)
|
|
{
|
|
echo "<p>" . get_vocab("combination_not_supported") . "</p>\n";
|
|
unset($res);
|
|
}
|
|
else
|
|
{
|
|
if ($output_format == OUTPUT_ICAL)
|
|
{
|
|
// We set $keep_private to FALSE here because we excluded all private
|
|
// events in the SQL query
|
|
$calendar = Calendar::createFromStatement($res, false, $report_end);
|
|
echo $calendar->toString();
|
|
exit;
|
|
}
|
|
|
|
if (($output_format == OUTPUT_HTML) && !$is_ajax)
|
|
{
|
|
echo "<p class=\"report_entries\"><span id=\"n_entries\">" . $nmatch . "</span> "
|
|
. ($nmatch == 1 ? get_vocab("entry_found") : get_vocab("entries_found"))
|
|
. "</p>\n";
|
|
}
|
|
|
|
// Report
|
|
if ($output == REPORT)
|
|
{
|
|
open_report();
|
|
report_header();
|
|
$body_rows = array();
|
|
while (false !== ($row = $res->next_row_keyed()))
|
|
{
|
|
$row = clean_row($row);
|
|
report_row($body_rows, $row);
|
|
}
|
|
output_body_rows($body_rows, $output_format);
|
|
close_report();
|
|
}
|
|
// Summary
|
|
else
|
|
{
|
|
open_summary();
|
|
if ($nmatch > 0)
|
|
{
|
|
while (false !== ($row = $res->next_row_keyed()))
|
|
{
|
|
$row = clean_row($row);
|
|
accumulate_summary($row, $count, $hours,
|
|
$report_start, $report_end,
|
|
$room_hash, $name_hash);
|
|
}
|
|
do_summary($count, $hours, $room_hash, $name_hash);
|
|
}
|
|
else
|
|
{
|
|
// Excel doesn't seem to like an empty file with just a BOM, so give
|
|
// it an empty row as well to keep it happy
|
|
$values = array();
|
|
output_row($values, $output_format);
|
|
}
|
|
close_summary();
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($cli_mode)
|
|
{
|
|
exit(0);
|
|
}
|
|
|
|
if ($output_form)
|
|
{
|
|
print_footer();
|
|
}
|