HL7Util.java

/**
 * The contents of this file are subject to the OpenMRS Public License
 * Version 1.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://license.openmrs.org
 *
 * Software distributed under the License is distributed on an "AS IS"
 * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the
 * License for the specific language governing rights and limitations
 * under the License.
 *
 * Copyright (C) OpenMRS, LLC.  All Rights Reserved.
 */
package org.openmrs.hl7;

import java.io.File;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;

import org.apache.commons.lang.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.openmrs.api.APIException;
import org.openmrs.api.context.Context;
import org.openmrs.util.OpenmrsConstants;
import org.openmrs.util.OpenmrsUtil;

import ca.uhn.hl7v2.HL7Exception;

/**
 * HL7-related utilities
 * 
 * @version 1.0
 */
public class HL7Util {
	
	private static Log log = LogFactory.getLog(HL7Util.class);
	
	// Date and time format parsers
	private static final String TIMESTAMP_FORMAT = "yyyyMMddHHmmss.SSSZ";
	
	private static final String TIME_FORMAT = "HHmmss.SSSZ";
	
	public static final String LOCAL_TIMEZONE_OFFSET = new SimpleDateFormat("Z").format(new Date());
	
	/**
	 * Converts an HL7 timestamp into a java.util.Date object. HL7 timestamps can be created with
	 * varying levels of precision — e.g., just the year or just the year and month, etc.
	 * Since java.util.Date cannot store a partial value, we fill in defaults like January, 01 at
	 * midnight within the current timezone.
	 * 
	 * @param s HL7 timestamp to be parsed
	 * @return Date object
	 * @throws HL7Exception
	 * @should fail on 78
	 * @should handle 1978
	 * @should fail on 19784
	 * @should handle 197804
	 * @should fail on 197841
	 * @should handle 19780411
	 * @should fail on 197804116
	 * @should handle 1978041106
	 * @should fail on 19780411065
	 * @should handle 197804110615
	 * @should fail on 1978041106153
	 * @should handle 19780411061538
	 * @should handle 19780411061538.1
	 * @should handle 19780411061538.12
	 * @should handle 19780411061538.123
	 * @should handle 19780411061538.1234
	 * @should fail on 197804110615-5
	 * @should handle 197804110615-05
	 * @should handle 197804110615-0200
	 * @should not flub dst with 20091225123000
	 */
	public static Date parseHL7Timestamp(String s) throws HL7Exception {
		
		// HL7 dates must at least contain year and cannot exceed 24 bytes
		if (s == null || s.length() < 4 || s.length() > 24)
			throw new HL7Exception("Invalid date '" + s + "'");
		
		StringBuffer dateString = new StringBuffer();
		dateString.append(s.substring(0, 4)); // year
		if (s.length() >= 6)
			dateString.append(s.substring(4, 6)); // month
		else
			dateString.append("01");
		if (s.length() >= 8) {
			dateString.append(s.substring(6, 8)); //day
		} else
			dateString.append("01");
		
		// Parse timezone (optional in HL7 format)
		String timeZoneOffset;
		try {
			Date parsedDay = new SimpleDateFormat("yyyyMMdd").parse(s.substring(0, 8));
			timeZoneOffset = getTimeZoneOffset(s, parsedDay);
		}
		catch (ParseException e) {
			throw new HL7Exception("Error parsing date: '" + s.substring(0, 8) + "' for time zone offset'" + s + "'", e);
		}
		s = s.replace(timeZoneOffset, ""); // remove the timezone from the string
		
		if (s.length() >= 10)
			dateString.append(s.substring(8, 10)); // hour
		else
			dateString.append("00");
		if (s.length() >= 12)
			dateString.append(s.substring(10, 12)); // minute
		else
			dateString.append("00");
		if (s.length() >= 14)
			dateString.append(s.substring(12, 14)); // seconds
		else
			dateString.append("00");
		if (s.length() >= 15 && s.charAt(14) != '.') // decimal point
			throw new HL7Exception("Invalid date format '" + s + "'");
		else
			dateString.append(".");
		if (s.length() >= 16)
			dateString.append(s.substring(15, 16)); // tenths
		else
			dateString.append("0");
		if (s.length() >= 17)
			dateString.append(s.substring(16, 17)); // hundredths
		else
			dateString.append("0");
		if (s.length() >= 18)
			dateString.append(s.subSequence(17, 18)); // milliseconds
		else
			dateString.append("0");
		
		dateString.append(timeZoneOffset);
		
		Date date;
		try {
			date = new SimpleDateFormat(TIMESTAMP_FORMAT).parse(dateString.toString());
		}
		catch (ParseException e) {
			throw new HL7Exception("Error parsing date '" + s + "'");
		}
		return date;
	}
	
	/**
	 * Gets the timezone string for this given fullString. If fullString contains a + or - sign, the
	 * strings after those are considered to be the timezone. <br/>
	 * <br/>
	 * If the fullString does not contain a timezone, the timezone is determined from the server's
	 * timezone on the "givenDate". (givenDate is needed to account for daylight savings time.)
	 * 
	 * @param fullString the hl7 string being parsed
	 * @param givenDate the date that should be used if no timezone exists on the fullString
	 * @return a string like +0500 or -0500 for the timezone
	 * @should return timezone string if exists in given string
	 * @should return timezone for givenDate and not the current date
	 */
	protected static String getTimeZoneOffset(String fullString, Date givenDate) {
		// Parse timezone (optional in HL7 format)
		String timeZoneOffset;
		int tzPlus = fullString.indexOf('+');
		int tzMinus = fullString.indexOf('-');
		boolean timeZoneFlag = (tzPlus > 0 || tzMinus > 0);
		if (timeZoneFlag) {
			int tzIndex;
			if (tzPlus > 0)
				tzIndex = tzPlus;
			else
				tzIndex = tzMinus;
			timeZoneOffset = fullString.substring(tzIndex);
			if (timeZoneOffset.length() != 5)
				log.error("Invalid timestamp because its too short: " + timeZoneOffset);
			
		} else {
			//set default timezone offset from the current day
			Calendar cal = Calendar.getInstance();
			cal.setTime(givenDate);
			timeZoneOffset = new SimpleDateFormat("Z").format(cal.getTime());
		}
		
		return timeZoneOffset;
	}
	
	/**
	 * Convenience method for parsing HL7 dates (treated just like a timestamp with only year,
	 * month, and day specified)
	 * 
	 * @see org.openmrs.hl7.HL7Util#parseHL7Timestamp(String)
	 * @throws HL7Exception
	 */
	public static Date parseHL7Date(String s) throws HL7Exception {
		return parseHL7Timestamp(s);
	}
	
	/**
	 * Converts an HL7 time into a java.util.Date object. Since the java.util.Date object cannot
	 * store just the time, the date will remain at the epoch (e.g., January 1, 1970). Time more
	 * precise than microseconds is ignored.
	 * 
	 * @param s HL7 time to be converted
	 * @return Date object set to time specified by HL7
	 * @throws HL7Exception
	 * @should fail on 197804110615
	 * @should handle 0615
	 * @should handle 061538
	 * @should handle 061538.1
	 * @should handle 061538.12
	 * @should handle 061538.123
	 * @should handle 061538.1234
	 * @should handle 061538-0300
	 */
	public static Date parseHL7Time(String s) throws HL7Exception {
		
		String timeZoneOffset = getTimeZoneOffset(s, new Date());
		s = s.replace(timeZoneOffset, ""); // remove the timezone from the string
		
		StringBuffer timeString = new StringBuffer();
		
		if (s.length() < 2 || s.length() > 16)
			throw new HL7Exception("Invalid time format '" + s + "'");
		
		timeString.append(s.substring(0, 2)); // hour
		if (s.length() >= 4)
			timeString.append(s.substring(2, 4)); // minute
		else
			timeString.append("00");
		if (s.length() >= 6)
			timeString.append(s.substring(4, 6)); // seconds
		else
			timeString.append("00");
		if (s.length() >= 7 && s.charAt(6) != '.') // decimal point
			throw new HL7Exception("Invalid time format '" + s + "'");
		else
			timeString.append(".");
		if (s.length() >= 8)
			timeString.append(s.substring(7, 8)); // tenths
		else
			timeString.append("0");
		if (s.length() >= 9)
			timeString.append(s.substring(8, 9)); // hundredths
		else
			timeString.append("0");
		if (s.length() >= 10)
			timeString.append(s.subSequence(9, 10)); // milliseconds
		else
			timeString.append("0");
		
		// Parse timezone (optional in HL7 format)
		timeString.append(timeZoneOffset);
		
		Date date;
		try {
			date = new SimpleDateFormat(TIME_FORMAT).parse(timeString.toString());
		}
		catch (ParseException e) {
			throw new HL7Exception("Invalid time format: '" + s + "' [" + timeString + "]", e);
		}
		return date;
	}
	
	/**
	 * Gets the destination directory for hl7 archives.
	 * 
	 * @return The destination directory for the hl7 in archive
	 */
	public static File getHl7ArchivesDirectory() throws APIException {
		String archiveDir = Context.getAdministrationService().getGlobalProperty(
		    OpenmrsConstants.GLOBAL_PROPERTY_HL7_ARCHIVE_DIRECTORY);
		
		if (StringUtils.isBlank(archiveDir)) {
			log.warn("Invalid value for global property '" + OpenmrsConstants.GLOBAL_PROPERTY_HL7_ARCHIVE_DIRECTORY
			        + "', trying to set a default one");
			archiveDir = HL7Constants.HL7_ARCHIVE_DIRECTORY_NAME;
			
			log.debug("Using '" + archiveDir
			        + "' in the application data directory as the root directory for hl7_in_archives");
		}
		
		//TODO Should take care of the case where the user is using removable media, this might explode
		return OpenmrsUtil.getDirectoryInApplicationDataDirectory(archiveDir);
	}
}