package com.radicaldynamic.groupinform.xform; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.UUID; import android.util.Log; import com.mycila.xmltool.CallBack; import com.mycila.xmltool.XMLDoc; import com.mycila.xmltool.XMLDocumentException; import com.mycila.xmltool.XMLTag; import com.radicaldynamic.groupinform.R; import com.radicaldynamic.groupinform.application.Collect; import com.radicaldynamic.groupinform.utilities.StringUtils; public class FormReader { private static final String t = "FormReader: "; private XMLTag mForm; // The "form" as it was loaded by xmltool private String mTitle; // The title of the template private String mInstanceRoot; // The name of the instance root element private String mInstanceRootId; // The name of the instance root ID attribute private String mDefaultPrefix; // The name of the default XForm prefix (needed for navigation) private ArrayList<String> mFieldList = new ArrayList<String>(); // State of binds, fields, instances and translations private ArrayList<Bind> mBinds = new ArrayList<Bind>(); private ArrayList<Field> mFields = new ArrayList<Field>(); private ArrayList<Instance> mInstance = new ArrayList<Instance>(); private ArrayList<Translation> mTranslations = new ArrayList<Translation>(); // Indexed by XMLTool tag location (e.g., *[2]/*[2]/*[3]) private HashMap<String, Field> mFlatFieldIndex = new HashMap<String, Field>(); { // List of valid fields that we can handle Collections.addAll(mFieldList, "group", "input", "item", "repeat", "select", "select1", "trigger", "upload"); } /* * Used to read in a form definition for manipulation by the Form Builder. */ public FormReader(InputStream is, boolean retainFlatFieldIndex) throws Exception { mForm = XMLDoc.from(is, false); /* * Ensure that namespaces are as expected by loading a default document with a good configuration, * removing the body of the template and pushing the contents of the recently loaded template into it. * * This works around cases where people load documents from things like KoBo Form Designer that * doesn't include the xmlns attribute as expected. */ if (mForm.getPefix(XForm.Value.XMLNS_XFORMS) == null || mForm.getPefix(XForm.Value.XMLNS_XFORMS).length() == 0) { try { InputStream xis = Collect.getInstance().getResources().openRawResource(R.raw.xform_template); XMLTag tag = XMLDoc.from(xis, false); xis.close(); tag.gotoRoot().deleteChilds(); for (XMLTag child : mForm.gotoRoot().getInnerDocument().getChilds()) { tag.gotoRoot().addTag(child); } // Reinitialize our XML object now that the <h:head...> element contains the expected namespaces mForm = XMLDoc.from(tag.toString(), false); } catch (IOException e) { e.printStackTrace(); throw e; } } mDefaultPrefix = mForm.getPefix(XForm.Value.XMLNS_XFORMS); /* * Initalize new forms * * This hack is in place in case a new form has been created but fails the first save attempt, * thereby creating a form that will not contain an instance root. Since instance roots are * expected, the lack of one will crash this application. * * FIXME: eventually remove this hack */ if (mForm.gotoRoot().gotoTag("h:head/%1$s:model/%1$s:instance", mDefaultPrefix).getChildCount() == 0) { // This might now be rigorous enough for i18n input String formName = Collect.getInstance().getFormBuilderState().getFormDefinition().getName(); String instanceRoot = formName.replaceAll("\\s", "").replaceAll("[^a-zA-Z0-9_]", ""); String instanceRootId = UUID.randomUUID().toString().replaceAll("[^a-zA-Z0-9]", ""); // Just in case the form name did not have anything useful in it with which to generate a sane instance root if (instanceRoot.length() == 0) { if (Collect.Log.WARN) Log.w(Collect.LOGTAG, t + "unable to construct instance root from form getName() of " + formName); instanceRoot = UUID.randomUUID().toString().replaceAll("[^a-zA-Z0-9]", ""); } // See "Form ID Guidelines" (id is preferred vs. xmlns) http://code.google.com/p/opendatakit/wiki/XFormDesignGuidelines mForm.gotoRoot().gotoTag("h:head/%1$s:model/%1$s:instance", mDefaultPrefix); mForm.addTag(XMLDoc.from("<" + instanceRoot + " id=\"" + instanceRootId + "\"></" + instanceRoot + ">", false)); } else { setTitle(mForm.gotoRoot().gotoTag("h:head/h:title").getInnerText()); } mInstanceRoot = mForm.gotoRoot().gotoTag("h:head/%1$s:model/%1$s:instance", mDefaultPrefix).gotoChild().getCurrentTagName(); try { mInstanceRootId = mForm.gotoRoot().gotoTag("h:head/%1$s:model/%1$s:instance", mDefaultPrefix).gotoChild().getAttribute(XForm.Attribute.ID); } catch (XMLDocumentException e) { if (Collect.Log.WARN) Log.w(Collect.LOGTAG, t + e.toString()); try { // It's possible that the ID attribute doesn't exist -- if this is the case, try and use the old-style XMLNS attribute mInstanceRootId = mForm.gotoRoot().gotoTag("h:head/%1$s:model/%1$s:instance", mDefaultPrefix).gotoChild().getAttribute(XForm.Attribute.XML_NAMESPACE); } catch (XMLDocumentException e1) { if (Collect.Log.ERROR) Log.e(Collect.LOGTAG, t + e1.toString()); e1.printStackTrace(); throw new Exception("Unable to find id or xmlns attribute for instance.\n\nPlease contact our support team with this message at support@groupcomplete.com"); } } if (Collect.Log.VERBOSE) Log.v(Collect.LOGTAG, t + "default prefix for form: " + mDefaultPrefix); if (Collect.Log.VERBOSE) Log.v(Collect.LOGTAG, t + "instance root element name: " + mInstanceRoot); if (Collect.Log.VERBOSE) Log.v(Collect.LOGTAG, t + "instance root ID: " + mInstanceRootId); parseForm(); // Free immediately (unless we need it) if (retainFlatFieldIndex == false) mFlatFieldIndex.clear(); } public HashMap<String, Field> getFlatFieldIndex() { return mFlatFieldIndex; } public ArrayList<Bind> getBinds() { return mBinds; } public ArrayList<Field> getFields() { return mFields; } public ArrayList<Instance> getInstance() { return mInstance; } public String getInstanceRoot() { return mInstanceRoot; } public String getInstanceRootId() { if (mInstanceRootId == null || mInstanceRootId.length() == 0) { if (Collect.Log.WARN) Log.w(Collect.LOGTAG, t + "missing instance root ID attribute, generating random string"); mInstanceRootId = UUID.randomUUID().toString().replaceAll("[^a-zA-Z0-9]", ""); } return mInstanceRootId; } public ArrayList<Translation> getTranslations() { return mTranslations; } /* * Trigger method for doing all of the actual work */ private void parseForm() throws Exception { if (mForm.gotoRoot().gotoTag("h:head/%1$s:model", mDefaultPrefix).hasTag("%1$s:itext", mDefaultPrefix)) { if (Collect.Log.DEBUG) Log.d(Collect.LOGTAG, t + "parsing itext form translations..."); parseFormTranslations(mForm.gotoRoot().gotoTag("h:head/%1$s:model/%1$s:itext", mDefaultPrefix)); } else { if (Collect.Log.DEBUG) Log.d(Collect.LOGTAG, t + "no form translations to parse"); } if (Collect.Log.DEBUG) Log.d(Collect.LOGTAG, t + "parsing form binds..."); parseFormBinds(mForm.gotoRoot().gotoTag("h:head/%1$s:model", mDefaultPrefix)); if (Collect.Log.DEBUG) Log.d(Collect.LOGTAG, t + "parsing form body..."); parseFormBody(mForm.gotoRoot().gotoTag("h:body")); if (Collect.Log.DEBUG) Log.d(Collect.LOGTAG, t + "parsing form instance..."); parseFormInstance(mForm.gotoRoot().gotoTag("h:head/%1$s:model/%1$s:instance", mDefaultPrefix).gotoChild(), "/" + mInstanceRoot); } /* * Recursively iterate over the form fields, creating objects to represent these fields */ private void parseFormBody(XMLTag tag) throws Exception { String ctl = tag.getCurrentTagLocation(); if (Collect.Log.VERBOSE) Log.v(Collect.LOGTAG, t + "visiting <" + tag.getCurrentTagName() + "> at " + ctl); // Is the tag name a field type that we understand? if (mFieldList.contains(tag.getCurrentTagName())) { Field f = null; if (ctl.split("/").length == 2) { // Top level field based on a current tag location of say *[2]/*[1] f = new Field(tag, mBinds, mInstanceRoot, null); mFields.add(f); } else { // Should belong to an existing parent field that has already been parsed /* * Attempt to look up parent field from index. E.g., if current field is *[2]/*[1]/*[1] * then we're looking for a field with the location *[2]/*[1]. */ String ptl = StringUtils.join(ctl.split("/"), "/", ctl.split("/").length - 1); Field p = mFlatFieldIndex.get(ptl); if (p == null) { if (Collect.Log.ERROR) Log.e(Collect.LOGTAG, t + "could not find parent!"); throw new Exception("Could not find parent tag at " + ptl + ".\n\nPlease contact our support team with this message at support@groupcomplete.com"); } else { f = new Field(tag, mBinds, mInstanceRoot, p); p.getChildren().add(f); } } mFlatFieldIndex.put(ctl, f); } else if (mFields.size() > 0) { String ptl = StringUtils.join(ctl.split("/"), "/", ctl.split("/").length - 1); Field p = mFlatFieldIndex.get(ptl); if (p == null) { if (Collect.Log.ERROR) Log.e(Collect.LOGTAG, t + "could not find parent!"); throw new Exception("Could not find parent tag at " + ptl + ".\n\nPlease contact our support team with this message at support@groupcomplete.com"); } if (tag.getCurrentTagName().equals("label")) { // Handle translated/untranslated labels if (tag.hasAttribute(XForm.Attribute.REFERENCE)) p.setLabel(tag.getAttribute(XForm.Attribute.REFERENCE)); else p.setLabel(tag.getInnerText()); } else if (tag.getCurrentTagName().equals("hint")) { // Handle translated/untranslated hints if (tag.hasAttribute(XForm.Attribute.REFERENCE)) p.setHint(tag.getAttribute(XForm.Attribute.REFERENCE)); else p.setHint(tag.getInnerText()); } else if (tag.getCurrentTagName().equals("value")) { // Handle select item values p.setItemValue(tag.getInnerText()); } } // Parse children of those tags in our "field list" and the top-level tag h:body if ((mFieldList.contains(tag.getCurrentTagName()) || tag.getCurrentTagName().equals("h:body")) && tag.getChildCount() > 0) { tag.forEachChild(new CallBack() { @Override public void execute(XMLTag arg0) { try { parseFormBody(arg0); } catch (Exception e) { e.printStackTrace(); } } }); } } /* * Recursively parse the form translations, adding objects to mTranslationState to represent them */ private void parseFormTranslations(XMLTag tag) { if (tag.getCurrentTagName().equals("translation")) { if (Collect.Log.VERBOSE) Log.v(Collect.LOGTAG, t + "adding translations for " + tag.getAttribute(XForm.Attribute.LANGUAGE)); Translation t = new Translation(tag.getAttribute(XForm.Attribute.LANGUAGE)); // The first translation to be parsed is considered the default/fallback translation for the form if (mTranslations.isEmpty()) t.setFallback(true); mTranslations.add(t); } else if (tag.getCurrentTagName().equals("text")) { if (Collect.Log.VERBOSE) Log.v(Collect.LOGTAG, t + "adding translation ID " + tag.getAttribute(XForm.Attribute.ID)); mTranslations.get(mTranslations.size() - 1).getTexts().add(new Translation(tag.getAttribute(XForm.Attribute.ID), null)); } else if (tag.getCurrentTagName().equals("value")) { if (Collect.Log.VERBOSE) Log.v(Collect.LOGTAG, t + "adding translation: " + tag.getInnerText()); mTranslations .get(mTranslations.size() - 1).getTexts() .get(mTranslations.get(mTranslations.size() - 1).getTexts().size() - 1) .setValue(tag.getInnerText()); } if (tag.getChildCount() > 0) { tag.forEachChild(new CallBack() { @Override public void execute(XMLTag arg0) { parseFormTranslations(arg0); } }); } } /* * Go through the list of binds and build objects to represent them. * Binds are associated with field objects when the field is instantiated. */ private void parseFormBinds(XMLTag tag) { tag.forEachChild(new CallBack() { @Override public void execute(XMLTag arg0) { if (arg0.getCurrentTagName().equals("bind")) mBinds.add(new Bind(arg0, mInstanceRoot)); } }); } /* * Recursively parse and use the information supplied in the form instance * to supplement the field objects in mFields */ private void parseFormInstance(XMLTag tag, final String instancePath) { /* * If the instancePath does not currently point to the instance root * (this only happens the first time this method runs) */ if (instancePath.equals("/" + mInstanceRoot) == false) { Instance newInstance = new Instance(instancePath, tag.getInnerText(), tag.getCurrentTagLocation(), mBinds); applyInstanceToField(null, newInstance); if (tag.getCurrentTagLocation().split("/").length == 5) { // Add a top level instance mInstance.add(newInstance); } else { attachChildToParentInstance(newInstance, null); } } tag.forEachChild(new CallBack() { @Override public void execute(XMLTag arg0) { parseFormInstance(arg0, instancePath + "/" + arg0.getCurrentTagName()); } }); } /* * Attempts to apply an instance to an existing field * * Returns true to indicate that application was successful and false * to indicate that it was not (e.g., the instance is hidden) */ private boolean applyInstanceToField(Field field, final Instance instance) { Iterator<Field> it; if (field == null) { it = mFields.iterator(); } else { if (field.hasXPath() && field.getXPath().equals(instance.getXPath())) { if (Collect.Log.VERBOSE) Log.v(Collect.LOGTAG, t + "instance matched with field object via " + instance.getXPath()); field.setInstance(instance); field.getInstance().setField(field); return true; } it = field.getChildren().iterator(); } while (it.hasNext()) { Field c = it.next(); if (applyInstanceToField(c, instance)) { return true; } } return false; } /* * For instances that are nested within other instances * (e.g., those that are probably nested in a repeated group somewhere) */ private boolean attachChildToParentInstance(Instance child, Instance incomingParent) { Iterator<Instance> it = null; if (incomingParent == null) it = mInstance.iterator(); else it = incomingParent.getChildren().iterator(); while (it.hasNext()) { Instance parent = it.next(); if (child.getLocation().split("/").length - parent.getLocation().split("/").length == 1 && parent.getLocation().equals(child.getLocation().substring(0, parent.getLocation().length()))) { child.setParent(parent); parent.getChildren().add(child); return true; } if (!parent.getChildren().isEmpty()) attachChildToParentInstance(child, parent); } return false; } public void setTitle(String mTitle) { this.mTitle = mTitle; } public String getTitle() { return mTitle; } }