| 1 | import { createStore } from "/js/AlpineStore.js"; |
| 2 | import * as api from "/js/api.js"; |
| 3 | import { openModal } from "/js/modals.js"; |
| 4 | import { renderSafeMarkdown } from "/js/safe-markdown.js"; |
| 5 | import { toastFrontendSuccess, toastFrontendError } from "/components/notifications/notification-store.js"; |
| 6 | import { showConfirmDialog } from "/js/confirmDialog.js"; |
| 7 | import { formatDateTime } from "/js/time-utils.js"; |
| 8 | import { store as imageViewerStore } from "/components/modals/image-viewer/image-viewer-store.js"; |
| 9 | import { store as pluginListStore } from "/components/plugins/list/pluginListStore.js"; |
| 10 | import { store as pluginExecuteStore } from "/components/plugins/list/plugin-execute-store.js"; |
| 11 | import { store as pluginSettingsStore } from "/components/plugins/plugin-settings-store.js"; |
| 12 | |
| 13 | const PLUGIN_API = "plugins/_plugin_installer/plugin_install"; |
| 14 | const PER_PAGE = 24; |
| 15 | const POPULAR_PLUGIN_MIN_STARS = 3; |
| 16 | const NEW_PLUGIN_WINDOW_DAYS = 14; |
| 17 | |
| 18 | const SECURITY_WARNING = { |
| 19 | title: "Security Warning", |
| 20 | message: ` |
| 21 | <p><strong>Third-party plugins may contain malicious code.</strong> <br> We can't guarantee their safety — install at your own risk.</p> |
| 22 | <p style="margin-top: 0.75em;">We recommend scanning all plugins with A0 first.</p> |
| 23 | `, |
| 24 | type: "warning", |
| 25 | confirmText: "Install Anyway", |
| 26 | cancelText: "Cancel", |
| 27 | }; |
| 28 | |
| 29 | const model = { |
| 30 | // ZIP install state |
| 31 | zipFile: null, |
| 32 | zipFileName: "", |
| 33 | |
| 34 | // Git install state |
| 35 | gitUrl: "", |
| 36 | gitToken: "", |
| 37 | |
| 38 | // Index state |
| 39 | index: { authors: {}, plugins: {} }, |
| 40 | indexLoadPromise: null, |
| 41 | indexLoadSeq: 0, |
| 42 | installedPlugins: [], |
| 43 | installedPluginDetails: {}, |
| 44 | search: "", |
| 45 | page: 1, |
| 46 | sortBy: "stars", |
| 47 | browseFilter: "all", |
| 48 | selectedPlugin: null, |
| 49 | |
| 50 | // Shared state |
| 51 | loading: false, |
| 52 | loadingMessage: "", |
| 53 | result: null, |
| 54 | |
| 55 | // README state |
| 56 | readmeContent: null, |
| 57 | readmeLoading: false, |
| 58 | |
| 59 | // Installed plugin detail (for manage buttons) |
| 60 | installedPluginInfo: null, |
| 61 | |
| 62 | detailThumbnailUrl: null, |
| 63 | |
| 64 | // Inline error for the detail modal (e.g. update failure), structured so |
| 65 | // the UI can render it next to the action button instead of relying on a |
| 66 | // toast the user can miss. |
| 67 | detailError: null, |
| 68 | |
| 69 | // Tab state |
| 70 | activeTab: "store", |
| 71 | |
| 72 | setTab(tab) { |
| 73 | this.activeTab = tab; |
| 74 | this.result = null; |
| 75 | }, |
| 76 | |
| 77 | setBrowseFilter(filter) { |
| 78 | const nextFilter = filter || "all"; |
| 79 | this.browseFilter = nextFilter; |
| 80 | if (nextFilter === "new" && this.sortBy === "stars") { |
| 81 | this.sortBy = "updated"; |
| 82 | } |
| 83 | this.page = 1; |
| 84 | }, |
| 85 | |
| 86 | /** Normalize GitHub URL and return raw.githubusercontent.com base (no trailing slash). */ |
| 87 | _githubRawBase(githubUrl) { |
| 88 | if (!githubUrl || typeof githubUrl !== "string") return null; |
| 89 | let url = githubUrl.trim().replace(/\.git$/i, ""); |
| 90 | if (!url.includes("github.com")) return null; |
| 91 | return url.replace("https://github.com/", "https://raw.githubusercontent.com/"); |
| 92 | }, |
| 93 | |
| 94 | _pluginPrimaryTag(plugin) { |
| 95 | const tags = Array.isArray(plugin?.tags) ? plugin.tags.filter(Boolean) : []; |
| 96 | return tags[0] || ""; |
| 97 | }, |
| 98 | |
| 99 | _formatBrowseTag(tag) { |
| 100 | if (!tag || typeof tag !== "string") return ""; |
| 101 | return tag |
| 102 | .split(/[-_]/) |
| 103 | .filter(Boolean) |
| 104 | .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) |
| 105 | .join(" "); |
| 106 | }, |
| 107 | |
| 108 | _isPopularPlugin(plugin) { |
| 109 | return (plugin?.stars || 0) >= POPULAR_PLUGIN_MIN_STARS; |
| 110 | }, |
| 111 | |
| 112 | _isNewPlugin(plugin) { |
| 113 | const updatedAt = (plugin?.updated || "").trim(); |
| 114 | if (!updatedAt) return false; |
| 115 | const updatedMs = Date.parse(updatedAt); |
| 116 | if (Number.isNaN(updatedMs)) return false; |
| 117 | |
| 118 | const nowMs = Date.now(); |
| 119 | const cutoffMs = nowMs - NEW_PLUGIN_WINDOW_DAYS * 24 * 60 * 60 * 1000; |
| 120 | return updatedMs >= cutoffMs; |
| 121 | }, |
| 122 | |
| 123 | isNewPlugin(plugin) { |
| 124 | return this._isNewPlugin(plugin); |
| 125 | }, |
| 126 | |
| 127 | _getSuspensionReason(plugin) { |
| 128 | return typeof plugin?.suspended === "string" ? plugin.suspended.trim() : ""; |
| 129 | }, |
| 130 | |
| 131 | isPluginSuspended(plugin) { |
| 132 | return !!this._getSuspensionReason(plugin); |
| 133 | }, |
| 134 | |
| 135 | _matchesBrowseFilter(plugin, filterKey) { |
| 136 | if (!filterKey || filterKey === "all") return true; |
| 137 | if (filterKey === "installed") return !!plugin?.installed; |
| 138 | if (filterKey === "update") return !!plugin?.has_update; |
| 139 | if (filterKey === "popular") return this._isPopularPlugin(plugin); |
| 140 | if (filterKey === "new") return this._isNewPlugin(plugin); |
| 141 | if (filterKey.startsWith("tag:")) { |
| 142 | return this._pluginPrimaryTag(plugin) === filterKey.slice(4); |
| 143 | } |
| 144 | return false; |
| 145 | }, |
| 146 | |
| 147 | _compareTimestamp(a, b) { |
| 148 | const aTime = a ? Date.parse(a) : NaN; |
| 149 | const bTime = b ? Date.parse(b) : NaN; |
| 150 | if (Number.isNaN(aTime) || Number.isNaN(bTime)) return 0; |
| 151 | if (aTime === bTime) return 0; |
| 152 | return aTime > bTime ? 1 : -1; |
| 153 | }, |
| 154 | |
| 155 | _hasPluginHubUpdate(indexPlugin, installedPlugin) { |
| 156 | const latestCommit = (indexPlugin?.commit || "").trim(); |
| 157 | const currentCommit = (installedPlugin?.current_commit || "").trim(); |
| 158 | if (!latestCommit || !currentCommit) return false; |
| 159 | if (latestCommit === currentCommit) return false; |
| 160 | |
| 161 | const latestTimestamp = indexPlugin?.updated || ""; |
| 162 | const currentTimestamp = installedPlugin?.current_commit_timestamp || ""; |
| 163 | const timestampComparison = this._compareTimestamp(latestTimestamp, currentTimestamp); |
| 164 | if (timestampComparison !== 0) return timestampComparison > 0; |
| 165 | |
| 166 | return true; |
| 167 | }, |
| 168 | |
| 169 | _comparePluginsByStars(a, b) { |
| 170 | const aSuspended = this.isPluginSuspended(a); |
| 171 | const bSuspended = this.isPluginSuspended(b); |
| 172 | if (aSuspended !== bSuspended) { |
| 173 | return aSuspended ? 1 : -1; |
| 174 | } |
| 175 | |
| 176 | const aStars = aSuspended ? 0 : Number(a?.stars) || 0; |
| 177 | const bStars = bSuspended ? 0 : Number(b?.stars) || 0; |
| 178 | if (aStars !== bStars) { |
| 179 | return bStars - aStars; |
| 180 | } |
| 181 | |
| 182 | return (a.title || a.key).localeCompare(b.title || b.key); |
| 183 | }, |
| 184 | |
| 185 | _comparePluginsByUpdated(a, b) { |
| 186 | const updatedComparison = this._compareTimestamp(a?.updated, b?.updated); |
| 187 | if (updatedComparison !== 0) { |
| 188 | return updatedComparison > 0 ? -1 : 1; |
| 189 | } |
| 190 | return this._comparePluginsByStars(a, b); |
| 191 | }, |
| 192 | |
| 193 | // ── ZIP Install ────────────────────────────── |
| 194 | |
| 195 | handleFileUpload(event) { |
| 196 | const file = event.target.files[0]; |
| 197 | if (!file) return; |
| 198 | this.zipFile = file; |
| 199 | this.zipFileName = file.name; |
| 200 | this.result = null; |
| 201 | }, |
| 202 | |
| 203 | async installZip() { |
| 204 | if (!this.zipFile) { |
| 205 | void toastFrontendError("Please select a ZIP file first", "Plugin Installer"); |
| 206 | return; |
| 207 | } |
| 208 | |
| 209 | const confirmed = await showConfirmDialog(SECURITY_WARNING); |
| 210 | if (!confirmed) return; |
| 211 | |
| 212 | try { |
| 213 | this.loading = true; |
| 214 | this.loadingMessage = "Installing plugin from ZIP..."; |
| 215 | this.result = null; |
| 216 | |
| 217 | const formData = new FormData(); |
| 218 | formData.append("action", "install_zip"); |
| 219 | formData.append("plugin_file", this.zipFile); |
| 220 | |
| 221 | const response = await api.fetchApi(PLUGIN_API, { |
| 222 | method: "POST", |
| 223 | body: formData, |
| 224 | }); |
| 225 | |
| 226 | const data = await response.json(); |
| 227 | if (!data.success) { |
| 228 | void toastFrontendError(data.error || "Installation failed", "Plugin Installer"); |
| 229 | return; |
| 230 | } |
| 231 | |
| 232 | this.result = data; |
| 233 | |
| 234 | toastFrontendSuccess( |
| 235 | `Plugin "${data.title || data.plugin_name}" installed`, |
| 236 | "Plugin Installer" |
| 237 | ); |
| 238 | } catch (e) { |
| 239 | const message = e instanceof Error ? e.message : String(e); |
| 240 | void toastFrontendError(`Installation error: ${message}`, "Plugin Installer"); |
| 241 | } finally { |
| 242 | this.loading = false; |
| 243 | this.loadingMessage = ""; |
| 244 | } |
| 245 | }, |
| 246 | |
| 247 | // ── Git Install ────────────────────────────── |
| 248 | |
| 249 | async installGit() { |
| 250 | const url = (this.gitUrl || "").trim(); |
| 251 | if (!url) { |
| 252 | void toastFrontendError("Please enter a Git URL", "Plugin Installer"); |
| 253 | return; |
| 254 | } |
| 255 | |
| 256 | const confirmed = await showConfirmDialog(SECURITY_WARNING); |
| 257 | if (!confirmed) return; |
| 258 | |
| 259 | try { |
| 260 | this.loading = true; |
| 261 | this.loadingMessage = "Cloning repository..."; |
| 262 | this.result = null; |
| 263 | |
| 264 | const data = await api.callJsonApi(PLUGIN_API, { |
| 265 | action: "install_git", |
| 266 | git_url: url, |
| 267 | git_token: this.gitToken || "", |
| 268 | }); |
| 269 | |
| 270 | if (!data.success) { |
| 271 | void toastFrontendError(data.error || "Clone failed", "Plugin Installer"); |
| 272 | return; |
| 273 | } |
| 274 | |
| 275 | this.result = data; |
| 276 | |
| 277 | toastFrontendSuccess( |
| 278 | `Plugin "${data.title || data.plugin_name}" installed`, |
| 279 | "Plugin Installer" |
| 280 | ); |
| 281 | } catch (e) { |
| 282 | const message = e instanceof Error ? e.message : String(e); |
| 283 | void toastFrontendError(`Clone error: ${message}`, "Plugin Installer"); |
| 284 | } finally { |
| 285 | this.loading = false; |
| 286 | this.loadingMessage = ""; |
| 287 | } |
| 288 | }, |
| 289 | |
| 290 | // ── Index Browse ───────────────────────────── |
| 291 | |
| 292 | hasIndexData() { |
| 293 | const plugins = this.index?.plugins; |
| 294 | return !!plugins && typeof plugins === "object" && Object.keys(plugins).length > 0; |
| 295 | }, |
| 296 | |
| 297 | async fetchIndex(options = {}) { |
| 298 | const force = !!options?.force; |
| 299 | const background = !!options?.background; |
| 300 | const suppressErrors = !!options?.suppressErrors; |
| 301 | if (!force && this.indexLoadPromise) { |
| 302 | if (background) { |
| 303 | return this.indexLoadPromise; |
| 304 | } |
| 305 | |
| 306 | this.loading = true; |
| 307 | this.loadingMessage = "Loading plugin index..."; |
| 308 | try { |
| 309 | return await this.indexLoadPromise; |
| 310 | } finally { |
| 311 | this.loading = false; |
| 312 | this.loadingMessage = ""; |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | const requestSeq = ++this.indexLoadSeq; |
| 317 | const loadPromise = (async () => { |
| 318 | try { |
| 319 | if (!background) { |
| 320 | this.loading = true; |
| 321 | this.loadingMessage = "Loading plugin index..."; |
| 322 | } |
| 323 | |
| 324 | const data = await api.callJsonApi(PLUGIN_API, { |
| 325 | action: "fetch_index", |
| 326 | force, |
| 327 | }); |
| 328 | |
| 329 | if (!data.success) { |
| 330 | if (!suppressErrors && requestSeq === this.indexLoadSeq) { |
| 331 | void toastFrontendError(data.error || "Failed to load index", "Plugin Installer"); |
| 332 | } |
| 333 | return false; |
| 334 | } |
| 335 | |
| 336 | const installedResponse = await api.callJsonApi("plugins_list", { |
| 337 | filter: { custom: true, builtin: false, search: "" }, |
| 338 | }); |
| 339 | const installedList = Array.isArray(installedResponse.plugins) ? installedResponse.plugins : []; |
| 340 | |
| 341 | if (requestSeq !== this.indexLoadSeq) { |
| 342 | return false; |
| 343 | } |
| 344 | |
| 345 | this.index = data.index; |
| 346 | this.installedPlugins = data.installed_plugins || []; |
| 347 | this.installedPluginDetails = Object.fromEntries( |
| 348 | installedList.map((plugin) => [plugin.name, plugin]) |
| 349 | ); |
| 350 | this.page = 1; |
| 351 | return true; |
| 352 | } catch (e) { |
| 353 | const message = e instanceof Error ? e.message : String(e); |
| 354 | if (!suppressErrors && requestSeq === this.indexLoadSeq) { |
| 355 | void toastFrontendError(`Failed to load plugin index: ${message}`, "Plugin Installer"); |
| 356 | } |
| 357 | return false; |
| 358 | } finally { |
| 359 | if (!background && requestSeq === this.indexLoadSeq) { |
| 360 | this.loading = false; |
| 361 | this.loadingMessage = ""; |
| 362 | } |
| 363 | } |
| 364 | })(); |
| 365 | |
| 366 | const trackedPromise = loadPromise.finally(() => { |
| 367 | if (this.indexLoadPromise === trackedPromise) { |
| 368 | this.indexLoadPromise = null; |
| 369 | } |
| 370 | }); |
| 371 | this.indexLoadPromise = trackedPromise; |
| 372 | |
| 373 | return trackedPromise; |
| 374 | }, |
| 375 | |
| 376 | async openIndexView() { |
| 377 | this.resetIndex(); |
| 378 | return this.fetchIndex({ force: true }); |
| 379 | }, |
| 380 | |
| 381 | async reloadIndex() { |
| 382 | return this.fetchIndex({ force: true }); |
| 383 | }, |
| 384 | |
| 385 | async ensureIndexLoaded(options = {}) { |
| 386 | if (this.hasIndexData()) { |
| 387 | return true; |
| 388 | } |
| 389 | |
| 390 | await this.fetchIndex({ |
| 391 | background: !!options?.background, |
| 392 | suppressErrors: !!options?.background, |
| 393 | }); |
| 394 | return this.hasIndexData(); |
| 395 | }, |
| 396 | |
| 397 | get pluginsList() { |
| 398 | if (!this.index?.plugins) return []; |
| 399 | return Object.entries(this.index.plugins).map(([key, val]) => { |
| 400 | const installedPlugin = this.installedPluginDetails[key] || null; |
| 401 | const installed = this.installedPlugins.some((pluginKey) => pluginKey === key); |
| 402 | const plugin = { |
| 403 | key, |
| 404 | ...val, |
| 405 | commit: val?.commit || val?.latest_commit || "", |
| 406 | updated: val?.updated || val?.latest_commit_timestamp || "", |
| 407 | version: val?.version || "", |
| 408 | suspended: this._getSuspensionReason(val), |
| 409 | installed, |
| 410 | }; |
| 411 | |
| 412 | return { |
| 413 | ...plugin, |
| 414 | current_commit: installedPlugin?.current_commit || "", |
| 415 | current_commit_timestamp: installedPlugin?.current_commit_timestamp || "", |
| 416 | has_update: this._hasPluginHubUpdate(plugin, installedPlugin), |
| 417 | }; |
| 418 | }); |
| 419 | }, |
| 420 | |
| 421 | get browseFilters() { |
| 422 | const plugins = this.pluginsList; |
| 423 | const filters = [{ key: "all", label: "All", count: plugins.length }]; |
| 424 | |
| 425 | const installedCount = plugins.filter((plugin) => plugin.installed).length; |
| 426 | if (installedCount) { |
| 427 | filters.push({ key: "installed", label: "Installed", count: installedCount }); |
| 428 | } |
| 429 | |
| 430 | const updateCount = plugins.filter((plugin) => plugin.has_update).length; |
| 431 | filters.push({ key: "update", label: "Update", count: updateCount }); |
| 432 | |
| 433 | const popularCount = plugins.filter((plugin) => this._isPopularPlugin(plugin)).length; |
| 434 | if (popularCount) { |
| 435 | filters.push({ key: "popular", label: "Popular", count: popularCount }); |
| 436 | } |
| 437 | |
| 438 | const newCount = plugins.filter((plugin) => this._isNewPlugin(plugin)).length; |
| 439 | if (newCount) { |
| 440 | filters.push({ key: "new", label: "New", count: newCount }); |
| 441 | } |
| 442 | |
| 443 | const tagCounts = new Map(); |
| 444 | for (const plugin of plugins) { |
| 445 | const tag = this._pluginPrimaryTag(plugin); |
| 446 | if (!tag) continue; |
| 447 | tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1); |
| 448 | } |
| 449 | |
| 450 | for (const [tag, count] of Array.from(tagCounts.entries()) |
| 451 | .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) |
| 452 | .slice(0, 4)) { |
| 453 | filters.push({ |
| 454 | key: `tag:${tag}`, |
| 455 | label: this._formatBrowseTag(tag), |
| 456 | count, |
| 457 | }); |
| 458 | } |
| 459 | |
| 460 | return filters; |
| 461 | }, |
| 462 | |
| 463 | get filteredPlugins() { |
| 464 | let list = this.pluginsList.filter((plugin) => |
| 465 | this._matchesBrowseFilter(plugin, this.browseFilter) |
| 466 | ); |
| 467 | const q = (this.search || "").toLowerCase().trim(); |
| 468 | if (q) { |
| 469 | list = list.filter( |
| 470 | (p) => |
| 471 | (p.title || "").toLowerCase().includes(q) || |
| 472 | (p.author || "").toLowerCase().includes(q) || |
| 473 | (p.description || "").toLowerCase().includes(q) || |
| 474 | (p.key || "").toLowerCase().includes(q) || |
| 475 | (p.tags || []).some((t) => t.toLowerCase().includes(q)) |
| 476 | ); |
| 477 | } |
| 478 | if (this.sortBy === "updated" || this.browseFilter === "new") { |
| 479 | list.sort((a, b) => this._comparePluginsByUpdated(a, b)); |
| 480 | } else if (this.sortBy === "stars") { |
| 481 | list.sort((a, b) => this._comparePluginsByStars(a, b)); |
| 482 | } else { |
| 483 | list.sort((a, b) => |
| 484 | (a.title || a.key).localeCompare(b.title || b.key) |
| 485 | ); |
| 486 | } |
| 487 | return list; |
| 488 | }, |
| 489 | |
| 490 | get browseResultsSummary() { |
| 491 | const total = this.pluginsList.length; |
| 492 | const visible = this.filteredPlugins.length; |
| 493 | if (!total) return "No plugins available"; |
| 494 | if (visible === total) { |
| 495 | return `${total} plugin${total === 1 ? "" : "s"} available`; |
| 496 | } |
| 497 | return `Showing ${visible} of ${total} plugins`; |
| 498 | }, |
| 499 | |
| 500 | get totalPages() { |
| 501 | return Math.max(1, Math.ceil(this.filteredPlugins.length / PER_PAGE)); |
| 502 | }, |
| 503 | |
| 504 | get paginatedPlugins() { |
| 505 | const start = (this.page - 1) * PER_PAGE; |
| 506 | return this.filteredPlugins.slice(start, start + PER_PAGE); |
| 507 | }, |
| 508 | |
| 509 | getBrowseSubtitle(plugin) { |
| 510 | const author = (plugin?.author || "").trim(); |
| 511 | if (author) return author; |
| 512 | const tag = this._pluginPrimaryTag(plugin); |
| 513 | if (tag) return this._formatBrowseTag(tag); |
| 514 | return plugin?.key || ""; |
| 515 | }, |
| 516 | |
| 517 | getBrowsePrimaryTag(plugin) { |
| 518 | return this._formatBrowseTag(this._pluginPrimaryTag(plugin)); |
| 519 | }, |
| 520 | |
| 521 | setPage(p) { |
| 522 | this.page = Math.max(1, Math.min(p, this.totalPages)); |
| 523 | }, |
| 524 | |
| 525 | getPluginHubPluginByKey(pluginKey) { |
| 526 | const key = typeof pluginKey === "string" ? pluginKey.trim() : ""; |
| 527 | if (!key) return null; |
| 528 | return this.pluginsList.find((plugin) => plugin.key === key) || null; |
| 529 | }, |
| 530 | |
| 531 | async openPluginHubDetailByKey(pluginKey) { |
| 532 | const key = typeof pluginKey === "string" ? pluginKey.trim() : ""; |
| 533 | if (!key) return false; |
| 534 | |
| 535 | const loaded = await this.ensureIndexLoaded(); |
| 536 | if (!loaded) return false; |
| 537 | |
| 538 | const plugin = this.getPluginHubPluginByKey(key); |
| 539 | if (!plugin) { |
| 540 | void toastFrontendError( |
| 541 | `Plugin "${key}" is not available in the Plugin Hub index`, |
| 542 | "Plugin Installer" |
| 543 | ); |
| 544 | return false; |
| 545 | } |
| 546 | |
| 547 | this.openDetail(plugin); |
| 548 | return true; |
| 549 | }, |
| 550 | |
| 551 | openDetail(plugin) { |
| 552 | this.selectedPlugin = { ...plugin, name: plugin?.key || "" }; |
| 553 | this.result = null; |
| 554 | this.installedPluginInfo = null; |
| 555 | this.readmeContent = null; |
| 556 | this.detailError = null; |
| 557 | this.detailThumbnailUrl = this.getThumbnailUrl(this.selectedPlugin); |
| 558 | if (this.selectedPlugin.installed) { |
| 559 | this.fetchInstalledPluginInfo(this.selectedPlugin.name); |
| 560 | } |
| 561 | this.fetchReadme(this.selectedPlugin); |
| 562 | openModal("/plugins/_plugin_installer/webui/install-detail.html"); |
| 563 | }, |
| 564 | |
| 565 | async fetchReadme(plugin) { |
| 566 | const rawBase = this._githubRawBase(plugin?.github); |
| 567 | if (!rawBase) return; |
| 568 | |
| 569 | try { |
| 570 | this.readmeLoading = true; |
| 571 | this.readmeContent = null; |
| 572 | let lastError = null; |
| 573 | |
| 574 | for (const branch of ["main", "master"]) { |
| 575 | try { |
| 576 | const response = await fetch(`${rawBase}/${branch}/README.md`); |
| 577 | if (!response.ok) continue; |
| 578 | |
| 579 | const readme = await response.text(); |
| 580 | this.readmeContent = renderSafeMarkdown(readme, { |
| 581 | githubUrl: plugin?.github, |
| 582 | branch, |
| 583 | }); |
| 584 | return; |
| 585 | } catch (error) { |
| 586 | lastError = error; |
| 587 | } |
| 588 | } |
| 589 | |
| 590 | if (lastError) { |
| 591 | console.warn("Failed to fetch readme:", lastError); |
| 592 | } |
| 593 | } finally { |
| 594 | this.readmeLoading = false; |
| 595 | } |
| 596 | }, |
| 597 | |
| 598 | async installFromIndex(plugin) { |
| 599 | if (!plugin?.github) { |
| 600 | void toastFrontendError("No GitHub URL available for this plugin", "Plugin Installer"); |
| 601 | return; |
| 602 | } |
| 603 | |
| 604 | const confirmed = await showConfirmDialog({ |
| 605 | ...SECURITY_WARNING, |
| 606 | extensionContext: { |
| 607 | kind: "plugin_hub_plugin_install_warning", |
| 608 | source: "plugin_installer", |
| 609 | pluginKey: plugin.key || "", |
| 610 | pluginTitle: plugin.title || plugin.key || "", |
| 611 | gitUrl: plugin.github, |
| 612 | }, |
| 613 | }); |
| 614 | if (!confirmed) return; |
| 615 | |
| 616 | try { |
| 617 | this.loading = true; |
| 618 | this.loadingMessage = "Installing"; |
| 619 | |
| 620 | const data = await api.callJsonApi(PLUGIN_API, { |
| 621 | action: "install_git", |
| 622 | git_url: plugin.github, |
| 623 | plugin_name: plugin.key, |
| 624 | thumbnail_url: this.getThumbnailUrl(plugin) || "", |
| 625 | }); |
| 626 | |
| 627 | if (!data.success) { |
| 628 | void toastFrontendError(data.error || "Installation failed", "Plugin Installer"); |
| 629 | return; |
| 630 | } |
| 631 | |
| 632 | const installedKey = plugin.key || data.plugin_name; |
| 633 | if (installedKey && !this.installedPlugins.some((pluginKey) => pluginKey === installedKey)) { |
| 634 | this.installedPlugins = [...this.installedPlugins, installedKey]; |
| 635 | } |
| 636 | |
| 637 | this.selectedPlugin = { |
| 638 | ...plugin, |
| 639 | name: plugin.key || "", |
| 640 | installed: true, |
| 641 | }; |
| 642 | this.detailThumbnailUrl = this.getThumbnailUrl(this.selectedPlugin); |
| 643 | this.fetchInstalledPluginInfo(plugin.key || data.plugin_name); |
| 644 | |
| 645 | toastFrontendSuccess( |
| 646 | `Plugin "${data.title || data.plugin_name}" installed`, |
| 647 | "Plugin Installer" |
| 648 | ); |
| 649 | } catch (e) { |
| 650 | const message = e instanceof Error ? e.message : String(e); |
| 651 | void toastFrontendError(`Installation error: ${message}`, "Plugin Installer"); |
| 652 | } finally { |
| 653 | this.loading = false; |
| 654 | this.loadingMessage = ""; |
| 655 | } |
| 656 | }, |
| 657 | |
| 658 | async _refreshSelectedPluginState(pluginKey) { |
| 659 | await this.fetchInstalledPluginInfo(pluginKey); |
| 660 | |
| 661 | const latestInstalled = this.installedPluginInfo || null; |
| 662 | const currentSelectedPlugin = this.selectedPlugin ? Object.assign({}, this.selectedPlugin) : null; |
| 663 | const indexPlugin = this.pluginsList.find((plugin) => plugin.key === pluginKey) || currentSelectedPlugin; |
| 664 | if (!indexPlugin) return; |
| 665 | |
| 666 | this.selectedPlugin = { |
| 667 | ...indexPlugin, |
| 668 | name: pluginKey || indexPlugin["name"] || "", |
| 669 | installed: true, |
| 670 | current_commit: latestInstalled?.["current_commit"] || indexPlugin["current_commit"] || "", |
| 671 | current_commit_timestamp: latestInstalled?.["current_commit_timestamp"] || indexPlugin["current_commit_timestamp"] || "", |
| 672 | has_update: this._hasPluginHubUpdate(indexPlugin, latestInstalled), |
| 673 | }; |
| 674 | this.detailThumbnailUrl = this.getThumbnailUrl(this.selectedPlugin); |
| 675 | }, |
| 676 | |
| 677 | // ── Installed Plugin Info ───────────────────── |
| 678 | |
| 679 | async fetchInstalledPluginInfo(pluginName) { |
| 680 | this.installedPluginInfo = null; |
| 681 | try { |
| 682 | const response = await api.callJsonApi("plugins_list", { |
| 683 | filter: { custom: true, builtin: true, search: "" }, |
| 684 | }); |
| 685 | const plugins = Array.isArray(response.plugins) ? response.plugins : []; |
| 686 | this.installedPluginInfo = plugins.find((p) => p.name === pluginName) || null; |
| 687 | } catch (_error) { |
| 688 | this.installedPluginInfo = null; |
| 689 | } |
| 690 | }, |
| 691 | |
| 692 | |
| 693 | handleOpenPlugin() { |
| 694 | const info = this.installedPluginInfo; |
| 695 | if (!info || !info.name || !info.has_main_screen) return; |
| 696 | openModal(`/plugins/${info.name}/webui/main.html`); |
| 697 | }, |
| 698 | |
| 699 | async handleOpenConfig() { |
| 700 | if (this.installedPluginInfo) { |
| 701 | try { |
| 702 | await pluginSettingsStore.openConfig(this.installedPluginInfo.name); |
| 703 | } catch (e) { |
| 704 | const message = e instanceof Error ? e.message : String(e); |
| 705 | void toastFrontendError(message, "Plugin Installer"); |
| 706 | } |
| 707 | } |
| 708 | }, |
| 709 | |
| 710 | async handleOpenDoc(doc) { |
| 711 | if (this.installedPluginInfo) { |
| 712 | await pluginListStore.openPluginDoc(this.installedPluginInfo, doc); |
| 713 | } |
| 714 | }, |
| 715 | |
| 716 | handleOpenInfo() { |
| 717 | if (!this.installedPluginInfo) return; |
| 718 | const pluginHubKey = (this.selectedPlugin?.key || "").trim(); |
| 719 | const plugin = pluginHubKey |
| 720 | ? { |
| 721 | ...this.installedPluginInfo, |
| 722 | pluginHub: { |
| 723 | key: pluginHubKey, |
| 724 | title: |
| 725 | this.selectedPlugin?.title || |
| 726 | this.installedPluginInfo.display_name || |
| 727 | this.installedPluginInfo.name, |
| 728 | }, |
| 729 | } |
| 730 | : this.installedPluginInfo; |
| 731 | pluginListStore.openPluginInfo(plugin); |
| 732 | }, |
| 733 | |
| 734 | handleOpenExecute() { |
| 735 | if (this.installedPluginInfo) { |
| 736 | pluginExecuteStore.open(this.installedPluginInfo); |
| 737 | } |
| 738 | }, |
| 739 | |
| 740 | async handleDeletePlugin() { |
| 741 | if (!this.installedPluginInfo) return; |
| 742 | |
| 743 | try { |
| 744 | this.loading = true; |
| 745 | this.loadingMessage = "Uninstalling plugin..."; |
| 746 | |
| 747 | await pluginListStore.deletePlugin(this.installedPluginInfo); |
| 748 | const currentPlugin = this.selectedPlugin ? Object.assign({}, this.selectedPlugin) : null; |
| 749 | if (currentPlugin) { |
| 750 | this.selectedPlugin = { ...currentPlugin, installed: false }; |
| 751 | this.installedPlugins = this.installedPlugins.filter( |
| 752 | (key) => key !== currentPlugin["key"] |
| 753 | ); |
| 754 | } |
| 755 | this.installedPluginInfo = null; |
| 756 | } finally { |
| 757 | this.loading = false; |
| 758 | this.loadingMessage = ""; |
| 759 | } |
| 760 | }, |
| 761 | |
| 762 | getIndexUrl(pluginKey) { |
| 763 | if (!pluginKey) return ""; |
| 764 | return `https://github.com/agent0ai/a0-plugins/tree/main/plugins/${pluginKey}`; |
| 765 | }, |
| 766 | |
| 767 | getCommitShortHash(commitHash) { |
| 768 | if (!commitHash || typeof commitHash !== "string") return ""; |
| 769 | return commitHash.slice(0, 7); |
| 770 | }, |
| 771 | |
| 772 | formatUserLocaleDateTime(value) { |
| 773 | if (!value || typeof value !== "string") return ""; |
| 774 | |
| 775 | const trimmedValue = value.trim(); |
| 776 | const hasExplicitTimezone = /([zZ]|[+-]\d{2}:?\d{2})$/.test(trimmedValue); |
| 777 | let normalizedValue = /t/i.test(trimmedValue) ? trimmedValue : trimmedValue.replace(" ", "T"); |
| 778 | |
| 779 | if (!hasExplicitTimezone && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?$/.test(normalizedValue)) { |
| 780 | normalizedValue = `${normalizedValue}Z`; |
| 781 | } |
| 782 | |
| 783 | const date = new Date(normalizedValue); |
| 784 | if (Number.isNaN(date.getTime())) return value; |
| 785 | |
| 786 | return formatDateTime(normalizedValue, "full"); |
| 787 | }, |
| 788 | |
| 789 | getRepoCommitUrl(plugin, commitHash) { |
| 790 | const githubUrl = (plugin?.github || "").trim().replace(/\.git$/i, ""); |
| 791 | if (!githubUrl || !commitHash) return ""; |
| 792 | return `${githubUrl}/commit/${commitHash}`; |
| 793 | }, |
| 794 | |
| 795 | getCurrentInstalledCommit() { |
| 796 | return this.installedPluginInfo?.["current_commit"] || this.selectedPlugin?.["current_commit"] || ""; |
| 797 | }, |
| 798 | |
| 799 | getCurrentInstalledVersion() { |
| 800 | return this.installedPluginInfo?.["version"] || ""; |
| 801 | }, |
| 802 | |
| 803 | getCurrentInstalledCommitTimestamp() { |
| 804 | return this.installedPluginInfo?.["current_commit_timestamp"] || this.selectedPlugin?.["current_commit_timestamp"] || ""; |
| 805 | }, |
| 806 | |
| 807 | getLatestPluginHubVersion() { |
| 808 | return this.selectedPlugin?.["version"] || ""; |
| 809 | }, |
| 810 | |
| 811 | getLatestPluginHubCommit() { |
| 812 | return this.selectedPlugin?.["commit"] || ""; |
| 813 | }, |
| 814 | |
| 815 | getLatestPluginHubCommitTimestamp() { |
| 816 | return this.selectedPlugin?.["updated"] || ""; |
| 817 | }, |
| 818 | |
| 819 | async handleUpdatePlugin() { |
| 820 | const selectedPlugin = this["selectedPlugin"]; |
| 821 | const pluginRecord = selectedPlugin && typeof selectedPlugin === "object" ? selectedPlugin : {}; |
| 822 | const pluginKey = pluginRecord["key"] || pluginRecord["name"] || this.installedPluginInfo?.name || ""; |
| 823 | if (!pluginKey) { |
| 824 | void toastFrontendError("Plugin name is missing", "Plugin Installer"); |
| 825 | return; |
| 826 | } |
| 827 | |
| 828 | const confirmed = await showConfirmDialog({ |
| 829 | ...SECURITY_WARNING, |
| 830 | extensionContext: { |
| 831 | kind: "plugin_hub_plugin_install_warning", |
| 832 | source: "plugin_installer", |
| 833 | pluginKey, |
| 834 | pluginTitle: pluginRecord["title"] || pluginKey, |
| 835 | gitUrl: pluginRecord["github"] || "", |
| 836 | }, |
| 837 | }); |
| 838 | if (!confirmed) return; |
| 839 | |
| 840 | this.detailError = null; |
| 841 | |
| 842 | try { |
| 843 | this.loading = true; |
| 844 | this.loadingMessage = "Updating"; |
| 845 | |
| 846 | const data = await api.callJsonApi(PLUGIN_API, { |
| 847 | action: "update_plugin", |
| 848 | plugin_name: pluginKey, |
| 849 | }); |
| 850 | |
| 851 | if (!(data?.ok && data?.success)) { |
| 852 | const message = data?.error || "Update failed"; |
| 853 | this.detailError = { |
| 854 | kind: data?.error_kind || "update_failed", |
| 855 | message, |
| 856 | conflicting_files: Array.isArray(data?.conflicting_files) ? data.conflicting_files : [], |
| 857 | }; |
| 858 | void toastFrontendError(message, "Plugin Installer"); |
| 859 | return; |
| 860 | } |
| 861 | |
| 862 | await this.fetchIndex(); |
| 863 | |
| 864 | const installedPluginsSource = this["installedPlugins"]; |
| 865 | const installedPlugins = Array.isArray(installedPluginsSource) ? Array.from(installedPluginsSource) : []; |
| 866 | if (!installedPlugins.some((installedKey) => installedKey === pluginKey)) { |
| 867 | installedPlugins.push(String(pluginKey)); |
| 868 | Reflect.set(this, "installedPlugins", installedPlugins); |
| 869 | } |
| 870 | |
| 871 | await this._refreshSelectedPluginState(pluginKey); |
| 872 | this.refreshPluginList(); |
| 873 | |
| 874 | toastFrontendSuccess( |
| 875 | `Plugin "${data.title || data.plugin_name}" updated`, |
| 876 | "Plugin Installer" |
| 877 | ); |
| 878 | } catch (e) { |
| 879 | const message = e instanceof Error ? e.message : String(e); |
| 880 | void toastFrontendError(`Update error: ${message}`, "Plugin Installer"); |
| 881 | } finally { |
| 882 | this.loading = false; |
| 883 | this.loadingMessage = ""; |
| 884 | } |
| 885 | }, |
| 886 | |
| 887 | getThumbnailUrl(plugin) { |
| 888 | if (!plugin) return null; |
| 889 | if (plugin.thumbnail && typeof plugin.thumbnail === "string") return plugin.thumbnail; |
| 890 | const rawBase = this._githubRawBase(plugin?.github); |
| 891 | return rawBase ? `${rawBase}/main/thumbnail.png` : null; |
| 892 | }, |
| 893 | |
| 894 | getDetailThumbnailUrl() { |
| 895 | return this.detailThumbnailUrl; |
| 896 | }, |
| 897 | |
| 898 | openScreenshot(url) { |
| 899 | if (!url) return; |
| 900 | const selectedPlugin = this.selectedPlugin || null; |
| 901 | imageViewerStore.open(url, { |
| 902 | name: selectedPlugin?.["title"] || selectedPlugin?.["key"] || "Plugin screenshot", |
| 903 | }); |
| 904 | }, |
| 905 | |
| 906 | getReportUrl(plugin) { |
| 907 | const githubUrl = plugin?.github; |
| 908 | if (!githubUrl || typeof githubUrl !== "string") return ""; |
| 909 | try { |
| 910 | const url = new URL(githubUrl.trim().replace(/\.git$/i, "")); |
| 911 | if (!url.hostname.includes("github.com")) return ""; |
| 912 | const parts = url.pathname.split("/").filter(Boolean); |
| 913 | if (parts.length >= 1) { |
| 914 | const username = parts[0]; |
| 915 | const contentUrl = encodeURIComponent(githubUrl); |
| 916 | const report = encodeURIComponent(`${username} (user)`); |
| 917 | return `https://github.com/contact/report-content?content_url=${contentUrl}&report=${report}`; |
| 918 | } |
| 919 | } catch (e) { |
| 920 | // ignore |
| 921 | } |
| 922 | return ""; |
| 923 | }, |
| 924 | |
| 925 | // ── Shared ─────────────────────────────────── |
| 926 | |
| 927 | resetZip() { |
| 928 | this.zipFile = null; |
| 929 | this.zipFileName = ""; |
| 930 | this.result = null; |
| 931 | }, |
| 932 | |
| 933 | resetGit() { |
| 934 | this.gitUrl = ""; |
| 935 | this.gitToken = ""; |
| 936 | this.result = null; |
| 937 | }, |
| 938 | |
| 939 | resetIndex() { |
| 940 | this.search = ""; |
| 941 | this.page = 1; |
| 942 | this.sortBy = "stars"; |
| 943 | this.browseFilter = "all"; |
| 944 | this.result = null; |
| 945 | this.selectedPlugin = null; |
| 946 | }, |
| 947 | |
| 948 | /** Refresh related list views after installer/detail actions. */ |
| 949 | refreshPluginList() { |
| 950 | const pluginHubActive = pluginListStore.activeTab === "pluginHub"; |
| 951 | if (pluginHubActive) { |
| 952 | void this.fetchIndex(); |
| 953 | } |
| 954 | pluginListStore.refresh(); |
| 955 | }, |
| 956 | |
| 957 | truncate(text, max) { |
| 958 | if (!text || text.length <= max) return text || ""; |
| 959 | return text.substring(0, max) + "..."; |
| 960 | }, |
| 961 | }; |
| 962 | |
| 963 | const store = createStore("pluginInstallStore", model); |
| 964 | export { store }; |