harshith99 Claude Opus 4.8 commited on
Commit
f981d71
Β·
1 Parent(s): 1a26356

Feat: living-library merge polish (Phase 3)

Browse files

- /import returns added_urls + kept; merge_rows now returns the added rows.
- Client tags freshly-merged places (row._isNew) β†’ a "✨ New" card badge,
floats them to the top of Browse (stable, transient β€” resets on reload).
- Merge banner reports "N new Β· M already in your library".
- Import card gains a Merge/Replace radio (#import-mode), shown only when a
cached library exists; a zero-overlap merge still offers Replace inline.

Completes the living-library feature (Phases 1–3). Visual review at mobile +
desktop; tests/test_merge.py extended (added_urls/kept) and green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

CLAUDE.md CHANGED
@@ -100,7 +100,12 @@ existing library CSV) folds the uploaded file into it via `_merge_rows(existing,
100
  so user-owned `status`/edits are never overwritten; only new keys are appended. `no_overlap`
101
  flags "nothing matched" (likely wrong file β†’ client offers Replace). Client (`importPlacesFile`,
102
  `{forceReplace}`) sends `merge_into` from `localStorage.savedLibrary` unless restoring. Pinned
103
- by `tests/test_merge.py` (CI guardrail).
 
 
 
 
 
104
 
105
  **`POST /upload` living-library cost-skip (Phase 2).** `/upload` also accepts `merge_into`
106
  (the cached library CSV). `_run_pipeline` writes it into the job dir as the seed `places_full.csv`
 
100
  so user-owned `status`/edits are never overwritten; only new keys are appended. `no_overlap`
101
  flags "nothing matched" (likely wrong file β†’ client offers Replace). Client (`importPlacesFile`,
102
  `{forceReplace}`) sends `merge_into` from `localStorage.savedLibrary` unless restoring. Pinned
103
+ by `tests/test_merge.py` (CI guardrail). `/import` also returns `added_urls` + `kept`: the client
104
+ tags those rows `row._isNew` β†’ a `.new-badge` ("✨ New") in `makeCard`, floats them to the top in
105
+ `applyFilters` (stable sort, transient β€” resets on reload), and the banner reads "N new Β· M already
106
+ in your library". The import card's `#import-mode` Merge/Replace radio (shown by `_syncImportMode`
107
+ only when a cached library exists) sets `forceReplace`; a zero-overlap merge also offers Replace
108
+ inline.
109
 
110
  **`POST /upload` living-library cost-skip (Phase 2).** `/upload` also accepts `merge_into`
111
  (the cached library CSV). `_run_pipeline` writes it into the job dir as the seed `places_full.csv`
README.md CHANGED
@@ -331,7 +331,7 @@ The UI lets you:
331
  - **Stats summary** β€” place count, countries covered, geocoded fraction, and API cost shown at the top of the Browse tab
332
  - **KML round-trip** β€” city and state are embedded in each Placemark's description so re-importing the KML preserves full location data (not just country)
333
  - **Remembers your library (this device)** β€” after a run, the app caches your places in the browser (`localStorage`) and keeps the copy in sync as you mark places visited, edit fields, or pin them. Come back later and a **πŸ“¦ Restore your last library** card brings everything back β€” visited status, edits, and pins included β€” with no re-upload and no re-extraction. It's stored only on your device and never uploaded; clearing your browser data removes it, so the Download buttons remain your permanent backup. Edits are flushed to the cache when you switch away from or close the tab, so a change made right before leaving isn't lost
334
- - **Living library β€” imports merge, not replace** β€” when you already have a library loaded and drop another KML/CSV, it's **merged into** your library keyed on the Instagram link (keep-existing-wins): your visited status and manual edits are preserved and only genuinely new places are added ("N new places added"). If nothing in the file matches your library, you're offered the choice to replace instead. This is the foundation for incrementally growing one map over time
335
  - **Re-export costs nothing for places you already have** β€” when you re-run Extract on a fuller export months later, the app sends your existing library along, so the pipeline **skips the AI call on posts already in your map** and only extracts genuinely new ones (your visited status and pins are kept). A 600-post re-export where 550 are already mapped only pays to extract the 50 new ones
336
 
337
  ### Web UI providers
 
331
  - **Stats summary** β€” place count, countries covered, geocoded fraction, and API cost shown at the top of the Browse tab
332
  - **KML round-trip** β€” city and state are embedded in each Placemark's description so re-importing the KML preserves full location data (not just country)
333
  - **Remembers your library (this device)** β€” after a run, the app caches your places in the browser (`localStorage`) and keeps the copy in sync as you mark places visited, edit fields, or pin them. Come back later and a **πŸ“¦ Restore your last library** card brings everything back β€” visited status, edits, and pins included β€” with no re-upload and no re-extraction. It's stored only on your device and never uploaded; clearing your browser data removes it, so the Download buttons remain your permanent backup. Edits are flushed to the cache when you switch away from or close the tab, so a change made right before leaving isn't lost
334
+ - **Living library β€” imports merge, not replace** β€” when you already have a library loaded and drop another KML/CSV, it's **merged into** your library keyed on the Instagram link (keep-existing-wins): your visited status and manual edits are preserved and only genuinely new places are added. Freshly-added places get a **✨ New** badge and float to the top of Browse, and the banner reports "N new Β· M already in your library". A **Merge / Replace** control on the import card (shown when a library exists) lets you choose; if nothing in the file matches your library, you're offered Replace inline. This is the foundation for incrementally growing one map over time
335
  - **Re-export costs nothing for places you already have** β€” when you re-run Extract on a fuller export months later, the app sends your existing library along, so the pipeline **skips the AI call on posts already in your map** and only extracts genuinely new ones (your visited status and pins are kept). A 600-post re-export where 550 are already mapped only pays to extract the 50 new ones
336
 
337
  ### Web UI providers
TODO.md CHANGED
@@ -45,8 +45,10 @@ The paid competitors (Someday Map, Rezz, ReelsMap, Drawer) are all closed, cloud
45
  so `_warm_start_from_csv` skips the LLM on known posts (`new_urls` excludes them; progress
46
  says "X already in your library, skipped"), then `_merge_rows` folds results back so
47
  nothing is lost. `tests/test_merge.py` proves known posts never reach `analyze_batch`.
48
- - [ ] **Phase 3 β€” polish.** New-to-top sort + "new" card badge; an explicit
49
- merge-vs-replace toggle on the import card; a dedup/merge report.
 
 
50
  - [ ] **HF Spaces live deploy** β€” code-complete (see `deploy/hf-spaces/`); remaining step is the manual "create the Space + push" in `deploy/hf-spaces/SETUP.md`, then add the live URL to README.
51
  - [ ] **Offline / privacy-first map targets (GPX + docs)** β€” KML and GPX import into Organic Maps and OsmAnd: fully offline, no account, works abroad with no data plan.
52
  - [ ] **Close the mobile consumption loop (docs)** β€” document the last mile of opening a custom Google My Map in the Google Maps app and navigating to a pin.
 
45
  so `_warm_start_from_csv` skips the LLM on known posts (`new_urls` excludes them; progress
46
  says "X already in your library, skipped"), then `_merge_rows` folds results back so
47
  nothing is lost. `tests/test_merge.py` proves known posts never reach `analyze_batch`.
48
+ - [x] **Phase 3 β€” polish (done).** `/import` returns `added_urls`+`kept`; freshly-merged
49
+ places get a "✨ New" card badge and float to the top of Browse (transient); the banner
50
+ reports "N new Β· M already in your library"; the import card has a Merge/Replace radio
51
+ (`#import-mode`, shown when a library exists). Living-library feature complete.
52
  - [ ] **HF Spaces live deploy** β€” code-complete (see `deploy/hf-spaces/`); remaining step is the manual "create the Space + push" in `deploy/hf-spaces/SETUP.md`, then add the live URL to README.
53
  - [ ] **Offline / privacy-first map targets (GPX + docs)** β€” KML and GPX import into Organic Maps and OsmAnd: fully offline, no account, works abroad with no data plan.
54
  - [ ] **Close the mobile consumption loop (docs)** β€” document the last mile of opening a custom Google My Map in the Google Maps app and navigating to a pin.
tests/test_merge.py CHANGED
@@ -83,7 +83,8 @@ incoming = [
83
  merged, added = _merge_rows(existing, incoming)
84
  by_url = {r.get("instagram_url"): r for r in merged if r.get("instagram_url")}
85
 
86
- check("added count is 1 (only ccc is new)", added == 1)
 
87
  check("merged length = 4 (3 existing + 1 new)", len(merged) == 4)
88
  check("keep-existing-wins: Ichiran stays visited", by_url["https://instagram.com/p/aaa"]["status"] == "visited")
89
  check("new post ccc present", "https://instagram.com/p/ccc" in by_url)
@@ -95,7 +96,7 @@ check("URL-less existing kept as visited",
95
 
96
  # Idempotency: merging the existing set into itself adds nothing
97
  _, added_self = _merge_rows(existing, existing)
98
- check("idempotent: re-merge adds 0", added_self == 0)
99
 
100
  # Key shape
101
  check("_merge_key uses url when present", _merge_key(existing[0]).startswith("url:"))
@@ -120,6 +121,9 @@ try:
120
  check("route merge: status 200", r.status_code == 200)
121
  check("route merge: merged flag true", body.get("merged") is True)
122
  check("route merge: added == 1", body.get("added") == 1)
 
 
 
123
  check("route merge: total rows == 4", body.get("rows") == 4)
124
  check("route merge: no_overlap false (aaa matched)", body.get("no_overlap") is False)
125
 
 
83
  merged, added = _merge_rows(existing, incoming)
84
  by_url = {r.get("instagram_url"): r for r in merged if r.get("instagram_url")}
85
 
86
+ check("added count is 1 (only ccc is new)", len(added) == 1)
87
+ check("added rows expose the new url", added[0].get("instagram_url") == "https://instagram.com/p/ccc")
88
  check("merged length = 4 (3 existing + 1 new)", len(merged) == 4)
89
  check("keep-existing-wins: Ichiran stays visited", by_url["https://instagram.com/p/aaa"]["status"] == "visited")
90
  check("new post ccc present", "https://instagram.com/p/ccc" in by_url)
 
96
 
97
  # Idempotency: merging the existing set into itself adds nothing
98
  _, added_self = _merge_rows(existing, existing)
99
+ check("idempotent: re-merge adds 0", len(added_self) == 0)
100
 
101
  # Key shape
102
  check("_merge_key uses url when present", _merge_key(existing[0]).startswith("url:"))
 
121
  check("route merge: status 200", r.status_code == 200)
122
  check("route merge: merged flag true", body.get("merged") is True)
123
  check("route merge: added == 1", body.get("added") == 1)
124
+ check("route merge: added_urls lists the new post",
125
+ body.get("added_urls") == ["https://instagram.com/p/ccc"])
126
+ check("route merge: kept == 2 (aaa + the URL-less dup matched)", body.get("kept") == 2)
127
  check("route merge: total rows == 4", body.get("rows") == 4)
128
  check("route merge: no_overlap false (aaa matched)", body.get("no_overlap") is False)
129
 
web/app.py CHANGED
@@ -826,12 +826,14 @@ def _merge_key(row: dict) -> str:
826
  return "nc:" + name + "|" + city
827
 
828
 
829
- def _merge_rows(existing: list[dict], incoming: list[dict]) -> tuple[list[dict], int]:
830
  """Fold incoming rows into existing, keyed on _merge_key β€” keep-existing-wins.
831
 
832
  Existing rows are never dropped or overwritten, so user-owned status and
833
  manual edits are preserved (the status-is-user-owned invariant). Only
834
- genuinely new keys are appended. Returns (merged_rows, added_count).
 
 
835
  """
836
  seen: set[str] = set()
837
  merged: list[dict] = []
@@ -840,13 +842,13 @@ def _merge_rows(existing: list[dict], incoming: list[dict]) -> tuple[list[dict],
840
  if k not in seen:
841
  seen.add(k)
842
  merged.append(row)
843
- added = 0
844
  for row in incoming:
845
  k = _merge_key(row)
846
  if k not in seen:
847
  seen.add(k)
848
  merged.append(row)
849
- added += 1
850
  return merged, added
851
 
852
 
@@ -1234,6 +1236,8 @@ async def import_places(file: UploadFile = File(...),
1234
  # library (keep-existing-wins) when one is supplied.
1235
  incoming_count = len(rows)
1236
  added = incoming_count
 
 
1237
  merged = False
1238
  no_overlap = False
1239
  if merge_into:
@@ -1242,7 +1246,10 @@ async def import_places(file: UploadFile = File(...),
1242
  except ValueError:
1243
  existing = []
1244
  if existing:
1245
- rows, added = _merge_rows(existing, rows)
 
 
 
1246
  merged = True
1247
  no_overlap = added == incoming_count # nothing matched β†’ maybe wrong file
1248
 
@@ -1268,7 +1275,8 @@ async def import_places(file: UploadFile = File(...),
1268
  _persist_state(job_id, state)
1269
 
1270
  return {"job_id": job_id, "rows": len(rows), "pinned": pinned,
1271
- "added": added, "merged": merged, "no_overlap": no_overlap}
 
1272
 
1273
 
1274
  if __name__ == "__main__":
 
826
  return "nc:" + name + "|" + city
827
 
828
 
829
+ def _merge_rows(existing: list[dict], incoming: list[dict]) -> tuple[list[dict], list[dict]]:
830
  """Fold incoming rows into existing, keyed on _merge_key β€” keep-existing-wins.
831
 
832
  Existing rows are never dropped or overwritten, so user-owned status and
833
  manual edits are preserved (the status-is-user-owned invariant). Only
834
+ genuinely new keys are appended. Returns (merged_rows, added_rows) where
835
+ added_rows is the list of incoming rows that were new (use len() for the
836
+ count, instagram_url for badging).
837
  """
838
  seen: set[str] = set()
839
  merged: list[dict] = []
 
842
  if k not in seen:
843
  seen.add(k)
844
  merged.append(row)
845
+ added: list[dict] = []
846
  for row in incoming:
847
  k = _merge_key(row)
848
  if k not in seen:
849
  seen.add(k)
850
  merged.append(row)
851
+ added.append(row)
852
  return merged, added
853
 
854
 
 
1236
  # library (keep-existing-wins) when one is supplied.
1237
  incoming_count = len(rows)
1238
  added = incoming_count
1239
+ added_urls: list[str] = []
1240
+ kept = 0
1241
  merged = False
1242
  no_overlap = False
1243
  if merge_into:
 
1246
  except ValueError:
1247
  existing = []
1248
  if existing:
1249
+ rows, added_rows = _merge_rows(existing, rows)
1250
+ added = len(added_rows)
1251
+ added_urls = [r["instagram_url"] for r in added_rows if r.get("instagram_url")]
1252
+ kept = incoming_count - added # posts the file shared with the library
1253
  merged = True
1254
  no_overlap = added == incoming_count # nothing matched β†’ maybe wrong file
1255
 
 
1275
  _persist_state(job_id, state)
1276
 
1277
  return {"job_id": job_id, "rows": len(rows), "pinned": pinned,
1278
+ "added": added, "added_urls": added_urls, "kept": kept,
1279
+ "merged": merged, "no_overlap": no_overlap}
1280
 
1281
 
1282
  if __name__ == "__main__":
web/static/app.css CHANGED
@@ -657,6 +657,25 @@
657
  .rest-card.status-closed { opacity: 0.45; }
658
  .rest-card.status-closed .card-name { color: var(--muted); }
659
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
660
  .card-name {
661
  font-size: 0.95rem; font-weight: 800;
662
  margin-bottom: 3px; padding-right: 20px;
 
657
  .rest-card.status-closed { opacity: 0.45; }
658
  .rest-card.status-closed .card-name { color: var(--muted); }
659
 
660
+ /* Freshly-merged "new" places */
661
+ .rest-card.is-new { border-color: var(--ig2); }
662
+ .new-badge {
663
+ position: absolute; top: 10px; right: 24px;
664
+ font-size: 0.62rem; font-weight: 700; letter-spacing: 0.3px;
665
+ padding: 2px 7px; border-radius: 100px;
666
+ background: linear-gradient(135deg, var(--ig1), var(--ig2));
667
+ color: #fff;
668
+ }
669
+
670
+ /* Import card: Merge vs Replace control (shown when a library exists) */
671
+ .import-mode {
672
+ margin-top: 12px; font-size: 0.82rem;
673
+ display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center;
674
+ }
675
+ .import-mode-label { width: 100%; color: var(--muted); }
676
+ .import-mode label { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; color: var(--text); }
677
+ .import-mode input { accent-color: var(--ig1); }
678
+
679
  .card-name {
680
  font-size: 0.95rem; font-weight: 800;
681
  margin-bottom: 3px; padding-right: 20px;
web/static/app.js CHANGED
@@ -147,7 +147,15 @@ async function importPlacesFile(file, statusEl, { forceReplace = false } = {}) {
147
  $('download-csv-btn').onclick = () => { window.location = '/download/' + data.job_id + '/csv'; };
148
  showSubTab('browse');
149
  await loadResults(data.job_id);
150
- if (data.merged) _showMergeBanner(data, file);
 
 
 
 
 
 
 
 
151
  if (_clientGeocode) geocodeClientSide();
152
  return data.job_id;
153
  }
@@ -157,12 +165,13 @@ async function importPlacesFile(file, statusEl, { forceReplace = false } = {}) {
157
  function _showMergeBanner(data, file) {
158
  const banner = $('new-banner'), txt = $('new-banner-text');
159
  if (!banner || !txt) return;
160
- const n = data.added;
161
  if (data.no_overlap && n > 0) {
162
  txt.innerHTML = `Added <strong>${n}</strong> place${n === 1 ? '' : 's'} β€” none matched your existing library. `
163
  + `<a href="#" id="merge-replace-link">Replace instead?</a>`;
164
  } else if (n > 0) {
165
- txt.textContent = `${n} new place${n === 1 ? '' : 's'} added to your library`;
 
166
  } else {
167
  txt.textContent = 'No new places β€” everything in that file was already in your library';
168
  }
@@ -190,14 +199,25 @@ async function handleImportFile(f) {
190
  $('import-file-chosen').style.display = 'block';
191
  status.textContent = 'Importing…';
192
  status.classList.remove('error');
 
 
193
  try {
194
- await importPlacesFile(f, status);
195
  } catch (err) {
196
  status.textContent = '⚠ ' + err.message;
197
  status.classList.add('error');
198
  }
199
  }
200
 
 
 
 
 
 
 
 
 
 
201
  // ── Client-side library persistence (localStorage) ─────────────────────────
202
  // Cache the canonical CSV so a returning visitor restores their library without
203
  // re-uploading or re-extracting. Reconstruction-cache only: restore replays the
@@ -1106,6 +1126,11 @@ function applyFilters() {
1106
  const ft = $('food-toggle');
1107
  if (ft) ft.classList.toggle('active', filterState.foodOnly);
1108
 
 
 
 
 
 
1109
  renderGrid(rows);
1110
  renderMap(rows);
1111
  $('result-count').textContent = rows.length === allRows.length
@@ -1175,9 +1200,17 @@ function val(v) { return (!v || v === 'UNKNOWN') ? '' : v; }
1175
 
1176
  function makeCard(row) {
1177
  const card = document.createElement('div');
1178
- card.className = 'rest-card' + (row.geocoded ? '' : ' missing-pin');
1179
  card.dataset.url = row.instagram_url;
1180
 
 
 
 
 
 
 
 
 
1181
  // Pin badge
1182
  const badge = document.createElement('div');
1183
  badge.className = 'pin-badge ' + (row.geocoded ? 'pin-ok' : 'pin-missing');
@@ -1653,5 +1686,7 @@ $('feedback-submit').addEventListener('click', () => {
1653
  });
1654
 
1655
  // ── Boot ───────────────────────────────────────────────────────────────────
1656
- // Offer to restore a previously cached library (no live job β†’ returning visitor).
 
1657
  maybeShowRestore();
 
 
147
  $('download-csv-btn').onclick = () => { window.location = '/download/' + data.job_id + '/csv'; };
148
  showSubTab('browse');
149
  await loadResults(data.job_id);
150
+ if (data.merged) {
151
+ // Tag the freshly-added rows so Browse can badge them and float them to top.
152
+ if (data.added_urls && data.added_urls.length) {
153
+ const fresh = new Set(data.added_urls);
154
+ allRows.forEach(r => { if (fresh.has(r.instagram_url)) r._isNew = true; });
155
+ applyFilters();
156
+ }
157
+ _showMergeBanner(data, file);
158
+ }
159
  if (_clientGeocode) geocodeClientSide();
160
  return data.job_id;
161
  }
 
165
  function _showMergeBanner(data, file) {
166
  const banner = $('new-banner'), txt = $('new-banner-text');
167
  if (!banner || !txt) return;
168
+ const n = data.added, kept = data.kept || 0;
169
  if (data.no_overlap && n > 0) {
170
  txt.innerHTML = `Added <strong>${n}</strong> place${n === 1 ? '' : 's'} β€” none matched your existing library. `
171
  + `<a href="#" id="merge-replace-link">Replace instead?</a>`;
172
  } else if (n > 0) {
173
+ const keptNote = kept ? ` Β· ${kept} already in your library` : '';
174
+ txt.textContent = `${n} new place${n === 1 ? '' : 's'} added${keptNote}`;
175
  } else {
176
  txt.textContent = 'No new places β€” everything in that file was already in your library';
177
  }
 
199
  $('import-file-chosen').style.display = 'block';
200
  status.textContent = 'Importing…';
201
  status.classList.remove('error');
202
+ const modeEl = document.querySelector('input[name="import-mode"]:checked');
203
+ const forceReplace = modeEl ? modeEl.value === 'replace' : false;
204
  try {
205
+ await importPlacesFile(f, status, { forceReplace });
206
  } catch (err) {
207
  status.textContent = '⚠ ' + err.message;
208
  status.classList.add('error');
209
  }
210
  }
211
 
212
+ // Show the Merge/Replace control on the import card only when a cached library
213
+ // exists (otherwise an import is always a fresh start β€” nothing to merge into).
214
+ function _syncImportMode() {
215
+ const el = $('import-mode');
216
+ if (!el) return;
217
+ const lib = _readSavedLibrary();
218
+ el.style.display = (lib && lib.csv) ? 'block' : 'none';
219
+ }
220
+
221
  // ── Client-side library persistence (localStorage) ─────────────────────────
222
  // Cache the canonical CSV so a returning visitor restores their library without
223
  // re-uploading or re-extracting. Reconstruction-cache only: restore replays the
 
1126
  const ft = $('food-toggle');
1127
  if (ft) ft.classList.toggle('active', filterState.foodOnly);
1128
 
1129
+ // Float just-merged "new" places to the top (stable; only after a merge).
1130
+ if (allRows.some(r => r._isNew)) {
1131
+ rows = [...rows].sort((a, b) => (b._isNew ? 1 : 0) - (a._isNew ? 1 : 0));
1132
+ }
1133
+
1134
  renderGrid(rows);
1135
  renderMap(rows);
1136
  $('result-count').textContent = rows.length === allRows.length
 
1200
 
1201
  function makeCard(row) {
1202
  const card = document.createElement('div');
1203
+ card.className = 'rest-card' + (row.geocoded ? '' : ' missing-pin') + (row._isNew ? ' is-new' : '');
1204
  card.dataset.url = row.instagram_url;
1205
 
1206
+ // "New" badge β€” set on rows just added by a merge (transient, resets on reload)
1207
+ if (row._isNew) {
1208
+ const nb = document.createElement('div');
1209
+ nb.className = 'new-badge';
1210
+ nb.textContent = '✨ New';
1211
+ card.appendChild(nb);
1212
+ }
1213
+
1214
  // Pin badge
1215
  const badge = document.createElement('div');
1216
  badge.className = 'pin-badge ' + (row.geocoded ? 'pin-ok' : 'pin-missing');
 
1686
  });
1687
 
1688
  // ── Boot ───────────────────────────────────────────────────────────────────
1689
+ // Offer to restore a previously cached library (no live job β†’ returning visitor),
1690
+ // and show the import card's Merge/Replace control when a library exists.
1691
  maybeShowRestore();
1692
+ _syncImportMode();
web/templates/index.html CHANGED
@@ -271,6 +271,11 @@
271
  <div class="drop-sub">places_map.kml Β· places_full.csv β€” click to browse</div>
272
  <div class="file-chosen" id="import-file-chosen"></div>
273
  </div>
 
 
 
 
 
274
  <div class="status-msg" id="import-status" style="margin-top:10px;"></div>
275
  </div>
276
 
 
271
  <div class="drop-sub">places_map.kml Β· places_full.csv β€” click to browse</div>
272
  <div class="file-chosen" id="import-file-chosen"></div>
273
  </div>
274
+ <div class="import-mode" id="import-mode" style="display:none;">
275
+ <span class="import-mode-label">You already have a saved library:</span>
276
+ <label><input type="radio" name="import-mode" value="merge" checked> Merge in (keep status &amp; edits)</label>
277
+ <label><input type="radio" name="import-mode" value="replace"> Replace it</label>
278
+ </div>
279
  <div class="status-msg" id="import-status" style="margin-top:10px;"></div>
280
  </div>
281