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");