/**************************************************************************************** * Copyright (c) 2009 Daniel Svärd <daniel.svard@gmail.com> * * Copyright (c) 2010 Rick Gruber-Riemer <rick@vanosten.net> * * * * 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 3 of the License, 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, see <http://www.gnu.org/licenses/>. * ****************************************************************************************/ package com.ichi2.anki.model; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.TreeMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.ichi2.anki.Utils; import com.ichi2.anki.db.AnkiDb; import com.mindprod.common11.StringTools; /** * Anki model. A model describes the type of information you want to input, and the type of cards which should be * generated. See http://ichi2.net/anki/wiki/KeyTermsAndConcepts#Models There can be several models in a Deck. A Model * is related to a Deck via attribute deckId. A CardModel is related to a Model via CardModel's modelId. A FieldModel is * related to a Model via FieldModel's modelId A Card has a link to CardModel via Card's cardModelId A Card has a link * to a Fact via Card's factId A Field has a link to a Fact via Field's factId A Field has a link to a FieldModel via * Field's fieldModelId => In order to get the CardModel and all FieldModels for a given Card: % the CardModel can * directly be retrieved from the DB using the Card's cardModelId % then from the retrieved CardModel we can get the * modelId % using the modelId we can get all FieldModels from the DB % (alternatively in the CardModel the qformat and * aformat fields could be parsed for relevant field names and then this used to only get the necessary fields. But this * adds a lot overhead vs. using a bit more memory) */ public class Model { public static Logger log = LoggerFactory.getLogger(Model.class); /** Singleton */ // private static Model currentModel; /** Text align constants */ private static final String[] align_text = { "center", "left", "right" }; /** * A Map of the currently loaded Models. The Models are loaded from the database as soon as they are needed for the * first time. This is a compromise between RAM need, speed and the probability with which more than one Model is * needed. If only one model is needed, then RAM consumption is basically the same as having a static "currentModel" * variable. If more than one Model is needed, then more RAM is needed, but on the other hand side Model and its * related CardModel and FieldModel are not reloaded again and again. This Map uses the Model.id field as key */ private static HashMap<Long, Model> sModels = new HashMap<Long, Model>(); /** * As above but mapping from CardModel to related Model (because when one has a Card, then you need to jump from * CardModel to Model. */ private static HashMap<Long, Model> sCardModelToModelMap = new HashMap<Long, Model>(); // BEGIN SQL table entries private long mId; // Primary key private long mDeckId; // Foreign key private double mCreated = Utils.now(); private double mModified = Utils.now(); private String mTags = ""; private String mName; private String mDescription = ""; private String mFeatures = ""; // used as the media url private double mSpacing = 0.1; // obsolete as of libanki 1.1.4 private double mInitialSpacing = 60; // obsolete as of libanki 1.1.4 private int mSource = 0; // BEGIN SQL table entries private Deck mDeck; /** Map for convenience and speed which contains CardModels from current model */ private LinkedHashMap<Long, CardModel> mCardModelsMap = new LinkedHashMap<Long, CardModel>(); /** Map for convenience and speed which contains FieldModels from current model */ private TreeMap<Long, FieldModel> mFieldModelsMap = new TreeMap<Long, FieldModel>(); /** Map for convenience and speed which contains the CSS code related to a CardModel */ private HashMap<Long, String> mCssCardModelMap = new HashMap<Long, String>(); private HashMap<Long, String> mColorCardModelMap = new HashMap<Long, String>(); /** * The percentage chosen in preferences for font sizing at the time when the css for the CardModels related to this * Model was calculated in prepareCSSForCardModels. */ private transient int mDisplayPercentage = 0; private boolean mInvertedColor = false; private Model(Deck deck, String name) { mDeck = deck; mName = name; mId = Utils.genID(); } private Model(Deck deck) { this(deck, ""); } // XXX: Unused // public void setModified() { // mModified = Utils.now(); // } /** * FIXME: this should be called whenever the deck is changed. Otherwise unnecessary space will be used. XXX: Unused */ protected static final void reset() { sModels = new HashMap<Long, Model>(); sCardModelToModelMap = new HashMap<Long, Model>(); } /** * Returns a Model based on the submitted identifier. If a model id is submitted (isModelId = true), then the Model * data and all related CardModel and FieldModel data are loaded, unless the id is the same as one of the * currentModel. If a cardModel id is submitted, then the related Model data and all related CardModel and * FieldModel data are loaded unless the cardModel id is already in the cardModel map. FIXME: nothing is done to * treat db failure or non-existing identifiers * * @param deck The deck we are working with * @param identifier a cardModel id or a model id * @param isModelId if true then the submitted identifier is a model id; otherwise the identifier is a cardModel id * @return */ protected static Model getModel(Deck deck, long identifier, boolean isModelId) { if (!isModelId) { // check whether the identifier is in the cardModelToModelMap if (!sCardModelToModelMap.containsKey(identifier)) { // get the modelId long myModelId = CardModel.modelIdFromDB(deck, identifier); // get the model loadFromDBPlusRelatedModels(deck, myModelId); } return sCardModelToModelMap.get(identifier); } // else it is a modelId if (!sModels.containsKey(identifier)) { // get the model loadFromDBPlusRelatedModels(deck, identifier); } return sModels.get(identifier); } public static HashMap<Long, Model> getModels(Deck deck) { Model mModel; HashMap<Long, Model> mModels = new HashMap<Long, Model>(); ResultSet result = null; AnkiDb ankiDB = deck.getDB(); try { result = ankiDB.rawQuery("SELECT id FROM models"); while (result.next()) { Long id = result.getLong(1); mModel = getModel(deck, id, true); mModels.put(id, mModel); } } catch (SQLException e) { e.printStackTrace(); } finally { if (result != null) { try { result.close(); } catch (SQLException e) { } } } return mModels; } public TreeMap<Long, FieldModel> getFieldModels() { TreeMap<Long, FieldModel> mFieldModels = new TreeMap<Long, FieldModel>(); FieldModel.fromDb(mDeck, mId, mFieldModels); return mFieldModels; } public List<CardModel> getCardModels() { return new ArrayList<CardModel>(mCardModelsMap.values()); } protected final CardModel getCardModel(long identifier) { return mCardModelsMap.get(identifier); } /** * Loads the Model from the database. then loads the related CardModels and FieldModels from the database. * * @param deck * @param modelId */ private static void loadFromDBPlusRelatedModels(Deck deck, long modelId) { Model currentModel = fromDb(deck, modelId); // load related card models CardModel.fromDb(deck, currentModel.mId, currentModel.mCardModelsMap); // load related field models FieldModel.fromDb(deck, modelId, currentModel.mFieldModelsMap); // make relations to maps sModels.put(currentModel.mId, currentModel); CardModel myCardModel = null; for (Map.Entry<Long, CardModel> entry : currentModel.mCardModelsMap.entrySet()) { myCardModel = entry.getValue(); sCardModelToModelMap.put(myCardModel.getId(), currentModel); } } protected void saveToDBPlusRelatedModels(Deck deck) { for (CardModel cm : mCardModelsMap.values()) { cm.toDB(deck); } for (FieldModel fm : mFieldModelsMap.values()) { fm.toDB(deck); } toDB(deck); } protected void toDB(Deck deck) { Map<String, Object> values = new HashMap<String, Object>(); values.put("id", mId); values.put("deckid", mDeckId); values.put("created", mCreated); values.put("modified", mModified); values.put("tags", mTags); values.put("name", mName); values.put("description", mDescription); values.put("features", mFeatures); values.put("spacing", mSpacing); values.put("initialSpacing", mInitialSpacing); values.put("source", mSource); deck.getDB().update(deck, "models", values, "id = " + mId); } /** * Loads a model from the database based on the id. FIXME: nothing is done in case of db error or no returned row * * @param deck * @param id * @return */ private static Model fromDb(Deck deck, long id) { ResultSet result = null; Model model = null; try { StringBuffer query = new StringBuffer(); query.append("SELECT id, deckId, created, modified, tags, name, description"); query.append(", features, spacing, initialSpacing, source"); query.append(" FROM models"); query.append(" WHERE id = ").append(id); result = deck.getDB().rawQuery(query.toString()); if (result.next()) { model = new Model(deck); } int i = 1; model.mId = result.getLong(i++); // Primary key model.mDeckId = result.getLong(i++); // Foreign key model.mCreated = result.getDouble(i++); model.mModified = result.getDouble(i++); model.mTags = result.getString(i++); model.mName = result.getString(i++); model.mDescription = result.getString(i++); model.mFeatures = result.getString(i++); model.mSpacing = result.getDouble(i++); model.mInitialSpacing = result.getDouble(i++); model.mSource = result.getInt(i++); } catch (SQLException e) { e.printStackTrace(); } finally { if (result != null) { try { result.close(); } catch (SQLException e) { } } } return model; } /** * @return the ID */ public long getId() { return mId; } /** * Prepares the CSS for all CardModels in this Model */ /* private void prepareCSSForCardModels(boolean invertedColors, int nightModeBackground) { CardModel myCardModel = null; String cssString = null; for (Map.Entry<Long, CardModel> entry : mCardModelsMap.entrySet()) { myCardModel = entry.getValue(); cssString = createCSSForFontColorSize(myCardModel.getId(), mDisplayPercentage, invertedColors, nightModeBackground); mCssCardModelMap.put(myCardModel.getId(), cssString); } } */ /** * Prepares the Background Colors for all CardModels in this Model */ /* private void prepareColorForCardModels(boolean invertedColors, int nightModeBackground) { CardModel myCardModel = null; String color = null; mColorCardModelMap.clear(); for (Map.Entry<Long, CardModel> entry : mCardModelsMap.entrySet()) { myCardModel = entry.getValue(); color = invertColor(myCardModel.getLastFontColour(), invertedColors); if (nightModeBackground != Color.BLACK && Color.parseColor(color) == Color.BLACK) { color = String.format("#%X", nightModeBackground); } mColorCardModelMap.put(myCardModel.getId(), color); } } */ /** * Returns a cached CSS for the font color and font size of a given CardModel taking into account the included * fields * * @param myCardModelId * @param percentage the preference factor to use for calculating the display font size from the cardmodel and * fontmodel font size * @return the html contents surrounded by a css style which contains class styles for answer/question and fields */ /* protected final String getCSSForFontColorSize(long myCardModelId, int percentage, boolean invertedColors, int nightModeBackground) { // If the percentage or night mode has changed, prepare for them. if (mDisplayPercentage != percentage || mInvertedColor != invertedColors) { mDisplayPercentage = percentage; mInvertedColor = invertedColors; prepareColorForCardModels(invertedColors, nightModeBackground); prepareCSSForCardModels(invertedColors, nightModeBackground); } return mCssCardModelMap.get(myCardModelId); } */ /* protected final int getBackgroundColor(long myCardModelId) { String color = mColorCardModelMap.get(myCardModelId); if (color != null) { return Color.parseColor(color); } else { return Color.WHITE; } } */ /** * @param myCardModelId * @param percentage the factor to apply to the font size in card model to the display size (in %) * @return the html contents surrounded by a css style which contains class styles for answer/question and fields */ /* private String createCSSForFontColorSize(long myCardModelId, int percentage, boolean invertedColors, int nightModeBackground) { StringBuffer sb = new StringBuffer(); sb.append("<!-- ").append(percentage).append(" % display font size-->"); sb.append("<style type=\"text/css\">\n"); CardModel myCardModel = mCardModelsMap.get(myCardModelId); // body background if (null != myCardModel.getLastFontColour() && 0 < myCardModel.getLastFontColour().trim().length()) { String color = invertColor(myCardModel.getLastFontColour(), invertedColors); if (nightModeBackground != Color.BLACK && Color.parseColor(color) == Color.BLACK) { color = String.format("#%X", nightModeBackground); } sb.append("body {background-color:").append(color).append(";}\n"); } // question sb.append(".").append(Reviewer.QUESTION_CLASS).append(" {\n"); sb.append(calculateDisplay(percentage, myCardModel.getQuestionFontFamily(), myCardModel.getQuestionFontSize(), myCardModel.getQuestionFontColour(), myCardModel.getQuestionAlign(), false, invertedColors)); sb.append("}\n"); // answer (alignment is stored in question as alignment is shared in question and answer) sb.append(".").append(Reviewer.ANSWER_CLASS).append(" {\n"); sb.append(calculateDisplay(percentage, myCardModel.getAnswerFontFamily(), myCardModel.getAnswerFontSize(), myCardModel.getAnswerFontColour(), myCardModel.getQuestionAlign(), false, invertedColors)); sb.append("}\n"); // css for fields. Gets css for all fields no matter whether they actually are used in a given card model FieldModel myFieldModel = null; String hexId = null; // a FieldModel id in unsigned hexa code for the class attribute for (Map.Entry<Long, FieldModel> entry : mFieldModelsMap.entrySet()) { myFieldModel = entry.getValue(); hexId = "fm" + Long.toHexString(myFieldModel.getId()); sb.append(".").append(hexId).append(" {\n"); sb.append(calculateDisplay(percentage, myFieldModel.getQuizFontFamily(), myFieldModel.getQuizFontSize(), myFieldModel.getQuizFontColour(), 0, true, invertedColors)); sb.append("}\n"); } // finish sb.append("</style>"); return sb.toString(); } */ /** * Returns a string where all colors have been inverted. * It applies to anything that is in a tag and looks like #FFFFFF * * Example: Here only #000000 will be replaced (#777777 is content) * <span style="color: #000000;">Code #777777 is the grey color</span> * * This is done with a state machine with 2 states: * - 0: within content * - 1: within a tag */ public static String invertColors(String text, boolean invert) { if (invert) { int state = 0; StringBuffer inverted = new StringBuffer(text.length()); for(int i=0; i<text.length(); i++) { char character = text.charAt(i); if (state == 1 && character == '#') { inverted.append(invertColor(text.substring(i+1, i+7), true)); } else { if (character == '<') { state = 1; } if (character == '>') { state = 0; } inverted.append(character); } } return inverted.toString(); } else { return text; } } private static String invertColor(String color, boolean invert) { if (invert) { if (color != null) { color = StringTools.toUpperCase(color); } final char[] items = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'}; final char[] tmpItems = {'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v'}; for (int i = 0; i < 16; i++) { color = color.replace(items[i], tmpItems[15-i]); } for (int i = 0; i < 16; i++) { color = color.replace(tmpItems[i], items[i]); } } return color; } private static String calculateDisplay(int percentage, String fontFamily, int fontSize, String fontColour, int align, boolean isField, boolean invertedColors) { StringBuffer sb = new StringBuffer(); if (null != fontFamily && 0 < fontFamily.trim().length()) { sb.append("font-family:\"").append(fontFamily).append("\";\n"); } if (null != fontColour && 0 < fontColour.trim().length()) { sb.append("color:").append(invertColor(fontColour, invertedColors)).append(";\n"); } if (0 < fontSize) { sb.append("font-size:"); sb.append((percentage * fontSize) / 100); sb.append("px;\n"); } if (!isField) { sb.append("text-align:"); sb.append(align_text[align]); sb.append(";\n"); sb.append("padding-left:5px;\n"); sb.append("padding-right:5px;\n"); } return sb.toString(); } /** * @return the name */ public String getName() { return mName; } /** * @return the tags */ public String getTags() { return mTags; } public String getFeatures() { return mFeatures; } public Boolean hasTag(String tag) { if(mTags==null || mTags.equals("")) return false; if(mTags.equals(tag)) return true; return Arrays.asList(mTags.split(" ")).contains(tag); } }