@@ -156,6 +156,55 @@ class PropertyValueDelegate {
156156 /// evaluates to null. See [NullSubstitutionMode] for more details.
157157 ///
158158 /// [scopedValues] is required to retrieve the data from various sources.
159+ /// Substitutes variables in a JSON [template] value by value, so the
160+ /// result stays valid JSON whatever the values contain: a string that is
161+ /// exactly one `${path}` becomes the path's value as is (a list, a number,
162+ /// null, ...), and other strings get their paths substituted as text.
163+ ///
164+ /// Returns null when [template] isn't JSON before substitution, e.g. when a
165+ /// path stands unquoted for a number.
166+ static Map <String , dynamic >? substituteVariablesInJson (
167+ String template, {
168+ required ScopedValues scopedValues,
169+ }) {
170+ final Object ? parsed = tryJsonDecode (template.trim ());
171+ if (parsed is ! Map ) return null ;
172+ return Map <String , dynamic >.from (
173+ _substituteJsonValue (parsed, scopedValues) as Map ,
174+ );
175+ }
176+
177+ static Object ? _substituteJsonValue (
178+ Object ? value,
179+ ScopedValues scopedValues,
180+ ) {
181+ return switch (value) {
182+ String () =>
183+ RegExp (r'^\$\{[^{}]+\}$' ).hasMatch (value.trim ())
184+ ? _retrieveVariableValue (value.trim (), scopedValues: scopedValues)
185+ : substituteVariables (
186+ value,
187+ scopedValues: scopedValues,
188+ nullSubstitutionMode: NullSubstitutionMode .emptyString,
189+ ),
190+ List () => [
191+ for (final item in value) _substituteJsonValue (item, scopedValues),
192+ ],
193+ Map () => {
194+ for (final MapEntry (: key, : value) in value.entries)
195+ substituteVariables (
196+ key.toString (),
197+ scopedValues: scopedValues,
198+ nullSubstitutionMode: NullSubstitutionMode .emptyString,
199+ ): _substituteJsonValue (
200+ value,
201+ scopedValues,
202+ ),
203+ },
204+ _ => value,
205+ };
206+ }
207+
159208 static String substituteVariables (
160209 String text, {
161210 required ScopedValues scopedValues,
@@ -215,7 +264,9 @@ class PropertyValueDelegate {
215264
216265 if (variable == null ) return null ;
217266
218- VariableType accessorType = match.hasAccessor
267+ // Predefined variables like item hold any type, so read accessors for
268+ // the value they hold (an item can be a string, a map, ...).
269+ VariableType accessorType = match.hasAccessor || match.isPredefinedVariable
219270 ? VariableType .fromObjectType (value)
220271 : variable.type;
221272 List <Accessor > accessors = variable.name.startsWith ('api' )
@@ -431,11 +482,37 @@ class PropertyValueDelegate {
431482 final JsonPath jsonPath = JsonPath (path);
432483 // Retrieve values from JSON that match the path.
433484 final values = jsonPath.read (data);
434- if (values.isEmpty) return ( null , null );
485+ if (values.isEmpty) return _substituteComputedProperty (text, data );
435486 // Return the first value.
436487 return (values.first.value, values.first.pointer);
437488 }
438489
490+ /// Resolves a path that ends in a computed property of a nested value, like
491+ /// `${item.memberIds.length}` or `${item.createdAt.timeAgo}` , which plain
492+ /// JSON paths can't read: the rest of the path is resolved first, then the
493+ /// property is read from [getVariableProperties] of that value.
494+ static (Object ? , JsonPointer ? ) _substituteComputedProperty (
495+ String text,
496+ Map <String , dynamic > data,
497+ ) {
498+ final String path = text.substring (2 , text.length - 1 );
499+ final int dot = path.lastIndexOf ('.' );
500+ if (dot <= 0 ) return (null , null );
501+ final String property = path.substring (dot + 1 );
502+ if (! RegExp (r'^[a-zA-Z]+$' ).hasMatch (property)) return (null , null );
503+ final (Object ? parent, _) = substituteJsonPath (
504+ path.substring (0 , dot),
505+ data,
506+ );
507+ if (parent == null ) return (null , null );
508+ final Map <String , dynamic > properties = getVariableProperties (
509+ parent,
510+ VariableType .fromObjectType (parent),
511+ );
512+ if (! properties.containsKey (property)) return (null , null );
513+ return (properties[property], null );
514+ }
515+
439516 static void putValueInJsonPath (
440517 String path,
441518 Object ? value,
@@ -501,6 +578,13 @@ class PropertyValueDelegate {
501578 'upcase' : value.toUpperCase (),
502579 'downcase' : value.toLowerCase (),
503580 'capitalize' : value.capitalized,
581+ 'initial' : value.trim ().isEmpty
582+ ? ''
583+ : value.trim ().characters.first.toUpperCase (),
584+ if (DateTime .tryParse (value) case final DateTime date) ...{
585+ 'timeAgo' : formatTimeAgo (date),
586+ 'date' : formatDate (date),
587+ },
504588 },
505589 int () => {
506590 'isEven' : value.isEven,
@@ -576,7 +660,7 @@ List<Accessor> getAccessorsForType(VariableType type) {
576660 Accessor (
577661 name: 'downcase' ,
578662 type: VariableType .text,
579- getValue: (value) => value? .toString ().toUpperCase (),
663+ getValue: (value) => value? .toString ().toLowerCase (),
580664 ),
581665 Accessor (
582666 name: 'capitalize' ,
@@ -598,6 +682,30 @@ List<Accessor> getAccessorsForType(VariableType type) {
598682 type: VariableType .boolean,
599683 getValue: (value) => value? .toString ().trim ().isEmpty,
600684 ),
685+ Accessor (
686+ name: 'initial' ,
687+ type: VariableType .text,
688+ getValue: (value) {
689+ final String text = value? .toString ().trim () ?? '' ;
690+ return text.isEmpty ? '' : text.characters.first.toUpperCase ();
691+ },
692+ ),
693+ Accessor (
694+ name: 'timeAgo' ,
695+ type: VariableType .text,
696+ getValue: (value) => switch (DateTime .tryParse ('${value ?? '' }' )) {
697+ final DateTime date => formatTimeAgo (date),
698+ null => null ,
699+ },
700+ ),
701+ Accessor (
702+ name: 'date' ,
703+ type: VariableType .text,
704+ getValue: (value) => switch (DateTime .tryParse ('${value ?? '' }' )) {
705+ final DateTime date => formatDate (date),
706+ null => null ,
707+ },
708+ ),
601709 ];
602710 case VariableType .integer:
603711 return [
@@ -911,3 +1019,29 @@ List<Accessor> getCloudDataVariableAccessorsForType(VariableType dataType) => [
9111019 getValue: (value) => value.toMap ()? ['error' ],
9121020 ),
9131021];
1022+
1023+ const List <String > _monthNames = [
1024+ 'Jan' , 'Feb' , 'Mar' , 'Apr' , 'May' , 'Jun' , //
1025+ 'Jul' , 'Aug' , 'Sep' , 'Oct' , 'Nov' , 'Dec' ,
1026+ ];
1027+
1028+ /// Formats [date] like `Oct 14, 2026` , in local time.
1029+ String formatDate (DateTime date) {
1030+ final DateTime local = date.toLocal ();
1031+ return '${_monthNames [local .month - 1 ]} ${local .day }, ${local .year }' ;
1032+ }
1033+
1034+ /// Formats [date] relative to now: `Just now` , `5 min ago` , `2 hours ago` ,
1035+ /// `3 days ago` , then [formatDate] after a week.
1036+ String formatTimeAgo (DateTime date, {DateTime ? now}) {
1037+ final Duration diff = (now ?? DateTime .now ()).difference (date);
1038+ if (diff.inMinutes < 1 ) return 'Just now' ;
1039+ if (diff.inHours < 1 ) return '${diff .inMinutes } min ago' ;
1040+ if (diff.inDays < 1 ) {
1041+ return '${diff .inHours } ${diff .inHours == 1 ? 'hour' : 'hours' } ago' ;
1042+ }
1043+ if (diff.inDays < 7 ) {
1044+ return '${diff .inDays } ${diff .inDays == 1 ? 'day' : 'days' } ago' ;
1045+ }
1046+ return formatDate (date);
1047+ }
0 commit comments