/*************************************************************************************** * * * 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.Context; import android.content.Intent; import android.content.res.Resources; import android.os.Bundle; import android.support.v4.app.Fragment; import android.support.v4.app.FragmentManager; import android.support.v4.app.FragmentPagerAdapter; import android.support.v4.view.PagerAdapter; import android.support.v4.view.ViewPager; import android.support.v7.widget.Toolbar; import android.text.Editable; import android.text.TextWatcher; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.view.inputmethod.InputMethodManager; import android.widget.EditText; import com.afollestad.materialdialogs.MaterialDialog; import com.ichi2.anim.ActivityTransitionAnimation; import com.ichi2.anki.dialogs.ConfirmationDialog; import com.ichi2.anki.exception.ConfirmModSchemaException; import com.ichi2.async.DeckTask; import com.ichi2.libanki.Card; import com.ichi2.libanki.Collection; import com.ichi2.libanki.Models; import com.ichi2.libanki.Note; import com.ichi2.themes.Themes; import com.ichi2.ui.SlidingTabLayout; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.util.ArrayList; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; import timber.log.Timber; /** * Allows the user to view the template for the current note type */ public class CardTemplateEditor extends AnkiActivity { private TemplatePagerAdapter mTemplateAdapter; private JSONObject mModelBackup = null; private ViewPager mViewPager; private SlidingTabLayout mSlidingTabLayout; private long mModelId; private long mNoteId; private static final int REQUEST_PREVIEWER = 0; private static final String DUMMY_TAG = "DUMMY_NOTE_TO_DELETE_x0-90-fa"; // ---------------------------------------------------------------------------- // Listeners // ---------------------------------------------------------------------------- /* Used for updating the collection when a reverse card is added */ private DeckTask.TaskListener mUpdateTemplateHandler = new DeckTask.TaskListener() { @Override public void onPreExecute() { showProgressBar(); } @Override public void onProgressUpdate(DeckTask.TaskData... values) { } @Override public void onPostExecute(DeckTask.TaskData result) { hideProgressBar(); if (result.getBoolean()) { // Refresh the GUI -- setting the last template as the active tab try { selectTemplate(getCol().getModels().get(mModelId).getJSONArray("tmpls").length()); } catch (JSONException e) { throw new RuntimeException(e); } } else if (result.getString() != null && result.getString().equals("removeTemplateFailed")) { // Failed to remove template String message = getResources().getString(R.string.card_template_editor_would_delete_note); UIUtils.showThemedToast(CardTemplateEditor.this, message, false); } else { // RuntimeException occurred setResult(RESULT_CANCELED); finishWithoutAnimation(); } } @Override public void onCancelled() { hideProgressBar(); } }; // ---------------------------------------------------------------------------- // ANDROID METHODS // ---------------------------------------------------------------------------- @Override protected void onCreate(Bundle savedInstanceState) { Timber.d("onCreate()"); super.onCreate(savedInstanceState); setContentView(R.layout.card_template_editor_activity); // Load the args either from the intent or savedInstanceState bundle if (savedInstanceState == null) { // get model id mModelId = getIntent().getLongExtra("modelId", -1L); if (mModelId == -1) { Timber.e("CardTemplateEditor :: no model ID was provided"); finishWithoutAnimation(); return; } // get id for currently edited card (optional) mNoteId = getIntent().getLongExtra("noteId", -1L); } else { mModelId = savedInstanceState.getLong("modelId"); mNoteId = savedInstanceState.getLong("noteId"); try { mModelBackup = new JSONObject(savedInstanceState.getString("modelBackup")); } catch (JSONException e) { Timber.e(e, "Malformed model in savedInstanceState"); } } // Disable the home icon Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); if (toolbar != null) { setSupportActionBar(toolbar); } startLoadingCollection(); } @Override public void onSaveInstanceState(Bundle outState) { if (mModelBackup != null) { outState.putString("modelBackup", mModelBackup.toString()); } outState.putLong("modelId", mModelId); outState.putLong("noteId", mNoteId); super.onSaveInstanceState(outState); } @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case android.R.id.home: { if (modelHasChanged()) { showDiscardChangesDialog(); } else { finishWithAnimation(ActivityTransitionAnimation.RIGHT); } return true; } default: return super.onOptionsItemSelected(item); } } @Override public void showProgressBar() { super.showProgressBar(); findViewById(R.id.progress_description).setVisibility(View.VISIBLE); findViewById(R.id.fragment_parent).setVisibility(View.INVISIBLE); } @Override public void hideProgressBar() { super.hideProgressBar(); findViewById(R.id.progress_description).setVisibility(View.INVISIBLE); findViewById(R.id.fragment_parent).setVisibility(View.VISIBLE); } /** * Callback used to finish initializing the activity after the collection has been correctly loaded * @param col Collection which has been loaded */ @Override protected void onCollectionLoaded(Collection col) { super.onCollectionLoaded(col); // Create the adapter that will return a fragment for each of the three // primary sections of the activity. mTemplateAdapter = new TemplatePagerAdapter(getSupportFragmentManager()); mTemplateAdapter.setModel(col.getModels().get(mModelId)); // Set up the ViewPager with the sections adapter. mViewPager = (ViewPager) findViewById(R.id.pager); mViewPager.setAdapter(mTemplateAdapter); mViewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() { @Override public void onPageScrolled(final int position, final float v, final int i2) { } @Override public void onPageSelected(final int position) { CardTemplateFragment fragment = (CardTemplateFragment) mTemplateAdapter.instantiateItem(mViewPager, position); if (fragment != null) { fragment.updateCss(); } } @Override public void onPageScrollStateChanged(final int position) { } }); mSlidingTabLayout = (SlidingTabLayout) findViewById(R.id.sliding_tabs); mSlidingTabLayout.setViewPager(mViewPager); // Set activity title if (getSupportActionBar() != null) { getSupportActionBar().setTitle(R.string.title_activity_template_editor); getSupportActionBar().setSubtitle(col.getModels().get(mModelId).optString("name")); } // Make backup of the model for cancellation purposes try { if (mModelBackup == null) { mModelBackup = new JSONObject(col.getModels().get(mModelId).toString()); } } catch (JSONException e) { throw new RuntimeException(e); } // Close collection opening dialog if needed Timber.i("CardTemplateEditor:: Card template editor successfully started for model id %d", mModelId); } public boolean modelHasChanged() { JSONObject newModel = getCol().getModels().get(mModelId); return mModelBackup != null && !mModelBackup.toString().equals(newModel.toString()); } 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("TemplateEditor:: OK button pressed to confirm discard changes"); getCol().getModels().update(CardTemplateEditor.this.mModelBackup); getCol().getModels().flush(); getCol().reset(); finishWithAnimation(ActivityTransitionAnimation.RIGHT); } }) .build().show(); } // ---------------------------------------------------------------------------- // CUSTOM METHODS // ---------------------------------------------------------------------------- /** * Refresh list of templates and select position * @param idx index of template to select */ public void selectTemplate(int idx) { // invalidate all existing fragments mTemplateAdapter.notifyChangeInPosition(1); // notify of new data set mTemplateAdapter.notifyDataSetChanged(); // reload the list of tabs mSlidingTabLayout.setViewPager(mViewPager); // select specified tab mViewPager.setCurrentItem(idx); } // ---------------------------------------------------------------------------- // INNER CLASSES // ---------------------------------------------------------------------------- /** * A {@link android.support.v4.app.FragmentPagerAdapter} that returns a fragment corresponding to * one of the tabs. */ public class TemplatePagerAdapter extends FragmentPagerAdapter { private JSONObject mModel; private long baseId = 0; public TemplatePagerAdapter(FragmentManager fm) { super(fm); } //this is called when notifyDataSetChanged() is called @Override public int getItemPosition(Object object) { // refresh all tabs when data set changed return PagerAdapter.POSITION_NONE; } @Override public Fragment getItem(int position) { return CardTemplateFragment.newInstance(position, mModelId, mNoteId); } @Override public long getItemId(int position) { // give an ID different from position when position has been changed return baseId + position; } @Override public int getCount() { try { return mModel.getJSONArray("tmpls").length(); } catch (JSONException e) { throw new RuntimeException(e); } } @Override public CharSequence getPageTitle(int position) { try { return mModel.getJSONArray("tmpls").getJSONObject(position).getString("name"); } catch (JSONException e) { throw new RuntimeException(e); } } /** * Notify that the position of a fragment has been changed. * Create a new ID for each position to force recreation of the fragment * @see <a href="http://stackoverflow.com/questions/10396321/remove-fragment-page-from-viewpager-in-android/26944013#26944013">stackoverflow</a> * @param n number of items which have been changed */ public void notifyChangeInPosition(int n) { // shift the ID returned by getItemId outside the range of all previous fragments baseId += getCount() + n; } public void setModel(JSONObject model) { mModel = model; } } public static class CardTemplateFragment extends Fragment{ EditText mFront; EditText mCss; EditText mBack; JSONObject mModel; public static CardTemplateFragment newInstance(int position, long modelId, long noteId) { CardTemplateFragment f = new CardTemplateFragment(); Bundle args = new Bundle(); args.putInt("position", position); args.putLong("modelId",modelId); args.putLong("noteId",noteId); f.setArguments(args); return f; } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View mainView = inflater.inflate(R.layout.card_template_editor_item, container, false); final int position = getArguments().getInt("position"); try { // Load template long mid = getArguments().getLong("modelId"); mModel = ((AnkiActivity) getActivity()).getCol().getModels().get(mid); final JSONArray tmpls = mModel.getJSONArray("tmpls"); final JSONObject template = tmpls.getJSONObject(position); // Load EditText Views mFront = ((EditText) mainView.findViewById(R.id.front_edit)); mCss = ((EditText) mainView.findViewById(R.id.styling_edit)); mBack = ((EditText) mainView.findViewById(R.id.back_edit)); // Set EditText content mFront.setText(template.getString("qfmt")); mCss.setText(mModel.getString("css")); mBack.setText(template.getString("afmt")); // Set text change listeners TextWatcher templateEditorWatcher = new TextWatcher() { @Override public void afterTextChanged(Editable arg0) { try { template.put("qfmt", mFront.getText()); template.put("afmt", mBack.getText()); mModel.put("css", mCss.getText()); tmpls.put(position, template); mModel.put("tmpls", tmpls); } catch (JSONException e) { Timber.e(e, "Could not update card template"); } } @Override public void beforeTextChanged(CharSequence arg0, int arg1, int arg2, int arg3) {} @Override public void onTextChanged(CharSequence arg0, int arg1, int arg2, int arg3) {} }; mFront.addTextChangedListener(templateEditorWatcher); mCss.addTextChangedListener(templateEditorWatcher); mBack.addTextChangedListener(templateEditorWatcher); // Enable menu setHasOptionsMenu(true); } catch (JSONException e) { throw new RuntimeException(e); } return mainView; } @Override public void onResume() { super.onResume(); } private void updateCss() { if (mCss != null && mModel!= null) { try { mCss.setText(mModel.getString("css")); } catch (JSONException e) { // do nothing } } } @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { inflater.inflate(R.menu.card_template_editor, menu); super.onCreateOptionsMenu(menu, inflater); } @Override public boolean onOptionsItemSelected(MenuItem item) { final Collection col = ((AnkiActivity) getActivity()).getCol(); final JSONObject model = getModel(); switch (item.getItemId()) { case R.id.action_add: Timber.i("CardTemplateEditor:: Add template button pressed"); addNewTemplateWithCheck(getModel()); return true; case R.id.action_delete: { Timber.i("CardTemplateEditor:: Delete template button pressed"); Resources res = getResources(); int position = getArguments().getInt("position"); try { JSONArray tmpls = model.getJSONArray("tmpls"); final JSONObject template = tmpls.getJSONObject(position); // Don't do anything if only one template if (tmpls.length() < 2) { UIUtils.showThemedToast(getActivity(), res.getString(R.string.card_template_editor_cant_delete), false); return true; } // Show confirmation dialog int numAffectedCards = col.getModels().tmplUseCount(model, position); confirmDeleteCards(template, model, numAffectedCards); } catch (JSONException e) { throw new RuntimeException(e); } return true; } case R.id.action_preview: { Timber.i("CardTemplateEditor:: Preview model button pressed"); // Save the model if necessary if (modelHasChanged()) { col.getModels().save(model, false); } // Create intent for the previewer and add some arguments Intent i = new Intent(getActivity(), Previewer.class); int pos = getArguments().getInt("position"); long cid; if (getArguments().getLong("noteId") != -1L && pos < col.getNote(getArguments().getLong("noteId")).cards().size()) { // Give the card ID if we started from an actual note and it has a card generated in this pos cid = col.getNote(getArguments().getLong("noteId")).cards().get(pos).getId(); } else { // Otherwise create a dummy card to show the effect of formatting Card dummyCard = getDummyCard(); if (dummyCard != null) { cid = dummyCard.getId(); } else { UIUtils.showSimpleSnackbar(getActivity(), R.string.invalid_template, false); return true; } } // Launch intent i.putExtra("cardList", new long[] {cid}); i.putExtra("index", 0); startActivityForResult(i, REQUEST_PREVIEWER); return true; } case R.id.action_confirm: Timber.i("CardTemplateEditor:: Save model button pressed"); if (modelHasChanged()) { // regenerate the cards of the model DeckTask.TaskData args = new DeckTask.TaskData(new Object[] {model}); DeckTask.launchDeckTask(DeckTask.TASK_TYPE_SAVE_MODEL, mSaveModelAndExitHandler, args); } else { ((AnkiActivity) getActivity()).finishWithAnimation(ActivityTransitionAnimation.RIGHT); } return true; default: return super.onOptionsItemSelected(item); } } /** * Get a dummy card * @return */ private Card getDummyCard() { Timber.d("Creating dummy note"); JSONObject model = getCol().getModels().get(getArguments().getLong("modelId")); Note n =getCol().newNote(model); ArrayList<String> fieldNames = getCol().getModels().fieldNames(model); for (int i = 0; i < fieldNames.size(); i++) { n.setField(i, fieldNames.get(i)); } n.addTag(DUMMY_TAG); getCol().addNote(n); if (n.cards().size() <= getArguments().getInt("position")) { return null; } return getCol().getCard(n.cards().get(getArguments().getInt("position")).getId()); } private void deleteDummyCards() { // TODO: make into an async task List<Long> remnantNotes = getCol().findNotes("tag:" + DUMMY_TAG); if (remnantNotes.size() > 0) { long[] nids = new long[remnantNotes.size()]; for (int i = 0; i < remnantNotes.size(); i++) { nids[i] = remnantNotes.get(i); } getCol().remNotes(nids); getCol().save(); } } @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); deleteDummyCards(); } /* Used for updating the collection when a model has been edited */ private DeckTask.TaskListener mSaveModelAndExitHandler = new DeckTask.TaskListener() { @Override public void onPreExecute() { ((AnkiActivity) getActivity()).showProgressBar(); final InputMethodManager imm = (InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE); imm.hideSoftInputFromWindow(getView().getWindowToken(), 0); } @Override public void onProgressUpdate(DeckTask.TaskData... values) { } @Override public void onPostExecute(DeckTask.TaskData result) { if (result.getBoolean()) { getActivity().setResult(RESULT_OK); ((AnkiActivity) getActivity()).finishWithAnimation(ActivityTransitionAnimation.RIGHT); } else { // RuntimeException occurred getActivity().setResult(RESULT_CANCELED); ((AnkiActivity) getActivity()).finishWithoutAnimation(); } } @Override public void onCancelled() {} }; private boolean modelHasChanged() { return ((CardTemplateEditor) getActivity()).modelHasChanged(); } /** * Load the model from the collection * @return the model we are editing */ private JSONObject getModel() { long mid = getArguments().getLong("modelId"); return ((AnkiActivity) getActivity()).getCol().getModels().get(mid); } /** * Get the collection * @return */ private Collection getCol() { return ((AnkiActivity) getActivity()).getCol(); } /** * Confirm if the user wants to delete all the cards associated with current template * * @param tmpl template to remove * @param model model to remove from * @param numAffectedCards number of cards which will be affected */ private void confirmDeleteCards(final JSONObject tmpl, final JSONObject model, int numAffectedCards) { ConfirmationDialog d = new ConfirmationDialog(); Resources res = getResources(); String msg = String.format(res.getQuantityString(R.plurals.card_template_editor_confirm_delete, numAffectedCards), numAffectedCards, tmpl.optString("name")); d.setArgs(msg); Runnable confirm = new Runnable() { @Override public void run() { deleteTemplateWithCheck(tmpl, model); } }; d.setConfirm(confirm); ((AnkiActivity) getActivity()).showDialogFragment(d); } /** * Delete tmpl from model, asking user to confirm again if it's going to require a full sync * * @param tmpl template to remove * @param model model to remove from */ private void deleteTemplateWithCheck(final JSONObject tmpl, final JSONObject model) { try { ((CardTemplateEditor) getActivity()).getCol().modSchema(true); deleteTemplate(tmpl, model); } catch (ConfirmModSchemaException e) { ConfirmationDialog d = new ConfirmationDialog(); d.setArgs(getResources().getString(R.string.full_sync_confirmation)); Runnable confirm = new Runnable() { @Override public void run() { deleteTemplate(tmpl, model); } }; d.setConfirm(confirm); ((AnkiActivity) getActivity()).showDialogFragment(d); } } /** * Launch background task to delete tmpl from model * @param tmpl template to remove * @param model model to remove from */ private void deleteTemplate(JSONObject tmpl, JSONObject model) { CardTemplateEditor activity = ((CardTemplateEditor) getActivity()); activity.getCol().modSchemaNoCheck(); Object [] args = new Object[] {model, tmpl}; DeckTask.launchDeckTask(DeckTask.TASK_TYPE_REMOVE_TEMPLATE, activity.mUpdateTemplateHandler, new DeckTask.TaskData(args)); activity.dismissAllDialogFragments(); } /** * Add new template to model, asking user to confirm if it's going to require a full sync * * @param model model to add new template to */ private void addNewTemplateWithCheck(final JSONObject model) { try { ((CardTemplateEditor) getActivity()).getCol().modSchema(true); addNewTemplate(model); } catch (ConfirmModSchemaException e) { ConfirmationDialog d = new ConfirmationDialog(); d.setArgs(getResources().getString(R.string.full_sync_confirmation)); Runnable confirm = new Runnable() { @Override public void run() { addNewTemplate(model); } }; d.setConfirm(confirm); ((AnkiActivity) getActivity()).showDialogFragment(d); } } /** * Launch background task to add new template to model * @param model model to add new template to */ private void addNewTemplate(JSONObject model) { CardTemplateEditor activity = ((CardTemplateEditor) getActivity()); activity.getCol().modSchemaNoCheck(); Models mm = activity.getCol().getModels(); // Build new template JSONObject newTemplate; try { int oldPosition = getArguments().getInt("position"); JSONArray templates = model.getJSONArray("tmpls"); JSONObject oldTemplate = templates.getJSONObject(oldPosition); newTemplate = mm.newTemplate(newCardName(templates)); // Set up question & answer formats newTemplate.put("qfmt", oldTemplate.get("qfmt")); newTemplate.put("afmt", oldTemplate.get("afmt")); // Reverse the front and back if only one template if (templates.length() == 1) { flipQA(newTemplate); } } catch (JSONException e) { throw new RuntimeException(e); } // Add new template to the current model via AsyncTask Object [] args = new Object[] {model, newTemplate}; DeckTask.launchDeckTask(DeckTask.TASK_TYPE_ADD_TEMPLATE, activity.mUpdateTemplateHandler, new DeckTask.TaskData(args)); activity.dismissAllDialogFragments(); } /** * Flip the question and answer side of the template * @param template template to flip */ private void flipQA (JSONObject template) { try { String qfmt = template.getString("qfmt"); String afmt = template.getString("afmt"); Matcher m = Pattern.compile("(?s)(.+)<hr id=answer>(.+)").matcher(afmt); if (!m.find()) { template.put("qfmt", afmt.replace("{{FrontSide}}","")); } else { template.put("qfmt",m.group(2).trim()); } template.put("afmt","{{FrontSide}}\n\n<hr id=answer>\n\n" + qfmt); } catch (JSONException e) { throw new RuntimeException(e); } } /** * Get name for new template * @param templates array of templates which is being added to * @return name for new template */ private String newCardName(JSONArray templates) { String name; // Start by trying to set the name to "Card n" where n is the new num of templates int n = templates.length() + 1; // If the starting point for name already exists, iteratively increase n until we find a unique name while (true) { // Get new name name = "Card " + Integer.toString(n); // Cycle through all templates checking if new name exists boolean exists = false; for (int i = 0; i < templates.length(); i++) { try { exists = exists || name.equals(templates.getJSONObject(i).getString("name")); } catch (JSONException e) { throw new RuntimeException(e); } } if (!exists) { break; } n+=1; } return name; } } }