From 2bddc2d0ca36dfcedc4fab45bcd5c21f678f057a Mon Sep 17 00:00:00 2001 From: Julian Knight Date: Sun, 26 Jun 2016 22:18:44 +0100 Subject: [PATCH 1/2] Extensive changes for tz/locale & date calcs Added moment-timezone and os-locale for tz/locale/dst awareness. Added moment-parseformat to help interpret input strings. Updated to use latest node-red standards. --- README.md | 70 ++++---- moment/nrmoment.html | 268 ++++++++++++++++++++--------- moment/nrmoment.js | 396 +++++++++++++++++++++++++++++-------------- package.json | 25 ++- 4 files changed, 508 insertions(+), 251 deletions(-) diff --git a/README.md b/README.md index 6c1c43a..e1f1fcc 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,58 @@ # node-red-contrib-moment -[Node-Red](http://nodered.org) Node that produces a nicely formatted Date/Time string using the Moment.JS library. +[Node-Red](http://nodered.org) Node that produces a nicely formatted Date/Time string using the Moment.JS library & is fully time zone/DST/locale aware. -Based on thoughts from a [conversation in the Node-Red Google Group](https://groups.google.com/d/msg/node-red/SXEGvfFLfQA/fhJCGBWvYEAJ). +Based on thoughts from a [conversation in the Node-Red Google Group](https://groups.google.com/d/msg/node-red/SXEGvfFLfQA/fhJCGBWvYEAJ). Updated with timezone/locale capabilities after Jaques44's initial work. Updated with +/- adjustments after [another conversion in the Google Group](https://groups.google.com/forum/#!topic/node-red/u3qoISFoKus). #Install Run the following command in the root directory of your Node-RED install - npm install node-red-contrib-moment + npm install node-red-contrib-moment + +If you want to install the original version: + + npm install node-red-contrib-moment@1 While in development, install with: npm install https://github.com/TotallyInformation/node-red-contrib-moment/tarball/master #Updates -1.0.5 - Merged a pull request containing a Locale option for localisation. 2016-03-30 +- 1.0.5 - Merged a pull request containing a Locale option for localisation. 2016-03-30 +- 2.0.0 - Significant rewrite, updated moment.js, got rid of all eval's, added adjustment calcs, added time zone and locale awareness. 2016-06-26 #Usage -The node expects an input from the incoming msg. By default, this is msg.payload. If it is a recognisable date/time, it will apply a format and output the resulting string or +The node generally expects an input from the incoming msg. By default, this is msg.payload. If it is a recognisable date/time, it will apply a format and output the resulting string or object accordingly. -There are 5 parameters to the node. - -1. *Topic* - as expected, if provided, msg.topic will be set on the output. Otherwise, any input topic is passed through -2. *Input* - defines the Property on the input msg that carries the date/time. msg.payload by default. - Input must be either a Javascript Date object or a [date/time string that can be parsed by Moment.JS](http://momentjs.com/docs/#/parsing/string/). - - It tries to work out the input format and allows more variations to be recognised. Such as 'Thursday, February 6th, 2014 9:20pm' - - It can also be null, non-existant or an empty string, in which case it will be set to the current date/time. Useful for easily injecting the - current date/time from any trigger. -3. *Format* - defines how the output should be formatted. - Can be any [format string recognised by Moment.JS](http://momentjs.com/docs/#/displaying/) or one of (alternative spellings in brackets, spellings are not case sensitive): -
-
If left blank
-
If the input is a Javascript Date object, output in ISO8601 format. If the input is a recognised date string, output a Javascript Date object
-
ISO8601 (ISO)
-
ISO 8602 format, e.g. "2015-01-28T16:24:48+00:00"
This is the default if the input is a Javascript Date object
-
date (jsDate)
-
a Javascript Date object
This is the default if the input is a recognised date string
-
fromNow (timeAgo)
-
e.g. 30 minutes ago
-
calendar (aroundNow)
-
e.g. "Last Monday", "Tomorrow 2:30pm"
-
duration
-
e.g. "8 minutes"
-
-4. *Output* - defines the property on the output msg that will carry the formatted date/time string (or Javascript object). -5. *Name* - as usual, a unique name identifier for the node instance. +Input and output time zones are settable as is the output locale. All of which default to the host systems tz/locale. + +This allows the node to be used to translate from one time zone to another. It also will take into account daylight savings time (DST). + +You can also apply an adjustment to the date/time by adding or subtracting an amount. + +See the node's built-in help for more details. + +#Depends on +- [Moment.js](http://momentjs.com/docs) - Clever date/time handler for Node.js and browsers +- [Moment-Timezone](http://momentjs.com/timezone/docs) - Adds timezone and locale awareness to Moment.js +- [Moment-ParseFormat](https://github.com/gr2m/moment-parseformat) - Tries to interpret input strings as date/times and creates a format string that moment.js can use. +- [os-locale](https://github.com/sindresorhus/os-locale) - interpets the host OS's locale. Works with Windows as well as Linux. +- [Node-Red](http://nodered.org/docs/) - of course! #To Do Summary of things I'd like to do with the moment node (not necessarily immediately): +* [ ] Add some additional nodes for doing date/time calculations +* [ ] Add additional node for doing duration calculations * [ ] Add a combo box to the Format field with common formats pre-populated - Combo boxes are fiddly in HTML. -* [ ] Improve the error messages when Moment.JS fails to interpret the input (say why) -* [ ] Allow more input date/time formats - turns out Moment.JS doesn't really help here. At present, I see too many input failures from US/UK date formats, etc. - It would be great if I could parse "human" inputs like "tomorrow" and "2 minutes from now". We can output them now but not input them. - - ~~Partly complete: Added the [parseFormat plugin](https://github.com/gr2m/moment.parseFormat).~~ That failed, see code for details. +* [x] Improve the error messages when Moment.JS fails to interpret the input (say why) +* [x] Allow more input date/time formats - turns out Moment.JS doesn't really help here. At present, I see too many input failures from US/UK date formats, etc. + It would be great if I could parse "human" inputs like "tomorrow" and "2 minutes from now". We can output them now but not input them. As of v1.0.5, a localisation parameter is supported. - Maybe add a dropdown with a country code to give a hint. + ~~Partly complete: Added the [parseFormat plugin](https://github.com/gr2m/moment.parseFormat). That failed, see code for details.~~ Now complete. #License diff --git a/moment/nrmoment.html b/moment/nrmoment.html index 135fcfa..31f4851 100644 --- a/moment/nrmoment.html +++ b/moment/nrmoment.html @@ -21,126 +21,236 @@ diff --git a/moment/nrmoment.js b/moment/nrmoment.js index 92e95f7..cc4de62 100644 --- a/moment/nrmoment.js +++ b/moment/nrmoment.js @@ -1,4 +1,4 @@ -/*jslint devel: true, node: true, indent: 4*/ +/*jshint devel: true, node: true, indent: 2*/ /** * Copyright (c) 2015 Julian Knight (Totally Information) * @@ -18,132 +18,280 @@ // Node for Node-Red that outputs a nicely formatted string from a date/time // object or string using the moment.js library. +// require moment.js (must be installed from package.js as a dependency) +var moment = require('moment-timezone'); +var parseFormat = require('moment-parseformat'); +var osLocale = require('os-locale'); +var hostTz = moment.tz.guess(); +var hostLocale = osLocale.sync(); + + +// Module name must match this nodes html file +var moduleName = 'moment'; + module.exports = function(RED) { - "use strict"; - - // require moment.js (must be installed from package.js as a dependency) - var moment = require("moment") - //parseFormat = require('moment-parseformat') // More input options // NOT WORKING - ; - - // The main node definition - most things happen in here - function FormatDateTime(n) { - // Create a RED node - RED.nodes.createNode(this,n); - - // Store local copies of the node configuration (as defined in the .html) - this.topic = n.topic; - this.input = n.input; - this.format = n.format; - this.output = n.output; - - // copy "this" object in case we need it in context of callbacks of other functions. - var node = this; - - // send out the message to the rest of the workspace. - // ... this message will get sent at startup so you may not see it in a debug node. - // Define OUTPUT msg... - //var msg = {}; - //msg.topic = this.topic; - //msg.payload = "Hello world !" - //node.send(msg); - - // respond to inputs.... - node.on('input', function (msg) { - 'use strict'; - // We will be using eval() so lets get a bit of safety using strict - - // If the node's topic is set, copy to output msg - if ( node.topic !== '' ) { - msg.topic = node.topic; - } // If nodes topic is blank, the input msg.topic is already there - - // make sure output property is set, if not, assume msg.payload - if ( node.output === '' ) { - node.output = 'payload'; - node.warn('Output field is REQUIRED, currently blank, set to msg.payload'); + 'use strict'; + + // The main node definition - most things happen in here + function nodeGo(config) { + // Create a RED node + RED.nodes.createNode(this,config); + + // Store local copies of the node configuration (as defined in the .html) + this.topic = config.topic; + this.input = config.input || 'payload'; // where to take the input from + this.inputType = config.inputType || 'msg'; // msg, flow or global + this.fakeUTC = config.fakeUTC || false; // is the input UTC rather than local date/time? + this.adjAmount = config.adjAmount || 0; // number + this.adjType = config.adjType || 'days'; // days, hours, etc. + this.adjDir = config.adjDir || 'add'; // add or subtract + this.format = config.format || ''; // valid moment.js format string + this.locale = config.locale || false // valid moment.js locale string + this.output = config.output || 'payload'; // where to put the output + this.outputType = config.outputType || 'msg'; // msg, flow or global + this.inTz = config.inTz || false; // timezone, '' or zone name, e.g. Europe/London + this.outTz = config.outTz || this.inTz; // timezone, '' or zone name, e.g. Europe/London + + // copy "this" object in case we need it in context of callbacks of other functions. + var node = this; + + // respond to inputs.... + node.on('input', function (msg) { + 'use strict'; // We will be using eval() so lets get a bit of safety using strict + + // If the node's topic is set, copy to output msg + if ( node.topic !== '' ) { + msg.topic = node.topic; + } // If nodes topic is blank, the input msg.topic is already there + + // make sure output property is set, if not, assume msg.payload + if ( node.output === '' ) { + node.output = 'payload'; + //node.warn('Output field is REQUIRED, currently blank, set to payload'); + } + if ( node.outputType === '' ) { + node.outputType = 'msg'; + node.warn('Output Type field is REQUIRED, currently blank, set to msg'); + } + + // If the input property is blank, assume NOW as the required timestamp + // or make sure that the node's input property actually exists on the input msg + var inp = ''; + // If input is a blank string, use a Date object with Now DT + if ( node.input === '' ) { + inp = new Date(); + } else { + // Otherwise, check which input type & get the input + try { + switch ( node.inputType ) { + case 'msg': + inp = msg[node.input]; + break; + case 'flow': + inp = node.context().flow.get(node.input); + break; + case 'global': + inp = node.context().global.get(node.input); + break; + case 'date': + inp = new Date(); + break; + case 'str': + inp = node.input.trim(); + break; + default: + inp = new Date(); + node.warn('Unrecognised Input Type, ' + node.inputType + '. Output has been set to NOW.'); + } + } catch (err) { + inp = new Date(); + node.warn('Input property, ' + node.inputType + '.' + node.input + ', does NOT exist. Output has been set to NOW.'); + } + } + // We are going to overwrite the output property without warning or permission! + + // Final check for input being a string (which moment doesn't really want to handle) + // NB: moment.js v3 will stop accepting strings. v2.7+ throws a warning. + var dtHack = '', inpFmt = ''; + if ( (typeof inp) === 'string' ) { + inp = inp.trim(); + // Some string input hacks + switch (inp.toLowerCase()) { + case 'today': + inp = new Date(); + break; + case 'yesterday': + inp = new Date(); + dtHack = {days:-1}; + break; + case 'tomorrow': + inp = new Date(); + dtHack = {days:+1}; + break; + default: + node.log(node.inTz.split('/')[0]); + var prefOrder = {preferredOrder: {'/': 'DMY', '.': 'DMY', '-': 'YMD'} }; + if ( (node.locale.toLowerCase().replace('-','_') === 'en_US') || (node.inTz.split('/')[0] === 'America') ) { + prefOrder = {preferredOrder: {'/': 'MDY', '.': 'DMY', '-': 'YMD'} }; } - // Reference the output object we want: it may be several layers deep - // e.g. msg. palyload.some.thing so we cant simply use msg[node.output] - // This is cludgy, there is probably a better way! - var outp = eval('msg.' + node.output); - - // If the input property is blank, assume NOW as the required timestamp - // or make sure that the node's input property actually exists on the input msg - var inp = ''; - if ( node.input !== '' ) { - if ( node.input in msg ) { - // It is so grab it - inp = msg[node.input]; - } else { - node.warn('Input property, ' + node.input + ', does NOT exist in the input msg. Output has been set to NOW.'); - } + inpFmt = parseFormat(inp, prefOrder); + /* + // Try to parse it - WARNING: THIS IS A MESS! Diff versions of JS interpret things differently!! https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Date/parse + try { + inp = new Date(inp); + } catch (err) { + inp = new Date(); + node.warn('Input property, ' + node.inputType + '.' + node.input + ', contained an unparsable text string. Output has been set to NOW.'); } - // If inp is a blank string, set it to a Date object with Now DT - if ( inp === '' ) { - inp = new Date(); + // We still have to check if that actually produced a date! It might have returned a NaN + if ( !(inp instanceof Date) ) { + inp = new Date(); + node.warn('Input property, ' + node.inputType + '.' + node.input + ', contained an unparsable text string. Output has been set to NOW.'); } + */ + } + } - // We are going to overwrite the output property without warning or permission! - - // Get a Moment.JS date/time - NB: the result might not be - // valid since the input might not parse as a date/time - var mDT = moment(inp); - if (n.locale) mDT.locale(n.locale); - - // Jacques44: This hack is for those who store local date as UTC string - // Not pretty I know but mongoimport only knows UTC string and my programs don't... - if (n.fakeUTC) mDT = moment(mDT.toISOString().slice(0,-1)); - - // Check if the input is a date? - if ( ! mDT.isValid() ) { - node.warn('The input property was NOT a recognisable date. Output will be a blank string'); - eval('msg.' + node.output + ' = ""; '); // SEE REASONS ABOVE! msg[node.output] = ''; - } else { - // Handle different format strings. We allow any fmt str that - // Moment.JS supports but also some special formats - - // If format not set, assume ISO8601 string if input is a Date otherwise assume Date - - if ( node.format === '' ) { - // Is the input a JS Date object? If so, output a string - // Is it a number (Inject outputs a TIMESTAMP which is a number), also output a string - if ( moment.isDate(inp) || Object.prototype.toString.call(inp) === '[object Number]') { - eval('msg.' + node.output + ' = mDT.toISOString(); '); // SEE REASONS ABOVE! msg[node.output] = mDT.toISOString(); - } else { // otherwise, output a Date object - eval('msg.' + node.output + ' = mDT.toDate(); '); // SEE REASONS ABOVE! msg[node.output] = mDT.toDate(); - } - } else if ( node.format.toUpperCase() === 'ISO8601' || node.format.toLowerCase() === 'iso' ) { - eval('msg.' + node.output + ' = mDT.toISOString(); '); // SEE REASONS ABOVE! msg[node.output] = mDT.toISOString(); - } else if ( node.format.toLowerCase() === 'fromnow' || node.format.toLowerCase() === 'timeago' ) { - // We are also going to handle time-from-now (AKA time ago) format - eval('msg.' + node.output + ' = mDT.fromNow(); '); // SEE REASONS ABOVE! msg[node.output] = mDT.fromNow(); - } else if ( node.format.toLowerCase() === 'calendar' || node.format.toLowerCase() === 'aroundnow' ) { - // We are also going to handle calendar format (AKA around now) - eval('msg.' + node.output + ' = mDT.calendar(); '); // SEE REASONS ABOVE! msg[node.output] = mDT.calendar(); - } else if ( node.format.toLowerCase() === 'date' || node.format.toLowerCase() === 'jsdate' ) { - // we also allow output as a Javascript Date object - eval('msg.' + node.output + ' = mDT.toDate(); '); // SEE REASONS ABOVE! msg[node.output] = mDT.toDate(); - } else if ( node.format.toLowerCase() === 'duration') { - eval('msg.' + node.output + ' = moment.duration(inp).humanize(); '); - } else { - // or we assume it is a valid format definition ... - eval('msg.' + node.output + ' = mDT.format(node.format); '); // SEE REASONS ABOVE! msg[node.output] = mDT.format(node.format); - } - } + // At this point, `inp` SHOULD be a Date object and safe to pass to moment.js + + if ( !node.inTz ) node.inTz = hostTz; + if ( !node.outTz ) node.outTz = node.inTz; + + // Get a Moment.JS date/time - NB: the result might not be + // valid since the input might not parse as a date/time + if ( inpFmt !== '' ) var mDT = moment.tz(inp, inpFmt, node.inTz); + else var mDT = moment.tz(inp, node.inTz); + + // Adjust the date for input hacks if needed (e.g. input was "yesterday" or "tommorow") + if ( dtHack !== '' ) { + mDT.add(dtHack); + } + + // JK: Added OS locale lookup + if ( !node.locale ) node.locale = hostLocale; + // JK: Add a trap to Jaques44's locale code in case the output locale string is invalid + try { + // Jacques44 - set locale for localised output formats + mDT.locale(node.locale); + } catch (err) { + node.warn('Locale string invalid - check moment.js for valid strings'); + } + + // Adjust the input date if required + if ( node.adjAmount !== 0 ) { + // check if measure is valid + if ( isMeasureValid(node.adjType) ) { + // NB: moments are mutable so don't need to reassign + if ( node.adjDir === 'subtract') { + mDT.subtract(node.adjAmount, node.adjType); + } else { + mDT.add(node.adjAmount, node.adjType); + } + } else { + // it isn't valid so warn and don't adjust + node.warn('Adjustment measure type not valid, no adjustment made - check moment.js docs for valid measures (days, hours, etc)'); + } + } + + // ==== NO MORE DATE/TIME CALCULATIONS AFTER HERE ==== // + + // If required, change to the output Timezone + if ( node.outTz !== '' ) mDT.tz(node.outTz); + + // Check if the input is a date? + if ( ! mDT.isValid() ) { + // THIS SHOULD NEVER BE CALLED - it left to catch the occasional error + node.warn('The input property was NOT a recognisable date. Output will be a blank string'); + setOutput(msg, node.outputType, node.output, ''); + } else { + // Handle different format strings. We allow any fmt str that + // Moment.JS supports but also some special formats + + // If format not set, assume ISO8601 string if input is a Date otherwise assume Date + switch ( node.format.toLowerCase() ) { + case '': + case 'iso8601': + case 'iso': + // Default to ISO8601 string + setOutput(msg, node.outputType, node.output, mDT.toISOString()); + break; + case 'fromnow': + case 'timeago': + // We are also going to handle time-from-now (AKA time ago) format + setOutput(msg, node.outputType, node.output, mDT.fromNow()); + break; + case 'calendar': + case 'aroundnow': + // We are also going to handle calendar format (AKA around now) + // Force dates >1 week from now to be in ISO instead of US format + setOutput(msg, node.outputType, node.output, mDT.calendar(null,{sameElse:'YYYY-MM-DD'})); + break; + case 'date': + case 'jsdate': + // we also allow output as a Javascript Date object + setOutput(msg, node.outputType, node.output, mDT.toDate()); + break; + case 'object': + // we also allow output as a Javascript Date object + setOutput(msg, node.outputType, node.output, mDT.toObject()); + break; + default: + // or we assume it is a valid format definition ... + setOutput(msg, node.outputType, node.output, mDT.format(node.format)); + } + } + + // Send the output message + node.send(msg); + }); + + // Tidy up if we need to + //node.on("close", function() { + // Called when the node is shutdown - eg on redeploy. + // Allows ports to be closed, connections dropped etc. + // eg: node.client.disconnect(); + //}); + + // Set the appropriate output variable + function setOutput(msg, outputType, output, value) { + try { + switch ( outputType ) { + case 'msg': + msg[output] = value; + break; + case 'flow': + node.context().flow.set(output, value); + break; + case 'global': + node.context().global.set(output, value); + break; + default: + node.warn('Unrecognised Output Type, ' + outputType + '. No output.'); + } + } catch (err) { + node.warn('Output property, ' + outputType + '.' + output + ', cannot be set. No output.', err); + } + } // --- end of setOutput function --- // + + // Is the date/time adjustment type (measure) valid? See moment.js docs + function isMeasureValid(adjType) { + var validTypes = ['years','y','quarters','Q','months','M','weeks','w','days','d','hours','h','minutes','m','seconds','s','milliseconds','ms']; + //return validTypes.includes(adjType); + return validTypes.indexOf(adjType) > -1; + } // --- end of isMeasureValid function --- // + + } // ---- end of nodeGo function ---- // + + // Register the node by name. This must be called before overriding any of the + // Node functions. + RED.nodes.registerType(moduleName,nodeGo); - // in this example just send it straight on... should process it here really - node.send(msg); - }); - - // Tidy up if we need to - //node.on("close", function() { - // Called when the node is shutdown - eg on redeploy. - // Allows ports to be closed, connections dropped etc. - // eg: node.client.disconnect(); - //}); - } - // Register the node by name. This must be called before overriding any of the - // Node functions. - RED.nodes.registerType("moment",FormatDateTime); -} + // Create API listener: sends the host locale & timezone to the admin ui + // NB: uses Express middleware on the admin server + RED.httpAdmin.get("/contribapi/moment", RED.auth.needsPermission('moment.read'), function(req,res) { + res.json({ + "tz": hostTz, + "locale": hostLocale + }); + }); +}; diff --git a/package.json b/package.json index 9b87fbd..3acd75a 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,31 @@ { "name": "node-red-contrib-moment", - "version": "1.0.9", - "description": "Node-Red Node that produces a nicely formatted Date/Time string using the Moment.JS library.", + "version": "2.0.0", + "description": "Node-Red Node that produces formatted Date/Time output using the Moment.JS library. Timezone, dst and locale aware.", "dependencies": { - "moment": "2.x" + "moment": "2.x", + "moment-parseformat": "^2.1.1", + "moment-timezone": "^0.5.4", + "os-locale": "^1.4.0" }, - "author" : { - "name" : "Julian Knight", - "url" : "https://github.com/totallyinformation" + "author": { + "name": "Julian Knight", + "url": "https://github.com/totallyinformation" }, "contributors": [ - {"name":"Jacques W", "url":"https://github.com/Jacques44"} + { + "name": "Jacques W", + "url": "https://github.com/Jacques44" + } ], "keywords": [ "node-red", "moment", "time", - "date" + "date", + "timezone", + "locale", + "DST" ], "node-red": { "nodes": { From 6253915e4be317f934c71f00e0c8d18b96ae384c Mon Sep 17 00:00:00 2001 From: Julian Knight Date: Sun, 26 Jun 2016 22:28:24 +0100 Subject: [PATCH 2/2] updated updates list --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e1f1fcc..c5d0eb8 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,10 @@ While in development, install with: npm install https://github.com/TotallyInformation/node-red-contrib-moment/tarball/master #Updates -- 1.0.5 - Merged a pull request containing a Locale option for localisation. 2016-03-30 - 2.0.0 - Significant rewrite, updated moment.js, got rid of all eval's, added adjustment calcs, added time zone and locale awareness. 2016-06-26 +- 1.0.9 - Merged in some fixes on Jacques44's contributions & acknowledged him in the package. Also fixed the npm readme. 2016-06-12 +- 1.0.5 - Merged a pull request containing a Locale option for localisation. 2016-03-30 +- 1.0.3 - First stable release. 2015-01-31 #Usage