001/* 002 * Copyright 2018 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.plugins.ugc.page; 017 018import java.util.ArrayList; 019import java.util.Arrays; 020import java.util.Comparator; 021import java.util.HashMap; 022import java.util.List; 023import java.util.Map; 024import java.util.Optional; 025import java.util.Set; 026import java.util.stream.Collectors; 027 028import javax.jcr.Node; 029import javax.jcr.RepositoryException; 030import javax.jcr.Value; 031 032import org.apache.avalon.framework.component.Component; 033import org.apache.avalon.framework.context.Context; 034import org.apache.avalon.framework.context.ContextException; 035import org.apache.avalon.framework.context.Contextualizable; 036import org.apache.avalon.framework.service.ServiceException; 037import org.apache.avalon.framework.service.ServiceManager; 038import org.apache.avalon.framework.service.Serviceable; 039import org.apache.cocoon.components.ContextHelper; 040import org.apache.cocoon.environment.Request; 041import org.apache.commons.lang3.StringUtils; 042import org.apache.jackrabbit.value.StringValue; 043import org.slf4j.Logger; 044 045import org.ametys.cms.contenttype.ContentAttributeDefinition; 046import org.ametys.cms.contenttype.ContentType; 047import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 048import org.ametys.cms.contenttype.ContentTypesHelper; 049import org.ametys.cms.data.ContentValue; 050import org.ametys.cms.repository.Content; 051import org.ametys.cms.repository.ContentTypeExpression; 052import org.ametys.cms.repository.LanguageExpression; 053import org.ametys.core.observation.Event; 054import org.ametys.core.observation.ObservationManager; 055import org.ametys.core.user.CurrentUserProvider; 056import org.ametys.core.util.I18nUtils; 057import org.ametys.core.util.LambdaUtils; 058import org.ametys.plugins.repository.AmetysObjectIterable; 059import org.ametys.plugins.repository.AmetysObjectResolver; 060import org.ametys.plugins.repository.AmetysRepositoryException; 061import org.ametys.plugins.repository.UnknownAmetysObjectException; 062import org.ametys.plugins.repository.jcr.JCRAmetysObject; 063import org.ametys.plugins.repository.query.QueryHelper; 064import org.ametys.plugins.repository.query.SortCriteria; 065import org.ametys.plugins.repository.query.expression.AndExpression; 066import org.ametys.plugins.repository.query.expression.Expression; 067import org.ametys.plugins.repository.query.expression.Expression.Operator; 068import org.ametys.plugins.repository.query.expression.ExpressionContext; 069import org.ametys.plugins.ugc.observation.ObservationConstants; 070import org.ametys.plugins.repository.query.expression.MetadataExpression; 071import org.ametys.plugins.repository.query.expression.NotExpression; 072import org.ametys.plugins.repository.query.expression.OrExpression; 073import org.ametys.plugins.repository.query.expression.StringExpression; 074import org.ametys.plugins.repository.query.expression.VirtualFactoryExpression; 075import org.ametys.runtime.model.ElementDefinition; 076import org.ametys.runtime.model.Enumerator; 077import org.ametys.runtime.model.ModelItem; 078import org.ametys.runtime.plugin.component.AbstractLogEnabled; 079import org.ametys.web.WebConstants; 080import org.ametys.web.WebHelper; 081import org.ametys.web.repository.SiteAwareAmetysObject; 082import org.ametys.web.repository.page.ModifiablePage; 083import org.ametys.web.repository.page.Page; 084import org.ametys.web.repository.page.PageQueryHelper; 085import org.ametys.web.repository.page.jcr.DefaultPage; 086 087/** 088 * Component providing methods to retrieve ugc virtual pages, such as the ugc root, 089 * transitional page and ugc content page. 090 */ 091public class UGCPageHandler extends AbstractLogEnabled implements Component, Serviceable, Contextualizable 092{ 093 /** The attribute to get the name of transitional page */ 094 public static final String ATTRIBUTE_TRANSITIONAL_PAGE_METADATA_VALUE = "metadata_value"; 095 096 /** The attribute to get the title of transitional page */ 097 public static final String ATTRIBUTE_TRANSITIONAL_PAGE_TITLE = "title"; 098 099 /** The avalon role. */ 100 public static final String ROLE = UGCPageHandler.class.getName(); 101 102 /** The data name for the content type of the ugc */ 103 public static final String CONTENT_TYPE_DATA_NAME = "ugc-root-contenttype"; 104 105 /** The data name for the classification attribute of the ugc */ 106 public static final String CLASSIFICATION_ATTRIBUTE_DATA_NAME = "ugc-root-classification-metadata"; 107 108 /** The data name for the visibility of transitional page of the ugc */ 109 public static final String CLASSIFICATION_PAGE_VISIBLE_DATA_NAME = "ugc-root-classification-page-visible"; 110 111 /** The ametys object resolver */ 112 protected AmetysObjectResolver _resolver; 113 114 /** The content type extension point */ 115 protected ContentTypeExtensionPoint _cTypeEP; 116 117 /** The content types helper */ 118 protected ContentTypesHelper _cTypeHelper; 119 120 /** The i18n utils */ 121 protected I18nUtils _i18nUtils; 122 123 /** The avalon context */ 124 protected Context _context; 125 126 /** Observer manager. */ 127 protected ObservationManager _observationManager; 128 129 /** Current user provider */ 130 protected CurrentUserProvider _currentUserProvider; 131 132 @Override 133 public void service(ServiceManager manager) throws ServiceException 134 { 135 _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 136 _cTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE); 137 _cTypeHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE); 138 _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE); 139 _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE); 140 _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE); 141 } 142 143 public void contextualize(Context context) throws ContextException 144 { 145 _context = context; 146 } 147 148 @Override 149 protected Logger getLogger() 150 { 151 return super.getLogger(); 152 } 153 154 /** 155 * Gets the path of the classification attribute 156 * @param rootPage The ugc root page 157 * @return the path of the classification attribute 158 */ 159 public String getClassificationAttribute(Page rootPage) 160 { 161 return rootPage.getValue(CLASSIFICATION_ATTRIBUTE_DATA_NAME); 162 } 163 164 /** 165 * Gets the content type id 166 * @param rootPage The ugc root page 167 * @return the content type id 168 */ 169 public String getContentTypeId(Page rootPage) 170 { 171 return rootPage.getValue(CONTENT_TYPE_DATA_NAME); 172 } 173 174 /** 175 * <code>true</code> if the classification pages are visible 176 * @param rootPage The ugc root page 177 * @return <code>true</code> if the classification pages are visible 178 */ 179 public boolean isClassificationPagesVisible(Page rootPage) 180 { 181 return rootPage.getValueOrDefault(CLASSIFICATION_PAGE_VISIBLE_DATA_NAME, false); 182 } 183 184 /** 185 * True if the page a UGC root page for the given content type 186 * @param page the page 187 * @param contentTypeId The id of content type. Cannot be null. 188 * @return true if the page is a UGC root page for the given content type 189 */ 190 public boolean isUGCRootPage(DefaultPage page, String contentTypeId) 191 { 192 try 193 { 194 Node node = page.getNode(); 195 196 if (node.hasProperty(AmetysObjectResolver.VIRTUAL_PROPERTY)) 197 { 198 List<Value> values = Arrays.asList(node.getProperty(AmetysObjectResolver.VIRTUAL_PROPERTY).getValues()); 199 200 boolean isUGCRootPage = values.stream() 201 .map(LambdaUtils.wrap(Value::getString)) 202 .anyMatch(v -> VirtualUGCPageFactory.class.getName().equals(v)); 203 204 if (isUGCRootPage) 205 { 206 return contentTypeId.equals(page.getValue(CONTENT_TYPE_DATA_NAME)); 207 } 208 } 209 } 210 catch (RepositoryException e) 211 { 212 getLogger().warn("Unable to determine if page '" + page.getId() + "' is a UGC root page", e); 213 } 214 215 return false; 216 } 217 218 /** 219 * Gets the ugc root pages from the given content type id. 220 * @param siteName the site name 221 * @param sitemapName the sitemap name 222 * @param contentTypeId The content type id 223 * @return the ugc root page. 224 * @throws AmetysRepositoryException if an error occured. 225 */ 226 public Page getUGCRootPage(String siteName, String sitemapName, String contentTypeId) throws AmetysRepositoryException 227 { 228 Expression expression = new VirtualFactoryExpression(VirtualUGCPageFactory.class.getName()); 229 Expression contentTypeExp = new StringExpression(CONTENT_TYPE_DATA_NAME, Operator.EQ, contentTypeId); 230 231 AndExpression andExp = new AndExpression(expression, contentTypeExp); 232 233 String query = PageQueryHelper.getPageXPathQuery(siteName, sitemapName, null, andExp, null); 234 235 AmetysObjectIterable<Page> pages = _resolver.query(query); 236 237 return pages.iterator().hasNext() ? pages.iterator().next() : null; 238 } 239 240 /** 241 * Get the ugc root pages 242 * @param siteName the current site. 243 * @param sitemapName the sitemap name. 244 * @return the ugc root pages 245 * @throws AmetysRepositoryException if an error occured. 246 */ 247 public Set<Page> getUGCRootPages(String siteName, String sitemapName) throws AmetysRepositoryException 248 { 249 Expression expression = new VirtualFactoryExpression(VirtualUGCPageFactory.class.getName()); 250 251 String query = PageQueryHelper.getPageXPathQuery(siteName, sitemapName, null, expression, null); 252 253 AmetysObjectIterable<Page> pages = _resolver.query(query); 254 255 return pages.stream().collect(Collectors.toSet()); 256 } 257 258 /** 259 * Set the ugc root page 260 * @param page the page to set as root 261 * @param contentTypeId the type of content root 262 * @param attributePath path to classification attribute 263 * @param classificationPageVisible true to show classification 264 * @throws RepositoryException if a repository error occurred 265 */ 266 public void setUGCRoot(Page page, String contentTypeId, String attributePath, boolean classificationPageVisible) throws RepositoryException 267 { 268 Page currentUGCPage = getUGCRootPage(page.getSiteName(), page.getSitemapName(), contentTypeId); 269 270 Map<String, Object> eventParams = new HashMap<>(); 271 eventParams.put(org.ametys.web.ObservationConstants.ARGS_PAGE, page); 272 273 if (currentUGCPage != null && currentUGCPage.getId().equals(page.getId())) 274 { 275 // Unindex pages for all workspaces before the properties changed 276 _observationManager.notify(new Event(ObservationConstants.EVENT_UGC_ROOT_UPDATING, _currentUserProvider.getUser(), eventParams)); 277 278 _updateUGCRootProperty(page, contentTypeId, attributePath, classificationPageVisible); 279 } 280 else 281 { 282 _addUGCRootProperty(page, contentTypeId, attributePath, classificationPageVisible); 283 } 284 285 // Live synchronization 286 _observationManager.notify(new Event(org.ametys.web.ObservationConstants.EVENT_PAGE_UPDATED, _currentUserProvider.getUser(), eventParams)); 287 288 // Indexation 289 _observationManager.notify(new Event(ObservationConstants.EVENT_UGC_ROOT_UPDATED, _currentUserProvider.getUser(), eventParams)); 290 } 291 292 private void _addUGCRootProperty(Page page, String contentType, String metadata, boolean classificationPageVisible) throws RepositoryException 293 { 294 if (page instanceof JCRAmetysObject) 295 { 296 JCRAmetysObject jcrPage = (JCRAmetysObject) page; 297 Node node = jcrPage.getNode(); 298 299 List<Value> values = new ArrayList<>(); 300 if (node.hasProperty(AmetysObjectResolver.VIRTUAL_PROPERTY)) 301 { 302 values.addAll(Arrays.asList(node.getProperty(AmetysObjectResolver.VIRTUAL_PROPERTY).getValues())); 303 } 304 305 StringValue virtualUGCPageFactoryClassName = new StringValue(VirtualUGCPageFactory.class.getName()); 306 if (!values.contains(virtualUGCPageFactoryClassName)) 307 { 308 values.add(virtualUGCPageFactoryClassName); 309 } 310 311 node.setProperty(AmetysObjectResolver.VIRTUAL_PROPERTY, values.toArray(new Value[values.size()])); 312 313 // Set the ugc root property 314 if (page instanceof ModifiablePage) 315 { 316 ((ModifiablePage) page).setValue(UGCPageHandler.CONTENT_TYPE_DATA_NAME, contentType); 317 ((ModifiablePage) page).setValue(UGCPageHandler.CLASSIFICATION_ATTRIBUTE_DATA_NAME, metadata); 318 ((ModifiablePage) page).setValue(UGCPageHandler.CLASSIFICATION_PAGE_VISIBLE_DATA_NAME, classificationPageVisible); 319 } 320 321 jcrPage.saveChanges(); 322 } 323 } 324 325 private void _updateUGCRootProperty(Page page, String contentType, String metadata, boolean classificationPageVisible) 326 { 327 if (page instanceof ModifiablePage) 328 { 329 ModifiablePage modifiablePage = (ModifiablePage) page; 330 331 // Set the ugc root property 332 modifiablePage.setValue(UGCPageHandler.CONTENT_TYPE_DATA_NAME, contentType); 333 modifiablePage.setValue(UGCPageHandler.CLASSIFICATION_ATTRIBUTE_DATA_NAME, metadata); 334 modifiablePage.setValue(UGCPageHandler.CLASSIFICATION_PAGE_VISIBLE_DATA_NAME, classificationPageVisible); 335 336 modifiablePage.saveChanges(); 337 } 338 } 339 340 /** 341 * Get UGC contents from rootPage 342 * @param rootPage the root page 343 * @return the list of UGC contents 344 */ 345 public AmetysObjectIterable<Content> getContentsForRootPage(Page rootPage) 346 { 347 String lang = rootPage.getSitemapName(); 348 String contentType = getContentTypeId(rootPage); 349 350 ContentTypeExpression contentTypeExp = new ContentTypeExpression(Operator.EQ, contentType); 351 352 ExpressionContext siteExpressionContext = ExpressionContext.newInstance().withInternal(true); 353 StringExpression siteExpr = new StringExpression(SiteAwareAmetysObject.METADATA_SITE, Operator.EQ, rootPage.getSiteName(), siteExpressionContext); 354 Expression noSiteExpr = new NotExpression(new MetadataExpression(SiteAwareAmetysObject.METADATA_SITE, siteExpressionContext)); 355 Expression fullSiteExpr = new OrExpression(siteExpr, noSiteExpr); 356 357 Expression finalExpr = new AndExpression(contentTypeExp, new LanguageExpression(Operator.EQ, lang), fullSiteExpr); 358 359 SortCriteria sort = new SortCriteria(); 360 sort.addCriterion(Content.ATTRIBUTE_TITLE, true, true); 361 362 String xPathQuery = QueryHelper.getXPathQuery(null, "ametys:content", finalExpr, sort); 363 364 return _resolver.query(xPathQuery); 365 } 366 367 /** 368 * Determines if content is part of UGC root page 369 * @param rootPage The root page 370 * @param content the content 371 * @return true if content is part of UGC root page 372 */ 373 public boolean hasContentForRootPage(Page rootPage, Content content) 374 { 375 String contentType = getContentTypeId(rootPage); 376 String siteName = rootPage.getSiteName(); 377 String classificationMetadata = getClassificationAttribute(rootPage); 378 379 return _cTypeHelper.isInstanceOf(content, contentType) // match content type 380 && (!(content instanceof SiteAwareAmetysObject) || siteName.equals(((SiteAwareAmetysObject) content).getSiteName())) // match site 381 && StringUtils.isBlank(classificationMetadata); // no classification attribute 382 } 383 384 /** 385 * Get the map of transitional page (name : (id, title)) 386 * @param rootPage the root page 387 * @return The map of transitional page 388 */ 389 public Map<String, Map<String, String>> getTransitionalPage(Page rootPage) 390 { 391 return _getClassificationType(rootPage) 392 .allTransitionalPages() 393 .stream() 394 .sorted(Comparator.comparing(TransitionalPageInformation::getTitle)) 395 .collect(LambdaUtils.Collectors.toLinkedHashMap( 396 TransitionalPageInformation::getKey, 397 TransitionalPageInformation::getInfo)); 398 } 399 400 private ClassificationType _getClassificationType(Page rootPage) 401 { 402 String classificationAttributePath = getClassificationAttribute(rootPage); 403 if (StringUtils.isBlank(classificationAttributePath)) 404 { 405 // No classification attribute defined, so no transitional page 406 return new ClassificationType.None(); 407 } 408 String contentTypeId = getContentTypeId(rootPage); 409 ContentType contentType = _cTypeEP.getExtension(contentTypeId); 410 411 if (contentType == null) 412 { 413 getLogger().warn("Can not classify UGC content of type '" + contentTypeId + "' on root page " + rootPage.getId()); 414 } 415 else if (contentType.hasModelItem(classificationAttributePath)) 416 { 417 ModelItem modelItem = contentType.getModelItem(classificationAttributePath); 418 if (modelItem instanceof ContentAttributeDefinition) 419 { 420 String attributeContentType = ((ContentAttributeDefinition) modelItem).getContentTypeId(); 421 return new ClassificationType.TypeContent(this, rootPage, attributeContentType); 422 } 423 else if (modelItem instanceof ElementDefinition<?>) 424 { 425 @SuppressWarnings("unchecked") 426 Enumerator<String> enumerator = ((ElementDefinition<String>) modelItem).getEnumerator(); 427 if (enumerator != null) 428 { 429 return new ClassificationType.TypeEnum(this, rootPage, enumerator); 430 } 431 } 432 } 433 434 return new ClassificationType.None(); 435 } 436 437 /** 438 * Get contents under transitional page 439 * @param rootPage the root page 440 * @param metadataValue the metadata value (linked to the transitional page) 441 * @return list of contents under transitional page 442 */ 443 public AmetysObjectIterable<Content> getContentsForTransitionalPage(Page rootPage, String metadataValue) 444 { 445 String classificationMetadata = getClassificationAttribute(rootPage); 446 447 String lang = rootPage.getSitemapName(); 448 String contentType = getContentTypeId(rootPage); 449 450 ContentTypeExpression contentTypeExp = new ContentTypeExpression(Operator.EQ, contentType); 451 StringExpression metadataExpression = new StringExpression(classificationMetadata, Operator.EQ, metadataValue); 452 453 ExpressionContext siteExpressionContext = ExpressionContext.newInstance().withInternal(true); 454 StringExpression siteExpr = new StringExpression(SiteAwareAmetysObject.METADATA_SITE, Operator.EQ, rootPage.getSiteName(), siteExpressionContext); 455 Expression noSiteExpr = new NotExpression(new MetadataExpression(SiteAwareAmetysObject.METADATA_SITE, siteExpressionContext)); 456 Expression fullSiteExpr = new OrExpression(siteExpr, noSiteExpr); 457 458 Expression finalExpr = new AndExpression(contentTypeExp, metadataExpression, new LanguageExpression(Operator.EQ, lang), fullSiteExpr); 459 460 SortCriteria sort = new SortCriteria(); 461 sort.addCriterion(Content.ATTRIBUTE_TITLE, true, true); 462 463 String xPathQuery = QueryHelper.getXPathQuery(null, "ametys:content", finalExpr, sort); 464 465 return _resolver.query(xPathQuery); 466 } 467 468 /** 469 * Determines if given content is part of a transitional page 470 * @param rootPage the root page 471 * @param metadataValue the metadata value (linked to the transitional page). Cannot be null. 472 * @param content the content 473 * @return true if content is part of the transitional page 474 */ 475 public boolean hasContentForTransitionalPage(Page rootPage, String metadataValue, Content content) 476 { 477 String contentType = getContentTypeId(rootPage); 478 String siteName = rootPage.getSiteName(); 479 String classificationMetadata = getClassificationAttribute(rootPage); 480 481 return _cTypeHelper.isInstanceOf(content, contentType) // match content type 482 && (!(content instanceof SiteAwareAmetysObject) || siteName.equals(((SiteAwareAmetysObject) content).getSiteName())) // match site 483 && StringUtils.isNotEmpty(classificationMetadata) && metadataValue.equals(((ContentValue) content.getValue(classificationMetadata)).getContentId()); // match classification attribute 484 485 } 486 487 /** 488 * Computes a page id 489 * @param path The path 490 * @param root The root page 491 * @param ugcContent The UGC content 492 * @return The id 493 */ 494 public String computePageId(String path, Page root, Content ugcContent) 495 { 496 // E.g: ugccontent://path?rootId=...&contentId=... 497 return "ugccontent://" + path + "?rootId=" + root.getId() + "&contentId=" + ugcContent.getId(); 498 } 499 500 /** 501 * Gets the UGC page related to the given UG Content id 502 * @param contentId the id of UG Content 503 * @param siteName The site name. Can be nul to get site from content or current site. 504 * @return the UGC page or null if not found 505 */ 506 public UGCPage getUgcPage(String contentId, String siteName) 507 { 508 if (contentId == null) 509 { 510 return null; 511 } 512 513 Content content; 514 try 515 { 516 content = _resolver.resolveById(contentId); 517 } 518 catch (UnknownAmetysObjectException e) 519 { 520 return null; 521 } 522 523 Request request = ContextHelper.getRequest(_context); 524 String site = StringUtils.isNotBlank(siteName) ? siteName : WebHelper.getSiteName(request, content); 525 526 String sitemap = (String) request.getAttribute(WebConstants.REQUEST_ATTR_SITEMAP_NAME); 527 528 for (String type : content.getTypes()) 529 { 530 Optional<UGCPage> ugcPage = getUgcPage(content, site, sitemap, type); 531 if (ugcPage.isPresent()) 532 { 533 return ugcPage.get(); 534 } 535 } 536 537 return null; 538 } 539 540 /** 541 * Gets the UGC page related to the given UG Content for given site, sitemap and type 542 * @param ugcContent the UG Content 543 * @param site the site name 544 * @param sitemap the sitemap name 545 * @param contentType the content type id 546 * @return the UGC page 547 */ 548 public Optional<UGCPage> getUgcPage(Content ugcContent, String site, String sitemap, String contentType) 549 { 550 String language = Optional.of(ugcContent) 551 .map(Content::getLanguage) 552 .orElse(sitemap); 553 Page ugcRootPage = getUGCRootPage(site, language, contentType); 554 555 return Optional.ofNullable(ugcRootPage) 556 .flatMap(root -> getUgcPage(root, ugcContent)); 557 } 558 559 /** 560 * Gets the UGC page related to the given UG Content for given UGC root 561 * @param ugcRootPage the UGC root page 562 * @param ugcContent the UG Content 563 * @return the UGC page 564 */ 565 public Optional<UGCPage> getUgcPage(Page ugcRootPage, Content ugcContent) 566 { 567 String path = _getPath(ugcRootPage, ugcContent); 568 return Optional.ofNullable(path) 569 .map(p -> computePageId(p, ugcRootPage, ugcContent)) 570 .map(this::_silentResolve); 571 } 572 573 private String _getPath(Page ugcRootPage, Content ugcContent) 574 { 575 try 576 { 577 ClassificationType transtionalPageType = _getClassificationType(ugcRootPage); 578 if (transtionalPageType instanceof ClassificationType.None) 579 { 580 return "_root"; 581 } 582 else 583 { 584 TransitionalPageInformation transitionalPageInfo = transtionalPageType.getTransitionalPage(ugcContent); 585 return transitionalPageInfo.getKey(); 586 } 587 } 588 catch (Exception e) 589 { 590 getLogger().error("Cannot get path for root {} and content {}", ugcRootPage, ugcContent, e); 591 return null; 592 } 593 } 594 595 private UGCPage _silentResolve(String id) 596 { 597 try 598 { 599 return _resolver.resolveById(id); 600 } 601 catch (UnknownAmetysObjectException e) 602 { 603 return null; 604 } 605 } 606}