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