diff --git a/package.json b/package.json index d9388928..201541fd 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,8 @@ }, "homepage": "http://wiki.openstreetmap.org/wiki/UMap", "dependencies": { + "@dwayneparton/geojson-to-gpx": "^0.2.0", + "@placemarkio/tokml": "^0.3.3", "@tmcw/togeojson": "^5.8.0", "colorbrewer": "^1.5.6", "csv2geojson": "5.1.1", @@ -62,9 +64,7 @@ "leaflet.path.drag": "0.0.6", "leaflet.photon": "0.9.1", "osmtogeojson": "^3.0.0-beta.3", - "simple-statistics": "^7.8.3", - "togpx": "^0.5.4", - "tokml": "0.4.0" + "simple-statistics": "^7.8.3" }, "browserslist": [ "> 0.5%, last 2 versions, Firefox ESR, not dead, not op_mini all" diff --git a/scripts/vendorsjs.sh b/scripts/vendorsjs.sh index 163f72f7..6236936e 100755 --- a/scripts/vendorsjs.sh +++ b/scripts/vendorsjs.sh @@ -19,16 +19,18 @@ mkdir -p umap/static/umap/vendors/formbuilder/ && cp -r node_modules/leaflet-for mkdir -p umap/static/umap/vendors/measurable/ && cp -r node_modules/leaflet-measurable/Leaflet.Measurable.* umap/static/umap/vendors/measurable/ mkdir -p umap/static/umap/vendors/photon/ && cp -r node_modules/leaflet.photon/leaflet.photon.js umap/static/umap/vendors/photon/ mkdir -p umap/static/umap/vendors/csv2geojson/ && cp -r node_modules/csv2geojson/csv2geojson.js umap/static/umap/vendors/csv2geojson/ -mkdir -p umap/static/umap/vendors/togeojson/ && cp -r node_modules/@tmcw/togeojson/dist/togeojson.umd.* umap/static/umap/vendors/togeojson/ +mkdir -p umap/static/umap/vendors/togeojson/ && cp node_modules/@tmcw/togeojson/dist/togeojson.es.mjs umap/static/umap/vendors/togeojson/togeojson.es.js +mkdir -p umap/static/umap/vendors/togeojson/ && cp node_modules/@tmcw/togeojson/dist/togeojson.es.mjs.map umap/static/umap/vendors/togeojson/togeojson.es.mjs.map +mkdir -p umap/static/umap/vendors/tokml/ && cp node_modules/@placemarkio/tokml/dist/tokml.es.mjs umap/static/umap/vendors/tokml/tokml.es.js +mkdir -p umap/static/umap/vendors/tokml/ && cp node_modules/@placemarkio/tokml/dist/tokml.es.mjs.map umap/static/umap/vendors/tokml/tokml.es.mjs.map mkdir -p umap/static/umap/vendors/osmtogeojson/ && cp -r node_modules/osmtogeojson/osmtogeojson.js umap/static/umap/vendors/osmtogeojson/ mkdir -p umap/static/umap/vendors/georsstogeojson/ && cp -r node_modules/georsstogeojson/GeoRSSToGeoJSON.js umap/static/umap/vendors/georsstogeojson/ -mkdir -p umap/static/umap/vendors/togpx/ && cp -r node_modules/togpx/togpx.js umap/static/umap/vendors/togpx/ -mkdir -p umap/static/umap/vendors/tokml && cp -r node_modules/tokml/tokml.js umap/static/umap/vendors/tokml mkdir -p umap/static/umap/vendors/locatecontrol/ && cp -r node_modules/leaflet.locatecontrol/dist/L.Control.Locate.min.* umap/static/umap/vendors/locatecontrol/ mkdir -p umap/static/umap/vendors/dompurify/ && cp -r node_modules/dompurify/dist/purify.es.mjs umap/static/umap/vendors/dompurify/purify.es.js mkdir -p umap/static/umap/vendors/dompurify/ && cp -r node_modules/dompurify/dist/purify.es.mjs.map umap/static/umap/vendors/dompurify/purify.es.mjs.map mkdir -p umap/static/umap/vendors/colorbrewer/ && cp node_modules/colorbrewer/index.js umap/static/umap/vendors/colorbrewer/colorbrewer.js mkdir -p umap/static/umap/vendors/simple-statistics/ && cp node_modules/simple-statistics/dist/simple-statistics.min.* umap/static/umap/vendors/simple-statistics/ mkdir -p umap/static/umap/vendors/iconlayers/ && cp node_modules/leaflet-iconlayers/dist/* umap/static/umap/vendors/iconlayers/ +mkdir -p umap/static/umap/vendors/geojson-to-gpx/ && cp node_modules/@dwayneparton/geojson-to-gpx/dist/index.js umap/static/umap/vendors/geojson-to-gpx/ echo 'Done!' diff --git a/umap/static/umap/js/modules/formatter.js b/umap/static/umap/js/modules/formatter.js new file mode 100644 index 00000000..64ac5e2b --- /dev/null +++ b/umap/static/umap/js/modules/formatter.js @@ -0,0 +1,153 @@ +/* Uses globals for: csv2geojson, osmtogeojson, GeoRSSToGeoJSON (not available as ESM) */ +import { translate } from './i18n.js' + +export const EXPORT_FORMATS = { + geojson: { + formatter: async (map) => JSON.stringify(map.toGeoJSON(), null, 2), + ext: '.geojson', + filetype: 'application/json', + }, + gpx: { + formatter: async (map) => await map.formatter.toGPX(map.toGeoJSON()), + ext: '.gpx', + filetype: 'application/gpx+xml', + }, + kml: { + formatter: async (map) => await map.formatter.toKML(map.toGeoJSON()), + ext: '.kml', + filetype: 'application/vnd.google-earth.kml+xml', + }, + csv: { + formatter: async (map) => { + const table = [] + map.eachFeature((feature) => { + const row = feature.toGeoJSON().properties + const center = feature.getCenter() + delete row._umap_options + row.Latitude = center.lat + row.Longitude = center.lng + table.push(row) + }) + return csv2geojson.dsv.csvFormat(table) + }, + ext: '.csv', + filetype: 'text/csv', + }, +} + +export class Formatter { + async fromGPX(str) { + const togeojson = await import('../../vendors/togeojson/togeojson.es.js') + return togeojson.gpx(this.toDom(str)) + } + + async fromKML(str) { + const togeojson = await import('../../vendors/togeojson/togeojson.es.js') + return togeojson.kml(this.toDom(str), { + skipNullGeometry: true, + }) + } + + async fromGeoJSON(str) { + try { + return JSON.parse(str) + } catch (err) { + U.Alert.error(`Invalid JSON file: ${err}`) + } + } + + async fromOSM(str) { + let src + try { + src = JSON.parse(str) + } catch (e) { + src = this.toDom(str) + } + return osmtogeojson(src, { flatProperties: true }) + } + + fromCSV(str, callback) { + csv2geojson.csv2geojson( + str, + { + delimiter: 'auto', + includeLatLon: false, + }, + (err, result) => { + // csv2geojson fallback to null geometries when it cannot determine + // lat or lon columns. This is valid geojson, but unwanted from a user + // point of view. + if (result?.features.length) { + if (result.features[0].geometry === null) { + err = { + type: 'Error', + message: translate('Cannot determine latitude and longitude columns.'), + } + } + } + if (err) { + let message + if (err.type === 'Error') { + message = err.message + } else { + message = translate('{count} errors during import: {message}', { + count: err.length, + message: err[0].message, + }) + } + U.Alert.error(message, 10000) + console.error(err) + } + if (result?.features.length) { + callback(result) + } + } + ) + } + + async fromGeoRSS(str) { + return GeoRSSToGeoJSON(this.toDom(c)) + } + + toDom(x) { + const doc = new DOMParser().parseFromString(x, 'text/xml') + const errorNode = doc.querySelector('parsererror') + if (errorNode) { + U.Alert.error(translate('Cannot parse data')) + } + return doc + } + + async parse(str, format) { + switch (format) { + case 'csv': + return new Promise((resolve, reject) => { + return this.fromCSV(str, (data) => resolve(data)) + }) + case 'gpx': + return await this.fromGPX(str) + case 'kml': + return await this.fromKML(str) + case 'osm': + return await this.fromOSM(str) + case 'georss': + return await this.fromGeoRSS(str) + case 'geojson': + return await this.fromGeoJSON(str) + } + } + + async toGPX(geojson) { + const togpx = await import('../../vendors/geojson-to-gpx/index.js') + for (const feature of geojson.features) { + feature.properties.desc = feature.properties.description + } + const gpx = togpx.default(geojson) + return new XMLSerializer().serializeToString(gpx) + } + + async toKML(geojson) { + const tokml = await import('../../vendors/tokml/tokml.es.js') + return tokml.toKML(geojson) + } +} diff --git a/umap/static/umap/js/modules/global.js b/umap/static/umap/js/modules/global.js index 9e71921c..11b62b2b 100644 --- a/umap/static/umap/js/modules/global.js +++ b/umap/static/umap/js/modules/global.js @@ -7,12 +7,14 @@ import { AjaxAutocomplete, AjaxAutocompleteMultiple } from './autocomplete.js' import Browser from './browser.js' import Caption from './caption.js' import Facets from './facets.js' +import { Formatter } from './formatter.js' import Help from './help.js' import Importer from './importer.js' import Orderable from './orderable.js' import { HTTPError, NOKError, Request, RequestError, ServerRequest } from './request.js' import Rules from './rules.js' import { SCHEMA } from './schema.js' +import Share from './share.js' import Slideshow from './slideshow.js' import { SyncEngine } from './sync/engine.js' import Dialog from './ui/dialog.js' @@ -36,6 +38,7 @@ window.U = { Dialog, EditPanel, Facets, + Formatter, FullPanel, Help, HTTPError, @@ -48,6 +51,7 @@ window.U = { Rules, SCHEMA, ServerRequest, + Share, Slideshow, SyncEngine, Tooltip, diff --git a/umap/static/umap/js/umap.share.js b/umap/static/umap/js/modules/share.js similarity index 69% rename from umap/static/umap/js/umap.share.js rename to umap/static/umap/js/modules/share.js index a2ab6106..363066ae 100644 --- a/umap/static/umap/js/umap.share.js +++ b/umap/static/umap/js/modules/share.js @@ -1,43 +1,11 @@ -U.Share = L.Class.extend({ - EXPORT_TYPES: { - geojson: { - formatter: (map) => JSON.stringify(map.toGeoJSON(), null, 2), - ext: '.geojson', - filetype: 'application/json', - }, - gpx: { - formatter: (map) => togpx(map.toGeoJSON()), - ext: '.gpx', - filetype: 'application/gpx+xml', - }, - kml: { - formatter: (map) => tokml(map.toGeoJSON()), - ext: '.kml', - filetype: 'application/vnd.google-earth.kml+xml', - }, - csv: { - formatter: (map) => { - const table = [] - map.eachFeature((feature) => { - const row = feature.toGeoJSON().properties - const center = feature.getCenter() - delete row._umap_options - row.Latitude = center.lat - row.Longitude = center.lng - table.push(row) - }) - return csv2geojson.dsv.csvFormat(table) - }, - ext: '.csv', - filetype: 'text/csv', - }, - }, +import { EXPORT_FORMATS } from './formatter.js' - initialize: function (map) { +export default class Share { + constructor(map) { this.map = map - }, + } - build: function () { + build() { this.container = L.DomUtil.create('div', '') this.title = L.DomUtil.createTitle( this.container, @@ -63,16 +31,10 @@ U.Share = L.Class.extend({ L.DomUtil.add('h4', '', this.container, L._('Download')) L.DomUtil.add('small', 'label', this.container, L._("Only visible layers' data")) - for (const key in this.EXPORT_TYPES) { - if (this.EXPORT_TYPES.hasOwnProperty(key)) { - L.DomUtil.createButton( - 'download-file', - this.container, - this.EXPORT_TYPES[key].name || key, - () => this.download(key), - this - ) - } + for (const format of Object.keys(EXPORT_FORMATS)) { + L.DomUtil.createButton('download-file', this.container, format, () => + this.download(format) + ) } L.DomUtil.create('div', 'vspace', this.container) L.DomUtil.add( @@ -135,7 +97,7 @@ U.Share = L.Class.extend({ for (let i = 0; i < this.map.HIDDABLE_CONTROLS.length; i++) { UIFields.push(`queryString.${this.map.HIDDABLE_CONTROLS[i]}Control`) } - const iframeExporter = new U.IframeExporter(this.map) + const iframeExporter = new IframeExporter(this.map) const buildIframeCode = () => { iframe.textContent = iframeExporter.build() exportUrl.value = window.location.protocol + iframeExporter.buildUrl() @@ -149,24 +111,24 @@ U.Share = L.Class.extend({ L._('Embed and link options') ) iframeOptions.appendChild(builder.build()) - }, + } - open: function () { + open() { if (!this.container) this.build() this.map.panel.open({ content: this.container }) - }, + } - format: function (mode) { - const type = this.EXPORT_TYPES[mode] - const content = type.formatter(this.map) + async format(mode) { + const type = EXPORT_FORMATS[mode] + const content = await type.formatter(this.map) let name = this.map.options.name || 'data' name = name.replace(/[^a-z0-9]/gi, '_').toLowerCase() const filename = name + type.ext return { content, filetype: type.filetype, filename } - }, + } - download: function (mode) { - const { content, filetype, filename } = this.format(mode) + async download(mode) { + const { content, filetype, filename } = await this.format(mode) const blob = new Blob([content], { type: filetype }) window.URL = window.URL || window.webkitURL const el = document.createElement('a') @@ -176,50 +138,49 @@ U.Share = L.Class.extend({ document.body.appendChild(el) el.click() document.body.removeChild(el) - }, -}) + } +} -U.IframeExporter = L.Evented.extend({ - options: { - includeFullScreenLink: true, - currentView: false, - keepCurrentDatalayers: false, - viewCurrentFeature: false, - }, - - queryString: { - scaleControl: false, - miniMap: false, - scrollWheelZoom: false, - zoomControl: true, - editMode: 'disabled', - moreControl: true, - searchControl: null, - tilelayersControl: null, - embedControl: null, - datalayersControl: true, - onLoadPanel: 'none', - captionBar: false, - captionMenus: true, - }, - - dimensions: { - width: '100%', - height: '300px', - }, - - initialize: function (map) { +class IframeExporter { + constructor(map) { this.map = map this.baseUrl = U.Utils.getBaseUrl() + this.options = { + includeFullScreenLink: true, + currentView: false, + keepCurrentDatalayers: false, + viewCurrentFeature: false, + } + + this.queryString = { + scaleControl: false, + miniMap: false, + scrollWheelZoom: false, + zoomControl: true, + editMode: 'disabled', + moreControl: true, + searchControl: null, + tilelayersControl: null, + embedControl: null, + datalayersControl: true, + onLoadPanel: 'none', + captionBar: false, + captionMenus: true, + } + + this.dimensions = { + width: '100%', + height: '300px', + } // Use map default, not generic default this.queryString.onLoadPanel = this.map.getOption('onLoadPanel') - }, + } - getMap: function () { + getMap() { return this.map - }, + } - buildUrl: function (options) { + buildUrl(options) { const datalayers = [] if (this.options.viewCurrentFeature && this.map.currentFeature) { this.queryString.feature = this.map.currentFeature.getSlug() @@ -239,9 +200,9 @@ U.IframeExporter = L.Evented.extend({ const currentView = this.options.currentView ? window.location.hash : '' const queryString = L.extend({}, this.queryString, options) return `${this.baseUrl}?${U.Utils.buildQueryString(queryString)}${currentView}` - }, + } - build: function () { + build() { const iframeUrl = this.buildUrl() let code = `` if (this.options.includeFullScreenLink) { @@ -249,5 +210,5 @@ U.IframeExporter = L.Evented.extend({ code += `

${L._('See full screen')}

` } return code - }, -}) + } +} diff --git a/umap/static/umap/js/umap.js b/umap/static/umap/js/umap.js index d1ef06d2..021734f0 100644 --- a/umap/static/umap/js/umap.js +++ b/umap/static/umap/js/umap.js @@ -29,7 +29,7 @@ L.Map.mergeOptions({ U.Map = L.Map.extend({ includes: [ControlsMixin], - initialize: function (el, geojson) { + initialize: async function (el, geojson) { this.sync_engine = new U.SyncEngine(this) this.sync = this.sync_engine.proxy(this) // Locale name (pt_PT, en_US…) @@ -116,6 +116,8 @@ U.Map = L.Map.extend({ // Needed for actions labels this.help = new U.Help(this) + this.formatter = new U.Formatter(this) + this.initControls() // Needs locate control and hash to exist this.initCenter() @@ -174,23 +176,7 @@ U.Map = L.Map.extend({ } this._default_extent = true this.options.name = L._('Untitled map') - let data = L.Util.queryString('data', null) - const url = new URL(window.location.href) - const dataUrls = new URLSearchParams(url.search).getAll('dataUrl') - const dataFormat = L.Util.queryString('dataFormat', 'geojson') - if (dataUrls.length) { - for (let dataUrl of dataUrls) { - dataUrl = decodeURIComponent(dataUrl) - dataUrl = this.localizeUrl(dataUrl) - dataUrl = this.proxyUrl(dataUrl) - const datalayer = this.createDataLayer() - datalayer.importFromUrl(dataUrl, dataFormat) - } - } else if (data) { - data = decodeURIComponent(data) - const datalayer = this.createDataLayer() - datalayer.importRaw(data, dataFormat) - } + await this.loadDataFromQueryString() } this.slideshow = new U.Slideshow(this, this.options.slideshow) @@ -295,7 +281,27 @@ U.Map = L.Map.extend({ } }, - setViewFromQueryString: function () { + loadDataFromQueryString: async function() { + let data = L.Util.queryString('data', null) + const url = new URL(window.location.href) + const dataUrls = new URLSearchParams(url.search).getAll('dataUrl') + const dataFormat = L.Util.queryString('dataFormat', 'geojson') + if (dataUrls.length) { + for (let dataUrl of dataUrls) { + dataUrl = decodeURIComponent(dataUrl) + dataUrl = this.localizeUrl(dataUrl) + dataUrl = this.proxyUrl(dataUrl) + const datalayer = this.createDataLayer() + await datalayer.importFromUrl(dataUrl, dataFormat) + } + } else if (data) { + data = decodeURIComponent(data) + const datalayer = this.createDataLayer() + await datalayer.importRaw(data, dataFormat) + } + }, + + setViewFromQueryString: async function () { if (this.options.noControl) return this.initCaptionBar() if (L.Util.queryString('share')) { diff --git a/umap/static/umap/js/umap.layer.js b/umap/static/umap/js/umap.layer.js index 857befe0..946a4282 100644 --- a/umap/static/umap/js/umap.layer.js +++ b/umap/static/umap/js/umap.layer.js @@ -792,11 +792,9 @@ U.DataLayer = L.Evented.extend({ const response = await this.map.request.get(url) if (response?.ok) { this.clear() - this.rawToGeoJSON( - await response.text(), - this.options.remoteData.format, - (geojson) => this.fromGeoJSON(geojson) - ) + this.map.formatter + .parse(await response.text(), this.options.remoteData.format) + .then((geojson) => this.fromGeoJSON(geojson)) } }, @@ -930,83 +928,6 @@ U.DataLayer = L.Evented.extend({ } }, - addRawData: function (c, type) { - this.rawToGeoJSON(c, type, (geojson) => this.addData(geojson)) - }, - - rawToGeoJSON: (c, type, callback) => { - const toDom = (x) => { - const doc = new DOMParser().parseFromString(x, 'text/xml') - const errorNode = doc.querySelector('parsererror') - if (errorNode) { - U.Alert.error(L._('Cannot parse data')) - } - return doc - } - - // TODO add a duck typing guessType - if (type === 'csv') { - csv2geojson.csv2geojson( - c, - { - delimiter: 'auto', - includeLatLon: false, - }, - (err, result) => { - // csv2geojson fallback to null geometries when it cannot determine - // lat or lon columns. This is valid geojson, but unwanted from a user - // point of view. - if (result?.features.length) { - if (result.features[0].geometry === null) { - err = { - type: 'Error', - message: L._('Cannot determine latitude and longitude columns.'), - } - } - } - if (err) { - let message - if (err.type === 'Error') { - message = err.message - } else { - message = L._('{count} errors during import: {message}', { - count: err.length, - message: err[0].message, - }) - } - U.Alert.error(message, 10000) - console.error(err) - } - if (result?.features.length) { - callback(result) - } - } - ) - } else if (type === 'gpx') { - callback(toGeoJSON.gpx(toDom(c))) - } else if (type === 'georss') { - callback(GeoRSSToGeoJSON(toDom(c))) - } else if (type === 'kml') { - callback(toGeoJSON.kml(toDom(c))) - } else if (type === 'osm') { - let d - try { - d = JSON.parse(c) - } catch (e) { - d = toDom(c) - } - callback(osmtogeojson(d, { flatProperties: true })) - } else if (type === 'geojson') { - try { - const gj = JSON.parse(c) - callback(gj) - } catch (err) { - U.Alert.error(`Invalid JSON file: ${err}`) - return - } - } - }, - // The choice of the name is not ours, because it is required by Leaflet. // It is misleading, as the returned objects are uMap objects, and not // GeoJSON features. @@ -1136,10 +1057,12 @@ U.DataLayer = L.Evented.extend({ return new U.Polygon(this.map, latlngs, { geojson: geojson, datalayer: this }, id) }, - importRaw: function (raw, type) { - this.addRawData(raw, type) + importRaw: async function (raw, format) { + this.map.formatter + .parse(raw, format) + .then((geojson) => this.addData(geojson)) + .then(() => this.zoomTo()) this.isDirty = true - this.zoomTo() }, importFromFiles: function (files, type) { diff --git a/umap/static/umap/js/umap.permissions.js b/umap/static/umap/js/umap.permissions.js index 42630301..58cdd2e5 100644 --- a/umap/static/umap/js/umap.permissions.js +++ b/umap/static/umap/js/umap.permissions.js @@ -116,7 +116,7 @@ U.MapPermissions = L.Class.extend({ L._('Advanced actions') ) const advancedButtons = L.DomUtil.create('div', 'button-bar', advancedActions) - const download = L.DomUtil.createButton( + L.DomUtil.createButton( 'button', advancedButtons, L._('Attach the map to my account'), diff --git a/umap/storage.py b/umap/storage.py index aaa6aee8..52c9ace2 100644 --- a/umap/storage.py +++ b/umap/storage.py @@ -32,7 +32,11 @@ class UmapManifestStaticFilesStorage(ManifestStaticFilesStorage): 'import"%(url)s"\n', ), ( - r"""(?Pimport\(["'](?P.*?)["']\))""", + r"""(?Pimport\(["'](?P.*?)["']\))\.then""", + """import("%(url)s")""", + ), + ( + r"""(?Pawait import\(["'](?P.*?)["']\))""", """import("%(url)s")""", ), ), diff --git a/umap/templates/umap/js.html b/umap/templates/umap/js.html index 4bdaeaeb..9659b547 100644 --- a/umap/templates/umap/js.html +++ b/umap/templates/umap/js.html @@ -19,7 +19,6 @@ - - - @@ -55,6 +52,5 @@ - diff --git a/umap/tests/integration/test_export_map.py b/umap/tests/integration/test_export_map.py index 1f65d46f..9f0d0b6a 100644 --- a/umap/tests/integration/test_export_map.py +++ b/umap/tests/integration/test_export_map.py @@ -218,8 +218,7 @@ def test_gpx_export(map, live_server, bootstrap, page): download.save_as(path) assert ( path.read_text() - == """testname=test -description=Some descriptionname polyname=name polytestname=test""" + == """testSome descriptiontest""" ) @@ -235,7 +234,7 @@ def test_kml_export(map, live_server, bootstrap, page): download.save_as(path) assert ( path.read_text() - == """name polyname poly11.25,53.585984 10.151367,52.975108 12.689209,52.167194 14.084473,53.199452 12.634277,53.618579 11.25,53.585984 11.25,53.585984testSome description[object Object]testSome description-0.274658,52.57635test[object Object]test-0.571289,54.476422 0.439453,54.610255 1.724854,53.448807 4.163818,53.988395 5.306396,53.533778 6.591797,53.709714 7.042236,53.350551""" + == """\n\nname poly\n \n\n 11.25,53.585984\n10.151367,52.975108\n12.689209,52.167194\n14.084473,53.199452\n12.634277,53.618579\n11.25,53.585984\n11.25,53.585984\n\ntestSome description\n {"color":"OliveDrab"}\n -0.274658,52.57635\n\ntest\n {"fill":false,"opacity":0.6}\n -0.571289,54.476422\n0.439453,54.610255\n1.724854,53.448807\n4.163818,53.988395\n5.306396,53.533778\n6.591797,53.709714\n7.042236,53.350551""" )