001/**
002 * Copyright 2005-2018 The Kuali Foundation
003 *
004 * Licensed under the Educational Community License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.opensource.org/licenses/ecl2.php
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package org.kuali.rice.krad.uif.util;
017
018import org.apache.commons.lang.ArrayUtils;
019import org.apache.commons.lang.StringUtils;
020import org.kuali.rice.krad.uif.component.Component;
021import org.kuali.rice.krad.uif.element.Message;
022import org.kuali.rice.krad.uif.lifecycle.ViewLifecycle;
023import org.kuali.rice.krad.uif.lifecycle.ViewPostMetadata;
024import org.kuali.rice.krad.uif.view.View;
025import org.kuali.rice.krad.util.KRADConstants;
026
027import java.util.ArrayList;
028import java.util.Arrays;
029import java.util.List;
030
031/**
032 * Rich message structure utilities for parsing message content and converting it to components/content
033 *
034 * @author Kuali Rice Team (rice.collab@kuali.org)
035 */
036public class MessageStructureUtils {
037
038    /**
039     * Translate a message with special hooks described in MessageStructureUtils.parseMessage.  However, tags which
040     * reference components will not be allowed/translated - only tags which can translate to string content will
041     * be included for this translation.
042     *
043     * @param messageText messageText with only String translateable tags included (no id or component index tags)
044     * @return html translation of rich messageText passed in
045     * @see MessageStructureUtils#parseMessage
046     */
047    public static String translateStringMessage(String messageText) {
048        if (!StringUtils.isEmpty(messageText)) {
049            List<Component> components = MessageStructureUtils.parseMessage(null, messageText, null, null, false);
050
051            if (!components.isEmpty()) {
052                Component message = components.get(0);
053
054                if (message instanceof Message) {
055                    messageText = ((Message) message).getMessageText();
056                }
057            }
058        }
059
060        return messageText;
061    }
062
063    /**
064     * Parses the message text passed in and returns the resulting rich message component structure.
065     *
066     * <p>If special characters [] are detected the message is split at that location.  The types of features supported
067     * by the parse are (note that &lt;&gt; are not part of the content, they specify placeholders):
068     * <ul>
069     * <li>[id=&lt;component id&gt;] - insert component with id specified at that location in the message</li>
070     * <li>[n] - insert component at index n from the inlineComponent list</li>
071     * <li>[&lt;html tag&gt;][/&lt;html tag&gt;] - insert html content directly into the message content at that
072     * location,
073     * without the need to escape the &lt;&gt; characters in xml</li>
074     * <li>[color=&lt;html color code/name&gt;][/color] - wrap content in color tags to make text that color
075     * in the message</li>
076     * <li>[css=&lt;css classes&gt;][/css] - apply css classes specified to the wrapped content - same as wrapping
077     * the content in span with class property set</li>
078     * <li>[link=&lt;href src&gt;][/link] - an easier way to create an anchor that will open in a new page to the
079     * href specified after =</li>
080     * <li>[action=&lt;href src&gt;][/action] - create an action link inline without having to specify a component by
081     * id or index.  The options for this are as follows and MUST be in a comma seperated list in the order specified
082     * (specify 1-4 always in this order):
083     * <ul>
084     * <li>methodToCall(String)</li>
085     * <li>validateClientSide(boolean) - true if not set</li>
086     * <li>ajaxSubmit(boolean) - true if not set</li>
087     * <li>successCallback(js function or function declaration) - this only works when ajaxSubmit is true</li>
088     * </ul>
089     * The tag would look something like this [action=methodToCall]Action[/action] in most common cases.  And in more
090     * complex cases [action=methodToCall,true,true,functionName]Action[/action].  <p>In addition to these settings,
091     * you can also specify data to send to the server in this fashion (space is required between settings and data):
092     * </p>
093     * [action=&lt;action settings&gt; data={key1: 'value 1', key2: value2}]
094     * </li>
095     * </ul>
096     * If the [] characters are needed in message text, they need to be declared with an escape character: \\[ \\]
097     * </p>
098     *
099     * @param messageId id of the message
100     * @param messageText message text to be parsed
101     * @param componentList the inlineComponent list
102     * @param view the current view
103     * @param parseComponents true to parse components
104     * @return list of components representing the parsed message structure
105     */
106    public static List<Component> parseMessage(String messageId, String messageText, List<Component> componentList,
107            View view, boolean parseComponents) {
108        messageText = messageText.replace("\\" + KRADConstants.MessageParsing.LEFT_TOKEN,
109                KRADConstants.MessageParsing.LEFT_BRACKET);
110        messageText = messageText.replace("\\" + KRADConstants.MessageParsing.RIGHT_TOKEN,
111                KRADConstants.MessageParsing.RIGHT_BRACKET);
112        messageText = messageText.replace(KRADConstants.MessageParsing.RIGHT_TOKEN,
113                KRADConstants.MessageParsing.RIGHT_TOKEN_PLACEHOLDER);
114        String[] messagePieces = messageText.split("[\\" + KRADConstants.MessageParsing.LEFT_TOKEN +
115                "|\\" + KRADConstants.MessageParsing.RIGHT_TOKEN + "]");
116
117        List<Component> messageComponentStructure = new ArrayList<Component>();
118
119        //current message object to concatenate to after it is generated to prevent whitespace issues and
120        //creation of multiple unneeded objects
121        Message currentMessageComponent = null;
122
123        for (String messagePiece : messagePieces) {
124            if (messagePiece.endsWith(KRADConstants.MessageParsing.RIGHT_TOKEN_MARKER)) {
125                messagePiece = StringUtils.removeEnd(messagePiece, KRADConstants.MessageParsing.RIGHT_TOKEN_MARKER);
126
127                if (StringUtils.startsWithIgnoreCase(messagePiece, KRADConstants.MessageParsing.COMPONENT_BY_ID + "=")
128                        && parseComponents) {
129                    //Component by Id
130                    currentMessageComponent = processIdComponentContent(messagePiece, messageComponentStructure,
131                            currentMessageComponent, view);
132                } else if (messagePiece.matches("^[0-9]+( .+=.+)*$") && parseComponents) {
133                    //Component by index of inlineComponents
134                    currentMessageComponent = processIndexComponentContent(messagePiece, componentList,
135                            messageComponentStructure, currentMessageComponent, view, messageId);
136                } else if (StringUtils.startsWithIgnoreCase(messagePiece, KRADConstants.MessageParsing.COLOR + "=")
137                        || StringUtils.startsWithIgnoreCase(messagePiece, "/" + KRADConstants.MessageParsing.COLOR)) {
138                    //Color span
139                    currentMessageComponent = processColorContent(messagePiece, currentMessageComponent, view);
140                } else if (StringUtils.startsWithIgnoreCase(messagePiece,
141                        KRADConstants.MessageParsing.CSS_CLASSES + "=") || StringUtils.startsWithIgnoreCase(
142                        messagePiece, "/" + KRADConstants.MessageParsing.CSS_CLASSES)) {
143                    //css class span
144                    currentMessageComponent = processCssClassContent(messagePiece, currentMessageComponent, view);
145                } else if (StringUtils.startsWithIgnoreCase(messagePiece, KRADConstants.MessageParsing.LINK + "=")
146                        || StringUtils.startsWithIgnoreCase(messagePiece, "/" + KRADConstants.MessageParsing.LINK)) {
147                    //link (a tag)
148                    currentMessageComponent = processLinkContent(messagePiece, currentMessageComponent, view);
149                } else if (StringUtils.startsWithIgnoreCase(messagePiece,
150                        KRADConstants.MessageParsing.ACTION_LINK + "=") || StringUtils.startsWithIgnoreCase(
151                        messagePiece, "/" + KRADConstants.MessageParsing.ACTION_LINK)) {
152                    //action link (a tag)
153                    currentMessageComponent = processActionLinkContent(messagePiece, currentMessageComponent, view);
154                } else if (messagePiece.equals("")) {
155                    //do nothing    
156                } else {
157                    //raw html content
158                    currentMessageComponent = processHtmlContent(messagePiece, currentMessageComponent, view);
159                }
160            } else {
161                //raw string
162                messagePiece = addBlanks(messagePiece);
163                currentMessageComponent = concatenateStringMessageContent(currentMessageComponent, messagePiece, view);
164            }
165        }
166
167        if (currentMessageComponent != null && StringUtils.isNotEmpty(currentMessageComponent.getMessageText())) {
168            messageComponentStructure.add(currentMessageComponent);
169            currentMessageComponent = null;
170        }
171
172        return messageComponentStructure;
173    }
174
175    /**
176     * Concatenates string content onto the message passed in and passes it back.  If the message is null, creates
177     * a new message object with the string content and passes that back.
178     *
179     * @param currentMessageComponent Message object
180     * @param messagePiece string content to be concatenated
181     * @param view the current view
182     * @return resulting concatenated Message
183     */
184    private static Message concatenateStringMessageContent(Message currentMessageComponent, String messagePiece,
185            View view) {
186        if (currentMessageComponent == null) {
187            currentMessageComponent = ComponentFactory.getMessage();
188            currentMessageComponent.setMessageText(messagePiece);
189            currentMessageComponent.setRenderWrapperTag(false);
190        } else {
191            currentMessageComponent.setMessageText(currentMessageComponent.getMessageText() + messagePiece);
192        }
193
194        return currentMessageComponent;
195    }
196
197    /**
198     * Process the additional properties beyond index 0 of the tag (that was split into parts).
199     *
200     * <p>This will evaluate and set each of properties on the component passed in.  This only allows
201     * setting of properties that can easily be converted to/from/are String type by Spring.</p>
202     *
203     * @param component component to have its properties set
204     * @param tagParts the tag split into parts, index 0 is ignored
205     * @return component with its properties set found in the tag's parts
206     */
207    private static Component processAdditionalProperties(Component component, String[] tagParts) {
208        String componentString = tagParts[0];
209        tagParts = (String[]) ArrayUtils.remove(tagParts, 0);
210
211        for (String part : tagParts) {
212            String[] propertyValue = part.split("=");
213
214            if (propertyValue.length == 2) {
215                String path = propertyValue[0];
216                String value = propertyValue[1].trim();
217                value = StringUtils.removeStart(value, "'");
218                value = StringUtils.removeEnd(value, "'");
219                ObjectPropertyUtils.setPropertyValue(component, path, value);
220            } else {
221                throw new RuntimeException(
222                        "Invalid Message structure for component defined as " + componentString + " around " + part);
223            }
224        }
225
226        return component;
227    }
228
229    /**
230     * Inserts &amp;nbsp; into the string passed in, if spaces exist at the beginning and/or end,
231     * so spacing is not lost in html translation.
232     *
233     * @param text string to insert  &amp;nbsp;
234     * @return String with  &amp;nbsp; inserted, if applicable
235     */
236    public static String addBlanks(String text) {
237        if (StringUtils.startsWithIgnoreCase(text, " ")) {
238            text = "&nbsp;" + StringUtils.removeStart(text, " ");
239        }
240
241        if (text.endsWith(" ")) {
242            text = StringUtils.removeEnd(text, " ") + "&nbsp;";
243        }
244
245        return text;
246    }
247
248    /**
249     * Process a piece of the message that has id content to get a component by id and insert it in the structure
250     *
251     * @param messagePiece String piece with component by id content
252     * @param messageComponentStructure the structure of the message being built
253     * @param currentMessageComponent the state of the current text based message being built
254     * @param view current View
255     * @return null if currentMessageComponent had a value (it is now added to the messageComponentStructure passed in)
256     */
257    private static Message processIdComponentContent(String messagePiece, List<Component> messageComponentStructure,
258            Message currentMessageComponent, View view) {
259        //splits around spaces not included in single quotes
260        String[] parts = messagePiece.trim().trim().split("([ ]+(?=([^']*'[^']*')*[^']*$))");
261        messagePiece = parts[0];
262
263        //if there is a currentMessageComponent add it to the structure and reset it to null
264        //because component content is now interrupting the string content
265        if (currentMessageComponent != null && StringUtils.isNotEmpty(currentMessageComponent.getMessageText())) {
266            messageComponentStructure.add(currentMessageComponent);
267            currentMessageComponent = null;
268        }
269
270        //match component by id from the view
271        messagePiece = StringUtils.remove(messagePiece, "'");
272        messagePiece = StringUtils.remove(messagePiece, "\"");
273        Component component = ComponentFactory.getNewComponentInstance(StringUtils.removeStart(messagePiece,
274                KRADConstants.MessageParsing.COMPONENT_BY_ID + "="));
275
276        if (component != null) {
277            component.addStyleClass(KRADConstants.MessageParsing.INLINE_COMP_CLASS);
278
279            if (parts.length > 1) {
280                component = processAdditionalProperties(component, parts);
281            }
282            messageComponentStructure.add(component);
283        }
284
285        return currentMessageComponent;
286    }
287
288    /**
289     * Process a piece of the message that has index content to get a component by index in the componentList passed in
290     * and insert it in the structure
291     *
292     * @param messagePiece String piece with component by index content
293     * @param componentList list that contains the component referenced by index
294     * @param messageComponentStructure the structure of the message being built
295     * @param currentMessageComponent the state of the current text based message being built
296     * @param view current View
297     * @param messageId id of the message being parsed (for exception notification)
298     * @return null if currentMessageComponent had a value (it is now added to the messageComponentStructure passed in)
299     */
300    private static Message processIndexComponentContent(String messagePiece, List<Component> componentList,
301            List<Component> messageComponentStructure, Message currentMessageComponent, View view, String messageId) {
302        //splits around spaces not included in single quotes
303        String[] parts = messagePiece.trim().trim().split("([ ]+(?=([^']*'[^']*')*[^']*$))");
304        messagePiece = parts[0];
305
306        //if there is a currentMessageComponent add it to the structure and reset it to null
307        //because component content is now interrupting the string content
308        if (currentMessageComponent != null && StringUtils.isNotEmpty(currentMessageComponent.getMessageText())) {
309            messageComponentStructure.add(currentMessageComponent);
310            currentMessageComponent = null;
311        }
312
313        //match component by index from the componentList passed in
314        int cIndex = Integer.parseInt(messagePiece);
315
316        if (componentList != null && cIndex < componentList.size() && !componentList.isEmpty()) {
317            Component component = componentList.get(cIndex);
318
319            if (component != null) {
320                if (parts.length > 1) {
321                    component = processAdditionalProperties(component, parts);
322                }
323
324                component.addStyleClass(KRADConstants.MessageParsing.INLINE_COMP_CLASS);
325                messageComponentStructure.add(component);
326            }
327        } else {
328            throw new RuntimeException("Component with index " + cIndex +
329                    " does not exist in inlineComponents of the message component with id " + messageId);
330        }
331
332        return currentMessageComponent;
333    }
334
335    /**
336     * Process a piece of the message that has color content by creating a span with that color style set
337     *
338     * @param messagePiece String piece with color content
339     * @param currentMessageComponent the state of the current text based message being built
340     * @param view current View
341     * @return currentMessageComponent with the new textual content generated by this method appended to its
342     *         messageText
343     */
344    private static Message processColorContent(String messagePiece, Message currentMessageComponent, View view) {
345        if (!StringUtils.startsWithIgnoreCase(messagePiece, "/")) {
346            messagePiece = StringUtils.remove(messagePiece, "'");
347            messagePiece = StringUtils.remove(messagePiece, "\"");
348            messagePiece = "<span style='color: " + StringUtils.removeStart(messagePiece,
349                    KRADConstants.MessageParsing.COLOR + "=") + ";'>";
350        } else {
351            messagePiece = "</span>";
352        }
353
354        return concatenateStringMessageContent(currentMessageComponent, messagePiece, view);
355    }
356
357    /**
358     * Process a piece of the message that has css content by creating a span with those css classes set
359     *
360     * @param messagePiece String piece with css class content
361     * @param currentMessageComponent the state of the current text based message being built
362     * @param view current View
363     * @return currentMessageComponent with the new textual content generated by this method appended to its
364     *         messageText
365     */
366    private static Message processCssClassContent(String messagePiece, Message currentMessageComponent, View view) {
367        if (!StringUtils.startsWithIgnoreCase(messagePiece, "/")) {
368            messagePiece = StringUtils.remove(messagePiece, "'");
369            messagePiece = StringUtils.remove(messagePiece, "\"");
370            messagePiece = "<span class='" + StringUtils.removeStart(messagePiece,
371                    KRADConstants.MessageParsing.CSS_CLASSES + "=") + "'>";
372        } else {
373            messagePiece = "</span>";
374        }
375
376        return concatenateStringMessageContent(currentMessageComponent, messagePiece, view);
377    }
378
379    /**
380     * Process a piece of the message that has link content by creating an anchor (a tag) with the href set
381     *
382     * @param messagePiece String piece with link content
383     * @param currentMessageComponent the state of the current text based message being built
384     * @param view current View
385     * @return currentMessageComponent with the new textual content generated by this method appended to its
386     *         messageText
387     */
388    private static Message processLinkContent(String messagePiece, Message currentMessageComponent, View view) {
389        if (!StringUtils.startsWithIgnoreCase(messagePiece, "/")) {
390            //clean up href
391            messagePiece = StringUtils.removeStart(messagePiece, KRADConstants.MessageParsing.LINK + "=");
392            messagePiece = StringUtils.removeStart(messagePiece, "'");
393            messagePiece = StringUtils.removeEnd(messagePiece, "'");
394            messagePiece = StringUtils.removeStart(messagePiece, "\"");
395            messagePiece = StringUtils.removeEnd(messagePiece, "\"");
396
397            messagePiece = "<a href='" + messagePiece + "' target='_blank'>";
398        } else {
399            messagePiece = "</a>";
400        }
401
402        return concatenateStringMessageContent(currentMessageComponent, messagePiece, view);
403    }
404
405    /**
406     * Process a piece of the message that has action link content by creating an anchor (a tag) with the onClick set
407     * to perform either ajaxSubmit or submit to the controller with a methodToCall
408     *
409     * @param messagePiece String piece with action link content
410     * @param currentMessageComponent the state of the current text based message being built
411     * @param view current View
412     * @return currentMessageComponent with the new textual content generated by this method appended to its
413     *         messageText
414     */
415    private static Message processActionLinkContent(String messagePiece, Message currentMessageComponent, View view) {
416        if (!StringUtils.startsWithIgnoreCase(messagePiece, "/")) {
417            messagePiece = StringUtils.removeStart(messagePiece, KRADConstants.MessageParsing.ACTION_LINK + "=");
418            String[] splitData = messagePiece.split(KRADConstants.MessageParsing.ACTION_DATA + "=");
419
420            String[] params = splitData[0].trim().split("([,]+(?=([^']*'[^']*')*[^']*$))");
421            String methodToCall = ((params.length >= 1) ? params[0] : "");
422            String validate = ((params.length >= 2) ? params[1] : "true");
423            String ajaxSubmit = ((params.length >= 3) ? params[2] : "true");
424            String successCallback = ((params.length >= 4) ? params[3] : "null");
425
426            String submitData = "null";
427
428            if (splitData.length > 1) {
429                submitData = splitData[1].trim();
430            }
431
432            methodToCall = StringUtils.remove(methodToCall, "'");
433            methodToCall = StringUtils.remove(methodToCall, "\"");
434
435            messagePiece = "<a href=\"javascript:void(null)\" onclick=\"submitForm(" +
436                    "'" +
437                    methodToCall +
438                    "'," +
439                    submitData +
440                    "," +
441                    validate +
442                    "," +
443                    ajaxSubmit +
444                    "," +
445                    successCallback +
446                    "); return false;\">";
447
448            ViewPostMetadata viewPostMetadata = ViewLifecycle.getViewPostMetadata();
449            if (viewPostMetadata != null) {
450                viewPostMetadata.addAccessibleMethodToCall(methodToCall);
451                viewPostMetadata.addAvailableMethodToCall(methodToCall);
452            }
453        } else {
454            messagePiece = "</a>";
455        }
456
457        return concatenateStringMessageContent(currentMessageComponent, messagePiece, view);
458    }
459
460    /**
461     * Process a piece of the message that is assumed to have a valid html tag
462     *
463     * @param messagePiece String piece with html tag content
464     * @param currentMessageComponent the state of the current text based message being built
465     * @param view current View
466     * @return currentMessageComponent with the new textual content generated by this method appended to its
467     *         messageText
468     */
469    private static Message processHtmlContent(String messagePiece, Message currentMessageComponent, View view) {
470        //raw html
471        messagePiece = messagePiece.trim();
472
473        if (StringUtils.startsWithAny(messagePiece, KRADConstants.MessageParsing.UNALLOWED_HTML) || StringUtils
474                .endsWithAny(messagePiece, KRADConstants.MessageParsing.UNALLOWED_HTML)) {
475            throw new RuntimeException("The following html is not allowed in Messages: " + Arrays.toString(
476                    KRADConstants.MessageParsing.UNALLOWED_HTML));
477        }
478
479        messagePiece = "<" + messagePiece + ">";
480
481        return concatenateStringMessageContent(currentMessageComponent, messagePiece, view);
482    }
483}