mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-14 13:35:35 +00:00
Restore and share firmware picker selections through URLs
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
+30
-2
@@ -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.
|
||||
|
||||
<div class="firmware-picker" data-firmware-picker data-release-repo="mikecarper/MeshCore" data-controls-url="../_data/firmware_controls.json">
|
||||
<div class="firmware-picker" data-firmware-picker data-release-repo="mikecarper/MeshCore" data-controls-url="../_data/firmware_controls.json" data-share-url="https://mikecarper.github.io/MeshCore/firmware_picker/">
|
||||
<div class="firmware-picker-intro" role="note">
|
||||
<strong>Current release set</strong>
|
||||
<p data-role="release-set">Loading release information...</p>
|
||||
@@ -31,7 +31,8 @@ from the published firmware assets.
|
||||
|
||||
<p class="firmware-picker-order-note">
|
||||
Pick in any order. Use <strong>Any</strong> 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.
|
||||
</p>
|
||||
|
||||
<form class="firmware-picker-form" data-role="form">
|
||||
@@ -111,6 +112,9 @@ from the published firmware assets.
|
||||
|
||||
<div class="firmware-picker-form-actions firmware-picker-wide">
|
||||
<button type="reset" data-action="clear" disabled>Clear all choices</button>
|
||||
<button type="button" data-action="copy-link" disabled>Copy link to settings</button>
|
||||
<a data-role="share-link" hidden>Link to these settings</a>
|
||||
<p data-role="link-status" aria-live="polite"></p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=<script>']);
|
||||
console.log('shareable picker URL tests passed');
|
||||
|
||||
Reference in New Issue
Block a user