Java tutorial
/**************************************************************************************** * Copyright (c) 2009 Daniel Svrd <daniel.svard@gmail.com> * * Copyright (c) 2009 Casey Link <unnamedrambler@gmail.com> * * Copyright (c) 2009 Edu Zamora <edu.zasu@gmail.com> * * Copyright (c) 2010 Norbert Nagold <norbert.nagold@gmail.com> * * Copyright (c) 2015 Houssam Salem <houssam.salem.au@gmail.com> * * * * 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.libanki; import android.content.ContentValues; import android.text.TextUtils; import com.ichi2.anki.exception.ConfirmModSchemaException; import com.ichi2.anki.exception.DeckRenameException; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.TreeMap; import java.util.regex.Pattern; // fixmes: // - make sure users can't set grad interval < 1 public class Decks { public static final String defaultDeck = "" + "{" + "'newToday': [0, 0]," // currentDay, count + "'revToday': [0, 0]," + "'lrnToday': [0, 0]," + "'timeToday': [0, 0]," // time in ms + "'conf': 1," + "'usn': 0," + "'desc': \"\"," + "'dyn': 0," // anki uses int/bool interchangably here + "'collapsed': False," // added in beta11 + "'extendNew': 10," + "'extendRev': 50" + "}"; private static final String defaultDynamicDeck = "" + "{" + "'newToday': [0, 0]," + "'revToday': [0, 0]," + "'lrnToday': [0, 0]," + "'timeToday': [0, 0]," + "'collapsed': False," + "'dyn': 1," + "'desc': \"\"," + "'usn': 0," + "'delays': null," + "'separate': True," // list of (search, limit, order); we only use first element for now + "'terms': [[\"\", 100, 0]]," + "'resched': True," + "'return': True" // currently unused + "}"; public static final String defaultConf = "" + "{" + "'name': \"Default\"," + "'new': {" + "'delays': [1, 10]," + "'ints': [1, 4, 7]," // 7 is not currently used + "'initialFactor': 2500," + "'separate': True," + "'order': " + Consts.NEW_CARDS_DUE + "," + "'perDay': 20," // may not be set on old decks + "'bury': True" + "}," + "'lapse': {" + "'delays': [10]," + "'mult': 0," + "'minInt': 1," + "'leechFails': 8," // type 0=suspend, 1=tagonly + "'leechAction': 0" + "}," + "'rev': {" + "'perDay': 100," + "'ease4': 1.3," + "'fuzz': 0.05," + "'minSpace': 1," // not currently used + "'ivlFct': 1," + "'maxIvl': 36500," // may not be set on old decks + "'bury': True" + "}," + "'maxTaken': 60," + "'timer': 0," + "'autoplay': True," + "'replayq': True," + "'mod': 0," + "'usn': 0" + "}"; private Collection mCol; private HashMap<Long, JSONObject> mDecks; private HashMap<Long, JSONObject> mDconf; private boolean mChanged; /** * Registry save/load * *********************************************************** */ public Decks(Collection col) { mCol = col; } public void load(String decks, String dconf) { mDecks = new HashMap<>(); mDconf = new HashMap<>(); try { JSONObject decksarray = new JSONObject(decks); JSONArray ids = decksarray.names(); for (int i = 0; i < ids.length(); i++) { String id = ids.getString(i); JSONObject o = decksarray.getJSONObject(id); long longId = Long.parseLong(id); mDecks.put(longId, o); } JSONObject confarray = new JSONObject(dconf); ids = confarray.names(); for (int i = 0; ids != null && i < ids.length(); i++) { String id = ids.getString(i); mDconf.put(Long.parseLong(id), confarray.getJSONObject(id)); } } catch (JSONException e) { throw new RuntimeException(e); } mChanged = false; } public void save() { save(null); } /** * Can be called with either a deck or a deck configuration. */ public void save(JSONObject g) { if (g != null) { try { g.put("mod", Utils.intNow()); g.put("usn", mCol.usn()); } catch (JSONException e) { throw new RuntimeException(e); } } mChanged = true; } public void flush() { ContentValues values = new ContentValues(); if (mChanged) { try { JSONObject decksarray = new JSONObject(); for (Map.Entry<Long, JSONObject> d : mDecks.entrySet()) { decksarray.put(Long.toString(d.getKey()), d.getValue()); } values.put("decks", Utils.jsonToString(decksarray)); JSONObject confarray = new JSONObject(); for (Map.Entry<Long, JSONObject> d : mDconf.entrySet()) { confarray.put(Long.toString(d.getKey()), d.getValue()); } values.put("dconf", Utils.jsonToString(confarray)); } catch (JSONException e) { throw new RuntimeException(e); } mCol.getDb().update("col", values); mChanged = false; } } /** * Deck save/load * *********************************************************** */ public Long id(String name) { return id(name, true); } public Long id(String name, boolean create) { return id(name, create, defaultDeck); } public Long id(String name, String type) { return id(name, true, type); } /** * Add a deck with NAME. Reuse deck if already exists. Return id as int. */ public Long id(String name, boolean create, String type) { try { name = name.replace("\"", ""); for (Map.Entry<Long, JSONObject> g : mDecks.entrySet()) { if (g.getValue().getString("name").equalsIgnoreCase(name)) { return g.getKey(); } } if (!create) { return null; } if (name.contains("::")) { // not top level; ensure all parents exist name = _ensureParents(name); } JSONObject g; long id; g = new JSONObject(type); g.put("name", name); while (true) { id = Utils.intNow(1000); if (!mDecks.containsKey(id)) { break; } } g.put("id", id); mDecks.put(id, g); save(g); maybeAddToActive(); //runHook("newDeck"); // TODO return id; } catch (JSONException e) { throw new RuntimeException(e); } } public void rem(long did) { rem(did, false); } public void rem(long did, boolean cardsToo) { rem(did, cardsToo, true); } /** * Remove the deck. If cardsToo, delete any cards inside. */ public void rem(long did, boolean cardsToo, boolean childrenToo) { try { if (did == 1) { // we won't allow the default deck to be deleted, but if it's a // child of an existing deck then it needs to be renamed JSONObject deck = get(did); if (deck.getString("name").contains("::")) { deck.put("name", "Default"); save(deck); } return; } // log the removal regardless of whether we have the deck or not mCol._logRem(new long[] { did }, Consts.REM_DECK); // do nothing else if doesn't exist if (!mDecks.containsKey(did)) { return; } JSONObject deck = get(did); if (deck.getInt("dyn") != 0) { // deleting a cramming deck returns cards to their previous deck // rather than deleting the cards mCol.getSched().emptyDyn(did); if (childrenToo) { for (long id : children(did).values()) { rem(id, cardsToo); } } } else { // delete children first if (childrenToo) { // we don't want to delete children when syncing for (long id : children(did).values()) { rem(id, cardsToo); } } // delete cards too? if (cardsToo) { // don't use cids(), as we want cards in cram decks too ArrayList<Long> cids = mCol.getDb().queryColumn(Long.class, "SELECT id FROM cards WHERE did = " + did + " OR odid = " + did, 0); mCol.remCards(Utils.arrayList2array(cids)); } } } catch (JSONException e) { throw new RuntimeException(e); } // delete the deck and add a grave mDecks.remove(did); // ensure we have an active deck if (active().contains(did)) { select(mDecks.keySet().iterator().next()); } save(); } public ArrayList<String> allNames() { return allNames(true); } /** * An unsorted list of all deck names. */ public ArrayList<String> allNames(boolean dyn) { ArrayList<String> list = new ArrayList<>(); try { if (dyn) { for (JSONObject x : mDecks.values()) { list.add(x.getString("name")); } } else { for (JSONObject x : mDecks.values()) { if (x.getInt("dyn") == 0) { list.add(x.getString("name")); } } } } catch (JSONException e) { throw new RuntimeException(e); } return list; } /** * A list of all decks. */ public ArrayList<JSONObject> all() { ArrayList<JSONObject> decks = new ArrayList<>(); for (JSONObject deck : mDecks.values()) { decks.add(deck); } return decks; } /** * Return the same deck list from all() but sorted using a comparator that ensures the same * sorting order for decks as the desktop client. * * This method does not exist in the original python module but *must* be used for any user * interface components that display a deck list to ensure the ordering is consistent. */ public ArrayList<JSONObject> allSorted() { ArrayList<JSONObject> decks = all(); Collections.sort(decks, new Comparator<JSONObject>() { @Override public int compare(JSONObject lhs, JSONObject rhs) { try { return lhs.getString("name").compareTo(rhs.getString("name")); } catch (JSONException e) { throw new RuntimeException(e); } } }); return decks; } public Long[] allIds() { return mDecks.keySet().toArray(new Long[mDecks.keySet().size()]); } public void collpase(long did) { try { JSONObject deck = get(did); deck.put("collapsed", !deck.getBoolean("collapsed")); save(deck); } catch (JSONException e) { throw new RuntimeException(e); } } public void collapseBrowser(long did) { try { JSONObject deck = get(did); boolean collapsed = deck.optBoolean("browserCollapsed", false); deck.put("browserCollapsed", !collapsed); save(deck); } catch (JSONException e) { throw new RuntimeException(e); } } /** * Return the number of decks. */ public int count() { return mDecks.size(); } public JSONObject get(long did) { return get(did, true); } public JSONObject get(long did, boolean _default) { if (mDecks.containsKey(did)) { return mDecks.get(did); } else if (_default) { return mDecks.get(1l); } else { return null; } } /** * Get deck with NAME. */ public JSONObject byName(String name) { try { for (JSONObject m : mDecks.values()) { if (m.get("name").equals(name)) { return m; } } } catch (JSONException e) { throw new RuntimeException(e); } return null; } /** * Add or update an existing deck. Used for syncing and merging. */ public void update(JSONObject g) { try { mDecks.put(g.getLong("id"), g); } catch (JSONException e) { throw new RuntimeException(e); } maybeAddToActive(); // mark registry changed, but don't bump mod time save(); } /** * Rename deck prefix to NAME if not exists. Updates children. */ public void rename(JSONObject g, String newName) throws DeckRenameException { // make sure target node doesn't already exist if (allNames().contains(newName)) { throw new DeckRenameException(DeckRenameException.ALREADY_EXISTS); } try { // ensure we have parents newName = _ensureParents(newName); // make sure we're not nesting under a filtered deck if (newName.contains("::")) { List<String> parts = Arrays.asList(newName.split("::", -1)); String newParent = TextUtils.join("::", parts.subList(0, parts.size() - 1)); if (byName(newParent).getInt("dyn") != 0) { throw new DeckRenameException(DeckRenameException.FILTERED_NOSUBDEKCS); } } // rename children String oldName = g.getString("name"); for (JSONObject grp : all()) { if (grp.getString("name").startsWith(oldName + "::")) { // In Java, String.replaceFirst consumes a regex so we need to quote the pattern to be safe grp.put("name", grp.getString("name").replaceFirst(Pattern.quote(oldName + "::"), newName + "::")); save(grp); } } // adjust name g.put("name", newName); // ensure we have parents again, as we may have renamed parent->child newName = _ensureParents(newName); save(g); // renaming may have altered active did order maybeAddToActive(); } catch (JSONException e) { throw new RuntimeException(e); } } public void renameForDragAndDrop(Long draggedDeckDid, Long ontoDeckDid) throws DeckRenameException { try { JSONObject draggedDeck = get(draggedDeckDid); String draggedDeckName = draggedDeck.getString("name"); String ontoDeckName = get(ontoDeckDid).getString("name"); if (ontoDeckDid == null) { if (_path(draggedDeckName).size() > 1) { rename(draggedDeck, _basename(draggedDeckName)); } } else if (_canDragAndDrop(draggedDeckName, ontoDeckName)) { draggedDeck = get(draggedDeckDid); draggedDeckName = draggedDeck.getString("name"); ontoDeckName = get(ontoDeckDid).getString("name"); rename(draggedDeck, ontoDeckName + "::" + _basename(draggedDeckName)); } } catch (JSONException e) { throw new RuntimeException(e); } } private boolean _canDragAndDrop(String draggedDeckName, String ontoDeckName) { if (draggedDeckName.equals(ontoDeckName) || _isParent(ontoDeckName, draggedDeckName) || _isAncestor(draggedDeckName, ontoDeckName)) { return false; } else { return true; } } private boolean _isParent(String parentDeckName, String childDeckName) { List<String> parentDeckPath = _path(parentDeckName); parentDeckPath.add(_basename(childDeckName)); Iterator<String> cpIt = _path(childDeckName).iterator(); Iterator<String> ppIt = parentDeckPath.iterator(); while (cpIt.hasNext() && ppIt.hasNext()) { if (!cpIt.next().equals(ppIt.next())) { return false; } } return true; } private boolean _isAncestor(String ancestorDeckName, String descendantDeckName) { Iterator<String> apIt = _path(ancestorDeckName).iterator(); Iterator<String> dpIt = _path(descendantDeckName).iterator(); while (apIt.hasNext() && dpIt.hasNext()) { if (!apIt.next().equals(dpIt.next())) { return false; } } return true; } private List<String> _path(String name) { return Arrays.asList(name.split("::", -1)); } private String _basename(String name) { List<String> path = _path(name); return path.get(path.size() - 1); } /** * Ensure parents exist, and return name with case matching parents. */ public String _ensureParents(String name) { String s = ""; List<String> path = _path(name); if (path.size() < 2) { return name; } for (String p : path.subList(0, path.size() - 1)) { if (TextUtils.isEmpty(s)) { s += p; } else { s += "::" + p; } // fetch or create long did = id(s); // get original case s = name(did); } name = s + "::" + path.get(path.size() - 1); return name; } /** * Deck configurations * *********************************************************** */ /** * A list of all deck config. */ public ArrayList<JSONObject> allConf() { ArrayList<JSONObject> confs = new ArrayList<>(); for (JSONObject c : mDconf.values()) { confs.add(c); } return confs; } public JSONObject confForDid(long did) { JSONObject deck = get(did, false); assert deck != null; if (deck.has("conf")) { try { JSONObject conf = getConf(deck.getLong("conf")); conf.put("dyn", 0); return conf; } catch (JSONException e) { throw new RuntimeException(e); } } // dynamic decks have embedded conf return deck; } public JSONObject getConf(long confId) { return mDconf.get(confId); } public void updateConf(JSONObject g) { try { mDconf.put(g.getLong("id"), g); } catch (JSONException e) { throw new RuntimeException(e); } save(); } public long confId(String name) { return confId(name, defaultConf); } /** * Create a new configuration and return id. */ public long confId(String name, String cloneFrom) { JSONObject c; long id; try { c = new JSONObject(cloneFrom); while (true) { id = Utils.intNow(1000); if (!mDconf.containsKey(id)) { break; } } c.put("id", id); c.put("name", name); } catch (JSONException e) { throw new RuntimeException(e); } mDconf.put(id, c); save(c); return id; } /** * Remove a configuration and update all decks using it. * @throws ConfirmModSchemaException */ public void remConf(long id) throws ConfirmModSchemaException { assert id != 1; mCol.modSchema(true); mDconf.remove(id); try { for (JSONObject g : all()) { // ignore cram decks if (!g.has("conf")) { continue; } if (g.getString("conf").equals(Long.toString(id))) { g.put("conf", 1); save(g); } } } catch (JSONException e) { throw new RuntimeException(e); } } public void setConf(JSONObject grp, long id) { try { grp.put("conf", id); } catch (JSONException e) { throw new RuntimeException(e); } save(grp); } public List<Long> didsForConf(JSONObject conf) { List<Long> dids = new ArrayList<>(); try { for (JSONObject deck : mDecks.values()) { if (deck.has("conf") && deck.getLong("conf") == conf.getLong("id")) { dids.add(deck.getLong("id")); } } return dids; } catch (JSONException e) { throw new RuntimeException(e); } } public void restoreToDefault(JSONObject conf) { try { int oldOrder = conf.getJSONObject("new").getInt("order"); JSONObject _new = new JSONObject(defaultConf); _new.put("id", conf.getLong("id")); _new.put("name", conf.getString("name")); mDconf.put(conf.getLong("id"), _new); save(_new); // if it was previously randomized, resort if (oldOrder == 0) { mCol.getSched().resortConf(_new); } } catch (JSONException e) { throw new RuntimeException(e); } } /** * Deck utils * *********************************************************** */ public String name(long did) { return name(did, false); } public String name(long did, boolean _default) { try { JSONObject deck = get(did, _default); if (deck != null) { return deck.getString("name"); } return "[no deck]"; } catch (JSONException e) { throw new RuntimeException(e); } } public String nameOrNone(long did) { JSONObject deck = get(did, false); if (deck != null) { try { return deck.getString("name"); } catch (JSONException e) { throw new RuntimeException(e); } } return null; } public void setDeck(long[] cids, long did) { mCol.getDb().execute("update cards set did=?,usn=?,mod=? where id in " + Utils.ids2str(cids), new Object[] { did, mCol.usn(), Utils.intNow() }); } private void maybeAddToActive() { // reselect current deck, or default if current has disappeared JSONObject c = current(); try { select(c.getLong("id")); } catch (JSONException e) { throw new RuntimeException(e); } } public Long[] cids(long did) { return cids(did, false); } public Long[] cids(long did, boolean children) { if (!children) { return Utils.list2ObjectArray( mCol.getDb().queryColumn(Long.class, "select id from cards where did=" + did, 0)); } List<Long> dids = new ArrayList<>(); dids.add(did); for (Map.Entry<String, Long> entry : children(did).entrySet()) { dids.add(entry.getValue()); } return Utils.list2ObjectArray(mCol.getDb().queryColumn(Long.class, "select id from cards where did in " + Utils.ids2str(Utils.arrayList2array(dids)), 0)); } public void recoverOrphans() { Long[] dids = allIds(); boolean mod = mCol.getDb().getMod(); mCol.getDb().execute("update cards set did = 1 where did not in " + Utils.ids2str(dids)); mCol.getDb().setMod(mod); } /** * Deck selection * *********************************************************** */ /** * The currently active dids. Make sure to copy before modifying. */ public LinkedList<Long> active() { try { JSONArray ja = mCol.getConf().getJSONArray("activeDecks"); LinkedList<Long> result = new LinkedList<>(); for (int i = 0; i < ja.length(); i++) { result.add(ja.getLong(i)); } return result; } catch (JSONException e) { throw new RuntimeException(e); } } /** * The currently selected did. */ public long selected() { try { return mCol.getConf().getLong("curDeck"); } catch (JSONException e) { throw new RuntimeException(e); } } public JSONObject current() { return get(selected()); } /** * Select a new branch. */ public void select(long did) { try { String name = mDecks.get(did).getString("name"); // current deck mCol.getConf().put("curDeck", Long.toString(did)); // and active decks (current + all children) TreeMap<String, Long> actv = children(did); // Note: TreeMap is already sorted actv.put(name, did); JSONArray ja = new JSONArray(); for (Long n : actv.values()) { ja.put(n); } mCol.getConf().put("activeDecks", ja); mChanged = true; } catch (JSONException e) { throw new RuntimeException(e); } } /** * All children of did as nodes of (key:name, value:id) * * TODO: There is likely no need for this collection to be a TreeMap. This method should not * need to sort on behalf of select(). */ public TreeMap<String, Long> children(long did) { String name; try { name = get(did).getString("name"); TreeMap<String, Long> actv = new TreeMap<>(); for (JSONObject g : all()) { if (g.getString("name").startsWith(name + "::")) { actv.put(g.getString("name"), g.getLong("id")); } } return actv; } catch (JSONException e) { throw new RuntimeException(e); } } /** * All parents of did. */ public List<JSONObject> parents(long did) { // get parent and grandparent names List<String> parents = new ArrayList<>(); try { List<String> parts = Arrays.asList(get(did).getString("name").split("::", -1)); for (String part : parts.subList(0, parts.size() - 1)) { if (parents.size() == 0) { parents.add(part); } else { parents.add(parents.get(parents.size() - 1) + "::" + part); } } // convert to objects List<JSONObject> oParents = new ArrayList<>(); for (int i = 0; i < parents.size(); i++) { oParents.add(i, get(id(parents.get(i)))); } return oParents; } catch (JSONException e) { throw new RuntimeException(e); } } /** * Sync handling * *********************************************************** */ public void beforeUpload() { try { for (JSONObject d : all()) { d.put("usn", 0); } for (JSONObject c : allConf()) { c.put("usn", 0); } } catch (JSONException e) { throw new RuntimeException(e); } save(); } /** * Dynamic decks ***************************************************************/ /** * Return a new dynamic deck and set it as the current deck. */ public long newDyn(String name) { long did = id(name, defaultDynamicDeck); select(did); return did; } public boolean isDyn(long did) { try { return get(did).getInt("dyn") != 0; } catch (JSONException e) { throw new RuntimeException(e); } } /* * *********************************************************** * The methods below are not in LibAnki. * *********************************************************** */ public String getActualDescription() { return current().optString("desc", ""); } public HashMap<Long, JSONObject> getDecks() { return mDecks; } }