001package ca.uhn.fhir.narrative;
002
003/*
004 * #%L
005 * HAPI FHIR - Core Library
006 * %%
007 * Copyright (C) 2014 - 2016 University Health Network
008 * %%
009 * Licensed under the Apache License, Version 2.0 (the "License");
010 * you may not use this file except in compliance with the License.
011 * You may obtain a copy of the License at
012 * 
013 *      http://www.apache.org/licenses/LICENSE-2.0
014 * 
015 * Unless required by applicable law or agreed to in writing, software
016 * distributed under the License is distributed on an "AS IS" BASIS,
017 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
018 * See the License for the specific language governing permissions and
019 * limitations under the License.
020 * #L%
021 */
022import static org.apache.commons.lang3.StringUtils.isBlank;
023
024import java.io.File;
025import java.io.FileInputStream;
026import java.io.IOException;
027import java.io.InputStream;
028import java.io.Reader;
029import java.io.StringReader;
030import java.util.HashMap;
031import java.util.HashSet;
032import java.util.List;
033import java.util.Properties;
034
035import org.apache.commons.io.IOUtils;
036import org.apache.commons.io.input.ReaderInputStream;
037import org.apache.commons.lang3.StringUtils;
038import org.hl7.fhir.instance.model.api.IBaseDatatype;
039import org.hl7.fhir.instance.model.api.IBaseResource;
040import org.hl7.fhir.instance.model.api.INarrative;
041import org.thymeleaf.Arguments;
042import org.thymeleaf.Configuration;
043import org.thymeleaf.TemplateEngine;
044import org.thymeleaf.TemplateProcessingParameters;
045import org.thymeleaf.context.Context;
046import org.thymeleaf.dom.Document;
047import org.thymeleaf.dom.Element;
048import org.thymeleaf.dom.Node;
049import org.thymeleaf.exceptions.TemplateInputException;
050import org.thymeleaf.messageresolver.StandardMessageResolver;
051import org.thymeleaf.processor.IProcessor;
052import org.thymeleaf.processor.ProcessorResult;
053import org.thymeleaf.processor.attr.AbstractAttrProcessor;
054import org.thymeleaf.resourceresolver.IResourceResolver;
055import org.thymeleaf.standard.StandardDialect;
056import org.thymeleaf.standard.expression.IStandardExpression;
057import org.thymeleaf.standard.expression.IStandardExpressionParser;
058import org.thymeleaf.standard.expression.StandardExpressions;
059import org.thymeleaf.templatemode.StandardTemplateModeHandlers;
060import org.thymeleaf.templateparser.xmlsax.XhtmlAndHtml5NonValidatingSAXTemplateParser;
061import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
062import org.thymeleaf.templateresolver.TemplateResolver;
063
064import ca.uhn.fhir.context.ConfigurationException;
065import ca.uhn.fhir.context.FhirContext;
066import ca.uhn.fhir.model.api.IDatatype;
067import ca.uhn.fhir.model.api.annotation.ResourceDef;
068import ca.uhn.fhir.parser.DataFormatException;
069
070public abstract class BaseThymeleafNarrativeGenerator implements INarrativeGenerator {
071        private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(BaseThymeleafNarrativeGenerator.class);
072        private static final XhtmlAndHtml5NonValidatingSAXTemplateParser PARSER = new XhtmlAndHtml5NonValidatingSAXTemplateParser(1);
073
074        private Configuration myThymeleafConfig;
075        private boolean myApplyDefaultDatatypeTemplates = true;
076        private HashMap<Class<?>, String> myClassToName;
077        private boolean myCleanWhitespace = true;
078        private boolean myIgnoreFailures = true;
079        private boolean myIgnoreMissingTemplates = true;
080        private volatile boolean myInitialized;
081        private HashMap<String, String> myNameToNarrativeTemplate;
082        private TemplateEngine myProfileTemplateEngine;
083
084        public BaseThymeleafNarrativeGenerator() {
085                myThymeleafConfig = new Configuration();
086                myThymeleafConfig.addTemplateResolver(new ClassLoaderTemplateResolver());
087                myThymeleafConfig.addMessageResolver(new StandardMessageResolver());
088                myThymeleafConfig.setTemplateModeHandlers(StandardTemplateModeHandlers.ALL_TEMPLATE_MODE_HANDLERS);
089                myThymeleafConfig.initialize();
090        }
091
092
093
094        @Override
095        public void generateNarrative(FhirContext theContext, IBaseResource theResource, INarrative theNarrative) {
096                if (!myInitialized) {
097                        initialize(theContext);
098                }
099
100                String name = null;
101                if (name == null) {
102                        name = myClassToName.get(theResource.getClass());
103                }
104                if (name == null) {
105                        name = theContext.getResourceDefinition(theResource).getName().toLowerCase();
106                }
107
108                if (name == null) {
109                        if (myIgnoreMissingTemplates) {
110                                ourLog.debug("No narrative template available for resorce: {}", name);
111                                try {
112                                        theNarrative.setDivAsString("<div>No narrative template available for resource : " + name + "</div>");
113                                } catch (Exception e) {
114                                        // last resort..
115                                }
116                                theNarrative.setStatusAsString("empty");
117                                return;
118                        } else {
119                                throw new DataFormatException("No narrative template for class " + theResource.getClass().getCanonicalName());
120                        }
121                }
122
123                try {
124                        Context context = new Context();
125                        context.setVariable("resource", theResource);
126                        context.setVariable("fhirVersion", theContext.getVersion().getVersion().name());
127
128                        String result = myProfileTemplateEngine.process(name, context);
129
130                        if (myCleanWhitespace) {
131                                ourLog.trace("Pre-whitespace cleaning: ", result);
132                                result = cleanWhitespace(result);
133                                ourLog.trace("Post-whitespace cleaning: ", result);
134                        }
135                        
136                        if (isBlank(result)) {
137                                return;
138                        }
139
140                        theNarrative.setDivAsString(result);
141                        theNarrative.setStatusAsString("generated");
142                        return;
143                } catch (Exception e) {
144                        if (myIgnoreFailures) {
145                                ourLog.error("Failed to generate narrative", e);
146                                try {
147                                        theNarrative.setDivAsString("<div>No narrative available - Error: " + e.getMessage() + "</div>");
148                                } catch (Exception e1) {
149                                        // last resort..
150                                }
151                                theNarrative.setStatusAsString("empty");
152                                return;
153                        } else {
154                                throw new DataFormatException(e);
155                        }
156                }
157        }
158
159
160
161        protected abstract List<String> getPropertyFile();
162
163        private Document getXhtmlDOMFor(final Reader source) {
164                final Configuration configuration1 = myThymeleafConfig;
165                try {
166                        return PARSER.parseTemplate(configuration1, "input", source);
167                } catch (final Exception e) {
168                        throw new TemplateInputException("Exception during parsing of source", e);
169                }
170        }
171
172        private synchronized void initialize(FhirContext theContext) {
173                if (myInitialized) {
174                        return;
175                }
176
177                ourLog.info("Initializing narrative generator");
178
179                myClassToName = new HashMap<Class<?>, String>();
180                myNameToNarrativeTemplate = new HashMap<String, String>();
181
182                List<String> propFileName = getPropertyFile();
183
184                try {
185                        if (myApplyDefaultDatatypeTemplates) {
186                                loadProperties(DefaultThymeleafNarrativeGenerator.NARRATIVES_PROPERTIES);
187                        }
188                        for (String next : propFileName) {
189                                loadProperties(next);
190                        }
191                } catch (IOException e) {
192                        ourLog.info("Failed to load property file " + propFileName, e);
193                        throw new ConfigurationException("Can not load property file " + propFileName, e);
194                }
195
196                {
197                        myProfileTemplateEngine = new TemplateEngine();
198                        TemplateResolver resolver = new TemplateResolver();
199                        resolver.setResourceResolver(new ProfileResourceResolver());
200                        myProfileTemplateEngine.setTemplateResolver(resolver);
201                        StandardDialect dialect = new StandardDialect();
202                        HashSet<IProcessor> additionalProcessors = new HashSet<IProcessor>();
203                        additionalProcessors.add(new NarrativeAttributeProcessor(theContext));
204                        dialect.setAdditionalProcessors(additionalProcessors);
205                        myProfileTemplateEngine.setDialect(dialect);
206                        myProfileTemplateEngine.initialize();
207                }
208
209                myInitialized = true;
210        }
211
212        /**
213         * If set to <code>true</code> (which is the default), most whitespace will be trimmed from the generated narrative
214         * before it is returned.
215         * <p>
216         * Note that in order to preserve formatting, not all whitespace is trimmed. Repeated whitespace characters (e.g.
217         * "\n       \n       ") will be trimmed to a single space.
218         * </p>
219         */
220        public boolean isCleanWhitespace() {
221                return myCleanWhitespace;
222        }
223
224        /**
225         * If set to <code>true</code>, which is the default, if any failure occurs during narrative generation the
226         * generator will suppress any generated exceptions, and simply return a default narrative indicating that no
227         * narrative is available.
228         */
229        public boolean isIgnoreFailures() {
230                return myIgnoreFailures;
231        }
232
233        /**
234         * If set to true, will return an empty narrative block for any profiles where no template is available
235         */
236        public boolean isIgnoreMissingTemplates() {
237                return myIgnoreMissingTemplates;
238        }
239
240        private void loadProperties(String propFileName) throws IOException {
241                ourLog.debug("Loading narrative properties file: {}", propFileName);
242
243                Properties file = new Properties();
244
245                InputStream resource = loadResource(propFileName);
246                file.load(resource);
247                for (Object nextKeyObj : file.keySet()) {
248                        String nextKey = (String) nextKeyObj;
249                        if (nextKey.endsWith(".profile")) {
250                                String name = nextKey.substring(0, nextKey.indexOf(".profile"));
251                                if (isBlank(name)) {
252                                        continue;
253                                }
254
255                                String narrativePropName = name + ".narrative";
256                                String narrativeName = file.getProperty(narrativePropName);
257                                if (isBlank(narrativeName)) {
258                                        throw new ConfigurationException("Found property '" + nextKey + "' but no corresponding property '" + narrativePropName + "' in file " + propFileName);
259                                }
260
261                                if (StringUtils.isNotBlank(narrativeName)) {
262                                        String narrative = IOUtils.toString(loadResource(narrativeName));
263                                        myNameToNarrativeTemplate.put(name, narrative);
264                                }
265
266                        } else if (nextKey.endsWith(".class")) {
267
268                                String name = nextKey.substring(0, nextKey.indexOf(".class"));
269                                if (isBlank(name)) {
270                                        continue;
271                                }
272
273                                String className = file.getProperty(nextKey);
274
275                                Class<?> clazz;
276                                try {
277                                        clazz = Class.forName(className);
278                                } catch (ClassNotFoundException e) {
279                                        ourLog.debug("Unknown datatype class '{}' identified in narrative file {}", name, propFileName);
280                                        clazz = null;
281                                }
282
283                                if (clazz != null) {
284                                        myClassToName.put(clazz, name);
285                                }
286
287                        } else if (nextKey.endsWith(".narrative")) {
288                                String name = nextKey.substring(0, nextKey.indexOf(".narrative"));
289                                if (isBlank(name)) {
290                                        continue;
291                                }
292                                String narrativePropName = name + ".narrative";
293                                String narrativeName = file.getProperty(narrativePropName);
294                                if (StringUtils.isNotBlank(narrativeName)) {
295                                        String narrative = IOUtils.toString(loadResource(narrativeName));
296                                        myNameToNarrativeTemplate.put(name, narrative);
297                                }
298                                continue;
299                        } else if (nextKey.endsWith(".title")) {
300                                ourLog.debug("Ignoring title property as narrative generator no longer generates titles: {}", nextKey);
301                        } else {
302                                throw new ConfigurationException("Invalid property name: " + nextKey);
303                        }
304
305                }
306        }
307
308        private InputStream loadResource(String name) throws IOException {
309                if (name.startsWith("classpath:")) {
310                        String cpName = name.substring("classpath:".length());
311                        InputStream resource = DefaultThymeleafNarrativeGenerator.class.getResourceAsStream(cpName);
312                        if (resource == null) {
313                                resource = DefaultThymeleafNarrativeGenerator.class.getResourceAsStream("/" + cpName);
314                                if (resource == null) {
315                                        throw new IOException("Can not find '" + cpName + "' on classpath");
316                                }
317                        }
318                        return resource;
319                } else if (name.startsWith("file:")) {
320                        File file = new File(name.substring("file:".length()));
321                        if (file.exists() == false) {
322                                throw new IOException("File not found: " + file.getAbsolutePath());
323                        }
324                        return new FileInputStream(file);
325                } else {
326                        throw new IOException("Invalid resource name: '" + name + "' (must start with classpath: or file: )");
327                }
328        }
329
330        /**
331         * If set to <code>true</code> (which is the default), most whitespace will be trimmed from the generated narrative
332         * before it is returned.
333         * <p>
334         * Note that in order to preserve formatting, not all whitespace is trimmed. Repeated whitespace characters (e.g.
335         * "\n       \n       ") will be trimmed to a single space.
336         * </p>
337         */
338        public void setCleanWhitespace(boolean theCleanWhitespace) {
339                myCleanWhitespace = theCleanWhitespace;
340        }
341
342        /**
343         * If set to <code>true</code>, which is the default, if any failure occurs during narrative generation the
344         * generator will suppress any generated exceptions, and simply return a default narrative indicating that no
345         * narrative is available.
346         */
347        public void setIgnoreFailures(boolean theIgnoreFailures) {
348                myIgnoreFailures = theIgnoreFailures;
349        }
350
351        /**
352         * If set to true, will return an empty narrative block for any profiles where no template is available
353         */
354        public void setIgnoreMissingTemplates(boolean theIgnoreMissingTemplates) {
355                myIgnoreMissingTemplates = theIgnoreMissingTemplates;
356        }
357
358        static String cleanWhitespace(String theResult) {
359                StringBuilder b = new StringBuilder();
360                boolean inWhitespace = false;
361                boolean betweenTags = false;
362                boolean lastNonWhitespaceCharWasTagEnd = false;
363                boolean inPre = false;
364                for (int i = 0; i < theResult.length(); i++) {
365                        char nextChar = theResult.charAt(i);
366                        if (inPre) {
367                                b.append(nextChar);
368                                continue;
369                        } else if (nextChar == '>') {
370                                b.append(nextChar);
371                                betweenTags = true;
372                                lastNonWhitespaceCharWasTagEnd = true;
373                                continue;
374                        } else if (nextChar == '\n' || nextChar == '\r') {
375                                // if (inWhitespace) {
376                                // b.append(' ');
377                                // inWhitespace = false;
378                                // }
379                                continue;
380                        }
381
382                        if (betweenTags) {
383                                if (Character.isWhitespace(nextChar)) {
384                                        inWhitespace = true;
385                                } else if (nextChar == '<') {
386                                        if (inWhitespace && !lastNonWhitespaceCharWasTagEnd) {
387                                                b.append(' ');
388                                        }
389                                        inWhitespace = false;
390                                        b.append(nextChar);
391                                        inWhitespace = false;
392                                        betweenTags = false;
393                                        lastNonWhitespaceCharWasTagEnd = false;
394                                        if (i + 3 < theResult.length()) {
395                                                char char1 = Character.toLowerCase(theResult.charAt(i + 1));
396                                                char char2 = Character.toLowerCase(theResult.charAt(i + 2));
397                                                char char3 = Character.toLowerCase(theResult.charAt(i + 3));
398                                                char char4 = Character.toLowerCase((i + 4 < theResult.length()) ? theResult.charAt(i + 4) : ' ');
399                                                if (char1 == 'p' && char2 == 'r' && char3 == 'e') {
400                                                        inPre = true;
401                                                } else if (char1 == '/' && char2 == 'p' && char3 == 'r'&&char4=='e') {
402                                                        inPre = false;
403                                                }
404                                        }
405                                } else {
406                                        lastNonWhitespaceCharWasTagEnd = false;
407                                        if (inWhitespace) {
408                                                b.append(' ');
409                                                inWhitespace = false;
410                                        }
411                                        b.append(nextChar);
412                                }
413                        } else {
414                                b.append(nextChar);
415                        }
416                }
417                return b.toString();
418        }
419        
420        public class NarrativeAttributeProcessor extends AbstractAttrProcessor {
421
422                private FhirContext myContext;
423
424                protected NarrativeAttributeProcessor(FhirContext theContext) {
425                        super("narrative");
426                        myContext = theContext;
427                }
428
429                @Override
430                public int getPrecedence() {
431                        return 0;
432                }
433
434                @SuppressWarnings("unchecked")
435                @Override
436                protected ProcessorResult processAttribute(Arguments theArguments, Element theElement, String theAttributeName) {
437                        final String attributeValue = theElement.getAttributeValue(theAttributeName);
438
439                        final Configuration configuration = theArguments.getConfiguration();
440                        final IStandardExpressionParser expressionParser = StandardExpressions.getExpressionParser(configuration);
441
442                        final IStandardExpression expression = expressionParser.parseExpression(configuration, theArguments, attributeValue);
443                        final Object value = expression.execute(configuration, theArguments);
444
445                        theElement.removeAttribute(theAttributeName);
446                        theElement.clearChildren();
447
448                        if (value == null) {
449                                return ProcessorResult.ok();
450                        }
451
452                        Context context = new Context();
453                        context.setVariable("fhirVersion", myContext.getVersion().getVersion().name());
454                        context.setVariable("resource", value);
455
456                        String name = null;
457                        if (value != null) {
458                                Class<? extends Object> nextClass = value.getClass();
459                                do {
460                                        name = myClassToName.get(nextClass);
461                                        nextClass = nextClass.getSuperclass();
462                                } while (name == null && nextClass.equals(Object.class) == false);
463                                
464                                if (name == null) {
465                                        if (value instanceof IBaseResource) {
466                                                name = myContext.getResourceDefinition((Class<? extends IBaseResource>) value).getName();
467                                        } else if (value instanceof IDatatype) {
468                                                name = value.getClass().getSimpleName();
469                                                name = name.substring(0, name.length() - 2);
470                                        } else if (value instanceof IBaseDatatype) {
471                                                name = value.getClass().getSimpleName();
472                                                if (name.endsWith("Type")) {
473                                                        name = name.substring(0, name.length() - 4);
474                                                }
475                                        } else {
476                                                throw new DataFormatException("Don't know how to determine name for type: " + value.getClass());
477                                        }
478                                        name = name.toLowerCase();
479                                        if (!myNameToNarrativeTemplate.containsKey(name)) {
480                                                name = null;
481                                        }
482                                }
483                        }
484
485                        if (name == null) {
486                                if (myIgnoreMissingTemplates) {
487                                        ourLog.debug("No narrative template available for type: {}", value.getClass());
488                                        return ProcessorResult.ok();
489                                } else {
490                                        throw new DataFormatException("No narrative template for class " + value.getClass());
491                                }
492                        }
493
494                        String result = myProfileTemplateEngine.process(name, context);
495                        String trim = result.trim();
496                        if (!isBlank(trim + "AAA")) {
497                                Document dom = getXhtmlDOMFor(new StringReader(trim));
498                                
499                                Element firstChild = (Element) dom.getFirstChild();
500                                for (int i = 0; i < firstChild.getChildren().size(); i++) {
501                                        Node next = firstChild.getChildren().get(i);
502                                        if (i == 0 && firstChild.getChildren().size() == 1) {
503                                                if (next instanceof org.thymeleaf.dom.Text) {
504                                                        org.thymeleaf.dom.Text nextText = (org.thymeleaf.dom.Text) next;
505                                                        nextText.setContent(nextText.getContent().trim());
506                                                }
507                                        }
508                                        theElement.addChild(next);
509                                }
510                                
511                        }
512                        
513
514                        return ProcessorResult.ok();
515                }
516
517        }
518
519        // public String generateString(Patient theValue) {
520        //
521        // Context context = new Context();
522        // context.setVariable("resource", theValue);
523        // String result =
524        // myProfileTemplateEngine.process("ca/uhn/fhir/narrative/Patient.html",
525        // context);
526        //
527        // ourLog.info("Result: {}", result);
528        //
529        // return result;
530        // }
531
532        private final class ProfileResourceResolver implements IResourceResolver {
533                @Override
534                public String getName() {
535                        return getClass().getCanonicalName();
536                }
537
538                @Override
539                public InputStream getResourceAsStream(TemplateProcessingParameters theTemplateProcessingParameters, String theName) {
540                        String template = myNameToNarrativeTemplate.get(theName);
541                        if (template == null) {
542                                ourLog.info("No narative template for resource profile: {}", theName);
543                                return new ReaderInputStream(new StringReader(""));
544                        }
545                        return new ReaderInputStream(new StringReader(template));
546                }
547        }
548
549}