Randomized multiple-choice cloze tests (similar to Clozemaster) for Anki desktop and mobile: Experimental card template/mini-game

Contents

Overview

Demo

This card template takes a given sentence and randomly generates a cloze deletion for it. The cloze deletion is randomly selected every time you see the card. So that you don’t always get the same common words (like a, the, and, etc.), the length of the cloze deletion is always equal to or greater than the length of the average word in the sentence, rounded down to the nearest integer. Finally, the card template loads a list of four choices for you to choose from.

Because of the random nature of the selections, I think this template is probably more useful for people who want some casual language practice, and who aren’t looking to memorize anything. Think of it more as a mini-game than a flashcard.

This card template might be a bit of a hassle to set up, and to be honest, that’s mostly because I created it for my personal use. But because I think some people might enjoy using it, or find parts of the code useful, I am posting it here. Please be aware that I am not a programmer, and I have no intention of developing this template further. I am sharing this at this point precisely because this is about as far as I myself can take it, so others may as well get to play around with it now.

To set up this template, see the following section.

Setting up the card template

To use this template, you can simply add a new card type to an existing note type—but you may want to create a new note type, because styling affects all cards. (There is card-specific styling, but you would have to alter your current card styling. I guess the easiest method depends on how much CSS your cards currently have.)

All your note needs is a sentence field from which to generate the cloze, and an optional translation or hint field. The template below displays the sentence field {{Korean}} with {{hint:English}} below it as a hint—simply change the names to match your fields. You also need to change the language code "ko" in the line const segmenter = new Intl.Segmenter("ko", { so that it matches the language you are studying. (A list of language codes is here.) On the back side, you only need to change {{English}}.

Copy/paste the HTML below onto the front, back, and styling card templates, and then make the above changes:

Front Template

<div class="contentbox front">
  <div class="info">
    <span class="tags {{Tags}}"></span>
  </div>

  <div class="question">
    <div class="mySentence notifier" id="myCloze">{{Korean}}</div>
  </div>

  <div class="examples">
    <div>{{hint:English}}</div>
  </div>

  <div class="translation">
    <span id="sourcetext"></span>
    <span id="translation"></span>
  </div>

  <div class="choices"></div>
</div>



<script>
var correctAnswer = "";
var avgLength = 0;

(() => {
  const sentence = document.querySelector(".mySentence").innerText;

  const segmenter = new Intl.Segmenter("ko", {
    granularity: "word"
  });

  const segments = segmenter.segment(sentence);
  const iterator1 = segmenter.segment(sentence)[Symbol.iterator]();

  var myCloze = "";
  var text = "";

  var totalLength = 0;
  var wordNum = 0;

  for (const i of segments) {
    var x = iterator1.next();

    if (x.value.isWordLike == true) {
      myCloze += "<span class='cloze targetwords' onclick='getText()'>" + x.value.segment + "</span>";
      totalLength += x.value.segment.length;
      wordNum++;
    } else {
      myCloze += x.value.segment;
    }
  }

  avgLength = Math.floor(totalLength / wordNum);
  document.getElementById("myCloze").innerHTML = myCloze;

  clozeList = document.querySelectorAll(".cloze");

  let matchFound = false;

  while (matchFound == false) {
    let x = Math.floor(Math.random() * clozeList.length);

    if (clozeList[x].innerText.length >= avgLength) {
      clozeList[x].classList.add("selectedCloze");
      correctAnswer = clozeList[x].innerText;
      matchFound = true;
    }
  }
})();

var myScript = document.createElement("script");

/* custom variables */

var choicesNum = 4;

myScript.src = "_MKGsentences.js";

/* assemble multiple choice section */

myScript.onload = function() {
  myFunction()
};

function myFunction() {

  var myChoices = [correctAnswer];

  /* get incorrect choices */

  while (myChoices.length < choicesNum) {

    var myRandChoice = choices[Math.floor(Math.random() * (choices.length))];
    var newChoice = true;

    for (let i = 0; i < myChoices.length; i++) {
      if (myRandChoice == myChoices[i]) {
        myRandChoice = choices[Math.floor(Math.random() * (choices.length))];
        newChoice = false;
        break;
      }
      if (myRandChoice.length < avgLength) {
        myRandChoice = choices[Math.floor(Math.random() * (choices.length))];
        newChoice = false;
        break;
      }
    }

    if (newChoice) {
      myChoices.push(myRandChoice);
    }
  }

  /* wrap choices */

  for (let i = 0; i < myChoices.length; i++) {
    if (i == 0) {
      myChoices[i] = '<li class="correct">' + myChoices[i] + '</li>';
    } else {
      myChoices[i] = '<li class="incorrect">' + myChoices[i] + '</li>';
    }
  }

  /* randomize js array choices order */

  for (let i = myChoices.length - 1; i > 0; i--) {
    let j = Math.floor(Math.random() * (i + 1));
    let k = myChoices[i];
    myChoices[i] = myChoices[j];
    myChoices[j] = k;
  }

  /* convert array to html unordered list */

  var myChoicesList = "<ul>";

  for (let i = 0; i < myChoices.length; i++) {
    myChoicesList += myChoices[i];
  }

  myChoicesList += "</ul>";

  document.querySelector(".choices").innerHTML = myChoicesList;

  createButtons();
}

document.head.appendChild(myScript);

function detectAnkiPlatform() {
  if (typeof pycmd !== "undefined") {
    return "AnkiDesktop";
  } else if (window.anki && window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.cb !== undefined) {
    return "AnkiMobile";
  }
}

function createButtons() {
  var choiceList = document.querySelector("ul");

  var button = document.querySelectorAll("li");

  var platform = detectAnkiPlatform();

  if (platform == "AnkiDesktop") {
    for (let i = 0; i < button.length; i++) {
      button[i].addEventListener("click", showAns);
    }
  } else if (platform == "AnkiMobile") {
    for (let i = 0; i < button.length; i++) {
      button[i].addEventListener("touchend", showAnsMobile);
    }
  }
}

function showAns() {
  event.target.classList.add("selected");

  var choiceList = document.querySelector("ul").outerHTML;

  sessionStorage.setItem("answer", choiceList);
  sessionStorage.setItem("myCloze", document.getElementById("myCloze").innerHTML);

  pycmd("ans");
}

function showAnsMobile() {
  event.target.classList.add("selected");

  var choiceList = document.querySelector("ul").outerHTML;

  sessionStorage.setItem("answer", choiceList);
  sessionStorage.setItem("myCloze", document.getElementById("myCloze").innerHTML);

  window.webkit.messageHandlers.cb.postMessage(JSON.stringify({
    scheme: "ankitap",
    msg: "topCenter"
  }));
}

sessionStorage.setItem("answer", "");
sessionStorage.setItem("myCloze", "");
</script>

Back Template

<div class="contentbox back">
  <div class="info">
    <span class="tags {{Tags}}"></span>
  </div>

  <div class="question">
    <div class="mySentence notifier" id="myCloze"></div>
  </div>

  <div class="examples">
    <div>{{English}}</div>
  </div>

  <div class="translation">
    <span id="sourcetext"></span>
    <span id="translation"></span>
  </div>

  <div class="choices"></div>
</div>



<script>
document.getElementById("myCloze").innerHTML = sessionStorage.getItem("myCloze");
document.querySelector(".choices").innerHTML = sessionStorage.getItem("answer");

var choiceList = document.querySelector("ul");
var choices = choiceList.children;

if (document.querySelector(".selected").classList.contains("correct")) {
  document.querySelector(".selected").style.borderColor = "#11815a";

  document.querySelector(".selected").onclick = rateGood;

  var list = document.getElementsByClassName("incorrect");

  for (var i = 0; i < list.length; i++) {
    list[i].style.color = "#969baa";
    list[i].style.borderColor = "#eceef2";
    list[i].style.cursor = "default";
  }
} else {
  var list = document.getElementsByClassName("incorrect");

  for (var i = 0; i < list.length; i++) {
    if (list[i].classList.contains("selected")) {
      list[i].style.cursor = "default";
      list[i].style.borderColor = "#ffc38c";
    } else {
      list[i].style.cursor = "default";
      list[i].style.color = "#969baa";
      list[i].style.borderColor = "#eceef2";
    }
  }

  document.querySelector(".correct").style.borderColor = "#11815a";
  document.querySelector(".correct").style.borderStyle = "dashed";
  document.querySelector(".correct").onclick = rateAgain;
}

function rateAgain() {
  pycmd("ease1");
}

function rateGood() {
  pycmd("ease3");
}
</script>

Styling

.card {
  font-family: avenir;
  font-size: 35px;
  text-align: left;
  color: black;
  background-color: #f6f7fb;
}

.contentbox {
  background-color: #f7f7f7;

  border-radius: 10px;
  box-shadow: 0 0 15px 10px #eff0f4;

  padding: 0 40px 40px 40px;

  max-width: 1020px;
  margin: auto;
}

.info {
  min-height: 100px;
  display: flex;
  flex: 1;
  align-items: center;

  font-size: 15px;
  font-weight: bold;
  color: #929bb4;
}

.tags {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

span[class*="MKG"]::after {
  content: "Modern Korean Grammar";
}

.question {
}

.front .selectedCloze {
  display: contents;
  font-size: 0px;
}

.front .selectedCloze::after {
  font-size: 35px;
  font-weight: bold;
  content: "[…]";
  color: blue;
}

.selectedCloze {
  display: contents;
  color: blue;
  font-weight: bold;
}

.cloze {
  display: contents;
}

.translation {
  min-height: 60px;
  font-size: 25px;
  display: flex;
  align-items: center;
  white-space: pre;
}

.examples {
  margin: 10px 0 0 0;
  min-height: 80px;
  font-size: 22px;
  display: flex;
  align-items: top;
}

.choices {
  display: flex;
}

ul {
  padding: 0;
  list-style: none;
  display: grid;
  gap: 30px;
  width: 100%;
  grid-template-columns: repeat(2, 1fr);
  margin: 40px 0px;
}

li {
  font-size: 25px;
  margin-bottom: 10px;
  border: 1.5px solid #edeff3;
  border-radius: 10px;
  padding-left: 20px;
  padding-right: 20px;
  padding-top: 10px;
  padding-bottom: 10px;
  cursor: pointer;
  user-select: none;
  text-transform: lowercase;
}

li:hover {
  border-color: #929bb4;
}

@media screen and (max-width: 710px) {
  .info {
    min-height: 80px;
  }

  .mySentence {
    font-size: 25px;
  }

  .front .selectedCloze::after {
    font-size: 25px;
  }

  .contentbox {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;

    border-radius: 0px;
    margin: 0;

    padding: 0 20px 40px 20px;
  }

  .examples {
    min-height: 80px;
    font-size: 18px;
  }

  .translation {
    font-size: 18px;
  }

  ul {
    display: flex;
    flex-direction: column;
    gap: 10px;

    margin-top: 0;
  }

  li {
    font-size: 18px;
  }
}

Finally, to enable multiple choice tests, see the next section.

How to load random answer choices

This template selects answer choices from a .js file stored in Anki’s collection.media folder. The file should contain one JavaScript array (var choices), where each value is a separate word. In order to convert your sentences into choices and create this file, you can use a text editor to create the array yourself, or you can use the following steps:

  1. Go to jsfiddle.net.
  2. In the HTML field, paste the following code, with your sentences inside the indicated tags:
<p>Your sentences here</p>

<div id="myText"></div>
  1. In the JavaScript field, paste the following code:
  const sentence = document.querySelector("p").innerText;

  const segmenter = new Intl.Segmenter("ko", {
    granularity: "word"
  });

  const segments = segmenter.segment(sentence);
  const iterator1 = segmenter.segment(sentence)[Symbol.iterator]();

  var text = "var choices = [";

  for (const i of segments) {
    var x = iterator1.next();

    if (x.value.isWordLike == true) {
      text += '"' + x.value.segment + '", ';
    }
  }
  
  document.querySelector("p").innerText = "";
  document.getElementById("myText").innerText = text + ' ""];';
  1. You will need to change the language code in line 3 ("ko") so that it matches your chosen language. A list of language codes is here.
  2. Run the code. The above code adds an empty value (, "") at the end of the array (because I was too lazy to figure out how to avoid it), which you can simply delete.
  3. Copy and paste the output into a text file, then save the file. The file name should start with an underscore and end with .js, for example: _FDKsentences.js.
  4. Paste the file into Anki’s collection.media folder.
  5. On the front template, search for myScript.src, and add the name of your file:
myScript.src = "_FDKsentences.js";

The choices should load automatically. Problems may arise if your files are too small or too big, but I can’t really say where that line is. Personally, my files get pretty big (over 40,000 possible choices), but I haven’t noticed any lag on my Mac or iPhone. On the other hand, this template excludes choices below the average word length of a given sentence, as well as duplicate choices, so I can imagine a scenario where a small answers file plus a sentence with a high average word length could fail to load all choices.

  1. Depending on the language, a capitalized word among the choices can give away the answer, so this code converts all choices to lowercase. If you don’t want this to happen, search for and remove text-transform: lowercase; in the styling section:
li {
  ...
  text-transform: lowercase;
}

Click to flip

Clicking on an answer flips a card from front to back. For this to work on mobile, you will need to make the following change: Preferences > Review > Taps > When Question Shown: Top Center > Show Answer. (To use a different setting, see this code.) My understanding is that the way this works has changed and broken before, so be warned that it may break again.

On desktop, if you get a question wrong, you can mouse over to the correct answer and click it to automatically rate the card as Again.

Deck Options

I recommend using only one learning step (like 5m) instead of two (the default 1m 10m), which will be similar to the way Clozemaster works.

And not that it really matters, but for those curious, I use FSRS with Review sort order set to Ascending intervals.

Optional settings

Optional: Card headers

You can use tags to display a little information about the sentence, such as its source, at the top of the card. To do this, go to the styling section of the card template, and search for and alter the following example. In the example given, cards tagged with MKG will display Modern Korean Grammar at the top of the card:

span[class*="MKG"]::after {
  content: "Modern Korean Grammar";
}

Optional: TTS

Personally, I put my TTS field inside <div class="info">, before the card title, but only on the back side. I talk about this more in the “Improvements and modifications” section, but unfortunately, Anki desktop and mobile cannot dynamically load TTS, so you can only play the full sentence without omitting the cloze deletion.

  <div class="info">
    {{tts ko_KR speed=1 voices=Apple_Yuna_(Enhanced),Apple_Yuna:Korean TTS}}
    <span class="tags {{Tags}}"></span>
  </div>

Type or paste {{tts-voices:}} on the card template to see a list of available voices.

I use two custom SVGs along with the code below to alter the appearance of the audio button. Here’s a simpler method from the manual.

.playImage {
  visibility: hidden;
}

.replay-button {
  background-image: url("_audio_icon.svg");
  background-size: contain;

  width: 40px;
  height: 40px;

  margin-right: 20px;
}

.replay-button:hover {
  background-image: url("_audio_icon_hover.svg");
  background-size: contain;
  width: 40px;
  height: 40px;
}

Because there’s no audio on the front, I probably should have added some CSS to position the audio button on the top right instead of the top left, so that the card title doesn’t move back and forth, but at this point I don’t care enough to change it.

Optional: Load answer list by tag

Sometimes, you may want different answer choices to appear for different cards. For example, I like to separate choices by the book the sentences come from. Here’s how to do that.

  1. Tag all the cards for a given answer set with a corresponding tag. For example, my cards from Modern Korean Grammar are tagged with MKG; those from Frequency Dictionary of Korean are tagged with FDK. You will also want to put the choices corresponding to each source in a separate file.
  2. On the front template, add the following code in the section /* custom variables */. Then simply modify the if statements so that the correct file loads.
var answerList = "{{Tags}}";

if (answerList.includes("yourTagHere")) {
  myScript.src = "_yourFileHere.js";
}

if (answerList.includes("MKG")) {
  myScript.src = "_MKGsentences.js";
}

if (answerList.includes("FDK")) {
  myScript.src = "_FDKsentences.js";
}

If you use hierarchical tags (i.e. MKG::Part_A, MKG::Part_B), you can do something like:

var answerList = "{{Tags}}";

if (answerList.includes("MKG")) {
  if (answerList.includes("Part_A")) {
    myScript.src = "_MKGpta.js";
  }

  if (answerList.includes("Part_B")) {
    myScript.src = "_MKGptb.js";
  }
}

If the name of one of your tags includes the name of another of your tags (say MKG and KG) then you will need to use an else if statement for the latter tag.

Optional: Click-to-translate

Like Clozemaster, this template allows you to look up words by clicking on them. However, it’s complicated to set up, and I wouldn’t recommend going through this process in the first place unless you find yourself really needing it. You may want to explore other options first, like this Mac OSX Dictionary Lookup add-on.

For this to work, you will need to get an API key from Google Cloud, which is a bit of a hassle. You will need to put in payment information, but translations are free for up to 500,000 characters a month (but please check the pricing for yourself, in case it’s changed).

Here is the official Google setup page. (Note that this template uses the basic edition of Cloud Translation.) Also, here is a Medium post which has screenshots that might be helpful in enabling the API key (only step one of that post is relevant here). Be warned: as someone who is not a programmer, I found this process fairly confusing and difficult to understand. Also, the Google translations aren’t always accurate. Still, it can be helpful in some cases.

If you would like to enable this option, get an API key from Google, then paste the following code onto the front and back templates, respectively. Ultimately, you need to replace API_KEY in the code below with your API key. Also note that you will need to change the language codes in source=ko&target=en (in the same line) to correspond with the languages you are studying.

Front template:

<script>
function getText() {}
</script>

Back template:

<script>
function getText() {
  document.getElementById("translation").innerText = "";
  document.getElementById("sourcetext").innerText = "";

  var sourceText = event.target.innerText;
  document.getElementById("sourcetext").innerText = sourceText + ": ";

  translateText(sourceText, displayText);
}

function translateText(sourceText, callback) {
  url = 'https://translation.googleapis.com/language/translate/v2?key=API_KEY&source=ko&target=en&callback=displayText&q=' + sourceText;

  var script = document.createElement('script');
  script.type = 'text/javascript';
  script.src = url;
  document.getElementsByTagName('head')[0].appendChild(script);
}

function displayText(response) {
  document.getElementById("translation").innerHTML = response.data.translations[0].translatedText;
}

function clearText() {
  if (!event.target.className.includes("targetwords")) {
    document.getElementById("translation").innerText = "";
    document.getElementById("sourcetext").innerText = "";
  }
}

document.addEventListener("click", clearText);
</script>

Like Clozemaster, this will disable translations on the front side. But you can use the back template for the front and the back if you want, and it should work.

Where to get sentences (if you don’t already have them)

This Anki add-on (which appears to not be fully operational at the moment) gets sentences from Tatoeba based on your existing vocabulary words. You can also download sentence lists directly from Tatoeba.

You can also get a lot of good quality sentences with translations from a phrasebook, a frequency or essential vocabulary dictionary, or a grammar book. (The Tuttle Essentials Series and Routledge Frequency Dictionary, Modern Grammar, and Comprehensive Grammar Series offer a lot of languages.) You can copy the text using something like Calibre, paste it into a text file, and then use find/replace with patterns in the text to separate the different fields.

However, be aware that if you buy a dictionary or phrasebook through Amazon, you will have to know how to remove DRM in order to strip the sentences freely. Some older foreign language resources have screenshots in place of foreign scripts, and others have improperly formatted text that is impossible to copy even after removing DRM (looking at you, Routledge Frequency Dictionary of Arabic), so beware of that as well.

I also in the past few days found this add-on which uses AI to generate example sentences for you. In the past, I have used Language Tools to bulk translate example sentences for which I had no translations (the generated translations were… ok). I’m sure there are many other add-ons I don’t even know about that could help in this regard. You could potentially take a book, a script, or an article, chop it up into sentences, and then use those as a source.

Improvements and modifications

Typed answers

As you will know if you’ve used Clozemaster, in addition to multiple choice, there is a type answer option. While I have not integrated this option into the current template (and have no intention to do so), I’d like to point anyone interested in typed answers to this beautiful bit of JavaScript, which gives instant type feedback—red for wrong, yellow for partially correct, and green for correct. Obviously, you’d need to do some work in order to integrate it with this template, but when I was experimenting with traditional cloze cards a long time ago, I found it immensely helpful.

TTS / AI assistance

For me, there are two missing pieces. The first is that I wish there was a way to pass the generated cloze test to Anki TTS without Anki pronouncing the cloze deletion, but there’s currently no way to do this (unless you use AnkiDroid). I made a post about a similar issue here, and someone else very recently made a post asking about this here.

The second missing piece would be something like passing an incorrect answer to AI, which would return whether that sentence is nonetheless grammatically correct or not. But, I don’t know how to do this. I’ve seen that Clozemaster now gives you an AI explanation of the sentence, which would also be nice.

Potential bugs

The fact that this template doesn’t check whether all choices have been filled is also something that I should probably fix, but it hasn’t been a problem for me even once. I guess you could add that check, remove the character limit if it fails, and then add some kind of placeholder if they still don’t fill (but this probably just means your answers file is too small). If your tags match some of the classes in the styling template (cloze, examples), it may cause problems, so don’t do that.

Final thoughts

I am always experimenting with my templates, so there are some loose ends here and there, although I’ve tried to clean them up. Again, I am not a programmer, so I cannot help you if you have problems, because I’ve written this almost entirely by trial and error. I call this “experimental” in the title, but in reality I’ve done just about all I care to do—I just wanted to be clear about the somewhat incomplete and unstable nature of this project.

Overall, I’ve had some fun with this, even though I’m not sure how useful it is. But maybe some of you will enjoy it, or find parts of it useful. Please feel free to do anything you like with the code. If you really wanted to, I think it would be possible to combine this with Anki’s built-in cloze deletions, and then you would essentially end up with a Clozemaster clone.

I am just seeing now that this Memrise-style template has a cloze option, but I do not know how it works or how it compares to this one. If you are interested in more cloze tests, it might be worth checking out.

The answer confirmation you can see in the demo video is thanks to the color confirmation add-on, available here.

I’ve just realized I’m actually not on the current version of Anki desktop, and I don’t want to update because I’m terrified it’ll break this, and I’ve already spent all night finishing up this post. So let’s hope not!

4 Likes