diff --git a/containers/libreportal/frontend/core/boot/js/system-orchestrator.js b/containers/libreportal/frontend/core/boot/js/system-orchestrator.js index ebae760..34f28a9 100755 --- a/containers/libreportal/frontend/core/boot/js/system-orchestrator.js +++ b/containers/libreportal/frontend/core/boot/js/system-orchestrator.js @@ -109,6 +109,10 @@ class SystemOrchestrator { return new Promise((resolve, reject) => { // Create and show setup wizard const setupWizard = new SetupWizard(); + // A handle on the running wizard, for the headless tests and for anyone + // debugging a step from the console. The instance is otherwise local to + // this promise and unreachable once show() returns. + window.setupWizard = setupWizard; setupWizard.initialize(this.setupDetector, async () => { try { // Setup completed, continue with normal loading diff --git a/containers/libreportal/frontend/core/setup/css/setup-wizard.css b/containers/libreportal/frontend/core/setup/css/setup-wizard.css index dacc61a..30d6084 100755 --- a/containers/libreportal/frontend/core/setup/css/setup-wizard.css +++ b/containers/libreportal/frontend/core/setup/css/setup-wizard.css @@ -1421,6 +1421,7 @@ body.setup-wizard-open .custom-select-popup { z-index: 10001; } .setup-storage-choice { display: flex; align-items: center; + flex-wrap: wrap; gap: 12px; margin-bottom: 10px; } @@ -1445,3 +1446,18 @@ body.setup-wizard-open .custom-select-popup { z-index: 10001; } margin-top: 4px; word-break: break-all; } + +/* A custom storage path that cannot be used says so under the box, rather + than being quietly swapped for the system disk on the apply side. Its own + flex line, offset to sit under the input rather than under the label. */ +.setup-storage-choice-custom { padding-left: 132px; } +.setup-storage-choice-err { + flex: 0 0 100%; + margin: -4px 0 0; + font-size: 0.82em; + color: #ff9b8f; +} +.setup-storage-choice-err:empty { display: none; } +.setup-storage-choice input.is-invalid { + border-color: rgba(255, 120, 100, 0.75); +} diff --git a/containers/libreportal/frontend/core/setup/js/setup-wizard.js b/containers/libreportal/frontend/core/setup/js/setup-wizard.js index c63626a..0888d4e 100755 --- a/containers/libreportal/frontend/core/setup/js/setup-wizard.js +++ b/containers/libreportal/frontend/core/setup/js/setup-wizard.js @@ -5,6 +5,10 @@ // /api/setup/save which fans out into separate tasks per app, then this UI // hands off to the tasks page focused on the first task. +// Marker for "I will type a path" in the two storage dropdowns. Not a valid +// path itself, so it can never be mistaken for one. +const CUSTOM_PATH = '__custom__'; + class SetupWizard { constructor() { this.container = null; @@ -608,10 +612,17 @@ class SetupWizard { _storageCard(c, key, opts) { const o = opts || {}; const refused = c.verdict === 'refuse'; + // A registered location whose drive is not currently mounted is a different + // thing from one that has a problem: nothing is wrong with it, it is simply + // not here. Calling that "needs care" next to a blank size and an empty + // meter told a new user nothing at all — the row looked like a drive that + // had failed to load rather than one that is unplugged. + const offline = c.state === 'unmounted'; const badge = o.system ? 'system' - : (refused ? 'can\u2019t be used' - : (c.verdict === 'warn' ? 'needs care' : '')); + : (offline ? 'not connected' + : (refused ? 'can\u2019t be used' + : (c.verdict === 'warn' ? 'needs care' : ''))); // A registered location has a name the user chose; an unregistered // candidate only has a path. @@ -623,8 +634,10 @@ class SetupWizard { ${this.escapeHtml(title)} ${badge} - ${this.escapeHtml(c.free)} free of ${this.escapeHtml(c.size)}${c.removable ? ' · removable' : ''} - ${this._storageMeter(c)} + ${offline + ? `Not mounted right now · ${this.escapeHtml(c.path || '')}` + : `${this.escapeHtml(c.free)} free of ${this.escapeHtml(c.size)}${c.removable ? ' · removable' : ''} + ${this._storageMeter(c)}`} @@ -635,8 +648,14 @@ class SetupWizard { _storageChoices() { const opts = [{ value: 'primary', label: this._primaryLabel() }]; this.storageCandidates - .filter(c => c.verdict !== 'refuse') - .forEach(c => opts.push({ value: c.path, label: c.path })); + // Not an unmounted one: choosing it would put app data on a bare + // mountpoint, which is the failure this whole feature exists to avoid. + .filter(c => c.verdict !== 'refuse' && c.state !== 'unmounted') + .forEach(c => opts.push({ value: c.path, label: c.name ? `${c.name} (${c.path})` : c.path })); + // Somewhere the scan did not find: a NAS mount, an LVM volume, a directory + // on a drive already in use. The scan lists whole filesystems, so anything + // that is a path rather than a disk had no way in before this. + opts.push({ value: CUSTOM_PATH, label: 'Custom path\u2026' }); return opts; } @@ -687,14 +706,23 @@ class SetupWizard { return; } + // A value that is not one of the offered options is a path the user typed, + // so the control has to come back up showing Custom path rather than + // silently snapping to the first entry. + const isCustom = (v) => !!v && v !== 'primary' && !opts.some(o => o.value === v); const row = (id, label, tip, value) => `
${label} ? +
+
+ +
`; box.innerHTML = @@ -706,14 +734,61 @@ class SetupWizard { this.storageDefault) + ''; - box.querySelector('#sw-storage-system').addEventListener('change', (e) => { - this.storageSystemChoice = e.target.value; - this.renderStorageSystemMsg(); - }); - box.querySelector('#sw-storage-apps').addEventListener('change', (e) => { - this.storageDefault = e.target.value; - }); + // Choosing "Custom path" reveals the input and holds the value at empty + // until something is typed — the sentinel must never reach the payload. + const wire = (id, apply) => { + const sel = box.querySelector(`#${id}`); + const row = box.querySelector(`#${id}-custom-row`); + const inp = box.querySelector(`#${id}-custom`); + if (!sel) return; + sel.addEventListener('change', (e) => { + const custom = e.target.value === CUSTOM_PATH; + if (row) row.style.display = custom ? '' : 'none'; + apply(custom ? (inp && inp.value.trim()) || '' : e.target.value); + if (custom && inp) inp.focus(); + this._syncStorageNav(); + }); + if (inp) inp.addEventListener('input', () => { + if (sel.value === CUSTOM_PATH) apply(inp.value.trim()); + this._syncStorageNav(); + }); + }; + wire('sw-storage-system', (v) => { this.storageSystemChoice = v; this.renderStorageSystemMsg(); }); + wire('sw-storage-apps', (v) => { this.storageDefault = v; }); this.renderStorageSystemMsg(); + this._syncStorageNav(); + } + + // A half-typed custom path must not be able to reach the payload. The apply + // side would refuse it and fall back to the system disk, and a silent + // fallback is indistinguishable from having chosen the system disk on + // purpose — so block Continue and say why instead. + _customPathProblem(v) { + if (!v) return 'Enter a full path, starting with /'; + if (!v.startsWith('/')) return 'Use a full path, starting with /'; + if (/\s/.test(v)) return 'Paths with spaces are not supported'; + if (v === '/' || /^\/(boot|dev|etc|proc|run|sys|usr|bin|sbin|lib|lib64|var)(\/|$)/.test(v)) { + return 'Pick a path outside the system directories'; + } + return ''; + } + + // Returns the first problem found, and paints it beside the offending input. + _syncStorageNav() { + if (!this.container) return ''; + let blocked = ''; + ['sw-storage-system', 'sw-storage-apps'].forEach((id) => { + const sel = this.container.querySelector('#' + id); + const inp = this.container.querySelector('#' + id + '-custom'); + const err = this.container.querySelector('#' + id + '-custom-err'); + if (!sel || !inp) return; + const problem = sel.value === CUSTOM_PATH + ? this._customPathProblem(inp.value.trim()) : ''; + if (err) err.textContent = problem; + inp.classList.toggle('is-invalid', !!problem); + if (problem) blocked = problem; + }); + return blocked; } // Moving LibrePortal's own tree re-bakes the root-owned helpers, the systemd @@ -1327,6 +1402,12 @@ class SetupWizard { } } } + // 3 = Storage. Only a typed custom path can be invalid; the scanned + // options all came from the backend. + if (idx === 3) { + const problem = this._syncStorageNav(); + if (problem) return problem; + } // 6 = Recommended (Storage 3, Backups 4 and Import 5 shifted this along). if (idx === 6) { const traefikBox = this.container.querySelector('input[data-app="traefik"]'); diff --git a/docs/roadmap/storage-locations.md b/docs/roadmap/storage-locations.md index e24da05..f651c2e 100644 --- a/docs/roadmap/storage-locations.md +++ b/docs/roadmap/storage-locations.md @@ -540,6 +540,65 @@ default-layout host onto it means *every* app takes the stage-and-move branch, and staging (disk 1) and destination (disk 2) are different devices, so `app-adopt` exercises its copy path rather than `mv`. +## 12.6 — The Storage step, after watching someone read it + +Two things about the wizard's Storage step were wrong in a way only a fresh +pair of eyes catches. + +**A registered drive that is unplugged.** `storage list` reports it as +`unmounted`, and the step rendered that through the same path as every other +candidate: a yellow "needs care" badge, "free of" with no numbers either side, +and an empty meter. To someone installing for the first time that reads as *the +scan found two broken disks* — there is nothing on the card connecting it to a +drive they registered earlier and have since unplugged. It now says **not +connected**, names the path, and draws no meter, because a meter with nothing in +it is a claim about free space that was never measured. + +The same locations are also withheld from the two dropdowns. The wizard cannot +stat a directory on a drive that is absent, so it cannot promise an app placed +there would have anywhere to write. + +**Custom paths.** The dropdowns offered only what the scan turned up, which is +wrong for a NAS mount, an LVM volume, or anything else the disk heuristics do +not rank as a candidate. Both now end in *Custom path…*, revealing a text box. + +The validation lives in `validateStep(3)` rather than in a disabled button. The +apply side already refuses a relative or system path — but its refusal is to +fall back to the system disk, and *that is indistinguishable from having chosen +the system disk on purpose*. This is the failure shape this project keeps +having to fix (§10, and the compose-guard and `app-data-remove` bugs before it), +so the step blocks and says which rule the path broke. + +A path typed here is not a registered location, so `setup_apply.sh` registers it +through `storageAdd` before writing `CFG_STORAGE_DEFAULT`. Going through +`storageAdd` rather than writing the path straight into the config is what keeps +the empty-directory admission rule and the fitness checks in play — the +alternative silently adopts a directory full of someone else's data. It is named +after its own basename, so it reads as `nas` in the placement menus rather than +`location-3`. + +`libreportal storage remove` also learned to accept the name the listing prints. +It matched on id and path only, so `remove location-3` failed against a row the +table displayed as `location-3`. + +### What the test found about the tests + +`lp-storage-custom-test` drives the step in a real browser. Two things it caught +about itself are worth recording, because both are the same shape as the bugs it +exists to prevent — a check whose failure mode is to not run. + +It first counted "how many cards say *not connected*" and asserted things about +those. Disabling the feature makes that count zero, `every()` over an empty list +is true, and the whole block passed while asserting nothing. The expected count +now comes from the feed (`storageCandidates` in state `unmounted`), so removing +the rendering is a failure rather than an empty set. + +And both browser tests skipped — exit 0 — whenever the page returned nothing. +Run under `sudo`, where chromium refuses to start, they reported PASS having +checked nothing at all. They now probe the WebUI with `lp-shot --url` and curl: +if it answers HTTP then the browser is the only thing that can have broken, and +that is a failure, not a skip. + ## 13. Open questions 1. ~~**Naming.** "Storage location" vs "backup location" in the same UI~~ — **resolved (2026-08-24):** build the Disks view (§7.1). The device becomes the organising concept and the two registries become *roles* on it, so the user never has to hold the distinction to understand their own hardware. Registries stay separate underneath. diff --git a/init.sh b/init.sh index 8fe65c9..5e1ad15 100755 --- a/init.sh +++ b/init.sh @@ -134,7 +134,7 @@ command_symlink="/usr/local/bin/libreportal" # `update apply` runs as the manager and CANNOT rewrite root-owned files, so a bump # tells the updater the new release needs a root re-install (which re-bakes them). # Recorded at install in $lp_lib_dir/.footprint_version. See docs/contributing/development.md. -footprint_version=10 +footprint_version=11 footprint_marker="$lp_lib_dir/.footprint_version" # Directories — three independently-relocatable roots (see scripts/source/paths.sh diff --git a/scripts/dev/lp-backup-dialog-test b/scripts/dev/lp-backup-dialog-test index b7ee99e..8cbe36f 100755 --- a/scripts/dev/lp-backup-dialog-test +++ b/scripts/dev/lp-backup-dialog-test @@ -23,6 +23,16 @@ fail=0 chk(){ if [[ "$2" == "$3" ]]; then echo " ok $1"; else echo " FAIL $1: got '$2' want '$3'"; fail=1; fi; } command -v jq >/dev/null 2>&1 || { echo " SKIP jq not installed"; exit 0; } +# A skip must not be able to stand in for a pass. If the WebUI answers HTTP +# then the browser is the only thing that can have failed, and that is a +# failure — skipping there is how this test reported success under sudo, where +# chromium refuses to start at all. +lp_reachable() { + local u; u=$("$SHOT" --url 2>/dev/null) || return 1 + [[ -n "$u" ]] || return 1 + curl -fsS -o /dev/null --max-time 5 "$u" 2>/dev/null +} + read -r -d '' DRIVE <<'JS' const DUMMY = 'dummy-not-a-real-secret-0000'; const out = {}; @@ -139,7 +149,10 @@ JS OUT=$("$SHOT" --eval "/?step=4" "$DRIVE" 2>/dev/null) if [[ -z "$OUT" ]] || ! jq -e . >/dev/null 2>&1 <<< "$OUT"; then - echo " SKIP no WebUI reachable, or the step did not load" + if lp_reachable; then + echo " FAIL the WebUI is up but the step returned nothing (browser failed?)"; exit 1 + fi + echo " SKIP no WebUI reachable" exit 0 fi if [[ "$(jq -r '.error // ""' <<< "$OUT")" != "" ]]; then diff --git a/scripts/dev/lp-shot b/scripts/dev/lp-shot index 395f74b..c9d4be6 100755 --- a/scripts/dev/lp-shot +++ b/scripts/dev/lp-shot @@ -14,6 +14,7 @@ Environment: LP_SHOT_URL base URL of the WebUI (default: auto-detected, else http://localhost:3179) LP_SHOT_VIEWPORT WIDTHxHEIGHT (default 1440x900) --eval ROUTE JS run JS in the page and print the result; no screenshot + --url print the base URL that would be used, and exit LP_SHOT_EVAL JS run in the page before capture — open a dialog, pick a tab, expand a row. Awaited, so async handlers finish. LP_SHOT_SCALE device pixel ratio (default 2 — that's the "crisp") @@ -369,6 +370,14 @@ def main(): # # lp-shot --token -> the raw cookie VALUE # lp-shot --cookie-js -> a document.cookie assignment to paste/eval + # --url prints the base URL that would be used, and nothing else. Tests + # probe it with curl to tell "the WebUI is down, skip" apart from "the + # WebUI is up and the browser broke" — a browser test that skips on both + # reports success while asserting nothing. + if len(sys.argv) > 1 and sys.argv[1] == "--url": + print(base_url()) + sys.exit(0) + if len(sys.argv) > 1 and sys.argv[1] in ("--token", "--cookie-js"): token, src = mint_token() if os.environ.get("LP_SHOT_VERBOSE"): diff --git a/scripts/dev/lp-storage-custom-test b/scripts/dev/lp-storage-custom-test new file mode 100755 index 0000000..de81110 --- /dev/null +++ b/scripts/dev/lp-storage-custom-test @@ -0,0 +1,138 @@ +#!/bin/bash +# Drive the wizard's Storage step in a real browser. +# +# scripts/dev/lp-storage-custom-test # needs a running WebUI +# +# Two things this guards. +# +# A location whose drive is absent used to render as a warn card — "needs care", +# "free of" with no numbers, an empty meter. A new user has no way to read that +# as "this is a drive you registered and it is unplugged", so it looked like the +# scan had found two broken disks. It must now say it is not connected, name the +# path, and draw no meter. +# +# And a typed custom path must never reach the payload half-typed. The apply +# side refuses a relative or system path and falls back to the system disk — +# a fallback that looks exactly like having chosen the system disk on purpose, +# which is the failure shape this project keeps having to fix. validateStep +# has to block it at the step instead. +# +# One page load: the whole interaction runs in a single `lp-shot --eval`. + +REPO="$(cd "$(dirname "$0")/../.." && pwd)" +SHOT="$REPO/scripts/dev/lp-shot" +fail=0 +chk(){ if [[ "$2" == "$3" ]]; then echo " ok $1"; else echo " FAIL $1: got '$2' want '$3'"; fail=1; fi; } +command -v jq >/dev/null 2>&1 || { echo " SKIP jq not installed"; exit 0; } + +# A skip must not be able to stand in for a pass. If the WebUI answers HTTP +# then the browser is the only thing that can have failed, and that is a +# failure — skipping there is how this test reported success under sudo, where +# chromium refuses to start at all. +lp_reachable() { + local u; u=$("$SHOT" --url 2>/dev/null) || return 1 + [[ -n "$u" ]] || return 1 + curl -fsS -o /dev/null --max-time 5 "$u" 2>/dev/null +} + +read -r -d '' DRIVE <<'JS' + const out = {}; + const w = window.setupWizard; + if (!w) return JSON.stringify({ error: 'wizard handle missing' }); + const fire = (el, ev) => el.dispatchEvent(new Event(ev, { bubbles: true })); + + const sel = document.querySelector('#sw-storage-apps'); + const inp = document.querySelector('#sw-storage-apps-custom'); + const row = document.querySelector('#sw-storage-apps-custom-row'); + const err = document.querySelector('#sw-storage-apps-custom-err'); + if (!sel || !inp || !row) return JSON.stringify({ error: 'storage step not rendered' }); + + // An unmounted location must not be offered as somewhere to put app data: + // the wizard cannot check its free space or write to it. + // How many SHOULD be offline comes from the feed, not from the rendering. + // Counting the cards that say "not connected" would make every assertion + // below vacuous the moment the feature stopped working. + const cards = Array.from(document.querySelectorAll('.setup-app-card, .setup-storage-card')); + const expected = (w.storageCandidates || []).filter(c => c.state === 'unmounted'); + out.expectedOffline = expected.length; + const offline = cards.filter(c => + expected.some(e => e.path && c.textContent.includes(e.path))); + out.offlineCards = offline.length; + out.offlineSayNotConnected = offline.length > 0 + && offline.every(c => /not connected/i.test(c.textContent)); + out.offlineSaysNotMounted = offline.length > 0 && offline.every(c => /not mounted right now/i.test(c.textContent)); + out.offlineNamesPath = offline.length > 0 && offline.every(c => /·\s*\//.test(c.textContent)); + out.offlineHasNoMeter = offline.length > 0 && offline.every(c => !c.querySelector('.setup-storage-meter')); + out.offlineSaysNeedsCare = offline.some(c => /needs care/i.test(c.textContent)); + out.offlineOffered = Array.from(sel.options) + .some(o => offline.some(c => o.value && c.textContent.includes(o.value))); + + out.hasCustomOption = Array.from(sel.options).some(o => o.value === '__custom__'); + out.rowHiddenAtRest = row.style.display === 'none'; + + sel.value = '__custom__'; fire(sel, 'change'); + out.rowShownOnPick = row.style.display !== 'none'; + out.emptyBlocks = !!w.validateStep(3); + + const probe = (v) => { inp.value = v; fire(inp, 'input'); + return { blocks: !!w.validateStep(3), + marked: inp.classList.contains('is-invalid'), + said: (err && err.textContent) || '' }; }; + out.relative = probe('mnt/nas'); + out.systemDir = probe('/etc/libreportal'); + out.root = probe('/'); + out.spaces = probe('/mnt/my disk'); + out.good = probe('/mnt/nas/apps'); + out.goodStored = w.storageDefault; + + // Back to a scanned option: never blocked, and the error clears. + sel.value = 'primary'; fire(sel, 'change'); + out.primaryBlocks = !!w.validateStep(3); + out.primaryStored = w.storageDefault; + return JSON.stringify(out); +JS + +J=$("$SHOT" --eval "/?step=3" "$DRIVE" 2>/dev/null | tr "'" '"' | sed 's/\bTrue\b/true/g; s/\bFalse\b/false/g') +if [[ -z "$J" ]]; then + if lp_reachable; then + echo " FAIL the WebUI is up but the page returned nothing (browser failed?)"; exit 1 + fi + echo " SKIP no WebUI reachable"; exit 0 +fi +g(){ echo "$J" | jq -r "$1" 2>/dev/null; } + +if [[ "$(g '.error // empty')" != "" ]]; then echo " FAIL $(g .error)"; exit 1; fi + +echo "unmounted locations" +if [[ "$(g .expectedOffline)" == "0" ]]; then + echo " SKIP no unmounted location registered — nothing to check" +else + chk "every one has a card" "$(g .offlineCards)" "$(g .expectedOffline)" + chk "badged 'not connected'" "$(g .offlineSayNotConnected)" true + chk "say they are not mounted" "$(g .offlineSaysNotMounted)" true + chk "name the path" "$(g .offlineNamesPath)" true + chk "draw no free-space meter" "$(g .offlineHasNoMeter)" true + chk "do not say 'needs care'" "$(g .offlineSaysNeedsCare)" false + chk "are not offered as a target" "$(g .offlineOffered)" false +fi + +echo "custom path" +chk "the option exists" "$(g .hasCustomOption)" true +chk "input hidden until picked" "$(g .rowHiddenAtRest)" true +chk "input shown once picked" "$(g .rowShownOnPick)" true +chk "empty blocks the step" "$(g .emptyBlocks)" true +chk "relative path blocks" "$(g .relative.blocks)" true +chk "relative path is marked" "$(g .relative.marked)" true +chk "relative path says why" "$(g '.relative.said != ""')" true +chk "system dir blocks" "$(g .systemDir.blocks)" true +chk "/ blocks" "$(g .root.blocks)" true +chk "path with spaces blocks" "$(g .spaces.blocks)" true +chk "an absolute path passes" "$(g .good.blocks)" false +chk "and is not marked" "$(g .good.marked)" false +chk "and its error is cleared" "$(g '.good.said == ""')" true +chk "and it is what gets stored" "$(g .goodStored)" /mnt/nas/apps +chk "a scanned option never blocks" "$(g .primaryBlocks)" false +chk "and replaces the custom value" "$(g .primaryStored)" primary + +[[ $fail -eq 0 ]] && echo "storage custom-path test: OK" +exit $fail diff --git a/scripts/setup/setup_apply.sh b/scripts/setup/setup_apply.sh index 16da386..2a777a6 100644 --- a/scripts/setup/setup_apply.sh +++ b/scripts/setup/setup_apply.sh @@ -102,6 +102,29 @@ setupApplyConfig() if declare -f storageLocationName >/dev/null 2>&1; then default_name=$(storageLocationName "$storage_default" 2>/dev/null) || default_name="" fi + # A path typed into the wizard's "Custom path" box is not a registered + # location yet, so the lookup above finds nothing. Register it — through + # storageAdd, because that is what enforces the empty-directory + # admission rule and the fitness checks; writing the path straight into + # the config would skip both and is how an app ends up on a directory + # root never agreed to own. + if [[ -z "$default_name" || "$default_name" == "default" ]] \ + && [[ "$storage_default" == /* ]] \ + && declare -f storageAdd >/dev/null 2>&1; then + # Named after the directory, so it reads as "nas" in the app + # placement menus rather than "location-3". + local custom_name + custom_name=$(basename -- "$storage_default" | tr -c 'a-zA-Z0-9-' '-' | sed 's/^-*//; s/-*$//') + [[ -z "$custom_name" ]] && custom_name="custom" + if storageAdd "$storage_default" "$custom_name" >/dev/null 2>&1; then + storageCacheReset 2>/dev/null || true + default_name=$(storageLocationName "$storage_default" 2>/dev/null) || default_name="" + [[ -n "$default_name" ]] && isSuccessful "Registered '$storage_default' as a storage location." + else + isNotice "Could not use '$storage_default' — it must be an empty directory outside the system paths." + fi + fi + if [[ -n "$default_name" && "$default_name" != "default" ]]; then updateConfigOption "CFG_STORAGE_DEFAULT" "$default_name" isSuccessful "New apps will store their data on '$default_name'" diff --git a/scripts/system/libreportal-storage b/scripts/system/libreportal-storage index ab2062c..4d947f1 100755 --- a/scripts/system/libreportal-storage +++ b/scripts/system/libreportal-storage @@ -413,6 +413,16 @@ remove() { if [[ "$_id" == "$want" || "${_path%/}" == "${want%/}" ]]; then found_path="${_path%/}"; found_id="$_id"; break fi + # Also match the name the listing prints, since that is what a person + # reading the table will type. Includes the "location-N" fallback the + # marker records when a location was added without one. + if [[ -f "${_path%/}/$MARKER" ]]; then + local _name + _name=$(sed -n 's/^name=//p' "${_path%/}/$MARKER" 2>/dev/null | head -1) + if [[ -n "$_name" && "$_name" == "$want" ]]; then + found_path="${_path%/}"; found_id="$_id"; break + fi + fi done < "$REGISTRY" [[ -n "$found_id" ]] || { _err "no such location: $want"; return 1; }