/*
This file is part of leafdigital picstory.
picstory is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
picstory 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 General Public License for more details.
You should have received a copy of the GNU General Public License
along with picstory. If not, see <http://www.gnu.org/licenses/>.
Copyright 2010 Samuel Marshall.
*/
package com.leafdigital.picstory;
import java.awt.image.BufferedImage;
import java.io.*;
import java.nio.charset.Charset;
import java.security.NoSuchAlgorithmException;
import java.text.*;
import java.util.*;
import java.util.regex.Pattern;
import javax.imageio.ImageIO;
import org.apache.sanselan.*;
import org.apache.sanselan.formats.jpeg.JpegImageMetadata;
import org.apache.sanselan.formats.tiff.*;
import org.apache.sanselan.formats.tiff.constants.TiffConstants;
import org.w3c.dom.*;
/**
* A single story.
*/
class Story
{
private final static Pattern REGEX_PIC = Pattern.compile(
MainServlet.REGEX_PART_NAME);
private long lastUsed;
private long lastModified, date;
private String title, content;
private Element description;
private Pic indexPic;
private Map<String, Pic> pics = new HashMap<String, Pic>();
/**
* Loads a story from cache file or by creating it afresh (slow).
* <p>
* NOTE: This method is synchronized inside the story cache.
* @param mainServlet Main servlet
* @param xml XML processors
* @param cacheRoot Cache root folder
* @param storyRoot Story root folder
* @param storyName Story name
* @param lastModified Last modified date of index.xml
* @param reload True to reload
* @throws InternalException Any processing error
* @throws IOException Any I/O error
*/
public Story(MainServlet mainServlet, XmlProcessors xml,
File cacheRoot, File storyRoot,
String storyName, long lastModified, boolean reload)
throws InternalException, IOException
{
// Check cache folder to see if we already have a cached version of this
// story
File cachedStory = new File(new File(cacheRoot, storyName), "story.cache");
if(!reload)
{
long fileLastModified = cachedStory.lastModified();
// If cache exists and is newer or equal to last modified date of original
if(fileLastModified >= lastModified)
{
Document cache = xml.parseFile(cachedStory);
Element root = cache.getDocumentElement();
this.title = root.getElementsByTagName("title").item(0).
getFirstChild().getNodeValue();
this.description = (Element)root.getElementsByTagName(
"description").item(0);
this.content = root.getElementsByTagName("content").item(0).
getFirstChild().getNodeValue();
this.date = Long.parseLong(root.getAttribute("date"));
NodeList picList = root.getElementsByTagName("pic");
for(int i=0; i<picList.getLength(); i++)
{
Element picEl = (Element)picList.item(i);
Pic pic = new Pic(picEl);
pics.put(pic.getFilename(), pic);
if(pic.isIndexPic())
{
indexPic = pic;
}
}
this.lastModified = Long.parseLong(root.getAttribute("lastModified"));
return;
}
}
this.lastModified = lastModified;
// Load and parse index file
File storyFolder = new File(storyRoot, storyName);
File storyIndex = new File(storyFolder, "index.xml");
Document d = xml.parseFile(storyIndex);
// Get date (optional)
Element rootEl = d.getDocumentElement();
if(rootEl.hasAttribute("date"))
{
try
{
date = newDateFormat().parse(rootEl.getAttribute("date")).getTime();
}
catch(ParseException e)
{
throw new InternalException("Date invalid in '" + storyIndex + "'");
}
}
// Find title
NodeList titleNodes = d.getElementsByTagName("title");
if(titleNodes.getLength() != 1)
{
throw new InternalException(
"Unable to find title in '" + storyIndex + "'");
}
Node titleTextNode = titleNodes.item(0).getFirstChild();
if(titleTextNode.getNodeType() != Node.TEXT_NODE)
{
throw new InternalException(
"Unable to obtain title text in '" + storyIndex + "'");
}
title = titleTextNode.getNodeValue();
// Find description
NodeList descriptionNodes = d.getElementsByTagName("description");
if(descriptionNodes.getLength() != 1)
{
throw new InternalException(
"Unable to find description in '" + storyIndex + "'");
}
description = (Element)descriptionNodes.item(0);
// Get list of images and process each one
Collection<Pic> picList = new LinkedList<Pic>();
NodeList picNodes = d.getElementsByTagName("pic");
for(int i=0; i<picNodes.getLength(); i++)
{
Element picEl = (Element)picNodes.item(i);
// Check and find picture file
String picFileName = picEl.getAttribute("src");
if(!REGEX_PIC.matcher(picFileName).matches())
{
throw new InternalException(
"Picture '" + picFileName + "': not found (invalid name)");
}
File picFile = new File(storyFolder, picFileName + ".jpg");
if(!picFile.exists())
{
throw new InternalException(
"Picture '" + picFileName + "': not found");
}
// Give it a numeric id
picEl.setAttribute("id", "pic" + i);
// Load image bytes to make hash
byte[] imageBytes = Util.loadBytes(new FileInputStream(picFile));
String hash;
try
{
hash = Util.hash(imageBytes).substring(0, 8);
}
catch(NoSuchAlgorithmException e)
{
throw new InternalException(e);
}
picEl.setAttribute("hash", hash);
picEl.setAttribute("size", "" + imageBytes.length);
// Load image to get basic data
BufferedImage image = ImageIO.read(new ByteArrayInputStream(imageBytes));
picEl.setAttribute("width", "" + image.getWidth());
picEl.setAttribute("height", "" + image.getHeight());
Pic pic = new Pic(picFileName, hash, imageBytes.length,
image.getWidth(), image.getHeight());
if("y".equals(picEl.getAttribute("indexpic")))
{
pic.markIndexPic();
indexPic = pic;
}
picList.add(pic);
image = null;
imageBytes = null;
// Load metadata (this reads the file again, ah well)
try
{
JpegImageMetadata metadata =
(JpegImageMetadata)Sanselan.getMetadata(picFile);
if(metadata != null)
{
// Set metadata attributes
TiffField date = metadata.findEXIFValue(
TiffConstants.EXIF_TAG_DATE_TIME_ORIGINAL);
if(date != null)
{
// wtf - last character sometimes is null
String dateTimeString = date.getStringValue().replace("\u0000", "");
if(dateTimeString.length() == 19)
{
picEl.setAttribute("date", dateTimeString.substring(0, 10).replace(':', '-'));
picEl.setAttribute("time", dateTimeString.substring(11));
}
}
TiffField aperture = metadata.findEXIFValue(
TiffConstants.EXIF_TAG_APERTURE_VALUE);
if(aperture != null)
{
String fStop = "f" + (Math.round(
Math.pow(Math.sqrt(2), aperture.getDoubleValue()) * 10.0) / 10.0);
if(fStop.endsWith(".0"))
{
fStop = fStop.substring(0, fStop.length() - 2);
}
picEl.setAttribute("aperture", fStop);
}
TiffField exposureTime = metadata.findEXIFValue(
TiffConstants.EXIF_TAG_EXPOSURE_TIME);
if(exposureTime != null)
{
double inverse = 1.0 / exposureTime.getDoubleValue();
String speed;
if(Math.abs(inverse - Math.round(inverse)) < 0.001)
{
speed = "1/" + (int)inverse;
}
else
{
speed = exposureTime + "s";
}
picEl.setAttribute("shutterSpeed", speed);
}
TiffField focalLength = metadata.findEXIFValue(
TiffConstants.EXIF_TAG_FOCAL_LENGTH);
if(focalLength != null)
{
picEl.setAttribute("focalLength", Math.round(focalLength.getDoubleValue()) + "mm");
}
TiffField iso = metadata.findEXIFValue(
TiffConstants.EXIF_TAG_ISO);
if(iso != null)
{
picEl.setAttribute("iso", iso.getIntValue() + "");
}
// GPS
TiffImageMetadata exifMetadata = metadata.getExif();
if(exifMetadata != null)
{
TiffImageMetadata.GPSInfo gpsInfo = exifMetadata.getGPS();
if(gpsInfo != null)
{
double longitude = gpsInfo.getLongitudeAsDegreesEast();
longitude = Math.round(longitude * 10000000000.0) / 10000000000.0;
picEl.setAttribute("longitude", "" + longitude);
double latitude = gpsInfo.getLatitudeAsDegreesNorth();
latitude = Math.round(latitude * 10000000000.0) / 10000000000.0;
picEl.setAttribute("latitude", "" + latitude);
picEl.setAttribute("locationDisplay",
getPositionString(latitude, "N", "S") + " "
+ getPositionString(longitude, "E", "W"));
}
}
}
}
catch(ImageReadException e)
{
throw new InternalException("Picture '" + picFileName
+ "': error reading", e);
}
}
// Set index pic to first one if none was specified
if(indexPic == null)
{
Pic pic = picList.iterator().next();
pic.markIndexPic();
indexPic = pic;
}
// Build picture map
for(Pic pic : picList)
{
pics.put(pic.getFilename(), pic);
}
// Get XSL via the template mechanism
String xsl = mainServlet.getTemplates().get(
TemplateManager.Name.STORY_XSL).getString();
Document xslDocument = xml.parseString(
TemplateManager.Name.STORY_XSL.getFilename(), xsl);
content = xml.transform(xslDocument, d).replace("%%STORYFINAL%%",
mainServlet.getStoryFinalXhtml());
// Get cache file
Document cache = xml.newDocument();
rootEl = cache.createElement("cache");
cache.appendChild(rootEl);
rootEl.setAttribute("date", date + "");
rootEl.setAttribute("lastModified", lastModified + "");
// Version not used now, intended if necessary later if cache format changes
rootEl.setAttribute("cacheVersion", "1");
Element titleEl = cache.createElement("title");
rootEl.appendChild(titleEl);
titleEl.appendChild(cache.createTextNode(title));
rootEl.appendChild(cache.importNode(description, true));
Element contentEl = cache.createElement("content");
rootEl.appendChild(contentEl);
contentEl.appendChild(cache.createTextNode(content));
for(Pic pic : picList)
{
pic.add(rootEl);
}
String cacheString = xml.saveString(cache);
// Save it
if(!cachedStory.getParentFile().exists())
{
if(!cachedStory.getParentFile().mkdir())
{
throw new InternalException("Unable to create cache folder '"
+ cachedStory.getParentFile() + "'");
}
}
FileOutputStream out = new FileOutputStream(cachedStory);
out.write(cacheString.getBytes(Charset.forName("UTF-8")));
out.close();
}
private static String getPositionString(
double position, String positive, String negative)
{
String letter = position >= 0 ? positive : negative;
double result = Math.abs(position);
int degrees = (int)Math.floor(result);
result -= degrees;
result *= 60;
int minutes = (int)Math.floor(result);
result -= minutes;
result *= 60;
int seconds = (int)Math.floor(result);
return degrees + "\u00b0" + minutes + "\u2032"
+ seconds + "\u2033" + letter;
}
/**
* Marks this story as accessed so it won't expire from memory cache
*/
public void used()
{
lastUsed = System.currentTimeMillis();
}
/**
* @return Last time this story was accessed from cache
*/
public long getLastUsed()
{
return lastUsed;
}
/**
* @return Last modified date
*/
public long getLastModified()
{
return lastModified;
}
/**
* @return Date of story, or 0 if unspecified
*/
public long getDate()
{
return date;
}
/**
* @return Story title
*/
public String getTitle()
{
return title;
}
/**
* @return Story description
*/
public Element getDescription()
{
return description;
}
/**
* @return Story content
*/
public String getContent()
{
return content;
}
/**
* @return Index pic for this file
*/
public Pic getIndexPic()
{
return indexPic;
}
/**
* @param filename Filename of picture
* @return Pic object or null if none
*/
public Pic getPic(String filename)
{
return pics.get(filename);
}
/**
* @return Date format used in various places
*/
public static SimpleDateFormat newDateFormat()
{
return new SimpleDateFormat("yyyy-MM-dd");
}
}