Skip to content

Commit

Permalink
PB-1028 : add a parser for each type of file we want to support
Browse files Browse the repository at this point in the history
Instead of having a big chunk of code that does the heavy lifting (loosely copied or similar in places like the load-kml and load-gpx plugin, plus the drag and drop handler) I gathered all this logic in one place, giving it the necessary options/config to handle all situation and be written only once.

There is now only one way of dealing with a KML/KMZ/GPX file from external/local source, the dedicated parser.

List of other things done while working on this :
 - changing updateLayer action to receive a layer ID instead of an index (was never used because of the hassle to get this index). I replaced uses of updateLayers when it was used for a single layer.
 - move all extent utils in... extentUtils.js
 - FileInput can now receive params for i18n placeholders in the error message
 - Fixing a missing rename with the local file disclaimer, it was not the correct tooltip shown for local files because of that
 - Adding a zoom to extent button in the layer list, if the layer provides an extent (meaning KML/GPX and soon COG)
  • Loading branch information
pakb committed Oct 11, 2024
1 parent f477497 commit 8dec5c6
Show file tree
Hide file tree
Showing 48 changed files with 983 additions and 499 deletions.
3 changes: 1 addition & 2 deletions src/api/features/features.api.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ import {
import { getApi3BaseUrl } from '@/config/baseUrl.config'
import { DEFAULT_FEATURE_COUNT_SINGLE_POINT } from '@/config/map.config'
import allCoordinateSystems, { LV95 } from '@/utils/coordinates/coordinateSystems'
import { projExtent } from '@/utils/coordinates/coordinateUtils'
import { createPixelExtentAround } from '@/utils/extentUtils'
import { createPixelExtentAround, projExtent } from '@/utils/extentUtils'
import { getGeoJsonFeatureCoordinates, reprojectGeoJsonData } from '@/utils/geoJsonUtils'
import log from '@/utils/logging'

Expand Down
12 changes: 12 additions & 0 deletions src/api/file-proxy.api.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import axios from 'axios'
import { isString } from 'lodash'

import { getServiceProxyBaseUrl } from '@/config/baseUrl.config'
Expand Down Expand Up @@ -68,3 +69,14 @@ export function unProxifyUrl(proxifiedUrl) {

return proxifiedUrl
}

/**
* @param {String} fileUrl
* @returns {Promise<ArrayBuffer>}
*/
export async function getContentThroughServiceProxy(fileUrl) {
const proxifyGetResponse = await axios.get(proxifyUrl(fileUrl), {
responseType: 'arraybuffer',
})
return proxifyGetResponse.data
}
9 changes: 5 additions & 4 deletions src/api/files.api.js
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ export function loadKmlData(kmlLayer) {
* proxy.
*
* @param {string} url URL to fetch
* @param {Object} [options]
* @param {Number} [options.timeout] How long should the call wait before timing out
* @param {string} [options.responseType] Type of data that the server will respond with. Options
* are 'arraybuffer', 'document', 'json', 'text', 'stream'. Default is `json`
Expand All @@ -349,10 +350,10 @@ export async function getFileFromUrl(url, options = {}) {
const { timeout = null, responseType = null } = options
if (/^https?:\/\/localhost/.test(url) || isInternalUrl(url)) {
// don't go through proxy if it is on localhost or the internal server
return axios.get(url, { timeout, responseType })
return await axios.get(url, { timeout, responseType })
} else if (url.startsWith('http://')) {
// HTTP request goes through the proxy
return axios.get(proxifyUrl(url), { timeout, responseType })
return await axios.get(proxifyUrl(url), { timeout, responseType })
}

// For other urls we need to check if they support CORS
Expand All @@ -374,8 +375,8 @@ export async function getFileFromUrl(url, options = {}) {

if (supportCORS) {
// Server support CORS
return axios.get(url, { timeout, responseType })
return await axios.get(url, { timeout, responseType })
}
// server don't support CORS use proxy
return axios.get(proxifyUrl(url), { timeout, responseType })
return await axios.get(proxifyUrl(url), { timeout, responseType })
}
3 changes: 3 additions & 0 deletions src/api/layers/GPXLayer.class.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export default class GPXLayer extends AbstractLayer {
* @param {GPXMetadata | null} [gpxLayerData.gpxMetadata=null] Metadata of the GPX file. This
* object contains all the metadata found in the file itself within the <metadata> tag.
* Default is `null`
* @param {[Number, Number, Number, Number] | null} gpxLayerData.extent
* @throws InvalidLayerDataError if no `gpxLayerData` is given or if it is invalid
*/
constructor(gpxLayerData) {
Expand All @@ -27,6 +28,7 @@ export default class GPXLayer extends AbstractLayer {
opacity = 1.0,
gpxData = null,
gpxMetadata = null,
extent = null,
} = gpxLayerData
if (gpxFileUrl === null) {
throw new InvalidLayerDataError('Missing GPX file URL', gpxLayerData)
Expand All @@ -51,5 +53,6 @@ export default class GPXLayer extends AbstractLayer {
this.gpxFileUrl = gpxFileUrl
this.gpxData = gpxData
this.gpxMetadata = gpxMetadata
this.extent = extent
}
}
5 changes: 4 additions & 1 deletion src/api/layers/KMLLayer.class.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export default class KMLLayer extends AbstractLayer {
* @param {Map<string, ArrayBuffer>} [kmlLayerData.linkFiles=Map()] Map of KML link files. Those
* files are usually sent with the kml inside a KMZ archive and can be referenced inside the
* KML (e.g. icon, image, ...). Default is `Map()`
* @param {[Number, Number, Number, Number] | null} kmlLayerData.extent
* @throws InvalidLayerDataError if no `gpxLayerData` is given or if it is invalid
*/
constructor(kmlLayerData) {
Expand All @@ -46,6 +47,7 @@ export default class KMLLayer extends AbstractLayer {
kmlData = null,
kmlMetadata = null,
linkFiles = new Map(),
extent = null,
} = kmlLayerData
if (kmlFileUrl === null) {
throw new InvalidLayerDataError('Missing KML file URL', kmlLayerData)
Expand Down Expand Up @@ -77,13 +79,14 @@ export default class KMLLayer extends AbstractLayer {

this.kmlMetadata = kmlMetadata
if (kmlData) {
this.name = parseKmlName(kmlData)
this.name = parseKmlName(kmlData) ?? kmlFileUrl
this.isLoading = false
} else {
this.isLoading = true
}
this.kmlData = kmlData
this.linkFiles = linkFiles
this.extent = extent
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/modules/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,7 @@
"transparency": "Transparenz",
"try_test_viewer": "Probieren Sie test.map.geo.admin.ch aus",
"twitter_tooltip": "Twittern Sie diese Karte",
"unknown_projection_error": "Die Datei verwendet eine ununterstützte Projektion {epsg}",
"unsupported_content_type": "Nicht unterstützter Antwortinhaltstyp",
"upload_failed": "Fehler beim Hochladen!",
"upload_succeeded": "Upload OK!",
Expand Down
1 change: 1 addition & 0 deletions src/modules/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,7 @@
"transparency": "Transparency",
"try_test_viewer": "Try out test.map.geo.admin.ch",
"twitter_tooltip": "Tweet this map",
"unknown_projection_error": "File is using an unsupported projection {epsg}",
"unsupported_content_type": "Unsupported response content type",
"upload_failed": "Upload error!",
"upload_succeeded": "Upload OK!",
Expand Down
1 change: 1 addition & 0 deletions src/modules/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,7 @@
"transparency": "Transparence",
"try_test_viewer": "Essayez test.map.geo.admin.ch",
"twitter_tooltip": "Tweeter cette carte",
"unknown_projection_error": "Le fichier utilise une projection non prise en charge {epsg}",
"unsupported_content_type": "Type de contenu de réponse non pris en charge",
"upload_failed": "Erreur d'enregistrement!",
"upload_succeeded": "Chargement OK!",
Expand Down
1 change: 1 addition & 0 deletions src/modules/i18n/locales/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,7 @@
"transparency": "Trasparenza",
"try_test_viewer": "Prova test.map.geo.admin.ch",
"twitter_tooltip": "Tweet della carta",
"unknown_projection_error": "Il file utilizza una proiezione non supportata {epsg}",
"unsupported_content_type": "Tipo di contenuto della risposta non supportato",
"upload_failed": "Caricamento fallito!",
"upload_succeeded": "Caricamento OK!",
Expand Down
1 change: 1 addition & 0 deletions src/modules/i18n/locales/rm.json
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,7 @@
"transparency": "Transparenza",
"try_test_viewer": "Empruvai test.map.geo.admin.ch",
"twitter_tooltip": "Tschivlottais questa charta",
"unknown_projection_error": "Die Datei verwendet eine ununterstützte Projektion {epsg}",
"unsupported_content_type": "Tip da containt da resposta betg sustegnì",
"upload_failed": "Errur da chargiar",
"upload_succeeded": "Chargiar reussì",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
v-if="source.hasDataDisclaimer"
:source-name="source.name"
:complete-disclaimer-on-click="!source.url"
:is-external-data-local="source.isExternalDataLocal"
:is-local-file="source.isLocalFile"
>
<MapFooterAttributionItem
:source-id="source.id"
Expand Down Expand Up @@ -67,7 +67,7 @@ export default {
name: attribution.name,
url: attribution.url,
hasDataDisclaimer: this.hasDataDisclaimer(layer.id),
isExternalDataLocal: this.isLocalFile(layer),
isLocalFile: this.isLocalFile(layer),
}
})
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import { getBaseUrlOverride } from '@/config/baseUrl.config'
import { WMS_TILE_SIZE } from '@/config/map.config'
import useAddLayerToMap from '@/modules/map/components/openlayers/utils/useAddLayerToMap.composable'
import { LV95 } from '@/utils/coordinates/coordinateSystems'
import { flattenExtent } from '@/utils/coordinates/coordinateUtils'
import CustomCoordinateSystem from '@/utils/coordinates/CustomCoordinateSystem.class'
import { flattenExtent } from '@/utils/extentUtils'
import { getTimestampFromConfig } from '@/utils/layerUtils'
const props = defineProps({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,9 @@ import LayerTypes from '@/api/layers/LayerTypes.enum'
import { DRAWING_HIT_TOLERANCE } from '@/config/map.config'
import { IS_TESTING_WITH_CYPRESS } from '@/config/staging.config'
import { useDragBoxSelect } from '@/modules/map/components/openlayers/utils/useDragBoxSelect.composable'
import { handleFileContent } from '@/modules/menu/components/advancedTools/ImportFile/utils'
import useImportFile from '@/modules/menu/components/advancedTools/ImportFile/useImportFile.composable'
import { ClickInfo, ClickType } from '@/store/modules/map.store'
import { normalizeExtent, OutOfBoundsError } from '@/utils/coordinates/coordinateUtils'
import ErrorMessage from '@/utils/ErrorMessage.class'
import { EmptyGPXError } from '@/utils/gpxUtils'
import { EmptyKMLError } from '@/utils/kmlUtils'
import { normalizeExtent } from '@/utils/extentUtils'
import log from '@/utils/logging'

const dispatcher = {
Expand All @@ -27,6 +24,8 @@ const longPressEvents = [
]

export default function useMapInteractions(map) {
const { handleFileSource } = useImportFile()

const store = useStore()

const isCurrentlyDrawing = computed(() => store.state.drawing.drawingOverlay.show)
Expand Down Expand Up @@ -254,47 +253,6 @@ export default function useMapInteractions(map) {
mapElement.removeEventListener('dragleave', onDragLeave)
}

/**
* @param {File} file
* @returns {Promise<ArrayBuffer>}
*/
function readFileContent(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (event) => resolve(event.target.result)
reader.onerror = (error) => reject(error)
// The file might be a KMZ file, which is a zip archive. Reading zip archive as text
// is asking for trouble therefore we use ArrayBuffer
reader.readAsArrayBuffer(file)
})
}

/**
* @param {File} file
* @returns {Promise<void>}
*/
async function handleFile(file) {
try {
const fileContent = await readFileContent(file)
await handleFileContent(store, fileContent, file.name, file)
} catch (error) {
let errorKey
log.error(`Error loading file`, file.name, error)
if (error instanceof OutOfBoundsError) {
errorKey = 'imported_file_out_of_bounds'
} else if (error instanceof EmptyKMLError || error instanceof EmptyGPXError) {
errorKey = 'kml_gpx_file_empty'
} else {
errorKey = 'invalid_import_file_error'
log.error(`Failed to load file`, error)
}
store.dispatch('addErrors', {
errors: [new ErrorMessage(errorKey, null)],
...dispatcher,
})
}
}

function onDragOver(event) {
event.preventDefault()
store.dispatch('setShowDragAndDropOverlay', { showDragAndDropOverlay: true, ...dispatcher })
Expand All @@ -308,19 +266,15 @@ export default function useMapInteractions(map) {
})

if (event.dataTransfer.items) {
// Use DataTransferItemList interface to access the file(s)
for (let i = 0; i < event.dataTransfer.items.length; i++) {
for (/** @type {DataTransferItem} */ const item of event.dataTransfer.items) {
// If dropped items aren't files, reject them
if (event.dataTransfer.items[i].kind === 'file') {
const file = event.dataTransfer.items[i].getAsFile()
handleFile(file)
if (item.kind === 'file') {
handleFileSource(item.getAsFile())
}
}
} else {
// Use DataTransfer interface to access the file(s)
for (let i = 0; i < event.dataTransfer.files.length; i++) {
const file = event.dataTransfer.files[i]
handleFile(file)
for (/** @type {File} */ const file of event.dataTransfer.files) {
handleFileSource(file)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import MenuActiveLayersListItemTimeSelector from '@/modules/menu/components/acti
import ErrorButton from '@/utils/components/ErrorButton.vue'
import TextTruncate from '@/utils/components/TextTruncate.vue'
import ThirdPartyDisclaimer from '@/utils/components/ThirdPartyDisclaimer.vue'
import ZoomToExtentButton from '@/utils/components/ZoomToExtentButton.vue'
import { useTippyTooltip } from '@/utils/composables/useTippyTooltip'
import debounce from '@/utils/debounce'
import log from '@/utils/logging'
Expand Down Expand Up @@ -165,6 +166,7 @@ function duplicateLayer() {
@click="onToggleLayerVisibility"
>{{ layer.name }}</TextTruncate
>
<ZoomToExtentButton v-if="layer.extent" :extent="layer.extent" />
<button
v-if="showSpinner"
class="loading-button btn border-0 d-flex align-items-center"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
<script setup>
import { computed, ref, toRefs } from 'vue'
import { useStore } from 'vuex'
import ImportFileButtons from '@/modules/menu/components/advancedTools/ImportFile/ImportFileButtons.vue'
import { handleFileContent } from '@/modules/menu/components/advancedTools/ImportFile/utils'
import EmptyFileContentError from '@/modules/menu/components/advancedTools/ImportFile/parser/errors/EmptyFileContentError.error'
import OutOfBoundsError from '@/modules/menu/components/advancedTools/ImportFile/parser/errors/OutOfBoundsError.error'
import UnknownProjectionError from '@/modules/menu/components/advancedTools/ImportFile/parser/errors/UnknownProjectionError.error'
import useImportFile from '@/modules/menu/components/advancedTools/ImportFile/useImportFile.composable'
import FileInput from '@/utils/components/FileInput.vue'
import { OutOfBoundsError } from '@/utils/coordinates/coordinateUtils'
import { EmptyGPXError } from '@/utils/gpxUtils'
import { EmptyKMLError } from '@/utils/kmlUtils'
import log from '@/utils/logging'
const acceptedFileTypes = ['.kml', '.kmz', '.gpx', '.tif', '.tiff']
const store = useStore()
const { handleFileSource } = useImportFile()
const props = defineProps({
active: {
Expand All @@ -26,6 +25,7 @@ const { active } = toRefs(props)
const loadingFile = ref(false)
const selectedFile = ref(null)
const errorFileLoadingMessage = ref(null)
const errorFileLoadingExtraParams = ref(null)
const isFormValid = ref(false)
const activateValidation = ref(false)
const importSuccessMessage = ref('')
Expand All @@ -36,21 +36,21 @@ const buttonState = computed(() => (loadingFile.value ? 'loading' : 'default'))
async function loadFile() {
importSuccessMessage.value = ''
errorFileLoadingMessage.value = ''
errorFileLoadingExtraParams.value = null
activateValidation.value = true
loadingFile.value = true
if (isFormValid.value && selectedFile.value) {
try {
// The file might be a KMZ which is a zip archive. Handling zip archive as text is
// asking for trouble, therefore we need first to get it as binary
const content = await selectedFile.value.arrayBuffer()
await handleFileContent(store, content, selectedFile.value.name)
importSuccessMessage.value = 'file_imported_success'
await handleFileSource(selectedFile.value, false)
} catch (error) {
if (error instanceof OutOfBoundsError) {
errorFileLoadingMessage.value = 'imported_file_out_of_bounds'
} else if (error instanceof EmptyKMLError || error instanceof EmptyGPXError) {
} else if (error instanceof EmptyFileContentError) {
errorFileLoadingMessage.value = 'kml_gpx_file_empty'
} else if (error instanceof UnknownProjectionError) {
errorFileLoadingMessage.value = 'unknown_projection_error'
errorFileLoadingExtraParams.value = { epsg: error.epsg }
} else {
errorFileLoadingMessage.value = 'invalid_import_file_error'
log.error(`Failed to load file`, error)
Expand Down Expand Up @@ -86,6 +86,7 @@ function validateForm(valid) {
:activate-validation="activateValidation"
:invalid-marker="!!errorFileLoadingMessage"
:invalid-message="errorFileLoadingMessage"
:invalid-message-extra-params="errorFileLoadingExtraParams"
:valid-message="importSuccessMessage"
@validate="validateForm"
/>
Expand Down
Loading

0 comments on commit 8dec5c6

Please sign in to comment.