Spaces:
Runtime error
Runtime error
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 +6 -1
- README.md +1 -1
- TODO.md +4 -2
- tests/test_merge.py +6 -2
- web/app.py +14 -6
- web/static/app.css +19 -0
- web/static/app.js +41 -6
- web/templates/index.html +5 -0
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
|
| 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 |
-
- [
|
| 49 |
-
|
|
|
|
|
|
|
| 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],
|
| 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,
|
|
|
|
|
|
|
| 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 =
|
| 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
|
| 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,
|
|
|
|
|
|
|
|
|
|
| 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, "
|
|
|
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 & 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 |
|