nakarte

Source code of https://map.sikmir.ru (fork)
git clone git://git.sikmir.ru/nakarte
Log | Files | Refs | LICENSE

commit 1f6f48e5286f48ffcc87255ce3609d17100a8d9b
parent 53147b8d1b8a1ebe76c45bb936fcdb53bfafb268
Author: Sergej Orlov <wladimirych@gmail.com>
Date:   Mon,  5 Mar 2018 00:32:37 +0300

[tracks] save tracks to server

Diffstat:
Msrc/App.js | 8++++----
Msrc/config.js | 3++-
Msrc/lib/leaflet.control.track-list/lib/geo_file_formats.js | 25+++++--------------------
Asrc/lib/leaflet.control.track-list/lib/md5.js | 281+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib/leaflet.control.track-list/lib/nktk.js | 59++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/lib/leaflet.control.track-list/track-list.hash-state.js | 42+++++++++++++++++++++++++++---------------
Msrc/lib/leaflet.control.track-list/track-list.js | 68++++++++++++++++++++++++++++++++++++--------------------------------
7 files changed, 411 insertions(+), 75 deletions(-)

diff --git a/src/App.js b/src/App.js @@ -19,14 +19,13 @@ import 'lib/leaflet.control.track-list/track-list.localstorage'; import enableLayersControlAdaptiveHeight from 'lib/leaflet.control.layers.adaptive-height'; import enableLayersMinimize from 'lib/leaflet.control.layers.minimize'; import enableLayersConfig from 'lib/leaflet.control.layers.configure'; -import hashState from 'lib/leaflet.hashState/hashState'; import raiseControlsOnFocus from 'lib/leaflet.controls.raise-on-focus'; import getLayers from 'layers'; import 'lib/leaflet.control.layers.events'; import 'lib/leaflet.control.jnx'; import 'lib/leaflet.control.jnx/hash-state'; import 'lib/leaflet.control.azimuth'; - +import {hashState, bindHashStateReadOnly} from 'lib/leaflet.hashState/hashState'; function setUp() { fixAll(); @@ -93,10 +92,11 @@ function setUp() { /////////// controls bottom-right corner tracklist.addTo(map); - if (!hashState.getState('nktk')) { + if (!hashState.getState('nktk') && !hashState.getState('nktl')) { tracklist.loadTracksFromStorage(); } - tracklist.enableHashState('nktk'); + bindHashStateReadOnly('nktk', tracklist.loadNktkFromHash.bind(tracklist)); + bindHashStateReadOnly('nktl', tracklist.loadNktlFromHash.bind(tracklist)); ////////// adaptive layout diff --git a/src/config.js b/src/config.js @@ -7,5 +7,6 @@ export default Object.assign({ CORSProxyUrl: 'http://proxy.nakarte.tk/', elevationsServer: 'http://elevation.nakarte.tk/', newsUrl: 'http://about.nakarte.tk', - wikimediaCommonsCoverageUrl: 'http://tiles.nakarte.tk/wikimedia_commons_images/{z}/{x}/{y}' + wikimediaCommonsCoverageUrl: 'http://tiles.nakarte.tk/wikimedia_commons_images/{z}/{x}/{y}', + tracksStorageServer: 'http://tracks.nakarte.tk', }, secrets); diff --git a/src/lib/leaflet.control.track-list/lib/geo_file_formats.js b/src/lib/leaflet.control.track-list/lib/geo_file_formats.js @@ -8,7 +8,7 @@ import urlViaCorsProxy from 'lib/CORSProxy'; import {isGpsiesUrl, gpsiesXhrOptions, gpsiesParser} from './gpsies'; import {isStravaUrl, stravaXhrOptions, stravaParser} from './strava'; import {isEndomondoUrl, endomonXhrOptions, endomondoParser} from './endomondo'; -import {parseTrackUrlData, parseNktk} from './nktk'; +import {parseTrackUrlData, parseNakarteUrl, isNakarteLinkUrl, nakarteLinkXhrOptions, nakarteLinkParser} from './nktk'; function xmlGetNodeText(node) { @@ -552,25 +552,6 @@ function parseTrackUrl(s) { return parseTrackUrlData(s.substring(i + 8)); } -function parseNakarteUrl(s) { - var i = s.indexOf('#'); - if (i === -1) { - return null; - } - i = s.indexOf('nktk=', i + 1); - if (i === -1) { - return null; - } - s = s.substring(i + 5).split('/'); - var geodataArray = []; - for (i = 0; i < s.length; i++) { - if (s[i]) { - geodataArray.push.apply(geodataArray, parseNktk(s[i])); - } - } - return geodataArray; -} - function simpleTrackFetchOptions(url) { return [{ @@ -607,7 +588,11 @@ function loadFromUrl(url) { } else if (isStravaUrl(url)) { urlToRequest = stravaXhrOptions; parser = stravaParser; + } else if (isNakarteLinkUrl(url)) { + urlToRequest = nakarteLinkXhrOptions; + parser = nakarteLinkParser; } + const requests = urlToRequest(url); return Promise.all(requests.map((request) => fetch(request.url, request.options))) .then( diff --git a/src/lib/leaflet.control.track-list/lib/md5.js b/src/lib/leaflet.control.track-list/lib/md5.js @@ -0,0 +1,280 @@ +/* + * JavaScript MD5 + * https://github.com/blueimp/JavaScript-MD5 + * + * Copyright 2011, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * https://opensource.org/licenses/MIT + * + * Based on + * A JavaScript implementation of the RSA Data Security, Inc. MD5 Message + * Digest Algorithm, as defined in RFC 1321. + * Version 2.2 Copyright (C) Paul Johnston 1999 - 2009 + * Other contributors: Greg Holt, Andrew Kepert, Ydnar, Lostinet + * Distributed under the BSD License + * See http://pajhome.org.uk/crypt/md5 for more info. + */ + +/* global define */ + +;(function ($) { + 'use strict' + + /* + * Add integers, wrapping at 2^32. This uses 16-bit operations internally + * to work around bugs in some JS interpreters. + */ + function safeAdd (x, y) { + var lsw = (x & 0xffff) + (y & 0xffff) + var msw = (x >> 16) + (y >> 16) + (lsw >> 16) + return (msw << 16) | (lsw & 0xffff) + } + + /* + * Bitwise rotate a 32-bit number to the left. + */ + function bitRotateLeft (num, cnt) { + return (num << cnt) | (num >>> (32 - cnt)) + } + + /* + * These functions implement the four basic operations the algorithm uses. + */ + function md5cmn (q, a, b, x, s, t) { + return safeAdd(bitRotateLeft(safeAdd(safeAdd(a, q), safeAdd(x, t)), s), b) + } + function md5ff (a, b, c, d, x, s, t) { + return md5cmn((b & c) | (~b & d), a, b, x, s, t) + } + function md5gg (a, b, c, d, x, s, t) { + return md5cmn((b & d) | (c & ~d), a, b, x, s, t) + } + function md5hh (a, b, c, d, x, s, t) { + return md5cmn(b ^ c ^ d, a, b, x, s, t) + } + function md5ii (a, b, c, d, x, s, t) { + return md5cmn(c ^ (b | ~d), a, b, x, s, t) + } + + /* + * Calculate the MD5 of an array of little-endian words, and a bit length. + */ + function binlMD5 (x, len) { + /* append padding */ + x[len >> 5] |= 0x80 << (len % 32) + x[((len + 64) >>> 9 << 4) + 14] = len + + var i + var olda + var oldb + var oldc + var oldd + var a = 1732584193 + var b = -271733879 + var c = -1732584194 + var d = 271733878 + + for (i = 0; i < x.length; i += 16) { + olda = a + oldb = b + oldc = c + oldd = d + + a = md5ff(a, b, c, d, x[i], 7, -680876936) + d = md5ff(d, a, b, c, x[i + 1], 12, -389564586) + c = md5ff(c, d, a, b, x[i + 2], 17, 606105819) + b = md5ff(b, c, d, a, x[i + 3], 22, -1044525330) + a = md5ff(a, b, c, d, x[i + 4], 7, -176418897) + d = md5ff(d, a, b, c, x[i + 5], 12, 1200080426) + c = md5ff(c, d, a, b, x[i + 6], 17, -1473231341) + b = md5ff(b, c, d, a, x[i + 7], 22, -45705983) + a = md5ff(a, b, c, d, x[i + 8], 7, 1770035416) + d = md5ff(d, a, b, c, x[i + 9], 12, -1958414417) + c = md5ff(c, d, a, b, x[i + 10], 17, -42063) + b = md5ff(b, c, d, a, x[i + 11], 22, -1990404162) + a = md5ff(a, b, c, d, x[i + 12], 7, 1804603682) + d = md5ff(d, a, b, c, x[i + 13], 12, -40341101) + c = md5ff(c, d, a, b, x[i + 14], 17, -1502002290) + b = md5ff(b, c, d, a, x[i + 15], 22, 1236535329) + + a = md5gg(a, b, c, d, x[i + 1], 5, -165796510) + d = md5gg(d, a, b, c, x[i + 6], 9, -1069501632) + c = md5gg(c, d, a, b, x[i + 11], 14, 643717713) + b = md5gg(b, c, d, a, x[i], 20, -373897302) + a = md5gg(a, b, c, d, x[i + 5], 5, -701558691) + d = md5gg(d, a, b, c, x[i + 10], 9, 38016083) + c = md5gg(c, d, a, b, x[i + 15], 14, -660478335) + b = md5gg(b, c, d, a, x[i + 4], 20, -405537848) + a = md5gg(a, b, c, d, x[i + 9], 5, 568446438) + d = md5gg(d, a, b, c, x[i + 14], 9, -1019803690) + c = md5gg(c, d, a, b, x[i + 3], 14, -187363961) + b = md5gg(b, c, d, a, x[i + 8], 20, 1163531501) + a = md5gg(a, b, c, d, x[i + 13], 5, -1444681467) + d = md5gg(d, a, b, c, x[i + 2], 9, -51403784) + c = md5gg(c, d, a, b, x[i + 7], 14, 1735328473) + b = md5gg(b, c, d, a, x[i + 12], 20, -1926607734) + + a = md5hh(a, b, c, d, x[i + 5], 4, -378558) + d = md5hh(d, a, b, c, x[i + 8], 11, -2022574463) + c = md5hh(c, d, a, b, x[i + 11], 16, 1839030562) + b = md5hh(b, c, d, a, x[i + 14], 23, -35309556) + a = md5hh(a, b, c, d, x[i + 1], 4, -1530992060) + d = md5hh(d, a, b, c, x[i + 4], 11, 1272893353) + c = md5hh(c, d, a, b, x[i + 7], 16, -155497632) + b = md5hh(b, c, d, a, x[i + 10], 23, -1094730640) + a = md5hh(a, b, c, d, x[i + 13], 4, 681279174) + d = md5hh(d, a, b, c, x[i], 11, -358537222) + c = md5hh(c, d, a, b, x[i + 3], 16, -722521979) + b = md5hh(b, c, d, a, x[i + 6], 23, 76029189) + a = md5hh(a, b, c, d, x[i + 9], 4, -640364487) + d = md5hh(d, a, b, c, x[i + 12], 11, -421815835) + c = md5hh(c, d, a, b, x[i + 15], 16, 530742520) + b = md5hh(b, c, d, a, x[i + 2], 23, -995338651) + + a = md5ii(a, b, c, d, x[i], 6, -198630844) + d = md5ii(d, a, b, c, x[i + 7], 10, 1126891415) + c = md5ii(c, d, a, b, x[i + 14], 15, -1416354905) + b = md5ii(b, c, d, a, x[i + 5], 21, -57434055) + a = md5ii(a, b, c, d, x[i + 12], 6, 1700485571) + d = md5ii(d, a, b, c, x[i + 3], 10, -1894986606) + c = md5ii(c, d, a, b, x[i + 10], 15, -1051523) + b = md5ii(b, c, d, a, x[i + 1], 21, -2054922799) + a = md5ii(a, b, c, d, x[i + 8], 6, 1873313359) + d = md5ii(d, a, b, c, x[i + 15], 10, -30611744) + c = md5ii(c, d, a, b, x[i + 6], 15, -1560198380) + b = md5ii(b, c, d, a, x[i + 13], 21, 1309151649) + a = md5ii(a, b, c, d, x[i + 4], 6, -145523070) + d = md5ii(d, a, b, c, x[i + 11], 10, -1120210379) + c = md5ii(c, d, a, b, x[i + 2], 15, 718787259) + b = md5ii(b, c, d, a, x[i + 9], 21, -343485551) + + a = safeAdd(a, olda) + b = safeAdd(b, oldb) + c = safeAdd(c, oldc) + d = safeAdd(d, oldd) + } + return [a, b, c, d] + } + + /* + * Convert an array of little-endian words to a string + */ + function binl2rstr (input) { + var i + var output = '' + var length32 = input.length * 32 + for (i = 0; i < length32; i += 8) { + output += String.fromCharCode((input[i >> 5] >>> (i % 32)) & 0xff) + } + return output + } + + /* + * Convert a raw string to an array of little-endian words + * Characters >255 have their high-byte silently ignored. + */ + function rstr2binl (input) { + var i + var output = [] + output[(input.length >> 2) - 1] = undefined + for (i = 0; i < output.length; i += 1) { + output[i] = 0 + } + var length8 = input.length * 8 + for (i = 0; i < length8; i += 8) { + output[i >> 5] |= (input.charCodeAt(i / 8) & 0xff) << (i % 32) + } + return output + } + + /* + * Calculate the MD5 of a raw string + */ + function rstrMD5 (s) { + return binl2rstr(binlMD5(rstr2binl(s), s.length * 8)) + } + + /* + * Calculate the HMAC-MD5, of a key and some data (raw strings) + */ + function rstrHMACMD5 (key, data) { + var i + var bkey = rstr2binl(key) + var ipad = [] + var opad = [] + var hash + ipad[15] = opad[15] = undefined + if (bkey.length > 16) { + bkey = binlMD5(bkey, key.length * 8) + } + for (i = 0; i < 16; i += 1) { + ipad[i] = bkey[i] ^ 0x36363636 + opad[i] = bkey[i] ^ 0x5c5c5c5c + } + hash = binlMD5(ipad.concat(rstr2binl(data)), 512 + data.length * 8) + return binl2rstr(binlMD5(opad.concat(hash), 512 + 128)) + } + + /* + * Convert a raw string to a hex string + */ + function rstr2hex (input) { + var hexTab = '0123456789abcdef' + var output = '' + var x + var i + for (i = 0; i < input.length; i += 1) { + x = input.charCodeAt(i) + output += hexTab.charAt((x >>> 4) & 0x0f) + hexTab.charAt(x & 0x0f) + } + return output + } + + /* + * Encode a string as utf-8 + */ + function str2rstrUTF8 (input) { + return unescape(encodeURIComponent(input)) + } + + /* + * Take string arguments and return either raw or hex encoded strings + */ + function rawMD5 (s) { + return rstrMD5(str2rstrUTF8(s)) + } + function hexMD5 (s) { + return rstr2hex(rawMD5(s)) + } + function rawHMACMD5 (k, d) { + return rstrHMACMD5(str2rstrUTF8(k), str2rstrUTF8(d)) + } + function hexHMACMD5 (k, d) { + return rstr2hex(rawHMACMD5(k, d)) + } + + function md5 (string, key, raw) { + if (!key) { + if (!raw) { + return hexMD5(string) + } + return rawMD5(string) + } + if (!raw) { + return hexHMACMD5(key, string) + } + return rawHMACMD5(key, string) + } + + if (typeof define === 'function' && define.amd) { + define(function () { + return md5 + }) + } else if (typeof module === 'object' && module.exports) { + module.exports = md5 + } else { + $.md5 = md5 + } +})(this) +\ No newline at end of file diff --git a/src/lib/leaflet.control.track-list/lib/nktk.js b/src/lib/leaflet.control.track-list/lib/nktk.js @@ -2,6 +2,7 @@ import Pbf from 'pbf'; import {TrackView} from './nktk_pb'; import {arrayBufferToString, stringToArrayBuffer} from 'lib/binary-strings'; import utf8 from 'utf8'; +import config from 'config'; const arcUnit = ((1 << 24) - 1) / 360; @@ -317,7 +318,7 @@ function parseNktkProtobuf(s) { } -function parseNktk(s) { +function parseNktkFragment(s) { s = decodeUrlSafeBase64(s); if (!s) { return [{name: 'Text encoded track', error: ['CORRUPT']}]; @@ -333,4 +334,56 @@ function parseNktk(s) { } } -export {saveNktk, parseTrackUrlData, parseNktk}; -\ No newline at end of file +function parseNktkSequence(s) { + if (typeof s === "string") { + s = s.split('/'); + } + var geodataArray = []; + for (let i = 0; i < s.length; i++) { + if (s[i]) { + geodataArray.push.apply(geodataArray, parseNktkFragment(s[i])); + } + } + return geodataArray; +} + + +function parseNakarteUrl(s) { + let i = s.indexOf('#'); + if (i === -1) { + return null; + } + i = s.indexOf('nktk=', i + 1); + if (i === -1) { + return null; + } + s = s.substring(i + 5); + return parseNktkSequence(s) +} + + +const nakarteLinkRe = /#.*nktl=([A-Z-a-z0-9_-]+)/; + + +function isNakarteLinkUrl(url) { + return nakarteLinkRe.test(url); +} + + +function nakarteLinkXhrOptions(url) { + const m = nakarteLinkRe.exec(url); + if (!m) { + throw new Error('Invalid nakarteLink url'); + } + const trackId = m[1]; + return [{url: (`${config.tracksStorageServer}/track/${trackId}`), options: {responseType: 'binarystring'}}] +} + +function nakarteLinkParser(_, responses) { + if (responses.length !== 1) { + throw new Error(`Invalid responses array length ${responses.length}`); + } + return parseNktkSequence(responses[0].responseBinaryText); +} + +export {saveNktk, parseTrackUrlData, parseNakarteUrl, isNakarteLinkUrl, nakarteLinkXhrOptions, nakarteLinkParser, parseNktkSequence}; +\ No newline at end of file diff --git a/src/lib/leaflet.control.track-list/track-list.hash-state.js b/src/lib/leaflet.control.track-list/track-list.hash-state.js @@ -1,25 +1,37 @@ import L from 'leaflet'; -import {parseGeoFile} from './lib/geo_file_formats'; +import {loadFromUrl} from './lib/geo_file_formats'; import logging from 'lib/logging'; +import {parseNktkSequence} from './lib/nktk'; -L.Control.TrackList.include(L.Mixin.HashState); -L.Control.TrackList.include({ - stateChangeEvents: [], - serializeState: function(e) { - return null; +L.Control.TrackList.include({ + loadNktkFromHash: function(values) { + if (!values || !(values.length)) { + return false; + } + logging.captureBreadcrumb({message: 'load nktk from hashState'}); + const geodata = parseNktkSequence(values); + const notEmpty = this.addTracksFromGeodataArray(geodata, {href: window.location.href}); + if (notEmpty) { + this.setExpanded(); + } }, - unserializeState: function(values) { - if (values && values.length) { - logging.captureBreadcrumb({message: 'load track from hashState'}); - var geodata = parseGeoFile('', window.location.href); - const notEmpty = this.addTracksFromGeodataArray(geodata, {url: window.location.href}); - if (notEmpty) { - this.setExpanded(); - } + loadNktlFromHash: function(values) { + if (!values || !(values.length)) { + return false; } - return false; + logging.captureBreadcrumb({message: 'load nktl from hashState'}); + const url = `#nktl=${values[0]}`; + const href = window.location.href; + loadFromUrl(url).then( + (geodata) => { + const notEmpty = this.addTracksFromGeodataArray(geodata, {href}); + if (notEmpty) { + this.setExpanded(); + } + } + ); } } ); diff --git a/src/lib/leaflet.control.track-list/track-list.js b/src/lib/leaflet.control.track-list/track-list.js @@ -20,6 +20,9 @@ import 'lib/leaflet.polyline-edit'; import 'lib/leaflet.polyline-measure'; import logging from 'lib/logging'; import {notify} from 'lib/notifications'; +import {fetch} from 'lib/xhr-promise'; +import config from 'config'; +import md5 from './lib/md5'; const TrackSegment = L.MeasuredLine.extend({ includes: L.Polyline.EditMixin, @@ -34,6 +37,7 @@ const TrackSegment = L.MeasuredLine.extend({ TrackSegment.mergeOptions(L.Polyline.EditMixinOptions); + L.Control.TrackList = L.Control.extend({ options: {position: 'bottomright'}, includes: L.Mixin.Events, @@ -107,7 +111,7 @@ L.Control.TrackList = L.Control.extend({ L.DomEvent.addListener(map.getContainer(), 'drop', this.onFileDragDrop, this); L.DomEvent.addListener(map.getContainer(), 'dragover', this.onFileDraging, this); this.menu = new Contextmenu([ - {text: 'Copy all tracks to clipboard', callback: this.copyAllTracks.bind(this)}, + {text: 'Copy all tracks to clipboard', callback: this.copyAllTracksToClipboard.bind(this)}, {text: 'Copy visible tracks to clipboard', callback: this.copyVisibleTracks.bind(this)}, '-', {text: 'Delete all tracks', callback: this.deleteAllTracks.bind(this)}, @@ -402,7 +406,7 @@ L.Control.TrackList = L.Control.extend({ '-', {text: 'Save as GPX', callback: this.saveTrackAsFile.bind(this, track, geoExporters.saveGpx, '.gpx')}, {text: 'Save as KML', callback: this.saveTrackAsFile.bind(this, track, geoExporters.saveKml, '.kml')}, - {text: 'Copy link to clipboard', callback: this.copyLinkToClipboard.bind(this, track)}, + {text: 'Copy link to clipboard', callback: this.copyTrackLinkToClipboard.bind(this, track)}, ]; track._actionsMenu = new Contextmenu(items); }, @@ -456,12 +460,33 @@ L.Control.TrackList = L.Control.extend({ ); }, - copyLinkToClipboard: function(track, mouseEvent) { - this.stopActiveDraw(); - var s = this.trackToString(track, true); - var url = window.location + '&nktk=' + s; + copyTracksLinkToClipboard: function(tracks, mouseEvent) { + if (!tracks.length) { + notify('No tracks to copy'); + return; + } + let serialized = tracks.map((track) => this.trackToString(track)).join('/'); + const hashDigest = md5(serialized, null, true); + const key = btoa(hashDigest).replace(/\//g, '_').replace(/\+/g, '-').replace(/=/g, ''); + const url = window.location + '&nktl=' + key; copyToClipboard(url, mouseEvent); - logging.logEvent('copyLink', {'encodedSize': s.length}); + fetch(`${config.tracksStorageServer}/track/${key}`, {method: 'POST', data: serialized}).then((xhr) => { + }, + (e) => { + let message = e.message || e; + if (e.xhr.status == 413) { + message = 'track is too big'; + } + logging.captureMessage('Failed to save track to server', + {extra: {status: e.xhr.status, response: e.xhr.responseText}}); + alert('Error making link: ' + message); + } + ); + }, + + copyTrackLinkToClipboard: function(track, mouseEvent) { + this.stopActiveDraw(); + this.copyTracksLinkToClipboard([track], mouseEvent); }, saveTrackAsFile: function(track, exporter, extension) { @@ -962,36 +987,15 @@ L.Control.TrackList = L.Control.extend({ ); }, - copyAllTracks: function(mouseEvent) { + copyAllTracksToClipboard: function(mouseEvent) { this.stopActiveDraw(); - var tracks = this.tracks(), - serialized = [], - i, track, s; - for (i = 0; i < tracks.length; i++) { - track = tracks[i]; - s = this.trackToString(track); - serialized.push(s); - } - var url = window.location + '&nktk=' + serialized.join('/'); - copyToClipboard(url, mouseEvent); - logging.logEvent('copyAllTracks', {'encodedSizes': serialized.map((s) => s.length)}); + this.copyTracksLinkToClipboard(this.tracks(), mouseEvent); }, copyVisibleTracks: function(mouseEvent) { this.stopActiveDraw(); - var tracks = this.tracks(), - serialized = [], - i, track, s; - for (i = 0; i < tracks.length; i++) { - track = tracks[i]; - if (track.visible()) { - s = this.trackToString(track); - serialized.push(s); - } - } - var url = window.location + '&nktk=' + serialized.join('/'); - copyToClipboard(url, mouseEvent); - logging.logEvent('copyAllTracks', {'encodedSizes': serialized.map((s) => s.length)}); + const tracks = this.tracks().filter((track) => track.visible()); + this.copyTracksLinkToClipboard(tracks, mouseEvent); }, exportTracks: function(minTicksIntervalMeters) {