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>
264 lines
9.2 KiB
JavaScript
264 lines
9.2 KiB
JavaScript
/**
|
||
* Import-mapping modal controller.
|
||
*
|
||
* openImportMappingModal({ importId, filename, fc, onResult })
|
||
*
|
||
* Populates the modal from the parsed FeatureCollection, lets the user pick
|
||
* a target type and adjust the field map, then calls onResult with one of:
|
||
*
|
||
* { action: 'cancel' } — keep as Other / view only
|
||
* { action: 'save', targetType, mapping }
|
||
* { action: 'upload', targetType, mapping }
|
||
*
|
||
* The caller is responsible for updating external_imports + the staged
|
||
* features (this module knows nothing about the DB or the map).
|
||
*
|
||
* See LUPMIS2_Import_Upload_Design.docx §3.1.
|
||
*/
|
||
|
||
import { Modal } from 'bootstrap';
|
||
import {
|
||
TARGET_TYPES,
|
||
TARGET_FIELDS,
|
||
detectTargetType,
|
||
autoMapFields,
|
||
listSourceFields,
|
||
sampleSourceValues,
|
||
} from './import-detect.js';
|
||
|
||
const els = {}; // cached DOM lookups
|
||
let modal = null;
|
||
let state = null; // { importId, filename, fc, sourceFields, mapping, targetType, onResult }
|
||
|
||
function cacheEls() {
|
||
if (els.root) return;
|
||
els.root = document.getElementById('importMappingModal');
|
||
els.filename = document.getElementById('import-modal-filename');
|
||
els.summary = document.getElementById('import-modal-summary');
|
||
els.target = document.getElementById('import-modal-target');
|
||
els.targetHint = document.getElementById('import-modal-target-hint');
|
||
els.fieldsWrap = document.getElementById('import-modal-fields-wrap');
|
||
els.tbody = document.getElementById('import-modal-fields-tbody');
|
||
els.btnSave = document.getElementById('import-modal-save');
|
||
els.btnSaveUpload = document.getElementById('import-modal-save-upload');
|
||
els.btnCancel = document.getElementById('import-modal-cancel');
|
||
|
||
// Populate the target-type dropdown once.
|
||
if (els.target && !els.target.dataset.populated) {
|
||
els.target.innerHTML = TARGET_TYPES
|
||
.map((t) => `<option value="${t.key}">${t.label}</option>`)
|
||
.join('');
|
||
els.target.dataset.populated = '1';
|
||
}
|
||
|
||
// Event wiring (idempotent).
|
||
if (els.target && !els.target.dataset.wired) {
|
||
els.target.dataset.wired = '1';
|
||
els.target.addEventListener('change', onTargetChange);
|
||
}
|
||
if (els.btnSave && !els.btnSave.dataset.wired) {
|
||
els.btnSave.dataset.wired = '1';
|
||
els.btnSave.addEventListener('click', () => finish('save'));
|
||
}
|
||
if (els.btnSaveUpload && !els.btnSaveUpload.dataset.wired) {
|
||
els.btnSaveUpload.dataset.wired = '1';
|
||
els.btnSaveUpload.addEventListener('click', () => finish('upload'));
|
||
}
|
||
// Cancel uses Bootstrap's data-bs-dismiss; we hook the hidden event so
|
||
// closing via × / ESC / Cancel all behave the same: a 'cancel' result.
|
||
if (els.root && !els.root.dataset.wired) {
|
||
els.root.dataset.wired = '1';
|
||
els.root.addEventListener('hidden.bs.modal', () => {
|
||
if (state?.onResult && !state._resolved) {
|
||
state._resolved = true;
|
||
state.onResult({ action: 'cancel' });
|
||
}
|
||
state = null;
|
||
});
|
||
}
|
||
}
|
||
|
||
/** Render the field-mapping table for the current targetType. */
|
||
function renderFieldsTable() {
|
||
const targetType = state.targetType;
|
||
const columns = TARGET_FIELDS[targetType] || [];
|
||
|
||
// "Other (view only)" → no fields to map.
|
||
if (targetType === 'other' || columns.length === 0) {
|
||
els.fieldsWrap.style.display = 'none';
|
||
return;
|
||
}
|
||
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(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>`
|
||
);
|
||
return `
|
||
<tr>
|
||
<td><code>${escapeHtml(col)}</code></td>
|
||
<td>
|
||
<select class="form-select form-select-sm import-field-map"
|
||
data-col="${escapeAttr(col)}">
|
||
${select}
|
||
</select>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
|
||
// Update state.mapping when a row's source field changes.
|
||
els.tbody.querySelectorAll('.import-field-map').forEach((sel) => {
|
||
sel.addEventListener('change', (e) => {
|
||
const col = e.target.dataset.col;
|
||
state.mapping[col] = e.target.value || null;
|
||
});
|
||
});
|
||
}
|
||
|
||
/** Handler: target dropdown changed. */
|
||
function onTargetChange() {
|
||
const newType = els.target.value;
|
||
state.targetType = newType;
|
||
state.mapping = autoMapFields(state.fc, newType);
|
||
|
||
// Target-type hint: gentle reminder when "other" is chosen.
|
||
if (newType === 'other') {
|
||
els.targetHint.innerHTML =
|
||
'<em>This dataset will be visible on the map but cannot be uploaded ' +
|
||
'to the database. You can change the type later.</em>';
|
||
els.btnSave.disabled = false; // saves the "other" state
|
||
els.btnSaveUpload.disabled = true;
|
||
} else {
|
||
els.targetHint.innerHTML =
|
||
'Each LUPMIS2 column is matched to a source field where possible. ' +
|
||
'You can override any choice below.';
|
||
els.btnSave.disabled = false;
|
||
els.btnSaveUpload.disabled = false;
|
||
}
|
||
renderFieldsTable();
|
||
}
|
||
|
||
/** Handler: Save or Save+Upload clicked. */
|
||
function finish(action) {
|
||
if (!state || state._resolved) return;
|
||
state._resolved = true;
|
||
const { targetType, mapping, onResult } = state;
|
||
modal.hide();
|
||
if (onResult) {
|
||
onResult({
|
||
action, // 'save' | 'upload'
|
||
targetType,
|
||
mapping: targetType === 'other' ? null : { ...mapping },
|
||
});
|
||
}
|
||
state = null;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Public API
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Open the modal for a fresh import. Resolves via onResult callback.
|
||
*
|
||
* @param {Object} opts
|
||
* @param {number} opts.importId — staging row id (purely for the caller)
|
||
* @param {string} opts.filename
|
||
* @param {Object} opts.fc — parsed FeatureCollection
|
||
* @param {Function} opts.onResult — see module header
|
||
*/
|
||
export function openImportMappingModal(opts) {
|
||
cacheEls();
|
||
if (!els.root) {
|
||
console.warn('[ImportModal] Modal element missing — calling onResult with cancel');
|
||
opts.onResult?.({ action: 'cancel' });
|
||
return;
|
||
}
|
||
|
||
const fc = opts.fc;
|
||
const featureCount = fc?.features?.length ?? 0;
|
||
const targetType = detectTargetType(fc);
|
||
|
||
state = {
|
||
importId: opts.importId,
|
||
filename: opts.filename,
|
||
fc,
|
||
sourceFields: listSourceFields(fc),
|
||
sourceSamples: sampleSourceValues(fc),
|
||
targetType,
|
||
mapping: autoMapFields(fc, targetType),
|
||
onResult: opts.onResult,
|
||
_resolved: false,
|
||
};
|
||
|
||
// Header summary
|
||
els.filename.textContent = opts.filename || 'imported dataset';
|
||
els.summary.textContent = `— ${featureCount} feature${featureCount === 1 ? '' : 's'}`;
|
||
|
||
// Initial dropdown + hint + fields
|
||
els.target.value = state.targetType;
|
||
onTargetChange();
|
||
|
||
modal = Modal.getOrCreateInstance(els.root);
|
||
modal.show();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// HTML-escape helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function escapeHtml(s) {
|
||
return String(s)
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
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;
|
||
}
|