main
js 964 lines 29.1 KB
Raw
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 &mdash; 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 };