Show what's actually cached per-country, with a delete button per row
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:
1 parent
d09d953cbf
commit
78362d51f2
7 files changed
+158
-6
No files matched your search
@@ -291,6 +291,15 @@ module Maptool
|
||||
json ok: true
|
||||
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
|
||||
# clean re-download of just that one, or reclaim space for a country
|
||||
# that's no longer selected (the cache otherwise keeps it indefinitely).
|
||||
|
||||
@@ -147,7 +147,15 @@ which only changes what a *future* build covers and never frees
|
||||
anything already on disk. `DELETE
|
||||
/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
|
||||
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.
|
||||
meta.json` — each source country's code/Geofabrik slug/Last-Modified;
|
||||
|
||||
@@ -448,6 +448,26 @@ module Maptool
|
||||
File.delete("#{path}.last_modified") if File.exist?("#{path}.last_modified")
|
||||
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 ---
|
||||
|
||||
# planet.pmtiles (z0-12, always) plus whichever regional chunks
|
||||
|
||||
@@ -416,11 +416,20 @@ html, body {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -428,6 +437,34 @@ html, body {
|
||||
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
|
||||
(rx 5 of a 22-wide square) — a consistent frame rather than however
|
||||
each flag's own artwork happens to fill an unbordered square. */
|
||||
|
||||
@@ -81,26 +81,72 @@ class RegionPicker {
|
||||
container.appendChild(section);
|
||||
|
||||
let data;
|
||||
let cache;
|
||||
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 {
|
||||
this._list.innerHTML = `<li class="offline-data-panel__region-status">Could not load the region list.</li>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = new Set(data.selected);
|
||||
const cached = new Map(cache.extracts.map((e) => [e.code, e]));
|
||||
this._list.innerHTML = data.available
|
||||
.map(
|
||||
(region) => `
|
||||
.map((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>
|
||||
<label>
|
||||
<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">
|
||||
${escapeHtml(region.name)}
|
||||
</label>
|
||||
</li>`,
|
||||
)
|
||||
${cacheInfo}
|
||||
</li>`;
|
||||
})
|
||||
.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() {
|
||||
|
||||
@@ -438,6 +438,21 @@ class AppTest < Minitest::Test
|
||||
refute_path_exists cache_dir
|
||||
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
|
||||
Maptool::PmtilesBuilder.set_selected_regions(Maptool::App::DATA_DIR, ["SK"])
|
||||
gate = Queue.new
|
||||
|
||||
@@ -338,6 +338,23 @@ class PmtilesBuilderTest < Minitest::Test
|
||||
assert_nil Maptool::PmtilesBuilder.terrain_meta(@data_dir)
|
||||
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
|
||||
assert_nil Maptool::PmtilesBuilder.offline_meta(@data_dir)
|
||||
end
|
||||
|
||||
Reference in new issue
Block a user