001/* 002 * Copyright 2010 Anyware Services 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.ametys.web.site; 017 018import java.util.ArrayList; 019import java.util.Arrays; 020import java.util.Collection; 021import java.util.Collections; 022import java.util.Comparator; 023import java.util.Date; 024import java.util.HashMap; 025import java.util.HashSet; 026import java.util.Iterator; 027import java.util.LinkedHashMap; 028import java.util.List; 029import java.util.Map; 030import java.util.Set; 031import java.util.TreeMap; 032import java.util.regex.Pattern; 033 034import org.apache.avalon.framework.configuration.Configuration; 035import org.apache.avalon.framework.configuration.ConfigurationException; 036import org.apache.avalon.framework.service.ServiceException; 037import org.apache.avalon.framework.service.ServiceManager; 038import org.apache.commons.lang.StringUtils; 039 040import org.ametys.core.util.I18nUtils; 041import org.ametys.plugins.repository.AmetysRepositoryException; 042import org.ametys.plugins.repository.UnknownAmetysObjectException; 043import org.ametys.plugins.repository.metadata.UnknownMetadataException; 044import org.ametys.runtime.i18n.I18nizableText; 045import org.ametys.runtime.parameter.AbstractParameterParser; 046import org.ametys.runtime.parameter.Enumerator; 047import org.ametys.runtime.parameter.Errors; 048import org.ametys.runtime.parameter.ParameterHelper; 049import org.ametys.runtime.parameter.ParameterHelper.ParameterType; 050import org.ametys.runtime.parameter.Validator; 051import org.ametys.runtime.plugin.component.AbstractThreadSafeComponentExtensionPoint; 052import org.ametys.runtime.plugin.component.ThreadSafeComponentManager; 053import org.ametys.web.repository.site.Site; 054import org.ametys.web.repository.site.SiteManager; 055import org.ametys.web.repository.site.SiteType; 056import org.ametys.web.repository.site.SiteTypesExtensionPoint; 057 058/** 059 * Extension point holding all {@link SiteParameter} definitions. 060 */ 061public class SiteConfigurationExtensionPoint extends AbstractThreadSafeComponentExtensionPoint<SiteParameter> 062{ 063 /** Avalon Role */ 064 public static final String ROLE = SiteConfigurationExtensionPoint.class.getName(); 065 066 private static final Pattern __PARAM_NAME_PATTERN = Pattern.compile("[a-z][a-z0-9-_]*", Pattern.CASE_INSENSITIVE); 067 068 private static final String __GENERAL_INFORMATIONS_I18N_KEY = "PLUGINS_WEB_SITE_INFORMATION_CATEGORY"; 069 070 /** The site manager. */ 071 protected SiteManager _siteManager; 072 073 /** The site type extension point. */ 074 protected SiteTypesExtensionPoint _siteTypeEP; 075 076 /** Component gathering utility methods for internationalizable text translation {@link I18nUtils} */ 077 protected I18nUtils _i18nUtils; 078 079 /** Parameters map, indexed by parameter ID. */ 080 private Map<String, SiteParameter> _parameters; 081 082 /** Determines if all parameters are valued, by site. */ 083 private Map<String, Boolean> _isComplete; 084 085 /** ComponentManager for {@link Validator}s. */ 086 private ThreadSafeComponentManager<Validator> _validatorManager; 087 088 /** ComponentManager for {@link Enumerator}s. */ 089 private ThreadSafeComponentManager<Enumerator> _enumeratorManager; 090 091 /** Site parameter parser. */ 092 private SiteParameterParser _parameterParser; 093 094 095 @Override 096 public void initialize() throws Exception 097 { 098 super.initialize(); 099 100 _parameters = new LinkedHashMap<>(); 101 _isComplete = new HashMap<>(); 102 103 _validatorManager = new ThreadSafeComponentManager<>(); 104 _validatorManager.setLogger(getLogger()); 105 _validatorManager.contextualize(_context); 106 _validatorManager.service(_cocoonManager); 107 108 _enumeratorManager = new ThreadSafeComponentManager<>(); 109 _enumeratorManager.setLogger(getLogger()); 110 _enumeratorManager.contextualize(_context); 111 _enumeratorManager.service(_cocoonManager); 112 113 _parameterParser = new SiteParameterParser(_enumeratorManager, _validatorManager); 114 } 115 116 @Override 117 public void service(ServiceManager manager) throws ServiceException 118 { 119 super.service(manager); 120 _siteManager = (SiteManager) manager.lookup(SiteManager.ROLE); 121 _siteTypeEP = (SiteTypesExtensionPoint) manager.lookup(SiteTypesExtensionPoint.ROLE); 122 _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE); 123 } 124 125 /** 126 * Dispose the manager before restarting it 127 */ 128 @Override 129 public void dispose() 130 { 131 _isComplete = new HashMap<>(); 132 133 _parameterParser = null; 134 135 _parameters = null; 136 _validatorManager.dispose(); 137 _validatorManager = null; 138 _enumeratorManager.dispose(); 139 _enumeratorManager = null; 140 141 super.dispose(); 142 } 143 144 @Override 145 public boolean hasExtension(String id) 146 { 147 return _parameters.containsKey(id); 148 } 149 150 @Override 151 public void addExtension(String id, String pluginName, String featureName, Configuration configuration) throws ConfigurationException 152 { 153 if (getLogger().isDebugEnabled()) 154 { 155 getLogger().debug("Adding site parameters from feature " + pluginName + "/" + featureName); 156 } 157 158 Configuration[] parameterConfigurations = configuration.getChildren("param"); 159 for (Configuration parameterConfiguration : parameterConfigurations) 160 { 161 _addParameter(pluginName, featureName, parameterConfiguration); 162 } 163 } 164 165 @Override 166 public SiteParameter getExtension(String id) 167 { 168 return _parameters.get(id); 169 } 170 171 @Override 172 public Set<String> getExtensionsIds() 173 { 174 return Collections.unmodifiableSet(_parameters.keySet()); 175 } 176 177 @Override 178 public void initializeExtensions() throws Exception 179 { 180 super.initializeExtensions(); 181 _parameterParser.lookupComponents(); 182 } 183 184 /** 185 * Test if a site configuration is valid. 186 * @param siteName the site name. 187 * @return true if the site configuration is valid, false otherwise. 188 * @throws UnknownAmetysObjectException if the site doesn't exist. 189 */ 190 public boolean isValid(String siteName) throws UnknownAmetysObjectException 191 { 192 if (siteName == null) 193 { 194 throw new IllegalArgumentException("Cannot determine if a null siteName is valid or not"); 195 } 196 197 // Check if the site exists. 198 if (!_siteManager.hasSite(siteName)) 199 { 200 throw new UnknownAmetysObjectException ("Unknown site '" + siteName + "'. Can not check configuration."); 201 } 202 203 // Validate the site configuration now if it's not already done. 204 if (!_isComplete.containsKey(siteName)) 205 { 206 _isComplete.put(siteName, _validateSiteConfig(siteName)); 207 } 208 209 return _isComplete.get(siteName); 210 } 211 212 /** 213 * Reload a site's configuration. 214 * @param siteName the site name. 215 * @throws UnknownAmetysObjectException if the site doesn't exist. 216 */ 217 public void reloadConfiguration(String siteName) throws UnknownAmetysObjectException 218 { 219 // Check if the site exists. 220 _siteManager.getSite(siteName); 221 222 // Reload the site configuration. 223 _isComplete.put(siteName, _validateSiteConfig(siteName)); 224 } 225 226 /** 227 * Remove a site's configuration. 228 * @param siteName the site name. 229 */ 230 public void removeConfiguration(String siteName) 231 { 232 if (_isComplete.containsKey(siteName)) 233 { 234 _isComplete.remove(siteName); 235 } 236 } 237 238 /** 239 * Get all the parameters for a given site. 240 * @param siteName the site name. 241 * @return the parameters 242 */ 243 public Map<String, SiteParameter> getParameters(String siteName) 244 { 245 Map<String, SiteParameter> siteParams = new LinkedHashMap<>(); 246 247 Site site = _siteManager.getSite(siteName); 248 SiteType siteType = _siteTypeEP.getExtension(site.getType()); 249 250 if (siteType == null) 251 { 252 throw new IllegalStateException("The site '" + siteName + "' is using un unknown type '" + site.getType() + "'"); 253 } 254 255 for (SiteParameter parameter : _parameters.values()) 256 { 257 if (parameter.isInSiteType(siteType.getName())) 258 { 259 siteParams.put(parameter.getId(), parameter); 260 } 261 } 262 263 return siteParams; 264 } 265 266 /** 267 * Get all the parameters of a given site, classified by category and group. 268 * @param siteName the site name. 269 * @return the parameters classified by category and group. 270 */ 271 public Map<I18nizableText, Map<I18nizableText, List<SiteParameter>>> getCategorizedParameters(String siteName) 272 { 273 return _categorize(getParameters(siteName).values()); 274 } 275 276 /** 277 * Return the untyped value of a site parameter as String. 278 * @param siteName the site name. 279 * @param id Id of the parameter to get. 280 * @return the typed value as String, the default value or null if the parameter does not exist. 281 * @throws UnknownAmetysObjectException if the site does not exist. 282 * @throws AmetysRepositoryException if another repository error occurs. 283 */ 284 public String getUntypedValue (String siteName, String id) throws UnknownAmetysObjectException, AmetysRepositoryException 285 { 286 SiteParameter siteParameter = _parameters.get(id); 287 if (siteParameter == null) 288 { 289 getLogger().warn(String.format("Unable to get site parameter of id '%s' for site '%s': it is not a site's parameter", id, siteName)); 290 return null; 291 } 292 293 Site site = _siteManager.getSite(siteName); 294 try 295 { 296 return site.getMetadataHolder().getString(id); 297 } 298 catch (UnknownMetadataException e) 299 { 300 return ParameterHelper.valueToString(siteParameter.getDefaultValue()); 301 } 302 } 303 304 /** 305 * Return the value of a site parameter. 306 * @param siteName the site name. 307 * @param id Id of the parameter to get. 308 * @return the typed value as String, the default value or null if the parameter does not exist. 309 * @throws UnknownAmetysObjectException if the site does not exist. 310 * @throws AmetysRepositoryException if another repository error occurs. 311 */ 312 public Object getValue (String siteName, String id) throws UnknownAmetysObjectException, AmetysRepositoryException 313 { 314 SiteParameter siteParameter = _parameters.get(id); 315 if (siteParameter == null) 316 { 317 getLogger().warn(String.format("Unable to get site parameter of id '%s' for site '%s': it is not a site's parameter", id, siteName)); 318 return null; 319 } 320 321 Site site = _siteManager.getSite(siteName); 322 try 323 { 324 String untypedValue = site.getMetadataHolder().getString(id); 325 return ParameterHelper.castValue(untypedValue, siteParameter.getType()); 326 } 327 catch (UnknownMetadataException e) 328 { 329 return siteParameter.getDefaultValue(); 330 } 331 } 332 333 /** 334 * Return the typed value as String. 335 * @param siteName the site name. 336 * @param id Id of the parameter to get. 337 * @return the typed value as String, the default value or null if the parameter does not exist. 338 * @throws UnknownAmetysObjectException if the site does not exist. 339 * @throws AmetysRepositoryException if another repository error occurs. 340 */ 341 public String getValueAsString(String siteName, String id) throws UnknownAmetysObjectException, AmetysRepositoryException 342 { 343 SiteParameter siteParameter = _parameters.get(id); 344 if (siteParameter == null || siteParameter.getType() != ParameterType.STRING) 345 { 346 getLogger().warn(String.format("Unable to get site parameter of id '%s' for site '%s': it is not a site's parameter or it is not of type STRING.", id, siteName)); 347 return null; 348 } 349 350 Site site = _siteManager.getSite(siteName); 351 try 352 { 353 return site.getMetadataHolder().getString(id); 354 } 355 catch (UnknownMetadataException e) 356 { 357 return (String) siteParameter.getDefaultValue(); 358 } 359 } 360 361 /** 362 * Return the typed value as Date. 363 * @param siteName the site name. 364 * @param id Id of the parameter to get. 365 * @return the typed value as Date, the default value or null if the parameter does not exist. 366 * @throws UnknownAmetysObjectException if the site does not exist. 367 * @throws AmetysRepositoryException if another repository error occurs. 368 */ 369 public Date getValueAsDate(String siteName, String id) throws UnknownAmetysObjectException, AmetysRepositoryException 370 { 371 SiteParameter siteParameter = _parameters.get(id); 372 if (siteParameter == null || siteParameter.getType() != ParameterType.DATE) 373 { 374 getLogger().warn(String.format("Unable to get site parameter of id '%s' for site '%s': it is not a site's parameter or it is not of type DATE.", id, siteName)); 375 return null; 376 } 377 378 Site site = _siteManager.getSite(siteName); 379 try 380 { 381 return site.getMetadataHolder().getDate(id); 382 } 383 catch (UnknownMetadataException e) 384 { 385 return (Date) siteParameter.getDefaultValue(); 386 } 387 } 388 389 /** 390 * Return the typed value as long. 391 * @param siteName the site name. 392 * @param id Id of the parameter to get. 393 * @return the typed value as a Long, the default value or null if the parameter does not exist. 394 * @throws UnknownAmetysObjectException if the site does not exist. 395 * @throws AmetysRepositoryException if another repository error occurs. 396 */ 397 public Long getValueAsLong(String siteName, String id) throws UnknownAmetysObjectException, AmetysRepositoryException 398 { 399 SiteParameter siteParameter = _parameters.get(id); 400 if (siteParameter == null || siteParameter.getType() != ParameterType.LONG) 401 { 402 getLogger().warn(String.format("Unable to get site parameter of id '%s' for site '%s': it is not a site's parameter or it is not of type LONG.", id, siteName)); 403 return null; 404 } 405 406 Site site = _siteManager.getSite(siteName); 407 try 408 { 409 return site.getMetadataHolder().getLong(id); 410 } 411 catch (UnknownMetadataException e) 412 { 413 return (Long) siteParameter.getDefaultValue(); 414 } 415 } 416 417 /** 418 * Return the typed value as boolean. 419 * @param siteName the site name. 420 * @param id Id of the parameter to get 421 * @return the typed value as a Boolean, the default value or null if the parameter does not exist. 422 * @throws UnknownAmetysObjectException if the site does not exist. 423 * @throws AmetysRepositoryException if another repository error occurs. 424 */ 425 public Boolean getValueAsBoolean(String siteName, String id) throws UnknownAmetysObjectException, AmetysRepositoryException 426 { 427 SiteParameter siteParameter = _parameters.get(id); 428 if (siteParameter == null || siteParameter.getType() != ParameterType.BOOLEAN) 429 { 430 getLogger().warn(String.format("Unable to get site parameter of id '%s' for site '%s': it is not a site's parameter or it is not of type BOOLEAN.", id, siteName)); 431 return null; 432 } 433 434 Site site = _siteManager.getSite(siteName); 435 try 436 { 437 return site.getMetadataHolder().getBoolean(id); 438 } 439 catch (UnknownMetadataException e) 440 { 441 return (Boolean) siteParameter.getDefaultValue(); 442 } 443 } 444 445 /** 446 * Return the typed value casted as double. 447 * @param siteName the site name. 448 * @param id Id of the parameter to get. 449 * @return the typed value as a Double, the default value or null if the parameter does not exist. 450 * @throws UnknownAmetysObjectException if the site does not exist. 451 * @throws AmetysRepositoryException if another repository error occurs. 452 */ 453 public Double getValueAsDouble(String siteName, String id) throws UnknownAmetysObjectException, AmetysRepositoryException 454 { 455 SiteParameter siteParameter = _parameters.get(id); 456 if (siteParameter == null || siteParameter.getType() != ParameterType.DOUBLE) 457 { 458 getLogger().warn(String.format("Unable to get site parameter of id '%s' for site '%s': it is not a site's parameter or it is not of type DOUBLE.", id, siteName)); 459 return null; 460 } 461 462 Site site = _siteManager.getSite(siteName); 463 try 464 { 465 return site.getMetadataHolder().getDouble(id); 466 } 467 catch (UnknownMetadataException e) 468 { 469 return (Double) siteParameter.getDefaultValue(); 470 } 471 } 472 473 /** 474 * Declare a site parameter. 475 * @param pluginName The name of the plugin declaring the extension. 476 * @param featureName the name of the feature 477 * @param configuration The parameter configuration. 478 * @throws ConfigurationException if configuration if not complete. 479 */ 480 protected void _addParameter(String pluginName, String featureName, Configuration configuration) throws ConfigurationException 481 { 482 SiteParameter parameter = _parameterParser.parseParameter(_cocoonManager, pluginName, configuration); 483 String id = parameter.getId(); 484 485 if (!__PARAM_NAME_PATTERN.matcher(id).matches()) 486 { 487 throw new ConfigurationException("The feature " + pluginName + "/" + featureName + " declared an invalid site parameter name '" + id + "'. This value is not permited: only [a-zA-Z][a-zA-Z0-9-_]* are allowed.", configuration); 488 } 489 490 if (_parameters.containsKey(id)) 491 { 492 throw new ConfigurationException("In feature " + pluginName + "/" + featureName + " the parameter '" + id + "' is already declared. Parameter ids must be unique.", configuration); 493 } 494 495 _parameters.put(id, parameter); 496 497 if (getLogger().isDebugEnabled()) 498 { 499 getLogger().debug("Site parameter added: " + id); 500 } 501 } 502 503 /** 504 * Validate the configuration of a site. 505 * @param siteName the name of the site to check. 506 * @return true if the site is correctly configured, false otherwise. 507 */ 508 protected boolean _validateSiteConfig(String siteName) 509 { 510 if (getLogger().isDebugEnabled()) 511 { 512 getLogger().debug("Validating the configuration of site '" + siteName + "'"); 513 } 514 515 boolean siteValid = true; 516 517 Iterator<SiteParameter> params = getParameters(siteName).values().iterator(); 518 while (params.hasNext() && siteValid) 519 { 520 siteValid = _validateParameter(params.next(), siteName); 521 } 522 523 return siteValid; 524 } 525 526 /** 527 * Validate a parameter value for a given site. 528 * @param parameter the site parameter to validate. 529 * @param siteName the site name on which to check. 530 * @return true if the parameter's value is valid, false otherwise. 531 */ 532 protected boolean _validateParameter(SiteParameter parameter, String siteName) 533 { 534 String parameterId = parameter.getId(); 535 536 Object typedValue = getValue(siteName, parameterId); 537 538 Validator v = parameter.getValidator(); 539 Errors validationErrors = new Errors(); 540 if (v != null) 541 { 542 v.validate(typedValue != null ? typedValue : "", validationErrors); 543 } 544 545 if (validationErrors.hasErrors()) 546 { 547 if (getLogger().isWarnEnabled()) 548 { 549 StringBuffer sb = new StringBuffer(); 550 sb.append("The parameter '").append(parameterId).append("' of site '").append(siteName).append("' is not valid with value '").append(ParameterHelper.valueToString(typedValue)).append("' :"); 551 for (I18nizableText error : validationErrors.getErrors()) 552 { 553 sb.append("\n* " + error.toString()); 554 } 555 sb.append("\nConfiguration is not initialized"); 556 557 getLogger().warn(sb.toString()); 558 } 559 560 return false; 561 } 562 563 return true; 564 } 565 566 /** 567 * Organize a collection of site parameters by categories and groups. 568 * @param parameters a collection of site parameters. 569 * @return a Map of parameters sorted first by category then group. 570 */ 571 protected Map<I18nizableText, Map<I18nizableText, List<SiteParameter>>> _categorize(Collection<SiteParameter> parameters) 572 { 573 Map<I18nizableText, Map<I18nizableText, List<SiteParameter>>> categories = new TreeMap<>(new I18nizableTextTranslationComparator()); 574 575 // Classify parameters by groups and categories 576 for (SiteParameter parameter : parameters) 577 { 578 I18nizableText categoryName = parameter.getDisplayCategory(); 579 I18nizableText groupName = parameter.getDisplayGroup(); 580 581 // Get the map of groups of the category 582 Map<I18nizableText, List<SiteParameter>> category = categories.get(categoryName); 583 if (category == null) 584 { 585 category = new TreeMap<>(new I18nizableTextComparator()); 586 categories.put(categoryName, category); 587 } 588 589 // Get the map of parameters of the group 590 List<SiteParameter> group = category.get(groupName); 591 if (group == null) 592 { 593 group = new ArrayList<>(); 594 category.put(groupName, group); 595 } 596 597 group.add(parameter); 598 } 599 600 return categories; 601 } 602 603 class I18nizableTextComparator implements Comparator<I18nizableText> 604 { 605 @Override 606 public int compare(I18nizableText t1, I18nizableText t2) 607 { 608 String str1 = t1.isI18n() ? t1.getKey() : t1.getLabel(); 609 String str2 = t2.isI18n() ? t2.getKey() : t2.getLabel(); 610 611 return str1.toString().compareTo(str2.toString()); 612 } 613 } 614 615 class I18nizableTextTranslationComparator implements Comparator<I18nizableText> 616 { 617 @Override 618 public int compare(I18nizableText t1, I18nizableText t2) 619 { 620 // The general informations category always goes first 621 if (t1.getKey().equals(__GENERAL_INFORMATIONS_I18N_KEY)) 622 { 623 if (t2.getKey().equals(__GENERAL_INFORMATIONS_I18N_KEY)) 624 { 625 return 0; 626 } 627 return -1; 628 } 629 630 if (t2.getKey().equals(__GENERAL_INFORMATIONS_I18N_KEY)) 631 { 632 return 1; 633 } 634 635 String tt1 = _i18nUtils.translate(t1); 636 if (tt1 == null) 637 { 638 return -1; 639 } 640 641 String tt2 = _i18nUtils.translate(t2); 642 if (tt2 == null) 643 { 644 return 1; 645 } 646 647 return tt1.compareTo(tt2); 648 } 649 } 650 651 /** 652 * Parser for SiteParameter. 653 */ 654 class SiteParameterParser extends AbstractParameterParser<SiteParameter, ParameterType> 655 { 656 public SiteParameterParser(ThreadSafeComponentManager<Enumerator> enumeratorManager, ThreadSafeComponentManager<Validator> validatorManager) 657 { 658 super(enumeratorManager, validatorManager); 659 } 660 661 @Override 662 protected SiteParameter _createParameter(Configuration parameterConfig) throws ConfigurationException 663 { 664 return new SiteParameter(); 665 } 666 667 @Override 668 protected String _parseId(Configuration parameterConfig) throws ConfigurationException 669 { 670 return parameterConfig.getAttribute("id"); 671 } 672 673 @Override 674 protected ParameterType _parseType(Configuration parameterConfig) throws ConfigurationException 675 { 676 try 677 { 678 return ParameterType.valueOf(parameterConfig.getAttribute("type").toUpperCase()); 679 } 680 catch (IllegalArgumentException e) 681 { 682 throw new ConfigurationException("Invalid parameter type", parameterConfig, e); 683 } 684 } 685 686 @Override 687 protected Object _parseDefaultValue(Configuration parameterConfig, SiteParameter parameter) 688 { 689 String value; 690 691 Configuration childNode = parameterConfig.getChild("default-value", false); 692 if (childNode == null) 693 { 694 value = null; 695 } 696 else 697 { 698 value = childNode.getValue(""); 699 } 700 701 return ParameterHelper.castValue(value, parameter.getType()); 702 } 703 704 @Override 705 protected void _additionalParsing(ServiceManager manager, String pluginName, Configuration parameterConfig, String parameterId, SiteParameter parameter) throws ConfigurationException 706 { 707 super._additionalParsing(manager, pluginName, parameterConfig, parameterId, parameter); 708 709 parameter.setId(parameterId); 710 parameter.setDisplayCategory(_parseI18nizableText(parameterConfig, pluginName, "category")); 711 parameter.setDisplayGroup(_parseI18nizableText(parameterConfig, pluginName, "group")); 712 713 Set<String> siteTypes = new HashSet<>(); 714 String siteTypesStr = parameterConfig.getChild("site-types").getValue(""); 715 if (StringUtils.isNotEmpty(siteTypesStr)) 716 { 717 siteTypes.addAll(Arrays.asList(StringUtils.split(siteTypesStr, ", "))); 718 parameter.setSiteTypes(siteTypes); 719 } 720 } 721 } 722 723}