dk.dma.msinm.web.rest.MessageRestService.java Source code

Java tutorial

Introduction

Here is the source code for dk.dma.msinm.web.rest.MessageRestService.java

Source

/* Copyright (c) 2011 Danish Maritime Authority
 *
 * This library is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License as published by the Free Software Foundation; either
 * version 3 of the License, or (at your option) any later version.
 *
 * This library is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this library.  If not, see <http://www.gnu.org/licenses/>.
 */
package dk.dma.msinm.web.rest;

import dk.dma.msinm.common.MsiNmApp;
import dk.dma.msinm.common.model.DataFilter;
import dk.dma.msinm.common.repo.RepoFileVo;
import dk.dma.msinm.common.templates.PdfService;
import dk.dma.msinm.common.time.TimeModel;
import dk.dma.msinm.common.time.TimeParser;
import dk.dma.msinm.common.time.TimeProcessor;
import dk.dma.msinm.common.time.TimeTranslator;
import dk.dma.msinm.common.vo.JsonSerializable;
import dk.dma.msinm.model.Message;
import dk.dma.msinm.model.Reference;
import dk.dma.msinm.model.ReferenceType;
import dk.dma.msinm.model.SeriesIdentifier;
import dk.dma.msinm.model.Status;
import dk.dma.msinm.service.CalendarService;
import dk.dma.msinm.service.CategoryService;
import dk.dma.msinm.service.MessageSearchParams;
import dk.dma.msinm.service.MessageSearchResult;
import dk.dma.msinm.service.MessageSearchService;
import dk.dma.msinm.service.MessageService;
import dk.dma.msinm.service.PublishingService;
import dk.dma.msinm.vo.MessageHistoryVo;
import dk.dma.msinm.vo.MessageVo;
import dk.dma.msinm.vo.ReferenceVo;
import org.apache.commons.lang.StringUtils;
import org.jboss.ejb3.annotation.SecurityDomain;
import org.jboss.resteasy.annotations.GZIP;
import org.jboss.resteasy.annotations.cache.NoCache;
import org.slf4j.Logger;

import javax.annotation.security.PermitAll;
import javax.annotation.security.RolesAllowed;
import javax.ejb.Stateless;
import javax.inject.Inject;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.StreamingOutput;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;

import static dk.dma.msinm.service.Publisher.PublisherContext;

/**
 * REST interface for accessing MSI-NM messages
 */
@Path("/messages")
@Stateless
@SecurityDomain("msinm-policy")
@PermitAll
public class MessageRestService {

    public static final String TYPE_ICALENDAR = "text/calendar;charset=UTF-8";

    @Inject
    Logger log;

    @Inject
    MessageService messageService;

    @Inject
    CategoryService categoryService;

    @Inject
    MessageSearchService messageSearchService;

    @Inject
    PdfService pdfService;

    @Inject
    CalendarService calendarService;

    @Inject
    PublishingService publishingService;

    @Inject
    MsiNmApp app;

    public MessageRestService() {
    }

    /**
     * Creates a new message template with a temporary repository path
     *
     * @return the new message template
     */
    @GET
    @Path("/new-message-template")
    @Produces("application/json;charset=UTF-8")
    @GZIP
    @NoCache
    @RolesAllowed({ "editor" })
    public MessageVo newTemplateMessage() {
        return messageService.newTemplateMessage();
    }

    /**
     * Creates a new message copy template with a temporary repository path
     *
     * @return the new message copy template
     */
    @GET
    @Path("/copy-message-template/{messageId}")
    @Produces("application/json;charset=UTF-8")
    @GZIP
    @NoCache
    @RolesAllowed({ "editor" })
    public MessageVo copyMessageTemplate(@PathParam("messageId") String messageId,
            @QueryParam("reference") String reference) {
        // Look up the message
        final MessageVo message = getMessage(messageId, null);

        // Check if we need to add the original message as a reference
        if (StringUtils.isNotBlank(reference)) {
            try {
                if (message.getReferences() == null) {
                    message.setReferences(new HashSet<>());
                }
                Reference ref = new Reference();
                ref.setSeriesIdentifier(message.getSeriesIdentifier().copy());
                ref.setType(ReferenceType.valueOf(reference));
                message.getReferences().add(new ReferenceVo(ref));

            } catch (IllegalArgumentException e) {
                log.warn("Failed to add reference of type " + reference);
            }
        }

        // Get a temp repo path
        MessageVo newTemplateMessage = newTemplateMessage();
        message.setRepoPath(newTemplateMessage.getRepoPath());

        // Reset various identifier fields
        message.setId(null);
        message.setStatus(Status.DRAFT);
        SeriesIdentifier id = new SeriesIdentifier();

        id.setMainType(message.getSeriesIdentifier().getMainType());
        id.setAuthority(message.getSeriesIdentifier().getAuthority());
        id.setYear(newTemplateMessage.getSeriesIdentifier().getYear());
        message.setSeriesIdentifier(id);
        if (message.getReferences() != null) {
            message.getReferences().forEach(ref -> ref.setId(null));
        }
        if (message.getPublications() != null) {
            message.getPublications().forEach(pub -> pub.setId(null));
        }
        if (newTemplateMessage.getPublications() != null) {
            newTemplateMessage.getPublications().forEach(message::addPublicationIfUndefined);
        }

        return message;
    }

    /**
     * Translates the messageId, which may be an ID or a series identifier, into a message id
     *
     * @param messageId the mesage id
     * @return the message id
     */
    private Integer getMessageId(String messageId) {
        // Sanity check
        if (messageId == null) {
            throw new IllegalArgumentException("Must specify message id");
        }

        // The message id is either the ID of the message or the message series identifier
        Integer id = null;
        if (StringUtils.isNumeric(messageId)) {
            id = Integer.valueOf(messageId);
        } else {
            Message message = messageService.findBySeriesIdentifier(messageId);
            if (message != null) {
                id = message.getId();
            }
        }
        return id;
    }

    /**
     * Returns the message with the given ID or series ID
     *
     * @return the message, or null if not found
     */
    @GET
    @Path("/message/{messageId}")
    @Produces("application/json;charset=UTF-8")
    @GZIP
    @NoCache
    public MessageVo getMessage(@PathParam("messageId") String messageId, @QueryParam("lang") String lang) {

        // Get the message id
        Integer id = getMessageId(messageId);

        // Look up the cached message with the computed id
        MessageVo result = null;
        if (id != null) {
            Message message = messageService.getCachedMessage(id);
            if (message != null) {
                DataFilter filter = new DataFilter(MessageService.CACHED_MESSAGE_DATA).setLang(lang);
                result = new MessageVo(message, filter);
                try {
                    result.setRepoPath(messageService.getMessageFolderRepoPath(message));
                    result.setBookmarked(messageService.getBookmarks().contains(id));
                    List<RepoFileVo> attachments = messageService.getMessageAttacthments(message);
                    if (attachments.size() > 0) {
                        result.setAttachments(attachments);
                    }
                } catch (IOException e) {
                    log.warn("Failed looking up repo-path for message " + messageId, e);
                }
            }
        }

        return result;
    }

    /**
     * Saves a new message
     *
     * @param message the message to save
     * @return the persisted message
     */
    @POST
    @Path("/message")
    @Consumes("application/json")
    @Produces("application/json")
    @GZIP
    @NoCache
    @RolesAllowed({ "editor" })
    public MessageVo createMessage(MessageVo message) throws Exception {
        log.info("Creating message " + message);
        Message msg = messageService.createMessage(message);
        return getMessage(msg.getId().toString(), null);
    }

    /**
     * Updates a message
     *
     * @param message the message to update
     * @return the updated message
     */
    @PUT
    @Path("/message")
    @Consumes("application/json")
    @Produces("application/json")
    @GZIP
    @NoCache
    @RolesAllowed({ "editor" })
    public MessageVo updateMessage(MessageVo message) throws Exception {
        log.info("Updating message " + message);
        Message msg = messageService.updateMessage(message);
        return getMessage(msg.getId().toString(), null);
    }

    /**
     * Updates the status of a message
     *
     * @param status the status update
     * @return the updated message
     */
    @PUT
    @Path("/update-status")
    @Consumes("application/json")
    @Produces("application/json")
    @GZIP
    @NoCache
    @RolesAllowed({ "editor" })
    public MessageVo updateMessageStatus(MessageStatusVo status) throws Exception {
        log.info("Updating status of message " + status.getMessageId() + " to " + status.getStatus());
        Message msg = messageService.setStatus(status.getMessageId(), status.getStatus());
        return getMessage(msg.getId().toString(), null);
    }

    /***************************
     * Template functionality
     ***************************/

    /**
     * Transforms a message according to the requested template and language
     *
     * @param transformVo the transformation data
     * @return the result
     */
    @POST
    @Path("/transform")
    @Consumes("application/json")
    @Produces("text/plain;charset=UTF-8")
    @GZIP
    @NoCache
    @RolesAllowed({ "editor" })
    public String transform(TransformVo transformVo) throws Exception {
        log.info("Transforming message " + transformVo);

        return messageService.transformMessage(transformVo.getMessage(), transformVo.getTemplate(),
                transformVo.getLang());
    }

    /***************************************/
    /** Message History methods           **/
    /***************************************/

    /**
     * Returns the message history for the given message ID
     * @param messageId the message ID or message series ID
     * @return the message history
     */
    @GET
    @Path("/history/{messageId}")
    @Produces("application/json;charset=UTF-8")
    @GZIP
    @NoCache
    @RolesAllowed({ "editor" })
    public List<MessageHistoryVo> getMessageHistory(@PathParam("messageId") String messageId) {

        // Get the message id
        Integer id = getMessageId(messageId);

        return messageService.getMessageHistory(id);
    }

    /***************************************/
    /** Bookmark methods                  **/
    /***************************************/

    /**
     * Adds a bookmark for the calling user
     *
     * @param messageId the id of the message
     * @return if the bookmark was added
     */
    @POST
    @Path("/bookmark/{messageId}")
    @NoCache
    @RolesAllowed({ "user" })
    public boolean addBookmark(@PathParam("messageId") String messageId) {

        // Get the message id
        Integer id = getMessageId(messageId);

        return messageService.addBookmark(id);
    }

    /**
     * Removes a bookmark for the calling user
     *
     * @param messageId the id of the message
     * @return if the bookmark was removed
     */
    @DELETE
    @Path("/bookmark/{messageId}")
    @NoCache
    @RolesAllowed({ "user" })
    public boolean removeBookmark(@PathParam("messageId") String messageId) {

        // Get the message id
        Integer id = getMessageId(messageId);

        return messageService.removeBookmark(id);
    }

    /***************************
     * Search functionality
     ***************************/

    /**
     * Main search method
     */
    @GET
    @Path("/search")
    @Produces("application/json;charset=UTF-8")
    @GZIP
    @NoCache
    public MessageSearchResult search(@QueryParam("lang") String language, @QueryParam("q") String query,
            @QueryParam("status") @DefaultValue("PUBLISHED") String status, @QueryParam("type") String type,
            @QueryParam("loc") String loc, @QueryParam("areas") String areas,
            @QueryParam("categories") String categories, @QueryParam("charts") String charts,
            @QueryParam("from") String fromDate, @QueryParam("to") String toDate,
            @QueryParam("maxHits") @DefaultValue("100") int maxHits,
            @QueryParam("startIndex") @DefaultValue("0") int startIndex,
            @QueryParam("sortBy") @DefaultValue("DATE") String sortBy,
            @QueryParam("sortOrder") @DefaultValue("DESC") String sortOrder,
            @QueryParam("mapMode") @DefaultValue("false") boolean mapMode) throws Exception {
        long t0 = System.currentTimeMillis();
        MessageSearchParams params = MessageSearchParams.readParams(language, query, status, type, loc, areas,
                categories, charts, fromDate, toDate, maxHits, startIndex, sortBy, sortOrder, mapMode);
        MessageSearchResult searchResult = messageSearchService.search(params);
        log.info(String.format("Search [%s] returns %d of %d messages in %d ms", params.toString(),
                searchResult.getMessages().size(), searchResult.getTotal(), System.currentTimeMillis() - t0));
        return searchResult;
    }

    /**
     * Published messages method
     */
    @GET
    @Path("/published")
    @Produces("application/json;charset=UTF-8")
    @GZIP
    @NoCache
    public MessageSearchResult searchPublished(@QueryParam("lang") String language,
            @QueryParam("sortBy") @DefaultValue("DATE") String sortBy,
            @QueryParam("sortOrder") @DefaultValue("DESC") String sortOrder,
            @QueryParam("attachments") @DefaultValue("false") boolean attachments) throws Exception {
        long t0 = System.currentTimeMillis();
        MessageSearchParams params = MessageSearchParams.readParams(language, "", "PUBLISHED", "", "", "", "", "",
                "", "", 1000, 0, sortBy, sortOrder, false);
        MessageSearchResult searchResult = messageSearchService.search(params);
        if (attachments) {
            searchResult.getMessages().stream().forEach(msg -> {
                try {
                    List<RepoFileVo> repoFiles = messageService.getMessageAttacthments(msg.getId());
                    if (repoFiles.size() > 0) {
                        msg.setAttachments(repoFiles);
                    }
                } catch (Exception e) {
                    log.warn("Failed loading attachments for " + msg.getId());
                }
            });
        }
        log.trace(String.format("Search [%s] returns %d of %d messages in %d ms", params.toString(),
                searchResult.getMessages().size(), searchResult.getTotal(), System.currentTimeMillis() - t0));
        return searchResult;
    }

    /**
     * Published firing exercises
     */
    @GET
    @Path("/active-firing-exercises")
    @Produces("application/json;charset=UTF-8")
    @GZIP
    @NoCache
    public MessageSearchResult searchFiringExercises(@QueryParam("lang") String language) throws Exception {
        String categoryId = String.valueOf(categoryService.findOrCreateFiringExercisesCategory().getId());
        MessageSearchParams params = MessageSearchParams.readParams(language, "", "PUBLISHED", "MSI", "", "",
                categoryId, "", "", "", 1000, 0, "AREA", "ASC", false);
        return messageSearchService.search(params);
    }

    /***************************
     * PDF functionality
     ***************************/

    /**
     * Returns a PDF for the search result
     */
    @GET
    @Path("/search-pdf")
    @Produces("application/pdf")
    @NoCache
    public Response generatePdf(@QueryParam("lang") String language, @QueryParam("q") String query,
            @QueryParam("status") @DefaultValue("PUBLISHED") String status, @QueryParam("type") String type,
            @QueryParam("loc") String loc, @QueryParam("areas") String areas,
            @QueryParam("categories") String categories, @QueryParam("charts") String charts,
            @QueryParam("from") String fromDate, @QueryParam("to") String toDate,
            @QueryParam("sortBy") @DefaultValue("DATE") String sortBy,
            @QueryParam("sortOrder") @DefaultValue("DESC") String sortOrder,
            @QueryParam("mapMode") @DefaultValue("false") boolean mapMode) throws Exception {
        MessageSearchParams params = MessageSearchParams.readParams(language, query, status, type, loc, areas,
                categories, charts, fromDate, toDate, 1000, 0, sortBy, sortOrder, mapMode);
        MessageSearchResult result = messageSearchService.search(params);

        String template = "message-list.ftl";
        String bundle = "MessageList";
        Map<String, Object> data = new HashMap<>();
        data.put("messages", result.getMessages());
        data.put("areaHeadings", "AREA".equals(sortBy));

        try {
            StreamingOutput stream = os -> {
                try {
                    pdfService.generatePdf(data, template, language, bundle, os);
                } catch (Exception e) {
                    throw new WebApplicationException("Error generating PDF", e);
                }
            };

            return Response.ok(stream).type("application/pdf")
                    .header("Content-Disposition", "attachment; filename=\"messages.pdf\"").build();

        } catch (Exception e) {
            log.error("error generating PDF from template " + template, e);
            throw e;
        }
    }

    /**
     * Returns a PDF for the search result
     */
    @GET
    @Path("/message-pdf/{messageId}")
    @Produces("application/pdf")
    @NoCache
    public Response generatePdf(@PathParam("messageId") String messageId, @QueryParam("lang") String lang) {
        // Strip any ".pdf" suffix
        if (messageId.toLowerCase().endsWith(".pdf")) {
            messageId = messageId.substring(0, messageId.length() - 4);
        }

        // Get the message
        MessageVo message = getMessage(messageId, lang);

        String template = "message-details.ftl";
        String bundle = "MessageList";
        Map<String, Object> data = new HashMap<>();
        data.put("msg", message);

        try {
            StreamingOutput stream = os -> {
                try {
                    pdfService.generatePdf(data, template, lang, bundle, os);
                } catch (Exception e) {
                    throw new WebApplicationException("Error generating PDF", e);
                }
            };

            return Response.ok(stream).type("application/pdf")
                    .header("Content-Disposition",
                            "attachment; filename=\"" + message.getSeriesIdentifier().getFullId() + ".pdf\"")
                    .build();

        } catch (Exception e) {
            log.error("error generating PDF from template " + template, e);
            throw e;
        }
    }

    /***************************
     * Calendar functionality
     ***************************/

    /**
     * Returns an iCalendar ICS file for the message
     */
    @GET
    @Path("/message-cal/{messageId}")
    @Produces(TYPE_ICALENDAR)
    @NoCache
    public Response generateCalendar(@PathParam("messageId") String messageId, @QueryParam("lang") String lang) {
        // Strip any ".ics" suffix
        if (messageId.toLowerCase().endsWith(".ics")) {
            messageId = messageId.substring(0, messageId.length() - 4);
        }

        // Get the message
        MessageVo message = getMessage(messageId, lang);

        // Generate the calendar data
        try {
            StreamingOutput stream = os -> {
                try {
                    List<MessageVo> messages = new ArrayList<>();
                    messages.add(message);
                    calendarService.generateCalendarData(messages, app.getLanguage(lang), os);
                } catch (Exception e) {
                    throw new WebApplicationException("Error generating calendar data", e);
                }
            };

            return Response.ok(stream).type(TYPE_ICALENDAR)
                    .header("Content-Disposition",
                            "attachment; filename=\"" + message.getSeriesIdentifier().getFullId() + ".ics\"")
                    .build();

        } catch (Exception e) {
            log.error("error generating calendar for message " + messageId, e);
            throw e;
        }
    }

    /**
     * Returns an iCalendar ICS file for the message
     */
    @GET
    @Path("/active-msinm.ics")
    @Produces(TYPE_ICALENDAR)
    @NoCache
    public Response activeMsiNmCalendar(final @QueryParam("lang") String lang) {

        MessageSearchParams params = new MessageSearchParams();
        params.setLanguage(app.getLanguage(lang));
        params.setStartIndex(0);
        params.setMaxHits(1000);
        params.setSortBy(MessageSearchParams.SortBy.DATE);
        params.setSortOrder(MessageSearchParams.SortOrder.DESC);
        params.setStatus(Status.PUBLISHED);

        MessageSearchResult result = messageSearchService.search(params);

        // Generate the calendar data
        try {
            StreamingOutput stream = os -> {
                try {
                    calendarService.generateCalendarData(result.getMessages(), app.getLanguage(lang), os);
                } catch (Exception e) {
                    throw new WebApplicationException("Error generating calendar data", e);
                }
            };

            return Response.ok(stream).type(TYPE_ICALENDAR)
                    .header("Content-Disposition", "attachment; filename=\"active_msi_nm.ics\"").build();

        } catch (Exception e) {
            log.error("error generating calendar for active MSI-NM messages", e);
            throw e;
        }
    }

    /**
     * Re-creates the message search index.
     * Requires the "admin" role
     */
    @GET
    @Path("/recreate-search-index")
    @RolesAllowed({ "admin" })
    public String recreateSearchIndex() {
        try {
            log.info("Recreating message search index");
            messageSearchService.recreateIndex();
        } catch (IOException e) {
            log.error("Error recreating message search index");
        }
        return "OK";
    }

    /***************************
     * Publisher functionality
     ***************************/

    /**
     * Returns the list of available publishers
     * @return the list of available publishers
     */
    @GET
    @Path("/publishers")
    @RolesAllowed({ "admin" })
    @GZIP
    @NoCache
    public List<PublisherContext> getPublishers() {
        return publishingService.getPublisherContexts();
    }

    /**
     * Updates a publishers active status
     *
     * @param publisher the publisher to update
     * @return the updated publisher
     */
    @PUT
    @Path("/publisher")
    @Consumes("application/json")
    @Produces("application/json")
    @GZIP
    @NoCache
    @RolesAllowed({ "admin" })
    public PublisherContext updatePublisher(PublisherContext publisher) throws Exception {
        log.info("Setting active status of publisher " + publisher.getType() + " to " + publisher.isActive());
        return publishingService.updatePublisherContext(publisher);
    }

    /***************************
     * Time parsing functionality
     ***************************/

    /**
     * Translates the time description and determines validFrom and validTo from it
     *
     * @param timeVo the time to translate
     * @return the translated time
     */
    @POST
    @Path("/translate-time")
    @Consumes("application/json")
    @Produces("application/json")
    @GZIP
    @NoCache
    public MessageTimeVo translateTime(MessageTimeVo timeVo) {
        if (timeVo.getTimes() != null && timeVo.getTimes().size() > 0
                && StringUtils.isNotBlank(timeVo.getTimes().get(0).getTime())) {
            LocalizedTimeVo srcTime = timeVo.getTimes().get(0);
            try {
                String timeEn = ("en".equals(srcTime.getLang())) ? srcTime.getTime()
                        : TimeTranslator.get(srcTime.getLang()).translateToEnglish(srcTime.getTime());
                for (int x = 1; x < timeVo.getTimes().size(); x++) {
                    LocalizedTimeVo vo = timeVo.getTimes().get(x);
                    if ("en".equals(vo.getLang())) {
                        vo.setTime(timeEn);
                    } else {
                        vo.setTime(TimeTranslator.get(vo.getLang()).translateFromEnglish(timeEn));
                    }
                }

                // Compute validFrom and validTo...
                TimeModel model = TimeParser.get().parseModel(timeEn);
                Date[] dates = TimeProcessor.getDateInterval(model);
                timeVo.setValidFrom(dates[0]);
                timeVo.setValidTo(dates[1]);
            } catch (Exception e) {
                log.warn("Failed translating time " + timeVo + ": " + e);
            }
        }

        // No result
        return timeVo;
    }

    /***************************
     * Helper VO classes
     ***************************/

    /**
     * Helper class used changing the status of a message
     */
    public static class MessageStatusVo implements JsonSerializable {
        Integer messageId;
        Status status;

        public Integer getMessageId() {
            return messageId;
        }

        public void setMessageId(Integer messageId) {
            this.messageId = messageId;
        }

        public Status getStatus() {
            return status;
        }

        public void setStatus(Status status) {
            this.status = status;
        }
    }

    /**
     * Helper class used for submitting message transformation data
     */
    public static class TransformVo implements JsonSerializable {
        MessageVo message;
        String template;
        String lang;

        public MessageVo getMessage() {
            return message;
        }

        public void setMessage(MessageVo message) {
            this.message = message;
        }

        public String getTemplate() {
            return template;
        }

        public void setTemplate(String template) {
            this.template = template;
        }

        public String getLang() {
            return lang;
        }

        public void setLang(String lang) {
            this.lang = lang;
        }
    }

    /**
     * Helper class used for translating time descriptions
     */
    public static class MessageTimeVo implements JsonSerializable {
        Date validFrom, validTo;
        List<LocalizedTimeVo> times;

        public Date getValidFrom() {
            return validFrom;
        }

        public void setValidFrom(Date validFrom) {
            this.validFrom = validFrom;
        }

        public Date getValidTo() {
            return validTo;
        }

        public void setValidTo(Date validTo) {
            this.validTo = validTo;
        }

        public List<LocalizedTimeVo> getTimes() {
            return times;
        }

        public void setTimes(List<LocalizedTimeVo> times) {
            this.times = times;
        }
    }

    /**
     * Helper class that contains a localized time description
     */
    public static class LocalizedTimeVo {
        String lang, time;

        public String getLang() {
            return lang;
        }

        public void setLang(String lang) {
            this.lang = lang;
        }

        public String getTime() {
            return time;
        }

        public void setTime(String time) {
            this.time = time;
        }
    }

}