luci-base: add tuple validator
authorPaul Donald <redacted>
Sat, 14 Feb 2026 18:05:19 +0000 (19:05 +0100)
committerPaul Donald <redacted>
Mon, 16 Feb 2026 00:42:58 +0000 (01:42 +0100)
There are a number of validation types which are useful
but inaccessible when a value field combines simple
data-types. Example <ipaddr><space><ipaddr>. At which point
one must write a custom validate function, and applying the
built-in factory methods is not trivial.

Introduce a tuple function which combines known types
to validate a string, with a single line definition.
E.g. an IP and a port space-separated:

opt.datatype = 'tuple(ipaddr,port)';

All validation methods must return true for valid data.

The tuple function splits on space by default, or any string
provided by sep(). Here, a comma:

opt.datatype = 'tuple(ipaddr,port,sep(","))';

After the string is separated, any error message displayed
corresponds to the first invalid part of the input string
encountered.

Signed-off-by: Paul Donald <redacted>
modules/luci-base/htdocs/luci-static/resources/validation.js

index b6986bbec8a5e46583972c7a4b69ee99eb696174..5e0e7e0d158f93b19b698b9ebc9cf96d82b7b9a3 100644 (file)
@@ -309,6 +309,18 @@ const ValidatorFactory = baseclass.extend(/** @lends LuCI.validation.ValidatorFa
                                esc = true;
                                break;
 
+                       // Skip over quoted strings so commas inside quotes don't split tokens
+                       case 34: // "
+                       case 39: { // '\''
+                               const quote = code.charCodeAt(i);
+                               let j = i + 1;
+                               for (; j < code.length; j++) {
+                                       if (code.charCodeAt(j) === 92) { j++; continue; }
+                                       if (code.charCodeAt(j) === quote) { i = j; break; }
+                               }
+                               break;
+                       }
+
                        case 40:
                        case 44:
                                if (depth <= 0) {
@@ -841,6 +853,95 @@ const ValidatorFactory = baseclass.extend(/** @lends LuCI.validation.ValidatorFa
                                _('valid address:port'));
                },
 
+               /**
+                * Define a string separator `sep` for use in [tuple]{@link
+                * LuCI.validation.ValidatorFactory.types#tuple}.
+                * @function LuCI.validation.ValidatorFactory.types#sep
+                * @param {string} str define the separator string
+                * @returns {@link LuCI.validation.Validator#assert assert()} {boolean}
+                */
+               sep(str) {
+                       return this.apply('string', str);
+               },
+
+               /**
+                * Tuple validator: accepts 1-N tokens separated by a given separator
+                * {@link LuCI.validation.ValidatorFactory.types#sep sep}
+                * (whitespace by default if {@link LuCI.validation.ValidatorFactory.types#sep sep}
+                * is omitted) which will be validated against the 1-N types.
+                *
+                * This differs from {@link LuCI.validation.ValidatorFactory.types#and and}
+                * by first splitting the input and applying each validator function
+                * sequentially on the resulting array of the split string, whereby the
+                * first type applies to the first value element, the second to the
+                * second, and so on, to define a concrete order.
+                *
+                * {@link LuCI.validation.ValidatorFactory.types#sep sep}
+                * can appear at any position in the list.
+                *
+                * @example
+                *
+                * tuple(ipaddr,port) // "192.0.2.1 88"
+                *
+                * tuple(host,port,sep(',')) // "taurus,8000"
+                *
+                * tuple(port,port,port,sep('-')) // "33-45-78"
+                *
+                * @function LuCI.validation.ValidatorFactory.types#tuple
+                * @param {...function} types {@link LuCI.validation.ValidatorFactory.types
+                * types validation functions}
+                * @param {string} [sep()] function to define split separator string.
+                * @returns {@link LuCI.validation.Validator#assert assert()} {boolean}
+                */
+               tuple() {
+                       const argsraw = Array.prototype.slice.call(arguments);
+                       let sep = null;
+
+                       // Build list of (validator, validatorArgs) pairs
+                       const types = [];
+                       for (let i = 0; i < argsraw.length; i += 2)
+                               types.push([ argsraw[i], argsraw[i+1] ]);
+
+                       // Determine the separator, if provided
+                       if (types.length) {
+                               for (let t of types) {
+                                       if (t[0] === this.factory.types['sep']) {
+                                               const e = types.pop();
+                                               if (Array.isArray(e[1]) && e[1].length > 0)
+                                                       sep = e[1][0];
+                                       }
+                               }
+                       }
+
+                       const raw = (this.value || '');
+                       let tokens = (sep == null) ? raw.split(/\s+/) : raw.split(sep).map(s => s.trim());
+
+                       if (tokens.length != types.length) {
+                               const getName = (t) => {
+                                       if (typeof t === 'function') {
+                                               for (const k in this.factory.types)
+                                                       if (this.factory.types[k] === t)
+                                                               return k;
+                                               return _('value');
+                                       }
+                                       return _('value');
+                               };
+
+                               const expectedTypes = types.map(t => getName(t[0])).join(sep == null ? ' ' : sep);
+                               const sepDesc = sep == null ? _('whitespace') : `"${sep}"`;
+                               const msg_multi = _('%s; %d tokens separated by %s').format(expectedTypes, types.length, sepDesc);
+                               const msg_single = _('%s').format(expectedTypes, types.length, sepDesc);
+                               return this.assert(false, (types.length > 1) ? msg_multi : msg_single);
+                       }
+
+                       for (let i = 0; i < tokens.length; i++) {
+                               if (!this.apply(types[i][0], tokens[i], types[i][1]))
+                                       return this.assert(false, this.error);
+                       }
+
+                       return this.assert(true);
+               },
+
                /**
                 * Assert a valid (hexadecimal) WPA key of `8 <= length <= 63`, or hex if `length == 64`.
                 * @function LuCI.validation.ValidatorFactory.types#wpakey
git clone https://git.99rst.org/PROJECT