UNPKG

keystone

Version:

Web Application Framework and Admin GUI / Content Management System built on Express.js and Mongoose

543 lines (476 loc) 15.9 kB
var _ = require('lodash'); var FieldType = require('../Type'); var https = require('https'); var keystone = require('../../../'); var querystring = require('querystring'); var util = require('util'); var utils = require('keystone-utils'); var RADIUS_KM = 6371; var RADIUS_MILES = 3959; /** * Location FieldType Constructor */ function location (list, path, options) { this._underscoreMethods = ['format', 'googleLookup', 'kmFrom', 'milesFrom']; this._fixedSize = 'full'; this._properties = ['enableMapsAPI']; this.enableMapsAPI = (options.enableImprove === true || (options.enableImprove !== false && keystone.get('google server api key'))) ? true : false; // Throw on invalid options in 4.0 (remove for 5.0) if ('geocodeGoogle' in options) { throw new Error('The geocodeGoogle option for Location fields has been renamed to enableImprove'); } if (!options.defaults) { options.defaults = {}; } if (options.required) { if (Array.isArray(options.required)) { // required can be specified as an array of paths this.requiredPaths = options.required; } else if (typeof options.required === 'string') { // or it can be specified as a comma-delimited list this.requiredPaths = options.required.replace(/,/g, ' ').split(/\s+/); } // options.required should always be simplified to a boolean options.required = true; } // default this.requiredPaths if (!this.requiredPaths) { this.requiredPaths = ['street1', 'suburb']; } location.super_.call(this, list, path, options); } location.properName = 'Location'; util.inherits(location, FieldType); /** * Registers the field on the List's Mongoose Schema. */ location.prototype.addToSchema = function (schema) { var field = this; var options = this.options; var paths = this.paths = { number: this.path + '.number', name: this.path + '.name', street1: this.path + '.street1', street2: this.path + '.street2', suburb: this.path + '.suburb', state: this.path + '.state', postcode: this.path + '.postcode', country: this.path + '.country', geo: this.path + '.geo', geo_lat: this.path + '.geo_lat', geo_lng: this.path + '.geo_lng', serialised: this.path + '.serialised', improve: this.path + '_improve', overwrite: this.path + '_improve_overwrite', }; var getFieldDef = function (type, key) { var def = { type: type }; if (options.defaults[key]) { def.default = options.defaults[key]; } return def; }; schema.nested[this.path] = true; schema.add({ number: getFieldDef(String, 'number'), name: getFieldDef(String, 'name'), street1: getFieldDef(String, 'street1'), street2: getFieldDef(String, 'street2'), street3: getFieldDef(String, 'street3'), suburb: getFieldDef(String, 'suburb'), state: getFieldDef(String, 'state'), postcode: getFieldDef(String, 'postcode'), country: getFieldDef(String, 'country'), geo: { type: [Number], index: '2dsphere' }, }, this.path + '.'); schema.virtual(paths.serialised).get(function () { return _.compact([ this.get(paths.number), this.get(paths.name), this.get(paths.street1), this.get(paths.street2), this.get(paths.suburb), this.get(paths.state), this.get(paths.postcode), this.get(paths.country), ]).join(', '); }); // pre-save hook to fix blank geo fields // see http://stackoverflow.com/questions/16388836/does-applying-a-2dsphere-index-on-a-mongoose-schema-force-the-location-field-to schema.pre('save', function (next) { var obj = field._path.get(this); var geo = (obj.geo || []).map(Number).filter(_.isFinite); obj.geo = (geo.length === 2) ? geo : undefined; next(); }); this.bindUnderscoreMethods(); }; /** * Add filters to a query */ var FILTER_PATH_MAP = { street: 'street1', city: 'suburb', state: 'state', code: 'postcode', country: 'country', }; location.prototype.addFilterToQuery = function (filter) { var query = {}; var field = this; ['street', 'city', 'state', 'code', 'country'].forEach(function (i) { if (!filter[i]) return; var value = utils.escapeRegExp(filter[i]); value = new RegExp(value, 'i'); query[field.paths[FILTER_PATH_MAP[i]]] = filter.inverted ? { $not: value } : value; }); return query; }; /** * Formats a list of the values stored by the field. Only paths that * have values will be included. * * Optionally provide a space-separated list of values to include. * * Delimiter defaults to `', '`. */ location.prototype.format = function (item, values, delimiter) { if (!values) { return item.get(this.paths.serialised); } var paths = this.paths; values = values.split(' ').map(function (i) { return item.get(paths[i]); }); return _.compact(values).join(delimiter || ', '); }; /** * Detects whether the field has been modified */ location.prototype.isModified = function (item) { return item.isModified(this.paths.number) || item.isModified(this.paths.name) || item.isModified(this.paths.street1) || item.isModified(this.paths.street2) || item.isModified(this.paths.suburb) || item.isModified(this.paths.state) || item.isModified(this.paths.postcode) || item.isModified(this.paths.country) || item.isModified(this.paths.geo); }; location.prototype.getInputFromData = function (data) { // Allow JSON structured data var input = this.getValueFromData(data); // If there is no structured data, look for the flat paths if (!input) { input = { number: data[this.paths.number], name: data[this.paths.name], street1: data[this.paths.street1], street2: data[this.paths.street2], suburb: data[this.paths.suburb], state: data[this.paths.state], postcode: data[this.paths.postcode], country: data[this.paths.country], geo: data[this.paths.geo], geo_lat: data[this.paths.geo], geo_lng: data[this.paths.geo], improve: data[this.paths_improve], overwrite: data[this.paths_improve_overwrite], }; } return input; }; /** * Validates that a value for this field has been provided in a data object */ location.prototype.validateInput = function (data, callback) { // var input = this.getInputFromData(data); // TODO: We should strictly check for types in input here utils.defer(callback, true); }; /** * Validates that input has been provided * TODO: Needs test coverage */ location.prototype.validateRequiredInput = function (item, data, callback) { var result = true; var input = this.getInputFromData(data); var currentValue = item.get(this.path); this.requiredPaths.forEach(function (path) { // ignore missing values if they already exist in the item if (input[path] === undefined && currentValue[path]) return; // falsy values mean the input is invalid if (!input[path]) { result = false; } }); utils.defer(callback, result); }; /** * Validates that a value for this field has been provided in a data object * * options.required specifies an array or space-delimited list of paths that * are required (defaults to street1, suburb) * * Deprecated */ location.prototype.inputIsValid = function (data, required, item) { if (!required) return true; var paths = this.paths; var nested = this._path.get(data); var values = nested || data; var valid = true; this.requiredPaths.forEach(function (path) { if (nested) { if (!(path in values) && item && item.get(paths[path])) { return; } if (!values[path]) { valid = false; } } else { if (!(paths[path] in values) && item && item.get(paths[path])) { return; } if (!values[paths[path]]) { valid = false; } } }); return valid; }; /** * Updates the value for this field in the item from a data object */ location.prototype.updateItem = function (item, data, callback) { var paths = this.paths; var fieldKeys = ['number', 'name', 'street1', 'street2', 'suburb', 'state', 'postcode', 'country']; var geoKeys = ['geo', 'geo_lat', 'geo_lng']; var valueKeys = fieldKeys.concat(geoKeys); var valuePaths = valueKeys; var values = this._path.get(data); if (!values) { // Handle flattened values valuePaths = valueKeys.map(function (i) { return paths[i]; }); values = _.pick(data, valuePaths); } // convert valuePaths to a map for easier usage valuePaths = _.zipObject(valueKeys, valuePaths); var setValue = function (key) { if (valuePaths[key] in values && values[valuePaths[key]] !== item.get(paths[key])) { item.set(paths[key], values[valuePaths[key]] || null); } }; _.forEach(fieldKeys, setValue); if (valuePaths.geo in values) { var oldGeo = item.get(paths.geo) || []; if (oldGeo.length > 1) { oldGeo[0] = item.get(paths.geo)[1]; oldGeo[1] = item.get(paths.geo)[0]; } var newGeo = values[valuePaths.geo]; if (!Array.isArray(newGeo) || newGeo.length !== 2) { newGeo = []; } if (newGeo[0] !== oldGeo[0] || newGeo[1] !== oldGeo[1]) { item.set(paths.geo, newGeo); } } else if (valuePaths.geo_lat in values && valuePaths.geo_lng in values) { var lat = utils.number(values[valuePaths.geo_lat]); var lng = utils.number(values[valuePaths.geo_lng]); item.set(paths.geo, (lat && lng) ? [lng, lat] : undefined); } var doGoogleLookup = this.getValueFromData(data, '_improve'); if (doGoogleLookup) { var googleUpdateMode = this.getValueFromData(data, '_improve_overwrite') ? 'overwrite' : true; this.googleLookup(item, false, googleUpdateMode, function (err, location, result) { // TODO: we are currently log the error but otherwise discard it; should probably be returned.. needs consideration if (err) console.error(err); callback(); }); return; } process.nextTick(callback); }; /** * Internal Google geocode request method */ function doGoogleGeocodeRequest (address, region, callback) { // https://developers.google.com/maps/documentation/geocoding/ // Use of the Google Geocoding API is subject to a query limit of 2,500 geolocation requests per day, except with an enterprise license. // Note: the Geocoding API may only be used in conjunction with a Google map; geocoding results without displaying them on a map is prohibited. // Please make sure your Keystone app complies with the Google Maps API License. var options = { sensor: false, language: 'en', address: address, }; if (arguments.length === 2 && typeof region === 'function') { callback = region; region = null; } if (region) { options.region = region; } if (keystone.get('google server api key')) { options.key = keystone.get('google server api key'); } var endpoint = 'https://maps.googleapis.com/maps/api/geocode/json?' + querystring.stringify(options); https.get(endpoint, function (res) { var data = []; res .on('data', function (chunk) { data.push(chunk); }) .on('end', function () { var dataBuff = data.join('').trim(); var result; try { result = JSON.parse(dataBuff); } catch (exp) { result = { status_code: 500, status_text: 'JSON Parse Failed', status: 'UNKNOWN_ERROR', }; } callback(null, result); }); }) .on('error', function (err) { callback(err); }); } /** * Autodetect the full address and lat, lng from the stored value. * * Uses Google's Maps API and may only be used in conjunction with a Google map. * Geocoding results without displaying them on a map is prohibited. * Please make sure your Keystone app complies with the Google Maps API License. * * Internal status codes mimic the Google API status codes. */ location.prototype.googleLookup = function (item, region, update, callback) { if (typeof update === 'function') { callback = update; update = false; } var field = this; var stored = item.get(this.path); var address = item.get(this.paths.serialised); if (address.length === 0) { return callback({ status_code: 500, status_text: 'No address to geocode', status: 'NO_ADDRESS', }); } doGoogleGeocodeRequest(address, region || keystone.get('default region'), function (err, geocode) { if (err || geocode.status !== 'OK') { return callback(err || new Error(geocode.status + ': ' + geocode.error_message)); } // use the first result // if there were no results in the array, status would be ZERO_RESULTS var result = geocode.results[0]; // parse the address components into a location object var location = {}; _.forEach(result.address_components, function (val) { if (_.indexOf(val.types, 'street_number') >= 0) { location.street1 = location.street1 || []; location.street1.unshift(val.long_name); } if (_.indexOf(val.types, 'route') >= 0) { location.street1 = location.street1 || []; location.street1.push(val.short_name); } // in some cases, you get suburb, city as locality - so only use the first if (_.indexOf(val.types, 'locality') >= 0 && !location.suburb) { location.suburb = val.long_name; } if (_.indexOf(val.types, 'administrative_area_level_1') >= 0) { location.state = val.short_name; } if (_.indexOf(val.types, 'country') >= 0) { location.country = val.long_name; } if (_.indexOf(val.types, 'postal_code') >= 0) { location.postcode = val.short_name; } // These address_components could arguable all map to our 'number' field // .. https://developers.google.com/maps/documentation/geocoding/intro#GeocodingResponses // `subpremise` - "Indicates a first-order entity below a named location, usually a singular building within a collection of buildings with a common name" // In practice this is often the unit/apartment number or level and is not always included if (_.indexOf(val.types, 'subpremise') >= 0) { location.number = val.short_name; } // These are all optional (rarely used?) and probably shouldn't replace the number if already set (due to subpremise) // `floor` - Indicates the floor of a building address. // `post_box` - Indicates a specific postal box. // `room` - Indicates the room of a building address. if (_.indexOf(val.types, 'floor') >= 0 || _.indexOf(val.types, 'post_box') >= 0 || _.indexOf(val.types, 'room') >= 0) { location.number = location.number || val.short_name; } }); if (Array.isArray(location.street1)) { location.street1 = location.street1.join(' '); } location.geo = [ result.geometry.location.lng, result.geometry.location.lat, ]; // console.log('------ Google Geocode Results ------'); // console.log(address); // console.log(result); // console.log(location); if (update === 'overwrite') { item.set(field.path, location); } else if (update) { _.forEach(location, function (value, key) { if (key === 'geo') { return; } if (!stored[key]) { item.set(field.paths[key], value); } }); if (!Array.isArray(stored.geo) || !stored.geo[0] || !stored.geo[1]) { item.set(field.paths.geo, location.geo); } } callback(null, location, result); }); }; /** * Internal Distance calculation function * * See http://en.wikipedia.org/wiki/Haversine_formula */ function calculateDistance (point1, point2) { var dLng = (point2[0] - point1[0]) * Math.PI / 180; var dLat = (point2[1] - point1[1]) * Math.PI / 180; var lat1 = (point1[1]) * Math.PI / 180; var lat2 = (point2[1]) * Math.PI / 180; /* eslint-disable space-infix-ops */ var a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.sin(dLng/2) * Math.sin(dLng/2) * Math.cos(lat1) * Math.cos(lat2); var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); /* eslint-enable space-infix-ops */ return c; } /** * Returns the distance from a [lng, lat] point in kilometres */ location.prototype.kmFrom = function (item, point) { return calculateDistance(item.get(this.paths.geo), point) * RADIUS_KM; }; /** * Returns the distance from a [lng, lat] point in miles */ location.prototype.milesFrom = function (item, point) { return calculateDistance(item.get(this.paths.geo), point) * RADIUS_MILES; }; /* Export Field Type */ module.exports = location;