/****************************************************************************************
* Copyright (c) 2015 Houssam Salem <houssam.salem.au@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.libanki.template;
import android.text.TextUtils;
import com.ichi2.libanki.Utils;
import com.ichi2.libanki.hooks.Hooks;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* This class renders the card content by parsing the card template and replacing all marked sections
* and tags with their respective data. The data is derived from a context object that is given to
* the class when constructed which maps tags to the data that they should be replaced with.
* <p/>
* The AnkiDroid version of this class makes some assumptions about the valid data types that flow
* through it and is thus simplified. Namely, the context is assumed to always be a Map<String, String>,
* and sections are only ever considered to be String objects. Tests have shown that strings are the
* only data type used, and thus code that handles anything else has been omitted.
*/
public class Template {
public static final String clozeReg = "(?s)\\{\\{c%s::(.*?)(::(.*?))?\\}\\}";
private static final Pattern fHookFieldMod = Pattern.compile("^(.*?)(?:\\((.*)\\))?$");
private static final Pattern fClozeSection = Pattern.compile("c[qa]:(\\d+):(.+)");
// The regular expression used to find a #section
private Pattern sSection_re = null;
// The regular expression used to find a tag.
private Pattern sTag_re = null;
// Opening tag delimiter
private String sOtag = "{{";
// Closing tag delimiter
private String sCtag = "}}";
private String mTemplate;
private Map<String, String> mContext;
private static String get_or_attr(Map<String, String> obj, String name) {
return get_or_attr(obj, name, null);
}
private static String get_or_attr(Map<String, String> obj, String name, String _default) {
if (obj.containsKey(name)) {
return obj.get(name);
} else {
return _default;
}
}
public Template(String template, Map<String, String> context) {
mTemplate = template;
mContext = context == null ? new HashMap<String, String>() : context;
compile_regexps();
}
/**
* Turns a Mustache template into something wonderful.
*/
public String render() {
String template = render_sections(mTemplate, mContext);
return render_tags(template, mContext);
}
/**
* Compiles our section and tag regular expressions.
*/
private void compile_regexps() {
String otag = Pattern.quote(sOtag);
String ctag = Pattern.quote(sCtag);
String section = String.format(Locale.US,
"%s[\\#|^]([^\\}]*)%s(.+?)%s/\\1%s", otag, ctag, otag, ctag);
sSection_re = Pattern.compile(section, Pattern.MULTILINE | Pattern.DOTALL);
String tag = String.format(Locale.US, "%s(#|=|&|!|>|\\{)?(.+?)\\1?%s+", otag, ctag);
sTag_re = Pattern.compile(tag);
}
/**
* Expands sections.
*/
private String render_sections(String template, Map<String, String> context) {
while (true) {
Matcher match = sSection_re.matcher(template);
if (!match.find()) {
break;
}
String section = match.group(0);
String section_name = match.group(1);
String inner = match.group(2);
section_name = section_name.trim();
String it;
// check for cloze
Matcher m = fClozeSection.matcher(section_name);
if (m.find()) {
// get full field text
String txt = get_or_attr(context, m.group(2), null);
Matcher mm = Pattern.compile(String.format(clozeReg, m.group(1))).matcher(txt);
if (mm.find()) {
it = mm.group(1);
} else {
it = null;
}
} else {
it = get_or_attr(context, section_name, null);
}
String replacer = "";
if (!TextUtils.isEmpty(it)) {
it = Utils.stripHTMLMedia(it).trim();
}
if (!TextUtils.isEmpty(it)) {
if (section.charAt(2) != '^') {
replacer = inner;
}
} else if (TextUtils.isEmpty(it) && section.charAt(2) == '^') {
replacer = inner;
}
template = template.replace(section, replacer);
}
return template;
}
/**
* Renders all the tags in a template for a context.
*/
private String render_tags(String template, Map<String, String> context) {
while (true) {
Matcher match = sTag_re.matcher(template);
if (!match.find()) {
break;
}
String tag = match.group(0);
String tag_type = match.group(1);
String tag_name = match.group(2).trim();
String replacement;
if (tag_type == null) {
replacement = render_unescaped(tag_name, context);
} else if (tag_type.equals("{")) {
replacement = render_tag(tag_name, context);
} else if (tag_type.equals("!")) {
replacement = render_comment();
} else if (tag_type.equals("=")) {
replacement = render_delimiter(tag_name);
} else {
return "{{invalid template}}";
}
template = template.replace(tag, replacement);
}
return template;
}
/**
* {{{ functions just like {{ in anki
*/
private String render_tag(String tag_name, Map<String, String> context) {
return render_unescaped(tag_name, context);
}
/**
* Rendering a comment always returns nothing.
*/
private String render_comment() {
return "";
}
private String render_unescaped(String tag_name, Map<String, String> context) {
String txt = get_or_attr(context, tag_name);
if (txt != null) {
// some field names could have colons in them
// avoid interpreting these as field modifiers
// better would probably be to put some restrictions on field names
return txt;
}
// field modifiers
List<String> parts = Arrays.asList(tag_name.split(":"));
String extra = null;
List<String> mods;
String tag;
if (parts.size() == 1 || parts.get(0).equals("")) {
return String.format("{unknown field %s}", tag_name);
} else {
mods = parts.subList(0, parts.size() - 1);
tag = parts.get(parts.size() - 1);
}
txt = get_or_attr(context, tag);
// Since 'text:' and other mods can affect html on which Anki relies to
// process clozes, we need to make sure clozes are always
// treated after all the other mods, regardless of how they're specified
// in the template, so that {{cloze:text: == {{text:cloze:
// For type:, we return directly since no other mod than cloze (or other
// pre-defined mods) can be present and those are treated separately
Collections.reverse(mods);
Collections.sort(mods, new Comparator<String>() {
// This comparator ensures "type:" mods are ordered first in the list. The rest of
// the list remains in the same order.
@Override
public int compare(String lhs, String rhs) {
if (lhs.equals("type")) {
return 0;
} else {
return 1;
}
}
});
for (String mod : mods) {
//Timber.d("Models.get():: Processing field: modifier=%s, extra=%s, tag=%s, txt=%s", mod, extra, tag, txt);
// built-in modifiers
if (mod.equals("text")) {
// strip html
if (!TextUtils.isEmpty(txt)) {
txt = Utils.stripHTML(txt);
} else {
txt = "";
}
} else if (mod.equals("type")) {
// type answer field; convert it to [[type:...]] for the gui code
// to process
return String.format(Locale.US, "[[%s]]", tag_name);
} else if (mod.startsWith("cq-") || mod.startsWith("ca-")) {
// cloze deletion
String[] split = mod.split("-");
mod = split[0];
extra = split[1];
if (!TextUtils.isEmpty(txt) && !TextUtils.isEmpty(extra)) {
txt = clozeText(txt, extra, mod.charAt(1));
} else {
txt = "";
}
} else {
// hook-based field modifier
Matcher m = fHookFieldMod.matcher(mod);
if (m.matches()) {
mod = m.group(1);
extra = m.group(2);
}
txt = (String) Hooks.runFilter("fmod_" + mod,
txt == null ? "" : txt,
extra == null ? "" : extra,
context, tag, tag_name);
if (txt == null) {
return String.format("{unknown field %s}", tag_name);
}
}
}
return txt;
}
private static String clozeText(String txt, String ord, char type) {
Matcher m = Pattern.compile(String.format(Locale.US, clozeReg, ord)).matcher(txt);
if (!m.find()) {
return "";
}
m.reset();
StringBuffer repl = new StringBuffer();
while (m.find()) {
// replace chosen cloze with type
if (type == 'q') {
if (!TextUtils.isEmpty(m.group(3))) {
m.appendReplacement(repl, "<span class=cloze>[$3]</span>");
} else {
m.appendReplacement(repl, "<span class=cloze>[...]</span>");
}
} else {
m.appendReplacement(repl, "<span class=cloze>$1</span>");
}
}
txt = m.appendTail(repl).toString();
// and display other clozes normally
return txt.replaceAll(String.format(Locale.US, clozeReg, "\\d+"), "$1");
}
/**
* Changes the Mustache delimiter.
*/
private String render_delimiter(String tag_name) {
try {
String[] split = tag_name.split(" ");
sOtag = split[0];
sCtag = split[1];
} catch (IndexOutOfBoundsException e) {
// invalid
return null;
}
compile_regexps();
return "";
}
}