Project: jMemorize
/*
 * 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
    } 
}