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:
parent
26d4f6235f
commit
203ca5bc4d
BIN
LUPMIS2_Improvements_Mar_to_Jun_2026.docx
Normal file
BIN
LUPMIS2_Improvements_Mar_to_Jun_2026.docx
Normal file
Binary file not shown.
BIN
LUPMIS2_Staged_Upload_Flow.png
Normal file
BIN
LUPMIS2_Staged_Upload_Flow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 433 KiB |
265
LUPMIS2_Staged_Upload_Flow.svg
Normal file
265
LUPMIS2_Staged_Upload_Flow.svg
Normal 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_<target_type>.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 & Upload Design Rev. 2
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 18 KiB |
BIN
LUPMIS2_Workshop_Mar_to_Jun_2026.pptx
Normal file
BIN
LUPMIS2_Workshop_Mar_to_Jun_2026.pptx
Normal file
Binary file not shown.
17
dist/.htaccess
vendored
17
dist/.htaccess
vendored
@ -28,3 +28,20 @@ DirectoryIndex index.php index.html
|
|||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
RewriteRule ^ index.php [L]
|
RewriteRule ^ index.php [L]
|
||||||
</IfModule>
|
</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>
|
||||||
|
|||||||
1
dist/assets/index-DR_U08k-.js.map
vendored
1
dist/assets/index-DR_U08k-.js.map
vendored
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
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
86
dist/embed.php
vendored
@ -20,6 +20,15 @@
|
|||||||
*/
|
*/
|
||||||
session_start();
|
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
|
// SSO authentication — validate the cookie if we don't already have a session
|
||||||
// (same logic as index.php)
|
// (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'] ?? '';
|
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||||||
$isProduction = (bool) preg_match('/(^|\.)lupmis4luspa\.org$/i', $host);
|
$isProduction = (bool) preg_match('/(^|\.)lupmis4luspa\.org$/i', $host);
|
||||||
if ($isProduction && !isset($_SESSION['user_id'])) {
|
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;
|
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 *`.
|
// the real permitting host is confirmed. NEVER use `frame-ancestors *`.
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
$EMBED_ALLOWED_PARENTS = [
|
$EMBED_ALLOWED_PARENTS = [
|
||||||
'https://permits.lupmis4luspa.org',
|
'https://lupmis4luspa.org',
|
||||||
// Add local dev parents here if needed, e.g. 'http://localhost:8000'.
|
// Add local dev parents here if needed, e.g. 'http://localhost:8000'.
|
||||||
];
|
];
|
||||||
$frameAncestors = "'self' " . implode(' ', $EMBED_ALLOWED_PARENTS);
|
$frameAncestors = "'self' " . implode(' ', $EMBED_ALLOWED_PARENTS);
|
||||||
|
|||||||
25
dist/index.html
vendored
25
dist/index.html
vendored
@ -1598,7 +1598,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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/openlayers-D8ReJJOp.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/bootstrap-D1-uvFxm.js">
|
<link rel="modulepreload" crossorigin href="/assets/bootstrap-D1-uvFxm.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/ol-ext-P1ircg-B.js">
|
<link rel="modulepreload" crossorigin href="/assets/ol-ext-P1ircg-B.js">
|
||||||
@ -1716,7 +1716,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="dock-btn" type="button" id="dock-btn-draw" title="Draw / Edit Features">
|
<button class="dock-btn" type="button" id="dock-btn-draw" title="Draw / Edit Features">
|
||||||
<span>✏️</span>
|
<span>✏️</span>
|
||||||
<span class="dock-btn-label">Draw</span>
|
<span class="dock-btn-label">Digitise</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="dock-btn" type="button" id="dock-btn-clear" title="Clear Measurements">
|
<button class="dock-btn" type="button" id="dock-btn-clear" title="Clear Measurements">
|
||||||
<span>🗑️</span>
|
<span>🗑️</span>
|
||||||
@ -1870,6 +1870,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</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.
|
<!-- 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
|
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
|
and map source fields to LUPMIS2 columns; on save the staging row
|
||||||
|
|||||||
23
index.html
23
index.html
@ -1707,7 +1707,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="dock-btn" type="button" id="dock-btn-draw" title="Draw / Edit Features">
|
<button class="dock-btn" type="button" id="dock-btn-draw" title="Draw / Edit Features">
|
||||||
<span>✏️</span>
|
<span>✏️</span>
|
||||||
<span class="dock-btn-label">Draw</span>
|
<span class="dock-btn-label">Digitise</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="dock-btn" type="button" id="dock-btn-clear" title="Clear Measurements">
|
<button class="dock-btn" type="button" id="dock-btn-clear" title="Clear Measurements">
|
||||||
<span>🗑️</span>
|
<span>🗑️</span>
|
||||||
@ -1861,6 +1861,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</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.
|
<!-- 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
|
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
|
and map source fields to LUPMIS2 columns; on save the staging row
|
||||||
|
|||||||
96
main.js
96
main.js
@ -38,6 +38,8 @@ import {
|
|||||||
getExternalImport,
|
getExternalImport,
|
||||||
getExternalImportFeatures,
|
getExternalImportFeatures,
|
||||||
remapImportedFeatureProperties,
|
remapImportedFeatureProperties,
|
||||||
|
updateExternalImportFeatureGeometry,
|
||||||
|
deleteExternalImportFeature,
|
||||||
saveParcels,
|
saveParcels,
|
||||||
getLocalParcels,
|
getLocalParcels,
|
||||||
updateParcel,
|
updateParcel,
|
||||||
@ -104,6 +106,24 @@ import { createEmbedBridge } from './src/embed-bridge.js';
|
|||||||
import { openImportMappingModal } from './src/import-modal.js';
|
import { openImportMappingModal } from './src/import-modal.js';
|
||||||
import { applyFieldMapping } from './src/import-detect.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)
|
// GIS export from the analysis popups (Area / Circle)
|
||||||
import { openExportGisModal } from './src/export-gis-modal.js';
|
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
|
// 3. Initialize database
|
||||||
try {
|
try {
|
||||||
console.log('[App] Initializing database...');
|
console.log('[App] Initializing database...');
|
||||||
@ -2345,6 +2388,7 @@ function addImportedGeoJSON(geojsonInput, fallbackName, tag) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (totalFeatures === 0) {
|
if (totalFeatures === 0) {
|
||||||
|
hideImportSpinner();
|
||||||
showFileImportError('No features found in the file.');
|
showFileImportError('No features found in the file.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -2393,7 +2437,7 @@ function geometryToWkt4326(geometry) {
|
|||||||
*/
|
*/
|
||||||
async function stageImport(fc, displayName, layer) {
|
async function stageImport(fc, displayName, layer) {
|
||||||
const featureCount = fc?.features?.length ?? 0;
|
const featureCount = fc?.features?.length ?? 0;
|
||||||
if (featureCount === 0) return;
|
if (featureCount === 0) { hideImportSpinner(); return; }
|
||||||
|
|
||||||
// ── 1. Create the staging row + features ────────────────────────────────
|
// ── 1. Create the staging row + features ────────────────────────────────
|
||||||
const { id: importId } = await createExternalImport({
|
const { id: importId } = await createExternalImport({
|
||||||
@ -2403,18 +2447,43 @@ async function stageImport(fc, displayName, layer) {
|
|||||||
});
|
});
|
||||||
layer.set('_externalImportId', importId);
|
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 olFeatures = layer.getSource().getFeatures();
|
||||||
const stagedRows = olFeatures.map((f) => {
|
const stagedRows = olFeatures.map((f) => {
|
||||||
const geom = f.getGeometry();
|
const geom = f.getGeometry();
|
||||||
|
const cuuid = newClientUuid();
|
||||||
|
f.set('_externalImportId', importId);
|
||||||
|
f.set('_clientUuid', cuuid);
|
||||||
return {
|
return {
|
||||||
|
client_uuid: cuuid,
|
||||||
geometry_wkt: geom ? geometryToWkt4326(geom) : '',
|
geometry_wkt: geom ? geometryToWkt4326(geom) : '',
|
||||||
properties: stripGeometryFromProps(f.getProperties()),
|
properties: stripGeometryFromProps(f.getProperties()),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
await addExternalImportFeatures(importId, stagedRows);
|
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 ───────────────────────────────────────────
|
// ── 2. Open the mapping modal ───────────────────────────────────────────
|
||||||
|
hideImportSpinner();
|
||||||
openImportMappingModal({
|
openImportMappingModal({
|
||||||
importId,
|
importId,
|
||||||
filename: displayName,
|
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. */
|
/** OL Feature#getProperties() includes the geometry; we don't want it as JSON. */
|
||||||
function stripGeometryFromProps(props) {
|
function stripGeometryFromProps(props) {
|
||||||
const out = {};
|
const out = {};
|
||||||
@ -2692,6 +2772,9 @@ async function handleShapefileImport(evt) {
|
|||||||
return;
|
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 {
|
try {
|
||||||
let geojson;
|
let geojson;
|
||||||
let displayName;
|
let displayName;
|
||||||
@ -2710,6 +2793,7 @@ async function handleShapefileImport(evt) {
|
|||||||
const required = ['dbf', 'shx', 'prj'];
|
const required = ['dbf', 'shx', 'prj'];
|
||||||
const missing = required.filter(ext => !byExt[ext]);
|
const missing = required.filter(ext => !byExt[ext]);
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
|
hideImportSpinner();
|
||||||
showFileImportError('Missing required file(s): ' + missing.map(e => '.' + e).join(', ')
|
showFileImportError('Missing required file(s): ' + missing.map(e => '.' + e).join(', ')
|
||||||
+ '. Please select .shp, .dbf, .shx and .prj together.');
|
+ '. Please select .shp, .dbf, .shx and .prj together.');
|
||||||
evt.target.value = '';
|
evt.target.value = '';
|
||||||
@ -2730,6 +2814,7 @@ async function handleShapefileImport(evt) {
|
|||||||
geojson = await shp(shpObj);
|
geojson = await shp(shpObj);
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
hideImportSpinner();
|
||||||
showFileImportError('Please select a .zip or at least a .shp file.');
|
showFileImportError('Please select a .zip or at least a .shp file.');
|
||||||
evt.target.value = '';
|
evt.target.value = '';
|
||||||
return;
|
return;
|
||||||
@ -2737,6 +2822,7 @@ async function handleShapefileImport(evt) {
|
|||||||
|
|
||||||
addImportedGeoJSON(geojson, displayName, 'ShpImport');
|
addImportedGeoJSON(geojson, displayName, 'ShpImport');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
hideImportSpinner();
|
||||||
console.error('[ShpImport] Failed:', error);
|
console.error('[ShpImport] Failed:', error);
|
||||||
showFileImportError('Failed to parse shapefile: ' + error.message);
|
showFileImportError('Failed to parse shapefile: ' + error.message);
|
||||||
}
|
}
|
||||||
@ -2765,6 +2851,7 @@ async function handleGeoJSONImport(evt) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showImportSpinner(file.name);
|
||||||
try {
|
try {
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
console.log('[GeoJSONImport] Parsing', file.name, '(' + (file.size / 1024).toFixed(1) + ' KB)');
|
console.log('[GeoJSONImport] Parsing', file.name, '(' + (file.size / 1024).toFixed(1) + ' KB)');
|
||||||
@ -2781,6 +2868,7 @@ async function handleGeoJSONImport(evt) {
|
|||||||
// Bare geometry object
|
// Bare geometry object
|
||||||
fc = { type: 'FeatureCollection', features: [{ type: 'Feature', geometry: parsed, properties: {} }] };
|
fc = { type: 'FeatureCollection', features: [{ type: 'Feature', geometry: parsed, properties: {} }] };
|
||||||
} else {
|
} else {
|
||||||
|
hideImportSpinner();
|
||||||
showFileImportError('The file does not contain valid GeoJSON.');
|
showFileImportError('The file does not contain valid GeoJSON.');
|
||||||
evt.target.value = '';
|
evt.target.value = '';
|
||||||
return;
|
return;
|
||||||
@ -2789,6 +2877,7 @@ async function handleGeoJSONImport(evt) {
|
|||||||
const displayName = file.name.replace(/\.(geo)?json$/i, '');
|
const displayName = file.name.replace(/\.(geo)?json$/i, '');
|
||||||
addImportedGeoJSON(fc, displayName, 'GeoJSONImport');
|
addImportedGeoJSON(fc, displayName, 'GeoJSONImport');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
hideImportSpinner();
|
||||||
console.error('[GeoJSONImport] Failed:', error);
|
console.error('[GeoJSONImport] Failed:', error);
|
||||||
const sizeMB = (file.size / (1024 * 1024)).toFixed(1);
|
const sizeMB = (file.size / (1024 * 1024)).toFixed(1);
|
||||||
showFileImportError(
|
showFileImportError(
|
||||||
@ -2817,6 +2906,7 @@ async function handleKMLImport(evt) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showImportSpinner(file.name);
|
||||||
try {
|
try {
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
console.log('[KMLImport] Parsing', file.name, '(' + (file.size / 1024).toFixed(1) + ' KB)');
|
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) {
|
if (!features || features.length === 0) {
|
||||||
|
hideImportSpinner();
|
||||||
showFileImportError('No features found in the KML file.');
|
showFileImportError('No features found in the KML file.');
|
||||||
evt.target.value = '';
|
evt.target.value = '';
|
||||||
return;
|
return;
|
||||||
@ -2842,6 +2933,7 @@ async function handleKMLImport(evt) {
|
|||||||
const displayName = file.name.replace(/\.kml$/i, '');
|
const displayName = file.name.replace(/\.kml$/i, '');
|
||||||
addImportedGeoJSON(fc, displayName, 'KMLImport');
|
addImportedGeoJSON(fc, displayName, 'KMLImport');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
hideImportSpinner();
|
||||||
console.error('[KMLImport] Failed:', error);
|
console.error('[KMLImport] Failed:', error);
|
||||||
const sizeMB = (file.size / (1024 * 1024)).toFixed(1);
|
const sizeMB = (file.size / (1024 * 1024)).toFixed(1);
|
||||||
showFileImportError(
|
showFileImportError(
|
||||||
|
|||||||
@ -28,3 +28,20 @@ DirectoryIndex index.php index.html
|
|||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
RewriteRule ^ index.php [L]
|
RewriteRule ^ index.php [L]
|
||||||
</IfModule>
|
</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>
|
||||||
|
|||||||
@ -20,6 +20,15 @@
|
|||||||
*/
|
*/
|
||||||
session_start();
|
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
|
// SSO authentication — validate the cookie if we don't already have a session
|
||||||
// (same logic as index.php)
|
// (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'] ?? '';
|
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||||||
$isProduction = (bool) preg_match('/(^|\.)lupmis4luspa\.org$/i', $host);
|
$isProduction = (bool) preg_match('/(^|\.)lupmis4luspa\.org$/i', $host);
|
||||||
if ($isProduction && !isset($_SESSION['user_id'])) {
|
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;
|
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 *`.
|
// the real permitting host is confirmed. NEVER use `frame-ancestors *`.
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
$EMBED_ALLOWED_PARENTS = [
|
$EMBED_ALLOWED_PARENTS = [
|
||||||
'https://permits.lupmis4luspa.org',
|
'https://lupmis4luspa.org',
|
||||||
// Add local dev parents here if needed, e.g. 'http://localhost:8000'.
|
// Add local dev parents here if needed, e.g. 'http://localhost:8000'.
|
||||||
];
|
];
|
||||||
$frameAncestors = "'self' " . implode(' ', $EMBED_ALLOWED_PARENTS);
|
$frameAncestors = "'self' " . implode(' ', $EMBED_ALLOWED_PARENTS);
|
||||||
|
|||||||
@ -396,6 +396,20 @@ export class MapView {
|
|||||||
});
|
});
|
||||||
this._modifyInteraction.setActive(false);
|
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
|
// 4. UndoRedo interaction — watches the drawings source
|
||||||
this._undoRedo = new UndoRedo();
|
this._undoRedo = new UndoRedo();
|
||||||
this.map.addInteraction(this._undoRedo);
|
this.map.addInteraction(this._undoRedo);
|
||||||
@ -2175,6 +2189,20 @@ export class MapView {
|
|||||||
this._drawnPolygonCallbacks.push(callback);
|
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.
|
* Register a double-click callback.
|
||||||
* Callback receives (lon, lat, feature, event).
|
* Callback receives (lon, lat, feature, event).
|
||||||
|
|||||||
@ -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). */
|
/** Delete a staged import (and its features via CASCADE). */
|
||||||
export async function deleteExternalImport(importId) {
|
export async function deleteExternalImport(importId) {
|
||||||
try {
|
try {
|
||||||
@ -1631,6 +1701,8 @@ export default {
|
|||||||
listExternalImports,
|
listExternalImports,
|
||||||
getExternalImportFeatures,
|
getExternalImportFeatures,
|
||||||
remapImportedFeatureProperties,
|
remapImportedFeatureProperties,
|
||||||
|
updateExternalImportFeatureGeometry,
|
||||||
|
deleteExternalImportFeature,
|
||||||
deleteExternalImport,
|
deleteExternalImport,
|
||||||
saveParcels,
|
saveParcels,
|
||||||
getLocalParcels,
|
getLocalParcels,
|
||||||
|
|||||||
@ -248,3 +248,40 @@ export function listSourceFields(fc) {
|
|||||||
}
|
}
|
||||||
return Array.from(seen.keys());
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import {
|
|||||||
detectTargetType,
|
detectTargetType,
|
||||||
autoMapFields,
|
autoMapFields,
|
||||||
listSourceFields,
|
listSourceFields,
|
||||||
|
sampleSourceValues,
|
||||||
} from './import-detect.js';
|
} from './import-detect.js';
|
||||||
|
|
||||||
const els = {}; // cached DOM lookups
|
const els = {}; // cached DOM lookups
|
||||||
@ -89,13 +90,29 @@ function renderFieldsTable() {
|
|||||||
}
|
}
|
||||||
els.fieldsWrap.style.display = '';
|
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>']
|
const optionsHtml = ['<option value="">(none)</option>']
|
||||||
.concat(state.sourceFields.map((s) =>
|
.concat(state.sourceFields.map((s) =>
|
||||||
`<option value="${escapeAttr(s)}">${escapeHtml(s)}</option>`))
|
`<option value="${escapeAttr(s)}">${escapeHtml(toBoldUnicode(s))}${sampleSuffix(s)}</option>`))
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
els.tbody.innerHTML = columns.map((col) => {
|
els.tbody.innerHTML = columns.map((col) => {
|
||||||
const current = state.mapping[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(
|
const select = optionsHtml.replace(
|
||||||
`<option value="${escapeAttr(current)}">`,
|
`<option value="${escapeAttr(current)}">`,
|
||||||
`<option value="${escapeAttr(current)}" selected>`
|
`<option value="${escapeAttr(current)}" selected>`
|
||||||
@ -191,6 +208,7 @@ export function openImportMappingModal(opts) {
|
|||||||
filename: opts.filename,
|
filename: opts.filename,
|
||||||
fc,
|
fc,
|
||||||
sourceFields: listSourceFields(fc),
|
sourceFields: listSourceFields(fc),
|
||||||
|
sourceSamples: sampleSourceValues(fc),
|
||||||
targetType,
|
targetType,
|
||||||
mapping: autoMapFields(fc, targetType),
|
mapping: autoMapFields(fc, targetType),
|
||||||
onResult: opts.onResult,
|
onResult: opts.onResult,
|
||||||
@ -219,3 +237,27 @@ function escapeHtml(s) {
|
|||||||
.replace(/"/g, '"').replace(/'/g, ''');
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
function escapeAttr(s) { return escapeHtml(s); }
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user