Show what's actually cached per-country, with a delete button per row
CI / check (push) Successful in 17s
CI / deploy (push) Successful in 13s

The only visibility into downloaded data was the whole offline.pmtiles
row, and the only delete was "everything" (whole file + whole cache).
GET /api/offline-tiles/cache lists what's really in
data/geofabrik_cache/ right now — distinct from offline.pmtiles' own
meta.sources, which only reflects the last successful build and can
disagree with both the current selection and the cache (ticked-but-
not-Applied, or built-from-but-since-unticked).

The region picker now shows each cached country's size next to its
checkbox, with its own delete button (DELETE
/api/offline-tiles/cache/:code, already added but unused by the UI
until now) — removing just that entry rather than clearing the whole
cache.
This commit is contained in:
randy committed 2026-09-11 23:39:43 +02:00
1 parent d09d953cbf
commit 78362d51f2
7 files changed
+158 -6

No files matched your search

+9
View File
@@ -291,6 +291,15 @@ module Maptool
json ok: true json ok: true
end end
# What's actually in the per-country download cache right now (not the
# same as offline.pmtiles' own meta.sources — see cached_extracts) —
# read-only, same tier as GET /api/offline-regions above, so the region
# picker can show a size + delete affordance next to each cached
# country regardless of whether it's still selected or already built.
get "/api/offline-tiles/cache" do
json extracts: PmtilesBuilder.cached_extracts(DATA_DIR)
end
# Deletes one country's cached Geofabrik extract only — e.g. to force a # Deletes one country's cached Geofabrik extract only — e.g. to force a
# clean re-download of just that one, or reclaim space for a country # clean re-download of just that one, or reclaim space for a country
# that's no longer selected (the cache otherwise keeps it indefinitely). # that's no longer selected (the cache otherwise keeps it indefinitely).
+9 -1
View File
@@ -147,7 +147,15 @@ which only changes what a *future* build covers and never frees
anything already on disk. `DELETE anything already on disk. `DELETE
/api/offline-tiles/cache/:code` removes just one country's cached /api/offline-tiles/cache/:code` removes just one country's cached
extract, e.g. to force a clean re-download of it without touching the extract, e.g. to force a clean re-download of it without touching the
rest of the cache. rest of the cache. `GET /api/offline-tiles/cache` lists what's
actually in that cache right now (code/size/Last-Modified per file) —
not the same thing as offline.pmtiles' own `meta.sources`, which only
reflects the last successful build: a country can be cached without
ever having been built (ticked, not yet Applied) or built from without
still being selected (unticked since). The region picker shows this
next to each country's checkbox, with its own delete button, rather
than only offering "delete everything" on the whole offline.pmtiles
row.
Each successful build also writes a small sidecar (`offline.pmtiles. Each successful build also writes a small sidecar (`offline.pmtiles.
meta.json` — each source country's code/Geofabrik slug/Last-Modified; meta.json` — each source country's code/Geofabrik slug/Last-Modified;
+20
View File
@@ -448,6 +448,26 @@ module Maptool
File.delete("#{path}.last_modified") if File.exist?("#{path}.last_modified") File.delete("#{path}.last_modified") if File.exist?("#{path}.last_modified")
end end
# What's actually sitting in the per-country download cache right now —
# not the same thing as offline_meta's "sources", which only reflects
# whatever the *last successful build* used. A country can be cached
# without ever having been built (selected, then deselected again
# before Apply) or built from without still being selected, and this is
# what lets the region picker show/clear either case.
def self.cached_extracts(data_dir)
cache_dir = geofabrik_cache_dir(data_dir)
return [] unless Dir.exist?(cache_dir)
Dir.glob(File.join(cache_dir, "*.osm.pbf")).map do |path|
stamp_path = "#{path}.last_modified"
{
"code" => File.basename(path, ".osm.pbf"),
"size" => File.size(path),
"last_modified" => File.exist?(stamp_path) ? File.read(stamp_path) : nil,
}
end.sort_by { |s| s["code"] }
end
# --- terrain.pmtiles --- # --- terrain.pmtiles ---
# planet.pmtiles (z0-12, always) plus whichever regional chunks # planet.pmtiles (z0-12, always) plus whichever regional chunks
+37
View File
@@ -416,11 +416,20 @@ html, body {
border-radius: 4px; border-radius: 4px;
} }
.offline-data-panel__region-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.offline-data-panel__region-list label { .offline-data-panel__region-list label {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 4px 8px; padding: 4px 8px;
min-width: 0;
flex: 1;
cursor: pointer; cursor: pointer;
} }
@@ -428,6 +437,34 @@ html, body {
background: #f0f0f0; background: #f0f0f0;
} }
.offline-data-panel__cache-info {
display: flex;
align-items: center;
gap: 4px;
padding-right: 8px;
color: #888;
font-size: 11px;
white-space: nowrap;
}
.offline-data-panel__cache-delete {
border: none;
background: none;
padding: 0 2px;
color: #a33;
font-size: 11px;
cursor: pointer;
}
.offline-data-panel__cache-delete:hover {
text-decoration: underline;
}
.offline-data-panel__cache-delete:disabled {
opacity: 0.5;
cursor: default;
}
/* Same rounded-square badge ratio as icons.js's own MAP_MARKER_ICON /* Same rounded-square badge ratio as icons.js's own MAP_MARKER_ICON
(rx 5 of a 22-wide square) — a consistent frame rather than however (rx 5 of a 22-wide square) — a consistent frame rather than however
each flag's own artwork happens to fill an unbordered square. */ each flag's own artwork happens to fill an unbordered square. */
+51 -5
View File
@@ -81,26 +81,72 @@ class RegionPicker {
container.appendChild(section); container.appendChild(section);
let data; let data;
let cache;
try { try {
data = await (await fetch("/api/offline-regions")).json(); [data, cache] = await Promise.all([
fetch("/api/offline-regions").then((r) => r.json()),
fetch("/api/offline-tiles/cache").then((r) => r.json()),
]);
} catch { } catch {
this._list.innerHTML = `<li class="offline-data-panel__region-status">Could not load the region list.</li>`; this._list.innerHTML = `<li class="offline-data-panel__region-status">Could not load the region list.</li>`;
return; return;
} }
const selected = new Set(data.selected); const selected = new Set(data.selected);
const cached = new Map(cache.extracts.map((e) => [e.code, e]));
this._list.innerHTML = data.available this._list.innerHTML = data.available
.map( .map((region) => {
(region) => ` const extract = cached.get(region.code);
// Downloaded-but-not-(yet)-built is a real state (ticked, not
// Applied yet) as is built-but-no-longer-selected (unticked since)
// — this badge reflects the cache on disk, independent of either.
const cacheInfo = extract
? `
<span class="offline-data-panel__cache-info">
${formatBytes(extract.size)}
${
isLoggedIn()
? `<button type="button" class="offline-data-panel__cache-delete" data-code="${escapeHtml(region.code)}" data-name="${escapeHtml(region.name)}" title="Delete downloaded data for ${escapeHtml(region.name)}">✕</button>`
: ""
}
</span>`
: "";
return `
<li> <li>
<label> <label>
<input type="checkbox" value="${escapeHtml(region.code)}" ${selected.has(region.code) ? "checked" : ""}> <input type="checkbox" value="${escapeHtml(region.code)}" ${selected.has(region.code) ? "checked" : ""}>
<img class="offline-data-panel__flag" src="${flagIconUrl(region.code)}" alt="" width="18" height="18"> <img class="offline-data-panel__flag" src="${flagIconUrl(region.code)}" alt="" width="18" height="18">
${escapeHtml(region.name)} ${escapeHtml(region.name)}
</label> </label>
</li>`, ${cacheInfo}
) </li>`;
})
.join(""); .join("");
this._list.querySelectorAll(".offline-data-panel__cache-delete").forEach((button) => {
button.addEventListener("click", () => this._deleteCache(button));
});
}
// Removes just this row's cache badge on success, rather than re-fetching
// and re-rendering the whole list — that would also overwrite whatever
// unticked/ticked-but-not-yet-Applied state the user has pending.
async _deleteCache(button) {
const code = button.dataset.code;
if (!window.confirm(`Delete downloaded data for ${button.dataset.name}? This can't be undone.`)) return;
button.disabled = true;
try {
const response = await fetch(`/api/offline-tiles/cache/${code}`, { method: "DELETE" });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `could not delete: ${response.status}`);
} catch (err) {
window.alert(err.message);
button.disabled = false;
return;
}
button.closest(".offline-data-panel__cache-info").remove();
} }
async _apply() { async _apply() {
+15
View File
@@ -438,6 +438,21 @@ class AppTest < Minitest::Test
refute_path_exists cache_dir refute_path_exists cache_dir
end end
def test_offline_tiles_cache_lists_and_deletes_one_country
cache_dir = File.join(Maptool::App::DATA_DIR, "geofabrik_cache")
FileUtils.mkdir_p(cache_dir)
File.write(File.join(cache_dir, "SK.osm.pbf"), "fake")
extracts = json_body(request.get("/api/offline-tiles/cache"))["extracts"]
assert_equal ["SK"], extracts.map { |e| e["code"] }
response = request.delete("/api/offline-tiles/cache/SK", **logged_in_env)
assert_equal 200, response.status
refute_path_exists File.join(cache_dir, "SK.osm.pbf")
ensure
FileUtils.remove_entry(cache_dir) if cache_dir && Dir.exist?(cache_dir)
end
def test_offline_tiles_delete_is_409_when_a_build_is_running def test_offline_tiles_delete_is_409_when_a_build_is_running
Maptool::PmtilesBuilder.set_selected_regions(Maptool::App::DATA_DIR, ["SK"]) Maptool::PmtilesBuilder.set_selected_regions(Maptool::App::DATA_DIR, ["SK"])
gate = Queue.new gate = Queue.new
+17
View File
@@ -338,6 +338,23 @@ class PmtilesBuilderTest < Minitest::Test
assert_nil Maptool::PmtilesBuilder.terrain_meta(@data_dir) assert_nil Maptool::PmtilesBuilder.terrain_meta(@data_dir)
end end
def test_cached_extracts_is_empty_before_any_download
assert_empty Maptool::PmtilesBuilder.cached_extracts(@data_dir)
end
# Deliberately still selected — cached_extracts reflects the cache on
# disk, not the current selection or the last build's own meta (see
# cached_extracts' own comment for why those three can all disagree).
def test_cached_extracts_lists_what_was_downloaded
select_regions("SK", "AT")
Maptool::PmtilesBuilder.build_offline(data_dir: @data_dir, download_extract: fake_download).join
extracts = Maptool::PmtilesBuilder.cached_extracts(@data_dir)
assert_equal %w[AT SK], extracts.map { |e| e["code"] }
assert_equal [4, 4], extracts.map { |e| e["size"] } # "fake".bytesize
assert_equal ["Wed, 10 Sep 2026 08:00:01 GMT"] * 2, extracts.map { |e| e["last_modified"] }
end
def test_offline_meta_is_nil_before_any_build def test_offline_meta_is_nil_before_any_build
assert_nil Maptool::PmtilesBuilder.offline_meta(@data_dir) assert_nil Maptool::PmtilesBuilder.offline_meta(@data_dir)
end end