Compare commits

..

72 Commits

Author SHA1 Message Date
Nick O'Leary
014f206e9c Initial multiplayer feature 2024-03-27 17:30:44 +00:00
Nick O'Leary
068b93befa CComms API updates 2024-03-27 17:21:12 +00:00
Nick O'Leary
f041a21f22 Update publish-script for 4.x 2024-03-14 16:56:32 +00:00
Nick O'Leary
712d78ca39 Merge pull request #4611 from node-red/rel4-beta-tour
Update for 4.0.0-beta.1 release
2024-03-14 16:50:10 +00:00
Nick O'Leary
93f2910bd2 Bump to 4.0.0-beta.1 2024-03-14 16:05:15 +00:00
Nick O'Leary
d0ef12c486 Update tour for 4.0-beta.0 2024-03-14 16:01:54 +00:00
Nick O'Leary
241fd09053 Merge pull request #4587 from Steve-Mcl/config-in-subflow
Support config selection in a subflow env var
2024-03-14 15:11:54 +00:00
Nick O'Leary
208dd2a457 Merge pull request #4436 from marcus-j-davies/allow-ws-headers
Feat: Add ability to set headers for WebSocket client
2024-03-13 17:20:07 +00:00
Nick O'Leary
e34ee44b21 Update packages/node_modules/@node-red/nodes/core/network/22-websocket.html 2024-03-13 17:19:20 +00:00
Nick O'Leary
d5f59307b7 Update packages/node_modules/@node-red/editor-client/src/js/ui/common/typedInput.js 2024-03-13 17:08:58 +00:00
Nick O'Leary
64136cc565 Update packages/node_modules/@node-red/editor-client/src/js/ui/editors/envVarList.js 2024-03-13 17:08:42 +00:00
Nick O'Leary
3e2508c740 Merge branch 'dev' into config-in-subflow 2024-03-13 17:08:23 +00:00
Nick O'Leary
0853cd65b2 Merge pull request #4598 from joepavitt/header-styling
Improve the appearance of the Node-RED primary header
2024-03-13 17:07:13 +00:00
Nick O'Leary
01802c817b Merge branch 'master' into dev 2024-03-13 16:55:50 +00:00
Marcus Davies
7e10093bb8 Remove global access + reduce header list 2024-03-12 19:26:27 +00:00
Nick O'Leary
179032cd4d Merge pull request #4608 from node-red/rel317
Bump for 3.1.7 release
2024-03-12 17:43:32 +00:00
Nick O'Leary
6a6f0d04d6 Bump for 3.1.7 release 2024-03-12 14:25:41 +00:00
Nick O'Leary
add4d9758c Merge pull request #4603 from kazuhitoyokoi/master-addjpn
Add Japanese translation for v3.1.6
2024-03-11 16:07:28 +00:00
Kazuhito Yokoi
a0d3ea62b2 Add Japanese translation for v3.1.6 2024-03-10 23:36:20 +09:00
Joe Pavitt
54c17c3175 Merge branch 'dev' into header-styling 2024-03-08 13:48:43 +00:00
Nick O'Leary
80e60538e2 Merge pull request #4596 from node-red/revert-4352-rfdc
Revert "changed cloning library to rfdc for runtime module"
2024-03-08 11:40:21 +00:00
Joe Pavitt
84a76909e2 Improve the appearance of the Node-RED primary header 2024-03-08 10:51:25 +00:00
Nick O'Leary
033405fdbc Fully remove rfdc 2024-03-07 16:41:30 +00:00
Nick O'Leary
9444009a9b Revert "changed cloning library to rfdc for runtime module" 2024-03-07 16:40:32 +00:00
Nick O'Leary
29e9def314 Merge pull request #4595 from node-red/fix-rfdc-dependency
Add rfdc to @node-red/runtime package.json
2024-03-07 16:33:06 +00:00
Nick O'Leary
8832a1aa20 Add rfdc to @node-red/runtime package.json 2024-03-07 16:31:31 +00:00
Nick O'Leary
5beb6dbeee Merge pull request #4352 from anshumanr/rfdc
changed cloning library to rfdc for runtime module
2024-03-07 16:29:31 +00:00
Nick O'Leary
1261d26b23 Merge pull request #4439 from ralphwetzel/dev_link
Click on id in debug panel highlights node or flow
2024-03-07 16:27:15 +00:00
Nick O'Leary
0b9dd11fff Merge pull request #4513 from gorenje/html_node_content_and_attributes
HTML node: add option for collecting attributes and content
2024-03-07 16:24:28 +00:00
Dave Conway-Jones
08a607aa6a Merge branch 'dev' of https://github.com/node-red/node-red into dev 2024-03-07 16:21:03 +00:00
Nick O'Leary
e12efc320b Merge pull request #4408 from node-red/Fix-join-to-ignore-parts-in-manual-mode
Fix join node to ignore parts in manual mode
2024-03-07 16:20:05 +00:00
Dave Conway-Jones
3ded9de803 TCP node- when resetting, if no payload, stay disconnected 2024-03-07 16:19:51 +00:00
Nick O'Leary
7447e88a50 Merge pull request #4593 from hardillb/hardillb-patch-1
Update jsonata version
2024-03-07 14:26:02 +00:00
Ben Hardill
a193b79d3d Bump jsonata to match utils 2024-03-05 10:31:03 +00:00
Ben Hardill
da380f7464 Update jsonata version
Pulls in fix for CVE-2024-27307
2024-03-05 10:22:49 +00:00
Nick O'Leary
269cf02c0b Merge pull request #4586 from node-red/rel316
Bump for 3.1.6 release
2024-03-01 11:47:57 +00:00
Steve-Mcl
837d17ab65 Merge branch 'dev' into config-in-subflow 2024-03-01 11:36:35 +00:00
Steve-Mcl
eff31c4bdc linting 2024-03-01 11:35:13 +00:00
Steve-Mcl
6a8f653b73 typo 2024-03-01 11:17:16 +00:00
Steve-Mcl
0cdb36f73d support constrained items in editable list 2024-03-01 11:08:50 +00:00
Steve-Mcl
db249356e6 permit config selection in subflow and subflow template 2024-03-01 11:07:26 +00:00
Steve-Mcl
d509c1a57c add "conf-types" to typedInput 2024-03-01 11:06:27 +00:00
Nick O'Leary
fb50e2772a Bump for 3.1.6 release 2024-03-01 10:50:06 +00:00
Nick O'Leary
058c97138a Merge pull request #4582 from node-red/3795-allow-env-var-in-num-field-validation
Do not flag env var in num typedInput as error
2024-02-26 17:01:45 +00:00
Nick O'Leary
828ae29aed Merge pull request #4581 from node-red/4579-fix-undef-env-vars
Handle undefined env vars
2024-02-26 17:01:27 +00:00
Nick O'Leary
6a0f45140c Merge pull request #4568 from JaysonHurst/fips
fix: Removed offending MD5 crypto hash and replaced with SHA1 and SHA256 …
2024-02-26 17:00:26 +00:00
Nick O'Leary
50a267528d Merge pull request #4580 from giscafer/remove-never-use-code
chore: remove never use import code
2024-02-26 16:58:20 +00:00
Nick O'Leary
220786be60 Do not flag env var in num typedInput as error 2024-02-26 16:55:01 +00:00
Nick O'Leary
fa78bb3d78 Handle undefined env vars
Fixes #4579
2024-02-26 16:17:09 +00:00
Nick O'Leary
9a32ebd0c0 Merge pull request #4578 from kazuhitoyokoi/master-fiximportdialog
Fix example flow name in import dialog
2024-02-26 16:09:10 +00:00
giscafer
4643f5e8cc chore: remove never use import code 2024-02-25 22:44:01 +08:00
Kazuhito Yokoi
7de0984d6d Update test case for example flow name 2024-02-25 17:38:46 +09:00
Kazuhito Yokoi
635334f096 Fix example flow name in import dialog 2024-02-25 17:04:42 +09:00
Nick O'Leary
f0d0990b5a Merge pull request #4575 from giscafer/master
fix: template node zh-CN translation
2024-02-22 13:03:24 +00:00
giscafer
43b3589451 fix: template node zh-CN translation 2024-02-22 13:02:06 +08:00
Nick O'Leary
016a19ba7c Merge pull request #4570 from node-red/fix-icon-scaling
Fix missing node icons in workspace
2024-02-20 10:37:33 +00:00
Nick O'Leary
aeb79bce2a Fix missing node icons in workspace 2024-02-19 16:07:22 +00:00
Jayson Hurst
0ab9b9a5fd Merge branch 'master' into fips 2024-02-16 17:53:30 -07:00
Jayson Hurst
56e58521bd Removed offending MD5 crypto hash and replaced with SHA1 and SHA256 crypto hashes to work with the FIPS crypto policy. 2024-02-17 00:35:03 +00:00
Gerrit Riessen
7b01457038 HTML node: add option to for attribites and content 2024-01-06 00:12:09 +01:00
Ralph Wetzel
febc769df5 use of camelCasing: node_selector => nodeSelector 2023-11-21 20:48:52 +01:00
Ralph Wetzel
ea483218ea Click on id in debug panel highlights node or flow 2023-11-18 21:03:13 +01:00
anshumanr
c8f3ad8ac7 Merge branch 'dev' into rfdc 2023-11-17 13:17:26 -05:00
Marcus Davies
7916dc9c05 Slightly cleaner fasly checks 2023-11-14 22:31:25 +00:00
Marcus Davies
3123a5ee51 Feat: Add ability to set headers for WebSocket client 2023-11-14 20:44:04 +00:00
anshumanr
c48a15c915 Merge branch 'dev' into rfdc 2023-09-25 20:24:23 -04:00
asr
b0136d03ea reverted earlier change to use rfdc 2023-09-19 11:13:39 -04:00
asr
9fe73645ad remove package lock 2023-09-19 05:55:19 -04:00
asr
8e1a21e682 typo 2023-09-14 12:27:17 -04:00
asr
d84cdca43e Revert "typo"
This reverts commit 1c6dcd373d.
2023-09-14 12:24:52 -04:00
asr
1c6dcd373d typo 2023-09-14 12:22:34 -04:00
asr
4410ce1486 changed cloning library to rfdc 2023-09-14 11:40:36 -04:00
88 changed files with 1693 additions and 427 deletions

View File

@@ -1,3 +1,57 @@
#### 4.0.0-beta.1: Beta Release
Editor
- Click on id in debug panel highlights node or flow (#4439) @ralphwetzel
- Support config selection in a subflow env var (#4587) @Steve-Mcl
- Add timestamp formatting options to TypedInput (#4468) @knolleary
- Allow RED.view.select to select links (#4553) @lgrkvst
- Add auto-complete to flow/global/env typedInput types (#4480) @knolleary
- Improve the appearance of the Node-RED primary header (#4598) @joepavitt
Runtime
- let settings.httpNodeAuth accept single middleware or array of middlewares (#4572) @kevinGodell
- Upgrade to JSONata 2.x (#4590) @knolleary
- Bump minimum version to node 18 (#4571) @knolleary
- npm: Remove production flag on npm invocation (#4347) @ZJvandeWeg
- Timer testing fix (#4367) @hlovdal
- Bump to 4.0.0-dev (#4322) @knolleary
Nodes
- TCP node - when resetting, if no payload, stay disconnected @dceejay
- HTML node: add option for collecting attributes and content (#4513) @gorenje
- let split node specify property to split on, and join auto join correctly (#4386) @dceejay
- Add RFC4180 compliant mode to CSV node (#4540) @Steve-Mcl
- Fix change node to return boolean if asked (#4525) @dceejay
- Let msg.reset reset Tcp request node connection when in stay connected mode (#4406) @dceejay
- Let debug node status msg length be settable via settings (#4402) @dceejay
- Feat: Add ability to set headers for WebSocket client (#4436) @marcus-j-davies
#### 3.1.7: Maintenance Release
- Add Japanese translation for v3.1.6 (#4603) @kazuhitoyokoi
- Update jsonata version (#4593) @hardillb
#### 3.1.6: Maintenance Release
Editor
- Do not flag env var in num typedInput as error (#4582) @knolleary
- Fix example flow name in import dialog (#4578) @kazuhitoyokoi
- Fix missing node icons in workspace (#4570) @knolleary
Runtime
- Handle undefined env vars (#4581) @knolleary
- fix: Removed offending MD5 crypto hash and replaced with SHA1 and SHA256 … (#4568) @JaysonHurst
- chore: remove never use import code (#4580) @giscafer
Nodes
- fix: template node zh-CN translation (#4575) @giscafer
#### 3.1.5: Maintenance Release
Runtime

View File

@@ -143,6 +143,7 @@ module.exports = function(grunt) {
"packages/node_modules/@node-red/editor-client/src/js/user.js",
"packages/node_modules/@node-red/editor-client/src/js/comms.js",
"packages/node_modules/@node-red/editor-client/src/js/runtime.js",
"packages/node_modules/@node-red/editor-client/src/js/multiplayer.js",
"packages/node_modules/@node-red/editor-client/src/js/text/bidi.js",
"packages/node_modules/@node-red/editor-client/src/js/text/format.js",
"packages/node_modules/@node-red/editor-client/src/js/ui/state.js",

View File

@@ -1,6 +1,6 @@
{
"name": "node-red",
"version": "4.0.0-dev",
"version": "4.0.0-beta.1",
"description": "Low-code programming for event-driven applications",
"homepage": "https://nodered.org",
"license": "Apache-2.0",

View File

@@ -13,7 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
**/
var apiUtils = require("../util");
var runtimeAPI;
var settings;
var theme = require("../editor/theme");

View File

@@ -18,7 +18,6 @@ var BearerStrategy = require('passport-http-bearer').Strategy;
var ClientPasswordStrategy = require('passport-oauth2-client-password').Strategy;
var passport = require("passport");
var crypto = require("crypto");
var util = require("util");
var Tokens = require("./tokens");

View File

@@ -77,6 +77,53 @@ function CommsConnection(ws, user) {
log.trace("comms.close "+self.session);
removeActiveConnection(self);
});
const handleAuthPacket = function(msg) {
Tokens.get(msg.auth).then(function(client) {
if (client) {
Users.get(client.user).then(function(user) {
if (user) {
self.user = user;
log.audit({event: "comms.auth",user:self.user});
completeConnection(msg, client.scope,msg.auth,true);
} else {
log.audit({event: "comms.auth.fail"});
completeConnection(msg, null,null,false);
}
});
} else {
Users.tokens(msg.auth).then(function(user) {
if (user) {
self.user = user;
log.audit({event: "comms.auth",user:self.user});
completeConnection(msg, user.permissions,msg.auth,true);
} else {
log.audit({event: "comms.auth.fail"});
completeConnection(msg, null,null,false);
}
});
}
});
}
const completeConnection = function(msg, userScope, session, sendAck) {
try {
if (!userScope || !Permissions.hasPermission(userScope,"status.read")) {
ws.send(JSON.stringify({auth:"fail"}));
ws.close();
} else {
pendingAuth = false;
addActiveConnection(self);
self.token = msg.auth;
if (sendAck) {
ws.send(JSON.stringify({auth:"ok"}));
}
}
} catch(err) {
console.log(err.stack);
// Just in case the socket closes before we attempt
// to send anything.
}
}
ws.on('message', function(data,flags) {
var msg = null;
try {
@@ -86,68 +133,34 @@ function CommsConnection(ws, user) {
return;
}
if (!pendingAuth) {
if (msg.subscribe) {
if (msg.auth) {
handleAuthPacket(msg)
} else if (msg.subscribe) {
self.subscribe(msg.subscribe);
// handleRemoteSubscription(ws,msg.subscribe);
} else if (msg.topic) {
runtimeAPI.comms.receive({
user: self.user,
client: self,
topic: msg.topic,
data: msg.data
})
}
} else {
var completeConnection = function(userScope,session,sendAck) {
try {
if (!userScope || !Permissions.hasPermission(userScope,"status.read")) {
ws.send(JSON.stringify({auth:"fail"}));
ws.close();
} else {
pendingAuth = false;
addActiveConnection(self);
self.token = msg.auth;
if (sendAck) {
ws.send(JSON.stringify({auth:"ok"}));
}
}
} catch(err) {
console.log(err.stack);
// Just in case the socket closes before we attempt
// to send anything.
}
}
if (msg.auth) {
Tokens.get(msg.auth).then(function(client) {
if (client) {
Users.get(client.user).then(function(user) {
if (user) {
self.user = user;
log.audit({event: "comms.auth",user:self.user});
completeConnection(client.scope,msg.auth,true);
} else {
log.audit({event: "comms.auth.fail"});
completeConnection(null,null,false);
}
});
} else {
Users.tokens(msg.auth).then(function(user) {
if (user) {
self.user = user;
log.audit({event: "comms.auth",user:self.user});
completeConnection(user.permissions,msg.auth,true);
} else {
log.audit({event: "comms.auth.fail"});
completeConnection(null,null,false);
}
});
}
});
handleAuthPacket(msg)
} else {
if (anonymousUser) {
log.audit({event: "comms.auth",user:anonymousUser});
self.user = anonymousUser;
completeConnection(anonymousUser.permissions,null,false);
completeConnection(msg, anonymousUser.permissions, null, false);
//TODO: duplicated code - pull non-auth message handling out
if (msg.subscribe) {
self.subscribe(msg.subscribe);
}
} else {
log.audit({event: "comms.auth.fail"});
completeConnection(null,null,false);
completeConnection(msg, null,null,false);
}
}
}

View File

@@ -14,11 +14,9 @@
* limitations under the License.
**/
var express = require("express");
var path = require('path');
var comms = require("./comms");
var library = require("./library");
var info = require("./settings");
var auth = require("../auth");

View File

@@ -15,8 +15,6 @@
**/
var apiUtils = require("../util");
var fs = require('fs');
var fspath = require('path');
var runtimeAPI;

View File

@@ -13,9 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
**/
var fs = require('fs');
var path = require('path');
// var apiUtil = require('../util');
var i18n = require("@node-red/util").i18n; // TODO: separate module

View File

@@ -15,7 +15,6 @@
**/
var apiUtils = require("../util");
var express = require("express");
var runtimeAPI;
var settings;

View File

@@ -14,7 +14,6 @@
* limitations under the License.
**/
var express = require("express");
var util = require("util");
var path = require("path");
var fs = require("fs");
@@ -234,6 +233,10 @@ module.exports = {
themeSettings.projects = theme.projects;
}
if (theme.hasOwnProperty("multiplayer")) {
themeSettings.multiplayer = theme.multiplayer;
}
if (theme.hasOwnProperty("keymap")) {
themeSettings.keymap = theme.keymap;
}

View File

@@ -99,7 +99,7 @@ module.exports = {
// settings.instanceId is set asynchronously to the editor-api
// being initiaised. So we defer calculating the cacheBuster hash
// until the first load of the editor
cacheBuster = crypto.createHash('md5').update(`${settings.version || 'version'}-${settings.instanceId || 'instanceId'}`).digest("hex").substring(0,12)
cacheBuster = crypto.createHash('sha1').update(`${settings.version || 'version'}-${settings.instanceId || 'instanceId'}`).digest("hex").substring(0,12)
}
let sessionMessages;

View File

@@ -24,11 +24,8 @@
* @namespace @node-red/editor-api
*/
var express = require("express");
var bodyParser = require("body-parser");
var util = require('util');
var passport = require('passport');
var cors = require('cors');
var auth = require("./auth");
var apiUtil = require("./util");

View File

@@ -1,6 +1,6 @@
{
"name": "@node-red/editor-api",
"version": "4.0.0-dev",
"version": "4.0.0-beta.1",
"license": "Apache-2.0",
"main": "./lib/index.js",
"repository": {
@@ -16,8 +16,8 @@
}
],
"dependencies": {
"@node-red/util": "4.0.0-dev",
"@node-red/editor-client": "4.0.0-dev",
"@node-red/util": "4.0.0-beta.1",
"@node-red/editor-client": "4.0.0-beta.1",
"bcryptjs": "2.4.3",
"body-parser": "1.20.2",
"clone": "2.1.2",

View File

@@ -924,7 +924,8 @@
"date": "timestamp",
"jsonata": "expression",
"env": "env variable",
"cred": "credential"
"cred": "credential",
"conf-types": "config node"
},
"date": {
"format": {

View File

@@ -303,7 +303,8 @@
"missingType": "不正なフロー - __index__ 番目の要素に'type'プロパティがありません"
},
"conflictNotification1": "読み込もうとしているノードのいくつかは、既にワークスペース内に存在しています。",
"conflictNotification2": "読み込むノードを選択し、また既存のノードを置き換えるか、もしくはそれらのコピーを読み込むかも選択してください。"
"conflictNotification2": "読み込むノードを選択し、また既存のノードを置き換えるか、もしくはそれらのコピーを読み込むかも選択してください。",
"alreadyExists": "本ノードは既に存在"
},
"copyMessagePath": "パスをコピーしました",
"copyMessageValue": "値をコピーしました",

View File

@@ -1,6 +1,6 @@
{
"name": "@node-red/editor-client",
"version": "4.0.0-dev",
"version": "4.0.0-beta.1",
"license": "Apache-2.0",
"repository": {
"type": "git",

View File

@@ -26,6 +26,15 @@ RED.comms = (function() {
var reconnectAttempts = 0;
var active = false;
RED.events.on('login', function(username) {
// User has logged in
// Need to upgrade the connection to be authenticated
if (ws && ws.readyState == 1) {
const auth_tokens = RED.settings.get("auth-tokens");
ws.send(JSON.stringify({auth:auth_tokens.access_token}))
}
})
function connectWS() {
active = true;
var wspath;
@@ -56,6 +65,7 @@ RED.comms = (function() {
ws.send(JSON.stringify({subscribe:t}));
}
}
emit('connect')
}
ws = new WebSocket(wspath);
@@ -180,9 +190,53 @@ RED.comms = (function() {
}
}
function send(topic, msg) {
if (ws && ws.readyState == 1) {
ws.send(JSON.stringify({
topic,
data: msg
}))
}
}
const eventHandlers = {};
function on(evt,func) {
eventHandlers[evt] = eventHandlers[evt]||[];
eventHandlers[evt].push(func);
}
function off(evt,func) {
const handler = eventHandlers[evt];
if (handler) {
for (let i=0;i<handler.length;i++) {
if (handler[i] === func) {
handler.splice(i,1);
return;
}
}
}
}
function emit() {
const evt = arguments[0]
const args = Array.prototype.slice.call(arguments,1);
if (eventHandlers[evt]) {
let cpyHandlers = [...eventHandlers[evt]];
for (let i=0;i<cpyHandlers.length;i++) {
try {
cpyHandlers[i].apply(null, args);
} catch(err) {
console.warn("RED.comms.emit error: ["+evt+"] "+(err.toString()));
console.warn(err);
}
}
}
}
return {
connect: connectWS,
subscribe: subscribe,
unsubscribe:unsubscribe
unsubscribe:unsubscribe,
on,
off,
send
}
})();

View File

@@ -0,0 +1,217 @@
RED.multiplayer = (function () {
// sessionId - used to identify sessions across websocket reconnects
let sessionId
let headerWidget
// Map of session id to { session:'', user:{}, location:{}}
let connections = {}
// Map of username to { user:{}, connections:[] }
let users = {}
function addUserConnection (connection) {
if (connections[connection.session]) {
// This is an existing connection that has been authenticated
const existingConnection = connections[connection.session]
if (existingConnection.user.username !== connection.user.username) {
removeUserButton(users[existingConnection.user.username])
}
}
connections[connection.session] = connection
const user = users[connection.user.username] = users[connection.user.username] || {
user: connection.user,
connections: []
}
connection.location = connection.location || {}
user.connections.push(connection)
if (connection.user.username === RED.settings.user?.username ||
connection.session === sessionId
) {
// This is the current user - do not add a extra button for them
} else {
if (user.connections.length === 1) {
if (user.button) {
clearTimeout(user.inactiveTimeout)
clearTimeout(user.removeTimeout)
user.button.removeClass('inactive')
} else {
addUserButton(user)
}
}
}
}
function removeUserConnection (session, isDisconnected) {
const connection = connections[session]
delete connections[session]
const user = users[connection.user.username]
const i = user.connections.indexOf(connection)
user.connections.splice(i, 1)
if (isDisconnected) {
removeUserButton(user)
} else {
if (user.connections.length === 0) {
// Give the user 5s to reconnect before marking inactive
user.inactiveTimeout = setTimeout(() => {
user.button.addClass('inactive')
// Give the user further 20 seconds to reconnect before removing them
// from the user toolbar entirely
user.removeTimeout = setTimeout(() => {
removeUserButton(user)
}, 20000)
}, 5000)
}
}
}
function addUserButton (user) {
user.button = $('<li class="red-ui-multiplayer-user"><button type="button" class="red-ui-multiplayer-user-icon" href="#"></button></li>')
.attr('data-username', user.user.username)
.prependTo("#red-ui-multiplayer-user-list");
var button = user.button.find("button")
button.on('click', function () {
RED.popover.create({
target:button,
trigger: 'modal',
interactive: true,
width: "250px",
direction: 'bottom',
content: () => {
const content = $('<div>')
$('<div style="text-align: center">').text(user.user.username).appendTo(content)
const location = user.connections[0].location
if (location.workspace) {
const ws = RED.nodes.workspace(location.workspace) || RED.nodes.subflow(location.workspace)
if (ws) {
$('<div>').text(`${ws.type}: ${ws.label||ws.name||ws.id}`).appendTo(content)
} else {
$('<div>').text(`tab: unknown`).appendTo(content)
}
}
if (location.node) {
const node = RED.nodes.node(location.node)
if (node) {
$('<div>').text(`node: ${node.id}`).appendTo(content)
} else {
$('<div>').text(`node: unknown`).appendTo(content)
}
}
return content
},
}).open()
})
if (!user.user.image) {
$('<i class="fa fa-user"></i>').appendTo(button);
} else {
$('<span class="user-profile"></span>').css({
backgroundImage: "url("+user.user.image+")",
}).appendTo(button);
}
}
function getLocation () {
const location = {
workspace: RED.workspaces.active()
}
const editStack = RED.editor.getEditStack()
for (let i = editStack.length - 1; i >= 0; i--) {
if (editStack[i].id) {
location.node = editStack[i].id
break
}
}
return location
}
function updateLocation () {
const location = getLocation()
if (location.workspace !== 0) {
log('send', 'multiplayer/location', location)
RED.comms.send('multiplayer/location', location)
}
}
function removeUserButton (user) {
user.button.remove()
delete user.button
}
function updateUserLocation (data) {
connections[data.session].location = data
delete data.session
}
return {
init: function () {
sessionId = RED.settings.getLocal('multiplayer:sessionId')
if (!sessionId) {
sessionId = RED.nodes.id()
RED.settings.setLocal('multiplayer:sessionId', sessionId)
}
headerWidget = $('<li><ul id="red-ui-multiplayer-user-list"></ul></li>').prependTo('.red-ui-header-toolbar')
RED.comms.on('connect', () => {
const location = getLocation()
const connectInfo = {
session: sessionId
}
if (location.workspace !== 0) {
connectInfo.location = location
}
RED.comms.send('multiplayer/connect', connectInfo)
})
RED.comms.subscribe('multiplayer/#', (topic, msg) => {
log('recv', topic, msg)
if (topic === 'multiplayer/init') {
// We have just reconnected, runtime has sent state to
// initialise the world
connections = {}
users = {}
$('#red-ui-multiplayer-user-list').empty()
msg.forEach(connection => {
addUserConnection(connection)
})
} else if (topic === 'multiplayer/connection-added') {
addUserConnection(msg)
} else if (topic === 'multiplayer/connection-removed') {
removeUserConnection(msg.session, msg.disconnected)
} else if (topic === 'multiplayer/location') {
updateUserLocation(msg)
}
})
RED.events.on('workspace:change', (event) => {
updateLocation()
})
RED.events.on('editor:open', () => {
updateLocation()
})
RED.events.on('editor:close', () => {
updateLocation()
})
RED.events.on('editor:change', () => {
updateLocation()
})
RED.events.on('login', () => {
updateLocation()
})
RED.events.on('logout', () => {
const disconnectInfo = {
session: sessionId
}
RED.comms.send('multiplayer/disconnect', disconnectInfo)
RED.settings.removeLocal('multiplayer:sessionId')
})
}
}
function log() {
if (RED.multiplayer.DEBUG) {
console.log('[multiplayer]', ...arguments)
}
}
})();

View File

@@ -91,6 +91,31 @@ RED.nodes = (function() {
getNodeTypes: function() {
return Object.keys(nodeDefinitions);
},
/**
* Get an array of node definitions
* @param {Object} options - options object
* @param {boolean} [options.configOnly] - if true, only return config nodes
* @param {function} [options.filter] - a filter function to apply to the list of nodes
* @returns array of node definitions
*/
getNodeDefinitions: function(options) {
const result = []
const configOnly = (options && options.configOnly)
const filter = (options && options.filter)
const keys = Object.keys(nodeDefinitions)
for (const key of keys) {
const def = nodeDefinitions[key]
if(!def) { continue }
if (configOnly && def.category !== "config") {
continue
}
if (filter && !filter(nodeDefinitions[key])) {
continue
}
result.push(nodeDefinitions[key])
}
return result
},
setNodeList: function(list) {
nodeList = [];
for(var i=0;i<list.length;i++) {

View File

@@ -803,6 +803,10 @@ var RED = (function() {
RED.nodes.init();
RED.runtime.init()
if (RED.settings.theme("multiplayer.enabled",false)) {
RED.multiplayer.init()
}
RED.comms.connect();
$("#red-ui-main-container").show();

View File

@@ -174,12 +174,24 @@
this.uiContainer.width(m[1]);
}
if (this.options.sortable) {
var isCanceled = false; // Flag to track if an item has been canceled from being dropped into a different list
var noDrop = false; // Flag to track if an item is being dragged into a different list
var handle = (typeof this.options.sortable === 'string')?
this.options.sortable :
".red-ui-editableList-item-handle";
var sortOptions = {
axis: "y",
update: function( event, ui ) {
// dont trigger update if the item is being canceled
const targetList = $(event.target);
const draggedItem = ui.item;
const draggedItemParent = draggedItem.parent();
if (!targetList.is(draggedItemParent) && draggedItem.hasClass("red-ui-editableList-item-constrained")) {
noDrop = true;
}
if (isCanceled || noDrop) {
return;
}
if (that.options.sortItems) {
that.options.sortItems(that.items());
}
@@ -189,8 +201,32 @@
tolerance: "pointer",
forcePlaceholderSize:true,
placeholder: "red-ui-editabelList-item-placeholder",
start: function(e, ui){
ui.placeholder.height(ui.item.height()-4);
start: function (event, ui) {
isCanceled = false;
ui.placeholder.height(ui.item.height() - 4);
ui.item.css('cursor', 'grabbing'); // TODO: this doesn't seem to work, use a class instead?
},
stop: function (event, ui) {
ui.item.css('cursor', 'auto');
},
receive: function (event, ui) {
if (ui.item.hasClass("red-ui-editableList-item-constrained")) {
isCanceled = true;
$(ui.sender).sortable('cancel');
}
},
over: function (event, ui) {
// if the dragged item is constrained, prevent it from being dropped into a different list
const targetList = $(event.target);
const draggedItem = ui.item;
const draggedItemParent = draggedItem.parent();
if (!targetList.is(draggedItemParent) && draggedItem.hasClass("red-ui-editableList-item-constrained")) {
noDrop = true;
draggedItem.css('cursor', 'no-drop'); // TODO: this doesn't seem to work, use a class instead?
} else {
noDrop = false;
draggedItem.css('cursor', 'grabbing'); // TODO: this doesn't seem to work, use a class instead?
}
}
};
if (this.options.connectWith) {

View File

@@ -211,7 +211,7 @@ RED.popover = (function() {
closePopup(true);
});
}
if (trigger === 'hover' && options.interactive) {
if (/*trigger === 'hover' && */options.interactive) {
div.on('mouseenter', function(e) {
clearTimeout(timer);
active = true;

View File

@@ -596,18 +596,75 @@
eyeButton.show();
}
}
},
'conf-types': {
value: "conf-types",
label: "config",
icon: "fa fa-cog",
// hasValue: false,
valueLabel: function (container, value) {
// get the selected option (for access to the "name" and "module" properties)
const _options = this._optionsCache || this.typeList.find(opt => opt.value === value)?.options || []
const selectedOption = _options.find(opt => opt.value === value) || {
title: '',
name: '',
module: ''
}
container.attr("title", selectedOption.title) // set tooltip to the full path/id of the module/node
container.text(selectedOption.name) // apply the "name" of the selected option
// set "line-height" such as to make the "name" appear further up, giving room for the "module" to be displayed below the value
container.css("line-height", "1.4em")
// add the module name in smaller, lighter font below the value
$('<div></div>').text(selectedOption.module).css({
// "font-family": "var(--red-ui-monospace-font)",
color: "var(--red-ui-tertiary-text-color)",
"font-size": "0.8em",
"line-height": "1em",
opacity: 0.8
}).appendTo(container);
},
// hasValue: false,
options: function () {
if (this._optionsCache) {
return this._optionsCache
}
const configNodes = RED.nodes.registry.getNodeDefinitions({configOnly: true, filter: (def) => def.type !== "global-config"}).map((def) => {
// create a container with with 2 rows (row 1 for the name, row 2 for the module name in smaller, lighter font)
const container = $('<div style="display: flex; flex-direction: column; justify-content: space-between; row-gap: 1px;">')
const row1Name = $('<div>').text(def.type)
const row2Module = $('<div style="font-size: 0.8em; color: var(--red-ui-tertiary-text-color);">').text(def.set.module)
container.append(row1Name, row2Module)
return {
value: def.type,
name: def.type,
enabled: def.set.enabled ?? true,
local: def.set.local,
title: def.set.id, // tooltip e.g. "node-red-contrib-foo/bar"
module: def.set.module,
icon: container[0].outerHTML.trim(), // the typeInput will interpret this as html text and render it in the anchor
}
})
this._optionsCache = configNodes
return configNodes
}
}
};
// For a type with options, check value is a valid selection
// If !opt.multiple, returns the valid option object
// if opt.multiple, returns an array of valid option objects
// If not valid, returns null;
function isOptionValueValid(opt, currentVal) {
let _options = opt.options
if (typeof _options === "function") {
_options = _options.call(this)
}
if (!opt.multiple) {
for (var i=0;i<opt.options.length;i++) {
op = opt.options[i];
for (var i=0;i<_options.length;i++) {
op = _options[i];
if (typeof op === "string" && op === currentVal) {
return {value:currentVal}
} else if (op.value === currentVal) {
@@ -624,8 +681,8 @@
currentValues[v] = true;
}
});
for (var i=0;i<opt.options.length;i++) {
op = opt.options[i];
for (var i=0;i<_options.length;i++) {
op = _options[i];
var val = typeof op === "string" ? op : op.value;
if (currentValues.hasOwnProperty(val)) {
delete currentValues[val];
@@ -1056,7 +1113,9 @@
if (this.optionMenu) {
this.optionMenu.remove();
}
this.menu.remove();
if (this.menu) {
this.menu.remove();
}
this.uiSelect.remove();
},
types: function(types) {
@@ -1089,7 +1148,7 @@
this.menu = this._createMenu(this.typeList,{},function(v) { that.type(v) });
if (currentType && !this.typeMap.hasOwnProperty(currentType)) {
if (!firstCall) {
this.type(this.typeList[0].value);
this.type(this.typeList[0]?.value || ""); // permit empty typeList
}
} else {
this.propertyType = null;
@@ -1126,6 +1185,11 @@
var selectedOption = [];
var valueToCheck = value;
if (opt.options) {
let _options = opt.options
if (typeof opt.options === "function") {
_options = opt.options.call(this)
}
if (opt.hasValue && opt.parse) {
var parts = opt.parse(value);
if (this.options.debug) { console.log(this.identifier,"new parse",parts) }
@@ -1139,8 +1203,8 @@
checkValues = valueToCheck.split(",");
}
checkValues.forEach(function(valueToCheck) {
for (var i=0;i<opt.options.length;i++) {
var op = opt.options[i];
for (var i=0;i<_options.length;i++) {
var op = _options[i];
if (typeof op === "string") {
if (op === valueToCheck || op === ""+valueToCheck) {
selectedOption.push(that.activeOptions[op]);
@@ -1175,7 +1239,7 @@
},
type: function(type) {
if (!arguments.length) {
return this.propertyType;
return this.propertyType || this.options?.default || '';
} else {
var that = this;
if (this.options.debug) { console.log(this.identifier,"----- SET TYPE -----",type) }
@@ -1276,6 +1340,10 @@
this.optionMenu = null;
}
if (opt.options) {
let _options = opt.options
if (typeof _options === "function") {
_options = opt.options.call(this);
}
if (this.optionExpandButton) {
this.optionExpandButton.hide();
this.optionExpandButton.shown = false;
@@ -1292,7 +1360,7 @@
this.valueLabelContainer.hide();
}
this.activeOptions = {};
opt.options.forEach(function(o) {
_options.forEach(function(o) {
if (typeof o === 'string') {
that.activeOptions[o] = {label:o,value:o};
} else {
@@ -1312,7 +1380,7 @@
if (validValues) {
that._updateOptionSelectLabel(validValues)
} else {
op = opt.options[0];
op = _options[0] || {value:""}; // permit zero options
if (typeof op === "string") {
this.value(op);
that._updateOptionSelectLabel({value:op});
@@ -1331,7 +1399,7 @@
that._updateOptionSelectLabel(validValues);
}
} else {
var selectedOption = this.optionValue||opt.options[0];
var selectedOption = this.optionValue||_options[0];
if (opt.parse) {
var selectedOptionObj = typeof selectedOption === "string"?{value:selectedOption}:selectedOption
var parts = opt.parse(this.input.val(),selectedOptionObj);
@@ -1375,7 +1443,7 @@
})
}
}
this.optionMenu = this._createMenu(opt.options,opt,function(v){
this.optionMenu = this._createMenu(_options,opt,function(v){
if (!opt.multiple) {
that._updateOptionSelectLabel(that.activeOptions[v]);
if (!opt.hasValue) {

View File

@@ -326,47 +326,78 @@ RED.editor = (function() {
/**
* Create a config-node select box for this property
* @param node - the node being edited
* @param property - the name of the field
* @param type - the type of the config-node
* @param {Object} node - the node being edited
* @param {String} property - the name of the node property
* @param {String} type - the type of the config-node
* @param {"node-config-input"|"node-input"|"node-input-subflow-env"} prefix - the prefix to use in the input element ids
* @param {Function} [filter] - a function to filter the list of config nodes
* @param {Object} [env] - the environment variable object (only used for subflow env vars)
*/
function prepareConfigNodeSelect(node,property,type,prefix,filter) {
var input = $("#"+prefix+"-"+property);
if (input.length === 0 ) {
function prepareConfigNodeSelect(node, property, type, prefix, filter, env) {
let nodeValue
if (prefix === 'node-input-subflow-env') {
nodeValue = env?.value
} else {
nodeValue = node[property]
}
const buttonId = `${prefix}-lookup-${property}`
const selectId = prefix + '-' + property
const input = $(`#${selectId}`);
if (input.length === 0) {
return;
}
var newWidth = input.width();
var attrStyle = input.attr('style');
var m;
const attrStyle = input.attr('style');
let newWidth;
let m;
if ((m = /(^|\s|;)width\s*:\s*([^;]+)/i.exec(attrStyle)) !== null) {
newWidth = m[2].trim();
} else {
newWidth = "70%";
}
var outerWrap = $("<div></div>").css({
const outerWrap = $("<div></div>").css({
width: newWidth,
display:'inline-flex'
display: 'inline-flex'
});
var select = $('<select id="'+prefix+'-'+property+'"></select>').appendTo(outerWrap);
const select = $('<select id="' + selectId + '"></select>').appendTo(outerWrap);
input.replaceWith(outerWrap);
// set the style attr directly - using width() on FF causes a value of 114%...
select.css({
'flex-grow': 1
});
updateConfigNodeSelect(property,type,node[property],prefix,filter);
$('<a id="'+prefix+'-lookup-'+property+'" class="red-ui-button"><i class="fa fa-pencil"></i></a>')
.css({"margin-left":"10px"})
updateConfigNodeSelect(property, type, nodeValue, prefix, filter);
const disableButton = function(disabled) {
btn.prop( "disabled", !!disabled)
btn.toggleClass("disabled", !!disabled)
}
// create the edit button
const btn = $('<a id="' + buttonId + '" class="red-ui-button"><i class="fa fa-pencil"></i></a>')
.css({ "margin-left": "10px" })
.appendTo(outerWrap);
$('#'+prefix+'-lookup-'+property).on("click", function(e) {
showEditConfigNodeDialog(property,type,select.find(":selected").val(),prefix,node);
// add the click handler
btn.on("click", function (e) {
const selectedOpt = select.find(":selected")
if (selectedOpt.data('env')) { return } // don't show the dialog for env vars items (MVP. Future enhancement: lookup the env, if present, show the associated edit dialog)
if (btn.prop("disabled")) { return }
showEditConfigNodeDialog(property, type, selectedOpt.val(), prefix, node);
e.preventDefault();
});
// dont permit the user to click the button if the selected option is an env var
select.on("change", function () {
const selectedOpt = select.find(":selected")
if (selectedOpt?.data('env')) {
disableButton(true)
} else {
disableButton(false)
}
});
var label = "";
var configNode = RED.nodes.node(node[property]);
var node_def = RED.nodes.getType(type);
var configNode = RED.nodes.node(nodeValue);
if (configNode) {
label = RED.utils.getNodeLabel(configNode,configNode.id);
label = RED.utils.getNodeLabel(configNode, configNode.id);
}
input.val(label);
}
@@ -768,12 +799,9 @@ RED.editor = (function() {
}
function defaultConfigNodeSort(A,B) {
if (A.__label__ < B.__label__) {
return -1;
} else if (A.__label__ > B.__label__) {
return 1;
}
return 0;
// sort case insensitive so that `[env] node-name` items are at the top and
// not mixed inbetween the the lower and upper case items
return (A.__label__ || '').localeCompare((B.__label__ || ''), undefined, {sensitivity: 'base'})
}
function updateConfigNodeSelect(name,type,value,prefix,filter) {
@@ -788,7 +816,7 @@ RED.editor = (function() {
}
$("#"+prefix+"-"+name).val(value);
} else {
let inclSubflowEnvvars = false
var select = $("#"+prefix+"-"+name);
var node_def = RED.nodes.getType(type);
select.children().remove();
@@ -796,6 +824,7 @@ RED.editor = (function() {
var activeWorkspace = RED.nodes.workspace(RED.workspaces.active());
if (!activeWorkspace) {
activeWorkspace = RED.nodes.subflow(RED.workspaces.active());
inclSubflowEnvvars = true
}
var configNodes = [];
@@ -811,6 +840,31 @@ RED.editor = (function() {
}
}
});
// as includeSubflowEnvvars is true, this is a subflow.
// include any 'conf-types' env vars as a list of avaiable configs
// in the config dropdown as `[env] node-name`
if (inclSubflowEnvvars && activeWorkspace.env) {
const parentEnv = activeWorkspace.env.filter(env => env.ui?.type === 'conf-types' && env.type === type)
if (parentEnv && parentEnv.length > 0) {
const locale = RED.i18n.lang()
for (let i = 0; i < parentEnv.length; i++) {
const tenv = parentEnv[i]
const ui = tenv.ui || {}
const labels = ui.label || {}
const labelText = RED.editor.envVarList.lookupLabel(labels, labels["en-US"] || tenv.name, locale)
const config = {
env: tenv,
id: '${' + parentEnv[0].name + '}',
type: type,
label: labelText,
__label__: `[env] ${labelText}`
}
configNodes.push(config)
}
}
}
var configSortFn = defaultConfigNodeSort;
if (typeof node_def.sort == "function") {
configSortFn = node_def.sort;
@@ -822,7 +876,10 @@ RED.editor = (function() {
}
configNodes.forEach(function(cn) {
$('<option value="'+cn.id+'"'+(value==cn.id?" selected":"")+'></option>').text(RED.text.bidi.enforceTextDirectionWithUCC(cn.__label__)).appendTo(select);
const option = $('<option value="'+cn.id+'"'+(value==cn.id?" selected":"")+'></option>').text(RED.text.bidi.enforceTextDirectionWithUCC(cn.__label__)).appendTo(select);
if (cn.env) {
option.data('env', cn.env) // set a data attribute to indicate this is an env var (to inhibit the edit button)
}
delete cn.__label__;
});
@@ -1483,9 +1540,16 @@ RED.editor = (function() {
}
RED.tray.close(function() {
var filter = null;
if (editContext && typeof editContext._def.defaults[configProperty].filter === 'function') {
filter = function(n) {
return editContext._def.defaults[configProperty].filter.call(editContext,n);
// when editing a config via subflow edit panel, the `configProperty` will not
// necessarily be a property of the editContext._def.defaults object
// Also, when editing via dashboard sidebar, editContext can be null
// so we need to guard both scenarios
if (editContext?._def) {
const isSubflow = (editContext._def.type === 'subflow' || /subflow:.*/.test(editContext._def.type))
if (editContext && !isSubflow && typeof editContext._def.defaults?.[configProperty]?.filter === 'function') {
filter = function(n) {
return editContext._def.defaults[configProperty].filter.call(editContext,n);
}
}
}
updateConfigNodeSelect(configProperty,configType,editing_config_node.id,prefix,filter);
@@ -1546,7 +1610,7 @@ RED.editor = (function() {
RED.history.push(historyEvent);
RED.tray.close(function() {
var filter = null;
if (editContext && typeof editContext._def.defaults[configProperty].filter === 'function') {
if (editContext && typeof editContext._def.defaults[configProperty]?.filter === 'function') {
filter = function(n) {
return editContext._def.defaults[configProperty].filter.call(editContext,n);
}
@@ -2132,6 +2196,7 @@ RED.editor = (function() {
filteredEditPanes[type] = filter
}
editPanes[type] = definition;
}
},
prepareConfigNodeSelect: prepareConfigNodeSelect,
}
})();

View File

@@ -1,8 +1,9 @@
RED.editor.envVarList = (function() {
var currentLocale = 'en-US';
var DEFAULT_ENV_TYPE_LIST = ['str','num','bool','json','bin','env'];
var DEFAULT_ENV_TYPE_LIST_INC_CRED = ['str','num','bool','json','bin','env','cred','jsonata'];
const DEFAULT_ENV_TYPE_LIST = ['str','num','bool','json','bin','env'];
const DEFAULT_ENV_TYPE_LIST_INC_CONFTYPES = ['str','num','bool','json','bin','env','conf-types'];
const DEFAULT_ENV_TYPE_LIST_INC_CRED = ['str','num','bool','json','bin','env','cred','jsonata'];
/**
* Create env var edit interface
@@ -10,8 +11,8 @@ RED.editor.envVarList = (function() {
* @param node - subflow node
*/
function buildPropertiesList(envContainer, node) {
var isTemplateNode = (node.type === "subflow");
if(RED.editor.envVarList.debug) { console.log('envVarList: buildPropertiesList', envContainer, node) }
const isTemplateNode = (node.type === "subflow");
envContainer
.css({
@@ -83,7 +84,14 @@ RED.editor.envVarList = (function() {
// if `opt.ui` does not exist, then apply defaults. If these
// defaults do not change then they will get stripped off
// before saving.
if (opt.type === 'cred') {
if (opt.type === 'conf-types') {
opt.ui = opt.ui || {
icon: "fa fa-cog",
type: "conf-types",
opts: {opts:[]}
}
opt.ui.type = "conf-types";
} else if (opt.type === 'cred') {
opt.ui = opt.ui || {
icon: "",
type: "cred"
@@ -119,7 +127,7 @@ RED.editor.envVarList = (function() {
}
});
buildEnvEditRow(uiRow, opt.ui, nameField, valueField);
buildEnvEditRow(uiRow, opt, nameField, valueField);
nameField.trigger('change');
}
},
@@ -181,21 +189,23 @@ RED.editor.envVarList = (function() {
* @param nameField - name field of env var
* @param valueField - value field of env var
*/
function buildEnvEditRow(container, ui, nameField, valueField) {
function buildEnvEditRow(container, opt, nameField, valueField) {
const ui = opt.ui
if(RED.editor.envVarList.debug) { console.log('envVarList: buildEnvEditRow', container, ui, nameField, valueField) }
container.addClass("red-ui-editor-subflow-env-ui-row")
var topRow = $('<div></div>').appendTo(container);
$('<div></div>').appendTo(topRow);
$('<div>').text(RED._("editor.icon")).appendTo(topRow);
$('<div>').text(RED._("editor.label")).appendTo(topRow);
$('<div>').text(RED._("editor.inputType")).appendTo(topRow);
$('<div class="red-env-ui-input-type-col">').text(RED._("editor.inputType")).appendTo(topRow);
var row = $('<div></div>').appendTo(container);
$('<div><i class="red-ui-editableList-item-handle fa fa-bars"></i></div>').appendTo(row);
var typeOptions = {
'input': {types:DEFAULT_ENV_TYPE_LIST},
'select': {opts:[]},
'spinner': {},
'cred': {}
'input': {types:DEFAULT_ENV_TYPE_LIST_INC_CONFTYPES},
'select': {opts:[]},
'spinner': {},
'cred': {}
};
if (ui.opts) {
typeOptions[ui.type] = ui.opts;
@@ -260,15 +270,16 @@ RED.editor.envVarList = (function() {
labelInput.attr("placeholder",$(this).val())
});
var inputCell = $('<div></div>').appendTo(row);
var inputCellInput = $('<input type="text">').css("width","100%").appendTo(inputCell);
var inputCell = $('<div class="red-env-ui-input-type-col"></div>').appendTo(row);
var uiInputTypeInput = $('<input type="text">').css("width","100%").appendTo(inputCell);
if (ui.type === "input") {
inputCellInput.val(ui.opts.types.join(","));
uiInputTypeInput.val(ui.opts.types.join(","));
}
var checkbox;
var selectBox;
inputCellInput.typedInput({
// the options presented in the UI section for an "input" type selection
uiInputTypeInput.typedInput({
types: [
{
value:"input",
@@ -429,7 +440,7 @@ RED.editor.envVarList = (function() {
}
});
ui.opts.opts = vals;
inputCellInput.typedInput('value',Date.now())
uiInputTypeInput.typedInput('value',Date.now())
}
}
}
@@ -496,12 +507,13 @@ RED.editor.envVarList = (function() {
} else {
delete ui.opts.max;
}
inputCellInput.typedInput('value',Date.now())
uiInputTypeInput.typedInput('value',Date.now())
}
}
}
}
},
'conf-types',
{
value:"none",
label:RED._("editor.inputs.none"), icon:"fa fa-times",hasValue:false
@@ -519,14 +531,20 @@ RED.editor.envVarList = (function() {
// In the case of 'input' type, the typedInput uses the multiple-option
// mode. Its value needs to be set to a comma-separately list of the
// selected options.
inputCellInput.typedInput('value',ui.opts.types.join(","))
uiInputTypeInput.typedInput('value',ui.opts.types.join(","))
} else if (ui.type === 'conf-types') {
// In the case of 'conf-types' type, the typedInput will be populated
// with a list of all config nodes types installed.
// Restore the value to the last selected type
uiInputTypeInput.typedInput('value', opt.type)
} else {
// No other type cares about `value`, but doing this will
// force a refresh of the label now that `ui.opts` has
// been updated.
inputCellInput.typedInput('value',Date.now())
uiInputTypeInput.typedInput('value',Date.now())
}
if(RED.editor.envVarList.debug) { console.log('envVarList: inputCellInput on:typedinputtypechange. ui.type = ' + ui.type) }
switch (ui.type) {
case 'input':
valueField.typedInput('types',ui.opts.types);
@@ -544,7 +562,7 @@ RED.editor.envVarList = (function() {
valueField.typedInput('types',['cred']);
break;
default:
valueField.typedInput('types',DEFAULT_ENV_TYPE_LIST)
valueField.typedInput('types', DEFAULT_ENV_TYPE_LIST);
}
if (ui.type === 'checkbox') {
valueField.typedInput('type','bool');
@@ -556,8 +574,46 @@ RED.editor.envVarList = (function() {
}
}).on("change", function(evt,type) {
if (ui.type === 'input') {
var types = inputCellInput.typedInput('value');
const selectedType = $(this).typedInput('type') // the UI typedInput type
if(RED.editor.envVarList.debug) { console.log('envVarList: inputCellInput on:change. selectedType = ' + selectedType) }
if (selectedType === 'conf-types') {
const selectedConfigType = $(this).typedInput('value') || opt.type
let activeWorkspace = RED.nodes.workspace(RED.workspaces.active());
if (!activeWorkspace) {
activeWorkspace = RED.nodes.subflow(RED.workspaces.active());
}
// get a list of all config nodes matching the selectedValue
const configNodes = [];
RED.nodes.eachConfig(function(config) {
if (config.type == selectedConfigType && (!config.z || config.z === activeWorkspace.id)) {
const modulePath = config._def?.set?.id || ''
let label = RED.utils.getNodeLabel(config, config.id) || config.id;
label += config.d ? ' ['+RED._('workspace.disabled')+']' : '';
const _config = {
_type: selectedConfigType,
value: config.id,
label: label,
title: modulePath ? modulePath + ' - ' + label : label,
enabled: config.d !== true,
disabled: config.d === true,
}
configNodes.push(_config);
}
});
const tiTypes = {
value: selectedConfigType,
label: "config",
icon: "fa fa-cog",
options: configNodes,
}
valueField.typedInput('types', [tiTypes]);
valueField.typedInput('type', selectedConfigType);
valueField.typedInput('value', opt.value);
} else if (ui.type === 'input') {
var types = uiInputTypeInput.typedInput('value');
ui.opts.types = (types === "") ? ["str"] : types.split(",");
valueField.typedInput('types',ui.opts.types);
}
@@ -569,7 +625,7 @@ RED.editor.envVarList = (function() {
})
// Set the input to the right type. This will trigger the 'typedinputtypechange'
// event handler (just above ^^) to update the value if needed
inputCellInput.typedInput('type',ui.type)
uiInputTypeInput.typedInput('type',ui.type)
}
function setLocale(l, list) {

View File

@@ -153,10 +153,6 @@ RED.envVar = (function() {
}
function init(done) {
if (!RED.user.hasPermission("settings.write")) {
RED.notify(RED._("user.errors.settings"),"error");
return;
}
RED.userSettings.add({
id:'envvar',
title: RED._("env-var.environment"),

View File

@@ -909,17 +909,19 @@ RED.subflow = (function() {
/**
* Create interface for controlling env var UI definition
* Build the edit dialog for a subflow template (creating/modifying a subflow template)
* @param {Object} uiContainer - the jQuery container for the environment variable list
* @param {Object} node - the subflow template node
*/
function buildEnvControl(envList,node) {
function buildEnvControl(uiContainer,node) {
var tabs = RED.tabs.create({
id: "subflow-env-tabs",
onchange: function(tab) {
if (tab.id === "subflow-env-tab-preview") {
var inputContainer = $("#subflow-input-ui");
var list = envList.editableList("items");
var list = uiContainer.editableList("items");
var exportedEnv = exportEnvList(list, true);
buildEnvUI(inputContainer, exportedEnv,node);
buildEnvUI(inputContainer, exportedEnv, node);
}
$("#subflow-env-tabs-content").children().hide();
$("#" + tab.id).show();
@@ -957,12 +959,33 @@ RED.subflow = (function() {
RED.editor.envVarList.setLocale(locale);
}
function buildEnvUIRow(row, tenv, ui, node) {
/**
* Build a UI row for a subflow instance environment variable
* Also used to build the UI row for subflow template preview
* @param {JQuery} row - A form row element
* @param {Object} tenv - A template environment variable
* @param {String} tenv.name - The name of the environment variable
* @param {String} tenv.type - The type of the environment variable
* @param {String} tenv.value - The value set for this environment variable
* @param {Object} tenv.parent - The parent environment variable
* @param {String} tenv.parent.value - The value set for the parent environment variable
* @param {String} tenv.parent.type - The type of the parent environment variable
* @param {Object} tenv.ui - The UI configuration for the environment variable
* @param {String} tenv.ui.icon - The icon for the environment variable
* @param {Object} tenv.ui.label - The label for the environment variable
* @param {String} tenv.ui.type - The type of the UI control for the environment variable
* @param {Object} node - The subflow instance node
*/
function buildEnvUIRow(row, tenv, node) {
if(RED.subflow.debug) { console.log("buildEnvUIRow", tenv) }
const ui = tenv.ui || {}
ui.label = ui.label||{};
if ((tenv.type === "cred" || (tenv.parent && tenv.parent.type === "cred")) && !ui.type) {
ui.type = "cred";
ui.opts = {};
} else if (tenv.type === "conf-types") {
ui.type = "conf-types"
ui.opts = { types: ['conf-types'] }
} else if (!ui.type) {
ui.type = "input";
ui.opts = { types: RED.editor.envVarList.DEFAULT_ENV_TYPE_LIST }
@@ -1006,9 +1029,10 @@ RED.subflow = (function() {
if (tenv.hasOwnProperty('type')) {
val.type = tenv.type;
}
const elId = getSubflowEnvPropertyName(tenv.name)
switch(ui.type) {
case "input":
input = $('<input type="text">').css('width','70%').appendTo(row);
input = $('<input type="text">').css('width','70%').attr('id', elId).appendTo(row);
if (ui.opts.types && ui.opts.types.length > 0) {
var inputType = val.type;
if (ui.opts.types.indexOf(inputType) === -1) {
@@ -1035,7 +1059,7 @@ RED.subflow = (function() {
}
break;
case "select":
input = $('<select>').css('width','70%').appendTo(row);
input = $('<select>').css('width','70%').attr('id', elId).appendTo(row);
if (ui.opts.opts) {
ui.opts.opts.forEach(function(o) {
$('<option>').val(o.v).text(RED.editor.envVarList.lookupLabel(o.l, o.l['en-US']||o.v, locale)).appendTo(input);
@@ -1046,7 +1070,7 @@ RED.subflow = (function() {
case "checkbox":
label.css("cursor","default");
var cblabel = $('<label>').css('width','70%').appendTo(row);
input = $('<input type="checkbox">').css({
input = $('<input type="checkbox">').attr('id', elId).css({
marginTop: 0,
width: 'auto',
height: '34px'
@@ -1064,7 +1088,7 @@ RED.subflow = (function() {
input.prop("checked",boolVal);
break;
case "spinner":
input = $('<input>').css('width','70%').appendTo(row);
input = $('<input>').css('width','70%').attr('id', elId).appendTo(row);
var spinnerOpts = {};
if (ui.opts.hasOwnProperty('min')) {
spinnerOpts.min = ui.opts.min;
@@ -1093,18 +1117,25 @@ RED.subflow = (function() {
default: 'cred'
})
break;
}
if (input) {
input.attr('id',getSubflowEnvPropertyName(tenv.name))
case "conf-types":
// let clsId = 'config-node-input-' + val.type + '-' + val.value + '-' + Math.floor(Math.random() * 100000);
// clsId = clsId.replace(/\W/g, '-');
// input = $('<input>').css('width','70%').addClass(clsId).attr('id', elId).appendTo(row);
input = $('<input>').css('width','70%').attr('id', elId).appendTo(row);
const _type = tenv.parent?.type || tenv.type;
RED.editor.prepareConfigNodeSelect(node, tenv.name, _type, 'node-input-subflow-env', null, tenv);
break;
}
}
/**
* Create environment variable input UI
* Build the edit form for a subflow instance
* Also used to build the preview form in the subflow template edit dialog
* @param uiContainer - container for UI
* @param envList - env var definitions of template
*/
function buildEnvUI(uiContainer, envList, node) {
if(RED.subflow.debug) { console.log("buildEnvUI",envList) }
uiContainer.empty();
for (var i = 0; i < envList.length; i++) {
var tenv = envList[i];
@@ -1112,7 +1143,7 @@ RED.subflow = (function() {
continue;
}
var row = $("<div/>", { class: "form-row" }).appendTo(uiContainer);
buildEnvUIRow(row,tenv, tenv.ui || {}, node);
buildEnvUIRow(row, tenv, node);
}
}
// buildEnvUI
@@ -1185,6 +1216,9 @@ RED.subflow = (function() {
delete ui.opts
}
break;
case "conf-types":
delete ui.opts;
break;
default:
delete ui.opts;
}
@@ -1207,8 +1241,9 @@ RED.subflow = (function() {
if (/^subflow:/.test(node.type)) {
var subflowDef = RED.nodes.subflow(node.type.substring(8));
if (subflowDef.env) {
subflowDef.env.forEach(function(env) {
subflowDef.env.forEach(function(env, i) {
var item = {
index: i,
name:env.name,
parent: {
type: env.type,
@@ -1273,6 +1308,7 @@ RED.subflow = (function() {
}
function exportSubflowInstanceEnv(node) {
if(RED.subflow.debug) { console.log("exportSubflowInstanceEnv",node) }
var env = [];
// First, get the values for the SubflowTemplate defined properties
// - these are the ones with custom UI elements
@@ -1319,6 +1355,9 @@ RED.subflow = (function() {
item.type = 'bool';
item.value = ""+input.prop("checked");
break;
case "conf-types":
item.value = input.val()
item.type = data.parent.value;
}
if (ui.type === "cred" || item.type !== data.parent.type || item.value !== data.parent.value) {
env.push(item);
@@ -1332,8 +1371,15 @@ RED.subflow = (function() {
return 'node-input-subflow-env-'+name.replace(/[^a-z0-9-_]/ig,"_");
}
// Called by subflow.oneditprepare for both instances and templates
/**
* Build the subflow edit form
* Called by subflow.oneditprepare for both instances and templates
* @param {"subflow"|"subflow-template"} type - the type of subflow being edited
* @param {Object} node - the node being edited
*/
function buildEditForm(type,node) {
if(RED.subflow.debug) { console.log("buildEditForm",type,node) }
if (type === "subflow-template") {
// This is the tabbed UI that offers the env list - with UI options
// plus the preview tab

View File

@@ -435,10 +435,15 @@ RED.tourGuide = (function() {
function listTour() {
return [
{
id: "4_0",
label: "4.0",
path: "./tours/welcome.js"
},
{
id: "3_1",
label: "3.1",
path: "./tours/welcome.js"
path: "./tours/3.1/welcome.js"
},
{
id: "3_0",

View File

@@ -264,6 +264,7 @@
setTimeout(function() {
oldTray.tray.detach();
showTray(options);
RED.events.emit('editor:change')
},250)
} else {
if (stack.length > 0) {
@@ -333,6 +334,7 @@
RED.view.focus();
} else {
stack[stack.length-1].tray.css("z-index", "auto");
RED.events.emit('editor:change')
}
},250)
}

View File

@@ -483,6 +483,16 @@ RED.utils = (function() {
$('<span class="red-ui-debug-msg-type-string-swatch"></span>').css('backgroundColor',obj).appendTo(e);
}
let n = RED.nodes.node(obj) ?? RED.nodes.workspace(obj);
if (n) {
if (options.nodeSelector && "function" == typeof options.nodeSelector) {
e.css('cursor', 'pointer').on("click", function(evt) {
evt.preventDefault();
options.nodeSelector(n.id);
})
}
}
} else if (typeof obj === 'number') {
e = $('<span class="red-ui-debug-msg-type-number"></span>').appendTo(entryObj);
@@ -589,6 +599,7 @@ RED.utils = (function() {
exposeApi: exposeApi,
// tools: tools // Do not pass tools down as we
// keep them attached to the top-level header
nodeSelector: options.nodeSelector,
}
).appendTo(row);
}
@@ -619,6 +630,7 @@ RED.utils = (function() {
exposeApi: exposeApi,
// tools: tools // Do not pass tools down as we
// keep them attached to the top-level header
nodeSelector: options.nodeSelector,
}
).appendTo(row);
}
@@ -675,6 +687,7 @@ RED.utils = (function() {
exposeApi: exposeApi,
// tools: tools // Do not pass tools down as we
// keep them attached to the top-level header
nodeSelector: options.nodeSelector,
}
).appendTo(row);
}
@@ -906,7 +919,10 @@ RED.utils = (function() {
* @returns true if valid, String if invalid
*/
function validateTypedProperty(propertyValue, propertyType, opt) {
if (propertyValue && /^\${[^}]+}$/.test(propertyValue)) {
// Allow ${ENV_VAR} value
return true
}
let error
if (propertyType === 'json') {
try {

View File

@@ -4156,7 +4156,7 @@ RED.view = (function() {
}
var width = img.width * scaleFactor;
if (width > 20) {
scalefactor *= 20/width;
scaleFactor *= 20/width;
width = 20;
}
var height = img.height * scaleFactor;

View File

@@ -187,6 +187,7 @@ RED.user = (function() {
}
function logout() {
RED.events.emit('logout')
var tokens = RED.settings.get("auth-tokens");
var token = tokens?tokens.access_token:"";
$.ajax({
@@ -225,6 +226,7 @@ RED.user = (function() {
});
}
});
$('<i class="fa fa-user"></i>').appendTo("#red-ui-header-button-user");
} else {
RED.menu.addItem("red-ui-header-button-user",{
id:"usermenu-item-username",
@@ -237,6 +239,15 @@ RED.user = (function() {
RED.user.logout();
}
});
const userMenu = $("#red-ui-header-button-user")
userMenu.empty()
if (RED.settings.user.image) {
$('<span class="user-profile"></span>').css({
backgroundImage: "url("+RED.settings.user.image+")",
}).appendTo(userMenu);
} else {
$('<i class="fa fa-user"></i>').appendTo(userMenu);
}
}
}
@@ -247,14 +258,6 @@ RED.user = (function() {
var userMenu = $('<li><a id="red-ui-header-button-user" class="button hide" href="#"></a></li>')
.prependTo(".red-ui-header-toolbar");
if (RED.settings.user.image) {
$('<span class="user-profile"></span>').css({
backgroundImage: "url("+RED.settings.user.image+")",
}).appendTo(userMenu.find("a"));
} else {
$('<i class="fa fa-user"></i>').appendTo(userMenu.find("a"));
}
RED.menu.init({id:"red-ui-header-button-user",
options: []
});

View File

@@ -16,8 +16,20 @@
RED.validators = {
number: function(blankAllowed,mopt){
return function(v, opt) {
if ((blankAllowed&&(v===''||v===undefined)) || (v!=='' && !isNaN(v))) {
return true;
if (blankAllowed && (v === '' || v === undefined)) {
return true
}
if (v !== '') {
if (/^NaN$|^[+-]?[0-9]*\.?[0-9]*([eE][-+]?[0-9]+)?$|^[+-]?(0b|0B)[01]+$|^[+-]?(0o|0O)[0-7]+$|^[+-]?(0x|0X)[0-9a-fA-F]+$/.test(v)) {
return true
}
if (/^\${[^}]+}$/.test(v)) {
// Allow ${ENV_VAR} value
return true
}
}
if (!isNaN(v)) {
return true
}
if (opt && opt.label) {
return RED._("validator.errors.invalid-num-prop", {

View File

@@ -38,7 +38,7 @@ body {
}
#red-ui-main-container {
position: absolute;
top:40px; left:0; bottom: 0; right:0;
top: var(--red-ui-header-height); left:0; bottom: 0; right:0;
overflow:hidden;
}

View File

@@ -259,7 +259,8 @@ $deploy-button-background-disabled-hover: #555;
$header-background: #000;
$header-button-background-active: #121212;
$header-menu-color: #C7C7C7;
$header-accent: #d41313;
$header-menu-color: #eee;
$header-menu-color-disabled: #666;
$header-menu-heading-color: #fff;
$header-menu-sublabel-color: #aeaeae;

View File

@@ -23,16 +23,20 @@
top: 0;
left: 0;
width: 100%;
height: 40px;
height: var(--red-ui-header-height);
background: var(--red-ui-header-background);
box-sizing: border-box;
padding: 0px 0px 0px 20px;
color: var(--red-ui-header-menu-color);
font-size: 14px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid var(--red-ui-header-accent);
padding-top: 2px;
span.red-ui-header-logo {
float: left;
margin-top: 5px;
font-size: 30px;
line-height: 30px;
text-decoration: none;
@@ -42,7 +46,7 @@
vertical-align: middle;
font-size: 16px !important;
&:not(:first-child) {
margin-left: 5px;
margin-left: 8px;
}
}
img {
@@ -59,25 +63,29 @@
}
.red-ui-header-toolbar {
display: flex;
align-items: stretch;
padding: 0;
margin: 0;
list-style: none;
float: right;
> li {
display: inline-block;
display: inline-flex;
align-items: stretch;
padding: 0;
margin: 0;
position: relative;
}
}
.button {
height: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
text-align: center;
line-height: 40px;
display: inline-block;
font-size: 20px;
padding: 0px 12px;
text-decoration: none;
@@ -267,13 +275,13 @@
color: var(--red-ui-header-menu-heading-color);
}
#red-ui-header-button-user .user-profile {
.user-profile {
background-position: center center;
background-repeat: no-repeat;
background-size: contain;
display: inline-block;
width: 40px;
height: 35px;
width: 30px;
height: 30px;
vertical-align: middle;
}
}

View File

@@ -0,0 +1,48 @@
#red-ui-multiplayer-user-list {
display: inline-flex;
align-items: center;
margin: 0 5px;
li {
display: inline-flex;
align-items: center;
width: 30px;
margin: 0 2px;
}
}
.red-ui-multiplayer-user-icon {
background: var(--red-ui-header-background);
border: 2px solid var(--red-ui-header-menu-color);
border-radius: 30px;
display: inline-flex;
justify-content: center;
align-items: center;
width: 28px;
height: 28px;
text-align: center;
overflow: hidden;
box-sizing: border-box;
text-decoration: none;
color: var(--red-ui-header-menu-color);
padding: 0px;
margin: 0px;
vertical-align: middle;
&:focus {
outline: none;
}
.red-ui-multiplayer-user.inactive & {
opacity: 0.5;
}
.user-profile {
background-position: center center;
background-repeat: no-repeat;
background-size: contain;
display: inline-block;
vertical-align: middle;
width: 28px;
height: 28px;
}
}

View File

@@ -0,0 +1,17 @@
/**
* Copyright JS Foundation and other contributors, http://js.foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/
$header-height: 48px;

View File

@@ -15,4 +15,5 @@
**/
@import "colors";
@import "sizes";
@import "variables";

View File

@@ -15,6 +15,7 @@
**/
@import "colors";
@import "sizes";
@import "variables";
@import "mixins";
@@ -72,3 +73,5 @@
@import "radialMenu";
@import "tourGuide";
@import "multiplayer";

View File

@@ -16,6 +16,9 @@
--red-ui-shadow: #{$shadow};
// Header Height
--red-ui-header-height: #{$header-height};
// Main body text
--red-ui-primary-text-color: #{$primary-text-color};
// UI control label text
@@ -240,6 +243,7 @@
--red-ui-header-background: #{$header-background};
--red-ui-header-accent: #{$header-accent};
--red-ui-header-button-background-active: #{$header-button-background-active};
--red-ui-header-menu-color: #{$header-menu-color};
--red-ui-header-menu-color-disabled: #{$header-menu-color-disabled};

View File

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 189 KiB

View File

@@ -0,0 +1,231 @@
export default {
version: "3.1.0",
steps: [
{
titleIcon: "fa fa-map-o",
title: {
"en-US": "Welcome to Node-RED 3.1!",
"ja": "Node-RED 3.1へようこそ!",
"fr": "Bienvenue dans Node-RED 3.1!"
},
description: {
"en-US": "<p>Let's take a moment to discover the new features in this release.</p>",
"ja": "<p>本リリースの新機能を見つけてみましょう。</p>",
"fr": "<p>Prenons un moment pour découvrir les nouvelles fonctionnalités de cette version.</p>"
}
},
{
title: {
"en-US": "New ways to work with groups",
"ja": "グループの新たな操作方法",
"fr": "De nouvelles façons de travailler avec les groupes"
},
description: {
"en-US": `<p>We have changed how you interact with groups in the editor.</p>
<ul>
<li>They don't get in the way when clicking on a node</li>
<li>They can be reordered using the Moving Forwards and Move Backwards actions</li>
<li>Multiple nodes can be dragged into a group in one go</li>
<li>Holding <code>Alt</code> when dragging a node will *remove* it from its group</li>
</ul>`,
"ja": `<p>エディタ上のグループの操作が変更されました。</p>
<ul>
<li>グループ内のノードをクリックする時に、グループが邪魔をすることが無くなりました。</li>
<li>「前面へ移動」と「背面へ移動」の動作を用いて、複数のグループの表示順序を変えることができます。</li>
<li>グループ内へ一度に複数のノードをドラッグできるようになりました。</li>
<li><code>Alt</code> を押したまま、グループ内のノードをドラッグすると、そのグループから *除く* ことができます。</li>
</ul>`,
"fr": `<p>Nous avons modifié la façon dont vous interagissez avec les groupes dans l'éditeur.</p>
<ul>
<li>Ils ne gênent plus lorsque vous cliquez sur un noeud</li>
<li>Ils peuvent être réorganisés à l'aide des actions Avancer et Reculer</li>
<li>Plusieurs noeuds peuvent être glissés dans un groupe en une seule fois</li>
<li>Maintenir <code>Alt</code> lors du déplacement d'un noeud le *supprimera* de son groupe</li>
</ul>`
}
},
{
title: {
"en-US": "Change notification on tabs",
"ja": "タブ上の変更通知",
"fr": "Notification de changement sur les onglets"
},
image: 'images/tab-changes.png',
description: {
"en-US": `<p>When a tab contains undeployed changes it now shows the
same style of change icon used by nodes.</p>
<p>This will make it much easier to track down changes when you're
working across multiple flows.</p>`,
"ja": `<p>タブ内にデプロイされていない変更が存在する時は、ノードと同じスタイルで変更の印が表示されるようになりました。</p>
<p>これによって複数のフローを編集している時に、変更を見つけるのが簡単になりました。</p>`,
"fr": `<p>Lorsqu'un onglet contient des modifications non déployées, il affiche désormais le
même style d'icône de changement utilisé par les noeuds.</p>
<p>Cela facilitera grandement le suivi des modifications lorsque vous
travaillez sur plusieurs flux.</p>`
}
},
{
title: {
"en-US": "A bigger canvas to work with",
"ja": "より広くなった作業キャンバス",
"fr": "Un canevas plus grand pour travailler"
},
description: {
"en-US": `<p>The default canvas size has been increased so you can fit more
into one flow.</p>
<p>We still recommend using tools such as subflows and Link Nodes to help
keep things organised, but now you have more room to work in.</p>`,
"ja": `<p>標準のキャンバスが広くなったため、1つのフローに沢山のものを含めることができるようになりました。</p>
<p>引き続き、サブフローやリンクノードなどの方法を用いて整理することをお勧めしますが、作業できる場所が増えました。</p>`,
"fr": `<p>La taille par défaut du canevas a été augmentée pour que vous puissiez en mettre plus
sur un seul flux.</p>
<p>Nous recommandons toujours d'utiliser des outils tels que les sous-flux et les noeuds de lien pour vous aider
à garder les choses organisées, mais vous avez maintenant plus d'espace pour travailler.</p>`
}
},
{
title: {
"en-US": "Finding help",
"ja": "ヘルプを見つける",
"fr": "Trouver de l'aide"
},
image: 'images/node-help.png',
description: {
"en-US": `<p>All node edit dialogs now include a link to that node's help
in the footer.</p>
<p>Clicking it will open up the Help sidebar showing the help for that node.</p>`,
"ja": `<p>全てのノードの編集ダイアログの下に、ノードのヘルプへのリンクが追加されました。</p>
<p>これをクリックすると、ノードのヘルプサイドバーが表示されます。</p>`,
"fr": `<p>Toutes les boîtes de dialogue d'édition de noeud incluent désormais un lien vers l'aide de ce noeud
dans le pied de page.</p>
<p>Cliquer dessus ouvrira la barre latérale d'aide affichant l'aide pour ce noeud.</p>`
}
},
{
title: {
"en-US": "Improved Context Menu",
"ja": "コンテキストメニューの改善",
"fr": "Menu contextuel amélioré"
},
image: 'images/context-menu.png',
description: {
"en-US": `<p>The editor's context menu has been expanded to make lots more of
the built-in actions available.</p>
<p>Adding nodes, working with groups and plenty
of other useful tools are now just a click away.</p>
<p>The flow tab bar also has its own context menu to make working
with your flows much easier.</p>`,
"ja": `<p>より多くの組み込み動作を利用できるように、エディタのコンテキストメニューが拡張されました。</p>
<p>ノードの追加、グループの操作、その他の便利なツールをクリックするだけで実行できるようになりました。</p>
<p>フローのタブバーには、フローの操作をより簡単にする独自のコンテキストメニューもあります。</p>`,
"fr": `<p>Le menu contextuel de l'éditeur a été étendu pour faire beaucoup plus d'actions intégrées disponibles.</p>
<p>Ajouter des noeuds, travailler avec des groupes et beaucoup d'autres outils utiles sont désormais à portée de clic.</p>
<p>La barre d'onglets de flux possède également son propre menu contextuel pour faciliter l'utilisation de vos flux.</p>`
}
},
{
title: {
"en-US": "Hiding Flows",
"ja": "フローを非表示",
"fr": "Masquage de flux"
},
image: 'images/hiding-flows.png',
description: {
"en-US": `<p>Hiding flows is now done through the flow context menu.</p>
<p>The 'hide' button in previous releases has been removed from the tabs
as they were being clicked accidentally too often.</p>`,
"ja": `<p>フローを非表示にする機能は、フローのコンテキストメニューから実行するようになりました。</p>
<p>これまでのリリースでタブに存在していた「非表示」ボタンは、よく誤ってクリックされていたため、削除されました。</p>`,
"fr": `<p>Le masquage des flux s'effectue désormais via le menu contextuel du flux.</p>
<p>Le bouton "Masquer" des versions précédentes a été supprimé des onglets
car il était cliqué accidentellement trop souvent.</p>`
},
},
{
title: {
"en-US": "Locking Flows",
"ja": "フローを固定",
"fr": "Verrouillage de flux"
},
image: 'images/locking-flows.png',
description: {
"en-US": `<p>Flows can now be locked to prevent accidental changes being made.</p>
<p>When locked you cannot modify the nodes in any way.</p>
<p>The flow context menu provides the options to lock and unlock flows,
as well as in the Info sidebar explorer.</p>`,
"ja": `<p>誤ってフローに変更が加えられてしまうのを防ぐために、フローを固定できるようになりました。</p>
<p>固定されている時は、ノードを修正することはできません。</p>
<p>フローのコンテキストメニューと、情報サイドバーのエクスプローラには、フローの固定や解除をするためのオプションが用意されています。</p>`,
"fr": `<p>Les flux peuvent désormais être verrouillés pour éviter toute modification accidentelle.</p>
<p>Lorsqu'il est verrouillé, vous ne pouvez en aucun cas modifier les noeuds.</p>
<p>Le menu contextuel du flux fournit les options pour verrouiller et déverrouiller les flux,
ainsi que dans l'explorateur de la barre latérale d'informations.</p>`
},
},
{
title: {
"en-US": "Adding Images to node/flow descriptions",
"ja": "ノードやフローの説明へ画像を追加",
"fr": "Ajout d'images aux descriptions de noeud/flux"
},
// image: 'images/debug-path-tooltip.png',
description: {
"en-US": `<p>You can now add images to a node's or flows's description.</p>
<p>Simply drag the image into the text editor and it will get added inline.</p>
<p>When the description is shown in the Info sidebar, the image will be displayed.</p>`,
"ja": `<p>ノードまたはフローの説明に、画像を追加できるようになりました。</p>
<p>画像をテキストエディタにドラッグするだけで、行内に埋め込まれます。</p>
<p>情報サイドバーの説明を開くと、その画像が表示されます。</p>`,
"fr": `<p>Vous pouvez désormais ajouter des images à la description d'un noeud ou d'un flux.</p>
<p>Faites simplement glisser l'image dans l'éditeur de texte et elle sera ajoutée en ligne.</p>
<p>Lorsque la description s'affiche dans la barre latérale d'informations, l'image s'affiche.</p>`
},
},
{
title: {
"en-US": "Adding Mermaid Diagrams",
"ja": "Mermaid図を追加",
"fr": "Ajout de diagrammes Mermaid"
},
image: 'images/mermaid.png',
description: {
"en-US": `<p>You can also add <a href="https://github.com/mermaid-js/mermaid">Mermaid</a> diagrams directly into your node or flow descriptions.</p>
<p>This gives you much richer options for documenting your flows.</p>`,
"ja": `<p>ノードやフローの説明に、<a href="https://github.com/mermaid-js/mermaid">Mermaid</a>図を直接追加することもできます。</p>
<p>これによって、フローを説明する文書作成の選択肢がより多くなります。</p>`,
"fr": `<p>Vous pouvez également ajouter des diagrammes <a href="https://github.com/mermaid-js/mermaid">Mermaid</a> directement dans vos descriptions de noeud ou de flux.</p>
<p>Cela vous offre des options beaucoup plus riches pour documenter vos flux.</p>`
},
},
{
title: {
"en-US": "Managing Global Environment Variables",
"ja": "グローバル環境変数の管理",
"fr": "Gestion des variables d'environnement globales"
},
image: 'images/global-env-vars.png',
description: {
"en-US": `<p>You can set environment variables that apply to all nodes and flows in the new
'Global Environment Variables' section of User Settings.</p>`,
"ja": `<p>ユーザ設定に新しく追加された「大域環境変数」のセクションで、全てのノードとフローに適用される環境変数を登録できます。</p>`,
"fr": `<p>Vous pouvez définir des variables d'environnement qui s'appliquent à tous les noeuds et flux dans la nouvelle
section "Global Environment Variables" des paramètres utilisateur.</p>`
},
},
{
title: {
"en-US": "Node Updates",
"ja": "ノードの更新",
"fr": "Mises à jour des noeuds"
},
// image: "images/",
description: {
"en-US": `<p>The core nodes have received lots of minor fixes, documentation updates and
small enhancements. Check the full changelog in the Help sidebar for a full list.</p>`,
"ja": `<p>コアノードにマイナーな修正、ドキュメント更新、小規模な拡張が数多く追加されています。全ての一覧は、ヘルプサイドバーの全ての更新履歴を確認してください。</p>`,
"fr": `<p>Les noeuds principaux ont reçu de nombreux correctifs mineurs, mises à jour de la documentation et
petites améliorations. Consulter le journal des modifications complet dans la barre latérale d'aide.</p>`
}
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,12 +1,12 @@
export default {
version: "3.1.0",
version: "4.0.0-beta.1",
steps: [
{
titleIcon: "fa fa-map-o",
title: {
"en-US": "Welcome to Node-RED 3.1!",
"ja": "Node-RED 3.1へようこそ!",
"fr": "Bienvenue dans Node-RED 3.1!"
"en-US": "Welcome to Node-RED 4.0 Beta 1!",
"ja": "Node-RED 4.0 Beta 0へようこそ!",
"fr": "Bienvenue dans Node-RED 4.0 Beta 1!"
},
description: {
"en-US": "<p>Let's take a moment to discover the new features in this release.</p>",
@@ -16,202 +16,49 @@ export default {
},
{
title: {
"en-US": "New ways to work with groups",
"ja": "グループの新たな操作方法",
"fr": "De nouvelles façons de travailler avec les groupes"
"en-US": "Timestamp formatting options",
// "ja": ""
},
image: 'images/nr4-timestamp-formatting.png',
description: {
"en-US": `<p>We have changed how you interact with groups in the editor.</p>
"en-US": `<p>Nodes that let you set a timestamp now have options on what format that timestamp should be in.</p>
<p>We're keeping it simple to begin with by providing three options:<p>
<ul>
<li>They don't get in the way when clicking on a node</li>
<li>They can be reordered using the Moving Forwards and Move Backwards actions</li>
<li>Multiple nodes can be dragged into a group in one go</li>
<li>Holding <code>Alt</code> when dragging a node will *remove* it from its group</li>
<li>Milliseconds since epoch - this is existing behaviour of the timestamp option</li>
<li>ISO 8601 - a common format used by many systems</li>
<li>JavaScript Data Object</li>
</ul>`,
"ja": `<p>エディタ上のグループの操作が変更されました。</p>
<ul>
<li>グループ内のノードをクリックする時に、グループが邪魔をすることが無くなりました。</li>
<li>「前面へ移動」と「背面へ移動」の動作を用いて、複数のグループの表示順序を変えることができます。</li>
<li>グループ内へ一度に複数のノードをドラッグできるようになりました。</li>
<li><code>Alt</code> を押したまま、グループ内のノードをドラッグすると、そのグループから *除く* ことができます。</li>
</ul>`,
"fr": `<p>Nous avons modifié la façon dont vous interagissez avec les groupes dans l'éditeur.</p>
<ul>
<li>Ils ne gênent plus lorsque vous cliquez sur un noeud</li>
<li>Ils peuvent être réorganisés à l'aide des actions Avancer et Reculer</li>
<li>Plusieurs noeuds peuvent être glissés dans un groupe en une seule fois</li>
<li>Maintenir <code>Alt</code> lors du déplacement d'un noeud le *supprimera* de son groupe</li>
</ul>`
// "ja": ``
}
},
{
title: {
"en-US": "Change notification on tabs",
"ja": "タブ上の変更通知",
"fr": "Notification de changement sur les onglets"
"en-US": "Auto-complete of flow/global and env types",
// "ja": ""
},
image: 'images/tab-changes.png',
image: 'images/nr4-auto-complete.png',
description: {
"en-US": `<p>When a tab contains undeployed changes it now shows the
same style of change icon used by nodes.</p>
<p>This will make it much easier to track down changes when you're
working across multiple flows.</p>`,
"ja": `<p>タブ内にデプロイされていない変更が存在する時は、ノードと同じスタイルで変更の印が表示されるようになりました。</p>
<p>これによって複数のフローを編集している時に、変更を見つけるのが簡単になりました。</p>`,
"fr": `<p>Lorsqu'un onglet contient des modifications non déployées, il affiche désormais le
même style d'icône de changement utilisé par les noeuds.</p>
<p>Cela facilitera grandement le suivi des modifications lorsque vous
travaillez sur plusieurs flux.</p>`
"en-US": `<p>The <code>flow</code>/<code>global</code> context inputs and the <code>env</code> input
now all include auto-complete suggestions based on the live state of your flows.</p>
`,
// "ja": ``
}
},
{
title: {
"en-US": "A bigger canvas to work with",
"ja": "より広くなった作業キャンバス",
"fr": "Un canevas plus grand pour travailler"
"en-US": "Config node customisation in Subflows",
// "ja": ""
},
image: 'images/nr4-sf-config.png',
description: {
"en-US": `<p>The default canvas size has been increased so you can fit more
into one flow.</p>
<p>We still recommend using tools such as subflows and Link Nodes to help
keep things organised, but now you have more room to work in.</p>`,
"ja": `<p>標準のキャンバスが広くなったため、1つのフローに沢山のものを含めることができるようになりました。</p>
<p>引き続き、サブフローやリンクノードなどの方法を用いて整理することをお勧めしますが、作業できる場所が増えました。</p>`,
"fr": `<p>La taille par défaut du canevas a été augmentée pour que vous puissiez en mettre plus
sur un seul flux.</p>
<p>Nous recommandons toujours d'utiliser des outils tels que les sous-flux et les noeuds de lien pour vous aider
à garder les choses organisées, mais vous avez maintenant plus d'espace pour travailler.</p>`
"en-US": `<p>Subflows can now be customised to allow each instance to use a different
config node of a selected type.</p>
<p>For example, each instance of a subflow that connects to an MQTT Broker and does some post-processing
of the messages received can be pointed at a different broker.</p>
`,
// "ja": ``
}
},
{
title: {
"en-US": "Finding help",
"ja": "ヘルプを見つける",
"fr": "Trouver de l'aide"
},
image: 'images/node-help.png',
description: {
"en-US": `<p>All node edit dialogs now include a link to that node's help
in the footer.</p>
<p>Clicking it will open up the Help sidebar showing the help for that node.</p>`,
"ja": `<p>全てのノードの編集ダイアログの下に、ノードのヘルプへのリンクが追加されました。</p>
<p>これをクリックすると、ノードのヘルプサイドバーが表示されます。</p>`,
"fr": `<p>Toutes les boîtes de dialogue d'édition de noeud incluent désormais un lien vers l'aide de ce noeud
dans le pied de page.</p>
<p>Cliquer dessus ouvrira la barre latérale d'aide affichant l'aide pour ce noeud.</p>`
}
},
{
title: {
"en-US": "Improved Context Menu",
"ja": "コンテキストメニューの改善",
"fr": "Menu contextuel amélioré"
},
image: 'images/context-menu.png',
description: {
"en-US": `<p>The editor's context menu has been expanded to make lots more of
the built-in actions available.</p>
<p>Adding nodes, working with groups and plenty
of other useful tools are now just a click away.</p>
<p>The flow tab bar also has its own context menu to make working
with your flows much easier.</p>`,
"ja": `<p>より多くの組み込み動作を利用できるように、エディタのコンテキストメニューが拡張されました。</p>
<p>ノードの追加、グループの操作、その他の便利なツールをクリックするだけで実行できるようになりました。</p>
<p>フローのタブバーには、フローの操作をより簡単にする独自のコンテキストメニューもあります。</p>`,
"fr": `<p>Le menu contextuel de l'éditeur a été étendu pour faire beaucoup plus d'actions intégrées disponibles.</p>
<p>Ajouter des noeuds, travailler avec des groupes et beaucoup d'autres outils utiles sont désormais à portée de clic.</p>
<p>La barre d'onglets de flux possède également son propre menu contextuel pour faciliter l'utilisation de vos flux.</p>`
}
},
{
title: {
"en-US": "Hiding Flows",
"ja": "フローを非表示",
"fr": "Masquage de flux"
},
image: 'images/hiding-flows.png',
description: {
"en-US": `<p>Hiding flows is now done through the flow context menu.</p>
<p>The 'hide' button in previous releases has been removed from the tabs
as they were being clicked accidentally too often.</p>`,
"ja": `<p>フローを非表示にする機能は、フローのコンテキストメニューから実行するようになりました。</p>
<p>これまでのリリースでタブに存在していた「非表示」ボタンは、よく誤ってクリックされていたため、削除されました。</p>`,
"fr": `<p>Le masquage des flux s'effectue désormais via le menu contextuel du flux.</p>
<p>Le bouton "Masquer" des versions précédentes a été supprimé des onglets
car il était cliqué accidentellement trop souvent.</p>`
},
},
{
title: {
"en-US": "Locking Flows",
"ja": "フローを固定",
"fr": "Verrouillage de flux"
},
image: 'images/locking-flows.png',
description: {
"en-US": `<p>Flows can now be locked to prevent accidental changes being made.</p>
<p>When locked you cannot modify the nodes in any way.</p>
<p>The flow context menu provides the options to lock and unlock flows,
as well as in the Info sidebar explorer.</p>`,
"ja": `<p>誤ってフローに変更が加えられてしまうのを防ぐために、フローを固定できるようになりました。</p>
<p>固定されている時は、ノードを修正することはできません。</p>
<p>フローのコンテキストメニューと、情報サイドバーのエクスプローラには、フローの固定や解除をするためのオプションが用意されています。</p>`,
"fr": `<p>Les flux peuvent désormais être verrouillés pour éviter toute modification accidentelle.</p>
<p>Lorsqu'il est verrouillé, vous ne pouvez en aucun cas modifier les noeuds.</p>
<p>Le menu contextuel du flux fournit les options pour verrouiller et déverrouiller les flux,
ainsi que dans l'explorateur de la barre latérale d'informations.</p>`
},
},
{
title: {
"en-US": "Adding Images to node/flow descriptions",
"ja": "ノードやフローの説明へ画像を追加",
"fr": "Ajout d'images aux descriptions de noeud/flux"
},
// image: 'images/debug-path-tooltip.png',
description: {
"en-US": `<p>You can now add images to a node's or flows's description.</p>
<p>Simply drag the image into the text editor and it will get added inline.</p>
<p>When the description is shown in the Info sidebar, the image will be displayed.</p>`,
"ja": `<p>ノードまたはフローの説明に、画像を追加できるようになりました。</p>
<p>画像をテキストエディタにドラッグするだけで、行内に埋め込まれます。</p>
<p>情報サイドバーの説明を開くと、その画像が表示されます。</p>`,
"fr": `<p>Vous pouvez désormais ajouter des images à la description d'un noeud ou d'un flux.</p>
<p>Faites simplement glisser l'image dans l'éditeur de texte et elle sera ajoutée en ligne.</p>
<p>Lorsque la description s'affiche dans la barre latérale d'informations, l'image s'affiche.</p>`
},
},
{
title: {
"en-US": "Adding Mermaid Diagrams",
"ja": "Mermaid図を追加",
"fr": "Ajout de diagrammes Mermaid"
},
image: 'images/mermaid.png',
description: {
"en-US": `<p>You can also add <a href="https://github.com/mermaid-js/mermaid">Mermaid</a> diagrams directly into your node or flow descriptions.</p>
<p>This gives you much richer options for documenting your flows.</p>`,
"ja": `<p>ノードやフローの説明に、<a href="https://github.com/mermaid-js/mermaid">Mermaid</a>図を直接追加することもできます。</p>
<p>これによって、フローを説明する文書作成の選択肢がより多くなります。</p>`,
"fr": `<p>Vous pouvez également ajouter des diagrammes <a href="https://github.com/mermaid-js/mermaid">Mermaid</a> directement dans vos descriptions de noeud ou de flux.</p>
<p>Cela vous offre des options beaucoup plus riches pour documenter vos flux.</p>`
},
},
{
title: {
"en-US": "Managing Global Environment Variables",
"ja": "グローバル環境変数の管理",
"fr": "Gestion des variables d'environnement globales"
},
image: 'images/global-env-vars.png',
description: {
"en-US": `<p>You can set environment variables that apply to all nodes and flows in the new
'Global Environment Variables' section of User Settings.</p>`,
"ja": `<p>ユーザ設定に新しく追加された「大域環境変数」のセクションで、全てのノードとフローに適用される環境変数を登録できます。</p>`,
"fr": `<p>Vous pouvez définir des variables d'environnement qui s'appliquent à tous les noeuds et flux dans la nouvelle
section "Global Environment Variables" des paramètres utilisateur.</p>`
},
},
{
title: {
"en-US": "Node Updates",
@@ -221,10 +68,13 @@ export default {
// image: "images/",
description: {
"en-US": `<p>The core nodes have received lots of minor fixes, documentation updates and
small enhancements. Check the full changelog in the Help sidebar for a full list.</p>`,
"ja": `<p>コアノードにマイナーな修正、ドキュメント更新、小規模な拡張が数多く追加されています。全ての一覧は、ヘルプサイドバーの全ての更新履歴を確認してください。</p>`,
"fr": `<p>Les noeuds principaux ont reçu de nombreux correctifs mineurs, mises à jour de la documentation et
petites améliorations. Consulter le journal des modifications complet dans la barre latérale d'aide.</p>`
small enhancements. Check the full changelog in the Help sidebar for a full list.</p>
<ul>
<li>A fully RFC4180 compliant CSV mode</li>
<li>Customisable headers on the WebSocket node</li>
<li>Split node now can operate on any message property</li>
<li>and lots more...</li>
</ul>`
}
}
]

View File

@@ -227,34 +227,42 @@
name: {value:""},
props:{value:[{p:"payload"},{p:"topic",vt:"str"}], validate:function(v, opt) {
if (!v || v.length === 0) { return true }
const errors = []
for (var i=0;i<v.length;i++) {
if (/^\${[^}]+}$/.test(v[i].v)) {
// Allow ${ENV_VAR} value
continue
}
if (/msg|flow|global/.test(v[i].vt)) {
if (!RED.utils.validatePropertyExpression(v[i].v)) {
return RED._("node-red:inject.errors.invalid-prop", { prop: 'msg.'+v[i].p, error: v[i].v });
errors.push(RED._("node-red:inject.errors.invalid-prop", { prop: 'msg.'+v[i].p, error: v[i].v }))
}
} else if (v[i].vt === "jsonata") {
try{ jsonata(v[i].v); }
catch(e){
return RED._("node-red:inject.errors.invalid-jsonata", { prop: 'msg.'+v[i].p, error: e.message });
errors.push(RED._("node-red:inject.errors.invalid-jsonata", { prop: 'msg.'+v[i].p, error: e.message }))
}
} else if (v[i].vt === "json") {
try{ JSON.parse(v[i].v); }
catch(e){
return RED._("node-red:inject.errors.invalid-json", { prop: 'msg.'+v[i].p, error: e.message });
errors.push(RED._("node-red:inject.errors.invalid-json", { prop: 'msg.'+v[i].p, error: e.message }))
}
} else if (v[i].vt === "num"){
if (!/^[+-]?[0-9]*\.?[0-9]*([eE][-+]?[0-9]+)?$/.test(v[i].v)) {
return RED._("node-red:inject.errors.invalid-prop", { prop: 'msg.'+v[i].p, error: v[i].v });
errors.push(RED._("node-red:inject.errors.invalid-prop", { prop: 'msg.'+v[i].p, error: v[i].v }))
}
}
}
if (errors.length > 0) {
return errors
}
return true;
}
},
repeat: {
value:"", validate: function(v, opt) {
if ((v === "") ||
(RED.validators.number(v) &&
(RED.validators.number()(v) &&
(v >= 0) && (v <= 2147483))) {
return true;
}
@@ -263,7 +271,7 @@
},
crontab: {value:""},
once: {value:false},
onceDelay: {value:0.1},
onceDelay: {value:0.1, validate: RED.validators.number(true)},
topic: {value:""},
payload: {value:"", validate: RED.validators.typedInput("payloadType", false) },
payloadType: {value:"date"},

View File

@@ -512,7 +512,8 @@ RED.debug = (function() {
hideKey: false,
path: path,
sourceId: sourceNode&&sourceNode.id,
rootPath: path
rootPath: path,
nodeSelector: config.messageSourceClick,
});
// Do this in a separate step so the element functions aren't stripped
debugMessage.appendTo(el);

View File

@@ -40,6 +40,99 @@
(function() {
const headerTypes = [
/*
{ value: "Accept", label: "Accept", hasValue: false },
{ value: "Accept-Encoding", label: "Accept-Encoding", hasValue: false },
{ value: "Accept-Language", label: "Accept-Language", hasValue: false },
*/
{ value: "Authorization", label: "Authorization", hasValue: false },
/*
{ value: "Content-Type", label: "Content-Type", hasValue: false },
{ value: "Cache-Control", label: "Cache-Control", hasValue: false },
*/
{ value: "User-Agent", label: "User-Agent", hasValue: false },
/*
{ value: "Location", label: "Location", hasValue: false },
*/
{ value: "other", label: RED._("node-red:httpin.label.other"),
hasValue: true, icon: "red/images/typedInput/az.svg" },
]
const headerOptions = {};
const defaultOptions = [
{ value: "other", label: RED._("node-red:httpin.label.other"),
hasValue: true, icon: "red/images/typedInput/az.svg" },
"env",
];
/*
headerOptions["accept"] = [
{ value: "text/plain", label: "text/plain", hasValue: false },
{ value: "text/html", label: "text/html", hasValue: false },
{ value: "application/json", label: "application/json", hasValue: false },
{ value: "application/xml", label: "application/xml", hasValue: false },
...defaultOptions,
];
headerOptions["accept-encoding"] = [
{ value: "gzip", label: "gzip", hasValue: false },
{ value: "deflate", label: "deflate", hasValue: false },
{ value: "compress", label: "compress", hasValue: false },
{ value: "br", label: "br", hasValue: false },
{ value: "gzip, deflate", label: "gzip, deflate", hasValue: false },
{ value: "gzip, deflate, br", label: "gzip, deflate, br", hasValue: false },
...defaultOptions,
];
headerOptions["accept-language"] = [
{ value: "*", label: "*", hasValue: false },
{ value: "en-GB, en-US, en;q=0.9", label: "en-GB, en-US, en;q=0.9", hasValue: false },
{ value: "de-AT, de-DE;q=0.9, en;q=0.5", label: "de-AT, de-DE;q=0.9, en;q=0.5", hasValue: false },
{ value: "es-mx,es,en;q=0.5", label: "es-mx,es,en;q=0.5", hasValue: false },
{ value: "fr-CH, fr;q=0.9, en;q=0.8", label: "fr-CH, fr;q=0.9, en;q=0.8", hasValue: false },
{ value: "zh-CN, zh-TW; q = 0.9, zh-HK; q = 0.8, zh; q = 0.7, en; q = 0.6", label: "zh-CN, zh-TW; q = 0.9, zh-HK; q = 0.8, zh; q = 0.7, en; q = 0.6", hasValue: false },
{ value: "ja-JP, jp", label: "ja-JP, jp", hasValue: false },
...defaultOptions,
];
headerOptions["content-type"] = [
{ value: "text/css", label: "text/css", hasValue: false },
{ value: "text/plain", label: "text/plain", hasValue: false },
{ value: "text/html", label: "text/html", hasValue: false },
{ value: "application/json", label: "application/json", hasValue: false },
{ value: "application/octet-stream", label: "application/octet-stream", hasValue: false },
{ value: "application/pdf", label: "application/pdf", hasValue: false },
{ value: "application/xml", label: "application/xml", hasValue: false },
{ value: "application/zip", label: "application/zip", hasValue: false },
{ value: "multipart/form-data", label: "multipart/form-data", hasValue: false },
{ value: "audio/aac", label: "audio/aac", hasValue: false },
{ value: "audio/ac3", label: "audio/ac3", hasValue: false },
{ value: "audio/basic", label: "audio/basic", hasValue: false },
{ value: "audio/mp4", label: "audio/mp4", hasValue: false },
{ value: "audio/ogg", label: "audio/ogg", hasValue: false },
{ value: "image/bmp", label: "image/bmp", hasValue: false },
{ value: "image/gif", label: "image/gif", hasValue: false },
{ value: "image/jpeg", label: "image/jpeg", hasValue: false },
{ value: "image/png", label: "image/png", hasValue: false },
{ value: "image/tiff", label: "image/tiff", hasValue: false },
...defaultOptions,
];
headerOptions["cache-control"] = [
{ value: "max-age=0", label: "max-age=0", hasValue: false },
{ value: "max-age=86400", label: "max-age=86400", hasValue: false },
{ value: "no-cache", label: "no-cache", hasValue: false },
...defaultOptions,
];
*/
headerOptions["user-agent"] = [
{ value: "Mozilla/5.0", label: "Mozilla/5.0", hasValue: false },
...defaultOptions,
];
function getHeaderOptions(headerName) {
const lc = (headerName || "").toLowerCase();
let opts = headerOptions[lc];
return opts || defaultOptions;
}
function ws_oneditprepare() {
$("#websocket-client-row").hide();
$("#node-input-mode").on("change", function() {
@@ -192,7 +285,8 @@
value: "",
label:RED._("node-red:websocket.sendheartbeat"),
validate: RED.validators.number(/*blank allowed*/true) },
subprotocol: {value:"",required: false}
subprotocol: {value:"",required: false},
headers: { value: [] }
},
inputs:0,
outputs:0,
@@ -200,6 +294,9 @@
return this.path;
},
oneditprepare: function() {
const node = this;
$("#node-config-input-path").on("change keyup paste",function() {
$(".node-config-row-tls").toggle(/^wss:/i.test($(this).val()))
});
@@ -214,14 +311,114 @@
if (!heartbeatActive) {
$("#node-config-input-hb").val("");
}
const hasMatch = function (arr, value) {
return arr.some(function (ht) {
return ht.value === value
});
}
const headerList = $("#node-input-headers-container").css('min-height', '150px').css('min-width', '450px').editableList({
addItem: function (container, i, header) {
const row = $('<div/>').css({
overflow: 'hidden',
whiteSpace: 'nowrap',
display: 'flex'
}).appendTo(container);
const propertNameCell = $('<div/>').css({ 'flex-grow': 1 }).appendTo(row);
const propertyName = $('<input/>', { class: "node-input-header-name", type: "text", style: "width: 100%" })
.appendTo(propertNameCell)
.typedInput({ types: headerTypes });
const propertyValueCell = $('<div/>').css({ 'flex-grow': 1, 'margin-left': '10px' }).appendTo(row);
const propertyValue = $('<input/>', { class: "node-input-header-value", type: "text", style: "width: 100%" })
.appendTo(propertyValueCell)
.typedInput({
types: getHeaderOptions(header.keyType)
});
const setup = function(_header) {
const headerTypeIsAPreset = function(h) {return hasMatch(headerTypes, h) };
const headerValueIsAPreset = function(h, v) {return hasMatch(getHeaderOptions(h), v) };
const {keyType, keyValue, valueType, valueValue} = header;
if(keyType == "other") {
propertyName.typedInput('type', keyType);
propertyName.typedInput('value', keyValue);
} else if (headerTypeIsAPreset(keyType)) {
propertyName.typedInput('type', keyType);
} else {
propertyName.typedInput('type', "other");
propertyName.typedInput('value', keyValue);
}
if(valueType == "other" || valueType == "env" ) {
propertyValue.typedInput('type', valueType);
propertyValue.typedInput('value', valueValue);
} else if (headerValueIsAPreset(propertyName.typedInput('type'), valueType)) {
propertyValue.typedInput('type', valueType);
} else {
propertyValue.typedInput('type', "other");
propertyValue.typedInput('value', valueValue);
}
}
setup(header);
propertyName.on('change', function (event) {
propertyValue.typedInput('types', getHeaderOptions(propertyName.typedInput('type')));
});
},
sortable: true,
removable: true
});
if (node.headers) {
for (let index = 0; index < node.headers.length; index++) {
const element = node.headers[index];
headerList.editableList('addItem', node.headers[index]);
}
}
},
oneditsave: function() {
const node = this;
if (!/^wss:/i.test($("#node-config-input-path").val())) {
$("#node-config-input-tls").val("_ADD_");
}
if (!$("#node-config-input-hb-cb").prop("checked")) {
$("#node-config-input-hb").val("0");
}
const headers = $("#node-input-headers-container").editableList('items');
node.headers = [];
headers.each(function(i) {
const header = $(this);
const keyType = header.find(".node-input-header-name").typedInput('type');
const keyValue = header.find(".node-input-header-name").typedInput('value');
const valueType = header.find(".node-input-header-value").typedInput('type');
const valueValue = header.find(".node-input-header-value").typedInput('value');
node.headers.push({
keyType, keyValue, valueType, valueValue
})
});
},
oneditresize: function(size) {
const dlg = $("#dialog-form");
const expandRow = dlg.find('.node-input-headers-container-row');
let height = dlg.height() - 5;
if(expandRow && expandRow.length){
const siblingRows = dlg.find('> .form-row:not(.node-input-headers-container-row)');
for (let i = 0; i < siblingRows.size(); i++) {
const cr = $(siblingRows[i]);
if(cr.is(":visible"))
height -= cr.outerHeight(true);
}
$("#node-input-headers-container").editableList('height',height);
}
}
});
@@ -299,8 +496,15 @@
<span data-i18n="inject.seconds"></span>
</span>
</div>
<div class="form-row" style="margin-bottom:0;">
<label><i class="fa fa-list"></i> <span data-i18n="httpin.label.headers"></span></label>
</div>
<div class="form-row node-input-headers-container-row">
<ol id="node-input-headers-container"></ol>
</div>
<div class="form-tips">
<p><span data-i18n="[html]websocket.tip.url1"></span></p>
<span data-i18n="[html]websocket.tip.url2"></span>
<p><span data-i18n="[html]websocket.tip.url2"></span></p>
<span data-i18n="[html]websocket.tip.headers"></span>
</div>
</script>

View File

@@ -58,6 +58,7 @@ module.exports = function(RED) {
node.isServer = !/^ws{1,2}:\/\//i.test(node.path);
node.closing = false;
node.tls = n.tls;
node.upgradeHeaders = n.headers
if (n.hb) {
var heartbeat = parseInt(n.hb);
@@ -96,6 +97,42 @@ module.exports = function(RED) {
tlsNode.addTLSOptions(options);
}
}
// We need to check if undefined, to guard against previous installs, that will not have had this property set (applies to 3.1.x setups)
// Else this will be breaking potentially
if(node.upgradeHeaders !== undefined && node.upgradeHeaders.length > 0){
options.headers = {};
for(let i = 0;i<node.upgradeHeaders.length;i++){
const header = node.upgradeHeaders[i];
const keyType = header.keyType;
const keyValue = header.keyValue;
const valueType = header.valueType;
const valueValue = header.valueValue;
const headerName = keyType === 'other' ? keyValue : keyType;
let headerValue;
switch(valueType){
case 'other':
headerValue = valueValue;
break;
case 'env':
headerValue = RED.util.evaluateNodeProperty(valueValue,valueType,node);
break;
default:
headerValue = valueType;
break;
}
if(headerName && headerValue){
options.headers[headerName] = headerValue
}
}
}
var socket = new ws(node.path,node.subprotocol,options);
socket.setMaxListeners(0);
node.server = socket; // keep for closing

View File

@@ -582,6 +582,7 @@ module.exports = function(RED) {
const cc = Object.keys(clients).length;
node.status({fill:"green",shape:cc===0?"ring":"dot",text:RED._("tcpin.status.connections",{count:cc})});
if ((host === undefined || port === undefined) && !msg.hasOwnProperty("payload")) { return; }
if (!msg.hasOwnProperty("payload")) { return; }
}
// Store client information independently

View File

@@ -14,6 +14,7 @@
<option value="html" data-i18n="html.output.html"></option>
<option value="text" data-i18n="html.output.text"></option>
<option value="attr" data-i18n="html.output.attr"></option>
<option value="compl" data-i18n="html.output.compl"></option>
<!-- <option value="val">return the value from a form element</option> -->
</select>
</div>
@@ -28,6 +29,10 @@
<label for="node-input-outproperty">&nbsp;</label>
<span data-i18n="html.label.in" style="padding-left:8px; padding-right:2px; vertical-align:-1px;"></span> <input type="text" id="node-input-outproperty" style="width:64%">
</div>
<div id='html-prefix-row' class="form-row" style="display: none;">
<label for="node-input-chr" style="width: 230px;"><i class="fa fa-tag"></i> <span data-i18n="html.label.prefix"></span></label>
<input type="text" id="node-input-chr" style="text-align:center; width: 40px;" placeholder="_">
</div>
<br/>
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> <span data-i18n="common.label.name"></span></label>
@@ -45,7 +50,8 @@
outproperty: {value:"payload", validate: RED.validators.typedInput({ type: 'msg', allowUndefined: true }) },
tag: {value:""},
ret: {value:"html"},
as: {value:"single"}
as: {value:"single"},
chr: { value: "_" }
},
inputs:1,
outputs:1,
@@ -59,6 +65,13 @@
oneditprepare: function() {
$("#node-input-property").typedInput({default:'msg',types:['msg']});
$("#node-input-outproperty").typedInput({default:'msg',types:['msg']});
$('#node-input-ret').on( 'change', () => {
if ( $('#node-input-ret').val() == "compl" ) {
$('#html-prefix-row').show()
} else {
$('#html-prefix-row').hide()
}
});
}
});
</script>

View File

@@ -25,6 +25,7 @@ module.exports = function(RED) {
this.tag = n.tag;
this.ret = n.ret || "html";
this.as = n.as || "single";
this.chr = n.chr || "_";
var node = this;
this.on("input", function(msg,send,done) {
var value = RED.util.getMessageProperty(msg,node.property);
@@ -47,6 +48,11 @@ module.exports = function(RED) {
if (node.ret === "attr") {
pay2 = Object.assign({},this.attribs);
}
if (node.ret === "compl") {
var bse = {}
bse[node.chr] = $(this).html().trim()
pay2 = Object.assign(bse, this.attribs);
}
//if (node.ret === "val") { pay2 = $(this).val(); }
/* istanbul ignore else */
if (pay2) {
@@ -69,6 +75,11 @@ module.exports = function(RED) {
var attribs = Object.assign({},this.attribs);
pay.push( attribs );
}
if (node.ret === "compl") {
var bse = {}
bse[node.chr] = $(this).html().trim()
pay.push( Object.assign(bse, this.attribs) )
}
//if (node.ret === "val") { pay.push( $(this).val() ); }
}
index++;

View File

@@ -516,7 +516,8 @@
"path1": "Standardmäßig enthält <code>payload</code> die Daten, die über einen WebSocket gesendet oder von einem WebSocket empfangen werden. Der Empfänger (Listener) kann so konfiguriert werden, dass er das gesamte Nachrichtenobjekt als eine JSON-formatierte Zeichenfolge (string) sendet oder empfängt.",
"path2": "Dieser Pfad ist relativ zu <code>__path__</code>.",
"url1": "URL sollte ws:&#47;&#47; oder wss:&#47;&#47; Schema verwenden und auf einen vorhandenen WebSocket-Listener verweisen.",
"url2": "Standardmäßig enthält <code>payload</code> die Daten, die über einen WebSocket gesendet oder von einem WebSocket empfangen werden. Der Client kann so konfiguriert werden, dass er das gesamte Nachrichtenobjekt als eine JSON-formatierte Zeichenfolge (string) sendet oder empfängt."
"url2": "Standardmäßig enthält <code>payload</code> die Daten, die über einen WebSocket gesendet oder von einem WebSocket empfangen werden. Der Client kann so konfiguriert werden, dass er das gesamte Nachrichtenobjekt als eine JSON-formatierte Zeichenfolge (string) sendet oder empfängt.",
"headers": "Header werden nur während des Protokollaktualisierungsmechanismus übermittelt, von HTTP auf das WS/WSS-Protokoll."
},
"status": {
"connected": "Verbunden __count__",

View File

@@ -586,7 +586,8 @@
"path1": "By default, <code>payload</code> will contain the data to be sent over, or received from a websocket. The listener can be configured to send or receive the entire message object as a JSON formatted string.",
"path2": "This path will be relative to <code>__path__</code>.",
"url1": "URL should use ws:&#47;&#47; or wss:&#47;&#47; scheme and point to an existing websocket listener.",
"url2": "By default, <code>payload</code> will contain the data to be sent over, or received from a websocket. The client can be configured to send or receive the entire message object as a JSON formatted string."
"url2": "By default, <code>payload</code> will contain the data to be sent over, or received from a websocket. The client can be configured to send or receive the entire message object as a JSON formatted string.",
"headers": "Headers are only submitted during the Protocol upgrade mechanism, from HTTP to the WS/WSS Protocol."
},
"status": {
"connected": "connected __count__",
@@ -894,12 +895,14 @@
"label": {
"select": "Selector",
"output": "Output",
"in": "in"
"in": "in",
"prefix": "Property name for HTML content"
},
"output": {
"html": "the html content of the elements",
"text": "only the text content of the elements",
"attr": "an object of any attributes of the elements"
"attr": "an object of any attributes of the elements",
"compl": "an object of any attributes of the elements and html contents"
},
"format": {
"single": "as a single message containing an array",

View File

@@ -583,7 +583,8 @@
"path1": "Par défaut, <code>payload</code> contiendra les données à envoyer ou à recevoir d'un websocket. L'écouteur peut être configuré pour envoyer ou recevoir l'intégralité de l'objet message sous forme de chaîne au format JSON.",
"path2": "Ce chemin sera relatif à <code>__path__</code>.",
"url1": "L'URL doit utiliser le schéma ws:&#47;&#47; ou wss:&#47;&#47; et pointer vers un écouteur websocket existant.",
"url2": "Par défaut, <code>payload</code> contiendra les données à envoyer ou à recevoir d'un websocket. Le client peut être configuré pour envoyer ou recevoir l'intégralité de l'objet message sous forme de chaîne au format JSON."
"url2": "Par défaut, <code>payload</code> contiendra les données à envoyer ou à recevoir d'un websocket. Le client peut être configuré pour envoyer ou recevoir l'intégralité de l'objet message sous forme de chaîne au format JSON.",
"headers": "Les en-têtes ne sont soumis que lors du mécanisme de mise à niveau du protocole, de HTTP vers le protocole WS/WSS."
},
"status": {
"connected": "__count__ connecté",

View File

@@ -586,7 +586,8 @@
"path1": "標準では <code>payload</code> がwebsocketから送信、受信されるデータを持ちます。クライアントはJSON形式の文字列としてメッセージ全体を送信、受信するよう設定できます。",
"path2": "このパスは <code>__path__</code> の相対パスになります。",
"url1": "URLには ws:&#47;&#47; または wss:&#47;&#47; スキーマを使用して、存在するwebsocketリスナを設定してください。",
"url2": "標準では <code>payload</code> がwebsocketから送信、受信されるデータを持ちます。クライアントはJSON形式の文字列としてメッセージ全体を送信、受信するよう設定できます。"
"url2": "標準では <code>payload</code> がwebsocketから送信、受信されるデータを持ちます。クライアントはJSON形式の文字列としてメッセージ全体を送信、受信するよう設定できます。",
"headers": "ヘッダーは、HTTP から WS/WSS プロトコルへのプロトコル アップグレード メカニズム中にのみ送信されます。"
},
"status": {
"connected": "接続数 __count__",

View File

@@ -451,7 +451,8 @@
"path1": "표준으로는 <code>payload</code> 가 websocket에서 송신, 수신된 데이터를 기다립니다. 클라이언트는 JSON형식의 문자열로 메세지전체를 송신, 수신하도록 설정할 수 있습니다.",
"path2": "This path will be relative to <code>__path__</code>.",
"url1": "URL에는 ws:&#47;&#47; 또는 wss:&#47;&#47; 스키마를 사용하여, 존재하는 websocket리스너를 설정해 주세요.",
"url2": "표준으로는 <code>payload</code> 가 websocket에서 송신,수신될 데이터를 기다립니다.클라이언트는 JSON형식의 문자열로 메세지전체를 송신, 수신하도록 설정할 수 있습니다."
"url2": "표준으로는 <code>payload</code> 가 websocket에서 송신,수신될 데이터를 기다립니다.클라이언트는 JSON형식의 문자열로 메세지전체를 송신, 수신하도록 설정할 수 있습니다.",
"headers": "헤더는 HTTP에서 WS/WSS 프로토콜로 프로토콜 업그레이드 메커니즘 중에만 제출됩니다."
},
"status": {
"connected": "접속 수 __count__",

View File

@@ -573,7 +573,8 @@
"path1": "Por padrão, a <code>carga útil</code> conterá os dados a serem enviados ou recebidos de um websocket. O ouvinte pode ser configurado para enviar ou receber todo o objeto de mensagem como uma cadeia de caracteres formatada em JSON.",
"path2": "Este caminho será relativo a <code>__path__</code>.",
"url1": "A URL deve usar o esquema ws:&#47;&#47; ou wss:&#47;&#47; e apontar para um ouvinte de websocket existente.",
"url2": "Por padrão, <code>carga útil</code> conterá os dados a serem enviados ou recebidos de um websocket. O cliente pode ser configurado para enviar ou receber todo o objeto de mensagem como uma cadeia de caracteres formatada em JSON."
"url2": "Por padrão, <code>carga útil</code> conterá os dados a serem enviados ou recebidos de um websocket. O cliente pode ser configurado para enviar ou receber todo o objeto de mensagem como uma cadeia de caracteres formatada em JSON.",
"headers": "Os cabeçalhos são enviados apenas durante o mecanismo de atualização do protocolo, do HTTP para o protocolo WS/WSS."
},
"status": {
"connected": "conectado __count__",

View File

@@ -475,7 +475,8 @@
"path1": "По умолчанию <code>payload</code> будет содержать данные, которые будут отправлены или получены из websocket. Слушатель может быть настроен на отправку или получение всего объекта сообщения в виде строки в формате JSON.",
"path2": "Путь будет относительно <code>__path__</code>.",
"url1": "URL должен использовать схему ws:&#47;&#47; или wss:&#47;&#47; и указывать на существующего слушателя websocket.",
"url2": "По умолчанию <code>payload</code> будет содержать данные, которые будут отправлены или получены из websocket. Клиент может быть настроен на отправку или получение всего объекта сообщения в виде строки в формате JSON."
"url2": "По умолчанию <code>payload</code> будет содержать данные, которые будут отправлены или получены из websocket. Клиент может быть настроен на отправку или получение всего объекта сообщения в виде строки в формате JSON.",
"headers": "Заголовки передаются только во время механизма обновления протокола с HTTP на протокол WS/WSS."
},
"status": {
"connected": "подключен __count__",

View File

@@ -23,7 +23,7 @@
<dt class="optional">template <span class="property-type">string</span></dt>
<dd><code>msg.payload</code>msg</dd>
</dl>
<h3>Outputs</h3>
<h3>输出</h3>
<dl class="message-properties">
<dt>msg <span class="property-type">object</span></dt>
<dd>由来自传入msg的属性来填充已配置的模板后输出的带有属性的msg</dd>
@@ -32,7 +32,7 @@
<p>默认情况下使用<i><a href="http://mustache.github.io/mustache.5.html" target="_blank">mustache</a></i>格式如有需要也可以切换其他格式</p>
<p>例如:
<pre>Hello {{payload.name}}. Today is {{date}}</pre>
<p>receives a message containing:
<p>接收一条消息其中包含:
<pre>{
date: "Monday",
payload: {

View File

@@ -576,7 +576,8 @@
"path1": "默认情况下,<code>payload</code>将包含要发送或从Websocket接收的数据。侦听器可以配置为以JSON格式的字符串发送或接收整个消息对象.",
"path2": "这条路径将相对于 <code>__path__</code>.",
"url1": "URL 应该使用ws:&#47;&#47;或者wss:&#47;&#47;方案并指向现有的websocket侦听器.",
"url2": "默认情况下,<code>payload</code> 将包含要发送或从Websocket接收的数据。可以将客户端配置为以JSON格式的字符串发送或接收整个消息对象."
"url2": "默认情况下,<code>payload</code> 将包含要发送或从Websocket接收的数据。可以将客户端配置为以JSON格式的字符串发送或接收整个消息对象.",
"headers": "标头仅在协议升级机制期间提交,从 HTTP 到 WS/WSS 协议."
},
"status": {
"connected": "已连接数量 __count__",

View File

@@ -471,7 +471,8 @@
"path1": "預設情況下,<code>payload</code>將包含要發送或從Websocket接收的資料。偵聽器可以配置為以JSON格式的字串發送或接收整個消息物件.",
"path2": "這條路徑將相對於 <code>__path__</code>.",
"url1": "URL 應該使用ws:&#47;&#47;或者wss:&#47;&#47;方案並指向現有的websocket監聽器.",
"url2": "預設情況下,<code>payload</code> 將包含要發送或從Websocket接收的資料。可以將使用者端配置為以JSON格式的字串發送或接收整個消息物件."
"url2": "預設情況下,<code>payload</code> 將包含要發送或從Websocket接收的資料。可以將使用者端配置為以JSON格式的字串發送或接收整個消息物件.",
"headers": "標頭僅在協定升級機制期間提交,從 HTTP 到 WS/WSS 協定."
},
"status": {
"connected": "連接數 __count__",

View File

@@ -1,6 +1,6 @@
{
"name": "@node-red/nodes",
"version": "4.0.0-dev",
"version": "4.0.0-beta.1",
"license": "Apache-2.0",
"repository": {
"type": "git",

View File

@@ -36,7 +36,7 @@ async function getFlowsFromPath(path) {
promises.push(getFlowsFromPath(fullPath));
} else if (/\.json$/.test(file)){
validFiles.push(file);
promises.push(Promise.resolve(file.split(".")[0]))
promises.push(Promise.resolve(file.replace(/\.json$/, '')))
}
})
}

View File

@@ -1,6 +1,6 @@
{
"name": "@node-red/registry",
"version": "4.0.0-dev",
"version": "4.0.0-beta.1",
"license": "Apache-2.0",
"main": "./lib/index.js",
"repository": {
@@ -16,7 +16,7 @@
}
],
"dependencies": {
"@node-red/util": "4.0.0-dev",
"@node-red/util": "4.0.0-beta.1",
"clone": "2.1.2",
"fs-extra": "11.1.1",
"semver": "7.5.4",

View File

@@ -36,7 +36,7 @@ var connections = [];
const events = require("@node-red/util").events;
function handleCommsEvent(event) {
publish(event.topic,event.data,event.retain);
publish(event.topic,event.data,event.retain,event.session,event.excludeSession);
}
function handleStatusEvent(event) {
if (!event.status) {
@@ -74,13 +74,17 @@ function handleEventLog(event) {
publish("event-log/"+event.id,event.payload||{});
}
function publish(topic,data,retain) {
function publish(topic, data, retain, session, excludeSession) {
if (retain) {
retained[topic] = data;
} else {
delete retained[topic];
}
connections.forEach(connection => connection.send(topic,data))
connections.forEach(connection => {
if ((!session || connection.session === session) && (!excludeSession || connection.session !== excludeSession)) {
connection.send(topic,data)
}
})
}
@@ -109,6 +113,10 @@ var api = module.exports = {
*/
addConnection: async function(opts) {
connections.push(opts.client);
events.emit('comms:connection-added', {
session: opts.client.session,
user: opts.client.user
})
},
/**
@@ -126,6 +134,9 @@ var api = module.exports = {
break;
}
}
events.emit('comms:connection-removed', {
session: opts.client.session
})
},
/**
@@ -157,5 +168,23 @@ var api = module.exports = {
* @return {Promise<Object>} - resolves when complete
* @memberof @node-red/runtime_comms
*/
unsubscribe: async function(opts) {}
unsubscribe: async function(opts) {},
/**
* @param {Object} opts
* @param {User} opts.user - the user calling the api
* @param {CommsConnection} opts.client - the client connection
* @param {String} opts.topic - the message topic
* @param {String} opts.data - the message data
* @return {Promise<Object>} - resolves when complete
*/
receive: async function (opts) {
if (opts.topic) {
events.emit('comms:message:' + opts.topic, {
session: opts.client.session,
user: opts.user,
data: opts.data
})
}
}
};

View File

@@ -485,7 +485,7 @@ class Flow {
}
if (!key.startsWith("$parent.")) {
if (this._env.hasOwnProperty(key)) {
return (Object.hasOwn(this._env[key], 'value') && this._env[key].__clone__) ? clone(this._env[key].value) : this._env[key]
return (this._env[key] && Object.hasOwn(this._env[key], 'value') && this._env[key].__clone__) ? clone(this._env[key].value) : this._env[key]
}
} else {
key = key.substring(8);

View File

@@ -41,7 +41,7 @@ class Group {
}
if (!key.startsWith("$parent.")) {
if (this._env.hasOwnProperty(key)) {
return (Object.hasOwn(this._env[key], 'value') && this._env[key].__clone__) ? clone(this._env[key].value) : this._env[key]
return (this._env[key] && Object.hasOwn(this._env[key], 'value') && this._env[key].__clone__) ? clone(this._env[key].value) : this._env[key]
}
} else {
key = key.substring(8);

View File

@@ -376,7 +376,7 @@ class Subflow extends Flow {
}
if (!key.startsWith("$parent.")) {
if (this._env.hasOwnProperty(key)) {
return (Object.hasOwn(this._env[key], 'value') && this._env[key].__clone__) ? clone(this._env[key].value) : this._env[key]
return (this._env[key] && Object.hasOwn(this._env[key], 'value') && this._env[key].__clone__) ? clone(this._env[key].value) : this._env[key]
}
} else {
key = key.substring(8);

View File

@@ -22,6 +22,7 @@ var storage = require("./storage");
var library = require("./library");
var plugins = require("./plugins");
var settings = require("./settings");
const multiplayer = require("./multiplayer");
var express = require("express");
var path = require('path');
@@ -135,6 +136,7 @@ function start() {
.then(function() { return storage.init(runtime)})
.then(function() { return settings.load(storage)})
.then(function() { return library.init(runtime)})
.then(function() { return multiplayer.init(runtime)})
.then(function() {
if (settings.available()) {
if (settings.get('instanceId') === undefined) {

View File

@@ -0,0 +1,119 @@
let runtime
/**
* Active sessions, mapped by multiplayer session ids
*/
const sessions = new Map()
/**
* Active connections, mapping comms session to multiplayer session
*/
const connections = new Map()
function getSessionsList() {
return Array.from(sessions.values()).filter(session => session.active)
}
module.exports = {
init: function(_runtime) {
runtime = _runtime
runtime.events.on('comms:connection-removed', (opts) => {
const existingSessionId = connections.get(opts.session)
if (existingSessionId) {
connections.delete(opts.session)
const session = sessions.get(existingSessionId)
session.active = false
session.idleTimeout = setTimeout(() => {
sessions.delete(existingSessionId)
}, 30000)
runtime.events.emit('comms', {
topic: "multiplayer/connection-removed",
data: { session: existingSessionId }
})
}
})
runtime.events.on('comms:message:multiplayer/connect', (opts) => {
let session
if (!sessions.has(opts.data.session)) {
// Brand new session
let user = opts.user
if (!user || user.anonymous) {
user = user || { anonymous: true }
user.username = `Anon ${Math.floor(Math.random()*100)}`
}
session = {
session: opts.data.session,
user,
active: true
}
sessions.set(opts.data.session, session)
connections.set(opts.session, opts.data.session)
runtime.log.trace(`multiplayer new session:${opts.data.session} user:${user.username}`)
} else {
// Reconnected connection - keep existing state
connections.set(opts.session, opts.data.session)
// const existingConnection = connections.get(opts.data.session)
session = sessions.get(opts.data.session)
session.active = true
runtime.log.trace(`multiplayer reconnected session:${opts.data.session} user:${session.user.username}`)
clearTimeout(session.idleTimeout)
}
// Tell existing sessions about the new connection
runtime.events.emit('comms', {
topic: "multiplayer/connection-added",
excludeSession: opts.session,
data: session
})
// Send init info to new connection
const initPacket = {
topic: "multiplayer/init",
data: getSessionsList(),
session: opts.session
}
// console.log('<<', initPacket)
runtime.events.emit('comms', initPacket)
})
runtime.events.on('comms:message:multiplayer/disconnect', (opts) => {
const existingSessionId = connections.get(opts.session)
connections.delete(opts.session)
sessions.delete(existingSessionId)
runtime.events.emit('comms', {
topic: "multiplayer/connection-removed",
data: { session: existingSessionId, disconnected: true }
})
})
runtime.events.on('comms:message:multiplayer/location', (opts) => {
// console.log('>>>', opts.user, opts.data)
const sessionId = connections.get(opts.session)
const session = sessions.get(sessionId)
if (opts.user) {
if (session.user.anonymous !== opts.user.anonymous) {
session.user = opts.user
runtime.events.emit('comms', {
topic: 'multiplayer/connection-added',
excludeSession: opts.session,
data: session
})
}
}
session.location = opts.data
const payload = {
session: sessionId,
workspace: opts.data.workspace,
node: opts.data.node
}
runtime.events.emit('comms', {
topic: 'multiplayer/location',
data: payload,
excludeSession: opts.session
})
})
}
}

View File

@@ -77,7 +77,7 @@ var storageModuleInterface = {
flows: flows,
credentials: creds
};
result.rev = crypto.createHash('md5').update(JSON.stringify(result.flows)).digest("hex");
result.rev = crypto.createHash('sha256').update(JSON.stringify(result.flows)).digest("hex");
return result;
})
});
@@ -95,7 +95,7 @@ var storageModuleInterface = {
return credentialSavePromise.then(function() {
return storageModule.saveFlows(flows, user).then(function() {
return crypto.createHash('md5').update(JSON.stringify(config.flows)).digest("hex");
return crypto.createHash('sha256').update(JSON.stringify(config.flows)).digest("hex");
})
});
},

View File

@@ -1,6 +1,6 @@
{
"name": "@node-red/runtime",
"version": "4.0.0-dev",
"version": "4.0.0-beta.1",
"license": "Apache-2.0",
"main": "./lib/index.js",
"repository": {
@@ -16,8 +16,8 @@
}
],
"dependencies": {
"@node-red/registry": "4.0.0-dev",
"@node-red/util": "4.0.0-dev",
"@node-red/registry": "4.0.0-beta.1",
"@node-red/util": "4.0.0-beta.1",
"async-mutex": "0.4.0",
"clone": "2.1.2",
"express": "4.18.2",

View File

@@ -1,6 +1,6 @@
{
"name": "@node-red/util",
"version": "4.0.0-dev",
"version": "4.0.0-beta.1",
"license": "Apache-2.0",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
{
"name": "node-red",
"version": "4.0.0-dev",
"version": "4.0.0-beta.1",
"description": "Low-code programming for event-driven applications",
"homepage": "https://nodered.org",
"license": "Apache-2.0",
@@ -31,10 +31,10 @@
"flow"
],
"dependencies": {
"@node-red/editor-api": "4.0.0-dev",
"@node-red/runtime": "4.0.0-dev",
"@node-red/util": "4.0.0-dev",
"@node-red/nodes": "4.0.0-dev",
"@node-red/editor-api": "4.0.0-beta.1",
"@node-red/runtime": "4.0.0-beta.1",
"@node-red/util": "4.0.0-beta.1",
"@node-red/nodes": "4.0.0-beta.1",
"basic-auth": "2.0.1",
"bcryptjs": "2.4.3",
"express": "4.18.2",

View File

@@ -437,6 +437,10 @@ module.exports = {
}
},
multiplayer: {
/** To enable the Multiplayer feature, set this value to true */
enabled: false
},
},
/*******************************************************************************

View File

@@ -4,7 +4,7 @@ const path = require("path");
const fs = require("fs-extra");
const should = require("should");
const LATEST = "3";
const LATEST = "4";
function generateScript() {
return new Promise((resolve, reject) => {

View File

@@ -33,16 +33,15 @@ describe("library api", function() {
should.not.exist(library.getExampleFlowPath('foo','bar'));
});
it('returns a valid example path', function(done) {
it('returns valid example paths', function(done) {
library.init();
library.addExamplesDir("test-module",path.resolve(__dirname+'/resources/examples')).then(function() {
try {
var flows = library.getExampleFlows();
flows.should.deepEqual({"test-module":{"f":["one"]}});
flows.should.deepEqual({"test-module":{"f":["1.2.3","one"]}});
var examplePath = library.getExampleFlowPath('test-module','one');
examplePath.should.eql(path.resolve(__dirname+'/resources/examples/one.json'))
examplePath.should.eql(path.resolve(__dirname+'/resources/examples/one.json'));
library.removeExamplesDir('test-module');
@@ -57,6 +56,5 @@ describe("library api", function() {
done(err);
}
});
})
});
});