Permit-iframe hardening, import UX refinements, workshop deliverables

Permit-iframe hardening:
- public/embed.php — replace the 302 redirect on unauthenticated visits
  with an in-iframe HTML "Sign in to view the map" card (HTTP 401)
  whose primary button uses target="_top" to break the iframe and send
  the parent window to the SSO portal. The 302 was broken UX inside an
  iframe because the LUSPA portal refuses to be framed.
- public/embed.php + public/.htaccess — strip X-Frame-Options at the
  embed endpoint (defence in depth). Apache's <Files "embed.php">
  Header always unset X-Frame-Options + PHP's header_remove() both
  ensure the only iframe-policy header on the response is our CSP
  frame-ancestors (which already allows the permits subdomain). Fixes
  Safari's "Refused to display ... because it set 'X-Frame-Options'
  to 'SAMEORIGIN'" when the container's reverse proxy injects it.

Import UX refinements:
- Spinner overlay (index.html #import-spinner-overlay + main.js
  showImportSpinner/hideImportSpinner) shown during the file-drop →
  mapping-modal gap. Wired at the top of each handle*Import and at
  every error / early-return path; hidden by stageImport() just before
  openImportMappingModal() so it spans both the JS parse and the
  SQLocal staging insert.
- Per-feature client_uuid tagging — each imported OL feature now
  carries _externalImportId + _clientUuid set in stageImport(). These
  tags are the link that lets later edits find the matching staging
  row, and they are passed through to addExternalImportFeatures.
- Geometry-edit persistence — new public callback registry
  MapView.onFeatureModified(cb) fired from a modifyend listener on
  _modifyInteraction. main.js handler writes the new WKT (EPSG:4326)
  back to external_import_features.geometry_wkt via new helper
  updateExternalImportFeatureGeometry(clientUuid, wkt). Non-imported
  features carry no tags, so the handler is a no-op for them.
- Delete persistence — removefeature listener on each imported layer's
  source. New helper deleteExternalImportFeature(clientUuid) runs an
  atomic DELETE + decrement of external_imports.feature_count and
  broadcasts the changes so the LayerSwitcher badge can recount.
- Field-mapping dropdown — sample values + bold field names.
  New helpers sampleSourceValues(fc) in import-detect.js (picks first
  non-empty value per attribute, JSON-stringifies objects, collapses
  whitespace, truncates to 35 chars) and toBoldUnicode(s) in
  import-modal.js (ASCII letters/digits → Mathematical Alphanumeric
  Symbols block). Options now read as "𝐮𝐩𝐧 — [12345-6789]";
  HTML/CSS bold doesn't render inside <option> elements, so Unicode
  bold codepoints are the cross-browser way.

Workshop deliverables:
- LUPMIS2_Improvements_Mar_to_Jun_2026.docx — handout mirroring the
  slide deck one-to-one (160 paragraphs, branded styling).
- LUPMIS2_Workshop_Mar_to_Jun_2026.pptx — 16-slide pptxgenjs deck
  (16:9 widescreen, brand palette, hero + content + closing masters,
  embedded staged-upload diagram on slide 9).
- LUPMIS2_Staged_Upload_Flow.svg + .png — three swim-lane diagram of
  the staged-upload pipeline with a dedicated "Client QA Gate"
  callout. Hand-crafted SVG + 2400 px PNG.

save_gps_trail.php diagnosis (no code change, on the database team):
the reported "CORS" error is a missing endpoint — Apache returns 404
with no CORS headers and the browser surfaces it as access-control.
Once the endpoint is deployed the API server's global CORS handling
attaches the right headers and the GPS-trail sync will work without
client changes.

dist/ rebuilt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ekke 2026-06-23 13:16:37 +00:00
parent 26d4f6235f
commit 203ca5bc4d
18 changed files with 870 additions and 84 deletions

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 433 KiB

View File

@ -0,0 +1,265 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1100" font-family="Arial, sans-serif">
<!-- ============================================================
LUPMIS2 — Staged Upload Flow
Client (PWA) ↦ Server (PostgreSQL) ↦ Supervisor ↦ Live
Brand: navy #1E1A4B · green #10B981 · orange #FF9E1B
============================================================ -->
<defs>
<!-- Arrow markers (one per colour) -->
<marker id="arrowNavy" viewBox="0 0 12 12" refX="11" refY="6" markerWidth="9" markerHeight="9" orient="auto-start-reverse">
<path d="M 0 0 L 12 6 L 0 12 z" fill="#1E1A4B"/>
</marker>
<marker id="arrowGreen" viewBox="0 0 12 12" refX="11" refY="6" markerWidth="9" markerHeight="9" orient="auto-start-reverse">
<path d="M 0 0 L 12 6 L 0 12 z" fill="#10B981"/>
</marker>
<marker id="arrowOrange" viewBox="0 0 12 12" refX="11" refY="6" markerWidth="9" markerHeight="9" orient="auto-start-reverse">
<path d="M 0 0 L 12 6 L 0 12 z" fill="#FF9E1B"/>
</marker>
<marker id="arrowMuted" viewBox="0 0 12 12" refX="11" refY="6" markerWidth="9" markerHeight="9" orient="auto-start-reverse">
<path d="M 0 0 L 12 6 L 0 12 z" fill="#6B7280"/>
</marker>
<!-- Drop-shadow for cards -->
<filter id="shadow" x="-10%" y="-10%" width="120%" height="120%">
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="#000" flood-opacity="0.08"/>
</filter>
</defs>
<!-- Background -->
<rect x="0" y="0" width="1600" height="1100" fill="#FAFBFC"/>
<!-- ============================================================
Header
============================================================ -->
<rect x="0" y="0" width="1600" height="6" fill="#10B981"/>
<text x="40" y="50" font-size="28" font-weight="700" fill="#1E1A4B">
Staged Upload to the Remote Database
</text>
<text x="40" y="78" font-size="14" fill="#6B7280">
Client QA → Server staging in lu_parcels_upload_tmp → Supervisor review → Promotion to live lu_parcels
</text>
<text x="40" y="98" font-size="12" fill="#6B7280" font-style="italic">
Idempotent on client_uuid · per-row results · no silent overwrites
</text>
<!-- ============================================================
LANE 1 — CLIENT (PWA) y: 130 470
============================================================ -->
<!-- Lane background panel -->
<rect x="20" y="130" width="1560" height="340" rx="10" fill="#ffffff" stroke="#E5E7EB"/>
<!-- Lane number band (navy) -->
<rect x="20" y="130" width="160" height="340" rx="10" fill="#1E1A4B"/>
<rect x="170" y="130" width="10" height="340" fill="#1E1A4B"/>
<text x="100" y="170" font-size="13" font-weight="700" fill="#10B981" text-anchor="middle" letter-spacing="2">STAGE 1</text>
<text x="100" y="200" font-size="22" font-weight="700" fill="#ffffff" text-anchor="middle">CLIENT</text>
<text x="100" y="225" font-size="14" fill="#CADCFC" text-anchor="middle">(PWA)</text>
<text x="100" y="265" font-size="11" fill="#CADCFC" text-anchor="middle">browser session</text>
<text x="100" y="282" font-size="11" fill="#CADCFC" text-anchor="middle">SSO authenticated</text>
<!-- Step boxes — 5 steps, left to right -->
<!-- Step 1 — Drop -->
<g filter="url(#shadow)">
<rect x="200" y="160" width="200" height="80" rx="8" fill="#ffffff" stroke="#10B981" stroke-width="2"/>
<text x="300" y="190" font-size="13" font-weight="700" fill="#1E1A4B" text-anchor="middle">1 · Drop file</text>
<text x="300" y="212" font-size="11" fill="#333333" text-anchor="middle">.shp · .geojson · .kml</text>
<text x="300" y="228" font-size="11" fill="#333333" text-anchor="middle">on the map surface</text>
</g>
<!-- arrow -->
<line x1="402" y1="200" x2="438" y2="200" stroke="#1E1A4B" stroke-width="2" marker-end="url(#arrowNavy)"/>
<!-- Step 2 — Parse + stage -->
<g filter="url(#shadow)">
<rect x="440" y="160" width="220" height="80" rx="8" fill="#ffffff" stroke="#10B981" stroke-width="2"/>
<text x="550" y="186" font-size="13" font-weight="700" fill="#1E1A4B" text-anchor="middle">2 · Parse + stage locally</text>
<text x="550" y="206" font-size="11" fill="#333333" text-anchor="middle">shpjs / OL KML / native parser</text>
<text x="550" y="222" font-size="11" fill="#1E1A4B" text-anchor="middle" font-family="Consolas, monospace">external_imports +</text>
<text x="550" y="234" font-size="11" fill="#1E1A4B" text-anchor="middle" font-family="Consolas, monospace">external_import_features</text>
</g>
<line x1="662" y1="200" x2="698" y2="200" stroke="#1E1A4B" stroke-width="2" marker-end="url(#arrowNavy)"/>
<!-- Step 3 — Mapping modal -->
<g filter="url(#shadow)">
<rect x="700" y="160" width="220" height="80" rx="8" fill="#ffffff" stroke="#10B981" stroke-width="2"/>
<text x="810" y="188" font-size="13" font-weight="700" fill="#1E1A4B" text-anchor="middle">3 · Mapping modal</text>
<text x="810" y="208" font-size="11" fill="#333333" text-anchor="middle">user confirms target type</text>
<text x="810" y="224" font-size="11" fill="#333333" text-anchor="middle">+ field map · status = mapped</text>
</g>
<line x1="922" y1="200" x2="958" y2="200" stroke="#1E1A4B" stroke-width="2" marker-end="url(#arrowNavy)"/>
<!-- Step 4 — Click Upload -->
<g filter="url(#shadow)">
<rect x="960" y="160" width="220" height="80" rx="8" fill="#ffffff" stroke="#10B981" stroke-width="2"/>
<text x="1070" y="188" font-size="13" font-weight="700" fill="#1E1A4B" text-anchor="middle">4 · User clicks Upload</text>
<text x="1070" y="208" font-size="11" fill="#333333" text-anchor="middle">"Upload N" badge on the</text>
<text x="1070" y="224" font-size="11" fill="#333333" text-anchor="middle">LayerSwitcher row</text>
</g>
<!-- Down arrow from step 4 to QA -->
<line x1="1070" y1="244" x2="1070" y2="280" stroke="#1E1A4B" stroke-width="2" marker-end="url(#arrowNavy)"/>
<!-- ===== Client QA gate (callout) ===== -->
<g filter="url(#shadow)">
<rect x="200" y="285" width="980" height="170" rx="10" fill="#F3F4F6" stroke="#FF9E1B" stroke-width="2" stroke-dasharray="6,4"/>
<!-- Orange dot -->
<circle cx="222" cy="312" r="8" fill="#FF9E1B"/>
<text x="240" y="318" font-size="14" font-weight="700" fill="#1E1A4B">CLIENT QA GATE · applied before the request leaves the browser</text>
<!-- 6 QA items in a 3 × 2 grid -->
<g font-size="12" fill="#333333">
<!-- Row 1 -->
<text x="240" y="350">✓ Every feature has a geometry → converted to WKT in EPSG:4326</text>
<text x="240" y="372">✓ Target type explicitly confirmed (no silent 'other')</text>
<text x="240" y="394">✓ Field map applied — only mapped fields included; unmapped dropped</text>
<!-- Row 2 -->
<text x="720" y="350">✓ client_uuid generated per feature (SHA1 of filename + index)</text>
<text x="720" y="372">✓ user_id_upload taken from SSO session (window.LUPMIS_SESSION)</text>
<text x="720" y="394">✓ district_id + api_token auto-attached by remotePost()</text>
</g>
<text x="240" y="430" font-size="12" fill="#1E1A4B" font-style="italic">
Only imports in status 'mapped' can be uploaded. The badge is disabled in any other state.
</text>
</g>
<!-- ============================================================
HANDOFF — Client → Server y: 470 540
============================================================ -->
<line x1="800" y1="475" x2="800" y2="540" stroke="#1E1A4B" stroke-width="3" marker-end="url(#arrowNavy)"/>
<rect x="540" y="486" width="520" height="40" rx="6" fill="#1E1A4B"/>
<text x="800" y="512" font-size="13" font-weight="700" fill="#ffffff" text-anchor="middle">
POST /api/spatial_planning/upload_&lt;target_type&gt;.php
</text>
<!-- ============================================================
LANE 2 — SERVER y: 555 800
============================================================ -->
<rect x="20" y="555" width="1560" height="245" rx="10" fill="#ffffff" stroke="#E5E7EB"/>
<rect x="20" y="555" width="160" height="245" rx="10" fill="#1E1A4B"/>
<rect x="170" y="555" width="10" height="245" fill="#1E1A4B"/>
<text x="100" y="595" font-size="13" font-weight="700" fill="#10B981" text-anchor="middle" letter-spacing="2">STAGE 2</text>
<text x="100" y="625" font-size="22" font-weight="700" fill="#ffffff" text-anchor="middle">SERVER</text>
<text x="100" y="650" font-size="13" fill="#CADCFC" text-anchor="middle">PHP + PostgreSQL</text>
<text x="100" y="685" font-size="11" fill="#CADCFC" text-anchor="middle">SSO-scoped</text>
<text x="100" y="702" font-size="11" fill="#CADCFC" text-anchor="middle">per-district</text>
<!-- Step 1 — Auth -->
<g filter="url(#shadow)">
<rect x="200" y="585" width="200" height="80" rx="8" fill="#ffffff" stroke="#1E1A4B" stroke-width="2"/>
<text x="300" y="610" font-size="13" font-weight="700" fill="#1E1A4B" text-anchor="middle">1 · Auth check</text>
<text x="300" y="632" font-size="11" fill="#333333" text-anchor="middle">token + district scope</text>
<text x="300" y="648" font-size="11" fill="#333333" text-anchor="middle">vs SSO session</text>
</g>
<line x1="402" y1="625" x2="438" y2="625" stroke="#1E1A4B" stroke-width="2" marker-end="url(#arrowNavy)"/>
<!-- Step 2 — Per-feature validate -->
<g filter="url(#shadow)">
<rect x="440" y="585" width="220" height="80" rx="8" fill="#ffffff" stroke="#1E1A4B" stroke-width="2"/>
<text x="550" y="610" font-size="13" font-weight="700" fill="#1E1A4B" text-anchor="middle">2 · Per-feature validate</text>
<text x="550" y="632" font-size="11" fill="#333333" text-anchor="middle">geom valid · UPN policy</text>
<text x="550" y="648" font-size="11" fill="#333333" text-anchor="middle">required fields present</text>
</g>
<line x1="662" y1="625" x2="698" y2="625" stroke="#1E1A4B" stroke-width="2" marker-end="url(#arrowNavy)"/>
<!-- Step 3 — INSERT ON CONFLICT -->
<g filter="url(#shadow)">
<rect x="700" y="585" width="260" height="80" rx="8" fill="#ffffff" stroke="#1E1A4B" stroke-width="2"/>
<text x="830" y="608" font-size="13" font-weight="700" fill="#1E1A4B" text-anchor="middle">3 · Stage in tmp table</text>
<text x="830" y="628" font-size="11" fill="#1E1A4B" text-anchor="middle" font-family="Consolas, monospace">INSERT … ON CONFLICT</text>
<text x="830" y="643" font-size="11" fill="#1E1A4B" text-anchor="middle" font-family="Consolas, monospace">(client_uuid) DO UPDATE</text>
<text x="830" y="658" font-size="11" fill="#10B981" text-anchor="middle" font-weight="700">→ spatial.lu_parcels_upload_tmp</text>
</g>
<line x1="962" y1="625" x2="998" y2="625" stroke="#1E1A4B" stroke-width="2" marker-end="url(#arrowNavy)"/>
<!-- Step 4 — Row state -->
<g filter="url(#shadow)">
<rect x="1000" y="585" width="220" height="80" rx="8" fill="#ffffff" stroke="#1E1A4B" stroke-width="2"/>
<text x="1110" y="608" font-size="13" font-weight="700" fill="#1E1A4B" text-anchor="middle">4 · Row created</text>
<text x="1110" y="628" font-size="11" fill="#1E1A4B" text-anchor="middle" font-family="Consolas, monospace">migrated = 0</text>
<text x="1110" y="643" font-size="11" fill="#1E1A4B" text-anchor="middle" font-family="Consolas, monospace">user_id_upload, created_at,</text>
<text x="1110" y="658" font-size="11" fill="#1E1A4B" text-anchor="middle" font-family="Consolas, monospace">districtid (server-derived)</text>
</g>
<line x1="1222" y1="625" x2="1258" y2="625" stroke="#1E1A4B" stroke-width="2" marker-end="url(#arrowNavy)"/>
<!-- Step 5 — Per-row results -->
<g filter="url(#shadow)">
<rect x="1260" y="585" width="200" height="80" rx="8" fill="#ffffff" stroke="#1E1A4B" stroke-width="2"/>
<text x="1360" y="608" font-size="13" font-weight="700" fill="#1E1A4B" text-anchor="middle">5 · Per-row JSON</text>
<text x="1360" y="630" font-size="11" fill="#10B981" text-anchor="middle">inserted / updated</text>
<text x="1360" y="646" font-size="11" fill="#FF9E1B" text-anchor="middle">/ failed (with reason)</text>
</g>
<!-- Response back to client (curving line going up to client lane) -->
<path d="M 1360 685 C 1360 730, 1500 720, 1500 470" stroke="#10B981" stroke-width="2" stroke-dasharray="6,4" fill="none" marker-end="url(#arrowGreen)"/>
<text x="1360" y="710" font-size="11" fill="#10B981" text-anchor="middle" font-weight="700">PWA badge → ✓ submitted</text>
<text x="1360" y="725" font-size="10" fill="#6B7280" text-anchor="middle">(failed rows stay marked locally for retry)</text>
<!-- ============================================================
HANDOFF — Server (pending) → Supervisor y: 800 830
============================================================ -->
<line x1="800" y1="755" x2="800" y2="830" stroke="#1E1A4B" stroke-width="3" stroke-dasharray="6,4" marker-end="url(#arrowNavy)"/>
<rect x="540" y="776" width="520" height="36" rx="6" fill="#F3F4F6" stroke="#1E1A4B"/>
<text x="800" y="800" font-size="13" font-weight="700" fill="#1E1A4B" text-anchor="middle">
Awaiting supervisor review · rows remain with migrated = 0
</text>
<!-- ============================================================
LANE 3 — SUPERVISOR y: 845 1040
============================================================ -->
<rect x="20" y="845" width="1560" height="195" rx="10" fill="#ffffff" stroke="#E5E7EB"/>
<rect x="20" y="845" width="160" height="195" rx="10" fill="#1E1A4B"/>
<rect x="170" y="845" width="10" height="195" fill="#1E1A4B"/>
<text x="100" y="885" font-size="13" font-weight="700" fill="#10B981" text-anchor="middle" letter-spacing="2">STAGE 3</text>
<text x="100" y="912" font-size="20" font-weight="700" fill="#ffffff" text-anchor="middle">SUPERVISOR</text>
<text x="100" y="940" font-size="12" fill="#CADCFC" text-anchor="middle">Review UI</text>
<text x="100" y="958" font-size="11" fill="#FF9E1B" text-anchor="middle">(future · Laravel)</text>
<!-- Step 1 — List pending -->
<g filter="url(#shadow)">
<rect x="200" y="870" width="220" height="80" rx="8" fill="#ffffff" stroke="#1E1A4B" stroke-width="2"/>
<text x="310" y="897" font-size="13" font-weight="700" fill="#1E1A4B" text-anchor="middle">1 · List pending rows</text>
<text x="310" y="918" font-size="11" fill="#1E1A4B" text-anchor="middle" font-family="Consolas, monospace">WHERE migrated = 0</text>
<text x="310" y="934" font-size="11" fill="#333333" text-anchor="middle">filter by uploader / date / district</text>
</g>
<line x1="422" y1="910" x2="458" y2="910" stroke="#1E1A4B" stroke-width="2" marker-end="url(#arrowNavy)"/>
<!-- Step 2 — Side-by-side diff -->
<g filter="url(#shadow)">
<rect x="460" y="870" width="220" height="80" rx="8" fill="#ffffff" stroke="#1E1A4B" stroke-width="2"/>
<text x="570" y="895" font-size="13" font-weight="700" fill="#1E1A4B" text-anchor="middle">2 · Side-by-side diff</text>
<text x="570" y="917" font-size="11" fill="#333333" text-anchor="middle">vs existing lu_parcels by UPN</text>
<text x="570" y="933" font-size="11" fill="#333333" text-anchor="middle">map preview + attributes</text>
</g>
<line x1="682" y1="910" x2="718" y2="910" stroke="#1E1A4B" stroke-width="2" marker-end="url(#arrowNavy)"/>
<!-- Step 3 — Approve / Reject / Edit -->
<g filter="url(#shadow)">
<rect x="720" y="870" width="240" height="80" rx="8" fill="#ffffff" stroke="#1E1A4B" stroke-width="2"/>
<text x="840" y="895" font-size="13" font-weight="700" fill="#1E1A4B" text-anchor="middle">3 · Approve · Reject · Edit</text>
<text x="840" y="917" font-size="11" fill="#333333" text-anchor="middle">per-row or batch action</text>
<text x="840" y="933" font-size="11" fill="#333333" text-anchor="middle">comment captured</text>
</g>
<line x1="962" y1="910" x2="998" y2="910" stroke="#1E1A4B" stroke-width="2" marker-end="url(#arrowNavy)"/>
<!-- Step 4 — Promote -->
<g filter="url(#shadow)">
<rect x="1000" y="870" width="280" height="80" rx="8" fill="#ffffff" stroke="#10B981" stroke-width="3"/>
<text x="1140" y="893" font-size="13" font-weight="700" fill="#10B981" text-anchor="middle">4 · Promote to LIVE</text>
<text x="1140" y="914" font-size="11" fill="#1E1A4B" text-anchor="middle" font-family="Consolas, monospace">migrated = 1 + review_at + user_id_review</text>
<text x="1140" y="930" font-size="11" fill="#10B981" text-anchor="middle" font-weight="700">→ spatial.lu_parcels</text>
</g>
<!-- Polling arrow back to client -->
<path d="M 1140 955 C 1140 990, 1500 1010, 1500 470" stroke="#10B981" stroke-width="2" stroke-dasharray="6,4" fill="none" marker-end="url(#arrowGreen)"/>
<text x="1290" y="995" font-size="11" fill="#10B981" text-anchor="middle" font-weight="700">PWA polls GET /import_status.php</text>
<text x="1290" y="1010" font-size="11" fill="#10B981" text-anchor="middle" font-weight="700">→ badge flips: ✓ submitted → ✓ live</text>
<!-- ============================================================
Footer band
============================================================ -->
<rect x="20" y="1055" width="1560" height="36" rx="6" fill="#1E1A4B"/>
<text x="40" y="1078" font-size="12" fill="#ffffff">
Audit trail is intrinsic to the model: every row in lu_parcels_upload_tmp records user_id_upload + user_id_review + timestamps — no separate audit table required.
</text>
<text x="1560" y="1078" font-size="11" fill="#CADCFC" text-anchor="end" font-style="italic">
LUPMIS2 · Import &amp; Upload Design Rev. 2
</text>
</svg>

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

17
dist/.htaccess vendored
View File

@ -28,3 +28,20 @@ DirectoryIndex index.php index.html
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]
</IfModule>
# Iframe-policy override for the embed endpoint. Some Apache deployments set
# `X-Frame-Options: SAMEORIGIN` as a default security header for every
# response — that prevents `permits.lupmis4luspa.org` from framing
# `pwa.lupmis4luspa.org/embed`, even though our Content-Security-Policy
# `frame-ancestors` directive explicitly allows it. Safari prefers
# `X-Frame-Options` when both are present, so we have to remove it.
#
# We unset it ONLY for embed.php (so index.php still inherits the
# site-wide SAMEORIGIN protection against clickjacking). embed.php's own
# `Content-Security-Policy: frame-ancestors` header (set in PHP) is then
# the sole iframe-policy header and permits the configured embedder.
<IfModule mod_headers.c>
<Files "embed.php">
Header always unset X-Frame-Options
</Files>
</IfModule>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/index-DRlPLJxg.js.map vendored Normal file

File diff suppressed because one or more lines are too long

86
dist/embed.php vendored
View File

@ -20,6 +20,15 @@
*/
session_start();
// Strip any inherited X-Frame-Options header so CSP frame-ancestors (set
// below) is the only iframe-policy header in the response. Apache often
// sets `X-Frame-Options: SAMEORIGIN` as a default security header for
// every response; without this removal Safari (and other browsers) honour
// X-Frame-Options over CSP frame-ancestors, blocking the legitimate
// embedder. .htaccess also unsets it at the Apache level — this PHP call
// is defence in depth in case mod_headers is unavailable.
header_remove('X-Frame-Options');
// ────────────────────────────────────────────────────────────────────────────
// SSO authentication — validate the cookie if we don't already have a session
// (same logic as index.php)
@ -60,12 +69,83 @@ if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
}
// ────────────────────────────────────────────────────────────────────────────
// Production access guard — same rule as index.php
// Production access guard — render an in-iframe "Sign in required" page
// instead of a 302 redirect.
//
// Why not redirect like index.php does? A 302 inside an <iframe> reloads
// the IFRAME, not the parent window — and the LUSPA login page typically
// refuses to be framed (X-Frame-Options), so the embedder ends up with a
// blank / "refused to connect" iframe with no clear way for the user to
// authenticate. The HTML page below uses target="_top" on its sign-in
// button so the click navigates the parent window to the SSO portal,
// preserving the user's path back to the permits page after login.
// ────────────────────────────────────────────────────────────────────────────
$host = $_SERVER['HTTP_HOST'] ?? '';
$isProduction = (bool) preg_match('/(^|\.)lupmis4luspa\.org$/i', $host);
if ($isProduction && !isset($_SESSION['user_id'])) {
header('Location: https://lupmis4luspa.org/', true, 302);
// CSP / frame-ancestors must still match the embed's policy so the
// permits page can display this auth-required page in the iframe.
$EMBED_ALLOWED_PARENTS = [
'https://lupmis4luspa.org',
];
$frameAncestors = "'self' " . implode(' ', $EMBED_ALLOWED_PARENTS);
header("Content-Security-Policy: frame-ancestors {$frameAncestors}");
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: no-store, must-revalidate');
header('X-Embed-Auth-Status: required'); // diagnostic for the permits team
http_response_code(401);
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LUPMIS2 Sign in required</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
margin: 0; padding: 16px;
background: #f8fafc;
color: #1e1a4b;
display: flex; align-items: center; justify-content: center;
min-height: 100vh; min-height: 100svh;
box-sizing: border-box;
}
.card {
max-width: 420px; width: 100%;
padding: 32px 28px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
text-align: center;
}
.icon { font-size: 38px; line-height: 1; margin-bottom: 10px; }
h1 { margin: 0 0 12px; font-size: 1.25rem; color: #1e1a4b; }
p { margin: 0 0 16px; color: #4b5563; line-height: 1.5; }
a.cta {
display: inline-block; margin-top: 4px;
padding: 10px 18px; border-radius: 8px;
background: #005eb8; color: #fff;
text-decoration: none; font-weight: 600;
}
a.cta:hover { background: #004a92; }
.hint { font-size: 0.85rem; color: #6b7280; margin-top: 16px; }
</style>
</head>
<body>
<div class="card">
<div class="icon" aria-hidden="true">🔒</div>
<h1>Sign in to view the map</h1>
<p>The LUPMIS2 map can only be loaded after you sign in to the LUSPA portal.</p>
<a href="https://lupmis4luspa.org/" target="_top" class="cta" rel="noopener">
Open LUSPA sign-in
</a>
<p class="hint">After signing in, return to this page and refresh to load the map.</p>
</div>
</body>
</html>
<?php
exit;
}
@ -142,7 +222,7 @@ $html = preg_replace('/<head\b[^>]*>/i', '$0' . "\n " . $inject, $html, 1);
// the real permitting host is confirmed. NEVER use `frame-ancestors *`.
// ────────────────────────────────────────────────────────────────────────────
$EMBED_ALLOWED_PARENTS = [
'https://permits.lupmis4luspa.org',
'https://lupmis4luspa.org',
// Add local dev parents here if needed, e.g. 'http://localhost:8000'.
];
$frameAncestors = "'self' " . implode(' ', $EMBED_ALLOWED_PARENTS);

25
dist/index.html vendored
View File

@ -1598,7 +1598,7 @@
}
}
</style>
<script type="module" crossorigin src="/assets/index-DR_U08k-.js"></script>
<script type="module" crossorigin src="/assets/index-DRlPLJxg.js"></script>
<link rel="modulepreload" crossorigin href="/assets/openlayers-D8ReJJOp.js">
<link rel="modulepreload" crossorigin href="/assets/bootstrap-D1-uvFxm.js">
<link rel="modulepreload" crossorigin href="/assets/ol-ext-P1ircg-B.js">
@ -1716,7 +1716,7 @@
</button>
<button class="dock-btn" type="button" id="dock-btn-draw" title="Draw / Edit Features">
<span>✏️</span>
<span class="dock-btn-label">Draw</span>
<span class="dock-btn-label">Digitise</span>
</button>
<button class="dock-btn" type="button" id="dock-btn-clear" title="Clear Measurements">
<span>🗑️</span>
@ -1870,6 +1870,27 @@
</div>
</div>
<!-- Import spinner — shown while a dropped file is being parsed and staged
(Shapefile decompression in particular can take several seconds).
Hidden once the mapping modal opens or an error occurs. -->
<div id="import-spinner-overlay" class="d-none position-fixed top-0 start-0 w-100 h-100"
style="z-index: 1080; background: rgba(255,255,255,0.85);
align-items: center; justify-content: center;"
role="status" aria-live="polite">
<div class="text-center"
style="background:#fff;border:1px solid #e5e7eb;border-radius:12px;
padding:28px 36px;box-shadow:0 8px 24px rgba(0,0,0,0.08);">
<div class="spinner-border text-primary mb-3"
style="width:3rem;height:3rem;" aria-hidden="true"></div>
<div class="fw-bold" style="color:var(--primary,#1e1a4b);font-size:1rem;">
Parsing imported file…
</div>
<div class="text-muted mt-1" style="font-size:0.85rem;">
<span id="import-spinner-filename"></span>
</div>
</div>
</div>
<!-- Import-mapping modal — shown after a file is dropped on the map.
Wired up in src/import-modal.js. Lets the user pick a target type
and map source fields to LUPMIS2 columns; on save the staging row

View File

@ -1707,7 +1707,7 @@
</button>
<button class="dock-btn" type="button" id="dock-btn-draw" title="Draw / Edit Features">
<span>✏️</span>
<span class="dock-btn-label">Draw</span>
<span class="dock-btn-label">Digitise</span>
</button>
<button class="dock-btn" type="button" id="dock-btn-clear" title="Clear Measurements">
<span>🗑️</span>
@ -1861,6 +1861,27 @@
</div>
</div>
<!-- Import spinner — shown while a dropped file is being parsed and staged
(Shapefile decompression in particular can take several seconds).
Hidden once the mapping modal opens or an error occurs. -->
<div id="import-spinner-overlay" class="d-none position-fixed top-0 start-0 w-100 h-100"
style="z-index: 1080; background: rgba(255,255,255,0.85);
align-items: center; justify-content: center;"
role="status" aria-live="polite">
<div class="text-center"
style="background:#fff;border:1px solid #e5e7eb;border-radius:12px;
padding:28px 36px;box-shadow:0 8px 24px rgba(0,0,0,0.08);">
<div class="spinner-border text-primary mb-3"
style="width:3rem;height:3rem;" aria-hidden="true"></div>
<div class="fw-bold" style="color:var(--primary,#1e1a4b);font-size:1rem;">
Parsing imported file…
</div>
<div class="text-muted mt-1" style="font-size:0.85rem;">
<span id="import-spinner-filename"></span>
</div>
</div>
</div>
<!-- Import-mapping modal — shown after a file is dropped on the map.
Wired up in src/import-modal.js. Lets the user pick a target type
and map source fields to LUPMIS2 columns; on save the staging row

96
main.js
View File

@ -38,6 +38,8 @@ import {
getExternalImport,
getExternalImportFeatures,
remapImportedFeatureProperties,
updateExternalImportFeatureGeometry,
deleteExternalImportFeature,
saveParcels,
getLocalParcels,
updateParcel,
@ -104,6 +106,24 @@ import { createEmbedBridge } from './src/embed-bridge.js';
import { openImportMappingModal } from './src/import-modal.js';
import { applyFieldMapping } from './src/import-detect.js';
// ----- Import-spinner helpers ---------------------------------------------
// Shown between "user dropped a file" and "mapping modal opens" — Shapefile
// zip decompression in particular can take several seconds on big files.
function showImportSpinner(filename) {
const overlay = document.getElementById('import-spinner-overlay');
const nameEl = document.getElementById('import-spinner-filename');
if (!overlay) return;
if (nameEl) nameEl.textContent = filename || '';
overlay.classList.remove('d-none');
overlay.classList.add('d-flex');
}
function hideImportSpinner() {
const overlay = document.getElementById('import-spinner-overlay');
if (!overlay) return;
overlay.classList.add('d-none');
overlay.classList.remove('d-flex');
}
// GIS export from the analysis popups (Area / Circle)
import { openExportGisModal } from './src/export-gis-modal.js';
@ -415,6 +435,29 @@ async function initApp() {
}
});
// When the user modifies an imported feature with the EditBar (Draw mode +
// Select + drag a vertex/edge), persist the new geometry back to the
// matching external_import_features row so the next upload reflects it.
// Non-imported features (drawn parcels, server parcels, etc.) carry no
// _externalImportId / _clientUuid tags, so this is a quick no-op for them.
mapView.onFeatureModified(async (feature) => {
const importId = feature.get('_externalImportId');
const clientUuid = feature.get('_clientUuid');
if (importId == null || !clientUuid) return;
try {
const wkt = wktFormat.writeGeometry(feature.getGeometry(), {
dataProjection: 'EPSG:4326',
featureProjection: 'EPSG:3857',
});
await updateExternalImportFeatureGeometry(clientUuid, wkt);
console.log('[App] Imported feature geometry updated in staging:', clientUuid);
} catch (error) {
console.warn('[App] Failed to persist imported-feature edit:', error);
showError('Could not save the edit locally: ' + error.message);
}
});
// 3. Initialize database
try {
console.log('[App] Initializing database...');
@ -2345,6 +2388,7 @@ function addImportedGeoJSON(geojsonInput, fallbackName, tag) {
}
if (totalFeatures === 0) {
hideImportSpinner();
showFileImportError('No features found in the file.');
return;
}
@ -2393,7 +2437,7 @@ function geometryToWkt4326(geometry) {
*/
async function stageImport(fc, displayName, layer) {
const featureCount = fc?.features?.length ?? 0;
if (featureCount === 0) return;
if (featureCount === 0) { hideImportSpinner(); return; }
// ── 1. Create the staging row + features ────────────────────────────────
const { id: importId } = await createExternalImport({
@ -2403,18 +2447,43 @@ async function stageImport(fc, displayName, layer) {
});
layer.set('_externalImportId', importId);
// Convert each OL feature to (WKT 4326 + raw source properties).
// Convert each OL feature to (WKT 4326 + raw source properties). We generate
// a client_uuid up-front and tag the OL feature with it; that's how later
// edits (geometry modifications, deletions) can find the matching staging
// row in external_import_features.
const olFeatures = layer.getSource().getFeatures();
const stagedRows = olFeatures.map((f) => {
const geom = f.getGeometry();
const cuuid = newClientUuid();
f.set('_externalImportId', importId);
f.set('_clientUuid', cuuid);
return {
client_uuid: cuuid,
geometry_wkt: geom ? geometryToWkt4326(geom) : '',
properties: stripGeometryFromProps(f.getProperties()),
};
});
await addExternalImportFeatures(importId, stagedRows);
// ── 1b. When a tagged feature is removed (EditBar's Delete in Draw mode),
// tear down the matching staging row so the next upload doesn't
// re-send a deleted feature. The listener stays on the layer for
// the lifetime of the imported layer.
layer.getSource().on('removefeature', async (e) => {
const f = e.feature;
const cuuid = f?.get('_clientUuid');
const impId = f?.get('_externalImportId');
if (!cuuid || impId == null) return;
try {
await deleteExternalImportFeature(cuuid);
console.log('[FileImport] Removed feature from staging:', cuuid);
} catch (err) {
console.warn('[FileImport] Failed to remove staging row:', err);
}
});
// ── 2. Open the mapping modal ───────────────────────────────────────────
hideImportSpinner();
openImportMappingModal({
importId,
filename: displayName,
@ -2430,6 +2499,17 @@ async function stageImport(fc, displayName, layer) {
});
}
/** Generate a per-feature client_uuid. Uses crypto.randomUUID where available. */
function newClientUuid() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
});
}
/** OL Feature#getProperties() includes the geometry; we don't want it as JSON. */
function stripGeometryFromProps(props) {
const out = {};
@ -2692,6 +2772,9 @@ async function handleShapefileImport(evt) {
return;
}
// Show the import spinner — hidden again either when the mapping modal opens
// (see stageImport) or by the catch-block below on error.
showImportSpinner(files[0]?.name || 'shapefile');
try {
let geojson;
let displayName;
@ -2710,6 +2793,7 @@ async function handleShapefileImport(evt) {
const required = ['dbf', 'shx', 'prj'];
const missing = required.filter(ext => !byExt[ext]);
if (missing.length > 0) {
hideImportSpinner();
showFileImportError('Missing required file(s): ' + missing.map(e => '.' + e).join(', ')
+ '. Please select .shp, .dbf, .shx and .prj together.');
evt.target.value = '';
@ -2730,6 +2814,7 @@ async function handleShapefileImport(evt) {
geojson = await shp(shpObj);
} else {
hideImportSpinner();
showFileImportError('Please select a .zip or at least a .shp file.');
evt.target.value = '';
return;
@ -2737,6 +2822,7 @@ async function handleShapefileImport(evt) {
addImportedGeoJSON(geojson, displayName, 'ShpImport');
} catch (error) {
hideImportSpinner();
console.error('[ShpImport] Failed:', error);
showFileImportError('Failed to parse shapefile: ' + error.message);
}
@ -2765,6 +2851,7 @@ async function handleGeoJSONImport(evt) {
return;
}
showImportSpinner(file.name);
try {
const text = await file.text();
console.log('[GeoJSONImport] Parsing', file.name, '(' + (file.size / 1024).toFixed(1) + ' KB)');
@ -2781,6 +2868,7 @@ async function handleGeoJSONImport(evt) {
// Bare geometry object
fc = { type: 'FeatureCollection', features: [{ type: 'Feature', geometry: parsed, properties: {} }] };
} else {
hideImportSpinner();
showFileImportError('The file does not contain valid GeoJSON.');
evt.target.value = '';
return;
@ -2789,6 +2877,7 @@ async function handleGeoJSONImport(evt) {
const displayName = file.name.replace(/\.(geo)?json$/i, '');
addImportedGeoJSON(fc, displayName, 'GeoJSONImport');
} catch (error) {
hideImportSpinner();
console.error('[GeoJSONImport] Failed:', error);
const sizeMB = (file.size / (1024 * 1024)).toFixed(1);
showFileImportError(
@ -2817,6 +2906,7 @@ async function handleKMLImport(evt) {
return;
}
showImportSpinner(file.name);
try {
const text = await file.text();
console.log('[KMLImport] Parsing', file.name, '(' + (file.size / 1024).toFixed(1) + ' KB)');
@ -2827,6 +2917,7 @@ async function handleKMLImport(evt) {
});
if (!features || features.length === 0) {
hideImportSpinner();
showFileImportError('No features found in the KML file.');
evt.target.value = '';
return;
@ -2842,6 +2933,7 @@ async function handleKMLImport(evt) {
const displayName = file.name.replace(/\.kml$/i, '');
addImportedGeoJSON(fc, displayName, 'KMLImport');
} catch (error) {
hideImportSpinner();
console.error('[KMLImport] Failed:', error);
const sizeMB = (file.size / (1024 * 1024)).toFixed(1);
showFileImportError(

View File

@ -28,3 +28,20 @@ DirectoryIndex index.php index.html
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]
</IfModule>
# Iframe-policy override for the embed endpoint. Some Apache deployments set
# `X-Frame-Options: SAMEORIGIN` as a default security header for every
# response — that prevents `permits.lupmis4luspa.org` from framing
# `pwa.lupmis4luspa.org/embed`, even though our Content-Security-Policy
# `frame-ancestors` directive explicitly allows it. Safari prefers
# `X-Frame-Options` when both are present, so we have to remove it.
#
# We unset it ONLY for embed.php (so index.php still inherits the
# site-wide SAMEORIGIN protection against clickjacking). embed.php's own
# `Content-Security-Policy: frame-ancestors` header (set in PHP) is then
# the sole iframe-policy header and permits the configured embedder.
<IfModule mod_headers.c>
<Files "embed.php">
Header always unset X-Frame-Options
</Files>
</IfModule>

View File

@ -20,6 +20,15 @@
*/
session_start();
// Strip any inherited X-Frame-Options header so CSP frame-ancestors (set
// below) is the only iframe-policy header in the response. Apache often
// sets `X-Frame-Options: SAMEORIGIN` as a default security header for
// every response; without this removal Safari (and other browsers) honour
// X-Frame-Options over CSP frame-ancestors, blocking the legitimate
// embedder. .htaccess also unsets it at the Apache level — this PHP call
// is defence in depth in case mod_headers is unavailable.
header_remove('X-Frame-Options');
// ────────────────────────────────────────────────────────────────────────────
// SSO authentication — validate the cookie if we don't already have a session
// (same logic as index.php)
@ -60,12 +69,83 @@ if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
}
// ────────────────────────────────────────────────────────────────────────────
// Production access guard — same rule as index.php
// Production access guard — render an in-iframe "Sign in required" page
// instead of a 302 redirect.
//
// Why not redirect like index.php does? A 302 inside an <iframe> reloads
// the IFRAME, not the parent window — and the LUSPA login page typically
// refuses to be framed (X-Frame-Options), so the embedder ends up with a
// blank / "refused to connect" iframe with no clear way for the user to
// authenticate. The HTML page below uses target="_top" on its sign-in
// button so the click navigates the parent window to the SSO portal,
// preserving the user's path back to the permits page after login.
// ────────────────────────────────────────────────────────────────────────────
$host = $_SERVER['HTTP_HOST'] ?? '';
$isProduction = (bool) preg_match('/(^|\.)lupmis4luspa\.org$/i', $host);
if ($isProduction && !isset($_SESSION['user_id'])) {
header('Location: https://lupmis4luspa.org/', true, 302);
// CSP / frame-ancestors must still match the embed's policy so the
// permits page can display this auth-required page in the iframe.
$EMBED_ALLOWED_PARENTS = [
'https://lupmis4luspa.org',
];
$frameAncestors = "'self' " . implode(' ', $EMBED_ALLOWED_PARENTS);
header("Content-Security-Policy: frame-ancestors {$frameAncestors}");
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: no-store, must-revalidate');
header('X-Embed-Auth-Status: required'); // diagnostic for the permits team
http_response_code(401);
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LUPMIS2 Sign in required</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
margin: 0; padding: 16px;
background: #f8fafc;
color: #1e1a4b;
display: flex; align-items: center; justify-content: center;
min-height: 100vh; min-height: 100svh;
box-sizing: border-box;
}
.card {
max-width: 420px; width: 100%;
padding: 32px 28px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
text-align: center;
}
.icon { font-size: 38px; line-height: 1; margin-bottom: 10px; }
h1 { margin: 0 0 12px; font-size: 1.25rem; color: #1e1a4b; }
p { margin: 0 0 16px; color: #4b5563; line-height: 1.5; }
a.cta {
display: inline-block; margin-top: 4px;
padding: 10px 18px; border-radius: 8px;
background: #005eb8; color: #fff;
text-decoration: none; font-weight: 600;
}
a.cta:hover { background: #004a92; }
.hint { font-size: 0.85rem; color: #6b7280; margin-top: 16px; }
</style>
</head>
<body>
<div class="card">
<div class="icon" aria-hidden="true">🔒</div>
<h1>Sign in to view the map</h1>
<p>The LUPMIS2 map can only be loaded after you sign in to the LUSPA portal.</p>
<a href="https://lupmis4luspa.org/" target="_top" class="cta" rel="noopener">
Open LUSPA sign-in
</a>
<p class="hint">After signing in, return to this page and refresh to load the map.</p>
</div>
</body>
</html>
<?php
exit;
}
@ -142,7 +222,7 @@ $html = preg_replace('/<head\b[^>]*>/i', '$0' . "\n " . $inject, $html, 1);
// the real permitting host is confirmed. NEVER use `frame-ancestors *`.
// ────────────────────────────────────────────────────────────────────────────
$EMBED_ALLOWED_PARENTS = [
'https://permits.lupmis4luspa.org',
'https://lupmis4luspa.org',
// Add local dev parents here if needed, e.g. 'http://localhost:8000'.
];
$frameAncestors = "'self' " . implode(' ', $EMBED_ALLOWED_PARENTS);

View File

@ -396,6 +396,20 @@ export class MapView {
});
this._modifyInteraction.setActive(false);
// 3b. Fire onFeatureModified callbacks when a modification completes.
// Consumers attach via onFeatureModified() and decide for themselves
// whether to react (e.g. the staged-import code persists geometry
// changes back to external_import_features).
this._modifyInteraction.on('modifyend', (evt) => {
if (!this._featureModifiedCallbacks?.length) return;
const features = evt.features?.getArray?.() || [];
for (const f of features) {
for (const cb of this._featureModifiedCallbacks) {
try { cb(f); } catch (err) { console.warn('[MapView] onFeatureModified callback failed:', err); }
}
}
});
// 4. UndoRedo interaction — watches the drawings source
this._undoRedo = new UndoRedo();
this.map.addInteraction(this._undoRedo);
@ -2175,6 +2189,20 @@ export class MapView {
this._drawnPolygonCallbacks.push(callback);
}
/**
* Register a callback fired after the user finishes modifying a feature
* with the EditBar Modify interaction. Callback receives the OL feature
* whose geometry just changed. Consumers (e.g. the import-staging code in
* main.js) inspect the feature's tags (_externalImportId / _clientUuid)
* to decide whether to react. Multiple callbacks are supported.
*
* @param {Function} callback - (feature) => void | Promise<void>
*/
onFeatureModified(callback) {
if (!this._featureModifiedCallbacks) this._featureModifiedCallbacks = [];
this._featureModifiedCallbacks.push(callback);
}
/**
* Register a double-click callback.
* Callback receives (lon, lat, feature, event).

View File

@ -939,6 +939,76 @@ export async function remapImportedFeatureProperties(importId, remap) {
}
}
/**
* Update a single staged feature's geometry (and properties, optionally).
* Used when the user modifies an imported feature on the map (the EditBar
* Modify interaction fires `modifyend`) we write the new WKT back so the
* next upload re-sends the corrected geometry.
*
* @param {string} clientUuid - the per-feature idempotency key on the OL feature
* @param {string} geometryWkt - new geometry in EPSG:4326 WKT
* @param {Object} [properties] - optional new properties (if omitted, leave as-is)
*/
export async function updateExternalImportFeatureGeometry(clientUuid, geometryWkt, properties) {
try {
if (properties !== undefined) {
const propsJson = JSON.stringify(properties);
await sql`
UPDATE external_import_features
SET geometry_wkt = ${geometryWkt}, properties_json = ${propsJson}
WHERE client_uuid = ${clientUuid}
`;
} else {
await sql`
UPDATE external_import_features
SET geometry_wkt = ${geometryWkt}
WHERE client_uuid = ${clientUuid}
`;
}
broadcastChange('external_import_features', 'UPDATE', clientUuid);
} catch (error) {
console.error('[Database] ✗ Failed to update import feature geometry:', error);
throw error;
}
}
/**
* Delete a single staged feature by client_uuid, and decrement the parent
* import's feature_count. Used when the user deletes an imported feature on
* the map (EditBar Delete).
*
* @param {string} clientUuid
*/
export async function deleteExternalImportFeature(clientUuid) {
try {
// Look up parent import_id first so we can decrement its count atomically.
const row = await sql`
SELECT import_id FROM external_import_features WHERE client_uuid = ${clientUuid}
`;
if (row.length === 0) return; // already gone
const importId = row[0].import_id;
await sql`BEGIN`;
try {
await sql`DELETE FROM external_import_features WHERE client_uuid = ${clientUuid}`;
await sql`
UPDATE external_imports
SET feature_count = MAX(0, feature_count - 1)
WHERE id = ${importId}
`;
await sql`COMMIT`;
} catch (e) {
try { await sql`ROLLBACK`; } catch { /* */ }
throw e;
}
broadcastChange('external_import_features', 'DELETE', clientUuid);
broadcastChange('external_imports', 'UPDATE', importId);
} catch (error) {
console.error('[Database] ✗ Failed to delete import feature:', error);
throw error;
}
}
/** Delete a staged import (and its features via CASCADE). */
export async function deleteExternalImport(importId) {
try {
@ -1631,6 +1701,8 @@ export default {
listExternalImports,
getExternalImportFeatures,
remapImportedFeatureProperties,
updateExternalImportFeatureGeometry,
deleteExternalImportFeature,
deleteExternalImport,
saveParcels,
getLocalParcels,

View File

@ -248,3 +248,40 @@ export function listSourceFields(fc) {
}
return Array.from(seen.keys());
}
/**
* For each source-property name in the FeatureCollection, return one sample
* value drawn from the first feature that has a non-empty value for it.
* Used by the mapping modal to show users what each attribute actually
* contains e.g. dropdown options become `upn [12345-6789]` rather than
* just `upn`. Empty / null / undefined values are skipped while scanning;
* arrays and objects are JSON-stringified so something readable surfaces.
*
* @param {Object} fc parsed FeatureCollection
* @param {number} [maxLen] max sample length (truncated with ``)
* @returns {Object<string,string>} { [sourceField]: sample }
*/
export function sampleSourceValues(fc, maxLen = 35) {
const samples = {};
for (const feature of fc.features || []) {
const props = feature?.properties;
if (!props || typeof props !== 'object') continue;
for (const [key, value] of Object.entries(props)) {
if (samples[key] !== undefined) continue; // already have one
if (value === null || value === undefined || value === '') continue;
let str;
if (typeof value === 'object') {
try { str = JSON.stringify(value); } catch { str = String(value); }
} else {
str = String(value);
}
// Collapse whitespace so a multi-line cell value still renders inline.
str = str.replace(/\s+/g, ' ').trim();
if (!str) continue;
if (str.length > maxLen) str = str.slice(0, maxLen - 1) + '…';
samples[key] = str;
}
}
return samples;
}

View File

@ -23,6 +23,7 @@ import {
detectTargetType,
autoMapFields,
listSourceFields,
sampleSourceValues,
} from './import-detect.js';
const els = {}; // cached DOM lookups
@ -89,13 +90,29 @@ function renderFieldsTable() {
}
els.fieldsWrap.style.display = '';
// Each dropdown option shows the source field name AND a one-feature
// sample value so the user can recognise the attribute by its content,
// not just its name. Sample is computed once in state.sourceSamples; see
// sampleSourceValues() in import-detect.js.
//
// The field name is rendered with Unicode mathematical-bold characters
// (U+1D400 / U+1D41A / U+1D7CE blocks) because HTML / CSS bold doesn't
// render inside <option> elements — browsers strip markup and ignore
// font-weight on options. Unicode bold works cross-browser without
// HTML and gives an unmistakable visual distinction from the sample.
const sampleSuffix = (s) => {
const v = state.sourceSamples[s];
return v ? ` — [${escapeHtml(v)}]` : ' — [(empty)]';
};
const optionsHtml = ['<option value="">(none)</option>']
.concat(state.sourceFields.map((s) =>
`<option value="${escapeAttr(s)}">${escapeHtml(s)}</option>`))
`<option value="${escapeAttr(s)}">${escapeHtml(toBoldUnicode(s))}${sampleSuffix(s)}</option>`))
.join('');
els.tbody.innerHTML = columns.map((col) => {
const current = state.mapping[col] || '';
// Mark the matching option as selected. The opening `<option value="…">`
// is unique per field, so prefix-replace is safe regardless of label text.
const select = optionsHtml.replace(
`<option value="${escapeAttr(current)}">`,
`<option value="${escapeAttr(current)}" selected>`
@ -191,6 +208,7 @@ export function openImportMappingModal(opts) {
filename: opts.filename,
fc,
sourceFields: listSourceFields(fc),
sourceSamples: sampleSourceValues(fc),
targetType,
mapping: autoMapFields(fc, targetType),
onResult: opts.onResult,
@ -219,3 +237,27 @@ function escapeHtml(s) {
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeAttr(s) { return escapeHtml(s); }
/**
* Map ASCII letters and digits to their Unicode mathematical-bold equivalents
* (U+1D400 / U+1D41A / U+1D7CE blocks). Used to render source-attribute names
* inside <option> labels with visible weight, since HTML/CSS bold is ignored
* by browsers inside <select> dropdowns. Characters outside [A-Za-z0-9] pass
* through unchanged so underscores, hyphens, and punctuation stay readable.
*/
function toBoldUnicode(s) {
let out = '';
for (const ch of String(s)) {
const cp = ch.codePointAt(0);
if (cp >= 0x61 && cp <= 0x7A) { // a-z
out += String.fromCodePoint(cp - 0x61 + 0x1D41A);
} else if (cp >= 0x41 && cp <= 0x5A) { // A-Z
out += String.fromCodePoint(cp - 0x41 + 0x1D400);
} else if (cp >= 0x30 && cp <= 0x39) { // 0-9
out += String.fromCodePoint(cp - 0x30 + 0x1D7CE);
} else {
out += ch;
}
}
return out;
}