diff --git a/.cspell.json b/.cspell.json
index 7ab02ee..8099c61 100644
--- a/.cspell.json
+++ b/.cspell.json
@@ -8,5 +8,5 @@
"dictionaries": ["typescript", "node", "software-terms"],
"import": ["@cspell/dict-typescript/cspell-ext.json", "@cspell/dict-node/cspell-ext.json", "@cspell/dict-software-terms"],
"ignoreRegExpList": ["[0-9a-fA-F]{6}"],
- "ignoreWords": ["ubiquibot", "Supabase", "supabase", "SUPABASE", "sonarjs", "mischeck", "Typebox"]
+ "ignoreWords": ["ubiquibot", "Supabase", "supabase", "SUPABASE", "sonarjs", "mischeck", "Typebox", "tooltiptext"]
}
diff --git a/package.json b/package.json
index c57f2d1..9a79162 100644
--- a/package.json
+++ b/package.json
@@ -35,7 +35,7 @@
"dependencies": {
"@octokit/rest": "^21.0.2",
"@supabase/supabase-js": "^2.46.1",
- "@ubiquity-os/plugin-sdk": "^1.0.11",
+ "@ubiquity-os/plugin-sdk": "^3.0.0",
"@ubiquity-os/ubiquity-os-kernel": "^2.5.3",
"ajv": "^8.17.1",
"dotenv": "^16.4.4",
diff --git a/static/index.html b/static/index.html
index fa71ad7..a7d207d 100644
--- a/static/index.html
+++ b/static/index.html
@@ -11,8 +11,9 @@
>
+ />
+
+
UbiquityOS Plugin Installer
@@ -24,10 +25,16 @@
> | |
- | |
+
+
+ |
+
+
+
+
+ |
+
+
diff --git a/static/main.ts b/static/main.ts
index 9b7e82a..6da8f06 100644
--- a/static/main.ts
+++ b/static/main.ts
@@ -16,20 +16,21 @@ export async function mainModule() {
renderer.manifestGuiBody.dataset.loading = "false";
try {
+ // needs handled better
const ubiquityOrgsToFetchOfficialConfigFrom = ["ubiquity-os"];
const fetcher = new ManifestFetcher(ubiquityOrgsToFetchOfficialConfigFrom, auth.octokit);
if (auth.isActiveSession()) {
renderer.manifestGuiBody.dataset.loading = "true";
const killNotification = toastNotification("Fetching manifest data...", { type: "info", shouldAutoDismiss: true });
-
const userOrgs = await auth.getGitHubUserOrgs();
+
const userOrgRepos = await auth.getGitHubUserOrgRepos(userOrgs);
localStorage.setItem("orgRepos", JSON.stringify(userOrgRepos));
renderOrgPicker(renderer, userOrgs);
+ await fetcher.fetchOrgsUbiquityOsConfigs();
await fetcher.fetchMarketplaceManifests();
- await fetcher.fetchOfficialPluginConfig();
renderer.manifestGuiBody.dataset.loading = "false";
killNotification();
} else {
diff --git a/static/manifest-gui.css b/static/manifest-gui.css
index b6cc57c..f58756e 100644
--- a/static/manifest-gui.css
+++ b/static/manifest-gui.css
@@ -131,14 +131,30 @@ header #uos-logo path {
user-select: none;
background-image: linear-gradient(0deg, #101010, #202020);
margin: 0 auto;
- border-radius: 4px;
box-shadow: 0 24px 48px #000000;
- overflow: hidden;
opacity: 0;
transition: opacity 0.25s cubic-bezier(0, 1, 1, 1);
+ scrollbar-width: thin;
+ scrollbar-color: #303030 #101010;
}
#manifest-gui.plugin-editor {
+ max-height: calc(100vh - 192px);
+ overflow: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ padding: 16px;
+ width: 100%;
+}
+
+#manifest-gui.plugin-editor > #manifest-gui-body.rendered[data-loading="false"] {
+ display: inline-table;
+}
+
+#manifest-gui-title {
+ right: 0;
+ left: 0;
width: 100%;
}
@@ -205,6 +221,53 @@ header #uos-logo path {
border-top: 1px solid #303030;
}
+.table-data-value.config-input.buttons {
+ background-color: transparent;
+ border: none;
+ padding: 8px 10px;
+}
+
+.table-data-value.config-input.buttons > .button-group {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 4px;
+}
+
+.button-group {
+ margin: 8px;
+}
+
+.table-data-value.config-input.buttons > .button-group > button {
+ background-color: #101010;
+ color: #fff;
+ font-size: 16px;
+ border: 1px solid #303030;
+ border-radius: 4px;
+ font-family: "Proxima Nova", sans-serif;
+ cursor: pointer;
+ transition:
+ background-color 0.25s,
+ border-color 0.25s;
+}
+
+.table-data-value.config-input.buttons > .button-group > button:hover {
+ background-color: #404040;
+ border-color: #606060;
+}
+
+.table-data-value.config-input.buttons > .button-group > button:active {
+ background-color: #606060;
+}
+
+.table-data-value.config-input.buttons > .button-group > button:active {
+ background-color: #606060;
+}
+
+.table-data-value.config-input.buttons > .button-group > button.selected {
+ background-color: #303030;
+ border-color: #606060;
+}
+
.table-data-header {
padding: 8px 16px;
}
@@ -216,6 +279,7 @@ header #uos-logo path {
.table-data-value > input,
textarea {
padding: 8px 16px;
+ margin: 8px;
}
#controls {
@@ -223,47 +287,95 @@ textarea {
right: 24px;
top: 24px;
}
+
+#buttons {
+ display: flex;
+ gap: 8px;
+}
+
+tfoot {
+ display: flex;
+ justify-content: flex-end;
+ width: 100%;
+}
+
button {
appearance: none;
- background: 0 0;
+ background: none;
color: #fff;
font-size: 16px;
border: none;
padding: 8px 16px;
border-radius: 4px;
- opacity: 0.75;
+ opacity: 0.85;
font-family: "Proxima Nova", sans-serif;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
+ transition:
+ background-color 0.2s,
+ opacity 0.2s,
+ transform 0.1s;
}
+
button:hover {
background-color: #404040;
cursor: pointer;
opacity: 1;
+ transform: scale(1.05);
}
+
button:active {
background-color: #606060;
+ transform: scale(0.98);
}
button#reset-to-default {
background-color: #303030;
color: #fff;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ transition:
+ background-color 0.2s,
+ transform 0.2s;
+}
+
+button#reset-to-default::before {
+ content: "\21BA";
+ font-size: 18px;
}
+
button#reset-to-default:hover {
background-color: #404040;
+ transform: scale(1.05);
}
+
button#reset-to-default:active {
background-color: #505050;
+ transform: scale(0.98);
}
-button#reset-to-default::before {
- content: "♻️";
+
+button#remove {
+ color: #f00;
+ transition:
+ background-color 0.2s,
+ transform 0.2s;
+}
+
+button#remove::before {
+ content: "−";
}
-button#reset-to-default:hover::before {
- content: "Use Defaults";
+
+button#remove:hover {
+ background-color: rgba(255, 0, 0, 0.1);
+ transform: scale(1.05);
}
-button#reset-to-default:active::before {
- content: "♻️♻️♻️♻️♻️";
+
+button#remove:active {
+ background-color: rgba(255, 0, 0, 0.2);
+ transform: scale(0.98);
+}
+
+button#remove:active::before {
+ content: "−";
}
button#remove.disabled {
@@ -274,41 +386,74 @@ button#remove.disabled {
pointer-events: none;
}
-button#remove::before {
- content: "−";
-}
-button#remove:hover::before {
- content: "Remove";
-}
-button#remove {
- color: #f00;
+button#add {
+ color: #0f0;
+ transition:
+ background-color 0.2s,
+ transform 0.2s;
}
-button#remove:hover {
- background-color: #ff000010;
+
+button#add::before {
+ content: "+";
}
-button#remove:active {
- background-color: #ff000020;
+
+button#add:hover {
+ background-color: rgba(0, 255, 0, 0.1);
+ transform: scale(1.05);
}
-button#remove:active::before {
- content: "−−−−−−";
+
+button#add:active {
+ background-color: rgba(0, 255, 0, 0.2);
+ transform: scale(0.98);
}
-button#add::before {
+
+button#add:active::before {
content: "+";
}
-button#add:hover::before {
- content: "Add";
+
+#manifest-gui pre {
+ margin: 0 0 16px;
}
-button#add {
- color: #0f0;
+
+.tooltip {
+ position: relative;
+ display: inline-block;
+ cursor: help;
+ padding-left: 8px;
}
-button#add:hover {
- background-color: #00ff0010;
+
+.tooltip:last-child .tooltiptext {
+ top: -100%;
}
-button#add:active {
- background-color: #00ff0020;
+
+.tooltip .tooltiptext {
+ visibility: hidden;
+ min-width: 400px;
+ width: auto;
+ background-color: #101010;
+ color: #fff;
+ text-align: center;
+ border-radius: 4px;
+ padding: 8px;
+ position: absolute;
+ z-index: 1;
+ opacity: 0;
+ transition: opacity 0.25s;
+ font-size: 16px;
+ box-shadow: 0 24px 48px #000000;
+ white-space: pre-wrap;
+ line-height: 1.6;
+ top: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ margin-top: 8px;
+ border: 1px solid #303030;
+ text-transform: none;
}
-button#add:active::before {
- content: "+++";
+
+.tooltip:hover .tooltiptext {
+ visibility: visible;
+ opacity: 1;
}
.picker-select {
@@ -516,8 +661,13 @@ select option {
border-color: #808080;
}
-.config-input {
- margin-bottom: 16px;
+textarea.config-input {
+ resize: vertical;
+}
+
+.table-data-value.config-input.buttons {
+ padding: 0 8px;
+ gap: 8px;
}
.config-input[type="checkbox"] {
diff --git a/static/scripts/config-parser.ts b/static/scripts/config-parser.ts
index 68fb36e..d850e23 100644
--- a/static/scripts/config-parser.ts
+++ b/static/scripts/config-parser.ts
@@ -30,7 +30,7 @@ export class ConfigParser {
});
exists = true;
} catch (error) {
- console.log(error);
+ console.log(`${repo} does not exist in ${org}`, error);
exists = false;
}
@@ -44,8 +44,14 @@ export class ConfigParser {
toastNotification("We noticed you don't have a '.ubiquity-os' config repo, so we created one for you.", { type: "success" });
} catch (er) {
- console.log(er);
- throw new Error("Config repo creation failed");
+ if (er instanceof Error && er.message.includes("name already exists on this account")) {
+ // https://github.com/ubiquity-os/ubiquity-os-plugin-installer/issues/40
+ console.info(`Config repo could not be found but failed to create because it already exists:`, er);
+ exists = true;
+ } else {
+ console.log(er);
+ throw new Error("Config repo creation failed");
+ }
}
}
@@ -110,7 +116,7 @@ export class ConfigParser {
}
parseConfig(config?: string | null): PluginConfig {
- if (config && typeof config === "string") {
+ if (config && typeof config === "string" && config.trim() !== "") {
return YAML.parse(config);
} else {
return YAML.parse(this.loadConfig());
@@ -148,7 +154,7 @@ export class ConfigParser {
owner: org,
repo: repo,
path,
- message: `chore: Plugin Installer UI - update`,
+ message: `chore(PluginInstallerUI): updating config`,
content: btoa(this.newConfigYml),
sha,
});
diff --git a/static/scripts/fetch-manifest.ts b/static/scripts/fetch-manifest.ts
index 6ead971..1c900ca 100644
--- a/static/scripts/fetch-manifest.ts
+++ b/static/scripts/fetch-manifest.ts
@@ -1,31 +1,34 @@
import { Octokit } from "@octokit/rest";
import { CONFIG_FULL_PATH, CONFIG_ORG_REPO, DEV_CONFIG_FULL_PATH } from "@ubiquity-os/plugin-sdk/constants";
-import { Manifest, ManifestPreDecode } from "../types/plugins";
-import { getOfficialPluginConfig } from "../utils/storage";
+import { ManifestPreDecode, PluginConfig } from "../types/plugins";
+import YAML from "yaml";
/**
* Responsible for:
- * - Mainly UbiquityOS Marketplace data fetching (config-parser fetches user configs)
+ * - UbiquityOS Marketplace data fetching (`config-parser` fetches user configs)
* - Fetching the manifest.json files from the marketplace
* - Fetching the README.md files from the marketplace
- * - Fetching the official plugin config from the orgs
- * - Capturing the worker and action urls from the official plugin config (will be taken from the manifest directly soon)
* - Storing the fetched data in localStorage
+ * - building the `ManifestCache` from the fetched data
*/
export class ManifestFetcher {
private _orgs: string[];
private _octokit: Octokit | null;
- workerUrlRegex = /https:\/\/([a-z0-9-]+)\.ubiquity\.workers\.dev/g;
- actionUrlRegex = /[a-z0-9-]+\/[a-z0-9-]+(?:\/[^@]+)?@[a-z0-9-]+/g;
- workerUrls = new Set();
- actionUrls = new Set();
+ pluginUrls = new Set();
constructor(orgs: string[], octokit: Octokit | null) {
this._orgs = orgs;
this._octokit = octokit;
}
+ /**
+ * Setups up our `manifestCache` with the fetched data from the marketplace.
+ *
+ * Removes entries that caused an error during fetching, typically .github etc
+ * as well as nulls any malformed `homepage_url` entries which is
+ * used to `disable` the `plugin-select` button in the UI.
+ */
async fetchMarketplaceManifests() {
const org = "ubiquity-os-marketplace";
if (!this._octokit) {
@@ -37,12 +40,34 @@ export class ManifestFetcher {
for (const repo of repos.data) {
const manifestUrl = this.createGithubRawEndpoint(org, repo.name, "development", "manifest.json");
const manifest = await this.fetchPluginManifest(manifestUrl);
- const decoded = this.decodeManifestFromFetch(manifest, repo.name);
+ if (manifest.error) {
+ // naively, repos such as .github, .ubiquity-os
+ continue;
+ }
const readme = await this.fetchPluginReadme(this.createGithubRawEndpoint(org, repo.name, "development", "README.md"));
+ let homepageUrl = manifest.homepage_url || null;
+
+ if (homepageUrl && !homepageUrl.endsWith("ubiquity.workers.dev") && !homepageUrl.startsWith("ubiquity-os")) {
+ console.error("Invalid homepage url", homepageUrl);
+ homepageUrl = null;
+ }
- if (decoded) {
- manifestCache[manifestUrl] = { ...decoded, readme };
+ if (!homepageUrl) {
+ const repoUrls = Array.from(this.pluginUrls);
+ for (const url of repoUrls) {
+ if (url.includes(repo.name)) {
+ homepageUrl = url;
+ break;
+ }
+ }
}
+
+ // hacky but the issue remains in some plugins
+ if (repo.name !== manifest.name) {
+ manifest.name = repo.name;
+ }
+
+ manifestCache[repo.name] = { manifest, readme, homepageUrl };
}
localStorage.setItem("manifestCache", JSON.stringify(manifestCache));
@@ -57,46 +82,11 @@ export class ManifestFetcher {
return {};
}
- captureWorkerUrls(config: string) {
- // take the full url and just ping the endpoint
- let match;
- while ((match = this.workerUrlRegex.exec(config)) !== null) {
- const workerUrl = match[0];
- this.workerUrls.add(workerUrl);
- }
- }
-
createGithubRawEndpoint(owner: string, repo: string, branch: string, path: string) {
// no endpoint so we fetch the raw content from the owner/repo/branch
return `https://raw.githubusercontent.com/${owner}/${repo}/refs/heads/${branch}/${path}`;
}
- captureActionUrls(config: string) {
- let match;
- while ((match = this.actionUrlRegex.exec(config)) !== null) {
- this.actionUrls.add(match[0]);
- }
- }
-
- async fetchOfficialPluginConfig() {
- await this.fetchOrgsUbiquityOsConfigs();
- const officialPluginConfig = getOfficialPluginConfig();
-
- this.workerUrls.forEach((url) => {
- officialPluginConfig[url] = { workerUrl: url };
- });
-
- this.actionUrls.forEach((url) => {
- if (url.includes("ubiquibot")) {
- return;
- }
- officialPluginConfig[url] = { actionUrl: url };
- });
-
- localStorage.setItem("officialPluginConfig", JSON.stringify(officialPluginConfig));
- return officialPluginConfig;
- }
-
async fetchPluginManifest(actionUrl: string) {
try {
const response = await fetch(actionUrl);
@@ -147,6 +137,12 @@ export class ManifestFetcher {
}
}
+ /**
+ * Fetches the yaml config from the orgs used when initializing this class.
+ *
+ * This is used to get the action urls for the plugins and a fallback for worker
+ * which may not have `homepage_url` in the manifest.
+ */
async fetchOrgsUbiquityOsConfigs() {
const configFileContents: Record = {};
@@ -185,23 +181,18 @@ export class ManifestFetcher {
}
for (const config of Object.values(configFileContents)) {
- this.captureWorkerUrls(config);
- this.captureActionUrls(config);
- }
- }
-
- decodeManifestFromFetch(manifest: ManifestPreDecode, repoName: string) {
- if (manifest.error) {
- return null;
+ const configObj = YAML.parse(config) as PluginConfig;
+ for (const plugin of configObj.plugins) {
+ const pluginUrl = plugin.uses?.[0].plugin; // we only use single chain plugins for now, needs to be updated for multi-chain
+ if (!pluginUrl) {
+ console.error("No plugin url found in config", {
+ plugin,
+ configObj,
+ });
+ } else {
+ this.pluginUrls.add(pluginUrl);
+ }
+ }
}
-
- const decodedManifest: Manifest = {
- name: manifest.name,
- description: manifest.description,
- "ubiquity:listeners": manifest["ubiquity:listeners"],
- configuration: manifest.configuration,
- };
-
- return decodedManifest;
}
}
diff --git a/static/scripts/rendering/anyof-handling.ts b/static/scripts/rendering/anyof-handling.ts
new file mode 100644
index 0000000..0b09ff8
--- /dev/null
+++ b/static/scripts/rendering/anyof-handling.ts
@@ -0,0 +1,376 @@
+import { Manifest } from "@ubiquity-os/ubiquity-os-kernel";
+import { createElement, createConfigParamTooltip, manifestGuiBody } from "../../utils/element-helpers";
+
+type Member = {
+ type: string;
+ description: string;
+ examples: string[];
+ default: string;
+ properties: Record<
+ string,
+ {
+ kind: { const: string };
+ description: string;
+ examples: string[];
+ default: string;
+ }
+ >;
+};
+
+/**
+ * Configs exist wherein the two options are either defined or `null` (text-conversation-rewards), the assumption
+ * is then that any `Plugin-Input` that uses a setup like this is signalling that it's a boolean toggle for that module.
+ *
+ * See https://github.com/ubiquity-os-marketplace/text-conversation-rewards/blob/development/src/types/plugin-input.ts#L53
+ * vs
+ * https://github.com/ubiquity-os-marketplace/ubiquity-os-kernel-telegram/blob/development/src/types/plugin-inputs.ts#L24
+ *
+ * ```ts
+ * contentEvaluator: T.Union([contentEvaluatorConfigurationType, T.Null()], { default: null }),
+ * // vs
+ * aiConfig: T.Union([T.Object(....), T.Object(....)], { default: null }),
+ * ```
+ *
+ * The former is a boolean toggle, the latter is a configuration toggle with two options, none of them being 'disabled'.
+ * In any scenario where we detect this, we render `Enable` and `Disable` buttons. Otherwise,
+ * we render using the union discriminator using the `const` value of the `kind` property
+ * which is the TypeBox/TypeBox-Validators standard.
+ */
+export function createAnyOfToggle(key: string, prop: Manifest["configuration"], configDefaults: Record) {
+ const { kindConfigs, kinds } = processAnyOfProperties(prop);
+
+ const row = createElement("tr", {});
+ const headerCell = createElement("td", {});
+ headerCell.className = "table-data-header";
+ headerCell.textContent = key.replace(/([A-Z])/g, " $1");
+ row.appendChild(headerCell);
+
+ const valueCell = createElement("td", {});
+ valueCell.className = "table-data-value config-input buttons";
+ valueCell.dataset.hasToggle = "true";
+ valueCell.dataset.selected = kinds[0];
+ valueCell.dataset.configKey = key;
+ valueCell.id = key;
+ row.appendChild(valueCell);
+
+ const buttonGroup = createElement("div", {});
+ buttonGroup.className = "button-group";
+ createToggleOptions(kinds, kindConfigs, valueCell, buttonGroup);
+ valueCell.appendChild(buttonGroup);
+
+ // we only need a textarea for object types; string array unions are handled by the buttons
+ let textarea = null;
+
+ if (Object.values(kindConfigs).some((config) => config?.type === "object")) {
+ textarea = createElement("textarea", {
+ class: "config-input",
+ "data-config-key": key,
+ value: buildCleanConfigObject(prop?.anyOf[0]),
+ rows: `${kinds.length + 1}`,
+ });
+ } else {
+ valueCell.dataset.toggleType = "union";
+ }
+
+ buttonGroup.childNodes.forEach((button) => {
+ if ((button as HTMLButtonElement).value === kinds[0]) {
+ (button as HTMLButtonElement).classList.add("selected");
+ }
+ });
+
+ // build the first tooltip
+ if (prop && "description" in prop && prop.description) {
+ createConfigParamTooltip(headerCell, {
+ ...prop,
+ id: `tooltip-${key}`,
+ });
+ } else {
+ const { description, examples } = findDescriptionAndExamples(kinds[0], kinds, kindConfigs);
+ createConfigParamTooltip(headerCell, {
+ description,
+ examples,
+ id: `tooltip-${key}`,
+ });
+ }
+
+ // replace the tooltip with the new one when the option is changed
+ buttonGroup.childNodes.forEach((button, index) => {
+ (button as HTMLButtonElement).onclick = () => {
+ const isDisabled = (button as HTMLButtonElement).value === "Disabled";
+ const isEnabled = (button as HTMLButtonElement).value === "Enabled";
+ if (textarea && isEnabled) {
+ textarea.value = buildCleanConfigObject(prop?.anyOf[index]);
+ } else if (textarea && isDisabled) {
+ textarea.value = "";
+ textarea.placeholder = buildCleanConfigObject(prop?.anyOf[index]);
+ }
+ valueCell.dataset.selected = kinds[index];
+
+ const tooltip = document.getElementById(`tooltip-${key}`);
+ if (tooltip) {
+ tooltip.remove();
+ }
+
+ const { description, examples } = findDescriptionAndExamples(kinds[index], kinds, kindConfigs);
+
+ // build a custom tooltip for the `Disabled` option
+ if (!description && !examples && buttonGroup.childNodes.length === 2 && "Enabled" in kindConfigs && "Disabled" in kindConfigs) {
+ createConfigParamTooltip(headerCell, {
+ description: `\`${key}\` can be either 'Enabled' or 'Disabled', please refer to the plugin documentation for more information on what disabling this feature does.\n\n The placeholder text in the textarea will be updated to reflect the configuration object for the selected option.`,
+ examples: `Choose between 'Enabled' and 'Disabled'.`,
+ id: `tooltip-${key}`,
+ });
+ } else if (!description && !examples && !("Enabled" in kindConfigs) && !("Disabled" in kindConfigs)) {
+ // likely a string array union whose parent has the description
+ if (prop && "description" in prop && prop.description) {
+ createConfigParamTooltip(headerCell, {
+ ...prop,
+ id: `tooltip-${key}`,
+ });
+ }
+ } else {
+ createConfigParamTooltip(headerCell, {
+ description,
+ examples,
+ id: `tooltip-${key}`,
+ });
+ }
+
+ buttonGroup.childNodes.forEach((button) => {
+ (button as HTMLButtonElement).classList.remove("selected");
+ });
+ (button as HTMLButtonElement).classList.add("selected");
+ };
+ });
+
+ row.appendChild(headerCell);
+ if (textarea) {
+ valueCell.appendChild(textarea);
+ }
+
+ row.appendChild(valueCell);
+ manifestGuiBody?.appendChild(row);
+ configDefaults[key] = {
+ type: "anyOf",
+ value: prop?.anyOf[0],
+ };
+}
+
+/**
+ * Processes the `anyOf` properties of a configuration object and determines if:
+ * - The configuration is a boolean toggle.
+ * - The configuration is a configuration toggle.
+ *
+ * ---
+ *
+ * [`Typebox-Validators`](https://github.com/jtlapp/typebox-validators?tab=readme-ov-file#discriminated-union-examples)
+
+ * ```ts
+ * const schema1 = Type.Union([
+ * Type.Object({
+ * kind: Type.Literal('string'),
+ * val: Type.String(),
+ * }),
+ * Type.Null(),
+ * ]);
+ * ```
+ * - Above is simply a union of an object and `null`, in this case, the options are `Enabled` and `Disabled`. ([Text Conversation Rewards Manifest.json](https://github.com/ubiquity-os-marketplace/text-conversation-rewards/blob/fc949f518b5daa44014e4ac1d234b65c06e407de/manifest.json#L291))
+ * ---
+ * - Below is a union of two config styles, in this case, the options are `OpenAi` and `OpenRouter`. ([Telegram Kernel Manifest.json](https://github.com/ubiquity-os-marketplace/ubiquity-os-kernel-telegram/blob/development/manifest.json#L77))
+ *
+ * ```ts
+ * const schema2 = Type.Union([
+ * Type.Object({
+ * kind: Type.Literal(`OpenAi`),
+ * val: Type.String(),
+ * }),
+ * Type.Object({
+ * kind: Type.Literal(`OpenRouter`),
+ * val: Type.String(),
+ * }),
+ * ]);
+ * ```
+ */
+function processAnyOfProperties(prop: Manifest["configuration"]) {
+ let kinds: string[] = [];
+ let kindConfigs: Record = {};
+
+ try {
+ kinds = prop?.anyOf.map((member: Member) => {
+ if (member.type === "null") {
+ return "Disabled";
+ }
+
+ if ("const" in member) {
+ return member.const as string;
+ }
+
+ if ("properties" in member) {
+ if ("kind" in member.properties && "const" in member.properties.kind) {
+ return member.properties.kind.const as string;
+ }
+ return "Enabled";
+ } else {
+ return "Disabled";
+ }
+ });
+
+ kindConfigs = prop?.anyOf.reduce((acc: Record, member: Member) => {
+ if (member.type === "null") {
+ acc["Disabled"] = member;
+ return acc;
+ }
+
+ // string array union like `AssignedIssueScope` in `command-start-stop`
+ if ("const" in member) {
+ acc[member.const as string] = member;
+ return acc;
+ }
+
+ // object union of object like `aiConfig` in `telegram-kernel`
+ if ("properties" in member) {
+ if ("kind" in member.properties && "const" in member.properties.kind) {
+ acc[member.properties.kind.const as string] = member;
+ } else {
+ acc["Enabled"] = member;
+ }
+ } else {
+ acc["Disabled"] = member;
+ }
+ return acc;
+ }, {});
+ } catch (e) {
+ console.error(e);
+ }
+
+ return { kinds, kindConfigs };
+}
+
+function createToggleOptions(
+ kinds: string[],
+ kindConfigs: Record,
+ valueCell: HTMLTableCellElement,
+ buttonGroup: HTMLDivElement
+) {
+ function buildButton(kind: string) {
+ const button = createElement("button", {
+ class: "button",
+ textContent: kind,
+ value: kind,
+ });
+
+ if (kind === "Disabled" || kind === "Enabled") {
+ valueCell.dataset.toggleType = "boolean";
+ }
+
+ button.onclick = () => {
+ const config = kindConfigs[kind];
+
+ const textarea = valueCell.querySelector("textarea");
+ if (textarea) {
+ textarea.placeholder = buildCleanConfigObject(config);
+ }
+
+ const input = valueCell.querySelector("input");
+ if (input) {
+ input.value = config?.default;
+ }
+ };
+ buttonGroup.appendChild(button);
+ }
+
+ // We know that if the kinds array has a length of 2 and includes `null` and `undefined`
+ // then we can assume it's a boolean toggle. Otherwise, we render the union discriminator.
+
+ if ((kinds.includes("null") || kinds.includes("undefined")) && kinds.length === 2) {
+ kinds.forEach((kind: string) => {
+ if (kind === "null" || kind === "undefined") {
+ kind = "Disabled";
+ } else {
+ kind = "Enabled";
+ }
+ buildButton(kind);
+ });
+ } else {
+ valueCell.dataset.toggleType = "config";
+ kinds?.forEach((kind: string) => {
+ buildButton(kind);
+ });
+ }
+}
+
+/**
+ * Because these items are rendered within textarea elements as an editable JSON object, we need to provide
+ * a description and examples for each property in the configuration object. As opposed to standard object props
+ * which are rendered as input elements and have a tooltip specific to the property.
+ */
+function findDescriptionAndExamples(indexer: string, kinds: string[] = ["Enabled", "Disabled"], kindConfigs: Record = {}) {
+ const index = kinds.indexOf(indexer);
+ if (index === -1) {
+ return { description: "", examples: "" };
+ }
+
+ const props = kindConfigs[indexer]?.properties as Record;
+
+ if (!props) {
+ return { description: "", examples: "" };
+ }
+
+ let description = "";
+ let examples = "";
+
+ for (const [key, prop] of Object.entries(props)) {
+ if (key === "kind") {
+ // internal to typebox and although it has a description, it's likely more confusing than helpful
+ // as it's not required in the config object. The selection buttons display the kind anyway.
+ continue;
+ }
+
+ if (prop.description) {
+ description += `-\`${key}\`: ${prop.description}\n`;
+ }
+
+ if (prop.examples) {
+ examples += `-\`${key}\`: ${prop.examples}\n`;
+ }
+
+ if (prop.properties) {
+ const keys = Object.keys(prop.properties);
+ Object.values(prop.properties).forEach((subProp, index) => {
+ if (subProp.description) {
+ description += `- \`${keys[index]}\`: ${subProp.description}\n\n`;
+ }
+
+ if (subProp.examples) {
+ examples += `- \`${keys[index]}\`: ${subProp.examples}\n\n`;
+ }
+ });
+ }
+ }
+
+ return { description, examples };
+}
+
+/**
+ * This function is used to create a clean configuration object for the placeholder of the textarea.
+ *
+ * Effectively this fn does the same as processProperties but without creating elements.
+ */
+export function buildCleanConfigObject(config: Manifest["configuration"]): string {
+ const obj: Record = {};
+ if (!config) {
+ return JSON.stringify(obj, null, 2);
+ }
+ if (config.properties) {
+ Object.keys(config.properties).forEach((key) => {
+ const prop = config.properties[key];
+ if (prop.type === "object" && prop.properties) {
+ obj[key] = JSON.parse(buildCleanConfigObject(prop));
+ } else {
+ obj[key] = prop.default;
+ }
+ });
+ }
+
+ return JSON.stringify(obj, null, 2);
+}
diff --git a/static/scripts/rendering/config-editor.ts b/static/scripts/rendering/config-editor.ts
index 5f15d7b..b0aff03 100644
--- a/static/scripts/rendering/config-editor.ts
+++ b/static/scripts/rendering/config-editor.ts
@@ -1,11 +1,10 @@
import MarkdownIt from "markdown-it";
import { Manifest, Plugin } from "../../types/plugins";
import { getManifestCache } from "../../utils/storage";
-import { extractPluginIdentifier } from "../../utils/strings";
import { ManifestRenderer } from "../render-manifest";
import { controlButtons } from "./control-buttons";
import { processProperties } from "./input-parsing";
-import { addTrackedEventListener, getTrackedEventListeners, removeTrackedEventListener, updateGuiTitle } from "./utils";
+import { addTrackedEventListener, getTrackedEventListeners, normalizePluginName, removeTrackedEventListener, updateGuiTitle } from "./utils";
import { handleResetToDefault, writeNewConfig } from "./write-add-remove";
const md = new MarkdownIt();
@@ -30,7 +29,7 @@ export function renderConfigEditor(renderer: ManifestRenderer, pluginManifest: M
renderer.backButton.style.display = "block";
renderer.manifestGuiBody.innerHTML = null;
controlButtons({ hide: false });
- processProperties(renderer, pluginManifest, pluginManifest?.configuration.properties || {}, null);
+ processProperties(renderer, pluginManifest, pluginManifest?.configuration?.properties || {}, null);
const configInputs = document.querySelectorAll(".config-input");
// If plugin is passed in, we want to inject those values into the inputs
@@ -44,9 +43,6 @@ export function renderConfigEditor(renderer: ManifestRenderer, pluginManifest: M
const keys = key.split(".");
let currentObj = plugin;
for (let i = 0; i < keys.length; i++) {
- if (!currentObj[keys[i]]) {
- break;
- }
currentObj = currentObj[keys[i]] as Record;
}
@@ -85,40 +81,12 @@ export function renderConfigEditor(renderer: ManifestRenderer, pluginManifest: M
}
const parsedConfig = renderer.configParser.parseConfig(renderer.configParser.repoConfig || localStorage.getItem("config"));
-
- // Get the repository URL for the current plugin from the manifest cache
- const manifestCache = getManifestCache();
- const pluginUrls = Object.keys(manifestCache);
- const pluginUrl = pluginUrls.find((url) => {
- return manifestCache[url].name === pluginManifest?.name;
- });
-
- if (!pluginUrl) {
- throw new Error("Plugin URL not found");
- }
-
- const manifestPluginId = extractPluginIdentifier(pluginUrl);
-
- // Check if plugin is installed by looking for any URL that matches
- const isInstalled = parsedConfig.plugins?.find((p) => {
- const installedUrl = p.uses[0].plugin;
-
- // If the installed plugin is a GitHub URL, extract its identifier
- const installedPluginId = extractPluginIdentifier(installedUrl);
-
- // If both are GitHub URLs, compare the repo names
- const isBothGithubUrls = pluginUrl.includes("github") && installedUrl.includes("github");
- if (isBothGithubUrls) {
- return manifestPluginId === installedPluginId;
- }
-
- // Otherwise check if the installed URL contains the repo name
- return installedUrl.toLowerCase().includes(manifestPluginId.toLowerCase());
- });
+ // for when `resetToDefault` is called and no plugin gets passed in, we still want to show the remove button
+ const isInstalled = parsedConfig?.plugins?.find((p) => p.uses[0].plugin.includes(normalizePluginName(pluginManifest?.name || "")));
loadListeners({
renderer,
- pluginManifest,
+ pluginManifest: pluginManifest || null,
withPluginOrInstalled: !!(plugin || isInstalled),
add,
remove,
@@ -134,7 +102,16 @@ export function renderConfigEditor(renderer: ManifestRenderer, pluginManifest: M
}
resetToDefaultButton.hidden = !!(plugin || isInstalled);
- const readme = manifestCache[pluginUrl].readme;
+ const manifestCache = getManifestCache();
+ const pluginNames = Object.keys(manifestCache);
+ const pluginName = pluginNames.find((pluginName) => {
+ return pluginManifest?.name === pluginName;
+ });
+
+ if (!pluginName) {
+ throw new Error("Plugin URL not found");
+ }
+ const readme = manifestCache[pluginName].readme;
if (readme) {
const viewportCell = document.getElementById("viewport-cell");
@@ -149,7 +126,7 @@ export function renderConfigEditor(renderer: ManifestRenderer, pluginManifest: M
const org = localStorage.getItem("selectedOrg");
- updateGuiTitle(`Editing Configuration for ${pluginManifest?.name} in ${org}`);
+ updateGuiTitle(`Editing configuration for ${pluginName.replace(/-/g, " ").replace(/\b\w/g, (l) => l.toUpperCase())} in ${org}`);
renderer.manifestGui?.classList.add("plugin-editor");
renderer.manifestGui?.classList.add("rendered");
}
diff --git a/static/scripts/rendering/input-parsing.ts b/static/scripts/rendering/input-parsing.ts
index a029bcd..1bd5869 100644
--- a/static/scripts/rendering/input-parsing.ts
+++ b/static/scripts/rendering/input-parsing.ts
@@ -2,6 +2,8 @@ import AJV, { AnySchemaObject } from "ajv";
import { createInputRow } from "../../utils/element-helpers";
import { ManifestRenderer } from "../render-manifest";
import { Manifest } from "../../types/plugins";
+import { createAnyOfToggle } from "./anyof-handling";
+import { STRINGS } from "../../utils/strings";
// Without the raw Typebox Schema it was difficult to use Typebox which is why I've used AJV to validate the configuration.
const ajv = new AJV({ allErrors: true, coerceTypes: true, strict: true });
@@ -13,27 +15,69 @@ export function processProperties(
renderer: ManifestRenderer,
manifest: Manifest | null | undefined,
props: Record,
- prefix: string | null = null
+ prefix: string | null = null,
+ desc?: string,
+ examples?: string[]
) {
const required = manifest?.configuration?.required || [];
Object.keys(props).forEach((key) => {
const fullKey = prefix ? `${prefix}.${key}` : key;
const prop = props[key];
if (!prop) {
+ console.log(`No property found for ${key}`);
return;
}
+
+ /**
+ * Depending on how the developer uses the `description` property when defining the schema
+ * they may describe each property or the parent object. If the parent object has a description
+ * and the property does not, we use the parent object's description.
+ *
+ * This is also the case for examples.
+ */
+
+ let parentDesc = prop.description || desc;
+ let parentExamples = prop.examples || examples;
+
+ if (!parentDesc && prop.properties?.length > 0 && prop.properties[0].description) {
+ parentDesc = prop.properties[0].description;
+ }
+
+ if (!parentExamples && prop.properties?.length > 0 && prop.properties[0].examples) {
+ parentExamples = prop.properties[0].examples;
+ }
+
+ /**
+ * Non-null Union of Objects - (see kernel-telegram `Ai Config`):
+ * - We create a toggle and textarea for the user to select the option and edit the item.
+ *
+ * Null/Non-null Union of Objects - (see text-conversation-rewards `incentives.content-evaluator/user-extractor, etc...`):
+ * - We create an Enabled/Disabled toggle and textarea for the user
+ * to select the option and edit the item
+ *
+ * Non-object Union: (see command-start-stop `Assigned Issue Scope`)
+ * - We create a toggle for the user to select the option.
+ *
+ * Objects - (see text-conversation-rewards `incentives.file, etc...`):
+ * - We create an input for each property in the object. Typically, these are
+ * non-null properties because the object is nullable when the schema is defined.
+ *
+ * Arrays - (see command-start-stop `Roles with Review Authority`):
+ * - We create a textarea for the user to input an array of items.
+ *
+ * Boolean - (see command-start-stop `Start Requires Wallet`):
+ * - We create a checkbox for the user to enable/disable the property. (see command-start-stop `Start Requires Wallet`)
+ *
+ * Rest:
+ * - We create an input for the property.
+ */
+
if (prop.type === "object" && prop.properties) {
- processProperties(renderer, manifest, prop.properties, fullKey);
+ processProperties(renderer, manifest, prop.properties, fullKey, parentDesc, parentExamples);
} else if ("anyOf" in prop && Array.isArray(prop.anyOf)) {
- if (prop.default) {
- createInputRow(fullKey, prop, renderer.configDefaults, required.includes(fullKey));
- } else {
- prop.anyOf?.forEach((subProp) => {
- processProperties(renderer, manifest, subProp.properties || {}, fullKey);
- });
- }
+ createAnyOfToggle(fullKey, prop, renderer.configDefaults);
} else {
- createInputRow(fullKey, prop, renderer.configDefaults);
+ createInputRow(fullKey, prop, renderer.configDefaults, parentDesc, parentExamples, required.includes(key));
}
});
}
@@ -46,16 +90,19 @@ export function processProperties(
* easier and less buggy when using the installer.
*/
export function parseConfigInputs(
- configInputs: NodeListOf,
+ configInputs: NodeListOf,
manifest: Manifest
): { config: Record; missing: string[] } {
const config: Record = {};
- const schema = manifest.configuration;
- if (!schema) {
+ const { configuration } = manifest;
+
+ if (!configuration) {
throw new Error("No schema found in manifest");
}
- const required = schema.required || [];
- const validate = ajv.compile(schema as AnySchemaObject);
+ const required = configuration.required || [];
+ const validate = ajv.compile(configuration as AnySchemaObject);
+
+ let tempConfig: Record = {};
configInputs.forEach((input) => {
const key = input.getAttribute("data-config-key");
@@ -66,6 +113,7 @@ export function parseConfigInputs(
const keys = key.split(".");
let currentObj = config;
+
for (let i = 0; i < keys.length - 1; i++) {
const part = keys[i];
if (!currentObj[part] || typeof currentObj[part] !== "object") {
@@ -74,31 +122,15 @@ export function parseConfigInputs(
currentObj = currentObj[part] as Record;
}
- let value: unknown;
const expectedType = input.getAttribute("data-type");
-
- if (expectedType === "boolean") {
- value = (input as HTMLInputElement).checked;
- } else if (expectedType === "object" || expectedType === "array") {
- if (!input.value) {
- value = expectedType === "object" ? {} : [];
- } else
- try {
- value = JSON.parse((input as HTMLTextAreaElement).value);
- } catch (e) {
- console.error(e);
- throw new Error(`Invalid JSON input for ${expectedType} at key "${key}": ${input.value}`);
- }
- } else {
- value = (input as HTMLInputElement).value;
- }
- currentObj[keys[keys.length - 1]] = value;
+ processExpectedType(input, expectedType, key, keys, currentObj);
+ tempConfig = { ...tempConfig, ...config };
});
- if (validate(config)) {
+ if (validate(tempConfig)) {
const missing = [];
for (const key of required) {
- const isBoolean = schema.properties && schema.properties[key] && schema.properties[key].type === "boolean";
+ const isBoolean = configuration.properties && configuration.properties[key] && configuration.properties[key].type === "boolean";
if ((isBoolean && config[key] === false) || config[key] === true) {
continue;
}
@@ -125,3 +157,116 @@ export function parseConfigInputs(
throw new Error("Invalid configuration: " + JSON.stringify(validate.errors, null, 2));
}
}
+
+function processExpectedType(
+ input: HTMLInputElement | HTMLTextAreaElement | HTMLDivElement,
+ expectedType: string | null,
+ key: string,
+ keys: string[],
+ currentObj: Record
+) {
+ const parent = input.parentElement;
+
+ // Handle Enabled/Disabled toggles (see text-conversation-rewards modules)
+ if (parent?.hasAttribute("data-has-toggle") && (expectedType === null || expectedType === "null")) {
+ currentObj[keys[keys.length - 1]] = handleToggleInput(parent, key);
+ return;
+ }
+
+ let value: unknown;
+
+ switch (expectedType) {
+ case "boolean":
+ value = (input as HTMLInputElement).checked;
+ break;
+ case "object":
+ case "array": {
+ value = parseJsonValue(input, expectedType, key);
+ break;
+ }
+ case "number":
+ case "integer":
+ value = Number((input as HTMLInputElement).value);
+ break;
+ default:
+ value = (input as HTMLInputElement).value;
+ }
+
+ // Handle cases where toggles influence the value directly such as
+ // union selection options (see command-start-stop `Assigned Issue Scope`)
+ if (!value && input.hasAttribute("data-has-toggle") && parent) {
+ value = handleToggleInput(parent, key);
+ }
+
+ currentObj[keys[keys.length - 1]] = value;
+}
+
+function handleToggleInput(parent: HTMLElement, key: string): unknown {
+ const toggleType = parent.getAttribute("data-toggle-type");
+ const selected = parent.getAttribute(STRINGS.DATA_SELECTED);
+ const textArea = parent.querySelector("textarea");
+
+ if (!textArea) {
+ // it's a non-null option toggle (see command-start-stop `Assigned Issue Scope`)
+ // data-selected on the 2nd child element (td) is the value
+ const selected = parent.querySelectorAll("td")[1].getAttribute(STRINGS.DATA_SELECTED);
+ if (!selected) {
+ throw new Error(`No selected value found for key "${key}"`);
+ }
+ return selected;
+ }
+
+ switch (toggleType) {
+ case "union":
+ /**
+ * This is the actual value of the selected union type which is shown as a
+ * toggle button. The value is the key of the selected union type.
+ */
+ return selected;
+ case "config": {
+ /**
+ * We know that it cannot be disabled and so only the value is relevant.
+ *
+ * Obtain the `kind` from the toggle and parse the value of the textarea
+ * then merge the two together, as `kind` is required from by the typebox schema.
+ */
+
+ const kind = parent.getAttribute("data-selected");
+ if (!kind) {
+ throw new Error(`No kind found for key "${key}"`);
+ }
+
+ return {
+ kind,
+ value: JSON.parse(textArea.value),
+ };
+ }
+ case "boolean":
+ /**
+ * When enabled, the input has a value, when disabled, the input has a placeholder.
+ * Schema validation often requires a non-null value, so we need to parse the value
+ * when the toggle is enabled, and the placeholder when it's disabled which is likely to be
+ * an empty object or array for example.
+ *
+ * This is for improved user feedback when the user disables the toggle.
+ */
+ return selected === "Enabled" ? JSON.parse(textArea.value) : JSON.parse(textArea.placeholder);
+ default:
+ return textArea.value;
+ }
+}
+
+function parseJsonValue(input: HTMLInputElement | HTMLTextAreaElement | HTMLDivElement, expectedType: string, key: string): unknown {
+ if ("value" in input && !input.value) {
+ return expectedType === "object" ? {} : [];
+ }
+ try {
+ /**
+ * If the input is a textarea, we parse the value of the textarea, otherwise we parse the value of the input.
+ */
+ return JSON.parse("value" in input ? input.value : input.textContent || "");
+ } catch (e) {
+ console.error(e);
+ throw new Error(`Invalid JSON input for ${expectedType} at key "${key}": ${"value" in input ? input.value : input.textContent}`);
+ }
+}
diff --git a/static/scripts/rendering/plugin-select.ts b/static/scripts/rendering/plugin-select.ts
index 5ba7de3..03e0daf 100644
--- a/static/scripts/rendering/plugin-select.ts
+++ b/static/scripts/rendering/plugin-select.ts
@@ -1,7 +1,7 @@
-import { ManifestCache, ManifestPreDecode, Plugin } from "../../types/plugins";
+import { Plugin } from "../../types/plugins";
import { createElement } from "../../utils/element-helpers";
import { getManifestCache } from "../../utils/storage";
-import { STRINGS, extractPluginIdentifier } from "../../utils/strings";
+import { STRINGS } from "../../utils/strings";
import { ManifestRenderer } from "../render-manifest";
import { renderConfigEditor } from "./config-editor";
import { controlButtons } from "./control-buttons";
@@ -18,27 +18,21 @@ export function renderPluginSelector(renderer: ManifestRenderer): void {
controlButtons({ hide: true });
const manifestCache = getManifestCache();
- const pluginUrls = Object.keys(manifestCache);
+ const pluginNames = Object.keys(manifestCache);
- const pickerRow = document.createElement("tr");
- const pickerCell = document.createElement("td");
- pickerCell.colSpan = 2;
- pickerCell.className = STRINGS.TDV_CENTERED;
+ const pickerRow = createElement("tr", { className: STRINGS.TDV_CENTERED });
+ const pickerCell = createElement("td", {
+ colSpan: "2",
+ className: STRINGS.TDV_CENTERED,
+ });
const userConfig = renderer.configParser.repoConfig;
let installedPlugins: Plugin[] = [];
if (userConfig) {
- installedPlugins = renderer.configParser.parseConfig(userConfig).plugins;
+ installedPlugins = renderer.configParser.parseConfig(userConfig)?.plugins || [];
}
- const cleanManifestCache = Object.keys(manifestCache).reduce((acc, key) => {
- if (manifestCache[key]?.name) {
- acc[key] = manifestCache[key];
- }
- return acc;
- }, {} as ManifestCache);
-
const customSelect = createElement("div", { class: "custom-select" });
const selectSelected = createElement("div", {
@@ -58,32 +52,15 @@ export function renderPluginSelector(renderer: ManifestRenderer): void {
renderer.manifestGuiBody.appendChild(pickerRow);
- pluginUrls.forEach((url) => {
- if (!cleanManifestCache[url]?.name) {
+ pluginNames.forEach((pluginName) => {
+ if (!manifestCache[pluginName]?.manifest?.name) {
return;
}
+ const reg = new RegExp(pluginName, "i");
+ const installedPlugin: Plugin | undefined = installedPlugins.find((plugin) => reg.test(plugin.uses[0].plugin));
- const manifestPluginId = extractPluginIdentifier(url);
-
- // Check if plugin is installed by looking for any URL that matches
- const installedPlugin: Plugin | undefined = installedPlugins.find((plugin) => {
- const installedUrl = plugin.uses[0].plugin;
-
- // If the installed plugin is a GitHub URL, extract its identifier
- const installedPluginId = extractPluginIdentifier(installedUrl);
-
- // If both are GitHub URLs, compare the repo names
- const isBothGithubUrls = url.includes("github") && installedUrl.includes("github");
- if (isBothGithubUrls) {
- return manifestPluginId === installedPluginId;
- }
-
- // Otherwise check if the installed URL contains the repo name
- return installedUrl.toLowerCase().includes(manifestPluginId.toLowerCase());
- });
-
- const defaultForInstalled: ManifestPreDecode | null = cleanManifestCache[url];
- const optionText = defaultForInstalled.name;
+ const defaultForInstalled = manifestCache[pluginName];
+ const optionText = pluginName.replace(/-/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
const indicator = installedPlugin ? "🟢" : "🔴";
const optionDiv = createElement("div", { class: "select-option" });
@@ -93,12 +70,22 @@ export function renderPluginSelector(renderer: ManifestRenderer): void {
optionDiv.appendChild(textSpan);
optionDiv.appendChild(indicatorSpan);
- optionDiv.addEventListener("click", () => {
- selectSelected.textContent = optionText;
- closeAllSelect();
- localStorage.setItem("selectedPluginManifest", JSON.stringify(defaultForInstalled));
- renderConfigEditor(renderer, defaultForInstalled, installedPlugin?.uses[0].with);
- });
+ // if there is no `homepage_url` disable the plugin option
+ if (!manifestCache[pluginName].homepageUrl) {
+ console.log("No homepage url found for", {
+ pluginName,
+ manifest: manifestCache[pluginName].manifest,
+ });
+ optionDiv.style.pointerEvents = "none";
+ optionDiv.style.opacity = "0.5";
+ } else {
+ optionDiv.addEventListener("click", () => {
+ selectSelected.textContent = optionText;
+ closeAllSelect();
+ localStorage.setItem("selectedPluginManifest", JSON.stringify(defaultForInstalled));
+ renderConfigEditor(renderer, defaultForInstalled.manifest, installedPlugin?.uses[0].with);
+ });
+ }
selectItems.appendChild(optionDiv);
});
diff --git a/static/scripts/rendering/write-add-remove.ts b/static/scripts/rendering/write-add-remove.ts
index 1e63fc8..f2d43ef 100644
--- a/static/scripts/rendering/write-add-remove.ts
+++ b/static/scripts/rendering/write-add-remove.ts
@@ -1,8 +1,7 @@
import { toastNotification } from "../../utils/toaster";
import { ManifestRenderer } from "../render-manifest";
-import { Manifest, Plugin } from "../../types/plugins";
+import { Manifest, ManifestPreDecode, Plugin } from "../../types/plugins";
import { parseConfigInputs } from "./input-parsing";
-import { getOfficialPluginConfig } from "../../utils/storage";
import { renderConfigEditor } from "./config-editor";
import { normalizePluginName } from "./utils";
import { handleBackButtonClick } from "./navigation";
@@ -23,10 +22,10 @@ export function writeNewConfig(renderer: ManifestRenderer, option: "add" | "remo
});
throw new Error("No selected plugin manifest found");
}
- const pluginManifest = JSON.parse(selectedManifest) as Manifest;
+ const pluginManifest = JSON.parse(selectedManifest) as ManifestPreDecode;
const configInputs = document.querySelectorAll(".config-input");
- const { config: newConfig, missing } = parseConfigInputs(configInputs, pluginManifest);
+ const { config: newConfig, missing } = parseConfigInputs(configInputs, pluginManifest.manifest);
if (missing.length) {
toastNotification("Please fill out all required fields.", {
@@ -46,11 +45,8 @@ export function writeNewConfig(renderer: ManifestRenderer, option: "add" | "remo
}
renderer.configParser.loadConfig();
- const normalizedPluginName = normalizePluginName(pluginManifest.name);
- const officialPluginConfig: Record = getOfficialPluginConfig();
- const pluginUrl = Object.keys(officialPluginConfig).find((url) => {
- return url.includes(normalizedPluginName);
- });
+ const normalizedPluginName = normalizePluginName(pluginManifest.manifest.name);
+ const pluginUrl = pluginManifest.homepageUrl;
if (!pluginUrl) {
toastNotification(`No plugin URL found for ${normalizedPluginName}.`, {
@@ -72,9 +68,9 @@ export function writeNewConfig(renderer: ManifestRenderer, option: "add" | "remo
removePushNotificationIfPresent();
if (option === "add") {
- handleAddPlugin(renderer, plugin, pluginManifest);
+ handleAddPlugin(renderer, plugin, pluginManifest.manifest);
} else if (option === "remove") {
- handleRemovePlugin(renderer, plugin, pluginManifest);
+ handleRemovePlugin(renderer, plugin, pluginManifest.manifest);
}
}
diff --git a/static/types/plugins.ts b/static/types/plugins.ts
index 7f3404a..db51a77 100644
--- a/static/types/plugins.ts
+++ b/static/types/plugins.ts
@@ -1,42 +1,25 @@
-export type PluginConfig = {
+import { Manifest } from "@ubiquity-os/ubiquity-os-kernel";
+
+type PluginConfig = {
plugins: Plugin[];
};
-export interface Plugin {
+interface Plugin {
uses: Uses[];
}
-export interface Uses {
+interface Uses {
plugin: string;
with: Record;
}
-export interface ManifestPreDecode extends Manifest {
- actionUrl?: string;
- workerUrl?: string;
+interface ManifestPreDecode {
+ manifest: Manifest;
+ homepageUrl?: string | null;
error?: string;
+ readme?: string;
}
-export type ManifestCache = Record;
+type ManifestCache = Record;
-export type Manifest = {
- name: string;
- description: string;
- "ubiquity:listeners": string[];
- commands?: {
- [key: string]: {
- example: string;
- description: string;
- };
- };
- configuration: {
- type: string;
- default: object;
- items?: {
- type: string;
- };
- properties?: Record;
- required?: string[];
- };
- readme?: string;
-};
+export { ManifestCache, PluginConfig, Plugin, Uses, ManifestPreDecode, Manifest };
diff --git a/static/utils/element-helpers.ts b/static/utils/element-helpers.ts
index 26682f6..f526e10 100644
--- a/static/utils/element-helpers.ts
+++ b/static/utils/element-helpers.ts
@@ -1,5 +1,6 @@
import { Manifest } from "../types/plugins";
-import { toastNotification } from "./toaster";
+import MarkdownIt from "markdown-it";
+const md = new MarkdownIt();
const CONFIG_INPUT_STR = "config-input";
@@ -10,6 +11,7 @@ export function createElement(
attributes: { [key: string]: string | boolean | null }
): HTMLElementTagNameMap[TK] {
const element = document.createElement(tagName);
+
Object.keys(attributes).forEach((key) => {
if (key === "textContent") {
element.textContent = attributes[key] as string;
@@ -19,35 +21,47 @@ export function createElement(
element.setAttribute(key, `${attributes[key]}`);
}
});
+
return element;
}
export function createInputRow(
key: string,
prop: Manifest["configuration"],
configDefaults: Record,
+ desc?: string,
+ examples?: string[],
required = false
): void {
const row = document.createElement("tr");
-
const headerCell = document.createElement("td");
headerCell.className = "table-data-header";
headerCell.textContent = key.replace(/([A-Z])/g, " $1");
+
+ if (prop && !prop.description && desc) {
+ prop.description = desc;
+ }
+
+ if (prop && !prop.examples && examples) {
+ prop.examples = examples;
+ }
+
+ createConfigParamTooltip(headerCell, prop);
row.appendChild(headerCell);
const valueCell = document.createElement("td");
valueCell.className = "table-data-value";
valueCell.ariaRequired = `${required}`;
- const input = createInput(key, prop.default, prop.type);
+ const input = createInput(key, prop?.default, prop?.type);
valueCell.appendChild(input);
row.appendChild(valueCell);
manifestGuiBody?.appendChild(row);
configDefaults[key] = {
- type: prop.type,
- value: prop.default,
- items: prop.items ? { type: prop.items.type } : null,
+ type: prop?.type,
+ value: prop?.default,
+ items: prop?.items ? { type: prop?.items.type } : null,
};
}
export function createInput(key: string, defaultValue: unknown, prop: string): HTMLElement {
@@ -55,24 +69,21 @@ export function createInput(key: string, defaultValue: unknown, prop: string): H
throw new Error("Input name is required");
}
- let ele: HTMLElement;
+ let ele: HTMLElement | null = null;
- if (prop === "object" || prop === "array") {
- ele = createTextareaInput(key, defaultValue, prop);
+ if (prop === "object" || typeof defaultValue === "object") {
+ ele = createTextareaInput(key, defaultValue as object, prop);
} else if (prop === "boolean") {
- ele = createBooleanInput(key, defaultValue);
+ ele = createBooleanInput(key, defaultValue as boolean);
+ } else if (prop === "number" || prop === "integer") {
+ ele = createStringInput(key, defaultValue as string, "number");
} else {
- ele = createStringInput(key, defaultValue, prop);
- }
-
- if (!ele) {
- toastNotification("An error occurred while creating an input element", { type: "error" });
- throw new Error("Input type is required");
+ ele = createStringInput(key, defaultValue ? (defaultValue as string) : "", prop ?? typeof defaultValue);
}
return ele;
}
-export function createStringInput(key: string, defaultValue: string | unknown, dataType: string): HTMLElement {
+export function createStringInput(key: string, defaultValue: string, dataType: string): HTMLElement {
return createElement("input", {
type: "text",
id: key,
@@ -80,28 +91,32 @@ export function createStringInput(key: string, defaultValue: string | unknown, d
"data-config-key": key,
"data-type": dataType,
class: CONFIG_INPUT_STR,
- value: defaultValue ? `${defaultValue}` : "",
- placeholder: defaultValue ? "" : `Enter ${dataType}`,
+ value: defaultValue,
});
}
export function createBooleanInput(key: string, defaultValue: boolean | unknown): HTMLElement {
- const inputElem = createElement("input", {
+ return createElement("input", {
type: "checkbox",
id: key,
name: key,
"data-config-key": key,
"data-type": "boolean",
class: CONFIG_INPUT_STR,
+ checked: defaultValue as boolean,
});
+}
+export function createTextareaInput(key: string, defaultValue: object | unknown, dataType: string): HTMLElement {
+ let text = "";
- if (defaultValue) {
- inputElem.setAttribute("checked", "");
+ if (typeof defaultValue === "object") {
+ text = JSON.stringify(defaultValue, null, 2);
+ } else {
+ text = defaultValue ? (defaultValue as string) : "";
}
- return inputElem;
-}
-export function createTextareaInput(key: string, defaultValue: object | unknown, dataType: string): HTMLElement {
- const inputElem = createElement("textarea", {
+ const textContent = dataType === undefined ? "" : text;
+
+ return createElement("textarea", {
id: key,
name: key,
"data-config-key": key,
@@ -109,10 +124,75 @@ export function createTextareaInput(key: string, defaultValue: object | unknown,
class: CONFIG_INPUT_STR,
rows: "5",
cols: "50",
+ textContent,
});
- inputElem.textContent = JSON.stringify(defaultValue, null, 2);
+}
- inputElem.setAttribute("placeholder", `Enter ${dataType} in JSON format`);
+function createTooltipText(desc: string, examples: (string | number | object)[]) {
+ const tooltipText = createElement("span", { class: "tooltiptext" });
+ tooltipText.innerHTML = md.renderInline(desc);
- return inputElem;
+ if (!examples || typeof examples === "string") {
+ return tooltipText;
+ }
+
+ if (examples.length) {
+ let str;
+ try {
+ str = `Examples: ${examples
+ .map((ex) => {
+ if (!ex) {
+ return "";
+ }
+ if (typeof ex === "object") {
+ return JSON.stringify(ex);
+ }
+
+ if (typeof ex === "number") {
+ ex = ex.toString();
+ }
+
+ if (ex.includes("[") || ex.includes("{")) {
+ return ex;
+ }
+
+ return `${ex}`;
+ })
+ .join(", ")}`;
+ } catch (er) {
+ console.log(`Error parsing examples: `, examples);
+ console.error(er);
+ }
+
+ if (!str) {
+ return tooltipText;
+ }
+
+ const exampleElem = createElement("p", { textContent: str });
+ tooltipText.appendChild(exampleElem);
+ }
+
+ return tooltipText;
+}
+
+export function createConfigParamTooltip(headerCell: HTMLElement, prop: Manifest["configuration"]): void {
+ if (!prop || !prop.description) {
+ return;
+ }
+
+ const tooltip = createElement("span", { class: "tooltip", textContent: "?", id: prop?.id });
+ const tooltipText = createTooltipText(prop?.description, prop?.examples || []);
+
+ tooltip.appendChild(tooltipText);
+ headerCell.appendChild(tooltip);
+
+ tooltip.addEventListener("mouseenter", () => {
+ tooltipText.style.visibility = "visible";
+ tooltipText.style.opacity = "1";
+ });
+
+ tooltip.addEventListener("mouseleave", () => {
+ tooltipText.style.visibility = "hidden";
+ tooltipText.style.opacity = "0";
+ });
}
diff --git a/static/utils/storage.ts b/static/utils/storage.ts
index 71d4cdc..33a1863 100644
--- a/static/utils/storage.ts
+++ b/static/utils/storage.ts
@@ -3,7 +3,3 @@ import { ManifestCache } from "../types/plugins";
export function getManifestCache(): ManifestCache {
return JSON.parse(localStorage.getItem("manifestCache") || "{}");
}
-
-export function getOfficialPluginConfig() {
- return JSON.parse(localStorage.getItem("officialPluginConfig") || "{}");
-}
diff --git a/static/utils/strings.ts b/static/utils/strings.ts
index 89b2664..7a69041 100644
--- a/static/utils/strings.ts
+++ b/static/utils/strings.ts
@@ -4,6 +4,7 @@ export const STRINGS = {
SELECT_SELECTED: ".select-selected",
SELECT_HIDE: "select-hide",
SELECT_ARROW_ACTIVE: "select-arrow-active",
+ DATA_SELECTED: "data-selected",
PICKER_SELECT: "picker-select",
};
diff --git a/yarn.lock b/yarn.lock
index 34babbc..0d15426 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1891,11 +1891,6 @@
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.33.21.tgz#651191b5cc13c27ae0cb2150f3af5255597a9961"
integrity sha512-1wU0VNSZQt13BmJvxYhHRVwDBnG8y5qrcyi3DnmEQzvfeRycUNneQSd6quyxrNbspM1pV/m4r4udO6o1tCuXjg==
-"@sinclair/typebox@^0.33.21":
- version "0.33.22"
- resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.33.22.tgz#3339d85172509095a8384cb4b44834a7c9309d86"
- integrity sha512-auUj4k+f4pyrIVf4GW5UKquSZFHJWri06QgARy9C0t9ZTjJLIuNIrr1yl9bWcJWJ1Gz1vOvYN1D+QPaIlNMVkQ==
-
"@sinonjs/commons@^3.0.0":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd"
@@ -2281,10 +2276,10 @@
"@typescript-eslint/types" "8.8.1"
eslint-visitor-keys "^3.4.3"
-"@ubiquity-os/plugin-sdk@^1.0.11":
- version "1.0.11"
- resolved "https://registry.yarnpkg.com/@ubiquity-os/plugin-sdk/-/plugin-sdk-1.0.11.tgz#b45029a0bd7469b19e71d4685d9ee8e7163afe38"
- integrity sha512-BlZbqOfuBYMFyDEJfPc9HCrr5l8m3uNOXmPXr/M8/UFwZT+nHfZfB+AULoY0Goyx2BX1JaHd5bgDjJG1PwozPA==
+"@ubiquity-os/plugin-sdk@^3.0.0":
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/@ubiquity-os/plugin-sdk/-/plugin-sdk-3.0.0.tgz#33f99ac6b2526bb1bcb818b26a9ec46911f8183b"
+ integrity sha512-wvUSEYjcy2u6cZHAoSLqfa3kOCkCdOIeMX7Lh+dPxEc0hm3P6cq2b2xAyVZUCiVf5nsIt+xnb2xH0Bk9LSyr2g==
dependencies:
"@actions/core" "^1.11.1"
"@actions/github" "^6.0.0"
@@ -2297,8 +2292,7 @@
"@octokit/rest" "^21.0.2"
"@octokit/types" "^13.6.1"
"@octokit/webhooks" "^13.3.0"
- "@sinclair/typebox" "^0.33.21"
- "@ubiquity-os/ubiquity-os-logger" "^1.3.2"
+ "@ubiquity-os/ubiquity-os-logger" "^1.4.0"
dotenv "^16.4.5"
hono "^4.6.9"
@@ -2335,6 +2329,11 @@
resolved "https://registry.yarnpkg.com/@ubiquity-os/ubiquity-os-logger/-/ubiquity-os-logger-1.3.2.tgz#4423bc0baeac5c2f73123d15fd961310521163cd"
integrity sha512-oTIzR8z4jAQmaeJp98t1bZUKE3Ws9pas0sbxt58fC37MwXclPMWrLO+a0JlhPkdJYsvpv/q/79wC2MKVhOIVXQ==
+"@ubiquity-os/ubiquity-os-logger@^1.4.0":
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/@ubiquity-os/ubiquity-os-logger/-/ubiquity-os-logger-1.4.0.tgz#fa0c73216c330c823c80479ff956ded5ec3321f5"
+ integrity sha512-giybluPmu0sreEWi60t9X8NxC5d48X7oQAb9RjXR7wX/ZNYugbAaKgj0TYEqECvSVDqgzpKo7UNerh1UQBscGw==
+
JSONStream@^1.3.5:
version "1.3.5"
resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0"