Newer
Older
}
function describeSearch($method, $value, $name=false) {
$desc = $this->describeSearchMethod($method);
$value = $this->toString($value);
return sprintf($desc, $name, $value);
}
function addToQuery($query, $name=false) {
return $query->values($name ?: $this->get('name'));
}
/**
* Similary to to_php() and parse(), except a row from a queryset is
* passed. The value returned should be what would be retured from
* parse() or to_php()
*/
function from_query($row, $name=false) {
return $row[$name ?: $this->get('name')];
}
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
/**
* If the field can be used in a quick filter. To be used, it should
* also implement getQuickFilterChoices() which should return a list of
* choices to appear in a quick filter drop-down
*/
function supportsQuickFilter() {
return false;
}
/**
* Fetch a keyed array of quick filter choices. The keys should be
* passed later to ::applyQuickFilter() to apply the quick filter to a
* query. The values should be localized titles for the choices.
*/
function getQuickFilterChoices() {
return array();
}
/**
* Apply a quick filter selection of this field to the query. The
* modified query should be returned. Optionally, the orm path / field
* name can be passed.
*/
function applyQuickFilter($query, $choice, $name=false) {
return $query;
}
function getLabel() { return $this->get('label'); }
function applyOrderBy($query, $reverse=false, $name=false) {
$col = $name ?: CustomQueue::getOrmPath($this->get('name'), $query);
if ($reverse)
$col = '-' . $col;
return $query->order_by($col);
}
/**
* getImpl
*
* Magic method that will return an implementation instance of this
* field based on the simple text value of the 'type' value of this
* field instance. The list of registered fields is determined by the
* global get_dynamic_field_types() function. The data from this model
* will be used to initialize the returned instance.
*
* For instance, if the value of this field is 'text', a TextField
* instance will be returned.
*/
// Allow registration with ::addFieldTypes and delayed calling
$type = static::getFieldType($this->get('type'));
$clazz = $type[1];
$inst = new $clazz($this->ht);
$inst->parent = $parent;
$inst->setForm($this->_form);
function __call($what, $args) {
// XXX: Throw exception if $this->parent is not set
throw new Exception(sprintf(__('%s: Call to undefined function'),
$what));
// BEWARE: DynamicFormField has a __call() which will create a new
// FormField instance and invoke __call() on it or bounce
// immediately back
return call_user_func_array(
array($this->parent, $what), $args);
}
function getAnswer() { return $this->answer; }
function setAnswer($ans) { $this->answer = $ans; }
function setValue($value) {
$this->reset();
$this->getWidget()->value = $value;
}
/**
* Fetch a pseudo-random id for this form field. It is used when
* rendering the widget in the @name attribute emitted in the resulting
* HTML. The form element is based on the form id, field id and name,
* and the current user's session id. Therefor, the same form fields
* will yield differing names for different users. This is used to ward
* off bot attacks as it makes it very difficult to predict and
* correlate the form names to the data they represent.
*/
$default = $this->get('name') ?: $this->get('id');
if ($this->_form && is_numeric($fid = $this->_form->getFormId()))
return substr(md5(
session_id() . '-form-field-id-' . $fid . $default), -14);
elseif (is_numeric($this->get('id')))
return substr(md5(
session_id() . '-field-id-'.$this->get('id')), -16);
function setForm($form) {
$this->_form = $form;
}
function getForm() {
return $this->_form;
}
/**
* Returns the data source for this field. If created from a form, the
* data source from the form is returned. Otherwise, if the request is a
* POST, then _POST is returned.
*/
function getSource() {
if ($this->_form)
return $this->_form->getSource();
elseif ($_SERVER['REQUEST_METHOD'] == 'POST')
return $_POST;
else
return array();
}
function render($options=array()) {
$rv = $this->getWidget()->render($options);
if ($v = $this->get('visibility')) {
$v->emitJavascript($this);
}
return $rv;
function renderExtras($options=array()) {
function getMedia() {
$widget = $this->getWidget();
return $widget::$media;
}
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
function getConfigurationOptions() {
return array();
}
/**
* getConfiguration
*
* Loads configuration information from database into hashtable format.
* Also, the defaults from ::getConfigurationOptions() are integrated
* into the database-backed options, so that if options have not yet
* been set or a new option has been added and not saved for this field,
* the default value will be reflected in the returned configuration.
*/
function getConfiguration() {
if (!$this->_config) {
$this->_config = $this->get('configuration');
if (is_string($this->_config))
$this->_config = JsonDataParser::parse($this->_config);
elseif (!$this->_config)
$this->_config = array();
foreach ($this->getConfigurationOptions() as $name=>$field)
if (!isset($this->_config[$name]))
$this->_config[$name] = $field->get('default');
}
return $this->_config;
}
/**
* If the [Config] button should be shown to allow for the configuration
* of this field
*/
function isConfigurable() {
return true;
}
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
/**
* Field type is changeable in the admin interface
*/
function isChangeable() {
return true;
}
/**
* Field does not contain data that should be saved to the database. Ie.
* non data fields like section headers
*/
function hasData() {
return true;
}
/**
* Returns true if the field/widget should be rendered as an entire
* block in the target form.
*/
function isBlockLevel() {
return false;
}
/**
* Fields should not be saved with the dynamic data. It is assumed that
* some static processing will store the data elsewhere.
*/
function isPresentationOnly() {
return $this->presentation_only;
/**
* Indicates if the field places data in the `value_id` column. This
* is currently used by the materialized view system
*/
function hasIdValue() {
return false;
}
/**
* Indicates if the field has subfields accessible via getSubFields()
* method. Useful for filter integration. Should connect with
* getFilterData()
*/
function hasSubFields() {
return false;
}
function getSubFields() {
return null;
}
function getConfigurationForm($source=null) {
$type = static::getFieldType($this->get('type'));
$clazz = $type[1];
$T = new $clazz($this->ht);
$config = $this->getConfiguration();
$this->_cform = new SimpleForm($T->getConfigurationOptions(), $source);
foreach ($this->_cform->getFields() as $name=>$f) {
if ($config && isset($config[$name]))
$f->value = $config[$name];
elseif ($f->get('default'))
$f->value = $f->get('default');
}
}
function configure($prop, $value) {
$this->getConfiguration();
$this->_config[$prop] = $value;
}
function getWidget($widgetClass=false) {
if (!static::$widget)
throw new Exception(__('Widget not defined for this field'));
$wc = $widgetClass ?: $this->get('widget') ?: static::$widget;
$this->_widget->parseValue();
}
return $this->_widget;
function getSelectName() {
$name = $this->get('name') ?: 'field_'.$this->get('id');
if ($this->hasIdValue())
$name .= '_id';
return $name;
}
function getTranslateTag($subtag) {
return _H(sprintf('field.%s.%s%s', $subtag, $this->get('id'),
$this->get('form_id') ? '' : '*internal*'));
function getLocal($subtag, $default=false) {
$tag = $this->getTranslateTag($subtag);
$T = CustomDataTranslation::translate($tag);
return $T != $tag ? $T : ($default ?: $this->get($subtag));
}
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
function getEditForm($source=null) {
$fields = array(
'field' => $this,
'comments' => new TextareaField(array(
'id' => 2,
'label'=> '',
'required' => false,
'default' => '',
'configuration' => array(
'html' => true,
'size' => 'small',
'placeholder' => __('Optional reason for the update'),
)
))
);
return new SimpleForm($fields, $source);
}
function getChanges() {
$a = $this->to_database($this->getClean());
$b = $this->to_database($this->answer ? $this->answer->getValue() : $this->get('default'));
return ($a != $b) ? array($b, $a) : false;
}
function save() {
if (!($changes=$this->getChanges()))
return true;
if (!($a = $this->answer))
return false;
$val = $changes[1];
if (is_array($val)) {
$a->set('value', $val[0]);
$a->set('value_id', $val[1]);
} else {
$a->set('value', $val);
}
if (!$a->save(true))
return false;
return $this->parent->save();
}
static function init($config) {
return new Static($config);
}
}
class TextboxField extends FormField {
static $widget = 'TextboxWidget';
function getConfigurationOptions() {
return array(
'size' => new TextboxField(array(
'id'=>1, 'label'=>__('Size'), 'required'=>false, 'default'=>16,
'validator' => 'number')),
'length' => new TextboxField(array(
'id'=>2, 'label'=>__('Max Length'), 'required'=>false, 'default'=>30,
'validator' => 'number')),
'validator' => new ChoiceField(array(
'id'=>3, 'label'=>__('Validator'), 'required'=>false, 'default'=>'',
'choices' => array('phone'=>__('Phone Number'),'email'=>__('Email Address'),
'ip'=>__('IP Address'), 'number'=>__('Number'),
'regex'=>__('Custom (Regular Expression)'), ''=>__('None')))),
'regex' => new TextboxField(array(
'id'=>6, 'label'=>__('Regular Expression'), 'required'=>true,
'configuration'=>array('size'=>40, 'length'=>100),
'visibility' => new VisibilityConstraint(
new Q(array('validator__eq'=>'regex')),
VisibilityConstraint::HIDDEN
),
'cleaners' => function ($self, $value) {
$wrapped = "/".$value."/iu";
if (false === @preg_match($value, ' ')
&& false !== @preg_match($wrapped, ' ')) {
if ($value == '//iu')
return '';
return $value;
},
'validators' => function($self, $v) {
if (false === @preg_match($v, ' '))
$self->addError(__('Cannot compile this regular expression'));
})),
'validator-error' => new TextboxField(array(
'id'=>4, 'label'=>__('Validation Error'), 'default'=>'',
'configuration'=>array('size'=>40, 'length'=>60,
'translatable'=>$this->getTranslateTag('validator-error')
),
'hint'=>__('Message shown to user if the input does not match the validator'))),
'placeholder' => new TextboxField(array(
'id'=>5, 'label'=>__('Placeholder'), 'required'=>false, 'default'=>'',
'hint'=>__('Text shown in before any input from the user'),
'configuration'=>array('size'=>40, 'length'=>40,
'translatable'=>$this->getTranslateTag('placeholder')
),
);
}
function validateEntry($value) {
parent::validateEntry($value);
'email' => array(array('Validator', 'is_valid_email'),
__('Enter a valid email address')),
'phone' => array(array('Validator', 'is_phone'),
__('Enter a valid phone number')),
__('Enter a valid IP address')),
'number' => array('is_numeric', __('Enter a number')),
'regex' => array(
function($v) use ($config) {
$regex = $config['regex'];
return @preg_match($regex, $v);
}, __('Value does not match required pattern')
),
);
// Support configuration forms, as well as GUI-based form fields
$valid = $this->get('validator');
if (!$valid) {
$valid = $config['validator'];
}
if (!$value || !isset($validators[$valid]))
return;
$error = $func[1];
if ($config['validator-error'])
$error = $this->getLocal('validator-error', $config['validator-error']);
if (is_array($func) && is_callable($func[0]))
if (!call_user_func($func[0], $value))
function parse($value) {
return Format::striptags($value);
}
class PasswordField extends TextboxField {
static $widget = 'PasswordWidget';
function parse($value) {
// Don't trim the value
return $value;
}
// If not set in UI, don't save the empty value
if (!$value)
throw new FieldUnchanged();
return Crypto::encrypt($value, SECRET_SALT, 'pwfield');
return Crypto::decrypt($value, SECRET_SALT, 'pwfield');
static $widget = 'TextareaWidget';
function getConfigurationOptions() {
return array(
'cols' => new TextboxField(array(
'id'=>1, 'label'=>__('Width').' '.__('(chars)'), 'required'=>true, 'default'=>40)),
'id'=>2, 'label'=>__('Height').' '.__('(rows)'), 'required'=>false, 'default'=>4)),
'id'=>3, 'label'=>__('Max Length'), 'required'=>false, 'default'=>0)),
'id'=>4, 'label'=>__('HTML'), 'required'=>false, 'default'=>true,
'configuration'=>array('desc'=>__('Allow HTML input in this box')))),
'placeholder' => new TextboxField(array(
'id'=>5, 'label'=>__('Placeholder'), 'required'=>false, 'default'=>'',
'hint'=>__('Text shown in before any input from the user'),
'configuration'=>array('size'=>40, 'length'=>40,
'translatable'=>$this->getTranslateTag('placeholder')),
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
function validateEntry($value) {
parent::validateEntry($value);
$config = $this->getConfiguration();
$validators = array(
'' => null,
'choices' => array(
function($val) {
$val = str_replace('"', '', JsonDataEncoder::encode($val));
$regex = "/^(?! )[A-z0-9 _-]+:{1}[A-z0-9 _-]+$/";
foreach (explode('\r\n', $val) as $v) {
if (!preg_match($regex, $v))
return false;
}
return true;
}, __('Each choice requires a key and has to be on a new line. (eg. key:value)')
),
);
// Support configuration forms, as well as GUI-based form fields
$valid = $this->get('validator');
if (!$valid) {
$valid = $config['validator'];
}
if (!$value || !isset($validators[$valid]))
return;
$func = $validators[$valid];
$error = $func[1];
if ($config['validator-error'])
$error = $this->getLocal('validator-error', $config['validator-error']);
if (is_array($func) && is_callable($func[0]))
if (!call_user_func($func[0], $value))
$this->_errors[] = $error;
}
function display($value) {
$config = $this->getConfiguration();
if ($config['html'])
return Format::safe_html($value);
else
return nl2br(Format::htmlchars($value));
$body = new HtmlThreadEntryBody($value);
return $body->getSearchable();
function export($value) {
return (!$value) ? $value : Format::html2text($value);
}
function parse($value) {
$config = $this->getConfiguration();
if ($config['html'])
return Format::sanitize($value);
else
return $value;
}
}
class PhoneField extends FormField {
static $widget = 'PhoneNumberWidget';
function getConfigurationOptions() {
return array(
'ext' => new BooleanField(array(
'label'=>__('Extension'), 'default'=>true,
'desc'=>__('Add a separate field for the extension'),
),
)),
'digits' => new TextboxField(array(
'label'=>__('Minimum length'), 'default'=>7,
'hint'=>__('Fewest digits allowed in a valid phone number'),
'configuration'=>array('validator'=>'number', 'size'=>5),
)),
'format' => new ChoiceField(array(
'label'=>__('Display format'), 'default'=>'us',
'choices'=>array(''=>'-- '.__('Unformatted').' --',
'us'=>__('United States')),
function validateEntry($value) {
parent::validateEntry($value);
$config = $this->getConfiguration();
# Run validator against $this->value for email type
list($phone, $ext) = explode("X", $value, 2);
if ($phone && (
!is_numeric($phone) ||
strlen($phone) < $config['digits']))
$this->_errors[] = __("Enter a valid phone number");
$this->_errors[] = __("Enter a valid phone extension");
$this->_errors[] = __("Enter a phone number for the extension");
function parse($value) {
// NOTE: Value may have a legitimate 'X' to separate the number and
// extension parts. Don't remove the 'X'
$val = preg_replace('/[^\dX]/', '', $value);
// Pass completely-incorrect string for validation error
return $val ?: $value;
$config = $this->getConfiguration();
list($phone, $ext) = explode("X", $value, 2);
switch ($config['format']) {
case 'us':
$phone = Format::phone($phone);
break;
}
if ($ext)
$phone.=" x$ext";
return $phone;
}
}
class BooleanField extends FormField {
static $widget = 'CheckboxWidget';
function getConfigurationOptions() {
return array(
'desc' => new TextareaField(array(
'id'=>1, 'label'=>__('Description'), 'required'=>false, 'default'=>'',
'hint'=>__('Text shown inline with the widget'),
'configuration'=>array('rows'=>2)))
);
}
function to_database($value) {
return ($value) ? '1' : '0';
}
function parse($value) {
return $this->to_php($value);
}
return ($value) ? __('Yes') : __('No');
function getSearchMethods() {
return array(
'set' => __('checked'),
'nset' => __('unchecked'),
);
}
function getSearchMethodWidgets() {
return array(
'set' => null,
function getSearchQ($method, $value, $name=false) {
$name = $name ?: $this->get('name');
switch ($method) {
case 'set':
return new Q(array($name => '1'));
return new Q(array($name => '0'));
default:
return parent::getSearchQ($method, $value, $name);
}
}
function supportsQuickFilter() {
return true;
}
function getQuickFilterChoices() {
return array(
true => __('Checked'),
false => __('Not Checked'),
);
}
function applyQuickFilter($query, $qf_value, $name=false) {
return $query->filter(array(
$name ?: $this->get('name') => (int) $qf_value,
));
}
}
class ChoiceField extends FormField {
static $widget = 'ChoicesWidget';
function getConfigurationOptions() {
return array(
'choices' => new TextareaField(array(
'id'=>1, 'label'=>__('Choices'), 'required'=>false, 'default'=>'',
'hint'=>__('List choices, one per line. To protect against spelling changes, specify key:value names to preserve entries if the list item names change.</br><b>Note:</b> If you have more than two choices, use a List instead.'),
'validator'=>'choices',
'configuration'=>array('html'=>false)
)),
'default' => new TextboxField(array(
'id'=>3, 'label'=>__('Default'), 'required'=>false, 'default'=>'',
'hint'=>__('(Enter a key). Value selected from the list initially'),
'configuration'=>array('size'=>20, 'length'=>40),
)),
'prompt' => new TextboxField(array(
'id'=>2, 'label'=>__('Prompt'), 'required'=>false, 'default'=>'',
'hint'=>__('Leading text shown before a value is selected'),
'configuration'=>array('size'=>40, 'length'=>40,
'translatable'=>$this->getTranslateTag('prompt'),
),
'multiselect' => new BooleanField(array(
'id'=>1, 'label'=>'Multiselect', 'required'=>false, 'default'=>false,
'configuration'=>array(
'desc'=>'Allow multiple selections')
)),
return $this->to_php($value ?: null);
}
function to_database($value) {
if (!is_array($value)) {
$choices = $this->getChoices();
if (isset($choices[$value]))
$value = array($value => $choices[$value]);
}
if (is_array($value))
return $value;
}
function to_php($value) {
if (is_string($value))
$value = JsonDataParser::parse($value) ?: $value;
// CDATA table may be built with comma-separated key,value,key,value
$values = array();
$choices = $this->getChoices();
if (isset($choices[$V]))
$values[$V] = $choices[$V];
if (array_filter($values))
$value = $values;
$config = $this->getConfiguration();
if (!$config['multiselect'] && is_array($value) && count($value) < 2) {
reset($value);
if (!is_array($value))
$value = $this->getChoice($value);
if (is_array($value))
return implode(', ', $value);
return (string) $value;
function getKeys($value) {
if (!is_array($value))
$value = $this->getChoice($value);
if (is_array($value))
return implode(', ', array_keys($value));
return (string) $value;
}
function whatChanged($before, $after) {
$B = (array) $before;
$A = (array) $after;
$added = array_diff($A, $B);
$deleted = array_diff($B, $A);
$added = array_map(array($this, 'display'), $added);
$deleted = array_map(array($this, 'display'), $deleted);
if ($added && $deleted) {
$desc = sprintf(
__('added <strong>%1$s</strong> and removed <strong>%2$s</strong>'),
implode(', ', $added), implode(', ', $deleted));
}
elseif ($added) {
$desc = sprintf(
__('added <strong>%1$s</strong>'),
implode(', ', $added));
}
elseif ($deleted) {
$desc = sprintf(
__('removed <strong>%1$s</strong>'),
implode(', ', $deleted));
}
else {
$desc = sprintf(
__('changed from <strong>%1$s</strong> to <strong>%2$s</strong>'),
$this->display($before), $this->display($after));
}
return $desc;
}
/*
Return criteria to which the choice should be filtered by
*/
function getCriteria() {
$config = $this->getConfiguration();
$criteria = array();
if (isset($config['criteria']))
$criteria = $config['criteria'];
return $criteria;
}
$selection = array();
if ($value && is_array($value)) {
} elseif (isset($choices[$value]))
$selection[] = $choices[$value];
elseif ($this->get('default'))
$selection[] = $choices[$this->get('default')];
return $selection;
function getChoices($verbose=false) {
if ($this->_choices === null || $verbose) {
// Allow choices to be set in this->ht (for configurationOptions)
$this->_choices = $this->get('choices');
if (!$this->_choices) {
$this->_choices = array();
$config = $this->getConfiguration();
$choices = explode("\n", $config['choices']);
foreach ($choices as $choice) {
// Allow choices to be key: value
list($key, $val) = explode(':', $choice);
if ($val == null)
$val = $key;
$this->_choices[trim($key)] = trim($val);
}
// Add old selections if nolonger available
// This is necessary so choices made previously can be
// retained
$values = ($a=$this->getAnswer()) ? $a->getValue() : array();
if ($values && is_array($values)) {
foreach ($values as $k => $v) {
if (!isset($this->_choices[$k])) {
if ($verbose) $v .= ' (retired)';
$this->_choices[$k] = $v;
}
}
}
}
}
return $this->_choices;
function lookupChoice($value) {
return null;
}
function getSearchMethods() {
return array(
'set' => __('has a value'),
'nset' => __('does not have a value'),
'includes' => __('includes'),
'!includes' => __('does not include'),
function getSearchMethodWidgets() {
return array(
'set' => null,
'includes' => array('ChoiceField', array(
'choices' => $this->getChoices(),
'configuration' => array('multiselect' => true),
)),
'!includes' => array('ChoiceField', array(
'choices' => $this->getChoices(),
'configuration' => array('multiselect' => true),
)),
function getSearchQ($method, $value, $name=false) {
$name = $name ?: $this->get('name');
$val = $value;
if ($value && is_array($value))
$val = '"?'.implode('("|,|$)|"?', array_keys($value)).'("|,|$)';
switch ($method) {
case '!includes':
return Q::not(array("{$name}__regex" => $val));
return new Q(array("{$name}__regex" => $val));
default:
return parent::getSearchQ($method, $value, $name);
}
}
function describeSearchMethod($method) {
switch ($method) {
case 'includes':
return __('%s includes %s' /* includes -> if a list includes a selection */);
return __('%s does not include %s' /* includes -> if a list includes a selection */);
default:
return parent::describeSearchMethod($method);
}
}
function supportsQuickFilter() {
return true;
}
function getQuickFilterChoices() {
return $this->getChoices();
}
function applyQuickFilter($query, $qf_value, $name=false) {
return $query->filter(array(
$name ?: $this->get('name') => $qf_value,
));
}
}
class DatetimeField extends FormField {
static $widget = 'DatetimePickerWidget';
var $min = null;
var $max = null;
// Get php DatateTime object of the field - null if value is empty
function getDateTime($value=null) {
return Format::parseDateTime($value ?: $this->value);
}
// Get effective timezone for the field
function getTimeZone() {
global $cfg;
$config = $this->getConfiguration();
$timezone = new DateTimeZone($config['timezone'] ?:
$cfg->getTimezone());
return $timezone;
}
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
function getMinDateTime() {
if (!isset($this->min)) {
$config = $this->getConfiguration();
$this->min = $config['min']
? Format::parseDateTime($config['min']) : false;
}
return $this->min;
}
function getMaxDateTime() {
if (!isset($this->max)) {
$config = $this->getConfiguration();
$this->max = $config['max']
? Format::parseDateTime($config['max']) : false;
}
return $this->max;
}
// Store time in format given by Date Picker (DateTime::W3C)
return $value;
if (strtotime($value) <= 0)
return 0;
return $value;
if (!$value || !($datetime = Format::parseDateTime($value)))
return '';
$config = $this->getConfiguration();
if ($config['gmt'])
return $this->format((int) $datetime->format('U'));
// Force timezone if field has one.
if ($config['timezone']) {