From 78362d51f2b272d27d7206792288c23c07591618 Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Fri, 11 Sep 2026 23:39:43 +0200 Subject: [PATCH] Show what's actually cached per-country, with a delete button per row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app.rb | 9 ++++++ docs/development.adoc | 10 +++++- lib/maptool/pmtiles_builder.rb | 20 ++++++++++++ public/css/style.css | 37 ++++++++++++++++++++++ public/js/offline_data.js | 56 +++++++++++++++++++++++++++++++--- test/test_app.rb | 15 +++++++++ test/test_pmtiles_builder.rb | 17 +++++++++++ 7 files changed, 158 insertions(+), 6 deletions(-) diff --git a/app.rb b/app.rb index 12f840b..0246b79 100644 --- a/app.rb +++ b/app.rb @@ -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). diff --git a/docs/development.adoc b/docs/development.adoc index 7538f4b..13ce45a 100644 --- a/docs/development.adoc +++ b/docs/development.adoc @@ -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; diff --git a/lib/maptool/pmtiles_builder.rb b/lib/maptool/pmtiles_builder.rb index a4844fa..cccf2e9 100644 --- a/lib/maptool/pmtiles_builder.rb +++ b/lib/maptool/pmtiles_builder.rb @@ -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 diff --git a/public/css/style.css b/public/css/style.css index 4679802..b3fc4e9 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -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. */ diff --git a/public/js/offline_data.js b/public/js/offline_data.js index b4e2852..3fc4a2e 100644 --- a/public/js/offline_data.js +++ b/public/js/offline_data.js @@ -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 = `
  • Could not load the region list.
  • `; 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 + ? ` + + ${formatBytes(extract.size)} + ${ + isLoggedIn() + ? `` + : "" + } + ` + : ""; + return `
  • -
  • `, - ) + ${cacheInfo} + `; + }) .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() { diff --git a/test/test_app.rb b/test/test_app.rb index 53795aa..d69e97d 100644 --- a/test/test_app.rb +++ b/test/test_app.rb @@ -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 diff --git a/test/test_pmtiles_builder.rb b/test/test_pmtiles_builder.rb index 0a6328b..002bfc2 100644 --- a/test/test_pmtiles_builder.rb +++ b/test/test_pmtiles_builder.rb @@ -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