diff --git a/src/main/java/org/codelibs/fess/app/web/admin/plugin/AdminPluginAction.java b/src/main/java/org/codelibs/fess/app/web/admin/plugin/AdminPluginAction.java index 37ffbb527..2fb1720bd 100644 --- a/src/main/java/org/codelibs/fess/app/web/admin/plugin/AdminPluginAction.java +++ b/src/main/java/org/codelibs/fess/app/web/admin/plugin/AdminPluginAction.java @@ -15,6 +15,10 @@ */ package org.codelibs.fess.app.web.admin.plugin; +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -22,9 +26,11 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import org.codelibs.core.io.CopyUtil; import org.codelibs.fess.app.web.base.FessAdminAction; import org.codelibs.fess.helper.PluginHelper; import org.codelibs.fess.helper.PluginHelper.Artifact; +import org.codelibs.fess.helper.PluginHelper.ArtifactType; import org.codelibs.fess.util.ComponentUtil; import org.codelibs.fess.util.RenderDataUtil; import org.lastaflute.web.Execute; @@ -37,6 +43,8 @@ public class AdminPluginAction extends FessAdminAction { private static final Logger logger = LoggerFactory.getLogger(AdminPluginAction.class); + private static final String UPLOAD = "upload"; + @Override protected void setupHtmlData(final ActionRuntime runtime) { super.setupHtmlData(runtime); @@ -53,7 +61,7 @@ public class AdminPluginAction extends FessAdminAction { public HtmlResponse delete(final DeleteForm form) { validate(form, messages -> {}, () -> asHtml(path_AdminPlugin_AdminPluginJsp)); verifyToken(() -> asHtml(path_AdminPlugin_AdminPluginJsp)); - final Artifact artifact = new Artifact(form.name, form.version); + final Artifact artifact = new Artifact(form.name, form.version, null); deleteArtifact(artifact); saveInfo(messages -> messages.addSuccessDeletePlugin(GLOBAL, artifact.getFileName())); return redirect(getClass()); @@ -63,12 +71,48 @@ public class AdminPluginAction extends FessAdminAction { public HtmlResponse install(final InstallForm form) { validate(form, messages -> {}, () -> asHtml(path_AdminPlugin_AdminPluginInstallpluginJsp)); verifyToken(() -> asHtml(path_AdminPlugin_AdminPluginInstallpluginJsp)); - final Artifact artifact = getArtifactFromInstallForm(form); - if (artifact == null) { - throwValidationError(messages -> messages.addErrorsCrudCouldNotFindCrudTable(GLOBAL, form.id), () -> asListHtml()); + if (UPLOAD.equals(form.id)) { + if (form.jarFile == null) { + throwValidationError(messages -> messages.addErrorsPluginFileIsNotFound(GLOBAL, form.id), () -> asListHtml()); + } + if (!form.jarFile.getFileName().endsWith(".jar")) { + throwValidationError(messages -> messages.addErrorsFileIsNotSupported(GLOBAL, form.jarFile.getFileName()), + () -> asListHtml()); + } + final String filename = form.jarFile.getFileName(); + final File tempFile = ComponentUtil.getSystemHelper().createTempFile("tmp-adminplugin-", ".jar"); + try (final InputStream is = form.jarFile.getInputStream(); final OutputStream os = new FileOutputStream(tempFile)) { + CopyUtil.copy(is, os); + } catch (final Exception e) { + if (tempFile.exists() && !tempFile.delete()) { + logger.warn("Failed to delete {}.", tempFile.getAbsolutePath()); + } + logger.debug("Failed to copy " + filename, e); + throwValidationError(messages -> messages.addErrorsFailedToInstallPlugin(GLOBAL, filename), () -> asListHtml()); + } + new Thread(() -> { + try { + final PluginHelper pluginHelper = ComponentUtil.getPluginHelper(); + final Artifact artifact = + pluginHelper.getArtifactFromFileName(ArtifactType.UNKNOWN, filename, tempFile.getAbsolutePath()); + pluginHelper.installArtifact(artifact); + } catch (final Exception e) { + logger.warn("Failed to install " + filename, e); + } finally { + if (tempFile.exists() && !tempFile.delete()) { + logger.warn("Failed to delete {}.", tempFile.getAbsolutePath()); + } + } + }).start(); + saveInfo(messages -> messages.addSuccessInstallPlugin(GLOBAL, form.jarFile.getFileName())); + } else { + final Artifact artifact = getArtifactFromInstallForm(form); + if (artifact == null) { + throwValidationError(messages -> messages.addErrorsCrudCouldNotFindCrudTable(GLOBAL, form.id), () -> asListHtml()); + } + installArtifact(artifact); + saveInfo(messages -> messages.addSuccessInstallPlugin(GLOBAL, artifact.getFileName())); } - installArtifact(artifact); - saveInfo(messages -> messages.addSuccessInstallPlugin(GLOBAL, artifact.getFileName())); return redirect(getClass()); } @@ -88,6 +132,11 @@ public class AdminPluginAction extends FessAdminAction { public static List> getAllAvailableArtifacts() { final PluginHelper pluginHelper = ComponentUtil.getPluginHelper(); final List> result = new ArrayList<>(); + final Map map = new HashMap<>(); + map.put("id", UPLOAD); + map.put("name", ""); + map.put("version", ""); + result.add(map); for (final PluginHelper.ArtifactType artifactType : PluginHelper.ArtifactType.values()) { result.addAll(Arrays.stream(pluginHelper.getAvailableArtifacts(artifactType)).map(AdminPluginAction::beanToMap) .collect(Collectors.toList())); diff --git a/src/main/java/org/codelibs/fess/app/web/admin/plugin/InstallForm.java b/src/main/java/org/codelibs/fess/app/web/admin/plugin/InstallForm.java index f119697a0..db1b88881 100644 --- a/src/main/java/org/codelibs/fess/app/web/admin/plugin/InstallForm.java +++ b/src/main/java/org/codelibs/fess/app/web/admin/plugin/InstallForm.java @@ -17,6 +17,7 @@ package org.codelibs.fess.app.web.admin.plugin; import javax.validation.constraints.Size; +import org.lastaflute.web.ruts.multipart.MultipartFormFile; import org.lastaflute.web.validation.Required; public class InstallForm { @@ -25,4 +26,5 @@ public class InstallForm { @Size(max = 400) public String id; + public MultipartFormFile jarFile; } diff --git a/src/main/java/org/codelibs/fess/helper/PluginHelper.java b/src/main/java/org/codelibs/fess/helper/PluginHelper.java index 198bf0597..c40aee625 100644 --- a/src/main/java/org/codelibs/fess/helper/PluginHelper.java +++ b/src/main/java/org/codelibs/fess/helper/PluginHelper.java @@ -19,6 +19,7 @@ import static org.codelibs.core.stream.StreamUtil.split; import java.io.ByteArrayInputStream; import java.io.File; +import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; @@ -65,7 +66,8 @@ public class PluginHelper { protected LoadingCache availableArtifacts = CacheBuilder.newBuilder().maximumSize(10) .expireAfterWrite(5, TimeUnit.MINUTES).build(new CacheLoader() { - public Artifact[] load(ArtifactType key) { + @Override + public Artifact[] load(final ArtifactType key) { final List list = new ArrayList<>(); for (final String url : getRepositories()) { if (url.endsWith(".yaml")) { @@ -95,16 +97,16 @@ public class PluginHelper { protected List loadArtifactsFromRepository(final String url) { final String content = getRepositoryContent(url); - ObjectMapper objectMapper = new YAMLMapper(); + final ObjectMapper objectMapper = new YAMLMapper(); try { @SuppressWarnings("unchecked") - List> result = objectMapper.readValue(content, List.class); + final List> result = objectMapper.readValue(content, List.class); if (result != null) { return result.stream().map(o -> new Artifact((String) o.get("name"), (String) o.get("version"), (String) o.get("url"))) .collect(Collectors.toList()); } return Collections.emptyList(); - } catch (Exception e) { + } catch (final Exception e) { throw new PluginException("Failed to access " + url, e); } } @@ -129,7 +131,7 @@ public class PluginHelper { if (version.endsWith("SNAPSHOT")) { final String snapshotVersion = getSnapshotActualVersion(builder, pluginUrl, version); if (StringUtil.isNotBlank(snapshotVersion)) { - String actualVersion = version.replace("SNAPSHOT", snapshotVersion); + final String actualVersion = version.replace("SNAPSHOT", snapshotVersion); list.add(new Artifact(name, actualVersion, pluginUrl + version + "/" + name + "-" + actualVersion + ".jar")); } else if (logger.isDebugEnabled()) { logger.debug("Snapshot name is not found: " + name + "/" + version); @@ -161,7 +163,7 @@ public class PluginHelper { final Document doc = builder.parse(is); final NodeList snapshotNodeList = doc.getElementsByTagName("snapshot"); if (snapshotNodeList.getLength() > 0) { - NodeList nodeList = snapshotNodeList.item(0).getChildNodes(); + final NodeList nodeList = snapshotNodeList.item(0).getChildNodes(); for (int i = 0; i < nodeList.getLength(); i++) { final Node node = nodeList.item(i); if ("timestamp".equalsIgnoreCase(node.getNodeName())) { @@ -192,7 +194,7 @@ public class PluginHelper { public Artifact[] getInstalledArtifacts(final ArtifactType artifactType) { if (artifactType == ArtifactType.UNKNOWN) { final File[] jarFiles = ResourceUtil.getPluginJarFiles((d, n) -> { - for (ArtifactType type : ArtifactType.values()) { + for (final ArtifactType type : ArtifactType.values()) { if (n.startsWith(type.getId())) { return false; } @@ -216,12 +218,16 @@ public class PluginHelper { return list.toArray(new Artifact[list.size()]); } - protected Artifact getArtifactFromFileName(final ArtifactType artifactType, final String name) { - String baseName = StringUtils.removeEndIgnoreCase(name, ".jar"); - List nameList = new ArrayList<>(); - List versionList = new ArrayList<>(); + protected Artifact getArtifactFromFileName(final ArtifactType artifactType, final String filename) { + return getArtifactFromFileName(artifactType, filename, null); + } + + public Artifact getArtifactFromFileName(final ArtifactType artifactType, final String filename, final String url) { + final String baseName = StringUtils.removeEndIgnoreCase(filename, ".jar"); + final List nameList = new ArrayList<>(); + final List versionList = new ArrayList<>(); boolean isName = true; - for (String value : baseName.split("-")) { + for (final String value : baseName.split("-")) { if (isName && value.length() > 0 && value.charAt(0) >= '0' && value.charAt(0) <= '9') { isName = false; } @@ -231,21 +237,31 @@ public class PluginHelper { versionList.add(value); } } - return new Artifact(nameList.stream().collect(Collectors.joining("-")), versionList.stream().collect(Collectors.joining("-"))); + return new Artifact(nameList.stream().collect(Collectors.joining("-")), versionList.stream().collect(Collectors.joining("-")), url); } public void installArtifact(final Artifact artifact) { final String fileName = artifact.getFileName(); - try (final CurlResponse response = Curl.get(artifact.getUrl()).execute()) { - if (response.getHttpStatusCode() != 200) { - throw new PluginException("HTTP Status " + response.getHttpStatusCode() + " : failed to get the artifact from " - + artifact.getUrl()); + final String url = artifact.getUrl(); + if (StringUtil.isBlank(url)) { + throw new PluginException("url is blank: " + artifact.getName()); + } else if (url.startsWith("http:") || url.startsWith("https:")) { + try (final CurlResponse response = Curl.get(url).execute()) { + if (response.getHttpStatusCode() != 200) { + throw new PluginException("HTTP Status " + response.getHttpStatusCode() + " : failed to get the artifact from " + url); + } + try (final InputStream in = response.getContentAsStream()) { + CopyUtil.copy(in, ResourceUtil.getPluginPath(fileName).toFile()); + } + } catch (final Exception e) { + throw new PluginException("Failed to install the artifact " + artifact.getName(), e); } - try (final InputStream in = response.getContentAsStream()) { + } else { + try (final InputStream in = new FileInputStream(url)) { CopyUtil.copy(in, ResourceUtil.getPluginPath(fileName).toFile()); + } catch (final Exception e) { + throw new PluginException("Failed to install the artifact " + artifact.getName(), e); } - } catch (final Exception e) { - throw new PluginException("Failed to install the artifact " + artifact.getName(), e); } switch (artifact.getType()) { @@ -283,7 +299,7 @@ public class PluginHelper { } } - public Artifact getArtifact(String name, String version) { + public Artifact getArtifact(final String name, final String version) { if (StringUtil.isBlank(name) || StringUtil.isBlank(version)) { return null; } diff --git a/src/main/java/org/codelibs/fess/helper/SystemHelper.java b/src/main/java/org/codelibs/fess/helper/SystemHelper.java index 4a499215d..6962332c2 100644 --- a/src/main/java/org/codelibs/fess/helper/SystemHelper.java +++ b/src/main/java/org/codelibs/fess/helper/SystemHelper.java @@ -50,6 +50,7 @@ import org.apache.commons.lang3.LocaleUtils; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.Level; import org.apache.logging.log4j.core.config.Configurator; +import org.codelibs.core.exception.IORuntimeException; import org.codelibs.core.lang.StringUtil; import org.codelibs.core.misc.Pair; import org.codelibs.fess.Constants; @@ -469,6 +470,14 @@ public class SystemHelper { return System.getProperty(Constants.FESS_LOG_LEVEL, Level.WARN.toString()); } + public File createTempFile(String prefix, String suffix) { + try { + return File.createTempFile(prefix, suffix); + } catch (IOException e) { + throw new IORuntimeException(e); + } + } + public String getVersion() { return version; } @@ -484,5 +493,4 @@ public class SystemHelper { public String getProductVersion() { return productVersion; } - } diff --git a/src/main/java/org/codelibs/fess/mylasta/action/FessLabels.java b/src/main/java/org/codelibs/fess/mylasta/action/FessLabels.java index adec89c36..ada7b0572 100644 --- a/src/main/java/org/codelibs/fess/mylasta/action/FessLabels.java +++ b/src/main/java/org/codelibs/fess/mylasta/action/FessLabels.java @@ -2976,6 +2976,15 @@ public class FessLabels extends UserMessages { /** The key of the message: Install Plugin */ public static final String LABELS_plugin_install_title = "{labels.plugin_install_title}"; + /** The key of the message: Jar File */ + public static final String LABELS_plugin_jar_file = "{labels.plugin_jar_file}"; + + /** The key of the message: Local */ + public static final String LABELS_plugin_local_install = "{labels.plugin_local_install}"; + + /** The key of the message: Remote */ + public static final String LABELS_plugin_remote_install = "{labels.plugin_remote_install}"; + /** The key of the message: Install */ public static final String LABELS_crud_button_install = "{labels.crud_button_install}"; diff --git a/src/main/java/org/codelibs/fess/mylasta/action/FessMessages.java b/src/main/java/org/codelibs/fess/mylasta/action/FessMessages.java index 9e7fd63dd..ee9c26195 100644 --- a/src/main/java/org/codelibs/fess/mylasta/action/FessMessages.java +++ b/src/main/java/org/codelibs/fess/mylasta/action/FessMessages.java @@ -347,6 +347,12 @@ public class FessMessages extends FessLabels { /** The key of the message: {0} is not supported. */ public static final String ERRORS_file_is_not_supported = "{errors.file_is_not_supported}"; + /** The key of the message: {0} is not found. */ + public static final String ERRORS_plugin_file_is_not_found = "{errors.plugin_file_is_not_found}"; + + /** The key of the message: Failed to install {0}. */ + public static final String ERRORS_failed_to_install_plugin = "{errors.failed_to_install_plugin}"; + /** The key of the message: The given query has unknown condition. */ public static final String ERRORS_invalid_query_unknown = "{errors.invalid_query_unknown}"; @@ -2034,6 +2040,36 @@ public class FessMessages extends FessLabels { return this; } + /** + * Add the created action message for the key 'errors.plugin_file_is_not_found' with parameters. + *
+     * message: {0} is not found.
+     * 
+ * @param property The property name for the message. (NotNull) + * @param arg0 The parameter arg0 for message. (NotNull) + * @return this. (NotNull) + */ + public FessMessages addErrorsPluginFileIsNotFound(String property, String arg0) { + assertPropertyNotNull(property); + add(property, new UserMessage(ERRORS_plugin_file_is_not_found, arg0)); + return this; + } + + /** + * Add the created action message for the key 'errors.failed_to_install_plugin' with parameters. + *
+     * message: Failed to install {0}.
+     * 
+ * @param property The property name for the message. (NotNull) + * @param arg0 The parameter arg0 for message. (NotNull) + * @return this. (NotNull) + */ + public FessMessages addErrorsFailedToInstallPlugin(String property, String arg0) { + assertPropertyNotNull(property); + add(property, new UserMessage(ERRORS_failed_to_install_plugin, arg0)); + return this; + } + /** * Add the created action message for the key 'errors.invalid_query_unknown' with parameters. *
diff --git a/src/main/resources/fess_label.properties b/src/main/resources/fess_label.properties
index 94be67ea0..9abe33a49 100644
--- a/src/main/resources/fess_label.properties
+++ b/src/main/resources/fess_label.properties
@@ -983,4 +983,7 @@ labels.plugin_version=Version
 labels.plugin_delete=Delete
 labels.plugin_install=Install
 labels.plugin_install_title=Install Plugin
+labels.plugin_jar_file=Jar File
+labels.plugin_local_install=Local
+labels.plugin_remote_install=Remote
 labels.crud_button_install=Install
diff --git a/src/main/resources/fess_label_en.properties b/src/main/resources/fess_label_en.properties
index 94be67ea0..9abe33a49 100644
--- a/src/main/resources/fess_label_en.properties
+++ b/src/main/resources/fess_label_en.properties
@@ -983,4 +983,7 @@ labels.plugin_version=Version
 labels.plugin_delete=Delete
 labels.plugin_install=Install
 labels.plugin_install_title=Install Plugin
+labels.plugin_jar_file=Jar File
+labels.plugin_local_install=Local
+labels.plugin_remote_install=Remote
 labels.crud_button_install=Install
diff --git a/src/main/resources/fess_label_ja.properties b/src/main/resources/fess_label_ja.properties
index b1153644e..30e1eb901 100644
--- a/src/main/resources/fess_label_ja.properties
+++ b/src/main/resources/fess_label_ja.properties
@@ -983,4 +983,7 @@ labels.plugin_version=バージョン
 labels.plugin_delete=削除
 labels.plugin_install=インストール
 labels.plugin_install_title=プラグインのインストール
+labels.plugin_jar_file=Jarファイル
+labels.plugin_local_install=ローカル
+labels.plugin_remote_install=リモート
 labels.crud_button_install=インストール
diff --git a/src/main/resources/fess_message.properties b/src/main/resources/fess_message.properties
index 10c07b688..d27a131f1 100644
--- a/src/main/resources/fess_message.properties
+++ b/src/main/resources/fess_message.properties
@@ -137,6 +137,8 @@ errors.could_not_delete_logged_in_user=Could not delete logged in user.
 errors.unauthorized_request=Unauthorized request.
 errors.failed_to_print_thread_dump=Failed to print thread dump.
 errors.file_is_not_supported={0} is not supported.
+errors.plugin_file_is_not_found={0} is not found.
+errors.failed_to_install_plugin=Failed to install {0}.
 
 errors.invalid_query_unknown=The given query has unknown condition.
 errors.invalid_query_parse_error=The given query is invalid.
diff --git a/src/main/resources/fess_message_en.properties b/src/main/resources/fess_message_en.properties
index 814eb966c..27f23aeb7 100644
--- a/src/main/resources/fess_message_en.properties
+++ b/src/main/resources/fess_message_en.properties
@@ -133,6 +133,8 @@ errors.could_not_delete_logged_in_user=Could not delete logged in user.
 errors.unauthorized_request=Unauthorized request.
 errors.failed_to_print_thread_dump=Failed to print thread dump.
 errors.file_is_not_supported={0} is not supported.
+errors.plugin_file_is_not_found={0} is not found.
+errors.failed_to_install_plugin=Failed to install {0}.
 
 errors.invalid_query_unknown=The given query has unknown condition.
 errors.invalid_query_parse_error=The given query is invalid.
diff --git a/src/main/resources/fess_message_ja.properties b/src/main/resources/fess_message_ja.properties
index bf13d8beb..72207e3c7 100644
--- a/src/main/resources/fess_message_ja.properties
+++ b/src/main/resources/fess_message_ja.properties
@@ -23,7 +23,7 @@ constraints.NotNull.message = {item} は未入力です。
 constraints.Null.message = {item} は null でなければなりません。
 constraints.Past.message = {item} は過去の値にする必要があります。
 constraints.Pattern.message = {item} が 「{regexp}」 に一致しません。
-constraints.Size.message = {item}のサイズは {min} から {max} の範囲にしてください。
+constraints.Size.message = {item}のサイズは {min} 文字から {max} 文字の範囲にしてください。
 # ----------------------------------------------------------
 # Hibernate Validator
 # -------------------
@@ -139,6 +139,8 @@ errors.invalid_header_for_request_file=ヘッダー行が正しくありませ
 errors.could_not_delete_logged_in_user=ログインしているユーザーは削除できません。
 errors.failed_to_print_thread_dump=スレッドダンプの出力に失敗しました。
 errors.file_is_not_supported={0}はサポートされていません。
+errors.plugin_file_is_not_found={0}が見つかりません。
+errors.failed_to_install_plugin={0}のインストールに失敗しました。
 
 errors.property_required={0}は必須です。
 errors.property_type_integer={0}は数値です。
diff --git a/src/main/webapp/WEB-INF/view/admin/plugin/admin_plugin.jsp b/src/main/webapp/WEB-INF/view/admin/plugin/admin_plugin.jsp
index 6ad400c29..5ecb6a61f 100644
--- a/src/main/webapp/WEB-INF/view/admin/plugin/admin_plugin.jsp
+++ b/src/main/webapp/WEB-INF/view/admin/plugin/admin_plugin.jsp
@@ -1,131 +1,129 @@
 <%@page pageEncoding="UTF-8" contentType="text/html; charset=UTF-8"%>
 
 
-    
-    <la:message key="labels.admin_brand_title" /> | <la:message
-            key="labels.plugin_title" />
-    
+
+<la:message key="labels.admin_brand_title" /> | <la:message key="labels.plugin_title" />
+
 
 
-
- - - - - -
-
-

- -

- -
-
-
-
-
-
-

- -

-
- - - - +
+ + + + + +
+
+

+ +

+ +
+
+
+
+
+
+

+ +

+
+ + + + +
+ +
+ <%-- Message --%> +
+ +
${msg}
+
+ +
+ <%-- List --%> +
+
+
+ + + + + + + + + + + + + + + + + +
${f:h(artifact.type)}${f:h(artifact.name)}${f:h(artifact.version)} + + +
+
+
+
+ +
+
- -
- <%-- Message --%> -
- -
${msg}
-
- -
- <%-- List --%> -
-
-
- - - - - - - - - - - - - - - - - -
${f:h(artifact.type)}${f:h(artifact.name)}${f:h(artifact.version)} - - -
-
-
-
- -
- -
- -
-
-
-
- -
- + + + + + + + + diff --git a/src/main/webapp/WEB-INF/view/admin/plugin/admin_plugin_installplugin.jsp b/src/main/webapp/WEB-INF/view/admin/plugin/admin_plugin_installplugin.jsp index 21279d1bf..1c291b90e 100644 --- a/src/main/webapp/WEB-INF/view/admin/plugin/admin_plugin_installplugin.jsp +++ b/src/main/webapp/WEB-INF/view/admin/plugin/admin_plugin_installplugin.jsp @@ -1,77 +1,97 @@ <%@page pageEncoding="UTF-8" contentType="text/html; charset=UTF-8"%> - - <la:message key="labels.admin_brand_title" /> | <la:message - key="labels.plugin_install_title" /> - + +<la:message key="labels.admin_brand_title" /> | <la:message key="labels.plugin_install_title" /> + -
- - - - - -
-
-

- -

-
-
-
-
- -
${msg}
-
- -
-
-
- -
-

- -

-
- -
-
- - - - ${f:h(item.name)}-${f:h(item.version)} - - -
-
- - - -
-
- -
-
-
-
- -
- +
+ + + + + +
+
+

+ +

+
+
+
+
+ +
${msg}
+
+ +
+
+
+ +
+

+ +

+
+ +
+ +
+
+
+
+ + + + ${f:h(item.name)}-${f:h(item.version)} + + +
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+ + + +
+
+ +
+
+
+
+ +
+