package de.uni_passau.fim.infosun.prophet.util; import java.awt.Desktop; import java.awt.Font; import java.awt.FontFormatException; import java.awt.GraphicsEnvironment; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.io.IOException; import java.io.InputStream; import java.io.UnsupportedEncodingException; import java.net.MalformedURLException; import java.net.URISyntaxException; import java.net.URL; import java.net.URLDecoder; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.StringTokenizer; import java.util.concurrent.atomic.AtomicBoolean; import javax.swing.JButton; import javax.swing.JOptionPane; import javax.swing.JScrollPane; import javax.swing.JTextPane; import javax.swing.Timer; import javax.swing.UIManager; import javax.swing.event.HyperlinkEvent; import javax.swing.event.HyperlinkListener; import javax.swing.text.AttributeSet; import javax.swing.text.ComponentView; import javax.swing.text.StyleConstants; import javax.swing.text.View; import javax.swing.text.ViewFactory; import javax.swing.text.html.FormSubmitEvent; import javax.swing.text.html.HTMLDocument; import javax.swing.text.html.HTMLEditorKit; import de.uni_passau.fim.infosun.prophet.Constants; import de.uni_passau.fim.infosun.prophet.plugin.PluginList; import de.uni_passau.fim.infosun.prophet.util.qTree.QTreeNode; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import static de.uni_passau.fim.infosun.prophet.Constants.KEY_BACKWARD; import static de.uni_passau.fim.infosun.prophet.Constants.KEY_EXPERIMENT_CODE; import static de.uni_passau.fim.infosun.prophet.Constants.KEY_FORWARD; import static de.uni_passau.fim.infosun.prophet.Constants.KEY_QUESTIONSWITCHING; import static de.uni_passau.fim.infosun.prophet.Constants.KEY_SUBJECT_CODE; import static de.uni_passau.fim.infosun.prophet.Constants.KEY_SUBJECT_CODE_CAP; import static de.uni_passau.fim.infosun.prophet.util.language.UIElementNames.getLocalized; import static de.uni_passau.fim.infosun.prophet.util.qTree.QTreeNode.Type.CATEGORY; import static de.uni_passau.fim.infosun.prophet.util.qTree.QTreeNode.Type.EXPERIMENT; import static de.uni_passau.fim.infosun.prophet.util.qTree.QTreeNode.Type.QUESTION; import static de.uni_passau.fim.infosun.prophet.util.qTree.handlers.QTreeHTMLHandler.input; import static de.uni_passau.fim.infosun.prophet.util.qTree.handlers.QTreeHTMLHandler.table; import static javax.swing.text.html.HTML.Attribute.NAME; import static javax.swing.text.html.HTML.Attribute.TYPE; import static javax.swing.text.html.HTML.Tag.INPUT; import static javax.swing.text.html.HTML.Tag.SELECT; import static javax.swing.text.html.HTML.Tag.TEXTAREA; /** * Displays the rendered HTML content of a <code>QTreeNode</code>, saves the answers the user input to its * <code>QTreeNode</code> and adds the proper navigation buttons (next, previous, ...) for the node. * <code>ActionListener</code>s for navigation events can be added. * * @author Georg Seibt * @author Andreas Hasselberg * @author Markus Köppen */ public class QuestionViewPane extends JScrollPane { private static Font f; static { InputStream fontInput = QuestionViewPane.class.getResourceAsStream("VERDANAB.TTF"); try { f = Font.createFont(Font.TRUETYPE_FONT, fontInput); GraphicsEnvironment.getLocalGraphicsEnvironment().registerFont(f); } catch (FontFormatException | IOException e) { f = UIManager.getDefaults().getFont("TextPane.font"); } } private static final String HTML_SUBMIT = "submit"; private static final String HTML_DIVIDER = "<br><hr><br>"; private List<ActionListener> actionListeners; private QTreeNode questionNode; private JTextPane textPane; private ComponentView submitButton; private AtomicBoolean doNotFire; /** * A <code>HyperlinkListener</code> that will react to <code>FormSubmitEvent</code>s by * storing the data the user entered in the <code>questionNode</code> and firing the appropriate event (if any). */ private final HyperlinkListener formSubmitListener = event -> { if (event instanceof FormSubmitEvent) { FormSubmitEvent fse = (FormSubmitEvent) event; String action = saveAnswers(fse.getData()); if (action != null) { fireEvent(action); } } }; /** * A <code>HyperlinkListener</code> that will try and open links in the systems standard browser. */ private final HyperlinkListener hyperlinkListener = event -> { if (!event.getEventType().equals(HyperlinkEvent.EventType.ACTIVATED) || event instanceof FormSubmitEvent) { return; } if (Desktop.isDesktopSupported()) { Desktop desktop = Desktop.getDesktop(); if (desktop.isSupported(Desktop.Action.BROWSE)) { try { desktop.browse(event.getURL().toURI()); } catch (IOException e) { JOptionPane.showMessageDialog(textPane, getLocalized("MESSAGE_COULD_NOT_START_BROWSER")); } catch (URISyntaxException e) { JOptionPane.showMessageDialog(textPane, getLocalized("MESSAGE_INVALID_URL")); } } else { JOptionPane.showMessageDialog(textPane, getLocalized("MESSAGE_COULD_NOT_OPEN_STANDARD_BROWSER")); } } else { JOptionPane.showMessageDialog(textPane, getLocalized("MESSAGE_COULD_NOT_OPEN_STANDARD_BROWSER")); } }; /** * A custom <code>HTMLEditorKit.HTMLFactory</code> that stores the view produced for the 'nextQuestion' * button in the variable <code>submitButton</code>. The <code>JButton</code> contained in the * <code>ComponentView</code> is later used to submit the HTML form from code. */ private class CustomFactory extends HTMLEditorKit.HTMLFactory { @Override public View create(javax.swing.text.Element elem) { ComponentView view; AttributeSet eAttribs = elem.getAttributes(); Object elementName = eAttribs.getAttribute(StyleConstants.NameAttribute); if (INPUT.equals(elementName) || TEXTAREA.equals(elementName) || SELECT.equals(elementName)) { view = (ComponentView) super.create(elem); // if we find a 'nextQuestion' button we save it for use in the clickSubmit and saveAnswers method boolean isForwardButton = KEY_FORWARD.equals(eAttribs.getAttribute(NAME)); boolean isTypeSubmit = HTML_SUBMIT.equals(eAttribs.getAttribute(TYPE)); if (INPUT.equals(elementName) && isForwardButton && isTypeSubmit) { submitButton = view; } return view; } else { return super.create(elem); } } } /** * Constructs a new <code>QuestionViewPane</code> displaying the HTML content of the given <code>QTreeNode</code>. * The appropriate buttons for navigating between the parts of the experiment will be added. * * @param questionNode * the <code>QTreeNode</code> whose HTML is to be displayed */ public QuestionViewPane(QTreeNode questionNode) { this.actionListeners = new ArrayList<>(); this.questionNode = questionNode; this.doNotFire = new AtomicBoolean(false); this.textPane = new JTextPane(); this.setViewportView(textPane); textPane.setContentType("text/html"); textPane.setEditable(false); textPane.setEditorKit(new HTMLEditorKit() { @Override public ViewFactory getViewFactory() { return new CustomFactory(); } }); textPane.addHyperlinkListener(hyperlinkListener); textPane.addHyperlinkListener(formSubmitListener); ((HTMLEditorKit) textPane.getEditorKit()).setAutoFormSubmission(false); try { URL base = Paths.get("").toUri().toURL(); ((HTMLDocument) textPane.getDocument()).setBase(base); } catch (MalformedURLException e) { System.err.println("Could not determine the base URL for the HTML document."); System.err.println(e.getMessage()); } textPane.setText(getHTML(questionNode)); textPane.setCaretPosition(0); Timer bTimer = new Timer(0, null); bTimer.addActionListener(new ActionListener() { private int times; private String oldText; @Override public void actionPerformed(ActionEvent e) { JButton button = (JButton) submitButton.getComponent(); int maxTimes = 5; if (times == 0) { oldText = button.getText(); button.setEnabled(false); } if (times < maxTimes) { button.setText(String.format("%s (%d)", oldText, maxTimes - times)); times++; } else { button.setText(oldText); button.setEnabled(true); bTimer.stop(); } } }); bTimer.setDelay(1000); bTimer.setRepeats(true); bTimer.start(); } /** * Assembles the HTML content that will be displayed by the <code>textPane</code>. Adds the proper buttons * for navigation and an input field for the subject code if <code>node</code> is of type <code>EXPERIMENT</code>. * * @param node * the <code>QTreeNode</code> whose HTML content will be integrated into the returned HTML * * @return the HTML content for the <code>textPane</code> */ private String getHTML(QTreeNode node) { Document doc = Document.createShell(""); Element body = doc.body().appendElement("form"); body.attr("style", "font-family: " + f.getFamily()); body.append(node.getHtml()).append(HTML_DIVIDER); boolean qSwitching = false; if (node.getType() == QUESTION) { QTreeNode parent = node.getParent(); if (parent != null && parent.getType() == CATEGORY && parent.containsAttribute(KEY_QUESTIONSWITCHING)) { qSwitching = Boolean.parseBoolean(parent.getAttribute(KEY_QUESTIONSWITCHING).getValue()); } } if (qSwitching && hasActivePreviousNode(node)) { body.appendChild(input(HTML_SUBMIT, KEY_BACKWARD, getLocalized("FOOTER_BACKWARD_CAPTION"))); } if (node.getType() == EXPERIMENT) { String subjCodeDesc; if (!node.containsAttribute(KEY_SUBJECT_CODE_CAP)) { subjCodeDesc = getLocalized("FOOTER_SUBJECT_CODE_CAPTION"); } else { subjCodeDesc = node.getAttribute(KEY_SUBJECT_CODE_CAP).getValue(); } String experimentCode = node.getAttribute(KEY_EXPERIMENT_CODE).getValue(); body.appendChild(input("hidden", KEY_EXPERIMENT_CODE, experimentCode)); Object[] row = {subjCodeDesc, input(null, KEY_SUBJECT_CODE, null)}; body.appendChild(table(null, row)); body.append(HTML_DIVIDER); body.appendChild(input(HTML_SUBMIT, KEY_FORWARD, getLocalized("FOOTER_START_EXPERIMENT_CAPTION"))); } else { String caption; if (hasActiveNextNode(node)) { caption = getLocalized("FOOTER_FORWARD_CAPTION"); } else { caption = getLocalized("FOOTER_END_CATEGORY_CAPTION"); } body.appendChild(input(HTML_SUBMIT, KEY_FORWARD, caption)); } return doc.outerHtml(); } /** * Saves the answers contained in the given <code>String</code> to the <code>QTreeNode</code> this * <code>QuestionViewPane</code> displays. Returns the name of the button that was clicked or <code>null</code> * if the data submission was not caused by a button click. * * @param data * the submitted form data * * @return {@link Constants#KEY_FORWARD}, {@link Constants#KEY_BACKWARD} or <code>null</code> */ private String saveAnswers(String data) { Map<String, ArrayList<String>> answers = new HashMap<>(); StringTokenizer st = new StringTokenizer(data, "&"); String result = null; Document doc = Jsoup.parse(textPane.getText()); Element form = doc.body().getElementsByTag("form").first(); Set<String> tags = new HashSet<>(Arrays.asList("input", "textarea", "select")); String htmlName = "name"; for (Element namedElement : form.getElementsByAttribute(htmlName)) { String name = namedElement.attr(htmlName); boolean isNotButton = !KEY_BACKWARD.equals(name) && !KEY_FORWARD.equals(name); if (tags.contains(namedElement.tagName()) && isNotButton) { answers.put(name, new ArrayList<>()); } } while (st.hasMoreTokens()) { String token = st.nextToken(); String key; String value; try { key = URLDecoder.decode(token.substring(0, token.indexOf('=')), "ISO-8859-1"); value = URLDecoder.decode(token.substring(token.indexOf('=') + 1, token.length()), "ISO-8859-1").trim(); } catch (UnsupportedEncodingException e) { System.err.println("Invalid encoding in HTML form. " + e.getMessage()); continue; } if (KEY_FORWARD.equals(key) || KEY_BACKWARD.equals(key)) { result = key; } else { ArrayList<String> answer = answers.get(key); if (answer != null && !value.isEmpty()) { answer.add(value); } } } for (Map.Entry<String, ArrayList<String>> entry : answers.entrySet()) { ArrayList<String> value = entry.getValue(); questionNode.setAnswers(entry.getKey(), value.toArray(new String[value.size()])); } return result; } /** * Returns whether the given <code>QTreeNode</code> has an active (meaning a node that may be visited by the * <code>ExperimentViewer</code>) that comes after <code>node</code>. * * @param node * the <code>QTreeNode</code> to be searched from * * @return true iff the given <code>QTreeNode</code> has an active next node */ private boolean hasActiveNextNode(QTreeNode node) { if (node.getType() == EXPERIMENT || node.getType() == CATEGORY) { if (PluginList.denyEnterNode(node) || node.getChildCount() == 0) { return false; } else { node = node.getChild(0); return !PluginList.denyEnterNode(node) || hasActiveNextNode(node); } } else if (node.getType() == QUESTION) { node = node.getNextSibling(); return node != null && (!PluginList.denyEnterNode(node) || hasActiveNextNode(node)); } else { return false; } } /** * Returns whether the given <code>QTreeNode</code> has an active (meaning a node that may be visited by the * <code>ExperimentViewer</code>) that comes before <code>node</code>. * * @param node * the <code>QTreeNode</code> to be searched from * * @return true iff the given <code>QTreeNode</code> has an active previous node */ private boolean hasActivePreviousNode(QTreeNode node) { if (node.getType() == QUESTION) { node = node.getPreviousSibling(); return node != null && (!PluginList.denyEnterNode(node) || hasActivePreviousNode(node)); } else { return false; } } /** * Adds an <code>ActionListener</code> to the <code>QuestionViewPane</code>. It will be notified when next/previous * buttons are clicked. The {@link java.awt.event.ActionEvent#getActionCommand()} method will return * either {@link Constants#KEY_FORWARD} or {@link Constants#KEY_BACKWARD} thereby indicating which button was * clicked. * * @param listener * the <code>ActionListener</code> to be added */ public void addActionListener(ActionListener listener) { actionListeners.add(listener); } /** * Removes the given <code>ActionListener</code> from this <code>QuestionViewPane</code>s listeners. * * @param listener * the <code>ActionListener</code> to be removed * * @return true iff the <code>ActionListener</code> was removed */ public boolean removeActionListener(ActionListener listener) { return actionListeners.remove(listener); } /** * Fires an <code>ActionEvent</code> of type <code>ACTION_PERFORMED</code> for the * <code>actionListener</code> if there is one. * * @param action * the action <code>String</code> to be passed to the <code>ActionListener</code> */ private void fireEvent(String action) { ActionEvent actionEvent = new ActionEvent(this, ActionEvent.ACTION_PERFORMED, action); if (!doNotFire.compareAndSet(true, false)) { actionListeners.forEach(l -> l.actionPerformed(actionEvent)); } } /** * Clicks the submit button of this <code>QuestionViewPane</code> if there is one. * An event will be fired because of this click. * * @return true iff there was a button to be clicked */ public boolean clickSubmit() { return clickSubmit(true); } /** * Clicks the submit button of this <code>QuestionViewPane</code> if there is one. * * @param fireEvent * whether to fire an event because of this click * * @return true iff there was a button to be clicked */ public boolean clickSubmit(boolean fireEvent) { if (submitButton != null && submitButton.getComponent() instanceof JButton) { doNotFire.set(!fireEvent); ((JButton) submitButton.getComponent()).doClick(); return true; } return false; } }