diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index 008099b7..358ba8d9 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -792,6 +792,52 @@ }).filter(Boolean))); } + function selectionUrl(url, filters, automaticChipFamily) { + const result = new URL(url); + FACET_FIELDS.forEach(function (field) { + result.searchParams.delete(field); + if (filters[field]) result.searchParams.set(field, filters[field]); + }); + result.searchParams.delete("chipAuto"); + if (filters.hardwareFamily || filters.hardware) { + result.searchParams.set("chipAuto", automaticChipFamily ? "1" : "0"); + } + return result.href; + } + + function selectionFromUrl(url, profiles) { + const params = new URL(url).searchParams; + const requested = {}; + const filters = {}; + const unavailable = []; + FACET_FIELDS.forEach(function (field) { + requested[field] = params.get(field) || ""; + }); + // Handwritten links may specify just the exact hardware variant. + if (requested.hardware && !requested.hardwareFamily) { + const profile = profiles.find(function (item) { + return item.hardware === requested.hardware; + }); + if (profile) requested.hardwareFamily = profile.hardwareFamily; + } + const automaticChipFamily = params.get("chipAuto") === "1" || + (params.get("chipAuto") !== "0" && !requested.chipFamily && + Boolean(requested.hardwareFamily || requested.hardware)); + if (automaticChipFamily) requested.chipFamily = ""; + // Validate in picker order so a stale variant cannot discard valid hardware. + FACET_FIELDS.forEach(function (field) { + const value = requested[field]; + if (!value) return; + if ((field !== "hardware" || filters.hardwareFamily) && + facetValues(profiles, filters, field).includes(value)) { + filters[field] = value; + } else { + unavailable.push(field + "=" + value); + } + }); + return { filters: filters, automaticChipFamily: automaticChipFamily, unavailable: unavailable }; + } + function humanizeHardware(value) { return String(value || "").replace(/_/g, " ").replace(/\s+/g, " ").trim(); } @@ -1249,6 +1295,9 @@ const search = root.querySelector('[data-field="asset-search"]'); const assetResults = root.querySelector('[data-role="asset-results"]'); const clearButton = form.querySelector('[data-action="clear"]'); + const shareLink = root.querySelector('[data-role="share-link"]'); + const copyLinkButton = root.querySelector('[data-action="copy-link"]'); + const linkStatus = root.querySelector('[data-role="link-status"]'); const controls = {}; FACET_FIELDS.forEach(function (field) { controls[field] = form.querySelector('[data-field="' + field + '"]'); @@ -1258,6 +1307,53 @@ let automaticChipFamily = false; const groupPrefix = "firmware-picker-" + (++pickerInstanceCount); + function updateSelectionUrl() { + const url = selectionUrl(global.location.href, filters, automaticChipFamily); + if (url !== global.location.href) { + try { + global.history.replaceState(global.history.state, "", url); + } catch (error) { + // Some local-file viewers block History API writes. The share link + // still carries the complete selection on the public web picker. + } + } + if (shareLink) { + const base = global.location.protocol === "file:" + ? root.getAttribute("data-share-url") : url; + shareLink.href = selectionUrl(base || url, filters, automaticChipFamily); + shareLink.hidden = false; + } + if (copyLinkButton) copyLinkButton.disabled = false; + } + + function restoreSelectionUrl() { + if (!catalog.profiles.length) return; + const restored = selectionFromUrl(global.location.href, catalog.profiles); + FACET_FIELDS.forEach(function (field) { + filters[field] = restored.filters[field] || ""; + }); + automaticChipFamily = restored.automaticChipFamily; + if (linkStatus) linkStatus.textContent = restored.unavailable.length + ? "Some linked choices are unavailable or incompatible in the current release: " + + restored.unavailable.join(", ") + ". Review the remaining choices before downloading." + : ""; + refreshFacets(); + } + + if (copyLinkButton && shareLink) copyLinkButton.addEventListener("click", function () { + const copy = global.navigator.clipboard && global.navigator.clipboard.writeText; + if (!copy) { + linkStatus.textContent = "Copy the address bar, or copy the ‘Link to these settings’ link."; + return; + } + global.navigator.clipboard.writeText(shareLink.href).then(function () { + linkStatus.textContent = "Link copied."; + }).catch(function () { + linkStatus.textContent = "Copy the address bar, or copy the ‘Link to these settings’ link."; + }); + }); + global.addEventListener("popstate", restoreSelectionUrl); + function matchingProfiles(ignoredField) { return catalog.profiles.filter(function (profile) { return profileMatchesFacets(profile, filters, ignoredField); @@ -1387,6 +1483,7 @@ if (chipSummary) chipSummary.textContent = "Optional: chip family" + (filters.chipFamily ? " — " + labelFor("chipFamily", filters.chipFamily) : ""); render(); + updateSelectionUrl(); } function render() { @@ -1498,6 +1595,7 @@ const target = event.target; const field = target.dataset.choiceField || target.dataset.field; if (!FACET_FIELDS.includes(field)) return; + if (linkStatus) linkStatus.textContent = ""; if (field === "chipFamily") { automaticChipFamily = false; // A deliberate family switch takes precedence over an old board. @@ -1519,6 +1617,7 @@ event.preventDefault(); if (!catalog.profiles.length) return; automaticChipFamily = false; + if (linkStatus) linkStatus.textContent = ""; FACET_FIELDS.forEach(function (field) { filters[field] = ""; }); @@ -1572,7 +1671,7 @@ releaseSetStatus.appendChild(link); } clearButton.disabled = false; - refreshFacets(); + restoreSelectionUrl(); }) .catch(function (error) { status.textContent = @@ -1619,6 +1718,8 @@ profileMatchesFacets: profileMatchesFacets, facetValues: facetValues, uniqueValues: uniqueValues, + selectionUrl: selectionUrl, + selectionFromUrl: selectionFromUrl, canonicalAsset: canonicalAsset, resolveProfileAssets: resolveProfileAssets, shouldShowCandidateResults: shouldShowCandidateResults, diff --git a/docs/_stylesheets/firmware_picker.css b/docs/_stylesheets/firmware_picker.css index deb305bd..732b2465 100644 --- a/docs/_stylesheets/firmware_picker.css +++ b/docs/_stylesheets/firmware_picker.css @@ -144,6 +144,19 @@ grid-column: 1 / -1; } +.firmware-picker-form-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.65rem; +} + +.firmware-picker-form-actions [data-role="link-status"] { + flex-basis: 100%; + margin: 0; + overflow-wrap: anywhere; +} + .firmware-picker-form-actions button { min-height: 2.35rem; padding: 0.4rem 0.75rem; diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md index 2a563126..323e0aaa 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -17,7 +17,7 @@ The picker reads public release metadata from GitHub. It does not upload device information. Hardware names, target names, and download links come directly from the published firmware assets. -
+
Current release set

Loading release information...

@@ -31,7 +31,8 @@ from the published firmware assets.

Pick in any order. Use Any to clear one choice, or clear - everything with the button below. + everything with the button below. The address bar updates with your choices; + copy its URL to reopen or share the same selection.

@@ -111,6 +112,9 @@ from the published firmware assets.
+ + +

@@ -183,6 +187,30 @@ Companion; choose the existing `heltec_v4_repeater_bridge_espnow` firmware to make that board the LoRa/ESP-NOW gateway. Use its exact merged artifact when changing roles or partition layouts. +## Share a selection + +Select any combination of choices, then copy the address bar or use **Copy link +to settings**. Opening the link restores those choices after the release +catalog loads. Partial selections work too, so you can share a board and role +while leaving other choices open. **Clear all choices** removes the picker +parameters from the URL. Changes update the current browser-history entry +without reloading the page or adding a Back-button entry for every click. + +For example, [RAK3401 repeater with internal storage](?hardware=RAK_3401&role=repeater&ota=lora-receiver&variant=no-external-sensors) +preselects that board, role, OTA capability, and storage profile. + +The query parameters are `chipFamily`, `hardwareFamily`, `hardware`, `role`, +`logging`, `ota`, `mode`, `feature`, `variant`, and `install`. Values use the +picker's internal identifiers rather than the displayed labels. The generated +link also records `chipAuto` so automatic chip-family selection or an explicit +**Any** choice behaves the same after reopening. Existing section anchors and +unrelated query parameters are preserved. + +Links use the current release catalog. If a linked choice is no longer available +or conflicts with another choice, the picker identifies it and asks you to +review the remaining selections. The downloadable HTML's **Copy link to +settings** button creates a public website link that other people can open. + ## FULL versus standard For a new installation, use the FULL / complete profile when it exists and the diff --git a/test/test_firmware_picker.js b/test/test_firmware_picker.js index 9e92af9c..0a7ee174 100644 --- a/test/test_firmware_picker.js +++ b/test/test_firmware_picker.js @@ -936,3 +936,35 @@ assert.strictEqual(sibling.chipFamily, 'rp2040'); assert.strictEqual(sibling.controls, undefined, 'Chip inheritance must not invent runtime controls'); assert.strictEqual(chipCatalog.profiles.find(p => p.target === 'unlisted_repeater').chipFamily, 'unknown', 'UF2 does not identify a chip family'); console.log('chip-family filtering tests passed'); + +const shareBase = 'https://example.com/firmware_picker/?h=storage#installation-methods'; +const shareFilters = {hardwareFamily: 'RAK_4631', hardware: 'RAK_4631', + chipFamily: 'nrf52', role: 'companion', mode: 'full', feature: 'full', + logging: 'usb', ota: 'lora-source', variant: 'default', install: 'uf2'}; +const shareUrl = picker.selectionUrl(shareBase, shareFilters, true); +const restoredShare = picker.selectionFromUrl(shareUrl, controlled.profiles); +assert.deepStrictEqual(restoredShare.unavailable, []); +assert.strictEqual(restoredShare.automaticChipFamily, true); +for (const field of picker.FACET_FIELDS.filter(f => f !== 'chipFamily')) { + assert.strictEqual(restoredShare.filters[field], shareFilters[field], field); +} +assert.strictEqual(new URL(shareUrl).searchParams.get('h'), 'storage'); +assert.strictEqual(new URL(shareUrl).hash, '#installation-methods'); +assert.strictEqual(picker.selectionUrl(shareUrl, {}, false), shareBase); +const anyChip = picker.selectionFromUrl(picker.selectionUrl(shareBase, + {...shareFilters, chipFamily: ''}, false), controlled.profiles); +assert.strictEqual(anyChip.automaticChipFamily, false); +assert(!anyChip.filters.chipFamily); +const partialShare = picker.selectionFromUrl(shareBase + '&unused=value', controlled.profiles); +assert.deepStrictEqual(partialShare.filters, {}); +const hardwareOnly = picker.selectionFromUrl('https://example.com/?hardware=RAK_4631', controlled.profiles); +assert.strictEqual(hardwareOnly.filters.hardwareFamily, 'RAK_4631'); +assert.strictEqual(hardwareOnly.automaticChipFamily, true); +const staleShare = picker.selectionFromUrl('https://example.com/?hardware=RAK_4631&role=companion&variant=removed&install=bin', controlled.profiles); +assert.strictEqual(staleShare.filters.hardware, 'RAK_4631'); +assert.strictEqual(staleShare.filters.role, 'companion'); +assert.deepStrictEqual(staleShare.unavailable, ['variant=removed', 'install=bin']); +const escapedShare = picker.selectionFromUrl('https://example.com/?role=%3Cscript%3E', controlled.profiles); +assert.deepStrictEqual(escapedShare.filters, {}); +assert.deepStrictEqual(escapedShare.unavailable, ['role=