diff --git a/.env b/.env index 5074dd0f..24478a6d 100644 --- a/.env +++ b/.env @@ -20,6 +20,8 @@ VITE_MVTSTYLES_PATH_GET="/getpermalinkstyle" VITE_MVTSTYLES_PATH_UPLOAD="/uploadpermalinkstyle" VITE_MVTSTYLES_PATH_DELETE="/deletepermalinkstyle" +VITE_URL_MYMAPS_EXPORT_FILE="/mymaps/exportgpxkml" + # Offline VITE_OFFLINE_GUTTER=96 diff --git a/.env.development b/.env.development index d9c0ae14..342091b5 100644 --- a/.env.development +++ b/.env.development @@ -20,6 +20,8 @@ VITE_MVTSTYLES_PATH_GET="/getpermalinkstyle" VITE_MVTSTYLES_PATH_UPLOAD="/uploadpermalinkstyle" VITE_MVTSTYLES_PATH_DELETE="/deletepermalinkstyle" +VITE_URL_MYMAPS_EXPORT_FILE="http://localhost:8080/mymaps/exportgpxkml" + # Offline VITE_OFFLINE_GUTTER=96 diff --git a/.env.e2e b/.env.e2e index 3d4d87ac..be795cdb 100644 --- a/.env.e2e +++ b/.env.e2e @@ -20,6 +20,8 @@ VITE_MVTSTYLES_PATH_GET="/getpermalinkstyle" VITE_MVTSTYLES_PATH_UPLOAD="/uploadpermalinkstyle" VITE_MVTSTYLES_PATH_DELETE="/deletepermalinkstyle" +VITE_URL_MYMAPS_EXPORT_FILE="https://migration.geoportail.lu/mymaps/exportgpxkml" + # Offline VITE_OFFLINE_GUTTER=96 diff --git a/.env.staging b/.env.staging index cb0d2ca3..89190e8f 100644 --- a/.env.staging +++ b/.env.staging @@ -20,6 +20,8 @@ VITE_MVTSTYLES_PATH_GET="https://migration.geoportail.lu/getvtstyle" VITE_MVTSTYLES_PATH_UPLOAD="https://migration.geoportail.lu/uploadvtstyle" VITE_MVTSTYLES_PATH_DELETE="https://migration.geoportail.lu/deletevtstyle" +VITE_URL_MYMAPS_EXPORT_FILE="https://migration.geoportail.lu/mymaps/exportgpxkml" + # Offline VITE_OFFLINE_GUTTER=96 diff --git a/src/components/draw/feature-menu-popup.vue b/src/components/draw/feature-menu-popup.vue index a08e7afa..809c0b3d 100644 --- a/src/components/draw/feature-menu-popup.vue +++ b/src/components/draw/feature-menu-popup.vue @@ -6,22 +6,36 @@ import { type MenuPopupItem as MenuPopupItemType } from '@/components/common/men import MenuPopup from '@/components/common/menu-popup/menu-popup.vue' import MenuPopupItem from '@/components/common/menu-popup/menu-popup-item.vue' import { DrawnFeature } from '@/services/draw/drawn-feature' +import { + exportFeatureService, + type exportFormat, +} from '@/services/export-feature/export-feature.service' const { t } = useTranslation() -const feature: DrawnFeature | undefined = inject('feature') +const feature: DrawnFeature = inject('feature')! + +function download(format: exportFormat) { + exportFeatureService.export( + feature.map, + format, + [feature], + feature.label, + true + ) +} let drawingMenuOptions = [ { label: 'Exporter un GPX', - action: () => alert('TODO: Draw feature click drawingMenuOptions'), + action: () => download('gpx'), }, { label: 'Exporter un KML', - action: () => alert('TODO: Draw feature click drawingMenuOptions'), + action: () => download('kml'), }, { label: 'Exporter un Shapefile', - action: () => alert('TODO: Draw feature click drawingMenuOptions'), + action: () => download('shapefile'), }, ] diff --git a/src/services/export-feature/export-feature-gpx.spec.ts b/src/services/export-feature/export-feature-gpx.spec.ts new file mode 100644 index 00000000..fdb0ca60 --- /dev/null +++ b/src/services/export-feature/export-feature-gpx.spec.ts @@ -0,0 +1,122 @@ +import { Feature, Map } from 'ol' +import { Point, LineString, Polygon, MultiLineString } from 'ol/geom' +import { ExportFeatureGpx } from './export-feature-gpx' + +describe('ExportFeatureGpx', () => { + let exportFeatureGpx: ExportFeatureGpx + let features: Feature[] + + beforeEach(() => { + exportFeatureGpx = new ExportFeatureGpx(new Map({})) + features = [ + new Feature({ + geometry: new Point([0, 0]), + name: 'Point 1', + }), + new Feature({ + geometry: new LineString([ + [0, 0], + [1, 1], + ]), + name: 'Line 1', + }), + new Feature({ + geometry: new Polygon([ + [ + [0, 0], + [1, 1], + [1, 0], + [0, 0], + ], + ]), + name: 'Polygon 1', + }), + ] + + global.URL.createObjectURL = vi.fn(() => 'blob:http://localhost/test') + global.URL.revokeObjectURL = vi.fn() + }) + + describe('#export', () => { + it('should call download with correct parameters', () => { + const downloadSpy = vi.spyOn(exportFeatureGpx, 'download') + const fileName = 'testFile' + + exportFeatureGpx.export(features, fileName, true) + + expect(downloadSpy).toHaveBeenCalledWith( + fileName, + expect.any(String), + 'gpx', + 'application/gpx' + ) + }) + }) + + describe('#prepareFeatures', () => { + it('should prepare features correctly', () => { + const preparedFeatures = exportFeatureGpx.prepareFeatures(features, true) + + expect(preparedFeatures.length).toBeGreaterThan(0) + }) + }) + + describe('#generateContent', () => { + it('should generate GPX content', () => { + const content = exportFeatureGpx.generateContent(features, 'testFile') + expect(content).toBe( + 'testFilePoint 1Line 1' + ) + }) + }) + + describe('#changePolygonToLine', () => { + it('should convert polygon to line', () => { + const changedFeatures = exportFeatureGpx['changePolygonToLine'](features) + expect(changedFeatures[2].getGeometry()?.getType()).toBe('LineString') + }) + }) + + describe('#changeMultilineToLine', () => { + it('should convert multiline to line', () => { + const multiLineFeature = new Feature({ + geometry: new MultiLineString([ + [ + [0, 0], + [1, 1], + ], + [ + [2, 2], + [3, 3], + ], + ]), + name: 'MultiLine', + }) + + const changedFeatures = exportFeatureGpx['changeMultilineToLine']([ + multiLineFeature, + ]) + expect(changedFeatures.length).toBe(2) + expect(changedFeatures[0].getGeometry()?.getType()).toBe('LineString') + }) + }) + + describe('#changeLineToMultiline', () => { + it('should convert line to multiline', () => { + const changedFeatures = + exportFeatureGpx['changeLineToMultiline'](features) + expect(changedFeatures[1].getGeometry()?.getType()).toBe( + 'MultiLineString' + ) + }) + }) + + describe('#orderFeaturesForGpx', () => { + it('should order features correctly', () => { + const orderedFeatures = exportFeatureGpx['orderFeaturesForGpx'](features) + + expect(orderedFeatures[0].getGeometry()?.getType()).toBe('Point') + expect(orderedFeatures[1].getGeometry()?.getType()).toBe('LineString') + }) + }) +}) diff --git a/src/services/export-feature/export-feature-gpx.ts b/src/services/export-feature/export-feature-gpx.ts new file mode 100644 index 00000000..1166ab90 --- /dev/null +++ b/src/services/export-feature/export-feature-gpx.ts @@ -0,0 +1,145 @@ +import { Feature } from 'ol' +import { Geometry, LineString, Polygon } from 'ol/geom' +import MultiLineString from 'ol/geom/MultiLineString' + +import { ExportFeature } from './export-feature' +import { GPX } from './ol-format-gpx' + +export class ExportFeatureGpx extends ExportFeature { + /** + * Export a Gpx file + * @param features The features to export + * @param fileName The file name without the file extension (file extension ".gpx" will be added here) + * @param isTrack True if gpx should export tracks instead of routes (eg. use true for feature export, false for routing export) + */ + export(features: Feature[], fileName: string, isTrack = false) { + const explodedFeatures = this.prepareFeatures(features, isTrack) + const content = this.generateContent( + explodedFeatures as Feature[], + fileName + ) + + this.download(fileName, content, 'gpx', 'application/gpx') + } + + prepareFeatures(features: Feature[], isTrack = false) { + features.forEach(feature => { + const properties = feature.getProperties() + + // NB. not sure if this is used in Lux + // LineString geometries, and tracks from MultiLineString + if ('feature_name' in properties) { + feature.set('name', properties['feature_name'], true) + } + }) + + let explodedFeatures = this.exploseFeatures(features) + if (isTrack) { + explodedFeatures = this.changeLineToMultiline(explodedFeatures) + } else { + explodedFeatures = this.changeMultilineToLine(explodedFeatures) + } + + explodedFeatures = this.changePolygonToLine(explodedFeatures) + + return explodedFeatures + } + + generateContent(features: Feature[], fileName: string) { + return new GPX().writeFeatures(this.orderFeaturesForGpx(features), { + ...this.encodeOptions, + ...{ metadata: { name: fileName } }, + }) + } + + /** + * Change polygon to lines + */ + private changePolygonToLine(features: Feature[]) { + return features.map(feature => { + const geometry = feature.getGeometry() + + if (geometry?.getType() === 'Polygon') { + const polygon = feature.getGeometry() + const exteriorRing = + (polygon).getLinearRing(0)?.getCoordinates() || [] + const lineString = new LineString(exteriorRing) + + return this.cloneFeatureWithGeom(feature, lineString) + } + + return feature + }) + } + + /** + * Change each multiline contained in the array into line geometry + */ + private changeMultilineToLine(features: Feature[]) { + return features.reduce((acc, feature) => { + const geometry = feature.getGeometry() + + if (geometry?.getType() === 'MultiLineString') { + const lines = (geometry).getLineStrings() + lines.forEach(line => + acc.push(this.cloneFeatureWithGeom(feature, line)) + ) + } else { + acc.push(feature) + } + + return acc + }, [] as Feature[]) + } + + /** + * Change each line contained in the array into multiline geometry + */ + private changeLineToMultiline(features: Feature[]) { + return features.map(feature => { + const geometry = feature.getGeometry() + + if (geometry?.getType() === 'LineString') { + return this.cloneFeatureWithGeom( + feature, + new MultiLineString([(geometry).getCoordinates()]) + ) + } + + return feature + }) + } + + /** + * Order the feature to have the right GPX order. + * An optional instance of + * An arbitrary number of instances of + * An arbitrary number of instances of + * An arbitrary number of instances of + * An optional instance of + * @param features The features to order + */ + private orderFeaturesForGpx(features: Feature[]) { + const points: Feature[] = [] + const lines: Feature[] = [] + const others: Feature[] = [] + + features.forEach(feature => { + const geomType = feature.getGeometry()?.getType() + + switch (geomType) { + case 'Point': + points.push(feature) + break + case 'LineString': + lines.push(feature) + break + default: + others.push(feature) + break + } + }) + + return [...points, ...lines, ...others] + } +} diff --git a/src/services/export-feature/export-feature-kml.spec.ts b/src/services/export-feature/export-feature-kml.spec.ts new file mode 100644 index 00000000..ffcb4223 --- /dev/null +++ b/src/services/export-feature/export-feature-kml.spec.ts @@ -0,0 +1,50 @@ +import { Feature, Map } from 'ol' +import { Point } from 'ol/geom' +import { ExportFeatureKml } from './export-feature-kml' + +describe('ExportFeatureKml', () => { + let exportFeatureKml: ExportFeatureKml + let features: Feature[] + + beforeEach(() => { + global.URL.createObjectURL = vi.fn(() => 'blob:http://localhost/test') + global.URL.revokeObjectURL = vi.fn() + + exportFeatureKml = new ExportFeatureKml(new Map({})) + features = [ + new Feature({ + geometry: new Point([0, 0]), + name: 'Point 1', + }), + new Feature({ + geometry: new Point([1, 1]), + name: 'Point 2', + }), + ] + }) + + describe('#generateContent', () => { + it('should generate KML content from features', () => { + const content = exportFeatureKml.generateContent(features) + expect(content).toBe( + 'Point 10,0Point 20.000008983152841195214,0.000008983152838482056' + ) + }) + }) + + describe('#export', () => { + it('should call download method with correct parameters', () => { + const downloadSpy = vi.spyOn(exportFeatureKml, 'download') + const fileName = 'testFile' + + exportFeatureKml.export(features, fileName) + + expect(downloadSpy).toHaveBeenCalledWith( + fileName, + expect.stringContaining('[], fileName: string) { + const content = this.generateContent(features) + + this.download( + fileName, + content, + 'kml', + 'application/vnd.google-earth.kml+xml' + ) + } + + generateContent(features: Feature[]) { + return new olFormatKML().writeFeatures( + this.exploseFeatures(features), + this.encodeOptions + ) + } +} diff --git a/src/services/export-feature/export-feature-shapefile.spec.ts b/src/services/export-feature/export-feature-shapefile.spec.ts new file mode 100644 index 00000000..4ee3d335 --- /dev/null +++ b/src/services/export-feature/export-feature-shapefile.spec.ts @@ -0,0 +1,106 @@ +import { Feature, Map } from 'ol' +import { Point } from 'ol/geom' +import { ExportFeatureShapefile } from './export-feature-shapefile' + +describe('ExportFeatureShapefile', () => { + let exportFeatureShapefile: ExportFeatureShapefile + let features: Feature[] + + beforeEach(() => { + exportFeatureShapefile = new ExportFeatureShapefile(new Map({})) + features = [ + new Feature({ + geometry: new Point([0, 0]), + name: 'Point 1', + }), + ] + + global.URL.createObjectURL = vi.fn(() => 'blob:http://localhost/test') + global.URL.revokeObjectURL = vi.fn() + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + blob: vi + .fn() + .mockResolvedValue(new Blob(['test'], { type: 'application/zip' })), + }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('#export', () => { + it('should send a POST request and download the shapefile', async () => { + const downloadSpy = vi.spyOn(exportFeatureShapefile, 'download') + const fileName = 'testFile' + + await exportFeatureShapefile.export(features, fileName) + + expect(fetch).toHaveBeenCalledWith( + import.meta.env.VITE_URL_MYMAPS_EXPORT_FILE, + { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: expect.stringContaining( + 'format=shape&name=testFile&doc=%7B%22type%22%3A%22FeatureCollection%22%2C%22features%22%3A%5B%7B%22type%22%3A%22Feature%22%2C%22geometry%22%3A%7B%22type%22%3A%22Point%22%2C%22coordinates%22%3A%5B0%2C0%5D%7D%2C%22properties%22%3A%7B%22name%22%3A%22Point+1%22%7D%7D%5D%7D' + ), + } + ) + + expect(downloadSpy).toHaveBeenCalledWith( + fileName, + expect.any(Blob), + 'zip', + 'application/octet-stream' + ) + }) + + it('should throw an error if the response is not ok', async () => { + ;(global.fetch as vi.Mock).mockResolvedValueOnce({ + ok: false, + blob: vi.fn(), + }) + + await expect( + exportFeatureShapefile.export(features, 'testFile') + ).rejects.toThrow('Error while requesting shapefile') + }) + }) + + describe('#generateContent', () => { + it('should generate GeoJSON content from features', () => { + const content = exportFeatureShapefile.generateContent(features) + expect(content).toBe( + '{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[0,0]},"properties":{"name":"Point 1"}}]}' + ) + }) + }) + + describe('#fetchFileContent', () => { + it('should send a POST request to fetch file content', async () => { + const content = '{"type":"FeatureCollection"}' + const fileName = 'testFile' + + const blob = await exportFeatureShapefile.fetchFileContent( + content, + fileName + ) + + expect(fetch).toHaveBeenCalledWith('mymaps/exportgpxkml', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: JSON.stringify({ + doc: content, + format: 'shape', + name: fileName, + }), + }) + + expect(blob).toBeInstanceOf(Blob) + }) + }) +}) diff --git a/src/services/export-feature/export-feature-shapefile.ts b/src/services/export-feature/export-feature-shapefile.ts new file mode 100644 index 00000000..d28221aa --- /dev/null +++ b/src/services/export-feature/export-feature-shapefile.ts @@ -0,0 +1,73 @@ +import { Feature, Map } from 'ol' +import olFormatGeoJSON from 'ol/format/GeoJSON' +import { Geometry } from 'ol/geom' + +import { ExportFeature } from './export-feature' +import { PROJECTION_LUX } from '@/composables/map/map.composable' + +export const URL_MYMAPS_EXPORT_FILE = import.meta.env + .VITE_URL_MYMAPS_EXPORT_FILE + +export class ExportFeatureShapefile extends ExportFeature { + constructor(map: Map) { + super(map) + + this.encodeOptions.dataProjection = PROJECTION_LUX + } + + /** + * Export a Kml file + * @param features The features to export + * @param fileName The file name without the file extension (file extension ".kml" will be added here) + */ + async export(features: Feature[], fileName: string) { + const content = this.generateContent(features) + const url = URL_MYMAPS_EXPORT_FILE + const payload = new URLSearchParams({ + format: 'shape', + name: fileName, + doc: content, + }) + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: payload.toString(), + }) + + if (!response.ok) { + throw new Error('Error while requesting shapefile') + } + + const blob = await response.blob() + + this.download(fileName, blob, 'zip', 'application/octet-stream') + } + + generateContent(features: Feature[]) { + return new olFormatGeoJSON().writeFeatures( + this.exploseFeatures(features), + this.encodeOptions + ) + } + + async fetchFileContent(content: string, fileName: string) { + const url = 'mymaps/exportgpxkml' + const data = { + doc: content, + format: 'shape', + name: fileName, + } + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: JSON.stringify(data), + }) + const blob = await response.blob() + + return blob + } +} diff --git a/src/services/export-feature/export-feature.service.ts b/src/services/export-feature/export-feature.service.ts new file mode 100644 index 00000000..b413aa44 --- /dev/null +++ b/src/services/export-feature/export-feature.service.ts @@ -0,0 +1,32 @@ +import { Feature, Map } from 'ol' + +import { ExportFeatureGpx } from './export-feature-gpx' +import { ExportFeatureKml } from './export-feature-kml' +import { ExportFeatureShapefile } from './export-feature-shapefile' + +export type exportFormat = 'kml' | 'gpx' | 'shapefile' + +export class ExportFeatureService { + export( + map: Map, + format: exportFormat, + features: Feature[], + fileName: string, + isTrack = false + ) { + this.getExporter(map, format)?.export(features, fileName, isTrack) + } + + private getExporter(map: Map, format: string) { + switch (format) { + case 'gpx': + return new ExportFeatureGpx(map) + case 'kml': + return new ExportFeatureKml(map) + case 'shapefile': + return new ExportFeatureShapefile(map) + } + } +} + +export const exportFeatureService = new ExportFeatureService() diff --git a/src/services/export-feature/export-feature.ts b/src/services/export-feature/export-feature.ts new file mode 100644 index 00000000..f95b57ef --- /dev/null +++ b/src/services/export-feature/export-feature.ts @@ -0,0 +1,78 @@ +import { Feature, Map } from 'ol' +import { Projection } from 'ol/proj' +import { Geometry, GeometryCollection, MultiLineString } from 'ol/geom' + +import { PROJECTION_WGS84 } from '@/composables/map/map.composable' +import { downloadFile, sanitizeFilename } from '@/services/utils' + +export abstract class ExportFeature { + encodeOptions: { dataProjection: string; featureProjection: Projection } + + constructor(map: Map) { + this.encodeOptions = { + dataProjection: PROJECTION_WGS84, + featureProjection: map.getView().getProjection(), + } + } + + abstract export(features: Feature[], fileName: string): void + abstract generateContent( + features: Feature[], + fileName: string + ): void + + download( + fileName: string, + content: BlobPart, + ext = 'txt', + contentType = 'text/plain' + ) { + const file = `${sanitizeFilename(fileName)}.${sanitizeFilename(ext)}` + downloadFile(file, content, contentType) + } + + /** + * Explose the feature into multiple features if the geometry is a + * collection of geometries. + * @param features The features to explose. + * @return The exploded features. + * @private + */ + exploseFeatures(features: Feature[]) { + const explodedFeatures: Feature[] = [] + + features.forEach(feature => { + switch (feature.getGeometry()?.getType()) { + case 'GeometryCollection': { + const geometries = (( + feature.getGeometry() + )).getGeometriesArray() + geometries.forEach(geom => + explodedFeatures.push(this.cloneFeatureWithGeom(feature, geom)) + ) + break + } + case 'MultiLineString': { + const linestrings = (( + feature.getGeometry() + )).getLineStrings() + linestrings.forEach(geom => + explodedFeatures.push(this.cloneFeatureWithGeom(feature, geom)) + ) + break + } + default: + explodedFeatures.push(feature) + break + } + }) + + return explodedFeatures + } + + cloneFeatureWithGeom(feature: Feature, geom: Geometry) { + const newFeature = feature.clone() + newFeature.setGeometry(geom) + return newFeature + } +} diff --git a/src/services/export-feature/ol-format-gpx.ts b/src/services/export-feature/ol-format-gpx.ts new file mode 100644 index 00000000..2ce81c6e --- /dev/null +++ b/src/services/export-feature/ol-format-gpx.ts @@ -0,0 +1,42 @@ +import { Feature } from 'ol' +import { WriteOptions } from 'ol/format/Feature' +import olFormatGPX from 'ol/format/GPX' +import { writeStringTextNode } from 'ol/format/xsd' +import { Geometry } from 'ol/geom' +import { createElementNS } from 'ol/xml' + +export type WriteOptionsGPX = WriteOptions & { metadata: { name: string } } + +/** + * This class provides support for metadata name in GPX and it extends "ol.format.GPX" + */ +export class GPX extends olFormatGPX { + /** + * Encode an array of features in the GPX format as an XML node. + * LineString geometries are output as routes (``), and MultiLineString + * as tracks (``) + */ + writeFeaturesNode( + features: Feature[], + opt_options: WriteOptionsGPX + ) { + const gpx = super.writeFeaturesNode(features, opt_options) + + if ('metadata' in opt_options && 'name' in opt_options.metadata) { + const GPXNS = 'http://www.topografix.com/GPX/1/1' + const metadataEle = createElementNS(GPXNS, 'metadata') + const nameEle = createElementNS(GPXNS, 'name') + + writeStringTextNode(nameEle, opt_options.metadata.name) + metadataEle.appendChild(nameEle) + + if (gpx.childNodes.length > 0) { + gpx.insertBefore(metadataEle, gpx.childNodes[0]) + } else { + gpx.appendChild(metadataEle) + } + } + + return gpx + } +} diff --git a/src/services/utils.spec.ts b/src/services/utils.spec.ts index 2785f282..93873254 100644 --- a/src/services/utils.spec.ts +++ b/src/services/utils.spec.ts @@ -1,4 +1,4 @@ -import { debounce, stringToNumber } from './utils' +import { debounce, sanitizeFilename, stringToNumber } from './utils' const mock = vi.fn(() => 'function to be debounced') @@ -54,3 +54,31 @@ describe('#stringToNumber', () => { }) }) }) + +describe('#sanitizeFilename', () => { + it('should replace spaces with underscores', () => { + expect(sanitizeFilename('file name with spaces')).toBe( + 'file_name_with_spaces' + ) + }) + + it('should remove special characters except hyphen and underscore', () => { + expect(sanitizeFilename('file@name#with$special%chars!')).toBe( + 'filenamewithspecialchars' + ) + }) + + it('should preserve hyphens and underscores', () => { + expect(sanitizeFilename('file-name_with-hyphens_and_underscores')).toBe( + 'file-name_with-hyphens_and_underscores' + ) + }) + + it('should handle filenames with only spaces', () => { + expect(sanitizeFilename(' ')).toBe('_') + }) + + it('should handle filenames with only special characters', () => { + expect(sanitizeFilename('###@@@$$$')).toBe('_') + }) +}) diff --git a/src/services/utils.ts b/src/services/utils.ts index e086474b..13b50fe7 100644 --- a/src/services/utils.ts +++ b/src/services/utils.ts @@ -107,3 +107,29 @@ export const isIE = testUserAgent( /(?:Trident.*rv[ :]?11\.|msie|iemobile|Windows Phone)/i ) export const isEdge = testUserAgent(/Edge/i) + +/** + * Sanitize filenames: replace white space with _ and strip any special character + * @param name The string to sanitize. + * @return The sanitized string. + */ +export function sanitizeFilename(filename: string) { + return filename.replace(/\s+/g, '_').replace(/[^a-z0-9\-_]/gi, '') || '_' +} + +export function downloadFile( + filename: string, + content: BlobPart, + contentType = 'text/plain' +) { + const blob = new Blob([content], { type: contentType }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +}