Skip to content

Commit

Permalink
Merge branch 'main' into filter-data-by-selected-locations
Browse files Browse the repository at this point in the history
  • Loading branch information
pld authored Jun 11, 2024
2 parents 70443ba + ac0fa3d commit 92b2d85
Show file tree
Hide file tree
Showing 6 changed files with 738 additions and 5 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added
- Added a new class (PdfGenerator) for generating PDF documents from HTML content using Android's WebView and PrintManager
- Introduced a new class (HtmlPopulator) to populate HTML templates with data from a Questionnaire Response

## [1.1.0] - 2024-02-15

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/*
* Copyright 2021-2024 Ona Systems, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.smartregister.fhircore.engine.pdf

import java.util.regex.Matcher
import java.util.regex.Pattern
import org.hl7.fhir.r4.model.BaseDateTimeType
import org.hl7.fhir.r4.model.QuestionnaireResponse
import org.smartregister.fhircore.engine.util.extension.allItems
import org.smartregister.fhircore.engine.util.extension.formatDate
import org.smartregister.fhircore.engine.util.extension.makeItReadable
import org.smartregister.fhircore.engine.util.extension.valueToString

/**
* HtmlPopulator class is responsible for processing an HTML template by replacing custom tags with
* data from a QuestionnaireResponse. The class uses various regex patterns to find and replace
* custom tags such as @is-not-empty, @answer-as-list, @answer, @submitted-date, and @contains.
*
* @property questionnaireResponse The QuestionnaireResponse object containing data for replacement.
*/
class HtmlPopulator(
private val questionnaireResponse: QuestionnaireResponse,
) {

// Map to store questionnaire response items keyed by their linkId
private val questionnaireResponseItemMap =
questionnaireResponse.allItems.associateBy(
keySelector = { it.linkId },
valueTransform = { it.answer },
)

/**
* Populates the provided HTML template with data from the QuestionnaireResponse.
*
* After a tag got replaced, the current index will be used twice, adding an increment will skip a
* character right after the current index.
*
* @param rawHtml The raw HTML template containing custom tags to be replaced.
* @return The populated HTML with all custom tags replaced by corresponding data.
*/
fun populateHtml(rawHtml: String): String {
val html = StringBuilder(rawHtml)
var i = 0
while (i < html.length) {
when {
html.startsWith("@is-not-empty", i) -> {
val matcher = isNotEmptyPattern.matcher(html.substring(i))
if (matcher.find()) processIsNotEmpty(i, html, matcher) else i++
}
html.startsWith("@answer-as-list", i) -> {
val matcher = answerAsListPattern.matcher(html.substring(i))
if (matcher.find()) processAnswerAsList(i, html, matcher) else i++
}
html.startsWith("@answer", i) -> {
val matcher = answerPattern.matcher(html.substring(i))
if (matcher.find()) processAnswer(i, html, matcher) else i++
}
html.startsWith("@submitted-date", i) -> {
val matcher = submittedDatePattern.matcher(html.substring(i))
if (matcher.find()) processSubmittedDate(i, html, matcher) else i++
}
html.startsWith("@contains", i) -> {
val matcher = containsPattern.matcher(html.substring(i))
if (matcher.find()) processContains(i, html, matcher) else i++
}
else -> i++
}
}
return html.toString()
}

/**
* Processes the @is-not-empty tag by checking if the specified linkId has an answer. Replaces the
* tag with the content if the answer exists, otherwise removes the tag.
*
* @param i The starting index of the tag in the HTML.
* @param html The StringBuilder containing the HTML.
* @param matcher The Matcher object for the regex pattern.
*/
private fun processIsNotEmpty(i: Int, html: StringBuilder, matcher: Matcher) {
val linkId = matcher.group(1)
val content = matcher.group(2) ?: ""
val doesAnswerExist = questionnaireResponseItemMap.getOrDefault(linkId, listOf()).isNotEmpty()
if (doesAnswerExist) {
html.replace(i, matcher.end() + i, content)
// Start index is the index of '@' symbol, End index is the index after the ')' symbol.
// For example: @is-not-empty('link')Text@is-not-empty('link')
// The args we put the the replace function: The Start index is 0, the '@' symbol. The
// End index is the index after the ')' symbol.
// Note: The ones that are going to be replaced are from the Start index which is an '@' of
// the first tag, until the index before the End index which is an ')' of the second tag.
} else {
html.replace(i, matcher.end() + i, "")
}
}

/**
* Processes the @answer-as-list tag by replacing it with a list of answers for the specified
* linkId.
*
* @param i The starting index of the tag in the HTML.
* @param html The StringBuilder containing the HTML.
* @param matcher The Matcher object for the regex pattern.
*/
private fun processAnswerAsList(i: Int, html: StringBuilder, matcher: Matcher) {
val linkId = matcher.group(1)
val answerAsList =
questionnaireResponseItemMap.getOrDefault(linkId, listOf()).joinToString(separator = "") {
answer ->
"<li>${answer.value.valueToString()}</li>"
}
html.replace(i, matcher.end() + i, answerAsList)
}

/**
* Processes the @answer tag by replacing it with the answer for the specified linkId.
*
* @param i The starting index of the tag in the HTML.
* @param html The StringBuilder containing the HTML.
* @param matcher The Matcher object for the regex pattern.
*/
private fun processAnswer(i: Int, html: StringBuilder, matcher: Matcher) {
val linkId = matcher.group(1)
val dateFormat = matcher.group(2)
val answer =
questionnaireResponseItemMap.getOrDefault(linkId, listOf()).joinToString { answer ->
if (dateFormat == null) {
answer.value.valueToString()
} else answer.value.valueToString(dateFormat)
}
html.replace(i, matcher.end() + i, answer)
}

/**
* Processes the @submitted-date tag by replacing it with the formatted date.
*
* @param i The starting index of the tag in the HTML.
* @param html The StringBuilder containing the HTML.
* @param matcher The Matcher object for the regex pattern.
*/
private fun processSubmittedDate(i: Int, html: StringBuilder, matcher: Matcher) {
val dateFormat = matcher.group(1)
val date =
if (dateFormat == null) {
questionnaireResponse.meta.lastUpdated.formatDate()
} else {
questionnaireResponse.meta.lastUpdated.formatDate(dateFormat)
}
html.replace(i, matcher.end() + i, date)
}

/**
* Processes the @contains tag by checking if the specified linkId contains the indicator.
* Replaces the tag with the content if the indicator is found, otherwise removes the tag.
*
* @param i The starting index of the tag in the HTML.
* @param html The StringBuilder containing the HTML.
* @param matcher The Matcher object for the regex pattern.
*/
private fun processContains(i: Int, html: StringBuilder, matcher: Matcher) {
val linkId = matcher.group(1)
val indicator = matcher.group(2) ?: ""
val content = matcher.group(3) ?: ""
val doesAnswerExist =
questionnaireResponseItemMap.getOrDefault(linkId, listOf()).any {
when {
it.hasValueCoding() -> it.valueCoding.code == indicator
it.hasValueStringType() -> it.valueStringType.value.contains(indicator)
it.hasValueIntegerType() -> it.valueIntegerType.value == indicator.toInt()
it.hasValueDecimalType() -> it.valueDecimalType.value == indicator.toBigDecimal()
it.hasValueBooleanType() -> it.valueBooleanType.value == indicator.toBoolean()
it.hasValueQuantity() ->
"${it.valueQuantity.value.toPlainString()} ${it.valueQuantity.unit}" == indicator
it.hasValueDateType() || it.hasValueDateTimeType() ->
(it.value as BaseDateTimeType).value.makeItReadable() == indicator
else -> false
}
}
if (doesAnswerExist) {
html.replace(i, matcher.end() + i, content)
} else {
html.replace(i, matcher.end() + i, "")
}
}

companion object {
// Compile regex patterns for different tags
private val isNotEmptyPattern =
Pattern.compile("@is-not-empty\\('([^']+)'\\)((?s).*?)@is-not-empty\\('\\1'\\)")
private val answerAsListPattern = Pattern.compile("@answer-as-list\\('([^']+)'\\)")
private val answerPattern = Pattern.compile("@answer\\('([^']+)'(?:,'([^']+)')?\\)")
private val submittedDatePattern = Pattern.compile("@submitted-date(?:\\('([^']+)'\\))?")
private val containsPattern =
Pattern.compile("@contains\\('([^']+)','([^']+)'\\)((?s).*?)@contains\\('\\1'\\)")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ fun yesterday(): Date = DateTimeType.now().apply { add(Calendar.DATE, -1) }.valu

fun today(): Date = DateTimeType.today().value

fun Date.formatDate(pattern: String): String =
fun Date.formatDate(pattern: String = "dd-MMM-yyyy"): String =
SimpleDateFormat(pattern, Locale.ENGLISH).format(this)

fun Date.isToday() = this.formatDate(SDF_YYYY_MM_DD) == today().formatDate(SDF_YYYY_MM_DD)
Expand All @@ -55,11 +55,11 @@ fun SimpleDateFormat.tryParse(date: String): Date? =
.runCatching { SimpleDateFormat(this.toPattern(), Locale.ENGLISH).parse(date) }
.getOrNull()

fun Date?.makeItReadable(): String {
fun Date?.makeItReadable(pattern: String = "dd-MMM-yyyy"): String {
return if (this == null) {
"N/A"
} else {
SimpleDateFormat("dd-MMM-yyyy", Locale.getDefault()).run { format(this@makeItReadable) }
SimpleDateFormat(pattern, Locale.getDefault()).run { format(this@makeItReadable) }
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,25 @@ private fun List<QuestionnaireResponseItemComponent>.clearText() {
}
}
}

/** Pre-order list of all questionnaire response items in the questionnaire. */
val QuestionnaireResponse.allItems: List<QuestionnaireResponse.QuestionnaireResponseItemComponent>
get() = item.flatMap { it.descendant }

/**
* Pre-order list of descendants of the questionnaire response item (inclusive of the current item).
*/
val QuestionnaireResponse.QuestionnaireResponseItemComponent.descendant:
List<QuestionnaireResponse.QuestionnaireResponseItemComponent>
get() =
mutableListOf<QuestionnaireResponse.QuestionnaireResponseItemComponent>().also {
appendDescendantTo(it)
}

private fun QuestionnaireResponse.QuestionnaireResponseItemComponent.appendDescendantTo(
output: MutableList<QuestionnaireResponse.QuestionnaireResponseItemComponent>,
) {
output.add(this)
item.forEach { it.appendDescendantTo(output) }
answer.forEach { answer -> answer.item.forEach { it.appendDescendantTo(output) } }
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,10 @@ const val REFERENCE = "reference"
const val PARTOF = "part-of"
private val fhirR4JsonParser = FhirContext.forR4Cached().getCustomJsonParser()

fun Base?.valueToString(): String {
fun Base?.valueToString(datePattern: String = "dd-MMM-yyyy"): String {
return when {
this == null -> return ""
this.isDateTime -> (this as BaseDateTimeType).value.makeItReadable()
this.isDateTime -> (this as BaseDateTimeType).value.makeItReadable(datePattern)
this.isPrimitive -> (this as PrimitiveType<*>).asStringValue()
this is Coding -> display ?: code
this is CodeableConcept -> this.stringValue()
Expand Down
Loading

0 comments on commit 92b2d85

Please sign in to comment.