001/* 002 * This library is part of OpenCms - 003 * the Open Source Content Management System 004 * 005 * Copyright (c) Alkacon Software GmbH & Co. KG (http://www.alkacon.com) 006 * 007 * This library is free software; you can redistribute it and/or 008 * modify it under the terms of the GNU Lesser General Public 009 * License as published by the Free Software Foundation; either 010 * version 2.1 of the License, or (at your option) any later version. 011 * 012 * This library is distributed in the hope that it will be useful, 013 * but WITHOUT ANY WARRANTY; without even the implied warranty of 014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 015 * Lesser General Public License for more details. 016 * 017 * For further information about Alkacon Software, please see the 018 * company website: http://www.alkacon.com 019 * 020 * For further information about OpenCms, please see the 021 * project website: http://www.opencms.org 022 * 023 * You should have received a copy of the GNU Lesser General Public 024 * License along with this library; if not, write to the Free Software 025 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 026 */ 027 028package org.opencms.jsp.util; 029 030import org.opencms.acacia.shared.CmsTabInfo; 031import org.opencms.ade.contenteditor.CmsContentTypeVisitor; 032import org.opencms.ade.contenteditor.CmsWidgetUtil; 033import org.opencms.ade.contenteditor.CmsWidgetUtil.WidgetInfo; 034import org.opencms.file.CmsObject; 035import org.opencms.i18n.CmsMessages; 036import org.opencms.i18n.CmsMultiMessages; 037import org.opencms.jsp.util.I_CmsFormatterInfo.ResolveMode; 038import org.opencms.main.CmsLog; 039import org.opencms.main.OpenCms; 040import org.opencms.util.CmsMacroResolver; 041import org.opencms.util.CmsStringUtil; 042import org.opencms.widgets.A_CmsWidget; 043import org.opencms.widgets.CmsSelectWidgetOption; 044import org.opencms.xml.CmsXmlContentDefinition; 045import org.opencms.xml.content.CmsDefaultXmlContentHandler; 046import org.opencms.xml.content.I_CmsXmlContentHandler; 047import org.opencms.xml.types.CmsXmlNestedContentDefinition; 048import org.opencms.xml.types.I_CmsXmlSchemaType; 049 050import java.util.ArrayList; 051import java.util.Collection; 052import java.util.LinkedHashMap; 053import java.util.List; 054import java.util.Locale; 055import java.util.Set; 056import java.util.stream.Collectors; 057 058import org.apache.commons.logging.Log; 059 060/** 061 * Bean for accessing XML content schema information from JSPs. 062 */ 063public class CmsSchemaInfo { 064 065 /** 066 * Represents information about a single field in a content schema. 067 */ 068 public class Field implements I_CmsInfoWrapper { 069 070 /** The nested fields. */ 071 private LinkedHashMap<String, Field> m_children = new LinkedHashMap<>(); 072 073 /** The content definition (may be null). */ 074 private CmsXmlContentDefinition m_contentDefinition; 075 076 /** The field. */ 077 private I_CmsXmlSchemaType m_field; 078 079 /** The path of the field. */ 080 private String m_path = ""; 081 082 /** The widget information. */ 083 private WidgetInfo m_widgetInfo; 084 085 /** 086 * Creates a new instance for the root level of a schema. 087 * 088 * @param contentDef the content definition 089 */ 090 public Field(CmsXmlContentDefinition contentDef) { 091 092 m_contentDefinition = contentDef; 093 m_path = ""; 094 processContentDefinition(m_contentDefinition); 095 } 096 097 /** 098 * Creates a new instance. 099 * 100 * @param field the schema type for the field 101 * @param path the path of the field 102 */ 103 public Field(I_CmsXmlSchemaType field, String path) { 104 105 m_path = path; 106 m_field = field; 107 if (field instanceof CmsXmlNestedContentDefinition) { 108 CmsXmlNestedContentDefinition nestedDef = (CmsXmlNestedContentDefinition)field; 109 m_contentDefinition = nestedDef.getNestedContentDefinition(); 110 processContentDefinition(m_contentDefinition); 111 } 112 } 113 114 /** 115 * Gets the nested fields, if any. 116 * 117 * 118 * @return the nested fields 119 */ 120 public Collection<Field> getChildren() { 121 122 return m_children.values(); 123 } 124 125 /** 126 * Gets the content definition. 127 * 128 * @return the content definition 129 */ 130 public CmsXmlContentDefinition getContentDefinition() { 131 132 return m_contentDefinition; 133 } 134 135 /** 136 * Gets the default value. 137 * 138 * @return the default value 139 */ 140 @SuppressWarnings("synthetic-access") 141 public String getDefaultValue() { 142 143 if (m_field == null) { 144 // root node 145 return null; 146 } 147 return m_root.getContentDefinition().getContentHandler().getDefault(m_cms, null, m_field, m_path, m_locale); 148 } 149 150 /** 151 * Gets the description. 152 * 153 * @return the description 154 */ 155 public String getDescription() { 156 157 return getDescription(ResolveMode.text); 158 } 159 160 /** 161 * Gets the description key. 162 * 163 * @return the description key 164 */ 165 public String getDescriptionKey() { 166 167 return getDescription(ResolveMode.key); 168 } 169 170 /** 171 * Gets the raw configured description. 172 * 173 * @return the raw configured description 174 */ 175 public String getDescriptionRaw() { 176 177 return getDescription(ResolveMode.raw); 178 } 179 180 /** 181 * Gets the display name. 182 * 183 * @return the display name 184 */ 185 public String getDisplayName() { 186 187 return getDisplayName(ResolveMode.text); 188 } 189 190 /** 191 * Gets the display name key. 192 * 193 * @return the display name key 194 */ 195 public String getDisplayNameKey() { 196 197 return getDisplayName(ResolveMode.key); 198 } 199 200 /** 201 * Gets the raw configured string for the display name. 202 * 203 * @return the raw display name 204 */ 205 public String getDisplayNameRaw() { 206 207 return getDisplayName(ResolveMode.raw); 208 209 } 210 211 /** 212 * Checks if this is a choice type. 213 * 214 * @return true if this is a choice type 215 */ 216 public boolean getIsChoice() { 217 218 return m_field.isChoiceType(); 219 } 220 221 /** 222 * Checks if this is a nested content type. 223 * 224 * @return true if this is a nested content type 225 */ 226 public boolean getIsNestedContent() { 227 228 return m_contentDefinition != null; 229 } 230 231 /** 232 * Gets the maximum number of occurrences. 233 * 234 * @return the maximum number of occurrences 235 */ 236 public int getMaxOccurs() { 237 238 return m_field.getMaxOccurs(); 239 } 240 241 /** 242 * Gets the minimum number of occurrences. 243 * 244 * @return the minimum number of occurrences 245 */ 246 public int getMinOccurs() { 247 248 return m_field.getMinOccurs(); 249 } 250 251 /** 252 * Gets the field name. 253 * 254 * @return the name 255 */ 256 public String getName() { 257 258 if (m_field == null) { 259 return null; 260 } 261 return m_field.getName(); 262 } 263 264 /** 265 * Tries to interpret the widget configuration as a select option configuration and returns the list of select options if this succeeds, and null otherwise. 266 * 267 * @return the list of parsed select options, or null if the widget configuration couldn't be interpreted that way 268 */ 269 @SuppressWarnings({"synthetic-access"}) 270 public List<CmsSelectWidgetOption> getParsedSelectOptions() { 271 272 String widgetConfig = getWidgetConfig(); 273 if (CmsStringUtil.isEmptyOrWhitespaceOnly(widgetConfig)) { 274 // passing an empty/null configuration to parseOptions would result in an empty list, not null, and we want null here 275 return null; 276 } 277 try { 278 List<CmsSelectWidgetOption> options = org.opencms.widgets.CmsSelectWidgetOption.parseOptions( 279 widgetConfig); 280 List<CmsSelectWidgetOption> result = new ArrayList<>(); 281 Set<String> values = options.stream().map(option -> option.getValue()).collect(Collectors.toSet()); 282 String defaultValue = getDefaultValue(); 283 Locale locale = m_cms.getRequestContext().getLocale(); 284 if (CmsStringUtil.isEmptyOrWhitespaceOnly(defaultValue) || !values.contains(defaultValue)) { 285 CmsSelectWidgetOption noValue = new CmsSelectWidgetOption( 286 "", 287 true, 288 org.opencms.gwt.Messages.get().getBundle(locale).key( 289 org.opencms.gwt.Messages.GUI_SELECTBOX_EMPTY_SELECTION_0)); 290 result.add(noValue); 291 } 292 293 result.addAll(options); 294 return result; 295 } catch (Exception e) { 296 LOG.info(e.getLocalizedMessage(), e); 297 return null; 298 } 299 } 300 301 /** 302 * Gets the field type. 303 * 304 * @return the type 305 */ 306 public String getType() { 307 308 return m_field.getTypeName(); 309 } 310 311 /** 312 * Gets the validation error message. 313 * 314 * @return the validation error message 315 */ 316 @SuppressWarnings("synthetic-access") 317 public String getValidationError() { 318 319 if (m_field == null) { 320 return null; 321 } 322 I_CmsXmlContentHandler handler = m_field.getContentDefinition().getContentHandler(); 323 if (!(handler instanceof CmsDefaultXmlContentHandler)) { 324 return null; 325 } 326 CmsDefaultXmlContentHandler defaultHandler = (CmsDefaultXmlContentHandler)handler; 327 return defaultHandler.getValidationWarningOrErrorMessage(m_cms, m_locale, m_field.getName(), false, false); 328 } 329 330 /** 331 * Gets the validation error localization key. 332 * 333 * @return the validation error localization key 334 */ 335 @SuppressWarnings("synthetic-access") 336 public String getValidationErrorKey() { 337 338 if (m_field == null) { 339 return null; 340 } 341 I_CmsXmlContentHandler handler = m_field.getContentDefinition().getContentHandler(); 342 if (!(handler instanceof CmsDefaultXmlContentHandler)) { 343 return null; 344 } 345 CmsDefaultXmlContentHandler defaultHandler = (CmsDefaultXmlContentHandler)handler; 346 return defaultHandler.getValidationWarningOrErrorMessage(m_cms, m_locale, m_field.getName(), false, true); 347 } 348 349 /** 350 * Gets the validation warning message. 351 * 352 * @return the validation warning message 353 */ 354 @SuppressWarnings({"synthetic-access"}) 355 public String getValidationWarning() { 356 357 if (m_field == null) { 358 return null; 359 } 360 I_CmsXmlContentHandler handler = m_field.getContentDefinition().getContentHandler(); 361 if (!(handler instanceof CmsDefaultXmlContentHandler)) { 362 return null; 363 } 364 CmsDefaultXmlContentHandler defaultHandler = (CmsDefaultXmlContentHandler)handler; 365 return defaultHandler.getValidationWarningOrErrorMessage(m_cms, m_locale, m_field.getName(), true, false); 366 } 367 368 /** 369 * Gets the validation warning message key. 370 * 371 * @return the validation warning message key 372 */ 373 @SuppressWarnings("synthetic-access") 374 public String getValidationWarningKey() { 375 376 if (m_field == null) { 377 return null; 378 } 379 I_CmsXmlContentHandler handler = m_field.getContentDefinition().getContentHandler(); 380 if (!(handler instanceof CmsDefaultXmlContentHandler)) { 381 return null; 382 } 383 CmsDefaultXmlContentHandler defaultHandler = (CmsDefaultXmlContentHandler)handler; 384 return defaultHandler.getValidationWarningOrErrorMessage(m_cms, m_locale, m_field.getName(), true, true); 385 } 386 387 /** 388 * Gets the visibility configuration string for the field. 389 * 390 * @return the visibility configuration string 391 */ 392 public String getVisibility() { 393 394 I_CmsXmlContentHandler handler = m_root.getContentDefinition().getContentHandler(); 395 if (handler instanceof CmsDefaultXmlContentHandler) { 396 return ((CmsDefaultXmlContentHandler)handler).getVisibilityConfigString(m_path); 397 } 398 return null; 399 } 400 401 /** 402 * Gets the widget. 403 * 404 * @return the widget 405 */ 406 @SuppressWarnings("synthetic-access") 407 public String getWidget() { 408 409 if (m_contentDefinition != null) { 410 return null; 411 } 412 WidgetInfo widgetInfo = getWidgetInfo(); 413 if (widgetInfo != null) { 414 return widgetInfo.getWidget().getClass().getName(); 415 } else { 416 LOG.error("Widget info not defined for " + m_path + " in " + m_root.getContentDefinition()); 417 return null; 418 } 419 } 420 421 /** 422 * Gets the widget configuration. 423 * 424 * @return the widget configuration 425 */ 426 @SuppressWarnings("synthetic-access") 427 public String getWidgetConfig() { 428 429 if (m_contentDefinition != null) { 430 return null; 431 } 432 WidgetInfo widgetInfo = getWidgetInfo(); 433 if (widgetInfo != null) { 434 return widgetInfo.getWidget().getConfiguration(); 435 } else { 436 LOG.error("Widget info not defined for " + m_path + " in " + m_root.getContentDefinition()); 437 return null; 438 } 439 440 } 441 442 /** 443 * Gets the description or description key. 444 * 445 * @param keyOnly true if only the localization key should be returned rather than the localized string 446 * @return the description or localization key 447 */ 448 @SuppressWarnings("synthetic-access") 449 private String getDescription(ResolveMode resolveMode) { 450 451 if (m_field == null) { 452 return null; 453 } 454 StringBuffer result = new StringBuffer(64); 455 I_CmsXmlContentHandler handler = m_field.getContentDefinition().getContentHandler(); 456 if (handler instanceof CmsDefaultXmlContentHandler) { 457 CmsDefaultXmlContentHandler defaultHandler = (CmsDefaultXmlContentHandler)handler; 458 String help = defaultHandler.getFieldHelp().get(m_field.getName()); 459 if (help != null) { 460 CmsMacroResolver resolver = new CmsMacroResolver(); 461 if (resolveMode == ResolveMode.raw) { 462 return help; 463 } else { 464 if (resolveMode == ResolveMode.key) { 465 resolver = new CmsKeyDummyMacroResolver(resolver); 466 } 467 resolver.setCmsObject(m_cms); 468 resolver.setKeepEmptyMacros(true); 469 resolver.setMessages(m_messages); 470 471 String val = resolver.resolveMacros(help); 472 if (resolveMode == ResolveMode.key) { 473 return CmsKeyDummyMacroResolver.getKey(val); 474 } else { 475 return val; 476 } 477 } 478 } 479 } 480 result.append(A_CmsWidget.LABEL_PREFIX); 481 result.append(getTypeKey(m_field)); 482 result.append(A_CmsWidget.HELP_POSTFIX); 483 switch (resolveMode) { 484 case key: 485 case raw: 486 return result.toString(); 487 case text: 488 default: 489 return m_messages.keyDefault(result.toString(), null); 490 } 491 } 492 493 /** 494 * Gets the display name or localization key. 495 * 496 * @param keyOnly true if only the localization key should be returned rather than the localized display name 497 * @return the display name or localization key 498 */ 499 @SuppressWarnings("synthetic-access") 500 private String getDisplayName(ResolveMode resolveMode) { 501 502 if (m_field == null) { 503 return null; 504 } 505 I_CmsXmlContentHandler handler = m_field.getContentDefinition().getContentHandler(); 506 if (handler instanceof CmsDefaultXmlContentHandler) { 507 CmsDefaultXmlContentHandler defaultHandler = (CmsDefaultXmlContentHandler)handler; 508 String label = defaultHandler.getFieldLabels().get(m_field.getName()); 509 if (label != null) { 510 if (resolveMode == ResolveMode.raw) { 511 return label; 512 } else { 513 CmsMacroResolver resolver = new CmsMacroResolver(); 514 if (resolveMode == ResolveMode.key) { 515 resolver = new CmsKeyDummyMacroResolver(resolver); 516 } 517 resolver.setCmsObject(m_cms); 518 resolver.setKeepEmptyMacros(true); 519 resolver.setMessages(m_messages); 520 String val = resolver.resolveMacros(label); 521 if (resolveMode == ResolveMode.key) { 522 return CmsKeyDummyMacroResolver.getKey(val); 523 } else { 524 return val; 525 } 526 } 527 } 528 } 529 StringBuffer result = new StringBuffer(64); 530 result.append(A_CmsWidget.LABEL_PREFIX); 531 result.append(getTypeKey(m_field)); 532 switch (resolveMode) { 533 case raw: 534 case key: 535 return result.toString(); 536 case text: 537 default: 538 return m_messages.keyDefault(result.toString(), m_field.getName()); 539 } 540 } 541 542 /** 543 * Returns the schema type message key.<p> 544 * 545 * @param value the schema type 546 * 547 * @return the schema type message key 548 */ 549 private String getTypeKey(I_CmsXmlSchemaType value) { 550 551 StringBuffer result = new StringBuffer(64); 552 result.append(value.getContentDefinition().getInnerName()); 553 result.append('.'); 554 result.append(value.getName()); 555 return result.toString(); 556 } 557 558 /** 559 * Gets the widget info. 560 * 561 * @return the widget info 562 */ 563 @SuppressWarnings("synthetic-access") 564 private WidgetInfo getWidgetInfo() { 565 566 if (m_widgetInfo == null) { 567 m_widgetInfo = CmsWidgetUtil.collectWidgetInfo(m_cms, m_root.getContentDefinition(), m_path, null, m_cms.getRequestContext().getLocale()); 568 } 569 return m_widgetInfo; 570 571 } 572 573 /** 574 * Process content definition. 575 * 576 * @param contentDef the content definition 577 */ 578 private void processContentDefinition(CmsXmlContentDefinition contentDef) { 579 580 List<I_CmsXmlSchemaType> fields = contentDef.getTypeSequence(); 581 for (I_CmsXmlSchemaType field : fields) { 582 String name = field.getName(); 583 m_children.put(name, new Field(field, combinePaths(m_path, name))); 584 } 585 } 586 587 } 588 589 /** 590 * Represents the a single editor tab and its fields. 591 */ 592 public class Tab implements I_CmsInfoWrapper { 593 594 /** The description. */ 595 private String m_description; 596 597 /** The description key. */ 598 private String m_descriptionKey; 599 600 /** The raw description string. */ 601 private String m_descriptionRaw; 602 603 /** The display name. */ 604 private String m_displayName; 605 606 /** The display name key. */ 607 private String m_displayNameKey; 608 609 /** The raw display name string. */ 610 private String m_displayNameRaw; 611 612 /** The fields. */ 613 private List<Field> m_fields = new ArrayList<>(); 614 615 /** 616 * Adds a field. 617 * 618 * @param field the field to add 619 */ 620 public void add(Field field) { 621 622 m_fields.add(field); 623 } 624 625 /** 626 * Gets the description. 627 * 628 * @return the description 629 */ 630 public String getDescription() { 631 632 return m_description; 633 } 634 635 /** 636 * Gets the description key. 637 * 638 * @return the description key 639 */ 640 public String getDescriptionKey() { 641 642 return m_descriptionKey; 643 } 644 645 /** 646 * Gets the raw description string. 647 * 648 * @return the raw description 649 */ 650 public String getDescriptionRaw() { 651 652 return m_descriptionRaw; 653 } 654 655 /** 656 * Gets the display name. 657 * 658 * @return the display name 659 */ 660 public String getDisplayName() { 661 662 return m_displayName; 663 } 664 665 /** 666 * Gets the display name key. 667 * 668 * @return the display name key 669 */ 670 public String getDisplayNameKey() { 671 672 return m_displayNameKey; 673 } 674 675 /** 676 * Gets the raw display name string. 677 * 678 * @return the raw display name string 679 */ 680 public String getDisplayNameRaw() { 681 682 return m_displayNameRaw; 683 } 684 685 /** 686 * Gets the fields. 687 * 688 * @return the fields 689 */ 690 public List<Field> getFields() { 691 692 return m_fields; 693 } 694 695 /** 696 * Sets the description. 697 * 698 * @param description the new description 699 */ 700 public void setDescription(String description) { 701 702 m_description = description; 703 } 704 705 /** 706 * Sets the description key. 707 * 708 * @param descriptionKey the new description key 709 */ 710 public void setDescriptionKey(String descriptionKey) { 711 712 m_descriptionKey = descriptionKey; 713 } 714 715 /** 716 * Sets the raw description string. 717 * 718 * @param descriptionRaw the raw description string 719 */ 720 public void setDescriptionRaw(String descriptionRaw) { 721 722 m_descriptionRaw = descriptionRaw; 723 } 724 725 /** 726 * Sets the display name. 727 * 728 * @param displayName the new display name 729 */ 730 public void setDisplayName(String displayName) { 731 732 m_displayName = displayName; 733 } 734 735 /** 736 * Sets the display name key. 737 * 738 * @param displayNameKey the new display name key 739 */ 740 public void setDisplayNameKey(String displayNameKey) { 741 742 m_displayNameKey = displayNameKey; 743 } 744 745 /** 746 * Sets the raw display name string. 747 * 748 * @param displayNameRaw the raw display name string 749 */ 750 public void setDisplayNameRaw(String displayNameRaw) { 751 752 m_displayNameRaw = displayNameRaw; 753 } 754 755 } 756 757 /** The logger instance for this class. */ 758 private static final Log LOG = CmsLog.getLog(CmsSchemaInfo.class); 759 760 /** The CMS context. */ 761 private CmsObject m_cms; 762 763 /** The locale. */ 764 private Locale m_locale; 765 766 /** The messages. */ 767 private CmsMultiMessages m_messages; 768 769 /** The root field instanec representing the whole schema. */ 770 private Field m_root; 771 772 /** The tabs. */ 773 private List<Tab> m_tabs; 774 775 /** 776 * Creates a new instance. 777 * 778 * @param cms the CMS context 779 * @param contentDef the content definition 780 */ 781 public CmsSchemaInfo(CmsObject cms, CmsXmlContentDefinition contentDef) { 782 783 m_cms = cms; 784 m_locale = cms.getRequestContext().getLocale(); 785 m_root = new Field(contentDef); 786 CmsMessages messages = null; 787 m_messages = new CmsMultiMessages(m_locale); 788 m_messages.setFallbackHandler(contentDef.getContentHandler().getMessageKeyHandler()); 789 790 try { 791 messages = OpenCms.getWorkplaceManager().getMessages(m_locale); 792 if (messages != null) { 793 m_messages.addMessages(messages); 794 } 795 messages = contentDef.getContentHandler().getMessages(m_locale); 796 if (messages != null) { 797 m_messages.addMessages(messages); 798 } 799 } catch (Exception e) { 800 LOG.debug(e.getMessage(), e); 801 } 802 initTabs(); 803 804 } 805 806 /** 807 * Combine schema paths. 808 * 809 * @param a the first path 810 * @param b the second path 811 * @return the combined paths 812 */ 813 static String combinePaths(String a, String b) { 814 815 if ("".equals(a)) { 816 return b; 817 } 818 return a + "/" + b; 819 } 820 821 /** 822 * Gets the root node. 823 * 824 * @return the root node 825 */ 826 public Field getRoot() { 827 828 return m_root; 829 } 830 831 /** 832 * Gets the tabs. 833 * 834 * @return the tabs 835 */ 836 public List<Tab> getTabs() { 837 838 return m_tabs; 839 } 840 841 /** 842 * Checks for tabs. 843 * 844 * @return true, if there are tabs 845 */ 846 public boolean hasTabs() { 847 848 return m_tabs.get(0).getDisplayName() == null; 849 } 850 851 /** 852 * Initializes the tabs. 853 */ 854 private void initTabs() { 855 856 List<CmsTabInfo> tabs = CmsContentTypeVisitor.collectTabInfos(m_cms, m_root.getContentDefinition(), m_messages); 857 858 List<Tab> result = new ArrayList<>(); 859 if ((tabs == null) || (tabs.size() == 0)) { 860 Tab defaultTab = new Tab(); 861 result.add(defaultTab); 862 defaultTab.getFields().addAll(m_root.getChildren()); 863 } else { 864 int index = 0; 865 for (Field node : m_root.getChildren()) { 866 if ((index < tabs.size()) && node.getName().equals(tabs.get(index).getStartName())) { 867 Tab tab = new Tab(); 868 869 tab.setDisplayName(tabs.get(index).getTabName()); 870 tab.setDisplayNameKey(tabs.get(index).getTabNameKey()); 871 tab.setDisplayNameRaw(tabs.get(index).getTabNameRaw()); 872 tab.setDescriptionKey(tabs.get(index).getDescriptionKey()); 873 tab.setDescriptionRaw(tabs.get(index).getDescriptionRaw()); 874 tab.setDescription(tabs.get(index).getDescription()); 875 876 result.add(tab); 877 index += 1; 878 } 879 if (result.size() > 0) { 880 result.get(result.size() - 1).add(node); 881 } 882 } 883 } 884 m_tabs = result; 885 } 886}