diff --git a/changelog.md b/changelog.md
index 392c7852..d0f469c9 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,6 +1,9 @@
# Changelog
-version 5.8.0 - upcoming
+version 5.9.0
+ * added [marketplace](/milkman-plugins-management#marketplace) for plugins
+
+version 5.8.0
* base64 keytype
* Oauth: Authentication Grant flow now supports choosing port for return url
* extended information on http responses (ssl certificate, body size etc)
diff --git a/docs/features.md b/docs/features.md
index 6a9405a7..8b4bfdfe 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -1,6 +1,11 @@
# Features
+## Marketplace
+There is a marketplace where plugins are shown that are released on github.
+Instructions on how to publish your own plugins can be seen (here)[/docs/plugin-development.md].
+
+![Marketplace](/img/marketplace.png)
## Code Folding
diff --git a/docs/plugin-development.md b/docs/plugin-development.md
index d6142ae3..68399e77 100644
--- a/docs/plugin-development.md
+++ b/docs/plugin-development.md
@@ -5,6 +5,24 @@ A [sample plugin](https://github.com/warmuuh/milkman/tree/master/milkman-note) w
if you want to setup a new project, an exemplary pom can be found [here](/docs/plugin-development-setup.md).
+
+## Marketplace registration
+if you want your plugin to show up in the milkman marketplace, you need to add a topic (aka tag) to your repository `milkman-plugins` and a
+descriptor file `milkman-plugin.json` at the root which looks like this:
+
+```json
+{
+ "plugins": [
+ {
+ "author": "your name",
+ "name": "your plugin id",
+ "artifact": "the filename of the according artifact in your releases",
+ "description": "some description"
+ }
+ ]
+}
+```
+
# Data Model
![img](http://www.gravizo.com/svg?@startuml;object%20Workspace;object%20Environment;Environment%20:%20isGlobal;Workspace%20o--%20%22*%22%20Environment;object%20Collection;Workspace%20o--%20%22*%22%20Collection;object%20Request;Collection%20o--%20%22*%22%20Request;%20Request%20--%3E%20HtmlRequest%20;%20Request%20--%3E%20SqlRequest%20;Request%20o-%20%22*%22%20RequestAspect;RequestAspect%20--%3E%20HttpHeaderRequestAspect;RequestAspect%20--%3E%20HttpBodyRequestAspect;@enduml)
diff --git a/img/marketplace.png b/img/marketplace.png
new file mode 100644
index 00000000..518f18d3
Binary files /dev/null and b/img/marketplace.png differ
diff --git a/milkman-plugins-management/README.md b/milkman-plugins-management/README.md
index 73b087d3..80ecfa13 100644
--- a/milkman-plugins-management/README.md
+++ b/milkman-plugins-management/README.md
@@ -14,3 +14,11 @@ Click the Add button and choose the plugin file in the open dialog. After instal
### Uninstalling Plugins
All currently installed plugins are shown in the table. Find the plugin you don't want to use anymore and click the `X` button.
+
+
+## Marketplace
+
+There is a marketplace where plugins are shown that are released on github.
+Instructions on how to publish your own plugins can be seen (here)[/docs/plugin-development.md].
+
+![Marketplace](/img/marketplace.png)
diff --git a/milkman-plugins-management/src/main/java/milkman/ui/plugins/management/market/GithubApiClient.java b/milkman-plugins-management/src/main/java/milkman/ui/plugins/management/market/GithubApiClient.java
new file mode 100644
index 00000000..df46c08c
--- /dev/null
+++ b/milkman-plugins-management/src/main/java/milkman/ui/plugins/management/market/GithubApiClient.java
@@ -0,0 +1,84 @@
+package milkman.ui.plugins.management.market;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.List;
+import lombok.SneakyThrows;
+
+public class GithubApiClient {
+
+
+ private ObjectMapper mapper = new ObjectMapper();
+
+ public GithubApiClient() {
+ mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
+ }
+
+ @SneakyThrows
+ public List fetchPluginRepos() {
+ return searchForRepos("milkman-plugins")
+ .items().stream()
+ .flatMap(res -> loadPluginDescriptorForRepo(res.fullName())
+ .stream())
+ .toList();
+ }
+
+ private GithubRepoSearchResultList searchForRepos(String topic) throws IOException {
+ InputStream inputStream = new URL(
+ "https://api.github.com/search/repositories?q=topic:" + topic +
+ "&sort=created&order=asc").openStream();
+ GithubRepoSearchResultList searchResult =
+ mapper.readValue(inputStream, GithubRepoSearchResultList.class);
+ return searchResult;
+ }
+
+ @SneakyThrows
+ private List loadPluginDescriptorForRepo(String fullRepoName) {
+ HttpURLConnection con =
+ (HttpURLConnection) new URL("https://api.github.com/repos/" + fullRepoName +
+ "/contents/milkman-plugin.json").openConnection();
+ con.setRequestProperty("Accept", "application/vnd.github.raw+json");
+ con.connect();
+
+ if (con.getResponseCode() != 200) {
+ return List.of();
+ }
+
+ GithubPluginRepoDescription result =
+ mapper.readValue(con.getInputStream(), GithubPluginRepoDescription.class);
+
+
+ return result.plugins().stream().map(
+ p -> new MarketplacePlugin(p.author(), p.name(), p.description(),
+ "https://github.com/" + fullRepoName + "/releases/latest/download/" + p.artifact(),
+ "https:/github.com/" + fullRepoName)
+ ).toList();
+ }
+
+
+ record GithubRepoSearchResultList(
+ List items
+ ) {
+
+ }
+
+ record GithubRepoSearchResult(@JsonProperty("full_name") String fullName) {
+
+ }
+
+ public record GithubPluginRepoDescription(List plugins) {
+ }
+
+ public record GithubPluginDescription(String author, String name, String artifact, String description) {
+
+ }
+
+ public record MarketplacePlugin(String author, String name, String description, String artifactUrl, String documentationUrl) {
+ }
+
+}
diff --git a/milkman-plugins-management/src/main/java/milkman/ui/plugins/management/market/MarketplaceDialog.java b/milkman-plugins-management/src/main/java/milkman/ui/plugins/management/market/MarketplaceDialog.java
new file mode 100644
index 00000000..ee91c7de
--- /dev/null
+++ b/milkman-plugins-management/src/main/java/milkman/ui/plugins/management/market/MarketplaceDialog.java
@@ -0,0 +1,170 @@
+package milkman.ui.plugins.management.market;
+
+import static milkman.utils.fxml.FxmlBuilder.button;
+import static milkman.utils.fxml.FxmlBuilder.cancel;
+import static milkman.utils.fxml.FxmlBuilder.hbox;
+import static milkman.utils.fxml.FxmlBuilder.icon;
+import static milkman.utils.fxml.FxmlBuilder.label;
+import static milkman.utils.fxml.FxmlBuilder.text;
+import static milkman.utils.fxml.FxmlBuilder.vbox;
+
+import com.jfoenix.controls.JFXButton;
+import com.jfoenix.controls.JFXDialogLayout;
+import com.jfoenix.controls.JFXListCell;
+import com.jfoenix.controls.JFXTextField;
+import de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon;
+import java.time.Duration;
+import java.util.Comparator;
+import javafx.application.Platform;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import javafx.collections.transformation.FilteredList;
+import javafx.collections.transformation.SortedList;
+import javafx.scene.Node;
+import javafx.scene.control.Dialog;
+import javafx.scene.control.Label;
+import javafx.scene.control.ListView;
+import javafx.scene.layout.Border;
+import javafx.scene.layout.BorderStroke;
+import javafx.scene.layout.BorderStrokeStyle;
+import javafx.scene.layout.BorderWidths;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Priority;
+import javafx.scene.paint.Paint;
+import javafx.scene.text.Font;
+import javafx.scene.text.FontWeight;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import milkman.PlatformUtil;
+import milkman.ui.plugins.management.market.GithubApiClient.MarketplacePlugin;
+import milkman.utils.fxml.FxmlBuilder;
+import milkman.utils.fxml.FxmlUtil;
+import org.reactfx.EventStreams;
+
+public class MarketplaceDialog {
+
+ private JFXTextField txt_search;
+ private GithubApiClient apiClient = new GithubApiClient();
+ private Dialog dialog;
+ private ListView pluginList;
+ @Getter
+ private MarketplacePlugin chosenPlugin;
+
+
+ @Getter
+ boolean cancelled = true;
+
+ public MarketplaceDialog() {
+ }
+
+ public void showAndWait() {
+ JFXDialogLayout content = new MarketplaceDialogFxml(this);
+
+ pluginList.setCellFactory(view -> new MarketplacePluginCell());
+ pluginList.setMinWidth(600);
+ pluginList.setBorder(new Border(
+ new BorderStroke(Paint.valueOf("black"), BorderStrokeStyle.SOLID, null,
+ new BorderWidths(1))));
+ ObservableList observableList =
+ FXCollections.observableArrayList(apiClient.fetchPluginRepos());
+ SortedList sortedList =
+ observableList.sorted(Comparator.comparing(MarketplacePlugin::name));
+ FilteredList filteredList = new FilteredList<>(sortedList);
+
+
+ EventStreams.nonNullValuesOf(txt_search.textProperty())
+ .successionEnds(Duration.ofMillis(250))
+ .subscribe(qry -> setFilterPredicate(filteredList, qry));
+
+
+ pluginList.setItems(filteredList);
+
+
+
+
+ dialog = FxmlUtil.createDialog(content);
+ dialog.showAndWait();
+ }
+
+ private void setFilterPredicate(FilteredList filteredList, String searchTerm) {
+ if (searchTerm != null && !searchTerm.isEmpty()) {
+ filteredList.setPredicate(p -> p.name().toLowerCase().contains(searchTerm.toLowerCase())
+ || p.description().toLowerCase().contains(searchTerm.toLowerCase())
+ || p.author().toLowerCase().contains(searchTerm.toLowerCase()));
+ } else {
+ filteredList.setPredicate(o -> true);
+ }
+
+ }
+
+ private void onSave(MarketplacePlugin chosenPlugin) {
+ cancelled = false;
+ this.chosenPlugin = chosenPlugin;
+ dialog.close();
+ }
+
+ private void onCancel() {
+ cancelled = true;
+ dialog.close();
+ }
+
+ public static class MarketplaceDialogFxml extends JFXDialogLayout {
+
+ public MarketplaceDialogFxml(MarketplaceDialog controller) {
+ setHeading(label("Plugin Marketplace"));
+
+ setBody(vbox(
+ controller.txt_search = text("plugin-search", "search for plugins..."),
+ controller.pluginList = new ListView<>()
+ )
+ );
+
+ setActions(cancel(controller::onCancel));
+ }
+ }
+
+ @RequiredArgsConstructor
+ class MarketplacePluginCell extends JFXListCell {
+ @Override
+ protected void updateItem(MarketplacePlugin plugin, boolean empty) {
+ super.updateItem(plugin, empty);
+ if (empty || plugin == null) {
+ setText(null);
+ setGraphic(null);
+ } else {
+ setText(null);
+ setGraphic(createEntry(plugin));
+ }
+ }
+
+ private Node createEntry(MarketplacePlugin plugin) {
+ Label title = label(plugin.name());
+ title.setFont(Font.font(title.getFont().getFamily(), FontWeight.EXTRA_BOLD,
+ title.getFont().getSize() + 4));
+
+ Label label = label("Author: " + plugin.author());
+ label.setOpacity(0.7);
+
+ Label desc = label(plugin.description());
+ desc.setMaxWidth(400);
+ FxmlBuilder.VboxExt pluginDesc = vbox(
+ title,
+ desc,
+ hbox(label)
+ );
+ HBox.setHgrow(pluginDesc, Priority.ALWAYS);
+
+ JFXButton visitButton = button("marketplace.goto-plugin", icon(FontAwesomeIcon.GLOBE), () -> {
+ PlatformUtil.tryOpenBrowser(plugin.documentationUrl());
+ }
+ );
+ JFXButton installButton = button("marketplace.install-plugin", icon(FontAwesomeIcon.DOWNLOAD), () -> {
+ Platform.runLater(() -> onSave(plugin));
+ }
+ );
+
+ FxmlBuilder.HboxExt content = hbox(pluginDesc, vbox(visitButton, installButton));
+ return content;
+ }
+ }
+}
diff --git a/milkman-plugins-management/src/main/java/milkman/ui/plugins/management/options/PluginManager.java b/milkman-plugins-management/src/main/java/milkman/ui/plugins/management/options/PluginManager.java
index 4179b92b..c802053d 100644
--- a/milkman-plugins-management/src/main/java/milkman/ui/plugins/management/options/PluginManager.java
+++ b/milkman-plugins-management/src/main/java/milkman/ui/plugins/management/options/PluginManager.java
@@ -2,6 +2,8 @@
import java.io.File;
import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
@@ -15,7 +17,10 @@
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType;
+import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import static milkman.ExceptionDialog.showExceptionDialog;
@@ -25,6 +30,18 @@
@Slf4j
public class PluginManager {
+ @SneakyThrows
+ public Optional downloadAndInstallPlugin(String pluginFileUrl) {
+ InputStream in = new URL(pluginFileUrl).openStream();
+ File tempFile = File.createTempFile("milkman-plugin-", ".jar");
+ FileUtils.copyInputStreamToFile(in, tempFile);
+ try {
+ return installPlugin(tempFile);
+ } finally {
+ tempFile.delete();
+ }
+ }
+
public Optional installPlugin(File pluginFile) {
if (isValidPluginFile(pluginFile)) {
return copyPluginFile(pluginFile);
diff --git a/milkman-plugins-management/src/main/java/milkman/ui/plugins/management/options/PluginsManagementOptionsProvider.java b/milkman-plugins-management/src/main/java/milkman/ui/plugins/management/options/PluginsManagementOptionsProvider.java
index 51657764..5ff75850 100644
--- a/milkman-plugins-management/src/main/java/milkman/ui/plugins/management/options/PluginsManagementOptionsProvider.java
+++ b/milkman-plugins-management/src/main/java/milkman/ui/plugins/management/options/PluginsManagementOptionsProvider.java
@@ -15,6 +15,7 @@
import milkman.ui.main.options.OptionDialogBuilder;
import milkman.ui.main.options.OptionDialogPane;
import milkman.ui.plugin.OptionPageProvider;
+import milkman.ui.plugins.management.market.MarketplaceDialog;
import milkman.utils.fxml.FxmlUtil;
import static milkman.ui.plugins.management.options.PluginManagementMessages.CONFIRMATION_MESSAGE_UNINSTALL_PLUGIN;
@@ -53,16 +54,25 @@ public OptionDialogPane getOptionsDialog(OptionDialogBuilder builder) {
.page("Plugins", getOptions())
.section("Plugins")
.list()
- .itemProvider(PluginsManagementOptions::getPlugins)
- .columnValueProviders(columnValueProviders)
- .columnValueProviders(columnValueProviders)
- .newValueProvider(this::installPlugin)
- .vetoableListChangeListener(this::uninstallPlugin)
+ .itemProvider(PluginsManagementOptions::getPlugins)
+ .columnValueProviders(columnValueProviders)
+ .columnValueProviders(columnValueProviders)
+ .newValueProvider(this::installPlugin)
+ .vetoableListChangeListener(this::uninstallPlugin)
.endList()
+ .button("Marketplace...", this::installFromMarketplace)
.endSection()
.build();
}
+ private void installFromMarketplace() {
+ var marketplaceDialog = new MarketplaceDialog();
+ marketplaceDialog.showAndWait();
+ if (!marketplaceDialog.isCancelled()) {
+ pluginManager.downloadAndInstallPlugin(marketplaceDialog.getChosenPlugin().artifactUrl());
+ }
+ }
+
private PluginMetaData installPlugin() {
var fileChooser = new FileChooser();
fileChooser.setTitle("Use Plugin");