com.ichi2.anki.NoteEditor.java Source code

Java tutorial

Introduction

Here is the source code for com.ichi2.anki.NoteEditor.java

Source

/***************************************************************************************
 *                                                                                      *
 * Copyright (c) 2012 Norbert Nagold <norbert.nagold@gmail.com>                         *
 * Copyright (c) 2014 Timothy Rae <perceptualchaos2@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.anki;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.content.res.Resources;
import android.graphics.PorterDuff;
import android.graphics.Typeface;
import android.os.Bundle;
import android.support.v7.widget.PopupMenu;
import android.support.v7.widget.PopupMenu.OnMenuItemClickListener;
import android.support.v7.widget.Toolbar;
import android.text.Editable;
import android.text.Html;
import android.text.TextUtils;
import android.text.TextWatcher;

import android.util.Pair;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.WindowManager;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemSelectedListener;
import android.widget.ArrayAdapter;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.Spinner;
import android.widget.TextView;

import com.afollestad.materialdialogs.MaterialDialog;
import com.ichi2.anim.ActivityTransitionAnimation;
import com.ichi2.anki.dialogs.NoteEditorRescheduleCard;
import com.ichi2.anki.dialogs.ConfirmationDialog;
import com.ichi2.anki.dialogs.TagsDialog;
import com.ichi2.anki.dialogs.TagsDialog.TagsDialogListener;
import com.ichi2.anki.exception.ConfirmModSchemaException;
import com.ichi2.anki.multimediacard.IMultimediaEditableNote;
import com.ichi2.anki.multimediacard.activity.MultimediaEditFieldActivity;
import com.ichi2.anki.multimediacard.fields.AudioField;
import com.ichi2.anki.multimediacard.fields.EFieldType;
import com.ichi2.anki.multimediacard.fields.IField;
import com.ichi2.anki.multimediacard.fields.ImageField;
import com.ichi2.anki.multimediacard.fields.TextField;
import com.ichi2.anki.multimediacard.impl.MultimediaEditableNote;
import com.ichi2.anki.receiver.SdCardReceiver;
import com.ichi2.anki.servicelayer.NoteService;
import com.ichi2.async.DeckTask;
import com.ichi2.libanki.Card;
import com.ichi2.libanki.Collection;
import com.ichi2.libanki.Note;
import com.ichi2.libanki.Utils;
import com.ichi2.themes.StyledProgressDialog;
import com.ichi2.themes.Themes;
import com.ichi2.anki.widgets.PopupMenuWithIcons;
import com.ichi2.widget.WidgetStatus;

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.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

import timber.log.Timber;

/**
 * Allows the user to edit a fact, for instance if there is a typo. A card is a presentation of a fact, and has two
 * sides: a question and an answer. Any number of fields can appear on each side. When you add a fact to Anki, cards
 * which show that fact are generated. Some models generate one card, others generate more than one.
 *
 * @see <a href="http://ankisrs.net/docs/manual.html#cards">the Anki Desktop manual</a>
 */
public class NoteEditor extends AnkiActivity {

    //    public static final String SOURCE_LANGUAGE = "SOURCE_LANGUAGE";
    //    public static final String TARGET_LANGUAGE = "TARGET_LANGUAGE";
    public static final String SOURCE_TEXT = "SOURCE_TEXT";
    public static final String TARGET_TEXT = "TARGET_TEXT";
    public static final String EXTRA_CALLER = "CALLER";
    public static final String EXTRA_CARD_ID = "CARD_ID";
    public static final String EXTRA_CONTENTS = "CONTENTS";
    public static final String EXTRA_ID = "ID";

    private static final String ACTION_CREATE_FLASHCARD = "org.openintents.action.CREATE_FLASHCARD";
    private static final String ACTION_CREATE_FLASHCARD_SEND = "android.intent.action.SEND";

    // calling activity
    public static final int CALLER_NOCALLER = 0;

    public static final int CALLER_REVIEWER = 1;
    public static final int CALLER_STUDYOPTIONS = 2;
    public static final int CALLER_DECKPICKER = 3;

    public static final int CALLER_CARDBROWSER_EDIT = 6;
    public static final int CALLER_CARDBROWSER_ADD = 7;

    public static final int CALLER_CARDEDITOR = 8;
    public static final int CALLER_CARDEDITOR_INTENT_ADD = 10;

    public static final int REQUEST_ADD = 0;
    public static final int REQUEST_MULTIMEDIA_EDIT = 2;
    public static final int REQUEST_TEMPLATE_EDIT = 3;

    private boolean mChanged = false;
    private boolean mFieldEdited = false;

    /**
     * Flag which forces the calling activity to rebuild it's definition of current card from scratch
     */
    private boolean mReloadRequired = false;

    /**
     * Broadcast that informs us when the sd card is about to be unmounted
     */
    private BroadcastReceiver mUnmountReceiver = null;

    private LinearLayout mFieldsLayoutContainer;

    private TextView mTagsButton;
    private TextView mCardsButton;
    private Spinner mNoteTypeSpinner;
    private Spinner mNoteDeckSpinner;

    private Note mEditorNote;
    public static Card mCurrentEditedCard;
    private List<String> mSelectedTags;
    private long mCurrentDid;
    private ArrayList<Long> mAllDeckIds;
    private ArrayList<Long> mAllModelIds;
    private Map<Integer, Integer> mModelChangeFieldMap;
    private Map<Integer, Integer> mModelChangeCardMap;

    /* indicates if a new fact is added or a card is edited */
    private boolean mAddNote;

    private boolean mAedictIntent;

    /* indicates which activity called Note Editor */
    private int mCaller;

    private LinkedList<FieldEditText> mEditFields;

    private MaterialDialog mProgressDialog;

    private String[] mSourceText;

    // A bundle that maps field ords to the text content of that field for use in
    // restoring the Activity.
    private Bundle mSavedFields;

    private DeckTask.TaskListener mSaveFactHandler = new DeckTask.TaskListener() {
        private boolean mCloseAfter = false;
        private Intent mIntent;

        @Override
        public void onPreExecute() {
            Resources res = getResources();
            mProgressDialog = StyledProgressDialog.show(NoteEditor.this, "", res.getString(R.string.saving_facts),
                    false);
        }

        @Override
        public void onProgressUpdate(DeckTask.TaskData... values) {
            int count = values[0].getInt();
            if (count > 0) {
                mChanged = true;
                mSourceText = null;
                Note oldNote = mEditorNote.clone();
                setNote();
                // Respect "Remember last input when adding" field option.
                JSONArray flds;
                try {
                    flds = mEditorNote.model().getJSONArray("flds");
                    if (oldNote != null) {
                        for (int fldIdx = 0; fldIdx < flds.length(); fldIdx++) {
                            if (flds.getJSONObject(fldIdx).getBoolean("sticky")) {
                                mEditFields.get(fldIdx).setText(oldNote.getFields()[fldIdx]);
                            }
                        }
                    }
                } catch (JSONException e) {
                    throw new RuntimeException();
                }
                Themes.showThemedToast(NoteEditor.this,
                        getResources().getQuantityString(R.plurals.factadder_cards_added, count, count), true);
            } else {
                Themes.showThemedToast(NoteEditor.this, getResources().getString(R.string.factadder_saving_error),
                        true);
            }
            if (!mAddNote || mCaller == CALLER_CARDEDITOR || mAedictIntent) {
                mChanged = true;
                mCloseAfter = true;
            } else if (mCaller == CALLER_CARDEDITOR_INTENT_ADD) {
                if (count > 0) {
                    mChanged = true;
                }
                mCloseAfter = true;
                mIntent = new Intent();
                mIntent.putExtra(EXTRA_ID, getIntent().getStringExtra(EXTRA_ID));
            } else if (!mEditFields.isEmpty()) {
                mEditFields.getFirst().requestFocus();
            }
            if (!mCloseAfter) {
                if (mProgressDialog != null && mProgressDialog.isShowing()) {
                    try {
                        mProgressDialog.dismiss();
                    } catch (IllegalArgumentException e) {
                        Timber.e(e, "Note Editor: Error on dismissing progress dialog");
                    }
                }
            }
        }

        @Override
        public void onPostExecute(DeckTask.TaskData result) {
            if (result.getBoolean()) {
                if (mProgressDialog != null && mProgressDialog.isShowing()) {
                    try {
                        mProgressDialog.dismiss();
                    } catch (IllegalArgumentException e) {
                        Timber.e(e, "Note Editor: Error on dismissing progress dialog");
                    }
                }
                if (mCloseAfter) {
                    if (mIntent != null) {
                        closeNoteEditor(mIntent);
                    } else {
                        closeNoteEditor();
                    }
                } else {
                    // Reset check for changes to fields
                    mFieldEdited = false;
                }
            } else {
                // RuntimeException occured on adding note
                closeNoteEditor(DeckPicker.RESULT_DB_ERROR);
            }
        }

        @Override
        public void onCancelled() {
        }
    };

    // ----------------------------------------------------------------------------
    // ANDROID METHODS
    // ----------------------------------------------------------------------------

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        Timber.d("onCreate()");
        super.onCreate(savedInstanceState);
        setContentView(R.layout.note_editor);
        Intent intent = getIntent();
        if (savedInstanceState != null) {
            mCaller = savedInstanceState.getInt("caller");
            mAddNote = savedInstanceState.getBoolean("addFact");
            mCurrentDid = savedInstanceState.getLong("did");
            mSelectedTags = new ArrayList<>(Arrays.asList(savedInstanceState.getStringArray("tags")));
            mSavedFields = savedInstanceState.getBundle("editFields");
        } else {
            mCaller = intent.getIntExtra(EXTRA_CALLER, CALLER_NOCALLER);
            if (mCaller == CALLER_NOCALLER) {
                String action = intent.getAction();
                if (action != null && (ACTION_CREATE_FLASHCARD.equals(action)
                        || ACTION_CREATE_FLASHCARD_SEND.equals(action))) {
                    mCaller = CALLER_CARDEDITOR_INTENT_ADD;
                }
            }
        }

        startLoadingCollection();
    }

    @Override
    protected void onSaveInstanceState(Bundle savedInstanceState) {
        Timber.i("Saving instance");
        savedInstanceState.putInt("caller", mCaller);
        savedInstanceState.putBoolean("addFact", mAddNote);
        savedInstanceState.putLong("did", mCurrentDid);
        savedInstanceState.putStringArray("tags", mSelectedTags.toArray(new String[mSelectedTags.size()]));
        Bundle fields = new Bundle();
        // Save the content of all the note fields. We use the field's ord as the key to
        // easily map the fields correctly later.
        for (FieldEditText e : mEditFields) {
            fields.putString(Integer.toString(e.getOrd()), e.getText().toString());
        }
        savedInstanceState.putBundle("editFields", fields);
        super.onSaveInstanceState(savedInstanceState);
    }

    // Finish initializing the activity after the collection has been correctly loaded
    @Override
    protected void onCollectionLoaded(Collection col) {
        super.onCollectionLoaded(col);
        this.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);

        Intent intent = getIntent();
        Timber.d("onCollectionLoaded: caller: %d", mCaller);

        SharedPreferences preferences = AnkiDroidApp.getSharedPrefs(getBaseContext());

        registerExternalStorageListener();

        View mainView = findViewById(android.R.id.content);

        Toolbar toolbar = (Toolbar) mainView.findViewById(R.id.toolbar);
        if (toolbar != null) {
            setSupportActionBar(toolbar);
        }

        mFieldsLayoutContainer = (LinearLayout) findViewById(R.id.CardEditorEditFieldsLayout);

        mTagsButton = (TextView) findViewById(R.id.CardEditorTagText);
        mCardsButton = (TextView) findViewById(R.id.CardEditorCardsText);
        mCardsButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Timber.i("NoteEditor:: Cards button pressed. Opening template editor");
                showCardTemplateEditor();
            }
        });

        Preferences.COMING_FROM_ADD = false;

        mAedictIntent = false;

        switch (mCaller) {
        case CALLER_NOCALLER:
            Timber.e("no caller could be identified, closing");
            finishWithoutAnimation();
            return;

        case CALLER_REVIEWER:
            mCurrentEditedCard = AbstractFlashcardViewer.getEditorCard();
            if (mCurrentEditedCard == null) {
                finishWithoutAnimation();
                return;
            }
            mEditorNote = mCurrentEditedCard.note();
            mAddNote = false;
            break;

        case CALLER_STUDYOPTIONS:
        case CALLER_DECKPICKER:
            mAddNote = true;
            break;

        case CALLER_CARDBROWSER_EDIT:
            mCurrentEditedCard = CardBrowser.sCardBrowserCard;
            if (mCurrentEditedCard == null) {
                finishWithoutAnimation();
                return;
            }
            mEditorNote = mCurrentEditedCard.note();
            mAddNote = false;
            break;

        case CALLER_CARDBROWSER_ADD:
            mAddNote = true;
            break;

        case CALLER_CARDEDITOR:
            mAddNote = true;
            break;

        case CALLER_CARDEDITOR_INTENT_ADD:
            fetchIntentInformation(intent);
            if (mSourceText == null) {
                finishWithoutAnimation();
                return;
            }
            if (mSourceText[0].equals("Aedict Notepad") && addFromAedict(mSourceText[1])) {
                finishWithoutAnimation();
                return;
            }
            mAddNote = true;
            break;
        }

        // Note type Selector
        mNoteTypeSpinner = (Spinner) findViewById(R.id.note_type_spinner);
        mAllModelIds = new ArrayList<Long>();
        final ArrayList<String> modelNames = new ArrayList<String>();
        ArrayList<JSONObject> models = getCol().getModels().all();
        Collections.sort(models, new JSONNameComparator());
        for (JSONObject m : models) {
            try {
                modelNames.add(m.getString("name"));
                mAllModelIds.add(m.getLong("id"));
            } catch (JSONException e) {
                throw new RuntimeException(e);
            }
        }

        ArrayAdapter<String> noteTypeAdapter = new ArrayAdapter<String>(this, android.R.layout.simple_spinner_item,
                modelNames);
        mNoteTypeSpinner.setAdapter(noteTypeAdapter);
        noteTypeAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);

        // Deck Selector
        TextView deckTextView = (TextView) findViewById(R.id.CardEditorDeckText);
        // If edit mode and more than one card template distinguish between "Deck" and "Card deck"
        try {
            if (!mAddNote && mEditorNote.model().getJSONArray("tmpls").length() > 1) {
                deckTextView.setText(R.string.CardEditorCardDeck);
            }
        } catch (JSONException e1) {
            throw new RuntimeException();
        }
        mNoteDeckSpinner = (Spinner) findViewById(R.id.note_deck_spinner);
        mAllDeckIds = new ArrayList<Long>();
        final ArrayList<String> deckNames = new ArrayList<String>();

        ArrayList<JSONObject> decks = getCol().getDecks().all();
        Collections.sort(decks, new JSONNameComparator());
        for (JSONObject d : decks) {
            try {
                // add current deck and all other non-filtered decks to deck list
                long thisDid = d.getLong("id");
                long currentDid = getCol().getDecks().current().getLong("id");
                if (d.getInt("dyn") == 0 || (!mAddNote && thisDid == currentDid)) {
                    deckNames.add(d.getString("name"));
                    mAllDeckIds.add(thisDid);
                }
            } catch (JSONException e) {
                throw new RuntimeException(e);
            }
        }

        ArrayAdapter<String> noteDeckAdapter = new ArrayAdapter<String>(this, android.R.layout.simple_spinner_item,
                deckNames);
        mNoteDeckSpinner.setAdapter(noteDeckAdapter);
        noteDeckAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
        mNoteDeckSpinner.setOnItemSelectedListener(new OnItemSelectedListener() {
            @Override
            public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
                // Timber.i("NoteEditor:: onItemSelected() fired on mNoteDeckSpinner with pos = "+Integer.toString(pos));
                mCurrentDid = mAllDeckIds.get(pos);
            }

            @Override
            public void onNothingSelected(AdapterView<?> parent) {
                // Do Nothing
            }
        });

        setDid(mEditorNote);

        setNote(mEditorNote);

        // Set current note type and deck positions in spinners
        int position;
        try {
            position = mAllModelIds.indexOf(mEditorNote.model().getLong("id"));
        } catch (JSONException e) {
            throw new RuntimeException(e);
        }
        // set selection without firing selectionChanged event
        // nb: setOnItemSelectedListener needs to occur after this
        mNoteTypeSpinner.setSelection(position, false);

        if (mAddNote) {
            mNoteTypeSpinner.setOnItemSelectedListener(new SetNoteTypeListener());
            setTitle(R.string.cardeditor_title_add_note);
            // set information transferred by intent
            String contents = null;
            if (mSourceText != null) {
                if (mAedictIntent && (mEditFields.size() == 3) && mSourceText[1].contains("[")) {
                    contents = mSourceText[1].replaceFirst("\\[", "\u001f" + mSourceText[0] + "\u001f");
                    contents = contents.substring(0, contents.length() - 1);
                } else if (mEditFields.size() > 0) {
                    mEditFields.get(0).setText(mSourceText[0]);
                    if (mEditFields.size() > 1) {
                        mEditFields.get(1).setText(mSourceText[1]);
                    }
                }
            } else {
                contents = intent.getStringExtra(EXTRA_CONTENTS);
            }
            if (contents != null) {
                setEditFieldTexts(contents);
            }
        } else {
            mNoteTypeSpinner.setOnItemSelectedListener(new EditNoteTypeListener());
            setTitle(R.string.cardeditor_title_edit_card);
        }

        ((LinearLayout) findViewById(R.id.CardEditorTagButton)).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Timber.i("NoteEditor:: Tags button pressed... opening tags editor");
                showTagsDialog();
            }
        });

        if (!mAddNote && mCurrentEditedCard != null) {
            Timber.i("NoteEditor:: Edit note activity successfully started with card id %d",
                    mCurrentEditedCard.getId());
        }
    }

    @Override
    protected void onStop() {
        super.onStop();
        if (!isFinishing()) {
            WidgetStatus.update(this);
            UIUtils.saveCollectionInBackground(this);
        }
    }

    private void fetchIntentInformation(Intent intent) {
        Bundle extras = intent.getExtras();
        if (ACTION_CREATE_FLASHCARD.equals(intent.getAction())) {
            // mSourceLanguage = extras.getString(SOURCE_LANGUAGE);
            // mTargetLanguage = extras.getString(TARGET_LANGUAGE);
            mSourceText = new String[2];
            mSourceText[0] = extras.getString(SOURCE_TEXT);
            mSourceText[1] = extras.getString(TARGET_TEXT);
        } else {
            String first;
            String second;
            if (extras.getString(Intent.EXTRA_SUBJECT) != null) {
                first = extras.getString(Intent.EXTRA_SUBJECT);
            } else {
                first = "";
            }
            if (extras.getString(Intent.EXTRA_TEXT) != null) {
                second = extras.getString(Intent.EXTRA_TEXT);
            } else {
                second = "";
            }
            // Some users add cards via SEND intent from clipboard. In this case SUBJECT is empty
            if (first.equals("")) {
                // Assume that if only one field was sent then it should be the front
                first = second;
                second = "";
            }
            Pair<String, String> messages = new Pair<String, String>(first, second);

            mSourceText = new String[2];
            mSourceText[0] = messages.first;
            mSourceText[1] = messages.second;
        }
    }

    private boolean addFromAedict(String extra_text) {
        String category = "";
        String[] notepad_lines = extra_text.split("\n");
        for (int i = 0; i < notepad_lines.length; i++) {
            if (notepad_lines[i].startsWith("[") && notepad_lines[i].endsWith("]")) {
                category = notepad_lines[i].substring(1, notepad_lines[i].length() - 1);
                if (category.equals("default")) {
                    if (notepad_lines.length > i + 1) {
                        String[] entry_lines = notepad_lines[i + 1].split(":");
                        if (entry_lines.length > 1) {
                            mSourceText[0] = entry_lines[1];
                            mSourceText[1] = entry_lines[0];
                            mAedictIntent = true;
                        } else {
                            Themes.showThemedToast(NoteEditor.this,
                                    getResources().getString(R.string.intent_aedict_empty), false);
                            return true;
                        }
                    } else {
                        Themes.showThemedToast(NoteEditor.this,
                                getResources().getString(R.string.intent_aedict_empty), false);
                        return true;
                    }
                    return false;
                }
            }
        }
        Themes.showThemedToast(NoteEditor.this, getResources().getString(R.string.intent_aedict_category), false);
        return true;
    }

    private void resetEditFields(String[] content) {
        for (int i = 0; i < Math.min(content.length, mEditFields.size()); i++) {
            mEditFields.get(i).setText(content[i]);
        }
    }

    private boolean hasUnsavedChanges() {
        // changed note type?
        if (!mAddNote) {
            final JSONObject newModel = getCurrentlySelectedModel();
            final JSONObject oldModel = mCurrentEditedCard.model();
            if (!newModel.equals(oldModel)) {
                return true;
            }
        }
        // changed deck?
        if (!mAddNote && mCurrentEditedCard != null && mCurrentEditedCard.getDid() != mCurrentDid) {
            return true;
        }
        // changed fields?
        if (mFieldEdited) {
            return true;
        }
        // added tag?
        for (String t : mSelectedTags) {
            if (!mEditorNote.hasTag(t)) {
                return true;
            }
        }
        // removed tag?
        if (mEditorNote.getTags().size() > mSelectedTags.size()) {
            return true;
        }
        return false;
    }

    private void saveNote() {
        final Resources res = getResources();
        if (mSelectedTags == null) {
            mSelectedTags = new ArrayList<String>();
        }
        // treat add new note and edit existing note independently
        if (mAddNote) {
            // load all of the fields into the note
            for (FieldEditText f : mEditFields) {
                updateField(f);
            }
            try {
                // Save deck to model
                mEditorNote.model().put("did", mCurrentDid);
                // Save tags to model
                mEditorNote.setTagsFromStr(tagsAsString(mSelectedTags));
                JSONArray ja = new JSONArray();
                for (String t : mSelectedTags) {
                    ja.put(t);
                }
                getCol().getModels().current().put("tags", ja);
                getCol().getModels().setChanged();
            } catch (JSONException e) {
                throw new RuntimeException(e);
            }
            DeckTask.launchDeckTask(DeckTask.TASK_TYPE_ADD_FACT, mSaveFactHandler,
                    new DeckTask.TaskData(mEditorNote));
        } else {
            // Check whether note type has been changed
            final JSONObject newModel = getCurrentlySelectedModel();
            final JSONObject oldModel = mCurrentEditedCard.model();
            if (!newModel.equals(oldModel)) {
                mReloadRequired = true;
                if (mModelChangeCardMap.size() < mEditorNote.cards().size()
                        || mModelChangeCardMap.containsKey(null)) {
                    // If cards will be lost via the new mapping then show a confirmation dialog before proceeding with the change
                    ConfirmationDialog dialog = new ConfirmationDialog() {
                        @Override
                        public void confirm() {
                            // Bypass the check once the user confirms
                            changeNoteTypeWithErrorHandling(oldModel, newModel);
                        }
                    };
                    dialog.setArgs(res.getString(R.string.confirm_map_cards_to_nothing));
                    showDialogFragment(dialog);
                } else {
                    // Otherwise go straight to changing note type
                    changeNoteTypeWithErrorHandling(oldModel, newModel);
                }
                return;
            }
            // Regular changes in note content
            boolean modified = false;
            // changed did? this has to be done first as remFromDyn() involves a direct write to the database
            if (mCurrentEditedCard.getDid() != mCurrentDid) {
                mReloadRequired = true;
                // remove card from filtered deck first (if relevant)
                getCol().getSched().remFromDyn(new long[] { mCurrentEditedCard.getId() });
                // refresh the card object to reflect the database changes in remFromDyn()
                mCurrentEditedCard.load();
                // also reload the note object
                mEditorNote = mCurrentEditedCard.note();
                // then set the card ID to the new deck
                mCurrentEditedCard.setDid(mCurrentDid);
                modified = true;
            }
            // now load any changes to the fields from the form
            for (FieldEditText f : mEditFields) {
                modified = modified | updateField(f);
            }
            // added tag?
            for (String t : mSelectedTags) {
                modified = modified || !mEditorNote.hasTag(t);
            }
            // removed tag?
            modified = modified || mEditorNote.getTags().size() > mSelectedTags.size();
            if (modified) {
                mEditorNote.setTagsFromStr(tagsAsString(mSelectedTags));
                // set a flag so that changes to card object will be written to DB later via onActivityResult() in
                // CardBrowser
                mChanged = true;
            }
            closeNoteEditor();
        }
    }

    /**
     * Change the note type from oldModel to newModel, handling the case where a full sync will be required
     * @param oldModel
     * @param newModel
     */
    private void changeNoteTypeWithErrorHandling(final JSONObject oldModel, final JSONObject newModel) {
        Resources res = getResources();
        try {
            changeNoteType(oldModel, newModel);
        } catch (ConfirmModSchemaException e) {
            // Libanki has determined we should ask the user to confirm first
            ConfirmationDialog dialog = new ConfirmationDialog() {
                @Override
                public void confirm() {
                    // Bypass the check once the user confirms
                    getCol().modSchemaNoCheck();
                    try {
                        changeNoteType(oldModel, newModel);
                    } catch (ConfirmModSchemaException e2) {
                        // This should never be reached as we explicitly called modSchemaNoCheck()
                        throw new RuntimeException(e2);
                    }
                }
            };
            dialog.setArgs(res.getString(R.string.full_sync_confirmation));
            showDialogFragment(dialog);
        }
    }

    /**
     * Change the note type from oldModel to newModel, throwing ConfirmModSchemaException if a full sync will be required
     * @param oldModel
     * @param newModel
     * @throws ConfirmModSchemaException
     */
    private void changeNoteType(JSONObject oldModel, JSONObject newModel) throws ConfirmModSchemaException {
        final long[] nids = { mEditorNote.getId() };
        getCol().getModels().change(oldModel, nids, newModel, mModelChangeFieldMap, mModelChangeCardMap);
        // refresh the note object to reflect the database changes
        mEditorNote.load();
        // close note editor
        closeNoteEditor();
    }

    @Override
    public void onBackPressed() {
        Timber.i("NoteEditor:: onBackPressed()");
        closeCardEditorWithCheck();
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        if (mUnmountReceiver != null) {
            unregisterReceiver(mUnmountReceiver);
        }
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        getMenuInflater().inflate(R.menu.note_editor, menu);
        if (mAddNote) {
            menu.findItem(R.id.action_copy_card).setVisible(false);
        } else {
            menu.findItem(R.id.action_add_card_from_card_editor).setVisible(true);
            menu.findItem(R.id.action_reset_card_progress).setVisible(true);
            menu.findItem(R.id.action_reschedule_card).setVisible(true);
            menu.findItem(R.id.action_reset_card_progress).setVisible(true);
        }
        if (mEditFields != null) {
            for (int i = 0; i < mEditFields.size(); i++) {
                if (mEditFields.get(i).getText().length() > 0) {
                    menu.findItem(R.id.action_copy_card).setEnabled(true);
                    break;
                } else if (i == mEditFields.size() - 1) {
                    menu.findItem(R.id.action_copy_card).setEnabled(false);
                }
            }
        }
        return super.onCreateOptionsMenu(menu);
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        Resources res = getResources();
        switch (item.getItemId()) {
        case android.R.id.home:
            Timber.i("NoteEditor:: Home button pressed");
            closeCardEditorWithCheck();
            return true;

        case R.id.action_save:
            Timber.i("NoteEditor:: Save note button pressed");
            saveNote();
            return true;

        case R.id.action_add_card_from_card_editor:
        case R.id.action_copy_card:
            Timber.i("NoteEditor:: Copy or add card button pressed");
            Intent intent = new Intent(NoteEditor.this, NoteEditor.class);
            intent.putExtra(EXTRA_CALLER, CALLER_CARDEDITOR);
            // intent.putExtra(EXTRA_DECKPATH, mDeckPath);
            if (item.getItemId() == R.id.action_copy_card) {
                intent.putExtra(EXTRA_CONTENTS, getFieldsText());
            }
            startActivityForResultWithAnimation(intent, REQUEST_ADD, ActivityTransitionAnimation.LEFT);
            return true;

        case R.id.action_reset_card_progress:
            Timber.i("NoteEditor:: Reset progress button pressed");
            // Show confirmation dialog before resetting card progress
            ConfirmationDialog dialog = new ConfirmationDialog() {
                @Override
                public void confirm() {
                    Timber.i("NoteEditor:: OK button pressed");
                    getCol().getSched().forgetCards(new long[] { mCurrentEditedCard.getId() });
                    getCol().reset();
                    mReloadRequired = true;
                    Themes.showThemedToast(NoteEditor.this,
                            getResources().getString(R.string.reset_card_dialog_acknowledge), true);
                }
            };
            String title = res.getString(R.string.reset_card_dialog_title);
            String message = res.getString(R.string.reset_card_dialog_message);
            dialog.setArgs(title, message);
            showDialogFragment(dialog);
            return true;

        case R.id.action_reschedule_card:
            Timber.i("NoteEditor:: Reschedule button pressed");
            showDialogFragment(NoteEditorRescheduleCard.newInstance());
            return true;

        default:
            return super.onOptionsItemSelected(item);

        }
    }

    // ----------------------------------------------------------------------------
    // CUSTOM METHODS
    // ----------------------------------------------------------------------------

    /**
     * finish when sd card is ejected
     */
    private void registerExternalStorageListener() {
        if (mUnmountReceiver == null) {
            mUnmountReceiver = new BroadcastReceiver() {
                @Override
                public void onReceive(Context context, Intent intent) {
                    if (intent.getAction().equals(SdCardReceiver.MEDIA_EJECT)) {
                        finishWithoutAnimation();
                    }
                }
            };
            IntentFilter iFilter = new IntentFilter();
            iFilter.addAction(SdCardReceiver.MEDIA_EJECT);
            registerReceiver(mUnmountReceiver, iFilter);
        }
    }

    private void closeCardEditorWithCheck() {
        if (hasUnsavedChanges()) {
            showDiscardChangesDialog();
        } else {
            closeNoteEditor();
        }
    }

    private void showDiscardChangesDialog() {
        new MaterialDialog.Builder(this).content(R.string.discard_unsaved_changes).positiveText(R.string.dialog_ok)
                .negativeText(R.string.dialog_cancel).callback(new MaterialDialog.ButtonCallback() {
                    @Override
                    public void onPositive(MaterialDialog dialog) {
                        Timber.i("NoteEditor:: OK button pressed to confirm discard changes");
                        closeNoteEditor();
                    }
                }).build().show();
    }

    private void closeNoteEditor() {
        closeNoteEditor(null);
    }

    private void closeNoteEditor(Intent intent) {
        int result;
        if (mChanged) {
            result = RESULT_OK;
        } else {
            result = RESULT_CANCELED;
        }
        if (mReloadRequired) {
            if (intent == null) {
                intent = new Intent();
            }
            intent.putExtra("reloadRequired", true);
        }

        closeNoteEditor(result, intent);
    }

    private void closeNoteEditor(int result) {
        closeNoteEditor(result, null);
    }

    private void closeNoteEditor(int result, Intent intent) {
        if (intent != null) {
            setResult(result, intent);
        } else {
            setResult(result);
        }
        if (mCaller == CALLER_CARDEDITOR_INTENT_ADD) {
            finishWithAnimation(ActivityTransitionAnimation.NONE);
        } else {
            finishWithAnimation(ActivityTransitionAnimation.RIGHT);
        }
    }

    public void onRescheduleCard(int days) {
        Timber.i("Reschedule card");
        getCol().getSched().reschedCards(new long[] { mCurrentEditedCard.getId() }, days, days);
        getCol().reset();
        mReloadRequired = true;
        Themes.showThemedToast(NoteEditor.this,
                getResources().getString(R.string.reschedule_card_dialog_acknowledge), true);
    }

    private void showTagsDialog() {
        if (mSelectedTags == null) {
            mSelectedTags = new ArrayList<String>();
        }
        ArrayList<String> tags = new ArrayList<String>(getCol().getTags().all());
        ArrayList<String> selTags = new ArrayList<String>(mSelectedTags);
        TagsDialog dialog = com.ichi2.anki.dialogs.TagsDialog.newInstance(TagsDialog.TYPE_ADD_TAG, selTags, tags);
        dialog.setTagsDialogListener(new TagsDialogListener() {
            @Override
            public void onPositive(List<String> selectedTags, int option) {
                mSelectedTags = selectedTags;
                updateTags();
            }
        });
        showDialogFragment(dialog);
    }

    private void showCardTemplateEditor() {
        Intent intent = new Intent(this, CardTemplateEditor.class);
        // Pass the model ID
        try {
            intent.putExtra("modelId", getCurrentlySelectedModel().getLong("id"));
        } catch (JSONException e) {
            throw new RuntimeException(e);
        }
        // Also pass the card ID if not adding new note
        if (!mAddNote) {
            intent.putExtra("noteId", mCurrentEditedCard.note().getId());
        }
        startActivityForResultWithAnimation(intent, REQUEST_TEMPLATE_EDIT, ActivityTransitionAnimation.LEFT);
    }

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);

        if (resultCode == DeckPicker.RESULT_DB_ERROR) {
            closeNoteEditor(DeckPicker.RESULT_DB_ERROR);
        }

        switch (requestCode) {
        case REQUEST_ADD:
            if (resultCode != RESULT_CANCELED) {
                mChanged = true;
            }
            break;
        case REQUEST_MULTIMEDIA_EDIT:
            if (resultCode != RESULT_CANCELED) {
                Collection col = getCol();
                Bundle extras = data.getExtras();
                int index = extras.getInt(MultimediaEditFieldActivity.EXTRA_RESULT_FIELD_INDEX);
                IField field = (IField) extras.get(MultimediaEditFieldActivity.EXTRA_RESULT_FIELD);
                IMultimediaEditableNote mNote = NoteService.createEmptyNote(mEditorNote.model());
                NoteService.updateMultimediaNoteFromJsonNote(col, mEditorNote, mNote);
                mNote.setField(index, field);
                FieldEditText fieldEditText = mEditFields.get(index);
                // Completely replace text for text fields (because current text was passed in)
                if (field.getType() == EFieldType.TEXT) {
                    fieldEditText.setText(field.getFormattedValue());
                }
                // Insert text at cursor position if the field has focus
                else if (fieldEditText.hasFocus()) {
                    fieldEditText.getText().replace(fieldEditText.getSelectionStart(),
                            fieldEditText.getSelectionEnd(), field.getFormattedValue());
                }
                // Append text if the field doesn't have focus
                else {
                    fieldEditText.getText().append(field.getFormattedValue());
                }
                NoteService.saveMedia(col, (MultimediaEditableNote) mNote);
                mChanged = true;
            }
            break;
        case REQUEST_TEMPLATE_EDIT:
            if (resultCode == RESULT_OK) {
                mReloadRequired = true;
            }
            updateCards(mEditorNote.model());
        }
    }

    private void populateEditFields() {
        String[][] fields;
        // If we have a bundle of pre-populated field values, we overwrite the existing values
        // with those ones since we are resuming the activity after it was terminated early.
        if (mSavedFields != null) {
            fields = mEditorNote.items();
            for (String key : mSavedFields.keySet()) {
                int ord = Integer.parseInt(key);
                String text = mSavedFields.getString(key);
                fields[ord][1] = text;
            }
            // Clear the saved values since we've consumed them.
            mSavedFields = null;
        } else {
            fields = mEditorNote.items();
        }
        populateEditFields(fields, false);
    }

    private void populateEditFields(String[][] fields, boolean editModelMode) {
        mFieldsLayoutContainer.removeAllViews();
        mEditFields = new LinkedList<FieldEditText>();

        // Use custom font if selected from preferences
        Typeface mCustomTypeface = null;
        SharedPreferences preferences = AnkiDroidApp.getSharedPrefs(getBaseContext());
        String customFont = preferences.getString("browserEditorFont", "");
        if (!customFont.equals("")) {
            mCustomTypeface = AnkiFont.getTypeface(this, customFont);
        }

        for (int i = 0; i < fields.length; i++) {
            View editline_view = getLayoutInflater().inflate(R.layout.card_multimedia_editline, null);
            FieldEditText newTextbox = (FieldEditText) editline_view.findViewById(R.id.id_note_editText);

            initFieldEditText(newTextbox, i, fields[i], mCustomTypeface, !editModelMode);

            TextView label = newTextbox.getLabel();
            label.setPadding((int) UIUtils.getDensityAdjustedValue(this, 3.4f), 0, 0, 0);
            mEditFields.add(newTextbox);

            ImageButton mediaButton = (ImageButton) editline_view.findViewById(R.id.id_media_button);
            // Load icons from attributes
            int[] icons = Themes.getResFromAttr(this, new int[] { R.attr.attachFileImage, R.attr.upDownImage });
            // Make the icon change between media icon and switch field icon depending on whether editing note type
            if (editModelMode && allowFieldRemapping()) {
                // Allow remapping if originally more than two fields
                mediaButton.setBackgroundResource(icons[1]);
                setRemapButtonListener(mediaButton, i);
            } else if (editModelMode && !allowFieldRemapping()) {
                mediaButton.setBackgroundResource(0);
            } else {
                // Use media editor button if not changing note type
                mediaButton.setBackgroundResource(icons[0]);
                setMMButtonListener(mediaButton, i);
            }
            mFieldsLayoutContainer.addView(label);
            mFieldsLayoutContainer.addView(editline_view);
        }
    }

    private void setMMButtonListener(ImageButton mediaButton, final int index) {
        mediaButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Timber.i("NoteEditor:: Multimedia button pressed for field %d", index);
                final Collection col = CollectionHelper.getInstance().getCol(NoteEditor.this);
                if (mEditorNote.items()[index][1].length() > 0) {
                    // If the field already exists then we start the field editor, which figures out the type
                    // automatically
                    IMultimediaEditableNote mNote = NoteService.createEmptyNote(mEditorNote.model());
                    NoteService.updateMultimediaNoteFromJsonNote(col, mEditorNote, mNote);
                    IField field = mNote.getField(index);
                    startMultimediaFieldEditor(index, mNote, field);
                } else {
                    // Otherwise we make a popup menu allowing the user to choose between audio/image/text field
                    // TODO: Update the icons for dark material theme, then can set 3rd argument to true
                    PopupMenuWithIcons popup = new PopupMenuWithIcons(NoteEditor.this, v, false);
                    MenuInflater inflater = popup.getMenuInflater();
                    inflater.inflate(R.menu.popupmenu_multimedia_options, popup.getMenu());
                    popup.setOnMenuItemClickListener(new OnMenuItemClickListener() {
                        @Override
                        public boolean onMenuItemClick(MenuItem item) {
                            IMultimediaEditableNote mNote = NoteService.createEmptyNote(mEditorNote.model());
                            NoteService.updateMultimediaNoteFromJsonNote(col, mEditorNote, mNote);
                            IField field;
                            switch (item.getItemId()) {
                            case R.id.menu_multimedia_audio:
                                Timber.i("NoteEditor:: Record audio button pressed");
                                field = new AudioField();
                                mNote.setField(index, field);
                                startMultimediaFieldEditor(index, mNote, field);
                                return true;
                            case R.id.menu_multimedia_photo:
                                Timber.i("NoteEditor:: Add image button pressed");
                                field = new ImageField();
                                mNote.setField(index, field);
                                startMultimediaFieldEditor(index, mNote, field);
                                return true;
                            case R.id.menu_multimedia_text:
                                Timber.i("NoteEditor:: Advanced editor button pressed");
                                field = new TextField();
                                field.setText(mEditFields.get(index).getText().toString());
                                mNote.setField(index, field);
                                startMultimediaFieldEditor(index, mNote, field);
                                return true;
                            default:
                                return false;
                            }
                        }
                    });
                    popup.show();
                }
            }
        });
    }

    private void setRemapButtonListener(ImageButton remapButton, final int newFieldIndex) {
        remapButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Timber.i("NoteEditor:: Remap button pressed for new field %d", newFieldIndex);
                // Show list of fields from the original note which we can map to
                PopupMenu popup = new PopupMenu(NoteEditor.this, v);
                final String[][] items = mEditorNote.items();
                for (int i = 0; i < items.length; i++) {
                    popup.getMenu().add(Menu.NONE, i, Menu.NONE, items[i][0]);
                }
                // Add "nothing" at the end of the list
                popup.getMenu().add(Menu.NONE, items.length, Menu.NONE, R.string.nothing);
                popup.setOnMenuItemClickListener(new OnMenuItemClickListener() {
                    @Override
                    public boolean onMenuItemClick(MenuItem item) {
                        // Get menu item id
                        Integer idx = item.getItemId();
                        Timber.i("NoteEditor:: User chose to remap to old field %d", idx);
                        // Retrieve any existing mappings between newFieldIndex and idx
                        Integer previousMapping = getKeyByValue(mModelChangeFieldMap, newFieldIndex);
                        Integer mappingConflict = mModelChangeFieldMap.get(idx);
                        // Update the mapping depending on any conflicts
                        if (idx == items.length && previousMapping != null) {
                            // Remove the previous mapping if None selected
                            mModelChangeFieldMap.remove(previousMapping);
                        } else if (idx < items.length && mappingConflict != null && previousMapping != null
                                && newFieldIndex != mappingConflict) {
                            // Swap the two mappings if there was a conflict and previous mapping
                            mModelChangeFieldMap.put(previousMapping, mappingConflict);
                            mModelChangeFieldMap.put(idx, newFieldIndex);
                        } else if (idx < items.length && mappingConflict != null) {
                            // Set the conflicting field to None if no previous mapping to swap into it
                            mModelChangeFieldMap.remove(previousMapping);
                            mModelChangeFieldMap.put(idx, newFieldIndex);
                        } else if (idx < items.length) {
                            // Can simply set the new mapping if no conflicts
                            mModelChangeFieldMap.put(idx, newFieldIndex);
                        }
                        // Reload the fields                     
                        updateFieldsFromMap(getCurrentlySelectedModel());
                        return true;
                    }
                });
                popup.show();
            }
        });
    }

    private void startMultimediaFieldEditor(final int index, IMultimediaEditableNote mNote, IField field) {
        Intent editCard = new Intent(NoteEditor.this, MultimediaEditFieldActivity.class);
        editCard.putExtra(MultimediaEditFieldActivity.EXTRA_FIELD_INDEX, index);
        editCard.putExtra(MultimediaEditFieldActivity.EXTRA_FIELD, field);
        editCard.putExtra(MultimediaEditFieldActivity.EXTRA_WHOLE_NOTE, mNote);
        startActivityForResultWithoutAnimation(editCard, REQUEST_MULTIMEDIA_EDIT);
    }

    private void initFieldEditText(FieldEditText editText, final int index, String[] values,
            Typeface customTypeface, boolean enabled) {
        String name = values[0];
        String content = values[1];
        editText.init(index, name, content);
        if (customTypeface != null) {
            editText.setTypeface(customTypeface);
        }

        // Listen for changes in the first field so we can re-check duplicate status.
        editText.addTextChangedListener(new TextWatcher() {
            @Override
            public void afterTextChanged(Editable arg0) {
                mFieldEdited = true;
                if (index == 0) {
                    duplicateCheck();
                }
            }

            @Override
            public void beforeTextChanged(CharSequence arg0, int arg1, int arg2, int arg3) {
            }

            @Override
            public void onTextChanged(CharSequence arg0, int arg1, int arg2, int arg3) {
            }
        });
        editText.setEnabled(enabled);
    }

    private void setEditFieldTexts(String contents) {
        String[] fields = null;
        int len;
        if (contents == null) {
            len = 0;
        } else {
            fields = Utils.splitFields(contents);
            len = fields.length;
        }
        for (int i = 0; i < mEditFields.size(); i++) {
            if (i < len) {
                mEditFields.get(i).setText(fields[i]);
            } else {
                mEditFields.get(i).setText("");
            }
        }
    }

    private boolean duplicateCheck() {
        boolean isDupe;
        FieldEditText field = mEditFields.get(0);
        // Keep copy of current internal value for this field.
        String oldValue = mEditorNote.getFields()[0];
        // Update the field in the Note so we can run a dupe check on it.
        updateField(field);
        // 1 is empty, 2 is dupe, null is neither.
        Integer dupeCode = mEditorNote.dupeOrEmpty();
        // Change bottom line color of text field
        if (dupeCode != null && dupeCode == 2) {
            field.getBackground().setColorFilter(getResources().getColor(R.color.material_red_500),
                    PorterDuff.Mode.SRC_ATOP);
            isDupe = true;
        } else {
            field.getBackground().clearColorFilter();
            isDupe = false;
        }
        // Put back the old value so we don't interfere with modification detection
        mEditorNote.values()[0] = oldValue;
        return isDupe;
    }

    private String getFieldsText() {
        String[] fields = new String[mEditFields.size()];
        for (int i = 0; i < mEditFields.size(); i++) {
            fields[i] = mEditFields.get(i).getText().toString();
        }
        return Utils.joinFields(fields);
    }

    private void setDid(Note note) {
        // If the target deck ID has already been set, we use that value and avoid trying to
        // determine what it should be again. An existing value means we are resuming the activity
        // where the target deck was already decided by the user.
        if (mCurrentDid != 0) {
            return;
        }
        if (note == null || mAddNote) {
            try {
                JSONObject conf = getCol().getConf();
                JSONObject model = getCol().getModels().current();
                if (conf.optBoolean("addToCur", true)) {
                    mCurrentDid = conf.getLong("curDeck");
                    if (getCol().getDecks().isDyn(mCurrentDid)) {
                        /*
                         * If the deck in mCurrentDid is a filtered (dynamic) deck, then we can't create cards in it,
                         * and we set mCurrentDid to the Default deck. Otherwise, we keep the number that had been
                         * selected previously in the activity.
                         */
                        mCurrentDid = 1;
                    }
                } else {
                    mCurrentDid = model.getLong("did");
                }
            } catch (JSONException e) {
                throw new RuntimeException(e);
            }
        } else {
            mCurrentDid = mCurrentEditedCard.getDid();
        }
    }

    /** Make NOTE the current note. */
    private void setNote() {
        setNote(null);
    }

    private void setNote(Note note) {
        if (note == null || mAddNote) {
            JSONObject model = getCol().getModels().current();
            mEditorNote = new Note(getCol(), model);
        } else {
            mEditorNote = note;
        }
        if (mSelectedTags == null) {
            mSelectedTags = mEditorNote.getTags();
        }
        updateDeckPosition();
        updateTags();
        updateCards(mEditorNote.model());
        populateEditFields();
    }

    private void updateDeckPosition() {
        int position = mAllDeckIds.indexOf(mCurrentDid);
        if (position != -1) {
            mNoteDeckSpinner.setSelection(position, false);
        } else {
            Timber.e("updateDeckPosition() error :: mCurrentDid=%d, position=%d", mCurrentDid, position);
        }
    }

    private void updateTags() {
        if (mSelectedTags == null) {
            mSelectedTags = new ArrayList<String>();
        }
        mTagsButton.setText(getResources().getString(R.string.CardEditorTags,
                getCol().getTags().join(getCol().getTags().canonify(mSelectedTags)).trim().replace(" ", ", ")));
    }

    /** Update the list of card templates for current note type */
    private void updateCards(JSONObject model) {
        try {
            JSONArray tmpls = model.getJSONArray("tmpls");
            String cardsList = "";
            // Build comma separated list of card names
            for (int i = 0; i < tmpls.length(); i++) {
                String name = tmpls.getJSONObject(i).optString("name");
                // If more than one card then make currently selected card underlined
                if (!mAddNote && tmpls.length() > 1 && model == mEditorNote.model()
                        && mCurrentEditedCard.template().optString("name").equals(name)) {
                    name = "<u>" + name + "</u>";
                }
                cardsList += name;
                if (i < tmpls.length() - 1) {
                    cardsList += ", ";
                }
            }
            // Make cards list red if the number of cards is being reduced
            if (!mAddNote && tmpls.length() < mEditorNote.model().getJSONArray("tmpls").length()) {
                cardsList = "<font color='red'>" + cardsList + "</font>";
            }
            mCardsButton.setText(Html.fromHtml(getResources().getString(R.string.CardEditorCards, cardsList)));
        } catch (JSONException e) {
            throw new RuntimeException(e);
        }
    }

    private boolean updateField(FieldEditText field) {
        String newValue = field.getText().toString().replace(FieldEditText.NEW_LINE, "<br>");
        if (!mEditorNote.values()[field.getOrd()].equals(newValue)) {
            mEditorNote.values()[field.getOrd()] = newValue;
            return true;
        }
        return false;
    }

    private String tagsAsString(List<String> tags) {
        return TextUtils.join(" ", tags);
    }

    private JSONObject getCurrentlySelectedModel() {
        return getCol().getModels().get(mAllModelIds.get(mNoteTypeSpinner.getSelectedItemPosition()));
    }

    /**
     * Convenience method for getting the corresponding key given the value in a 1-to-1 map
     * @param map
     * @param value
     * @return
     */
    private <T, E> T getKeyByValue(Map<T, E> map, E value) {
        for (Entry<T, E> entry : map.entrySet()) {
            if (value.equals(entry.getValue())) {
                return entry.getKey();
            }
        }
        return null;
    }

    /**
     * Update all the field EditText views based on the currently selected note type and the mModelChangeFieldMap
     */
    private void updateFieldsFromMap(JSONObject newModel) {
        // Get the field map for new model and old fields list
        String[][] oldFields = mEditorNote.items();
        Map<String, Pair<Integer, JSONObject>> fMapNew = getCol().getModels().fieldMap(newModel);
        // Build array of label/values to provide to field EditText views
        String[][] fields = new String[fMapNew.size()][2];
        for (String fname : fMapNew.keySet()) {
            // Field index of new note type
            Integer i = fMapNew.get(fname).first;
            // Add values from old note type if they exist in map, otherwise make the new field empty
            if (mModelChangeFieldMap.containsValue(i)) {
                // Get index of field from old note type given the field index of new note type
                Integer j = getKeyByValue(mModelChangeFieldMap, i);
                // Set the new field label text
                if (allowFieldRemapping()) {
                    // Show the content of old field if remapping is enabled
                    fields[i][0] = String.format(getResources().getString(R.string.field_remapping), fname,
                            oldFields[j][0]);
                } else {
                    fields[i][0] = fname;
                }

                // Set the new field label value
                fields[i][1] = oldFields[j][1];
            } else {
                // No values from old note type exist in the mapping
                fields[i][0] = fname;
                fields[i][1] = "";
            }
        }
        populateEditFields(fields, true);
        updateCards(newModel);
    }

    /**
     *
     * @return whether or not to allow remapping of fields for current model
     */
    private boolean allowFieldRemapping() {
        // Map<String, Pair<Integer, JSONObject>> fMapNew = getCol().getModels().fieldMap(getCurrentlySelectedModel())
        return mEditorNote.items().length > 2;
    }

    // ----------------------------------------------------------------------------
    // INNER CLASSES
    // ----------------------------------------------------------------------------

    public class JSONNameComparator implements Comparator<JSONObject> {
        @Override
        public int compare(JSONObject lhs, JSONObject rhs) {
            String[] o1;
            String[] o2;
            try {
                o1 = lhs.getString("name").split("::");
                o2 = rhs.getString("name").split("::");
            } catch (JSONException e) {
                throw new RuntimeException(e);
            }
            for (int i = 0; i < Math.min(o1.length, o2.length); i++) {
                int result = o1[i].compareToIgnoreCase(o2[i]);
                if (result != 0) {
                    return result;
                }
            }
            if (o1.length < o2.length) {
                return -1;
            } else if (o1.length > o2.length) {
                return 1;
            } else {
                return 0;
            }
        }
    }

    private class SetNoteTypeListener implements OnItemSelectedListener {
        @Override
        public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
            // If a new column was selected then change the key used to map from mCards to the column TextView
            //Timber.i("NoteEditor:: onItemSelected() fired on mNoteTypeSpinner");
            long oldModelId;
            try {
                oldModelId = getCol().getModels().current().getLong("id");
            } catch (JSONException e) {
                throw new RuntimeException(e);
            }
            long newId = mAllModelIds.get(pos);
            if (oldModelId != newId) {
                JSONObject model = getCol().getModels().get(newId);
                getCol().getModels().setCurrent(model);
                JSONObject cdeck = getCol().getDecks().current();
                try {
                    cdeck.put("mid", newId);
                } catch (JSONException e) {
                    throw new RuntimeException(e);
                }
                getCol().getDecks().save(cdeck);
                // Update deck
                if (!getCol().getConf().optBoolean("addToCur", true)) {
                    try {
                        mCurrentDid = model.getLong("did");
                        updateDeckPosition();
                    } catch (JSONException e) {
                        throw new RuntimeException(e);
                    }
                }
                // Reset edit fields
                int size = mEditFields.size();
                String[] oldValues = new String[size];
                for (int i = 0; i < size; i++) {
                    oldValues[i] = mEditFields.get(i).getText().toString();
                }
                setNote();
                resetEditFields(oldValues);
                duplicateCheck();
            }
        }

        @Override
        public void onNothingSelected(AdapterView<?> parent) {
            // Do Nothing
        }
    }

    private class EditNoteTypeListener implements OnItemSelectedListener {
        @Override
        public void onItemSelected(AdapterView<?> parent, View view, int pos, long id) {
            // Get the current model
            long noteModelId;
            try {
                noteModelId = mCurrentEditedCard.model().getLong("id");
            } catch (JSONException e) {
                throw new RuntimeException(e);
            }
            // Get new model
            JSONObject newModel = getCol().getModels().get(mAllModelIds.get(pos));
            // Configure the interface according to whether note type is getting changed or not
            if (mAllModelIds.get(pos) != noteModelId) {
                // Initialize mapping between fields of old model -> new model
                mModelChangeFieldMap = new HashMap<Integer, Integer>();
                for (int i = 0; i < mEditorNote.items().length; i++) {
                    mModelChangeFieldMap.put(i, i);
                }
                // Initialize mapping between cards new model -> old model
                mModelChangeCardMap = new HashMap<Integer, Integer>();
                try {
                    for (int i = 0; i < newModel.getJSONArray("tmpls").length(); i++) {
                        if (i < mEditorNote.cards().size()) {
                            mModelChangeCardMap.put(i, i);
                        } else {
                            mModelChangeCardMap.put(i, null);
                        }
                    }
                } catch (JSONException e) {
                    throw new RuntimeException(e);
                }
                // Update the field text edits based on the default mapping just assigned
                updateFieldsFromMap(newModel);
                // Don't let the user change any other values at the same time as changing note type
                mSelectedTags = mEditorNote.getTags();
                updateTags();
                ((LinearLayout) findViewById(R.id.CardEditorTagButton)).setEnabled(false);
                //((LinearLayout) findViewById(R.id.CardEditorCardsButton)).setEnabled(false);
                mNoteDeckSpinner.setEnabled(false);
                int position = mAllDeckIds.indexOf(mCurrentEditedCard.getDid());
                if (position != -1) {
                    mNoteDeckSpinner.setSelection(position, false);
                }
            } else {
                populateEditFields();
                updateCards(mCurrentEditedCard.model());
                ((LinearLayout) findViewById(R.id.CardEditorTagButton)).setEnabled(true);
                //((LinearLayout) findViewById(R.id.CardEditorCardsButton)).setEnabled(false);
                mNoteDeckSpinner.setEnabled(true);
            }
        }

        @Override
        public void onNothingSelected(AdapterView<?> parent) {
            // Do Nothing
        }
    }
}