com.hichinaschool.flashcards.libanki.importer.Anki2Importer.java Source code

Java tutorial

Introduction

Here is the source code for com.hichinaschool.flashcards.libanki.importer.Anki2Importer.java

Source

/***************************************************************************************
 * Copyright (c) 2012 Norbert Nagold <norbert.nagold@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.hichinaschool.flashcards.libanki.importer;

import android.content.res.Resources;
import android.database.Cursor;
import android.util.Log;
import com.google.gson.stream.JsonReader;
import com.hichinaschool.flashcards.anki.AnkiDatabaseManager;
import com.hichinaschool.flashcards.anki.AnkiDroidApp;
import com.hichinaschool.flashcards.anki.BackupManager;
import com.hichinaschool.flashcards.anki.R;
import com.hichinaschool.flashcards.async.DeckTask;
import com.hichinaschool.flashcards.libanki.Collection;
import com.hichinaschool.flashcards.libanki.Media;
import com.hichinaschool.flashcards.libanki.Storage;
import com.hichinaschool.flashcards.libanki.Utils;

import org.json.JSONException;
import org.json.JSONObject;

import java.io.*;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipFile;

public class Anki2Importer {

    Collection mCol;
    ZipFile mZip;
    int mTotal;
    ArrayList<String> mLog;
    long mTs;
    String mDeckPrefix = null;
    DeckTask.ProgressCallback mProgress;
    Resources mResources;

    Collection mDst;
    Collection mSrc;
    String mDstMediaDir;

    HashMap<String, Object[]> mNotes;
    HashMap<String, HashMap<Integer, Long>> mCards;
    HashMap<Long, Long> mDecks;
    HashMap<Long, Long> mModelMap;
    HashMap<String, String> mChangedGuids;
    private HashMap<String, String> nameToNum;

    private static final int GUID = 1;
    private static final int MID = 2;

    private static final int MEDIAPICKLIMIT = 1024;

    private static final String CHECKMARK = "\u2714";

    public Anki2Importer(Collection col, String file, DeckTask.ProgressCallback progressCallback)
            throws IOException {
        mCol = col;
        mZip = new ZipFile(new File(file), ZipFile.OPEN_READ);
        mTotal = 0;
        mLog = new ArrayList<String>();
        mProgress = progressCallback;
        if (mProgress != null) {
            mResources = mProgress.getResources();
        }
        nameToNum = new HashMap<String, String>();
    }

    private void publishProgress(boolean unpacking, int notesDone, int cardsDone, boolean cleanup) {
        if (mProgress != null && mResources != null) {
            mProgress.publishProgress(new DeckTask.TaskData(mResources.getString(R.string.import_add_progress,
                    (unpacking ? CHECKMARK : "-"), notesDone, cardsDone, (cleanup ? CHECKMARK : "-"))));
        }
    }

    public int run() {
        publishProgress(false, 0, 0, false);
        try {
            // extract the deck from the zip file
            String tempDir = AnkiDroidApp.getCurrentAnkiDroidDirectory() + "/tmpzip";
            // from anki2.py
            String colFile = tempDir + "/collection.anki2";
            if (!Utils.unzipFiles(mZip, tempDir, new String[] { "collection.anki2", "media" }, null)
                    || !(new File(colFile)).exists() || !Storage.Collection(colFile).validCollection()) {
                return -2;
            }

            // we need the media dict in advance, and we'll need a map of fname number to use during the import
            File mediaMapFile = new File(tempDir, "media");
            HashMap<String, String> numToName = new HashMap<String, String>();
            if (mediaMapFile.exists()) {
                JsonReader jr = new JsonReader(new FileReader(mediaMapFile));
                jr.beginObject();
                String name;
                String num;
                while (jr.hasNext()) {
                    num = jr.nextName();
                    name = jr.nextString();
                    nameToNum.put(name, num);
                    numToName.put(num, name);
                }
                jr.endObject();
                jr.close();
            }

            _prepareFiles(colFile);
            publishProgress(true, 0, 0, false);
            int cnt = -1;
            try {
                cnt = _import();
            } finally {
                // do not close collection but close only db (in order not to confuse access counting in storage.java
                // Note that the media database is still open and needs to be closed below.
                AnkiDatabaseManager.closeDatabase(mSrc.getPath());
            }
            // import static media
            String mediaDir = mCol.getMedia().getDir();
            if (nameToNum.size() != 0) {
                for (Map.Entry<String, String> entry : nameToNum.entrySet()) {
                    String file = entry.getKey();
                    String c = entry.getValue();
                    if (!file.startsWith("_") && !file.startsWith("latex-")) {
                        continue;
                    }
                    File of = new File(mediaDir, file);
                    if (!of.exists()) {
                        Utils.unzipFiles(mZip, mediaDir, new String[] { c }, numToName);
                    }
                }
            }
            mZip.close();
            mSrc.getMedia().close();
            // delete tmp dir
            File dir = new File(tempDir);
            BackupManager.removeDir(dir);
            publishProgress(true, 100, 100, true);
            return cnt;
        } catch (RuntimeException e) {
            Log.e(AnkiDroidApp.TAG, "RuntimeException while importing ", e);
            return -1;
        } catch (IOException e) {
            Log.e(AnkiDroidApp.TAG, "IOException while importing ", e);
            return -1;
        }
    }

    private void _prepareFiles(String src) {
        mDst = mCol;
        mDstMediaDir = mDst.getMedia().getDir() + File.separator;
        mSrc = Storage.Collection(src);
    }

    private int _import() {
        mDecks = new HashMap<Long, Long>();
        if (mDeckPrefix != null) {
            long id = mDst.getDecks().id(mDeckPrefix);
            mDst.getDecks().select(id);
        }
        // Log.i(AnkiDroidApp.TAG, "Import - preparing");
        _prepareTS();
        _prepareModels();
        // Log.i(AnkiDroidApp.TAG, "Import - importing notes");
        _importNotes();
        // Log.i(AnkiDroidApp.TAG, "Import - importing cards");
        int cnt = _importCards();
        //      _importMedia();
        // Log.i(AnkiDroidApp.TAG, "Import - finishing");
        publishProgress(true, 100, 100, false);
        _postImport();
        // LIBANKI: vacuum and analyze is done in DeckTask
        return cnt;
    }

    /** timestamps */

    private void _prepareTS() {
        mTs = Utils.maxID(mDst.getDb());
    }

    private long ts() {
        mTs++;
        return mTs;
    }

    /** Notes */
    // should note new for wizard

    private void _importNotes() {
        // build guid -> (id,mod,mid) hash & map of existing note ids
        mNotes = new HashMap<String, Object[]>();
        HashMap<Long, Boolean> existing = new HashMap<Long, Boolean>();
        Cursor cursor = null;
        try {
            // "SELECT id, guid, mod, mid FROM notes"
            cursor = mDst.getDb().getDatabase().query("notes", new String[] { "id", "guid", "mod", "mid" }, null,
                    null, null, null, null);
            while (cursor.moveToNext()) {
                long id = cursor.getLong(0);
                mNotes.put(cursor.getString(1), new Object[] { id, cursor.getLong(2), cursor.getLong(3) });
                existing.put(id, true);
            }
        } finally {
            if (cursor != null) {
                cursor.close();
            }
        }
        // we may need to rewrite the guid if the model schemas don't match,
        // so we need to keep track of the changes for the card import stage
        mChangedGuids = new HashMap<String, String>();
        // iterate over source collection
        ArrayList<Object[]> add = new ArrayList<Object[]>();
        ArrayList<Long> dirty = new ArrayList<Long>();
        int usn = mDst.usn();
        int dupes = 0;
        try {
            // "SELECT * FROM notes"
            cursor = mSrc.getDb().getDatabase().query("notes", new String[] { "id", "guid", "mid", "mod", "usn",
                    "tags", "flds", "sfld", "csum", "flags", "data" }, null, null, null, null, null);
            int total = cursor.getCount();
            int i = 0;
            while (cursor.moveToNext()) {
                Object[] note = new Object[] { cursor.getLong(0), cursor.getString(1), cursor.getLong(2),
                        cursor.getLong(3), cursor.getInt(4), cursor.getString(5), cursor.getString(6),
                        cursor.getString(7), cursor.getLong(8), cursor.getInt(9), cursor.getString(10) };
                boolean shouldAdd = _uniquifyNote(note);
                if (shouldAdd) {
                    // ensure id is unique
                    while (existing.containsKey(note[0])) {
                        note[0] = ((Long) note[0]) + 999;
                    }
                    existing.put((Long) note[0], true);
                    // bump usn
                    note[4] = usn;
                    // update media references in case of dupes
                    note[6] = _mungeMedia((Long) note[MID], (String) note[6]);
                    add.add(note);
                    dirty.add((Long) note[0]);
                    // note we have the added guid
                    mNotes.put((String) note[GUID], new Object[] { note[0], note[3], note[MID] });
                } else {
                    dupes += 1;
                    //                  // update existing note - not yet tested; for post 2.0
                    //                  boolean newer = note[3] > mod;
                    //                  if (mAllowUpdate && _mid(mid) == mid && newer) {
                    //                     note[0] = localNid;
                    //                     note[4] = usn;
                    //                     add.add(note);
                    //                     dirty.add(note[0]);
                    //                  }
                }
                ++i;
                publishProgress(true, i * 100 / total, 0, false);
            }
        } finally {
            if (cursor != null) {
                cursor.close();
            }
        }
        if (dupes != 0) {
            // TODO: notify about dupes
        }
        // add to col
        mDst.getDb().executeMany("INSERT OR REPLACE INTO NOTES VALUES (?,?,?,?,?,?,?,?,?,?,?)", add);
        long[] dis = Utils.arrayList2array(dirty);
        mDst.updateFieldCache(dis);
        mDst.getTags().registerNotes(dis);
    }

    // determine if note is a duplicate, and adjust mid and/or guid as required
    // returns true if note should be added

    private boolean _uniquifyNote(Object[] note) {
        String origGuid = (String) note[GUID];
        long srcMid = (Long) note[MID];
        long dstMid = _mid(srcMid);
        // duplicate Schemas?
        if (srcMid == dstMid) {
            return !mNotes.containsKey(origGuid);
        }
        // differing schemas
        note[MID] = dstMid;
        if (!mNotes.containsKey(origGuid)) {
            return true;
        }
        // as the schemas differ and we already have a note with a different note type, this note needs a new guid
        while (true) {
            note[GUID] = Utils.incGuid((String) note[GUID]);
            mChangedGuids.put(origGuid, (String) note[GUID]);
            // if we don't have an existing guid, we can add
            if (!mNotes.containsKey((String) note[GUID])) {
                return true;
            }
            // if the existing guid shares the same mid, we can reuse
            if (dstMid == (Long) mNotes.get((String) note[GUID])[MID]) {
                return false;
            }
        }
    }

    /** Models */
    // Models in the two decks may share an ID but not a schema, so we need to
    // compare the field & template signature rather than just rely on ID. If
    // the schemas don't match, we increment the mid and try again, creating a
    // new model if necessary.

    /* Prepare index of schema hashes */
    private void _prepareModels() {
        mModelMap = new HashMap<Long, Long>();
    }

    /* Return local id for remote MID */
    private long _mid(long srcMid) {
        try {
            // already processed this mid?
            if (mModelMap.containsKey(srcMid)) {
                return mModelMap.get(srcMid);
            }
            long mid = srcMid;
            JSONObject srcModel = new JSONObject(Utils.jsonToString(mSrc.getModels().get(srcMid)));
            String srcScm = mSrc.getModels().scmhash(srcModel);
            while (true) {
                // missing from target col?
                if (!mDst.getModels().have(mid)) {
                    // copy it over
                    JSONObject model = new JSONObject(Utils.jsonToString(srcModel));
                    model.put("id", mid);
                    model.put("mod", Utils.intNow());
                    model.put("usn", mCol.usn());
                    mDst.getModels().update(model);
                    break;
                }
                // there's an existing model; do the schemas match?
                JSONObject dstModel = new JSONObject(Utils.jsonToString(mDst.getModels().get(mid)));
                String dstScm = mDst.getModels().scmhash(dstModel);
                if (srcScm.equals(dstScm)) {
                    // they do; we can reuse this mid
                    break;
                }
                // as they don't match, try next id
                mid += 1;
            }
            mModelMap.put(srcMid, mid);
            return mid;
        } catch (JSONException e) {
            throw new RuntimeException(e);
        }
    }

    /** Decks */

    /* Given did in src col, return local id */
    private long _did(long did) {
        try {
            // already converted?
            if (mDecks.containsKey(did)) {
                return mDecks.get(did);
            }
            // get the name in src
            JSONObject g = mSrc.getDecks().get(did);
            String name = g.getString("name");
            // if there's a prefix, replace the top level deck
            if (mDeckPrefix != null) {
                String[] tmpname = name.split("::");
                name = mDeckPrefix;
                if (tmpname.length > 1) {
                    for (int i = 0; i < tmpname.length - 2; i++) {
                        name += "::" + tmpname[i + 1];
                    }
                }
            }
            // Manually create any parents so we can pull in descriptions
            String head = "";
            String[] parents = name.split("::");
            for (int i = 0; i < parents.length - 1; ++i) {
                if (head.length() > 0) {
                    head = head.concat("::");
                }
                head = head.concat(parents[i]);
                long idInSrc = mSrc.getDecks().id(head);
                _did(idInSrc);
            }
            // create in local
            long newid = mDst.getDecks().id(name);
            // pull conf over
            if (g.has("conf") && g.getLong("conf") != 1) {
                mDst.getDecks().updateConf(mSrc.getDecks().getConf(g.getLong("conf")));
                JSONObject g2 = mDst.getDecks().get(newid);
                g2.put("conf", g.getLong("conf"));
                mDst.getDecks().save(g2);
            }
            // save desc
            JSONObject deck = mDst.getDecks().get(newid);
            deck.put("desc", g.getString("desc"));
            mDst.getDecks().save(deck);
            // add to deck map and return
            mDecks.put(did, newid);
            return newid;
        } catch (JSONException e) {
            throw new RuntimeException(e);
        }
    }

    /** Cards */

    private int _importCards() {
        // build map of (guid, ord) -> cid and used id cache
        mCards = new HashMap<String, HashMap<Integer, Long>>();
        HashMap<Long, Boolean> existing = new HashMap<Long, Boolean>();
        Cursor cursor = null;
        try {
            // "SELECT f.guid, c.ord, c.id FROM cards c, notes f WHERE c.nid = f.id"
            cursor = mDst.getDb().getDatabase().query("cards c, notes f",
                    new String[] { "f.guid", "c.ord", "c.id" }, "c.nid = f.id", null, null, null, null);
            while (cursor.moveToNext()) {
                long cid = cursor.getLong(2);
                existing.put(cid, true);
                String guid = cursor.getString(0);
                int ord = cursor.getInt(1);
                if (mCards.containsKey(guid)) {
                    mCards.get(guid).put(ord, cid);
                } else {
                    HashMap<Integer, Long> map = new HashMap<Integer, Long>();
                    map.put(ord, cid);
                    mCards.put(guid, map);
                }
            }
        } finally {
            if (cursor != null) {
                cursor.close();
            }
        }
        // loop through src
        ArrayList<Object[]> cards = new ArrayList<Object[]>();
        ArrayList<Object[]> revlog = new ArrayList<Object[]>();
        int cnt = 0;
        int usn = mDst.usn();
        long aheadBy = mSrc.getSched().getToday() - mDst.getSched().getToday();
        try {
            cursor = mSrc.getDb().getDatabase()
                    .rawQuery("SELECT f.guid, f.mid, c.* FROM cards c, notes f WHERE c.nid = f.id", null);
            int total = cursor.getCount();
            int ci = 0;
            while (cursor.moveToNext()) {
                Object[] card = new Object[] { cursor.getString(0), cursor.getLong(1), cursor.getLong(2),
                        cursor.getLong(3), cursor.getLong(4), cursor.getInt(5), cursor.getLong(6), cursor.getInt(7),
                        cursor.getInt(8), cursor.getInt(9), cursor.getLong(10), cursor.getLong(11),
                        cursor.getLong(12), cursor.getInt(13), cursor.getInt(14), cursor.getInt(15),
                        cursor.getLong(16), cursor.getLong(17), cursor.getInt(18), cursor.getString(19) };
                String guid = (String) card[0];
                if (mChangedGuids.containsKey(guid)) {
                    guid = mChangedGuids.get(guid);
                }
                // does the card's note exist in dst col?
                if (!mNotes.containsKey(guid)) {
                    continue;
                }
                Object[] dnid = mNotes.get(guid);
                // does the card already exist in the dst col?
                int ord = (Integer) card[5];
                if (mCards.containsKey(guid) && mCards.get(guid).containsKey(ord)) {
                    // fixme: in future, could update if newer mod time
                    continue;
                }
                // doesn't exist. strip off note info, and save src id for later
                Object[] oc = card;
                card = new Object[oc.length - 2];
                for (int i = 0; i < card.length; i++) {
                    card[i] = oc[i + 2];
                }
                long scid = (Long) card[0];
                // ensure the card id is unique
                while (existing.containsKey(card[0])) {
                    card[0] = (Long) card[0] + 999;
                }
                existing.put((Long) card[0], true);
                // update cid, nid, etc
                card[1] = mNotes.get(guid)[0];
                card[2] = _did((Long) card[2]);
                card[4] = Utils.intNow();
                card[5] = usn;
                // review cards have a due date relative to collection
                if ((Integer) card[7] == 2 || (Integer) card[7] == 3 || (Integer) card[6] == 2) {
                    card[8] = (Long) card[8] - aheadBy;
                }
                // if odid true, convert card from filtered to normal
                if ((Long) card[15] != 0) {
                    // odid
                    card[15] = 0;
                    // odue
                    card[8] = card[14];
                    card[14] = 0;
                    // queue
                    if ((Integer) card[6] == 1) { // type
                        card[7] = 0;
                    } else {
                        card[7] = card[6];
                    }
                    // type
                    if ((Integer) card[6] == 1) {
                        card[6] = 0;
                    }
                }
                cards.add(card);
                // we need to import revlog, rewriting card ids and bumping usn
                Cursor cur2 = null;
                try {
                    //                    "SELECT * FROM revlog WHERE cid = ?"
                    cur2 = mDst.getDb().getDatabase().query("revlog",
                            new String[] { "id", "cid", "usn", "ease", "ivl", "lastIvl", "factor", "time", "type" },
                            "cid = ?", new String[] { Long.toString(scid) }, null, null, null);
                    while (cur2.moveToNext()) {
                        Object[] rev = new Object[] { cur2.getLong(0), cur2.getLong(1), cur2.getInt(2),
                                cur2.getInt(3), cur2.getLong(4), cur2.getLong(5), cur2.getLong(6), cur2.getLong(7),
                                cur2.getInt(8) };
                        rev[1] = card[0];
                        rev[2] = mDst.usn();
                        revlog.add(rev);
                    }
                } finally {
                    if (cur2 != null) {
                        cur2.close();
                    }
                }
                cnt += 1;
                ++ci;
                publishProgress(true, 100, ci * 100 / total, false);
            }
        } finally {
            if (cursor != null) {
                cursor.close();
            }
        }
        // apply
        mDst.getDb().executeMany("INSERT OR IGNORE INTO cards VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", cards);
        mDst.getDb().executeMany("INSERT OR IGNORE INTO revlog VALUES (?,?,?,?,?,?,?,?,?)", revlog);
        return cnt;
    }

    private String _mungeMedia(long mid, String fields) {
        String[] fs = Utils.splitFields(fields);

        for (int i = 0; i < fs.length; ++i) {
            for (Pattern p : Media.fMediaRegexps) {
                Matcher m = p.matcher(fs[i]);
                StringBuffer sb = new StringBuffer();
                while (m.find()) {
                    String fname = m.group(2);
                    BufferedInputStream srcData = _srcMediaData(fname);
                    BufferedInputStream dstData = _dstMediaData(fname);
                    if (srcData == null) {
                        // file was not in source, ignore
                        m.appendReplacement(sb, m.group(0));
                        continue;
                    }
                    // if model-local file exists from a previous import, use that
                    int extPos = fname.lastIndexOf(".");
                    if (extPos <= 0) {
                        extPos = fname.length();
                    }
                    String lname = String.format(Locale.US, "%s_%d%s", fname.substring(0, extPos), mid,
                            fname.substring(extPos));
                    if (mDst.getMedia().have(lname)) {
                        m.appendReplacement(sb, m.group(0).replace(fname, lname));
                        continue;
                    } else if (dstData == null || compareMedia(srcData, dstData)) { // if missing or the same, pass unmodified
                        // need to copy?
                        if (dstData == null) {
                            _writeDstMedia(fname, srcData);
                        }
                        m.appendReplacement(sb, m.group(0));
                        continue;
                    }
                    // exists but does not match, so we need to dedupe
                    _writeDstMedia(lname, srcData);
                    m.appendReplacement(sb, m.group(0).replace(fname, lname));
                }
                m.appendTail(sb);
                fs[i] = sb.toString();
            }
        }
        return fields;
    }

    private boolean compareMedia(BufferedInputStream lhis, BufferedInputStream rhis) {
        byte[] lhbytes = _mediaPick(lhis);
        byte[] rhbytes = _mediaPick(rhis);
        boolean result = Arrays.equals(lhbytes, rhbytes);
        return result;
    }

    /**
     * Return the contents of the given input stream, limited to Anki2Importer.MEDIAPICKLIMIT bytes
     * This is only used for comparison of media files with the limited resources of mobile devices
     */
    byte[] _mediaPick(BufferedInputStream is) {
        try {
            ByteArrayOutputStream baos = new ByteArrayOutputStream(MEDIAPICKLIMIT * 2);
            byte[] buf = new byte[MEDIAPICKLIMIT];
            int readLen;
            int readSoFar = 0;
            is.mark(MEDIAPICKLIMIT * 2);
            while (true) {
                readLen = is.read(buf);
                baos.write(buf);
                if (readLen == -1) {
                    break;
                }
                readSoFar += readLen;
                if (readSoFar > MEDIAPICKLIMIT) {
                    break;
                }
            }
            is.reset();
            byte[] result = new byte[MEDIAPICKLIMIT];
            System.arraycopy(baos.toByteArray(), 0, result, 0, Math.min(baos.size(), MEDIAPICKLIMIT));
            return result;
        } catch (FileNotFoundException e) {
            return null;
        } catch (IOException e) {
            return null;
        }
    }

    private BufferedInputStream _mediaData(String fname, String dir) {
        try {
            return new BufferedInputStream(new FileInputStream(new File(dir, fname)), MEDIAPICKLIMIT * 2);
        } catch (FileNotFoundException e) {
            return null;
        }
    }

    /**
     * Data for FNAME in src collection.
     * @return A string containing the contents of fname, limited to Anki2Importer.MEDIAPICKLIMIT bytes
     */
    private BufferedInputStream _srcMediaData(String fname) {
        if (nameToNum.containsKey(fname)) {
            try {
                return new BufferedInputStream(mZip.getInputStream(mZip.getEntry(nameToNum.get(fname))));
            } catch (IOException e) {
                Log.e(AnkiDroidApp.TAG, "Could not extract media file " + fname + "from mZip file.");
            }
        }
        return null;
    }

    /**
     * Data for FNAME in src collection.
     * @return A string containing the contents of fname, limited to Anki2Importer.MEDIAPICKLIMIT bytes
     */
    private BufferedInputStream _dstMediaData(String fname) {
        return _mediaData(fname, mDst.getMedia().getDir());
    }

    private void _writeDstMedia(String fname, BufferedInputStream is) {
        try {
            Utils.writeToFile(is, mDstMediaDir + fname);
        } catch (IOException e) {
            // the user likely used subdirectories
            Log.e(AnkiDroidApp.TAG,
                    String.format(Locale.US,
                            "Anki2Importer._writeDstMedia: error copying file to %s (%s), ignoring and continuing.",
                            fname, e.getMessage()));
        }
    }

    /** post-import cleanup */

    private void _postImport() {
        try {
            // make sure new position is correct
            mDst.getConf().put("nextPos",
                    mDst.getDb().queryLongScalar("SELECT max(due) + 1 FROM cards WHERE type = 0", false));
            mDst.save();
        } catch (JSONException e) {
            throw new RuntimeException(e);
        }
    }
}