matthayes / anki_cloze_anything

Add cloze deletions to any existing Anki notes without any modification to Anki
https://ankiweb.net/shared/info/330680661
Apache License 2.0
52 stars 10 forks source link

Is it possible to reveal one by one? #6

Open thiswillbeyourgithub opened 4 years ago

thiswillbeyourgithub commented 4 years ago

Hi,

I was desperate to find a way to make cloze deletions that coul be revealed one by one, so I found a script and worked a bit on it in my repo. So now I have something that suits my need quite well.

But I just found out about your ambitious project that is far more mature. Your knowledge of js is incomparably better than mine. I played around with it but I can't figure out if there is a way to reveal the cloze one by one. Is it possible? An example of such behavior can be seen in the demo_gif of my repo.

I didn't post this to promote my thing or anything but you might be interested in taking a look anyway.

Have a nice day! And congrats on this addon.

matthayes commented 4 years ago

Hey thanks for the suggestion! This is a good idea. I think this should be pretty straightforward to add. I will take a look at the code example you linked. Since this is JavaScript it should be pretty doable.

thiswillbeyourgithub commented 4 years ago

Glad it can be useful :) Don't hesitate to ask me if you need anything.

I linked to your addon and to this thread in my repo as your addon will probably interest people that stumble upon my repo.

matthayes commented 4 years ago

I have something basically working if you'd like to test out a preview. Let me know what you think! I'll probably get this committed to master in the coming days. But if you have feedback I could make some adjustments. I wasn't able to check out your code because it is GPL and I've made this code all Apache 2 licensed. I'll need to update the plugin eventually too because right now it will give you notifications if you don't have fields for ExpressionCloze1, etc. These shouldn't be required for a pure click-to-reveal-type card like this one as you only have one card.

To use this, your card type must end in Reveal. So for example, use ExpressionClozeReveal.

Add this button somewhere in your card. This is what will cycle through the clozes on the question side.

<div id="next-cloze" style="display:none">
<button>Next</button>
</div>

Use the following script instead. Basically what this does is hook into that button and cycle through each of the cloze cards on the question side. For a "reveal" card I've updated the default setting for showAfter so that it is none, which means that you'll see all the clozes before but none after.

<script>
/*
Copyright 2019-2020 Matthew Hayes

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

var defaults = {
  showBefore: "all",
  showAfter: "all",
  replaceChar: ".",
  replaceSameLength: "false",
  alwaysShowBlanks: "false",
  blanksFormat: "[{blanks}]",
  hintFormat: "[{hint}]",
  blanksAndHintFormat: "[{blanks}|{hint}]"
}

var expEl = document.getElementById("cloze");
var card = expEl.getAttribute("data-card");

var cardMatch = card.match(/[^\d]+(\d+)$/);
var isBack = !!document.getElementById("back");

var currentClozeNum = null;
var isReveal = false;

if (cardMatch) {
  currentClozeNum = parseInt(cardMatch[1]);
}
else if (card.match(/Reveal$/)) {
  isReveal = true;

  // When using the reveal feature, instead of having each cloze be a separate card, you reveal
  // the clozes one-by-one on the front.  Typically by default you want to show everything before
  // the current cloze and nothing after.
  defaults.showAfter = "none";
}

var expContent = expEl.innerHTML;

// Controls whether we show other clozes before/after the current cloze.
// Valid values are: all, none, or a positive number
var showBeforeValue = expEl.getAttribute("data-cloze-show-before") || defaults.showBefore;
var showAfterValue = expEl.getAttribute("data-cloze-show-after") || defaults.showAfter;

// Character used to create blanks that replace content.
var replaceChar = expEl.getAttribute("data-cloze-replace-char") || defaults.replaceChar;

// Whether to replace content with blanks having the same number of characters, or a fixed number
// of characters in order to obfuscate the true length.
var replaceSameLength = (expEl.getAttribute("data-cloze-replace-same-length") || defaults.replaceSameLength) == "true";

// Whether to always show the blanks.  If true, blanks are always shown, even if there is a hint.
// If false, then blanks are not shown if there is a hint and there are no characters being kept.
var alwaysShowBlanks = (expEl.getAttribute("data-cloze-always-show-blanks") || defaults.alwaysShowBlanks) == "true";

// Format of cloze when there is no hint.
var blanksFormat = expEl.getAttribute("data-cloze-blanks-format") || defaults.blanksFormat;

// Format of cloze when there is a hint and we aren't showing blanks.
var hintFormat = expEl.getAttribute("data-cloze-hint-format") || defaults.hintFormat;

// Format of the cloze when we are showing the blanks and a hint.
var blanksAndHintFormat = expEl.getAttribute("data-cloze-blanks-and-hint-format") || defaults.blanksAndHintFormat;

// Identify characters in content that will not be replaced with blanks.
var charKeepRegex = /(`.+?`)/
var charKeepGlobalRegex = /(`.+?`)/g

// Regex used to split on spaces so spaces can be preserved.
var spaceSplit = /(\s+)/;

// Matches diacritics so we can remove them for length computation purposes.
var combiningDiacriticMarks = /[\u0300-\u036f]/g;

// Wraps the content in a span with given classes so we can apply CSS to it.
function wrap_span(content, classes) {
  return "<span class=\"" + classes + "\">" + content + "</span>";
}

// Replaces content with the replacement character so that result is the same length.
// Spaces are preserved.
function replace_chars_with_blanks(content) {
  // Check if content seems to have HTML.  If so, we'll need to extract text content.
  if (content.indexOf("<") >= 0) {
    content = (new DOMParser).parseFromString(content, "text/html").documentElement.textContent;
  }

  // Decompose so we can remove diacritics to compute an accurate length.  Otherwise
  // diacritics may contibute towards the length, which we don't want.
  content = content.normalize("NFD").replace(combiningDiacriticMarks, "");
  var split = content.split(spaceSplit);
  var parts = []
  split.forEach(function(p, i) {
    if (i % 2 == 0) {
      // Replace the non-space characters.
      parts.push(replaceChar.repeat(p.length));
    }
    else {
      // Spaces are returned as is.
      parts.push(p);
    }
  });
  return parts.join("");
}

// Returns the blanks for the given content that serve as a placeholder for it in the cloze.
// Depending on configuration, this could be:
// 1) Blanks with the same number of characters as the content
// 2) Blanks with a fixed number of 3 characters
// 3) A combination of blanks and characters from content that are kept.
function format_blanks(content) {
  var split = content.split(charKeepRegex);
  if (split.length == 1) {
    if (replaceSameLength) {
      return replace_chars_with_blanks(content);
    }
    else {
      return replaceChar.repeat(3)
    }
  }
  else {
    var parts = [];
    split.forEach(function(p, i) {
      if (i % 2 == 0) {
        if (p.length > 0) {
          if (replaceSameLength) {
            parts.push(replace_chars_with_blanks(p));
          }
          else {
            parts.push(replaceChar.repeat(2))
          }
        }
      }
      else {
        // trim the surrounding characters to get the inner content to keep
        parts.push(p.slice(1, p.length - 1));
      }
    });
    return parts.join("")
  }
}

// Performs string replacement of tokens in a {token} format.  For example, {hint} in the format
// will be replaced with the value of the hint key in the dictionary.
function string_format(format, d) {
  return format.replace(/\{([a-z]+)\}/g, function(match, key) {
    return d[key];
  });
}

function strip_keep_chars(content) {
  return content.replace(charKeepGlobalRegex, function(p) {
    return p.slice(1, p.length - 1);
  });
}

// Generates the replacement for the given content and hint.  The result is wrapped in a span
// with the given classes.
function replace_content(content, hint, classes) {
  var contentReplacement = null;
  var showBlanks = alwaysShowBlanks || content.match(charKeepRegex)
  if (showBlanks && hint) {
    contentReplacement = string_format(blanksAndHintFormat, {
      blanks: format_blanks(content),
      hint: hint
    })
  }
  else if (hint) {
    contentReplacement = string_format(hintFormat, {
      hint: hint
    })
  }
  else {
    contentReplacement = string_format(blanksFormat, {
      blanks: format_blanks(content)
    })
  }
  return wrap_span(contentReplacement, classes);
}

// Finds the largest cloze number.
function find_max_cloze(content) {
  var r = /\(\(c(\d+)::(.+?\)*)\)\)/g;
  var m;
  var result = [];
  while (m = r.exec(content)) {
    result.push(parseInt(m[1]));
  }
  var maxCloze = Math.max.apply(null, result);
  return maxCloze;
}

function render_cloze(content, cloze_num_to_render, show_clozes) {
  return content.replace(/\(\(c(\d+)::(.+?\)*)\)\)/g,function(match, clozeNum, content) {
    var contentSplit = content.split(/::/)
    var contentHint = null;
    clozeNum = parseInt(clozeNum);
    if (contentSplit.length == 2) {
      contentHint = contentSplit[1];
      content = contentSplit[0]
    }
    var result = null;
    if (!show_clozes) {
      // For the back card we need to strip out the surrounding characters used to mark those
      // we are keeping.
      result = strip_keep_chars(content);
    }
    else {
      if (clozeNum == cloze_num_to_render) {
        result = replace_content(content, contentHint, "current-cloze");
      }
      else if (clozeNum < cloze_num_to_render) {
        if (showBeforeValue == "all") {
          result = strip_keep_chars(content);
        }
        else if (showBeforeValue.match(/^\d+$/)) {
          var showBeforeNum = parseInt(showBeforeValue);
          if (cloze_num_to_render - clozeNum <= showBeforeNum) {
            result = strip_keep_chars(content);
          }
          else {
            result = replace_content(content, contentHint, "other-cloze");
          }
        }
        else {
          result = replace_content(content, contentHint, "other-cloze");
        }
      }
      else if (clozeNum > cloze_num_to_render) {
        if (showAfterValue == "all") {
          result = strip_keep_chars(content);
        }
        else if (showAfterValue.match(/^\d+$/)) {
          var showAfterNum = parseInt(showAfterValue);
          if (clozeNum - cloze_num_to_render <= showAfterNum) {
            result = strip_keep_chars(content);
          }
          else {
            result = replace_content(content, contentHint, "other-cloze");
          }
        }
        else {
          result = replace_content(content, contentHint, "other-cloze");
        }
      }
      else {
        result = strip_keep_chars(content);
      }
    }

    return result;
  });
}

if (currentClozeNum) {
  var showClozes = !isBack;
  expEl.innerHTML = render_cloze(expContent, currentClozeNum, showClozes)
}
else if (isReveal) {
  var showClozes = !isBack;
  currentClozeNum = 1;
  if (isBack) {
    expEl.innerHTML = render_cloze(expContent, currentClozeNum, showClozes)
  }
  else {
    expEl.innerHTML = render_cloze(expContent, currentClozeNum, showClozes)
  }

  if (showClozes) {
    var maxClozeNum = find_max_cloze(expContent);
    var nextClozeButton = document.getElementById("next-cloze");
    nextClozeButton.style.display = "block";
    console.log(nextClozeButton.style.display)

    nextClozeButton.addEventListener("click", function(event) {
      currentClozeNum += 1;

      console.log(currentClozeNum);

      if (currentClozeNum > maxClozeNum + 1) {
        currentClozeNum = 1;
      }

      expEl.innerHTML = render_cloze(expContent, currentClozeNum, showClozes);
    });
  }
}
</script>
thiswillbeyourgithub commented 4 years ago

I'm sorry, I tried a few things but I fail to understand how I should test this.

I have to add the script you quoted into the back of a new cloze type that ends in reveal ?

I tried various other combinations of things to test it but always have this error in the preview template :

Invalid HTML on card: TypeError: Cannot read property 'getAttribute' of null
TypeError: Cannot read property 'getAttribute' of null
at eval (eval at (http://127.0.0.1:45645/_anki/jquery.js:2:2651), :30:18)
at eval ()
at http://127.0.0.1:45645/_anki/jquery.js:2:2651
at Function.globalEval (http://127.0.0.1:45645/_anki/jquery.js:2:2662)
at Ha (http://127.0.0.1:45645/_anki/jquery.js:3:21262)
at n.fn.init.append (http://127.0.0.1:45645/_anki/jquery.js:3:22791)
at n.fn.init. (http://127.0.0.1:45645/_anki/jquery.js:3:24070)
at Y (http://127.0.0.1:45645/_anki/jquery.js:3:4515)
at n.fn.init.html (http://127.0.0.1:45645/_anki/jquery.js:3:23660)
at HTMLDivElement. (http://127.0.0.1:45645/_anki/reviewer.js:33:16)

edit : it's really too bad for the licensing. I am quite ignorant about this so if you want to tell me more I'm all hears :)

matthayes commented 4 years ago

Sorry I realize I should have given you more than the script because I made small changes to the front template, like adding a button. The button reveals the next cloze. When you get to the last one it starts over. I haven't added a keyboard shortcut.

I've created a branch with the changes. It's probably easiest to refer to the updated instructions in that branch:

https://github.com/matthayes/anki_cloze_anything/blob/click_to_reveal/docs/INSTRUCTIONS.md

These instructions include the above script as well as the other modifications needed to front template.

Also if you want to test it out without messing with Anki you can also download the HTML file here and open it in a browser.

https://github.com/matthayes/anki_cloze_anything/blob/click_to_reveal/examples/front.html

matthayes commented 4 years ago

I also have a question about how you typically use a "reveal one by one" style card or cards. With the way I've implemented it, all the c1, c2, etc. clozes are revealed on the question side of the card and there is only one card. First you see the content with everything hidden, then you click and see the c1 content filled in, then you click and see the c2 content filled in, etc.

I realize that an alternative possible implementation is to have a card for c1, a card for c2, etc. and reveal each of the c1 parts one by one on the first card, the c2 parts one by one on the second card, etc. So this would basically be very similar to traditional cloze cards except that if you have multiple parts clozed with c1 on the first card you can reveal the parts one by one. I could see both approaches being useful depending on what you are doing.

thiswillbeyourgithub commented 4 years ago

The version I use (that is mostly in my repo) does the second thing you talk about. The gif example in my repo contains only c1 cards for exampe. This :

I realize that an alternative possible implementation is to have a card for c1, a card for c2, etc. and reveal each of the c1 parts one by one on the first card, the c2 parts one by one on the second card, etc. So this would basically be very similar to traditional cloze cards except that if you have multiple parts clozed with c1 on the first card you can reveal the parts one by one. I could see both approaches being useful depending on what you are doing.

I think this way is better and more flexible : if I forget a subpart I can just turn the 1 into a 2 and it will create a new card with only the missing part.

matthayes commented 4 years ago

I'll have to think about this some more and how to accommodate both approaches in a generic way. The first approach was easy to implement because it is so similar to the way cloze normally works. Instead of having a card for c1, a card for c2, etc. you cycle through them in a single card. There is an explicit ordering based on the number. The downside is that you only have one card. With the second approach you can have multiple cards, where each does a reveal one-by-one. But there is an implied order that you can't control based on the order it appears in the text and you also can't control how much is revealed for each click; each click reveals the next c1 for example.

thiswillbeyourgithub commented 4 years ago

can't control how much is revealed for each click

You can just split cloze where you want the reveal to end, like adding }}{{c1::

I use Symbol as you type addon to do this quickly, it's now very fast and intuitive to me

I think the second approach is easier to grasp for someone who was using regular clozes previously. It also has an enormous benefit : people can convert all their old cloze in a batch way, whereas switching from regular cloze to the first approach requires rethinking how you cloze and reshaping each card IMO

matthayes commented 4 years ago

I think the second approach is easier to grasp for someone who was using regular clozes previously. It also has an enormous benefit : people can convert all their old cloze in a batch way

I think it depends on how you're using the normal cloze. Usually for my cards I don't reuse c1 for example. I have c1 for one or more words, then c2 for one or more words, etc. I could convert these easily to the first approach without having to change the content. With the second approach I would need to change these all to c1.

thiswillbeyourgithub commented 4 years ago

That right and renders my argument moot.

But I still think my way is more flexible as it allows to create multiple cards or just one. Which is very handy when forgetting a subquestion