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
|
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).
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user