From 429d8b4a26d3ca458d8e800fbec05e3ee23b65a5 Mon Sep 17 00:00:00 2001 From: Mathieu Baudier Date: Tue, 13 Jun 2023 08:11:29 +0200 Subject: [PATCH] Remove JCR depdencies --- .../src/org/argeo/app/api/AppUserState.java | 9 + org.argeo.app.core/OSGI-INF/appUserState.xml | 8 + org.argeo.app.core/OSGI-INF/dbk4Converter.xml | 7 - org.argeo.app.core/OSGI-INF/geoToolsTest.xml | 4 - org.argeo.app.core/bnd.bnd | 2 +- org.argeo.app.core/build.properties | 7 +- .../src/org/argeo/app/ux/AppUi.java | 11 + .../src/org/argeo/app/ux/SuiteUxEvent.java | 2 +- .../internal/app/core/AppUserStateImpl.java | 33 ++ .../src/org/argeo/app/swt/ux/SwtAppUi.java | 5 +- swt/org.argeo.app.ui/OSGI-INF/cmsApp.xml | 3 +- .../src/org/argeo/app/ui/SuiteApp.java | 300 +++++++++--------- 12 files changed, 222 insertions(+), 169 deletions(-) create mode 100644 org.argeo.app.api/src/org/argeo/app/api/AppUserState.java create mode 100644 org.argeo.app.core/OSGI-INF/appUserState.xml delete mode 100644 org.argeo.app.core/OSGI-INF/dbk4Converter.xml delete mode 100644 org.argeo.app.core/OSGI-INF/geoToolsTest.xml create mode 100644 org.argeo.app.core/src/org/argeo/app/ux/AppUi.java create mode 100644 org.argeo.app.core/src/org/argeo/internal/app/core/AppUserStateImpl.java diff --git a/org.argeo.app.api/src/org/argeo/app/api/AppUserState.java b/org.argeo.app.api/src/org/argeo/app/api/AppUserState.java new file mode 100644 index 0000000..763fd31 --- /dev/null +++ b/org.argeo.app.api/src/org/argeo/app/api/AppUserState.java @@ -0,0 +1,9 @@ +package org.argeo.app.api; + +import org.argeo.api.acr.Content; +import org.argeo.api.acr.ContentSession; +import org.argeo.api.cms.CmsSession; + +public interface AppUserState { + Content getOrCreateSessionDir(ContentSession contentSession, CmsSession session); +} diff --git a/org.argeo.app.core/OSGI-INF/appUserState.xml b/org.argeo.app.core/OSGI-INF/appUserState.xml new file mode 100644 index 0000000..82c3ec0 --- /dev/null +++ b/org.argeo.app.core/OSGI-INF/appUserState.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/org.argeo.app.core/OSGI-INF/dbk4Converter.xml b/org.argeo.app.core/OSGI-INF/dbk4Converter.xml deleted file mode 100644 index ccad605..0000000 --- a/org.argeo.app.core/OSGI-INF/dbk4Converter.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/org.argeo.app.core/OSGI-INF/geoToolsTest.xml b/org.argeo.app.core/OSGI-INF/geoToolsTest.xml deleted file mode 100644 index 68a53ab..0000000 --- a/org.argeo.app.core/OSGI-INF/geoToolsTest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/org.argeo.app.core/bnd.bnd b/org.argeo.app.core/bnd.bnd index 9d8228b..e6be573 100644 --- a/org.argeo.app.core/bnd.bnd +++ b/org.argeo.app.core/bnd.bnd @@ -3,7 +3,7 @@ Bundle-ActivationPolicy: lazy Service-Component:\ OSGI-INF/termsManager.xml,\ OSGI-INF/maintenanceService.xml,\ -OSGI-INF/dbk4Converter.xml,\ +OSGI-INF/appUserState.xml,\ Import-Package:\ tech.units.indriya.unit,\ diff --git a/org.argeo.app.core/build.properties b/org.argeo.app.core/build.properties index 76d3ee9..dc82853 100644 --- a/org.argeo.app.core/build.properties +++ b/org.argeo.app.core/build.properties @@ -1,6 +1,7 @@ -output.. = bin/ bin.includes = META-INF/,\ .,\ - OSGI-INF/ -source.. = src/ + OSGI-INF/,\ + OSGI-INF/appUserState.xml additional.bundles = org.argeo.init +source.. = src/ +output.. = bin/ diff --git a/org.argeo.app.core/src/org/argeo/app/ux/AppUi.java b/org.argeo.app.core/src/org/argeo/app/ux/AppUi.java new file mode 100644 index 0000000..83e2926 --- /dev/null +++ b/org.argeo.app.core/src/org/argeo/app/ux/AppUi.java @@ -0,0 +1,11 @@ +package org.argeo.app.ux; + +import org.argeo.api.cms.ux.CmsUi; +import org.argeo.cms.Localized; + +public interface AppUi extends CmsUi { + Localized getTitle(); + + boolean isLoginScreen(); + +} diff --git a/org.argeo.app.core/src/org/argeo/app/ux/SuiteUxEvent.java b/org.argeo.app.core/src/org/argeo/app/ux/SuiteUxEvent.java index 4d690fd..00b65f4 100644 --- a/org.argeo.app.core/src/org/argeo/app/ux/SuiteUxEvent.java +++ b/org.argeo.app.core/src/org/argeo/app/ux/SuiteUxEvent.java @@ -11,7 +11,7 @@ public enum SuiteUxEvent implements CmsEvent { openNewPart, refreshPart, switchLayer; public final static String LAYER = "layer"; - public final static String USERNAME = "username"; +// public final static String USERNAME = "username"; // ACR public final static String CONTENT_PATH = "contentPath"; diff --git a/org.argeo.app.core/src/org/argeo/internal/app/core/AppUserStateImpl.java b/org.argeo.app.core/src/org/argeo/internal/app/core/AppUserStateImpl.java new file mode 100644 index 0000000..b7201a1 --- /dev/null +++ b/org.argeo.app.core/src/org/argeo/internal/app/core/AppUserStateImpl.java @@ -0,0 +1,33 @@ +package org.argeo.internal.app.core; + +import javax.jcr.Node; + +import org.argeo.api.acr.Content; +import org.argeo.api.acr.ContentSession; +import org.argeo.api.cms.CmsConstants; +import org.argeo.api.cms.CmsSession; +import org.argeo.app.api.AppUserState; +import org.argeo.app.core.SuiteUtils; +import org.argeo.cms.acr.ContentUtils; +import org.argeo.cms.jcr.acr.JcrContentProvider; +import org.argeo.jcr.Jcr; + +public class AppUserStateImpl implements AppUserState { + private JcrContentProvider jcrContentProvider; + + @Override + public Content getOrCreateSessionDir(ContentSession contentSession, CmsSession session) { + Node userDirNode = jcrContentProvider.doInAdminSession((adminSession) -> { + Node node = SuiteUtils.getOrCreateCmsSessionNode(adminSession, session); + return node; + }); + Content userDir = contentSession + .get(ContentUtils.SLASH + CmsConstants.SYS_WORKSPACE + Jcr.getPath(userDirNode)); + return userDir; + } + + public void setJcrContentProvider(JcrContentProvider jcrContentProvider) { + this.jcrContentProvider = jcrContentProvider; + } + +} diff --git a/swt/org.argeo.app.swt/src/org/argeo/app/swt/ux/SwtAppUi.java b/swt/org.argeo.app.swt/src/org/argeo/app/swt/ux/SwtAppUi.java index a604fc0..3005303 100644 --- a/swt/org.argeo.app.swt/src/org/argeo/app/swt/ux/SwtAppUi.java +++ b/swt/org.argeo.app.swt/src/org/argeo/app/swt/ux/SwtAppUi.java @@ -5,6 +5,7 @@ import java.util.Map; import org.argeo.api.acr.Content; import org.argeo.api.cms.CmsLog; +import org.argeo.app.ux.AppUi; import org.argeo.app.ux.SuiteStyle; import org.argeo.cms.Localized; import org.argeo.cms.swt.CmsSwtUi; @@ -14,7 +15,7 @@ import org.eclipse.swt.layout.FormLayout; import org.eclipse.swt.widgets.Composite; /** The view for the default UX of Argeo Suite. */ -public class SwtAppUi extends CmsSwtUi { +public class SwtAppUi extends CmsSwtUi implements AppUi { private static final long serialVersionUID = 6207018859086689108L; private final static CmsLog log = CmsLog.getLog(SwtAppUi.class); @@ -206,6 +207,7 @@ public class SwtAppUi extends CmsSwtUi { this.userDir = userDir; } +// @Override public Localized getTitle() { return title; } @@ -214,6 +216,7 @@ public class SwtAppUi extends CmsSwtUi { this.title = title; } + @Override public boolean isLoginScreen() { return loginScreen; } diff --git a/swt/org.argeo.app.ui/OSGI-INF/cmsApp.xml b/swt/org.argeo.app.ui/OSGI-INF/cmsApp.xml index a82f9af..f170f97 100644 --- a/swt/org.argeo.app.ui/OSGI-INF/cmsApp.xml +++ b/swt/org.argeo.app.ui/OSGI-INF/cmsApp.xml @@ -8,8 +8,7 @@ - - + diff --git a/swt/org.argeo.app.ui/src/org/argeo/app/ui/SuiteApp.java b/swt/org.argeo.app.ui/src/org/argeo/app/ui/SuiteApp.java index 854268c..8e3f9da 100644 --- a/swt/org.argeo.app.ui/src/org/argeo/app/ui/SuiteApp.java +++ b/swt/org.argeo.app.ui/src/org/argeo/app/ui/SuiteApp.java @@ -13,9 +13,6 @@ import java.util.Set; import java.util.TreeMap; import java.util.TreeSet; -import javax.jcr.Node; -import javax.jcr.RepositoryException; -import javax.jcr.nodetype.NodeType; import javax.xml.namespace.QName; import org.argeo.api.acr.Content; @@ -25,36 +22,31 @@ import org.argeo.api.cms.CmsConstants; import org.argeo.api.cms.CmsEventSubscriber; import org.argeo.api.cms.CmsLog; import org.argeo.api.cms.CmsSession; -import org.argeo.api.cms.directory.CmsUserManager; import org.argeo.api.cms.ux.CmsTheme; import org.argeo.api.cms.ux.CmsUi; import org.argeo.api.cms.ux.CmsView; +import org.argeo.app.api.AppUserState; import org.argeo.app.api.EntityConstants; -import org.argeo.app.api.EntityNames; +import org.argeo.app.api.EntityName; import org.argeo.app.api.EntityType; import org.argeo.app.api.RankedObject; -import org.argeo.app.core.SuiteUtils; +import org.argeo.app.swt.ux.SwtAppLayer; import org.argeo.app.swt.ux.SwtAppUi; +import org.argeo.app.ux.AppUi; import org.argeo.app.ux.SuiteUxEvent; -import org.argeo.app.swt.ux.SwtAppLayer; import org.argeo.cms.AbstractCmsApp; import org.argeo.cms.LocaleUtils; import org.argeo.cms.Localized; import org.argeo.cms.acr.ContentUtils; -import org.argeo.cms.jcr.CmsJcrUtils; -import org.argeo.cms.jcr.acr.JcrContent; -import org.argeo.cms.jcr.acr.JcrContentProvider; import org.argeo.cms.swt.CmsSwtUtils; import org.argeo.cms.swt.acr.SwtUiProvider; import org.argeo.cms.swt.dialogs.CmsFeedback; import org.argeo.cms.util.LangUtils; import org.argeo.cms.ux.CmsUxUtils; import org.argeo.eclipse.ui.specific.UiContext; -import org.argeo.jcr.JcrException; import org.eclipse.swt.SWT; import org.eclipse.swt.widgets.Composite; import org.osgi.framework.Constants; -import org.osgi.service.useradmin.User; /** The Argeo Suite App. */ public class SuiteApp extends AbstractCmsApp implements CmsEventSubscriber { @@ -91,15 +83,14 @@ public class SuiteApp extends AbstractCmsApp implements CmsEventSubscriber { private Map> layersByPid = Collections.synchronizedSortedMap(new TreeMap<>()); private Map> layersByType = Collections.synchronizedSortedMap(new TreeMap<>()); - private CmsUserManager cmsUserManager; +// private CmsUserManager cmsUserManager; // TODO make more optimal or via CmsSession/CmsView private Map managedUis = new HashMap<>(); // ACR private ContentRepository contentRepository; - private JcrContentProvider jcrContentProvider; - + private AppUserState appUserState; // JCR // private Repository repository; @@ -238,14 +229,14 @@ public class SuiteApp extends AbstractCmsApp implements CmsEventSubscriber { .get(ContentUtils.SLASH + CmsConstants.SYS_WORKSPACE + publicBasePath); ui.setUserDir(userDir); } else { - Node userDirNode = jcrContentProvider.doInAdminSession((adminSession) -> { - Node node = SuiteUtils.getOrCreateCmsSessionNode(adminSession, cmsSession); - return node; - }); - Content userDir = contentSession - .get(ContentUtils.SLASH + CmsConstants.SYS_WORKSPACE + userDirNode.getPath()); + Content userDir = appUserState.getOrCreateSessionDir(contentSession, cmsSession); ui.setUserDir(userDir); -// Content userDir = contentSession.getSessionRunDir(); +// Node userDirNode = jcrContentProvider.doInAdminSession((adminSession) -> { +// Node node = SuiteUtils.getOrCreateCmsSessionNode(adminSession, cmsSession); +// return node; +// }); +// Content userDir = contentSession +// .get(ContentUtils.SLASH + CmsConstants.SYS_WORKSPACE + userDirNode.getPath()); // ui.setUserDir(userDir); } } @@ -304,143 +295,151 @@ public class SuiteApp extends AbstractCmsApp implements CmsEventSubscriber { if (content == null) throw new IllegalArgumentException("A node should be provided"); - if (content instanceof JcrContent) { - Node context = ((JcrContent) content).getJcrNode(); - try { - // mixins - Set types = new TreeSet<>(); - for (NodeType mixinType : context.getMixinNodeTypes()) { - String mixinTypeName = mixinType.getName(); - if (byType.containsKey(mixinTypeName)) { - types.add(mixinTypeName); - } - for (NodeType superType : mixinType.getDeclaredSupertypes()) { - if (byType.containsKey(superType.getName())) { - types.add(superType.getName()); - } - } - } - // primary node type - NodeType primaryType = context.getPrimaryNodeType(); - String primaryTypeName = primaryType.getName(); - if (byType.containsKey(primaryTypeName)) { - types.add(primaryTypeName); - } - for (NodeType superType : primaryType.getDeclaredSupertypes()) { - if (byType.containsKey(superType.getName())) { - types.add(superType.getName()); - } - } - // entity type - if (context.isNodeType(EntityType.entity.get())) { - if (context.hasProperty(EntityNames.ENTITY_TYPE)) { - String entityTypeName = context.getProperty(EntityNames.ENTITY_TYPE).getString(); - if (byType.containsKey(entityTypeName)) { - types.add(entityTypeName); - } - } - } - - if (CmsJcrUtils.isUserHome(context) && byType.containsKey("nt:folder")) {// home node - types.add("nt:folder"); - } - - if (types.size() == 0) - throw new IllegalArgumentException( - "No type found for " + context + " (" + listTypes(context) + ")"); - String type = types.iterator().next(); - if (!byType.containsKey(type)) - throw new IllegalArgumentException("No component found for " + context + " with type " + type); - return byType.get(type).get(); - } catch (RepositoryException e) { - throw new IllegalStateException(e); - } - - } else { - List objectClasses = content.getContentClasses(); - Set types = new TreeSet<>(); - for (QName cc : objectClasses) { - String type = cc.getPrefix() + ":" + cc.getLocalPart(); - if (byType.containsKey(type)) - types.add(type); - } - if (types.size() == 0) { - throw new IllegalArgumentException("No type found for " + content + " (" + objectClasses + ")"); - } - String type = types.iterator().next(); - if (!byType.containsKey(type)) - throw new IllegalArgumentException("No component found for " + content + " with type " + type); - return byType.get(type).get(); +// boolean checkJcr = false; +// if (checkJcr && content instanceof JcrContent) { +// Node context = ((JcrContent) content).getJcrNode(); +// try { +// // mixins +// Set types = new TreeSet<>(); +// for (NodeType mixinType : context.getMixinNodeTypes()) { +// String mixinTypeName = mixinType.getName(); +// if (byType.containsKey(mixinTypeName)) { +// types.add(mixinTypeName); +// } +// for (NodeType superType : mixinType.getDeclaredSupertypes()) { +// if (byType.containsKey(superType.getName())) { +// types.add(superType.getName()); +// } +// } +// } +// // primary node type +// NodeType primaryType = context.getPrimaryNodeType(); +// String primaryTypeName = primaryType.getName(); +// if (byType.containsKey(primaryTypeName)) { +// types.add(primaryTypeName); +// } +// for (NodeType superType : primaryType.getDeclaredSupertypes()) { +// if (byType.containsKey(superType.getName())) { +// types.add(superType.getName()); +// } +// } +// // entity type +// if (context.isNodeType(EntityType.entity.get())) { +// if (context.hasProperty(EntityNames.ENTITY_TYPE)) { +// String entityTypeName = context.getProperty(EntityNames.ENTITY_TYPE).getString(); +// if (byType.containsKey(entityTypeName)) { +// types.add(entityTypeName); +// } +// } +// } +// +// if (CmsJcrUtils.isUserHome(context) && byType.containsKey("nt:folder")) {// home node +// types.add("nt:folder"); +// } +// +// if (types.size() == 0) +// throw new IllegalArgumentException( +// "No type found for " + context + " (" + listTypes(context) + ")"); +// String type = types.iterator().next(); +// if (!byType.containsKey(type)) +// throw new IllegalArgumentException("No component found for " + context + " with type " + type); +// return byType.get(type).get(); +// } catch (RepositoryException e) { +// throw new IllegalStateException(e); +// } +// +// } else { + Set types = new TreeSet<>(); + if (content.hasContentClass(EntityType.entity.qName())) { + String type = content.attr(EntityName.type.qName()); + if (type != null && byType.containsKey(type)) + types.add(type); } - } - private static String listTypes(Node context) { - try { - StringBuilder sb = new StringBuilder(); - sb.append(context.getPrimaryNodeType().getName()); - for (NodeType superType : context.getPrimaryNodeType().getDeclaredSupertypes()) { - sb.append(' '); - sb.append(superType.getName()); - } - - for (NodeType nodeType : context.getMixinNodeTypes()) { - sb.append(' '); - sb.append(nodeType.getName()); - if (nodeType.getName().equals(EntityType.local.get())) - sb.append('/').append(context.getProperty(EntityNames.ENTITY_TYPE).getString()); - for (NodeType superType : nodeType.getDeclaredSupertypes()) { - sb.append(' '); - sb.append(superType.getName()); - } - } - return sb.toString(); - } catch (RepositoryException e) { - throw new JcrException(e); + List objectClasses = content.getContentClasses(); + for (QName cc : objectClasses) { + String type = cc.getPrefix() + ":" + cc.getLocalPart(); + if (byType.containsKey(type)) + types.add(type); + } + if (types.size() == 0) { + throw new IllegalArgumentException("No type found for " + content + " (" + objectClasses + ")"); } + String type = types.iterator().next(); + if (!byType.containsKey(type)) + throw new IllegalArgumentException("No component found for " + content + " with type " + type); + return byType.get(type).get(); +// } } +// private static String listTypes(Node context) { +// try { +// StringBuilder sb = new StringBuilder(); +// sb.append(context.getPrimaryNodeType().getName()); +// for (NodeType superType : context.getPrimaryNodeType().getDeclaredSupertypes()) { +// sb.append(' '); +// sb.append(superType.getName()); +// } +// +// for (NodeType nodeType : context.getMixinNodeTypes()) { +// sb.append(' '); +// sb.append(nodeType.getName()); +// if (nodeType.getName().equals(EntityType.local.get())) +// sb.append('/').append(context.getProperty(EntityNames.ENTITY_TYPE).getString()); +// for (NodeType superType : nodeType.getDeclaredSupertypes()) { +// sb.append(' '); +// sb.append(superType.getName()); +// } +// } +// return sb.toString(); +// } catch (RepositoryException e) { +// throw new JcrException(e); +// } +// } + @Override public void setState(CmsUi cmsUi, String state) { + AppUi ui = (AppUi) cmsUi; if (state == null) return; if (!state.startsWith("/")) { - if (cmsUi instanceof SwtAppUi) { - SwtAppUi ui = (SwtAppUi) cmsUi; - if (LOGIN.equals(state)) { - String appTitle = ""; - if (ui.getTitle() != null) - appTitle = ui.getTitle().lead(); - ui.getCmsView().stateChanged(state, appTitle); - return; - } - Map properties = new HashMap<>(); - String layerId = HOME_STATE.equals(state) ? defaultLayerPid : state; - properties.put(SuiteUxEvent.LAYER, layerId); - properties.put(SuiteUxEvent.CONTENT_PATH, HOME_STATE); - ui.getCmsView().sendEvent(SuiteUxEvent.switchLayer.topic(), properties); +// if (cmsUi instanceof SwtAppUi) { +// SwtAppUi ui = (SwtAppUi) cmsUi; + if (LOGIN.equals(state)) { + String appTitle = ""; + if (ui.getTitle() != null) + appTitle = ui.getTitle().lead(); + ui.getCmsView().stateChanged(state, appTitle); + return; } + Map properties = new HashMap<>(); + String layerId = HOME_STATE.equals(state) ? defaultLayerPid : state; + properties.put(SuiteUxEvent.LAYER, layerId); + properties.put(SuiteUxEvent.CONTENT_PATH, HOME_STATE); + ui.getCmsView().sendEvent(SuiteUxEvent.switchLayer.topic(), properties); +// } return; } - SwtAppUi suiteUi = (SwtAppUi) cmsUi; - if (suiteUi.isLoginScreen()) { +// SwtAppUi suiteUi = (SwtAppUi) cmsUi; + if (ui.isLoginScreen()) { return; } - Content node = stateToNode(suiteUi, state); + Content node = stateToNode(ui, state); if (node == null) { - suiteUi.getCmsView().navigateTo(HOME_STATE); + ui.getCmsView().navigateTo(HOME_STATE); } else { - suiteUi.getCmsView().sendEvent(SuiteUxEvent.switchLayer.topic(), SuiteUxEvent.eventProperties(node)); - suiteUi.getCmsView().sendEvent(SuiteUxEvent.refreshPart.topic(), SuiteUxEvent.eventProperties(node)); + ui.getCmsView().sendEvent(SuiteUxEvent.switchLayer.topic(), SuiteUxEvent.eventProperties(node)); + ui.getCmsView().sendEvent(SuiteUxEvent.refreshPart.topic(), SuiteUxEvent.eventProperties(node)); } } // TODO move it to an internal package? - static String nodeToState(Content node) { + public static String nodeToState(Content node) { return node.getPath(); } - private Content stateToNode(SwtAppUi suiteUi, String state) { + private Content stateToNode(CmsUi suiteUi, String state) { if (suiteUi == null) return null; if (state == null || !state.startsWith("/")) @@ -542,14 +541,15 @@ public class SuiteApp extends AbstractCmsApp implements CmsEventSubscriber { return ui.getUserDir(); Content node; if (path == null) { - // look for a user - String username = get(event, SuiteUxEvent.USERNAME); - if (username == null) - return null; - User user = cmsUserManager.getUser(username); - if (user == null) - return null; - node = ContentUtils.roleToContent(cmsUserManager, contentSession, user); + return null; +// // look for a user +// String username = get(event, SuiteUxEvent.USERNAME); +// if (username == null) +// return null; +// User user = cmsUserManager.getUser(username); +// if (user == null) +// return null; +// node = ContentUtils.roleToContent(cmsUserManager, contentSession, user); } else { node = contentSession.get(path); } @@ -639,20 +639,20 @@ public class SuiteApp extends AbstractCmsApp implements CmsEventSubscriber { } } - public void setCmsUserManager(CmsUserManager cmsUserManager) { - this.cmsUserManager = cmsUserManager; - } +// public void setCmsUserManager(CmsUserManager cmsUserManager) { +// this.cmsUserManager = cmsUserManager; +// } - protected ContentRepository getContentRepository() { - return contentRepository; - } +// protected ContentRepository getContentRepository() { +// return contentRepository; +// } public void setContentRepository(ContentRepository contentRepository) { this.contentRepository = contentRepository; } - public void setJcrContentProvider(JcrContentProvider jcrContentProvider) { - this.jcrContentProvider = jcrContentProvider; + public void setAppUserState(AppUserState appUserState) { + this.appUserState = appUserState; } } -- 2.30.2