/*
* jMemorize - Learning made easy (and fun) - A Leitner flashcards tool
* Copyright(C) 2004-2008 Riad Djemili and contributors
*
* This program 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 1, or (at your option)
* any later version.
*
* This program 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 this program; if not, write to the Free Software
* Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
*/
package jmemorize.core.io;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.text.DateFormat;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.zip.GZIPInputStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import jmemorize.core.Card;
import jmemorize.core.CardSide;
import jmemorize.core.Category;
import jmemorize.core.ImageRepository;
import jmemorize.core.Lesson;
import jmemorize.core.LessonProvider;
import jmemorize.core.Main;
import jmemorize.core.Settings;
import jmemorize.core.ImageRepository.ImageItem;
import jmemorize.core.learn.LearnHistory;
import jmemorize.core.learn.LearnHistory.SessionSummary;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
/**
* @author djemili
*/
public class XmlBuilder
{
private static final String SESSION = "session"; //$NON-NLS-1$
private static final String LESSON = "Lesson"; //$NON-NLS-1$
private static final String DECK = "Deck"; //$NON-NLS-1$
private static final String CARD = "Card"; //$NON-NLS-1$
private static final String SIDE = "Side"; //$NON-NLS-1$
private static final String IMG = "image"; //$NON-NLS-1$
private static final String IMG_ID = "id"; //$NON-NLS-1$
private static final String NAME = "name"; //$NON-NLS-1$
private static final String CATEGORY = "Category"; //$NON-NLS-1$
private static final String TESTS_HIT = "TestsHit"; //$NON-NLS-1$
private static final String TESTS_TOTAL = "TestsTotal"; //$NON-NLS-1$
private static final String AMOUNT_LEARNED_BACK = "AmountLearnedBack"; //$NON-NLS-1$
private static final String AMOUNT_LEARNED_FRONT = "AmountLearnedFront"; //$NON-NLS-1$
private static final String DATE_EXPIRED = "DateExpired"; //$NON-NLS-1$
private static final String DATE_TESTED = "DateTested"; //$NON-NLS-1$
private static final String DATE_TOUCHED = "DateTouched"; //$NON-NLS-1$
private static final String DATE_CREATED = "DateCreated"; //$NON-NLS-1$
private static final String DATE_MODIFIED = "DateModified"; //$NON-NLS-1$
private static final String BACKSIDE = "Backside"; //$NON-NLS-1$
private static final String FRONTSIDE = "Frontside"; //$NON-NLS-1$
private static final String STATS_ROOT = "statistics"; //$NON-NLS-1$
private static final String STATS_RELEARNED = "relearned"; //$NON-NLS-1$
private static final String STATS_SKIPPED = "skipped"; //$NON-NLS-1$
private static final String STATS_FAILED = "failed"; //$NON-NLS-1$
private static final String STATS_PASSED = "passed"; //$NON-NLS-1$
private static final String STATS_END = "end"; //$NON-NLS-1$
private static final String STATS_START = "start"; //$NON-NLS-1$
private static final String LESSON_ZIP_ENTRY_NAME = "lesson.xml"; //$NON-NLS-1$
private static final String IMAGE_FOLDER = "images"; //$NON-NLS-1$
// we need a fixed formatter in file (not locale depent)
private final static DateFormat DATE_FORMAT = DateFormat.getDateTimeInstance(
DateFormat.MEDIUM, DateFormat.MEDIUM, Locale.UK);
/**
* Saves the lesson to an {@link OutputStream} which contains an XML
* document.
*
* Don't use this method directly. Use the {@link LessonProvider} instead.
*
* XML-Schema:
*
* <lesson>
* <deck>
* <card frontside="bla" backside="bla"/> ..
* </deck> ..
* </lesson>
*/
public static void saveAsXMLFile(File file, Lesson lesson) throws IOException,
TransformerException, ParserConfigurationException
{
OutputStream out;
ZipOutputStream zipOut = null;
if (Settings.loadIsSaveCompressed())
{
out = zipOut = new ZipOutputStream(new FileOutputStream(file));
zipOut.putNextEntry(new ZipEntry(LESSON_ZIP_ENTRY_NAME));
}
else
{
out = new FileOutputStream(file);
}
try
{
Document document = DocumentBuilderFactory.newInstance()
.newDocumentBuilder().newDocument();
// add lesson tag as root
Element lessonTag = document.createElement(LESSON);
document.appendChild(lessonTag);
// add category tags
writeCategory(document, lessonTag, lesson.getRootCategory());
writeLearnHistory(document, lesson.getLearnHistory());
// transform document for file output
Transformer transformer = TransformerFactory.newInstance().newTransformer();
transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); //$NON-NLS-1$
transformer.setOutputProperty(OutputKeys.INDENT, "yes"); //$NON-NLS-1$
transformer.transform(new DOMSource(document), new StreamResult(out));
}
finally
{
if (zipOut != null)
zipOut.closeEntry();
else if (out != null)
out.close();
}
try
{
removeUnusedImagesFromRepository(lesson);
if (zipOut == null)
writeImageRepositoryToDisk(new File(file.getParent()));
else
writeImageRepositoryToZip(zipOut);
}
finally
{
if (zipOut != null)
zipOut.close();
}
}
/**
* Loads a lesson from an XML document that is contained within a file.
*
* Don't use this method directly. Use the {@link LessonProvider} instead.
*
* @param File xmlFile the file that containt the XML document which
* represents the lesson.
*/
public static void loadFromXMLFile(File xmlFile, Lesson lesson)
throws SAXException, IOException, ParserConfigurationException
{
InputStream in;
ZipInputStream zipIn = null;
try
{
in = new GZIPInputStream(new FileInputStream(xmlFile));
}
catch (IOException ex)
{
in = zipIn = new ZipInputStream(new FileInputStream(xmlFile));
ZipEntry zipEntry = zipIn.getNextEntry();
// file might not be compressed. try loading it directly
if (zipEntry == null) // expected when the file is not zipped
{
in = new FileInputStream(xmlFile);
zipIn = null;
}
else
{
if (!zipEntry.getName().equals(LESSON_ZIP_ENTRY_NAME))
throw new IOException("Unexpected zip entry.");
}
}
// get lesson tag
try
{
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
Document doc = factory.newDocumentBuilder().parse(in);
// there must be a root category
Element categoryTag = (Element)doc.getElementsByTagName(CATEGORY).item(0);
loadCategory(lesson.getRootCategory(), null, categoryTag, 0);
loadLearnHistory(doc, lesson.getLearnHistory());
}
finally
{
if (zipIn == null)
in.close();
}
try
{
if (zipIn == null)
loadImageRepositoryFromDisk(xmlFile);
else
{
zipIn = new ZipInputStream(new FileInputStream(xmlFile));
ZipEntry entry;
while ((entry = zipIn.getNextEntry()) != null)
{
loadImageFromZipEntry(zipIn, entry);
}
}
}
catch (Exception e)
{
Main.logThrowable("Exception while loading lesson "+xmlFile, e);
}
finally
{
if (zipIn != null)
zipIn.close();
}
}
/**
* @deprecated
*/
public static void loadLearnHistory(Document document, LearnHistory history)
{
// there must be a root category
Element rootTag = (Element)document.getElementsByTagName(STATS_ROOT).item(0);
if (rootTag == null)
return;
NodeList childs = rootTag.getChildNodes();
for(int i = 0; i < childs.getLength(); i++)
{
Node child = childs.item(i);
if (child.getNodeType() != Node.ELEMENT_NODE)
continue;
NamedNodeMap attributes = child.getAttributes();
Date start = readDate(attributes, STATS_START);
Date end = readDate(attributes, STATS_END);
int passed = readInt(attributes, STATS_PASSED);
int failed = readInt(attributes, STATS_FAILED);
int skipped = readInt(attributes, STATS_SKIPPED);
int relearned = readInt(attributes, STATS_RELEARNED);
history.addSummary(start, end, passed, failed, skipped, relearned);
}
history.setIsLoaded(true);
}
/**
* @deprecated
*/
public static void writeLearnHistory(Document document, LearnHistory history)
{
// add lesson tag as root
Element statsTag = document.createElement(STATS_ROOT);
for (SessionSummary summary : history.getSummaries())
{
Element sessionTag = document.createElement(SESSION);
sessionTag.setAttribute(STATS_START, DATE_FORMAT.format(summary.getStart()));
sessionTag.setAttribute(STATS_END, DATE_FORMAT.format(summary.getEnd()));
sessionTag.setAttribute(STATS_PASSED, toInteger(summary.getPassed()));
sessionTag.setAttribute(STATS_FAILED, toInteger(summary.getFailed()));
sessionTag.setAttribute(STATS_SKIPPED, toInteger(summary.getSkipped()));
sessionTag.setAttribute(STATS_RELEARNED, toInteger(summary.getRelearned()));
statsTag.appendChild(sessionTag);
}
Element lessonTag = (Element)document.getElementsByTagName(LESSON).item(0);
if (lessonTag != null)
lessonTag.appendChild(statsTag);
else
document.appendChild(statsTag);
}
/**
* @return the folder where images were stored (usually a dedicated
* subfolder of given dir argument).
*/
public static File writeImageRepositoryToDisk(File dir) throws IOException
{
ImageRepository repository = ImageRepository.getInstance();
File imgDir = new File(dir + File.separator + IMAGE_FOLDER);
imgDir.mkdirs();
removeUnusedImages(repository, imgDir);
for (ImageItem item : repository.getImageItems())
{
File imgFile = new File(imgDir + File.separator + item.getId());
if (imgFile.exists())
{
// TODO if same file continue
}
FileOutputStream out = new FileOutputStream(imgFile, false);
out.write(item.getBytes());
out.close();
}
return imgDir;
}
private static void removeUnusedImages(ImageRepository repository, File imgDir)
{
Set<File> unusedFiles = new HashSet<File>(Arrays.asList(imgDir.listFiles()));
for (ImageItem item : repository.getImageItems())
{
File imgFile = new File(imgDir + File.separator + item.getId());
unusedFiles.remove(imgFile);
}
for (File unusedFile : unusedFiles)
{
unusedFile.delete();
}
}
private static void writeCategory(Document document, Element father, Category category)
{
Element categoryTag = document.createElement(CATEGORY);
categoryTag.setAttribute(NAME, category.getName());
father.appendChild(categoryTag);
// for all decks add a deck tag
for (int i = 0; i < category.getNumberOfDecks(); i++)
{
Element deckTag = document.createElement(DECK);
categoryTag.appendChild(deckTag);
// for all cards add a card tag
for (Card card : category.getLocalCards(i))
{
Element cardTag = writeCard(document, card);
deckTag.appendChild(cardTag);
}
}
// now add child categories
for (Category child : category.getChildCategories())
{
writeCategory(document, categoryTag, child);
}
}
private static Element writeCard(Document document, Card card)
{
Element cardTag = document.createElement(CARD);
// save card sides
cardTag.setAttribute(FRONTSIDE, card.getFrontSide().getText().getFormatted());
cardTag.setAttribute(BACKSIDE, card.getBackSide().getText().getFormatted());
// save dates
cardTag.setAttribute(DATE_CREATED, DATE_FORMAT.format(card.getDateCreated()));
cardTag.setAttribute(DATE_MODIFIED, DATE_FORMAT.format(card.getDateModified()));
cardTag.setAttribute(DATE_TOUCHED, DATE_FORMAT.format(card.getDateTouched()));
if (card.getDateTested() != null)
{
cardTag.setAttribute(DATE_TESTED, DATE_FORMAT.format(card.getDateTested()));
}
if (card.getDateExpired() != null)
{
cardTag.setAttribute(DATE_EXPIRED, DATE_FORMAT.format(card.getDateExpired()));
}
// save amount learned
cardTag.setAttribute(AMOUNT_LEARNED_FRONT,
Integer.toString(card.getLearnedAmount(true)));
cardTag.setAttribute(AMOUNT_LEARNED_BACK,
Integer.toString(card.getLearnedAmount(false)));
// save stats
cardTag.setAttribute(TESTS_TOTAL, Integer.toString(card.getTestsTotal()));
cardTag.setAttribute(TESTS_HIT, Integer.toString(card.getTestsPassed()));
// save images
cardTag.appendChild(writeImages(document, card.getFrontSide()));
cardTag.appendChild(writeImages(document, card.getBackSide()));
return cardTag;
}
private static Element writeImages(Document doc, CardSide cardSide)
{
Element sideElement = doc.createElement(SIDE);
for (String imgID : cardSide.getImages())
{
Element imgElement = doc.createElement(IMG);
imgElement.setAttribute(IMG_ID, imgID);
sideElement.appendChild(imgElement);
}
return sideElement;
}
private static void writeImageRepositoryToZip(ZipOutputStream zipOut)
throws IOException
{
ImageRepository repository = ImageRepository.getInstance();
for (ImageItem item : repository.getImageItems())
{
zipOut.putNextEntry(new ZipEntry(IMAGE_FOLDER + File.separator + item.getId()));
zipOut.write(item.getBytes());
zipOut.closeEntry();
}
}
private static void loadCategory(Category category, Category father,
Element categoryTag, int depth)
{
// for all child tags in category tag
int deckLevel = 0;
NodeList childs = categoryTag.getChildNodes();
for (int i = 0; i < childs.getLength(); i++)
{
Node child = childs.item(i);
// if deck tag
if (child.getNodeName().equalsIgnoreCase(DECK))
{
// for all card tags in deck tag
NodeList childTags = child.getChildNodes();
for (int j = 0; j < childTags.getLength(); j++)
{
Node childTag = childTags.item(j);
// if its a card child tag
if (!childTag.getNodeName().equalsIgnoreCase(CARD))
continue;
Card card = loadCard(childTag);
category.addCard(card, deckLevel);
}
deckLevel++;
}
// if category tag
else if (child.getNodeName().equalsIgnoreCase(CATEGORY))
{
Element catTag = (Element)child;
String name = catTag.getAttribute(NAME);
Category childCategory = category.getChildCategory(name);
if (childCategory == null)
{
childCategory = new Category(name);
category.addCategoryChild(childCategory);
}
loadCategory(childCategory, category, catTag, depth + 1);
}
}
}
private static Card loadCard(Node cardTag)
{
NamedNodeMap attributes = cardTag.getAttributes();
// read front/backside
String frontSide = attributes.getNamedItem(FRONTSIDE).getNodeValue();
String backSide = attributes.getNamedItem(BACKSIDE).getNodeValue();
// read dates
Date dateCreated = readDate(attributes, DATE_CREATED);
Date dateModified = readDate(attributes, DATE_MODIFIED);
Date dateTested = readDate(attributes, DATE_TESTED);
Date dateExpired = readDate(attributes, DATE_EXPIRED);
Date dateTouched = readDate(attributes, DATE_TOUCHED);
// just to be sure
if (dateCreated == null)
{
dateCreated = dateTested != null ? dateTested : new Date();
}
if (dateTouched == null)
{
dateTouched = dateTested != null ? dateTested : dateCreated;
}
// read amount learned
int frontAmountLearned = readInt(attributes, AMOUNT_LEARNED_FRONT);
int backAmountLearned = readInt(attributes, AMOUNT_LEARNED_BACK);
// read stats
int testsTotal = readInt(attributes, TESTS_TOTAL);
int testsHit = readInt(attributes, TESTS_HIT);
// create card
Card card = new Card(dateCreated, frontSide, backSide);
if (dateModified != null)
card.setDateModified(dateModified);
card.setDateTested(dateTested);
card.setDateExpired(dateExpired);
card.setDateTouched(dateTouched);
card.setLearnedAmount(true, frontAmountLearned);
card.setLearnedAmount(false, backAmountLearned);
card.incStats(testsHit, testsTotal);
// load images
card.getFrontSide().setImages(loadImages(cardTag, 0));
card.getBackSide().setImages(loadImages(cardTag, 1));
return card;
}
private static List<String> loadImages(Node cardTag, int side)
{
int sideIndex = 0;
NodeList cardChildren = cardTag.getChildNodes();
for (int i = 0; i < cardChildren.getLength(); i++)
{
Node sideTag = cardChildren.item(i);
if (!sideTag.getNodeName().equalsIgnoreCase(SIDE))
continue;
if (side != sideIndex)
{
sideIndex++;
continue;
}
NodeList childTags = sideTag.getChildNodes();
List<String> imgIDs = new ArrayList<String>(childTags.getLength());
for (int j = 0; j < childTags.getLength(); j++)
{
Node childTag = childTags.item(j);
if (!childTag.getNodeName().equalsIgnoreCase(IMG))
continue;
Node item = childTag.getAttributes().getNamedItem(IMG_ID);
if (item == null)
continue;
imgIDs.add(item.getNodeValue());
}
return imgIDs;
}
return new ArrayList<String>();
}
private static void loadImageRepositoryFromDisk(File dir)
{
ImageRepository repository = ImageRepository.getInstance();
File imgDir = new File(dir.getParent() + File.separator + IMAGE_FOLDER);
File[] files = imgDir.listFiles();
if (files == null)
return;
for (File file : files)
{
try
{
FileInputStream in = new FileInputStream(file);
repository.addImage(in, file.getName());
}
catch (FileNotFoundException e)
{
// ignore for now
}
catch (IOException e)
{
Main.logThrowable("could not load image "+file, e);
}
}
}
private static void loadImageFromZipEntry(InputStream in, ZipEntry entry)
throws IOException
{
ImageRepository repository = ImageRepository.getInstance();
String name = entry.getName();
if (!name.startsWith(IMAGE_FOLDER))
return;
repository.addImage(in, name.substring(IMAGE_FOLDER.length()+1));
}
private static void removeUnusedImagesFromRepository(Lesson lesson)
{
Set<String> usedImageIDs = new HashSet<String>();
List<Card> allCards = lesson.getRootCategory().getCards();
for (Card card : allCards)
{
usedImageIDs.addAll(card.getFrontSide().getImages());
usedImageIDs.addAll(card.getBackSide().getImages());
}
ImageRepository.getInstance().retain(usedImageIDs);
}
private static String toInteger(float num)
{
return Integer.toString((int)num);
}
private static int readInt(NamedNodeMap attributes, String attributeItem)
{
Node num = attributes.getNamedItem(attributeItem);
return (num != null) ? Integer.parseInt(num.getNodeValue()) : 0;
}
private static Date readDate(NamedNodeMap attributes, String attributeItem)
{
Node date = attributes.getNamedItem(attributeItem);
if (date != null)
{
try
{
return DATE_FORMAT.parse(date.getNodeValue());
}
catch (ParseException e)
{
Main.logThrowable("Could not parse date.", e);
}
}
return null;
}
}