diff --git a/.all-contributorsrc b/.all-contributorsrc index 01f343b..99aa83a 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -133,6 +133,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/22429087?v=4", "profile": "https://dev.to/kkazala", "contributions": ["bug", "code", "maintenance", "test", "userTesting"] + }, + { + "login": "Mosh-K", + "name": "Mosh-K", + "avatar_url": "https://avatars.githubusercontent.com/u/158998622?v=4", + "profile": "https://github.com/Mosh-K", + "contributions": ["code", "design", "userTesting"] } ], "contributorsPerLine": 7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c11c2d..3cdf061 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). -## [v2.2.6](https://github.com/Power-Maverick/DataverseDevTools-VSCode/compare/v2.2.5...v2.2.6) +## [v2.2.7](https://github.com/Power-Maverick/DataverseDevTools-VSCode/compare/v2.2.6...v2.2.7) ### Merged +- Fix garbled non-ASCII text in Compare with Server [`#322`](https://github.com/Power-Maverick/DataverseDevTools-VSCode/pull/322) +- Add plugin sync via pac plugin push [`#324`](https://github.com/Power-Maverick/DataverseDevTools-VSCode/pull/324) +- Feature: tools realignment [`#297`](https://github.com/Power-Maverick/DataverseDevTools-VSCode/pull/297) + +### Commits + +- Add tool icons and implement command registration for tools [`40bb631`](https://github.com/Power-Maverick/DataverseDevTools-VSCode/commit/40bb631ba721fed3fa7209228c86c50d72d3c312) +- Remove ERD generator function and its import from ToolsHelper [`429d4b1`](https://github.com/Power-Maverick/DataverseDevTools-VSCode/commit/429d4b1500ca682325a455a64edb47ac40a2fa57) +- Docs: Add @Mosh-K as a contributor [`b2922cf`](https://github.com/Power-Maverick/DataverseDevTools-VSCode/commit/b2922cf057e5e8a2a4ab75de46e4727e33cef5e6) + +## [v2.2.6](https://github.com/Power-Maverick/DataverseDevTools-VSCode/compare/v2.2.5...v2.2.6) - 2025-10-10 + +### Merged + +- Merge pull request #293 for release [`#294`](https://github.com/Power-Maverick/DataverseDevTools-VSCode/pull/294) - docs: Update README for v2.2.5 release with new features and bug fixes [`#293`](https://github.com/Power-Maverick/DataverseDevTools-VSCode/pull/293) - Fix: Context menu options not appearing after linking/initialization until VS Code restart [`#289`](https://github.com/Power-Maverick/DataverseDevTools-VSCode/pull/289) - Fix: Update command registration and improve status bar text for conn… [`#291`](https://github.com/Power-Maverick/DataverseDevTools-VSCode/pull/291) diff --git a/README.md b/README.md index ba5978b..43106fc 100644 --- a/README.md +++ b/README.md @@ -20,17 +20,11 @@ - - -

- - - - - + + @@ -66,6 +60,7 @@ - [Generate Typings](#generate-typings) - [Intellisense for type generated](#intellisense-for-type-generated) - [Upload Web Resources](#upload-web-resources) + - [Upload Plugins (pac plugin push)](#upload-plugins-pac-plugin-push) - [Filter by solution](#filter-by-solution) - [Entities](#entities) - [Web Resources](#web-resources) @@ -161,6 +156,24 @@ Once you build your project you can upload your script directly from the VS Code ![WebResourceUpload](https://github.com/Power-Maverick/DataverseDevTools-VSCode/blob/main/assets/WebResourceUpload.gif?raw=true) +### Upload Plugins (pac plugin push) + +You can push Dataverse plugins (assembly / NuGet package) directly from VS Code using the Power Platform CLI. + +**How it works** + +1. In VS Code Explorer, right-click your plugin project `.csproj` file. +2. Select **Dataverse DevTools: Link to Existing Dataverse Plugin** and pick the target plugin from your connected environment. + - This creates/updates a `dvdt.linker.xml` file in your workspace to remember the link (plugin id, type and environment). +3. Right-click the same `.csproj` file and select **Dataverse DevTools: Push Plugin to Dataverse**. + - Dataverse DevTools runs `pac plugin push --pluginId --type --environment ` in the project folder. + +**Prerequisites** + +- Power Platform CLI (`pac`) must be installed. +- `pac` must be authenticated to the target environment. + - If no auth profile is found, the extension will prompt you to run `pac auth create --environment ""`. + ### Filter by solution #### Entities @@ -311,6 +324,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Lars Hildebrandt
Lars Hildebrandt

πŸ’» πŸ€” πŸ› 🚧 ⚠️ Rob Montague
Rob Montague

πŸ› πŸ““ Kinga
Kinga

πŸ› πŸ’» 🚧 ⚠️ πŸ““ + Mosh-K
Mosh-K

πŸ’» 🎨 πŸ““ diff --git a/package-lock.json b/package-lock.json index 365b495..71b63e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dataverse-devtools", - "version": "2.2.5", + "version": "2.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dataverse-devtools", - "version": "2.2.5", + "version": "2.2.6", "dependencies": { "@azure/identity": "^3.0.0", "@azure/identity-vscode": "^1.0.0", diff --git a/package.json b/package.json index 4d2c219..4d1e1e5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dataverse-devtools", "displayName": "Dataverse DevTools", - "version": "2.2.6", + "version": "2.2.7", "description": "The all-in-one tool to develop code for Dataverse/Dynamics 365. Helps you connect to a Dataverse environment, generate TypeScript definitions for entities, create different type of Dataverse-specific projects and much more.", "publisher": "danish-naglekar", "author": { @@ -50,6 +50,7 @@ "Power Platform", "Dynamics 365", "Xrm", + "DVDT", "Dev Tools" ], "qna": "https://github.com/Power-Maverick/DataverseDevTools-VSCode/discussions", @@ -119,6 +120,16 @@ "command": "dvdt.explorer.webresources.compareWebResource", "when": "resourceExtname in devtools.resourcesExtn && resourceFilename in devtools.linkedResources", "group": "%dvdt.menus.group.wr%@3" + }, + { + "command": "dvdt.explorer.plugins.linkPlugin", + "when": "resourceExtname == .csproj", + "group": "%dvdt.menus.group.plugin%@1" + }, + { + "command": "dvdt.explorer.plugins.pushPlugin", + "when": "resourceExtname == .csproj", + "group": "%dvdt.menus.group.plugin%@2" } ], "view/title": [ @@ -522,6 +533,18 @@ "light": "resources/light/play.svg", "dark": "resources/dark/play.svg" } + }, + { + "command": "dvdt.explorer.plugins.linkPlugin", + "title": "Link to Existing Dataverse Plugin", + "when": "false", + "category": "%dvdt.explorer.category%" + }, + { + "command": "dvdt.explorer.plugins.pushPlugin", + "title": "Push Plugin to Dataverse", + "when": "false", + "category": "%dvdt.explorer.category%" } ], "configuration": { diff --git a/package.nls.json b/package.nls.json index 1a6f031..4303044 100644 --- a/package.nls.json +++ b/package.nls.json @@ -3,6 +3,7 @@ "dvdt.explorer.category": "Explorer", "dvdt.menus.group.ts": "dvdevtools-ts", "dvdt.menus.group.wr": "dvdevtools-wr", + "dvdt.menus.group.plugin": "dvdevtools-plugin", "dvdt.config.title": "Dataverse DevTools", "dvdt.config.enablePreview": "Enable a preview features for Dataverse DevTools. See [documentation for all available preview features](https://github.com/Power-Maverick/DataverseDevTools-VSCode#-early-access-preview).\n\n> _Note: these features are not fully tested; so if you encounter any bugs please report them on GitHub (your name will appear on the contributors list)._", "dvdt.config.defaultTSTemplate": "Specifies how Dataverse DevTools should behave when you initiate a TypeScript project. This will skip the extra question during TS project initialization when you either select `Plain TypeScript` or `Webpack`" diff --git a/resources/templates/dvdt.linker.xml b/resources/templates/dvdt.linker.xml index 584213a..c9d5ed0 100644 --- a/resources/templates/dvdt.linker.xml +++ b/resources/templates/dvdt.linker.xml @@ -2,6 +2,8 @@ + + \ No newline at end of file diff --git a/resources/toolIcons/cmt.png b/resources/toolIcons/dark/cmt.png similarity index 100% rename from resources/toolIcons/cmt.png rename to resources/toolIcons/dark/cmt.png diff --git a/resources/toolIcons/drb.png b/resources/toolIcons/dark/drb.png similarity index 100% rename from resources/toolIcons/drb.png rename to resources/toolIcons/dark/drb.png diff --git a/resources/toolIcons/dark/erd.svg b/resources/toolIcons/dark/erd.svg new file mode 100644 index 0000000..93f20a8 --- /dev/null +++ b/resources/toolIcons/dark/erd.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/toolIcons/dark/generic.svg b/resources/toolIcons/dark/generic.svg new file mode 100644 index 0000000..ae8f055 --- /dev/null +++ b/resources/toolIcons/dark/generic.svg @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/resources/toolIcons/pd.png b/resources/toolIcons/dark/pd.png similarity index 100% rename from resources/toolIcons/pd.png rename to resources/toolIcons/dark/pd.png diff --git a/resources/toolIcons/prt.png b/resources/toolIcons/dark/prt.png similarity index 100% rename from resources/toolIcons/prt.png rename to resources/toolIcons/dark/prt.png diff --git a/resources/toolIcons/light/cmt.png b/resources/toolIcons/light/cmt.png new file mode 100644 index 0000000..a880dcf Binary files /dev/null and b/resources/toolIcons/light/cmt.png differ diff --git a/resources/toolIcons/light/drb.png b/resources/toolIcons/light/drb.png new file mode 100644 index 0000000..969330a Binary files /dev/null and b/resources/toolIcons/light/drb.png differ diff --git a/resources/toolIcons/light/erd.svg b/resources/toolIcons/light/erd.svg new file mode 100644 index 0000000..9c4044b --- /dev/null +++ b/resources/toolIcons/light/erd.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/toolIcons/light/generic.svg b/resources/toolIcons/light/generic.svg new file mode 100644 index 0000000..02919c7 --- /dev/null +++ b/resources/toolIcons/light/generic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/toolIcons/light/pd.png b/resources/toolIcons/light/pd.png new file mode 100644 index 0000000..d6fc124 Binary files /dev/null and b/resources/toolIcons/light/pd.png differ diff --git a/resources/toolIcons/light/prt.png b/resources/toolIcons/light/prt.png new file mode 100644 index 0000000..d6fc124 Binary files /dev/null and b/resources/toolIcons/light/prt.png differ diff --git a/src/commands/registerCommands.ts b/src/commands/registerCommands.ts index b6a1db3..df10a9c 100644 --- a/src/commands/registerCommands.ts +++ b/src/commands/registerCommands.ts @@ -3,9 +3,10 @@ import * as vscode from "vscode"; import { CliCommandTreeItem } from "../cliCommands/cliCommandsDataProvider"; import { CLIHelper } from "../helpers/cliHelper"; import { DataverseHelper } from "../helpers/dataverseHelper"; -import { DRBHelper } from "../helpers/drbHelper"; import { ErrorHandler } from "../helpers/errorHandler"; +import { PluginHelper } from "../helpers/pluginHelper"; import { TemplateHelper } from "../helpers/templateHelper"; +import { ToolsHelper } from "../helpers/toolsHelper"; import { TypingsHelper } from "../helpers/typingsHelper"; import { WebResourceHelper } from "../helpers/webResourceHelper"; import { ToolsTreeItem } from "../tools/toolsDataProvider"; @@ -29,9 +30,10 @@ export async function registerCommands(vscontext: vscode.ExtensionContext, tr: T const cliHelper = new CLIHelper(vscontext); const templateHelper = new TemplateHelper(vscontext); const wrHelper = new WebResourceHelper(vscontext, dvHelper); + const pluginHelper = new PluginHelper(vscontext); const typingHelper = new TypingsHelper(vscontext, dvHelper); - const drbHelper = new DRBHelper(vscontext); const errorHandler = new ErrorHandler(tr); + const toolsHelper = new ToolsHelper(vscontext); dvStatusBarItem = vscode.window.createStatusBarItem(connectionStatusBarUniqueId, vscode.StatusBarAlignment.Left); vscontext.subscriptions.push(dvStatusBarItem); @@ -203,46 +205,6 @@ export async function registerCommands(vscontext: vscode.ExtensionContext, tr: T } }, }, - { - command: "dvdt.commands.openDRB", - callback: async () => { - try { - drbHelper.openDRB(views); - } catch (error) { - errorHandler.log(error, "openDRB"); - } - }, - }, - { - command: "dvdt.commands.launchPRT", - callback: async () => { - try { - cliHelper.launchPRT(); - } catch (error) { - errorHandler.log(error, "launchPRT"); - } - }, - }, - { - command: "dvdt.commands.launchCMT", - callback: async () => { - try { - cliHelper.launchCMT(); - } catch (error) { - errorHandler.log(error, "launchCMT"); - } - }, - }, - { - command: "dvdt.commands.launchPD", - callback: async () => { - try { - cliHelper.launchPD(); - } catch (error) { - errorHandler.log(error, "launchPD"); - } - }, - }, { command: "dvdt.explorer.webresources.linkExistingWebResource", callback: async (uri: vscode.Uri) => { @@ -257,22 +219,7 @@ export async function registerCommands(vscontext: vscode.ExtensionContext, tr: T command: "dvdt.explorer.tools.launchTool", callback: async (toolItem: ToolsTreeItem) => { try { - switch (toolItem.toolShortName) { - case "drb": - drbHelper.openDRB(views); - break; - case "prt": - cliHelper.launchPRT(); - break; - case "cmt": - cliHelper.launchCMT(); - break; - case "pd": - cliHelper.launchPD(); - break; - default: - break; - } + toolsHelper.openTool(toolItem); } catch (error) { errorHandler.log(error, "launchTool"); } @@ -308,6 +255,26 @@ export async function registerCommands(vscontext: vscode.ExtensionContext, tr: T } }, }, + { + command: "dvdt.explorer.plugins.linkPlugin", + callback: async (uri: vscode.Uri) => { + try { + await pluginHelper.linkPlugin(uri.fsPath); + } catch (error) { + errorHandler.log(error, "linkPlugin"); + } + }, + }, + { + command: "dvdt.explorer.plugins.pushPlugin", + callback: async (uri: vscode.Uri) => { + try { + await pluginHelper.pushPlugin(uri.fsPath); + } catch (error) { + errorHandler.log(error, "pushPlugin"); + } + }, + }, ); cmds.forEach((c) => { vscontext.subscriptions.push(vscode.commands.registerCommand(c.command, c.callback)); @@ -315,6 +282,7 @@ export async function registerCommands(vscontext: vscode.ExtensionContext, tr: T updateConnectionStatusBar(await dvHelper.reloadWorkspaceConnection()); vscode.commands.executeCommand("setContext", `${extensionPrefix}.linkedResources`, await wrHelper.getLinkedResourceStrings("@_localFileName")); + vscode.commands.executeCommand("setContext", `${extensionPrefix}.linkedPlugins`, await pluginHelper.getLinkedProjectPaths()); } /** diff --git a/src/commands/registerToolsCommands.ts b/src/commands/registerToolsCommands.ts new file mode 100644 index 0000000..30b425a --- /dev/null +++ b/src/commands/registerToolsCommands.ts @@ -0,0 +1,71 @@ +import TelemetryReporter from "@vscode/extension-telemetry"; +import * as vscode from "vscode"; +import { CLIHelper } from "../helpers/cliHelper"; +import { ErrorHandler } from "../helpers/errorHandler"; +import { ToolsHelper } from "../helpers/toolsHelper"; +import { ICommand } from "../utils/Interfaces"; +import { ViewBase } from "../views/ViewBase"; + +export async function registerToolsCommands(vscontext: vscode.ExtensionContext, tr: TelemetryReporter): Promise { + const toolHelper = new ToolsHelper(vscontext); + const views = new ViewBase(vscontext); + const errorHandler = new ErrorHandler(tr); + const cliHelper = new CLIHelper(vscontext); + + const cmds: Array = new Array( + { + command: "dvdt.commands.openDRB", + callback: async () => { + try { + toolHelper.openDRB(views); + } catch (error) { + errorHandler.log(error, "openDRB"); + } + }, + }, + // { + // command: "dvdt.commands.openERDGenerator", + // callback: async () => { + // try { + // toolHelper.openERDGenerator(); + // } catch (error) { + // errorHandler.log(error, "openERDGenerator"); + // } + // }, + // }, + { + command: "dvdt.commands.launchPRT", + callback: async () => { + try { + cliHelper.launchPRT(); + } catch (error) { + errorHandler.log(error, "launchPRT"); + } + }, + }, + { + command: "dvdt.commands.launchCMT", + callback: async () => { + try { + cliHelper.launchCMT(); + } catch (error) { + errorHandler.log(error, "launchCMT"); + } + }, + }, + { + command: "dvdt.commands.launchPD", + callback: async () => { + try { + cliHelper.launchPD(); + } catch (error) { + errorHandler.log(error, "launchPD"); + } + }, + }, + ); + + cmds.forEach((c) => { + vscontext.subscriptions.push(vscode.commands.registerCommand(c.command, c.callback)); + }); +} diff --git a/src/extension.ts b/src/extension.ts index 7ae5d89..85e7e34 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,6 +3,7 @@ import TelemetryReporter from "@vscode/extension-telemetry"; import * as vscode from "vscode"; import { registerCommands } from "./commands/registerCommands"; +import { registerToolsCommands } from "./commands/registerToolsCommands"; import { registerTreeDataProviders } from "./commands/registerTreeDataProviders"; import { DataverseHelper } from "./helpers/dataverseHelper"; import * as config from "./utils/Config"; @@ -32,6 +33,9 @@ export function activate(context: vscode.ExtensionContext) { registerTreeDataProviders(context, reporter); registerCommands(context, reporter); + registerToolsCommands(context, reporter); + + console.log(`Extension ${extensionId} v${extensionVersion} is now active!`); // Start periodic token expiration check startTokenExpirationCheck(context); @@ -59,20 +63,17 @@ export function deactivate() { */ function startTokenExpirationCheck(context: vscode.ExtensionContext) { let lastNotifiedExpiration = false; - + tokenExpirationTimer = setInterval(() => { const dvHelper = new DataverseHelper(context); const isExpired = dvHelper.isCurrentConnectionTokenExpired(); - + if (isExpired && !lastNotifiedExpiration) { // Get current connection to create tree item for reconnect const conn = dvHelper.getCurrentWorkspaceConnection(); - + // Show notification to user - vscode.window.showWarningMessage( - "Your Dataverse connection token has expired. Please reconnect to continue working.", - "Reconnect" - ).then(selection => { + vscode.window.showWarningMessage("Your Dataverse connection token has expired. Please reconnect to continue working.", "Reconnect").then((selection) => { if (selection === "Reconnect" && conn) { // Create a tree item with the connection name to pass to the command const connItem = { @@ -81,17 +82,17 @@ function startTokenExpirationCheck(context: vscode.ExtensionContext) { collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, level: 2, current: true, - expired: true + expired: true, }; vscode.commands.executeCommand("dvdt.explorer.connections.connectDataverse", connItem); } }); - + lastNotifiedExpiration = true; - + // Refresh the connection tree to show expired icon vscode.commands.executeCommand("dvdt.explorer.connections.refreshConnection"); - + // Update status bar to show expired state if (conn) { vscode.commands.executeCommand("dvdt.explorer.connections.updateStatusBar", conn); diff --git a/src/helpers/dataverseHelper.ts b/src/helpers/dataverseHelper.ts index ec96e09..1c4733a 100644 --- a/src/helpers/dataverseHelper.ts +++ b/src/helpers/dataverseHelper.ts @@ -10,6 +10,8 @@ import { customDataverseClientId, entityDefinitionsStoreKey, environmentTypes, + pluginAssembliesStoreKey, + pluginPackagesStoreKey, reservedWords, solDefinitionsStoreKey, wrDefinitionsStoreKey, @@ -24,6 +26,8 @@ import { IEntityMetadata, IOptionSet, IOptionSetMetadata, + IPluginAssemblies, + IPluginPackages, ISolutionComponents, ISolutions, IWebResource, @@ -145,8 +149,8 @@ export class DataverseHelper { this.vsstate.saveInWorkspace(connectionCurrentStoreKey, conn); progress.report({ increment: 30, message: "Getting entity metadata..." }); await this.getEntityDefinitions(); - progress.report({ increment: 70, message: "Getting web resources..." }); - await this.getWebResources(); + progress.report({ increment: 70, message: "Getting web resources & plugins..." }); + await Promise.all([this.getWebResources(), this.getPluginAssemblies(), this.getPluginPackages()]); vscode.commands.executeCommand("dvdt.explorer.connections.refreshConnection"); return new Promise((resolve) => { @@ -178,7 +182,7 @@ export class DataverseHelper { const connFromWS: IConnection = this.vsstate.getFromWorkspace(connectionCurrentStoreKey); if (connFromWS) { await this.getEntityDefinitions(); - await this.getWebResources(); + await Promise.all([this.getWebResources(), this.getPluginAssemblies(), this.getPluginPackages()]); return connFromWS; } return undefined; @@ -341,6 +345,24 @@ export class DataverseHelper { vscode.commands.executeCommand("dvdt.explorer.webresources.loadWebResources"); } + /** + * Get plugin assemblies (unmanaged) from the current connection. + */ + public async getPluginAssemblies(): Promise { + const respData = await this.request.requestData("pluginassemblies?$select=pluginassemblyid,name&$filter=ismanaged%20eq%20false%20and%20_packageid_value%20eq%20null"); + this.vsstate.saveInWorkspace(pluginAssembliesStoreKey, respData); + return respData; + } + + /** + * Get plugin packages (unmanaged) from the current connection. + */ + public async getPluginPackages(): Promise { + const respData = await this.request.requestData("pluginpackages?$select=pluginpackageid,name&$filter=ismanaged%20eq%20false"); + this.vsstate.saveInWorkspace(pluginPackagesStoreKey, respData); + return respData; + } + /** * Gets the web resource content * @param {string} wrId - The ID of the web resource to retrieve. diff --git a/src/helpers/drbHelper.ts b/src/helpers/drbHelper.ts deleted file mode 100644 index 86e2996..0000000 --- a/src/helpers/drbHelper.ts +++ /dev/null @@ -1,28 +0,0 @@ -import * as vscode from "vscode"; -import { connectionCurrentStoreKey } from "../utils/Constants"; -import { ErrorMessages } from "../utils/ErrorMessages"; -import { IConnection } from "../utils/Interfaces"; -import { State } from "../utils/State"; -import { DataverseRestBuilderView } from "../views/DataverseRestBuilderView"; -import { ViewBase } from "../views/ViewBase"; - -export class DRBHelper { - private vsstate: State; - - /** - * Initialization constructor for VS Code Context - */ - constructor(private vscontext: vscode.ExtensionContext) { - this.vsstate = new State(vscontext); - } - - public async openDRB(view: ViewBase): Promise { - const connFromWS: IConnection = this.vsstate.getFromWorkspace(connectionCurrentStoreKey); - if (connFromWS && connFromWS.currentAccessToken) { - const webview = await view.getWebView({ type: "openDRB", title: "Dataverse REST Builder" }); - new DataverseRestBuilderView(webview, this.vscontext, connFromWS.currentAccessToken); - } else { - vscode.window.showErrorMessage(ErrorMessages.drbConnectionError); - } - } -} diff --git a/src/helpers/pluginHelper.ts b/src/helpers/pluginHelper.ts new file mode 100644 index 0000000..d0b5743 --- /dev/null +++ b/src/helpers/pluginHelper.ts @@ -0,0 +1,310 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { spawn } from "child_process"; +import * as path from "path"; +import * as vscode from "vscode"; +import { connectionCurrentStoreKey, extensionPrefix, pluginAssembliesStoreKey, pluginPackagesStoreKey, PluginType } from "../utils/Constants"; +import { ErrorMessages } from "../utils/ErrorMessages"; +import { copyFolderOrFile, getRelativeFilePath, getWorkspaceFolder, readFileSync, writeFileSync } from "../utils/FileSystem"; +import { IConnection, ILinkerFile, ILinkerPlugin, IPluginAssemblies, IPluginPackages } from "../utils/Interfaces"; +import { jsonToXML, xmlToJSON } from "../utils/Parsers"; +import { Placeholders } from "../utils/Placeholders"; +import { State } from "../utils/State"; + +interface IPluginQuickPickItem extends vscode.QuickPickItem { + id: string; + name: string; + type: PluginType; +} + +interface ILookupResult { + entry: ILinkerPlugin; + index: number; +} + +export class PluginHelper { + private vsstate: State; + private static outputChannel: vscode.OutputChannel | undefined; + + constructor(private vscontext: vscode.ExtensionContext) { + this.vsstate = new State(vscontext); + } + + //#region Public + + public async linkPlugin(csprojFullPath: string): Promise { + const assemblies: IPluginAssemblies = this.vsstate.getFromWorkspace(pluginAssembliesStoreKey); + const packages: IPluginPackages = this.vsstate.getFromWorkspace(pluginPackagesStoreKey); + const conn: IConnection = this.vsstate.getFromWorkspace(connectionCurrentStoreKey); + + if (!conn) { + vscode.window.showErrorMessage("Connect to a Dataverse environment before linking a plugin."); + return undefined; + } + + const items: IPluginQuickPickItem[] = []; + if (assemblies && assemblies.value) { + assemblies.value.forEach((a) => items.push({ label: a.name, description: "Assembly", id: a.pluginassemblyid, name: a.name, type: PluginType.assembly })); + } + if (packages && packages.value) { + packages.value.forEach((p) => items.push({ label: p.name, description: "Package", id: p.pluginpackageid, name: p.name, type: PluginType.nuget })); + } + + if (items.length === 0) { + vscode.window.showErrorMessage(ErrorMessages.pluginListUnavailable); + return undefined; + } + + const qpOptions = Placeholders.getQuickPickOptions(Placeholders.pluginSelection); + const picked = await vscode.window.showQuickPick(items, qpOptions); + if (!picked) { + return undefined; + } + + const localRelativePath = getRelativeFilePath(csprojFullPath, getWorkspaceFolder()?.fsPath!); + const existing = await this.lookupLinkedPlugin(csprojFullPath); + + const newEntry: ILinkerPlugin = { + "@_Id": picked.id, + "@_dvName": picked.name, + "@_type": picked.type, + "@_localProjectPath": localRelativePath, + "@_environment": conn.environmentUrl.replace(/\/+$/, ""), + }; + + if (existing) { + const confirm = await vscode.window.showWarningMessage("This project is already linked. Re-link?", { detail: "Confirm your selection", modal: true }, "Yes", "No"); + if (confirm !== "Yes") { + return undefined; + } + return await this.updateInLinkerFile(existing.index, newEntry); + } + + return await this.addInLinkerFile(newEntry); + } + + public async pushPlugin(csprojFullPath: string): Promise { + let linked = await this.lookupLinkedPlugin(csprojFullPath); + if (!linked) { + const linkOptions = ["Link to existing plugin & push"]; + const qpOptions = Placeholders.getQuickPickOptions(Placeholders.pluginLinkSelection); + const choice = await vscode.window.showQuickPick(linkOptions, qpOptions); + if (!choice) { + return; + } + const newEntry = await this.linkPlugin(csprojFullPath); + if (!newEntry) { + return; + } + linked = { entry: newEntry, index: 0 }; + } + + const projectDir = path.dirname(csprojFullPath); + const projectName = path.basename(csprojFullPath); + const entry = linked.entry; + + const authed = await this.ensurePacAuth(entry["@_environment"]); + if (!authed) { + return; + } + + const args = ["plugin", "push", "--pluginId", entry["@_Id"], "--type", entry["@_type"], "--environment", entry["@_environment"]]; + + const channel = PluginHelper.getOutputChannel(); + channel.clear(); + channel.appendLine(`> pac ${args.join(" ")}`); + channel.appendLine(`> cwd: ${projectDir}`); + channel.appendLine(""); + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Pushing ${projectName}`, + cancellable: false, + }, + async (progress) => { + progress.report({ message: "Running pac plugin push..." }); + + const exitCode = await new Promise((resolve) => { + const child = spawn("pac", args, { cwd: projectDir, shell: true }); + + child.stdout.on("data", (data: Buffer) => channel.append(data.toString())); + child.stderr.on("data", (data: Buffer) => channel.append(data.toString())); + child.on("error", (err) => { + channel.appendLine(`\nFailed to start pac: ${err.message}`); + resolve(-1); + }); + child.on("close", (code) => resolve(code ?? -1)); + }); + + if (exitCode === 0) { + vscode.window.showInformationMessage(`${projectName} pushed.`); + } else { + channel.show(true); + vscode.window.showErrorMessage(ErrorMessages.pluginPushFailed); + } + }, + ); + } + + public async getLinkedProjectPaths(): Promise { + const linkerFile = await this.getLinkerFile(); + if (!linkerFile) { + return undefined; + } + const data = readFileSync(linkerFile.fsPath).toString(); + const json = xmlToJSON(data); + if (!json.DVDT.Plugins || !json.DVDT.Plugins.Plugin) { + return []; + } + if (Array.isArray(json.DVDT.Plugins.Plugin)) { + return json.DVDT.Plugins.Plugin.map((p) => p["@_localProjectPath"]); + } + return [json.DVDT.Plugins.Plugin["@_localProjectPath"]]; + } + + //#endregion Public + + //#region Private + + private async ensurePacAuth(environment: string): Promise { + if (!(await this.isPacInstalled())) { + const choice = await vscode.window.showErrorMessage("Power Platform CLI (pac) was not found. Install it and try again.", "Open install docs"); + if (choice === "Open install docs") { + vscode.env.openExternal(vscode.Uri.parse("https://aka.ms/PowerPlatformCLI")); + } + return false; + } + + if (await this.hasPacProfileForEnv(environment)) { + return true; + } + + const choice = await vscode.window.showWarningMessage(`pac is not authenticated to ${environment}. Authenticate first, then try Push again.`, "Authenticate"); + if (choice === "Authenticate") { + const terminal = vscode.window.createTerminal("DVDT - pac auth"); + terminal.show(); + terminal.sendText(`pac auth create --environment "${environment}"`, false); + } + return false; + } + + private async isPacInstalled(): Promise { + return new Promise((resolve) => { + const child = spawn("pac", [], { shell: true }); + child.on("close", (code) => resolve(code === 0)); + child.on("error", () => resolve(false)); + }); + } + + private async hasPacProfileForEnv(environment: string): Promise { + const output = await new Promise((resolve) => { + let buf = ""; + const child = spawn("pac", ["auth", "list"], { shell: true }); + child.stdout.on("data", (d: Buffer) => (buf += d.toString())); + child.stderr.on("data", (d: Buffer) => (buf += d.toString())); + child.on("close", () => resolve(buf)); + child.on("error", () => resolve("")); + }); + const normalize = (u: string) => u.replace(/\/+$/, "").toLowerCase(); + return normalize(output).includes(normalize(environment)); + } + + private async lookupLinkedPlugin(csprojFullPath: string): Promise { + const linkerFile = await this.getLinkerFile(); + if (!linkerFile) { + return undefined; + } + const localRelativePath = getRelativeFilePath(csprojFullPath, getWorkspaceFolder()?.fsPath!); + const data = readFileSync(linkerFile.fsPath).toString(); + const json = xmlToJSON(data); + + if (!json.DVDT.Plugins || !json.DVDT.Plugins.Plugin) { + return undefined; + } + + if (Array.isArray(json.DVDT.Plugins.Plugin)) { + const idx = json.DVDT.Plugins.Plugin.findIndex((p) => p["@_localProjectPath"] === localRelativePath); + return idx >= 0 ? { entry: json.DVDT.Plugins.Plugin[idx], index: idx } : undefined; + } else { + return json.DVDT.Plugins.Plugin["@_localProjectPath"] === localRelativePath ? { entry: json.DVDT.Plugins.Plugin, index: 0 } : undefined; + } + } + + private async addInLinkerFile(entry: ILinkerPlugin): Promise { + const linkerFile = await this.createLinkerFile(); + if (!linkerFile) { + return undefined; + } + const data = readFileSync(linkerFile.fsPath).toString(); + const json = xmlToJSON(data); + + if (!json.DVDT.Plugins) { + json.DVDT.Plugins = { Plugin: [] }; + } + if (Array.isArray(json.DVDT.Plugins.Plugin)) { + json.DVDT.Plugins.Plugin.push(entry); + } else { + const existing = json.DVDT.Plugins.Plugin; + json.DVDT.Plugins.Plugin = [existing, entry]; + } + writeFileSync(linkerFile.fsPath, jsonToXML(json)); + await this.updateLinkedPluginsContext(); + return entry; + } + + private async updateInLinkerFile(oldIndex: number, newEntry: ILinkerPlugin): Promise { + const linkerFile = await this.getLinkerFile(); + if (!linkerFile) { + return undefined; + } + const data = readFileSync(linkerFile.fsPath).toString(); + const json = xmlToJSON(data); + + if (!json.DVDT.Plugins) { + return undefined; + } + if (Array.isArray(json.DVDT.Plugins.Plugin)) { + json.DVDT.Plugins.Plugin[oldIndex] = newEntry; + } else { + json.DVDT.Plugins.Plugin = newEntry; + } + writeFileSync(linkerFile.fsPath, jsonToXML(json)); + await this.updateLinkedPluginsContext(); + return newEntry; + } + + private async getLinkerFile(): Promise { + const files = await vscode.workspace.findFiles("**/dvdt.linker.xml", "**/node_modules/**"); + return files.length > 0 ? files[0] : undefined; + } + + private async createLinkerFile(): Promise { + if (!vscode.workspace.workspaceFolders) { + return undefined; + } + const existing = await this.getLinkerFile(); + if (existing) { + return existing; + } + const workspaceFolder = vscode.workspace.workspaceFolders[0].uri; + const extPath = this.vscontext.extensionUri.fsPath; + const linkerTemplate = path.join(extPath, "resources", "templates", "dvdt.linker.xml"); + const newLinkerFileUri = vscode.Uri.joinPath(workspaceFolder, "dvdt.linker.xml"); + await copyFolderOrFile(linkerTemplate, newLinkerFileUri.fsPath); + return newLinkerFileUri; + } + + private async updateLinkedPluginsContext(): Promise { + const paths = await this.getLinkedProjectPaths(); + await vscode.commands.executeCommand("setContext", `${extensionPrefix}.linkedPlugins`, paths); + } + + private static getOutputChannel(): vscode.OutputChannel { + if (!PluginHelper.outputChannel) { + PluginHelper.outputChannel = vscode.window.createOutputChannel("Dataverse DevTools - Plugin Push"); + } + return PluginHelper.outputChannel; + } + + //#endregion Private +} diff --git a/src/helpers/toolsHelper.ts b/src/helpers/toolsHelper.ts new file mode 100644 index 0000000..2853281 --- /dev/null +++ b/src/helpers/toolsHelper.ts @@ -0,0 +1,64 @@ +import * as vscode from "vscode"; +import { ToolsTreeItem } from "../tools/toolsDataProvider"; +import { connectionCurrentStoreKey } from "../utils/Constants"; +import { ErrorMessages } from "../utils/ErrorMessages"; +import { IConnection } from "../utils/Interfaces"; +import { State } from "../utils/State"; +import { DataverseRestBuilderView } from "../views/DataverseRestBuilderView"; +import { ViewBase } from "../views/ViewBase"; +import { CLIHelper } from "./cliHelper"; + +export class ToolsHelper { + private vsstate: State; + + /** + * Initialization constructor for VS Code Context + */ + constructor(private vscontext: vscode.ExtensionContext) { + this.vsstate = new State(vscontext); + } + + public openTool(toolItem: ToolsTreeItem) { + const views = new ViewBase(this.vscontext); + const cliHelper = new CLIHelper(this.vscontext); + + switch (toolItem.toolShortName) { + case "drb": + this.openDRB(views); + break; + // case "erd": + // this.openERDGenerator(); + // break; + case "prt": + cliHelper.launchPRT(); + break; + case "cmt": + cliHelper.launchCMT(); + break; + case "pd": + cliHelper.launchPD(); + break; + default: + break; + } + } + + public async openDRB(view: ViewBase): Promise { + const connFromWS: IConnection = this.vsstate.getFromWorkspace(connectionCurrentStoreKey); + if (connFromWS && connFromWS.currentAccessToken) { + const webview = await view.getWebView({ type: "openDRB", title: "Dataverse REST Builder" }); + new DataverseRestBuilderView(webview, this.vscontext, connFromWS.currentAccessToken); + } else { + vscode.window.showErrorMessage(ErrorMessages.drbConnectionError); + } + } + + // public openERDGenerator(): void { + // const connFromWS: IConnection = this.vsstate.getFromWorkspace(connectionCurrentStoreKey); + // if (connFromWS && connFromWS.currentAccessToken) { + // showERDPanel(this.vscontext.extensionUri, connFromWS.environmentUrl, connFromWS.currentAccessToken); + // } else { + // vscode.window.showErrorMessage(ErrorMessages.commonToolsError); + // } + // } +} diff --git a/src/helpers/webResourceHelper.ts b/src/helpers/webResourceHelper.ts index 8300fcf..ec1f64e 100644 --- a/src/helpers/webResourceHelper.ts +++ b/src/helpers/webResourceHelper.ts @@ -4,7 +4,7 @@ import * as path from "path"; import * as vscode from "vscode"; import { extensionPrefix, WebResourceType, wrDefinitionsStoreKey } from "../utils/Constants"; import { ErrorMessages } from "../utils/ErrorMessages"; -import { decodeFromBase64, encodeToBase64, extractGuid } from "../utils/ExtensionMethods"; +import { decodeFromBase64ToUTF8, encodeToBase64, extractGuid } from "../utils/ExtensionMethods"; import { copyFolderOrFile, createTempDirectory, getFileExtension, getFileName, getRelativeFilePath, getWorkspaceFolder, readFileAsBase64Sync, readFileSync, writeFileSync } from "../utils/FileSystem"; import { ILinkerFile, ILinkerRes, ISmartMatchRecord, IWebResource, IWebResources } from "../utils/Interfaces"; import { jsonToXML, xmlToJSON } from "../utils/Parsers"; @@ -63,7 +63,7 @@ export class WebResourceHelper { if (resourceToCompare) { const base64Content = await this.dvHelper.getWebResourceContent(resourceToCompare["@_Id"]); if (base64Content) { - const parsedContent = decodeFromBase64(base64Content); + const parsedContent = decodeFromBase64ToUTF8(base64Content); const tempDirUri = await createTempDirectory(); const tempFilePath = vscode.Uri.joinPath(tempDirUri, `temp-${resourceToCompare["@_localFileName"]}`); writeFileSync(tempFilePath.fsPath, parsedContent); diff --git a/src/tools/tools.json b/src/tools/tools.json index 49e3778..af36cb3 100644 --- a/src/tools/tools.json +++ b/src/tools/tools.json @@ -1,8 +1,28 @@ { "tools": [ - { "toolName": "Dataverse REST Builder", "toolShortName": "drb", "toolAuthor": "Guido Preite" }, - { "toolName": "Plugin Registration", "toolShortName": "prt", "toolAuthor": "Microsoft" }, - { "toolName": "Configuration Migration", "toolShortName": "cmt", "toolAuthor": "Microsoft" }, - { "toolName": "Package Deployer", "toolShortName": "pd", "toolAuthor": "Microsoft" } + { + "name": "Dataverse REST Builder", + "shortName": "drb", + "author": "Guido Preite", + "icon": "drb.png" + }, + { + "name": "Plugin Registration", + "shortName": "prt", + "author": "Microsoft", + "icon": "prt.png" + }, + { + "name": "Configuration Migration", + "shortName": "cmt", + "author": "Microsoft", + "icon": "cmt.png" + }, + { + "name": "Package Deployer", + "shortName": "pd", + "author": "Microsoft", + "icon": "pd.png" + } ] -} +} \ No newline at end of file diff --git a/src/tools/toolsDataProvider.ts b/src/tools/toolsDataProvider.ts index 87ecda4..ab02f63 100644 --- a/src/tools/toolsDataProvider.ts +++ b/src/tools/toolsDataProvider.ts @@ -16,7 +16,7 @@ export class ToolsDataProvider implements vscode.TreeDataProvider let toolsTree: ToolsTreeItem[] = []; toolsArray.tools.map((tool) => { - toolsTree.push(new ToolsTreeItem(tool.toolName, tool.toolShortName, tool.toolAuthor, vscode.TreeItemCollapsibleState.None)); + toolsTree.push(new ToolsTreeItem(tool.name, tool.shortName, tool.author, vscode.TreeItemCollapsibleState.None, tool.icon)); }); return Promise.resolve(toolsTree); @@ -24,28 +24,18 @@ export class ToolsDataProvider implements vscode.TreeDataProvider } export class ToolsTreeItem extends ToolsItemBase { - constructor(public readonly toolName: string, public readonly toolShortName: string, public readonly authorName: string, public readonly collapsibleState: vscode.TreeItemCollapsibleState) { + constructor( + public readonly toolName: string, + public readonly toolShortName: string, + public readonly authorName: string, + public readonly collapsibleState: vscode.TreeItemCollapsibleState, + public readonly toolIcon?: string, + ) { super(toolName, authorName, collapsibleState); } iconPath = { - light: vscode.Uri.file( - path.join( - __filename, - "..", - "resources", - "toolIcons", - this.toolShortName === "drb" ? "drb.png" : this.toolShortName === "prt" ? "prt.png" : this.toolShortName === "cmt" ? "cmt.png" : this.toolShortName === "pd" ? "pd.png" : "generic.svg", - ), - ), - dark: vscode.Uri.file( - path.join( - __filename, - "..", - "resources", - "toolIcons", - this.toolShortName === "drb" ? "drb.png" : this.toolShortName === "prt" ? "prt.png" : this.toolShortName === "cmt" ? "cmt.png" : this.toolShortName === "pd" ? "pd.png" : "generic.svg", - ), - ), + light: vscode.Uri.file(path.join(__filename, "..", "resources", "toolIcons", "light", this.toolIcon ?? "generic.svg")), + dark: vscode.Uri.file(path.join(__filename, "..", "resources", "toolIcons", "dark", this.toolIcon ?? "generic.svg")), }; } diff --git a/src/utils/Constants.ts b/src/utils/Constants.ts index 28cd194..529d1aa 100644 --- a/src/utils/Constants.ts +++ b/src/utils/Constants.ts @@ -15,6 +15,8 @@ export const connectionStoreKey: string = `DataverseConnections`; export const connectionCurrentStoreKey: string = `LiveDVConnection`; export const entityDefinitionsStoreKey: string = `CurrentEntityDefinitions`; export const wrDefinitionsStoreKey: string = `CurrentWRs`; +export const pluginAssembliesStoreKey: string = `CurrentPluginAssemblies`; +export const pluginPackagesStoreKey: string = `CurrentPluginPackages`; export const solDefinitionsStoreKey: string = `CurrentSolutions`; export const smartMatchStoreKey: string = `CurrentSmartMatch`; export const environmentTypes: string[] = ["Dev", "QA", "UAT", "Prod"]; @@ -52,6 +54,11 @@ export enum WebResourceType { others = -1, } +export enum PluginType { + assembly = "Assembly", + nuget = "Nuget", +} + export enum ConfidenceScores { _90 = 90, _80 = 80, diff --git a/src/utils/ErrorMessages.ts b/src/utils/ErrorMessages.ts index c4a0aa6..2c3f83a 100644 --- a/src/utils/ErrorMessages.ts +++ b/src/utils/ErrorMessages.ts @@ -15,4 +15,7 @@ export class ErrorMessages { public static wrUploadError: string = "Failed to upload web resource. Please check your connection and try again."; public static drbConnectionError: string = "Connect to an environment before trying to load Dataverse REST Builder."; public static invalidLoginType: string = "Invalid Login Type."; + public static commonToolsError: string = "An error occurred while trying to open the tool. Please try again."; + public static pluginPushFailed: string = "Failed to push plugin. See the 'Dataverse DevTools - Plugin Push' output channel for details."; + public static pluginListUnavailable: string = "No plugin assemblies or packages found in the connected environment."; } diff --git a/src/utils/ExtensionMethods.ts b/src/utils/ExtensionMethods.ts index 4047bdf..9fbf1c2 100644 --- a/src/utils/ExtensionMethods.ts +++ b/src/utils/ExtensionMethods.ts @@ -79,6 +79,8 @@ export const extractGuid = (str: string): string | undefined => { } }; +export const decodeFromBase64ToUTF8 = (str: string): string => Buffer.from(str, "base64").toString("utf8"); + export const decodeFromBase64 = (str: string): string => Buffer.from(str, "base64").toString("binary"); export const encodeToBase64 = (str: string): string => Buffer.from(str, "binary").toString("base64"); diff --git a/src/utils/Interfaces.ts b/src/utils/Interfaces.ts index 7197155..8eb4e11 100644 --- a/src/utils/Interfaces.ts +++ b/src/utils/Interfaces.ts @@ -193,6 +193,7 @@ export interface ILinkerFile { export interface ILinkerRoot { WebResources: ILinkerResources; + Plugins?: ILinkerPlugins; Settings: any[]; } @@ -208,6 +209,36 @@ export interface ILinkerRes { "@_Id": string; } +export interface ILinkerPlugins { + Plugin: Array | ILinkerPlugin; +} + +export interface ILinkerPlugin { + "@_Id": string; + "@_dvName": string; + "@_type": string; + "@_localProjectPath": string; + "@_environment": string; +} + +export interface IPluginAssemblies { + value: IPluginAssembly[]; +} + +export interface IPluginAssembly { + pluginassemblyid: string; + name: string; +} + +export interface IPluginPackages { + value: IPluginPackage[]; +} + +export interface IPluginPackage { + pluginpackageid: string; + name: string; +} + export interface IOptionSetMetadata { LogicalName: string; MetadataId: string; @@ -294,9 +325,10 @@ export interface ITools { } export interface IToolDetails { - toolName: string; - toolShortName: string; - toolAuthor: string; + name: string; + shortName: string; + author: string; + icon?: string; } export interface ICliCommandList { commands: ICliCommand[]; diff --git a/src/utils/Placeholders.ts b/src/utils/Placeholders.ts index 236ae8f..341858c 100644 --- a/src/utils/Placeholders.ts +++ b/src/utils/Placeholders.ts @@ -122,6 +122,12 @@ export class Placeholders { case this.wrSearch: qpOptions = { placeHolder: this.ipWRSearch.placeHolderText, title: this.ipWRSearch.prompt, ignoreFocusOut: ignoreFocus }; break; + case this.pluginSelection: + qpOptions = { placeHolder: this.ipPluginSelection.placeHolderText, title: this.ipPluginSelection.prompt, ignoreFocusOut: ignoreFocus }; + break; + case this.pluginLinkSelection: + qpOptions = { placeHolder: this.ipPluginLinkSelection.placeHolderText, title: this.ipPluginLinkSelection.prompt, ignoreFocusOut: ignoreFocus }; + break; default: qpOptions = { placeHolder: "", ignoreFocusOut: ignoreFocus }; break; @@ -151,6 +157,8 @@ export class Placeholders { public static tsTemplateType: string = "TS-TemplateType"; public static entitiesSearch: string = "EntitiesSearch"; public static wrSearch: string = "WRSearch"; + public static pluginSelection: string = "PluginSelection"; + public static pluginLinkSelection: string = "PluginLinkSelection"; public static required: string = "[Required]"; public static optional: string = "[Optional]"; @@ -239,4 +247,12 @@ export class Placeholders { placeHolderText: `Search & Select Web Resources`, prompt: `Pick multiple web resources`, }; + private static ipPluginSelection: IPlaceholder = { + placeHolderText: `Select the plugin assembly or package`, + prompt: `Pick the Dataverse plugin to link with this project`, + }; + private static ipPluginLinkSelection: IPlaceholder = { + placeHolderText: `Select your option`, + prompt: `This project isn't linked. Link to an existing plugin?`, + }; }