diff --git a/custom_components/bahmcloud_store/panel/panel.js b/custom_components/bahmcloud_store/panel/panel.js index b9e5e07..c2100ab 100644 --- a/custom_components/bahmcloud_store/panel/panel.js +++ b/custom_components/bahmcloud_store/panel/panel.js @@ -15,14 +15,14 @@ class BahmcloudStorePanel extends HTMLElement { this._search = ""; this._category = "all"; - this._provider = "all"; // NEW: provider filter (all|github|gitea|gitlab|other|custom) + this._provider = "all"; // all|github|gitea|gitlab|other|custom this._detailRepoId = null; this._detailRepo = null; this._readmeLoading = false; this._readmeText = null; - this._readmeHtml = null; // backend may provide; client renderer does not rely on it + this._readmeHtml = null; this._readmeError = null; } @@ -127,7 +127,7 @@ class BahmcloudStorePanel extends HTMLElement { _openRepoDetail(repoId) { const repos = Array.isArray(this._data?.repos) ? this._data.repos : []; - const repo = repos.find((r) => r.id === repoId); + const repo = repos.find((r) => this._safeId(r?.id) === repoId); if (!repo) return; this._view = "detail"; @@ -160,7 +160,7 @@ class BahmcloudStorePanel extends HTMLElement { } else { this._readmeText = null; this._readmeHtml = null; - this._readmeError = resp?.message || "README not found."; + this._readmeError = this._safeText(resp?.message) || "README not found."; } } catch (e) { this._readmeText = null; @@ -340,7 +340,6 @@ class BahmcloudStorePanel extends HTMLElement { details{ margin-top: 10px; } summary{ cursor:pointer; color: var(--bcs-accent); font-weight: 900; } - /* Pretty Markdown container */ .md { line-height: 1.65; font-size: 14px; } .md :is(h1,h2,h3){ margin: 18px 0 10px; } .md :is(p,ul,ol,pre,blockquote,table){ margin: 10px 0; } @@ -392,7 +391,7 @@ class BahmcloudStorePanel extends HTMLElement { }); } - // Prevent HA global shortcuts while typing inside the panel + // prevent HA global shortcuts while typing const stopIfFormField = (e) => { const t = e.composedPath ? e.composedPath()[0] : e.target; if (!t) return; @@ -401,7 +400,6 @@ class BahmcloudStorePanel extends HTMLElement { tag === "input" || tag === "textarea" || tag === "select" || t.isContentEditable; if (isEditable) e.stopPropagation(); }; - root.addEventListener("keydown", stopIfFormField, true); root.addEventListener("keyup", stopIfFormField, true); root.addEventListener("keypress", stopIfFormField, true); @@ -450,7 +448,7 @@ class BahmcloudStorePanel extends HTMLElement { const subtitle = root.getElementById("subtitle"); const fabs = root.getElementById("fabs"); - const v = this._data?.version ? String(this._data.version) : null; + const v = this._safeText(this._data?.version); subtitle.textContent = v ? `BCS ${v}` : "BCS — loading…"; for (const tab of root.querySelectorAll(".tab")) { @@ -502,26 +500,23 @@ class BahmcloudStorePanel extends HTMLElement { _renderStore() { const repos = Array.isArray(this._data.repos) ? this._data.repos : []; const categories = this._computeCategories(repos); - - // Provider stats (for overview) const stats = this._computeProviderStats(repos); const filtered = repos .filter((r) => { const q = (this._search || "").trim().toLowerCase(); if (!q) return true; - const hay = `${r.name || ""} ${r.description || ""} ${r.url || ""} ${r.owner || ""}`.toLowerCase(); + const hay = `${this._safeText(r?.name)} ${this._safeText(r?.description)} ${this._safeText(r?.url)} ${this._safeText(r?.owner)}`.toLowerCase(); return hay.includes(q); }) .filter((r) => { if (this._category === "all") return true; - return (r.category || "").toLowerCase() === this._category; + return this._safeLower(r?.category) === this._category; }) .filter((r) => { if (this._provider === "all") return true; - if (this._provider === "custom") return r.source === "custom"; - const pv = (r.provider || "other").toLowerCase(); - return pv === this._provider; + if (this._provider === "custom") return r?.source === "custom"; + return this._safeLower(r?.provider) === this._provider; }); const options = [ @@ -538,24 +533,27 @@ class BahmcloudStorePanel extends HTMLElement { const rows = filtered .map((r) => { + const id = this._safeId(r?.id); + const name = this._safeText(r?.name) || "Unnamed repository"; + const desc = this._safeText(r?.description) || "No description available."; + const badge = - r.source === "custom" + r?.source === "custom" ? `Custom` : `Index`; - const desc = r.description || "No description available."; - const creator = r.owner ? `Creator: ${r.owner}` : "Creator: -"; - const latest = r.latest_version ? `Latest: ${r.latest_version}` : "Latest: unknown"; - const cat = r.category ? `Category: ${r.category}` : null; - const metaSrc = r.meta_source ? `Meta: ${r.meta_source}` : null; + const creator = this._safeText(r?.owner) ? `Creator: ${this._safeText(r?.owner)}` : "Creator: -"; + const latest = this._safeText(r?.latest_version) ? `Latest: ${this._safeText(r?.latest_version)}` : "Latest: unknown"; + const cat = this._safeText(r?.category) ? `Category: ${this._safeText(r?.category)}` : null; + const metaSrc = this._safeText(r?.meta_source) ? `Meta: ${this._safeText(r?.meta_source)}` : null; const lineBits = [creator, latest, cat, metaSrc].filter(Boolean); return ` -
+
-
${this._esc(r.name)}
+
${this._esc(name)}
${this._esc(desc)}
${this._esc(lineBits.join(" · "))}
@@ -581,6 +579,45 @@ class BahmcloudStorePanel extends HTMLElement { `; } + _wireStore() { + const root = this.shadowRoot; + const search = root.getElementById("searchInput"); + const cat = root.getElementById("categorySelect"); + const chips = root.getElementById("providerChips"); + + if (search) { + search.addEventListener("input", (e) => { + this._search = e.target.value; + this._update(); + }); + } + + if (cat) { + cat.addEventListener("change", (e) => { + this._category = String(e.target.value || "all"); + this._update(); + }); + } + + if (chips) { + for (const c of chips.querySelectorAll("[data-prov]")) { + c.addEventListener("click", () => { + const key = c.getAttribute("data-prov"); + if (!key) return; + this._provider = key; + this._update(); + }); + } + } + + for (const card of root.querySelectorAll("[data-repo]")) { + card.addEventListener("click", () => { + const id = card.getAttribute("data-repo"); + if (id) this._openRepoDetail(id); + }); + } + } + _renderProviderChips(stats) { const order = ["all", "github", "gitea", "gitlab", "other", "custom"]; const labels = { @@ -615,9 +652,10 @@ class BahmcloudStorePanel extends HTMLElement { for (const r of repos) { s.total += 1; - if (r.source === "custom") s.custom += 1; - const p = (r.provider || "other").toLowerCase(); + if (r?.source === "custom") s.custom += 1; + + const p = this._safeLower(r?.provider) || "other"; if (p === "github") s.github += 1; else if (p === "gitea") s.gitea += 1; else if (p === "gitlab") s.gitlab += 1; @@ -626,63 +664,29 @@ class BahmcloudStorePanel extends HTMLElement { return s; } - _wireStore() { - const root = this.shadowRoot; - const search = root.getElementById("searchInput"); - const cat = root.getElementById("categorySelect"); - const chips = root.getElementById("providerChips"); - - if (search) { - search.addEventListener("input", (e) => { - this._search = e.target.value; - this._update(); - }); - } - - if (cat) { - cat.addEventListener("change", (e) => { - this._category = String(e.target.value || "all"); - this._update(); - }); - } - - if (chips) { - for (const c of chips.querySelectorAll("[data-prov]")) { - c.addEventListener("click", () => { - const key = c.getAttribute("data-prov"); - if (!key) return; - this._provider = key; // set filter - this._update(); - }); - } - } - - for (const card of root.querySelectorAll("[data-repo]")) { - card.addEventListener("click", () => { - const id = card.getAttribute("data-repo"); - if (id) this._openRepoDetail(id); - }); - } - } - _renderDetail() { const r = this._detailRepo; if (!r) return `
Repository not found.
`; - const badge = - r.source === "custom" ? `Custom` : `Index`; + const name = this._safeText(r?.name) || "Unnamed repository"; + const desc = this._safeText(r?.description) || "No description available."; + const url = this._safeText(r?.url) || "#"; - const desc = r.description || "No description available."; - const latest = r.latest_version ? `Latest: ${r.latest_version}` : "Latest: unknown"; + const badge = + r?.source === "custom" + ? `Custom` + : `Index`; + + const latest = this._safeText(r?.latest_version) ? `Latest: ${this._safeText(r?.latest_version)}` : "Latest: unknown"; const infoBits = [ - r.owner ? `Creator: ${r.owner}` : "Creator: -", + this._safeText(r?.owner) ? `Creator: ${this._safeText(r?.owner)}` : "Creator: -", latest, - r.provider ? `Provider: ${r.provider}` : null, - r.category ? `Category: ${r.category}` : null, - r.meta_author ? `Author: ${r.meta_author}` : null, - r.meta_maintainer ? `Maintainer: ${r.meta_maintainer}` : null, - r.meta_source ? `Meta: ${r.meta_source}` : null, + this._safeText(r?.provider) ? `Provider: ${this._safeText(r?.provider)}` : null, + this._safeText(r?.category) ? `Category: ${this._safeText(r?.category)}` : null, + this._safeText(r?.meta_author) ? `Author: ${this._safeText(r?.meta_author)}` : null, + this._safeText(r?.meta_maintainer) ? `Maintainer: ${this._safeText(r?.meta_maintainer)}` : null, + this._safeText(r?.meta_source) ? `Meta: ${this._safeText(r?.meta_source)}` : null, ].filter(Boolean); const readmeBlock = this._readmeLoading @@ -718,11 +722,11 @@ class BahmcloudStorePanel extends HTMLElement {
-
${this._esc(r.name)}
+
${this._esc(name)}
${this._esc(desc)}
${this._esc(infoBits.join(" · "))}
${badge} @@ -754,57 +758,19 @@ class BahmcloudStorePanel extends HTMLElement { if (!mount) return; if (this._readmeText) { - const html = this._mdToHtml(this._readmeText); - - if (html) { - mount.innerHTML = html; - this._postprocessRenderedMarkdown(mount); - return; - } - + // Client renderer may be unavailable; prefer server-provided HTML if (this._readmeHtml) { mount.innerHTML = this._readmeHtml; this._postprocessRenderedMarkdown(mount); return; } - - mount.innerHTML = `
Markdown renderer not available on this client. Use “Show raw Markdown”.
`; + mount.innerHTML = `
Rendered HTML not available. Use “Show raw Markdown”.
`; return; } mount.innerHTML = ""; } - _mdToHtml(markdown) { - const md = String(markdown || ""); - if (!md.trim()) return ""; - - const markedObj = window.marked; - const domPurify = window.DOMPurify; - - let html = ""; - - try { - if (markedObj && typeof markedObj.parse === "function") { - html = markedObj.parse(md, { breaks: true, gfm: true }); - } else if (typeof markedObj === "function") { - html = markedObj(md); - } else { - return ""; - } - } catch (_) { - return ""; - } - - try { - if (domPurify && typeof domPurify.sanitize === "function") { - html = domPurify.sanitize(html); - } - } catch (_) {} - - return html; - } - _postprocessRenderedMarkdown(container) { if (!container) return; try { @@ -836,18 +802,18 @@ class BahmcloudStorePanel extends HTMLElement { const r = this._detailRepo; if (!r) return; + const url = this._safeText(r?.url); + const open = root.getElementById("fabOpen"); const reload = root.getElementById("fabReload"); const info = root.getElementById("fabInfo"); - if (open) open.addEventListener("click", () => window.open(r.url, "_blank", "noreferrer")); - + if (open) open.addEventListener("click", () => url && window.open(url, "_blank", "noreferrer")); if (reload) { reload.addEventListener("click", () => { if (this._detailRepoId) this._loadReadme(this._detailRepoId); }); } - if (info) { info.addEventListener("click", () => { this._view = "about"; @@ -858,24 +824,27 @@ class BahmcloudStorePanel extends HTMLElement { _renderManage() { const repos = Array.isArray(this._data.repos) ? this._data.repos : []; - const custom = repos.filter((r) => r.source === "custom"); + const custom = repos.filter((r) => r?.source === "custom"); const list = custom - .map( - (r) => ` -
-
-
-
${this._esc(r.name)}
-
${this._esc(r.url)}
-
-
- + .map((r) => { + const id = this._safeId(r?.id); + const name = this._safeText(r?.name) || "Unnamed repository"; + const url = this._safeText(r?.url) || ""; + return ` +
+
+
+
${this._esc(name)}
+
${this._esc(url)}
+
+
+ +
-
- ` - ) + `; + }) .join(""); return ` @@ -924,13 +893,14 @@ class BahmcloudStorePanel extends HTMLElement { } _renderAbout() { + const v = this._safeText(this._data?.version) || "-"; return `
Settings / About
Language: English (v1). i18n will be added later.
Theme: follows Home Assistant light/dark automatically.
Accent: Bahmcloud Blue.
-
BCS version: ${this._esc(this._data.version || "-")}
+
BCS version: ${this._esc(v)}
`; } @@ -938,12 +908,31 @@ class BahmcloudStorePanel extends HTMLElement { _computeCategories(repos) { const set = new Set(); for (const r of repos) { - const c = (r.category || "").trim().toLowerCase(); + const c = this._safeLower(r?.category); if (c) set.add(c); } return Array.from(set).sort(); } + // --- HARDENING HELPERS (fixes [object Object]) --- + _safeText(v) { + if (v === null || v === undefined) return ""; + const t = typeof v; + if (t === "string") return v; + if (t === "number" || t === "boolean") return String(v); + return ""; // objects/arrays/functions => empty (prevents [object Object]) + } + + _safeLower(v) { + const s = this._safeText(v); + return s ? s.trim().toLowerCase() : ""; + } + + _safeId(v) { + const s = this._safeText(v); + return s || ""; + } + _esc(s) { return String(s ?? "") .replaceAll("&", "&") @@ -954,4 +943,4 @@ class BahmcloudStorePanel extends HTMLElement { } } -customElements.define("bahmcloud-store-panel", BahmcloudStorePanel); \ No newline at end of file +customElements.define("bahmcloud-store-panel", BahmcloudStorePanel);