From 17ebdfc0997ecbe51b134e37064649d5ce10bbec Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 24 Jan 2020 11:06:11 +1000 Subject: [PATCH] extract and flag AV tags as part of the render process We can now show replay buttons for the audio contained in {{FrontSide}} without having to play it again when the answer is shown. The template code now always defers FrontSide rendering, as it wasn't a big saving, and meant the logic had to be implemented twice. --- proto/backend.proto | 16 +++++--- pylib/anki/cards.py | 11 +++++- pylib/anki/collection.py | 44 +++++++++++++-------- pylib/anki/rsbackend.py | 21 +++++----- pylib/anki/sound.py | 3 +- pylib/anki/template.py | 28 +++++++++++--- pylib/tests/test_collection.py | 2 +- qt/aqt/browser.py | 13 +++---- qt/aqt/clayout.py | 11 ++++-- qt/aqt/legacy.py | 7 +--- qt/aqt/main.py | 15 +++++--- qt/aqt/reviewer.py | 41 ++++++-------------- qt/aqt/sound.py | 40 +++++++++++-------- qt/aqt/utils.py | 2 +- rslib/src/backend.rs | 26 +++++++------ rslib/src/template.rs | 70 +++++----------------------------- rslib/src/template_filters.rs | 2 - rslib/src/text.rs | 69 ++++++++++++++++----------------- 18 files changed, 205 insertions(+), 216 deletions(-) diff --git a/proto/backend.proto b/proto/backend.proto index 2e0f76291..2522639eb 100644 --- a/proto/backend.proto +++ b/proto/backend.proto @@ -17,8 +17,7 @@ message BackendInput { RenderCardIn render_card = 21; int64 local_minutes_west = 22; string strip_av_tags = 23; - string get_av_tags = 24; - string flag_av_tags = 25; + ExtractAVTagsIn extract_av_tags = 24; } } @@ -32,8 +31,7 @@ message BackendOutput { RenderCardOut render_card = 21; sint32 local_minutes_west = 22; string strip_av_tags = 23; - GetAVTagsOut get_av_tags = 24; - string flag_av_tags = 25; + ExtractAVTagsOut extract_av_tags = 24; BackendError error = 2047; } @@ -149,8 +147,14 @@ message RenderedTemplateReplacement { repeated string filters = 3; } -message GetAVTagsOut { - repeated AVTag av_tags = 1; +message ExtractAVTagsIn { + string text = 1; + bool question_side = 2; +} + +message ExtractAVTagsOut { + string text = 1; + repeated AVTag av_tags = 2; } message AVTag { diff --git a/pylib/anki/cards.py b/pylib/anki/cards.py index e8a924b53..10c67d9a9 100644 --- a/pylib/anki/cards.py +++ b/pylib/anki/cards.py @@ -5,12 +5,13 @@ from __future__ import annotations import pprint import time -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, List, Optional, Union import anki # pylint: disable=unused-import from anki import hooks from anki.consts import * from anki.notes import Note +from anki.sound import AVTag from anki.utils import intTime, joinFields, timestampID # Cards @@ -26,7 +27,7 @@ from anki.utils import intTime, joinFields, timestampID class Card: - _qa: Optional[Dict[str, Union[str, int]]] + _qa: Optional[Dict[str, Union[str, int, List[AVTag]]]] _note: Optional[Note] timerStarted: Optional[float] lastIvl: int @@ -149,6 +150,12 @@ lapses=?, left=?, odue=?, odid=?, did=? where id = ?""", def a(self) -> str: return self.css() + self._getQA()["a"] + def question_av_tags(self) -> List[AVTag]: + return self._qa["q_av_tags"] # type: ignore + + def answer_av_tags(self) -> List[AVTag]: + return self._qa["a_av_tags"] # type: ignore + def css(self) -> str: return "" % self.model()["css"] diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index 07069ff99..d87ebe697 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -30,8 +30,9 @@ from anki.notes import Note from anki.rsbackend import RustBackend from anki.sched import Scheduler as V1Scheduler from anki.schedv2 import Scheduler as V2Scheduler +from anki.sound import AVTag from anki.tags import TagManager -from anki.template import QAData, TemplateRenderContext, render_card +from anki.template import QAData, RenderOutput, TemplateRenderContext, render_card from anki.utils import ( devMode, fieldChecksum, @@ -633,7 +634,7 @@ where c.nid = n.id and c.id in %s group by nid""" # data is [cid, nid, mid, did, ord, tags, flds, cardFlags] def _renderQA( self, data: QAData, qfmt: Optional[str] = None, afmt: Optional[str] = None - ) -> Dict[str, Union[str, int]]: + ) -> Dict[str, Union[str, int, List[AVTag]]]: # extract info from data split_fields = splitFields(data[6]) card_ord = data[4] @@ -671,31 +672,44 @@ where c.nid = n.id and c.id in %s group by nid""" # render fields. if any custom filters are encountered, # the field_filter hook will be called. try: - qtext, atext = render_card(self, qfmt, afmt, ctx) + output = render_card(self, qfmt, afmt, ctx) except anki.rsbackend.BackendException as e: errmsg = _("Card template has a problem:") + f"
{e}" - qtext = errmsg - atext = errmsg + output = RenderOutput( + question_text=errmsg, + answer_text=errmsg, + question_av_tags=[], + answer_av_tags=[], + ) # avoid showing the user a confusing blank card if they've # forgotten to add a cloze deletion if model["type"] == MODEL_CLOZE: if not self.models._availClozeOrds(model, data[6], False): - qtext = ( - qtext - + "

" - + _("Please edit this note and add some cloze deletions. (%s)") - % ("%s" % (HELP_SITE, _("help"))) - ) + output.question_text += "

" + _( + "Please edit this note and add some cloze deletions. (%s)" + ) % ("%s" % (HELP_SITE, _("help"))) # allow add-ons to modify the generated result - (qtext, atext) = hooks.card_did_render((qtext, atext), ctx) + (output.question_text, output.answer_text) = hooks.card_did_render( + (output.question_text, output.answer_text), ctx + ) # legacy hook - qtext = runFilter("mungeQA", qtext, "q", fields, model, data, self) - atext = runFilter("mungeQA", atext, "a", fields, model, data, self) + output.question_text = runFilter( + "mungeQA", output.question_text, "q", fields, model, data, self + ) + output.answer_text = runFilter( + "mungeQA", output.answer_text, "a", fields, model, data, self + ) - return dict(q=qtext, a=atext, id=card_id) + return dict( + q=output.question_text, + a=output.answer_text, + id=card_id, + q_av_tags=output.question_av_tags, + a_av_tags=output.answer_av_tags, + ) def _qaData(self, where="") -> Any: "Return [cid, nid, mid, did, ord, tags, flds, cardFlags] db query" diff --git a/pylib/anki/rsbackend.py b/pylib/anki/rsbackend.py index 0dc726716..00738588b 100644 --- a/pylib/anki/rsbackend.py +++ b/pylib/anki/rsbackend.py @@ -161,15 +161,16 @@ class RustBackend: def strip_av_tags(self, text: str) -> str: return self._run_command(pb.BackendInput(strip_av_tags=text)).strip_av_tags - def get_av_tags(self, text: str) -> List[AVTag]: - return list( - map( - av_tag_to_native, - self._run_command( - pb.BackendInput(get_av_tags=text) - ).get_av_tags.av_tags, + def extract_av_tags( + self, text: str, question_side: bool + ) -> Tuple[str, List[AVTag]]: + out = self._run_command( + pb.BackendInput( + extract_av_tags=pb.ExtractAVTagsIn( + text=text, question_side=question_side + ) ) - ) + ).extract_av_tags + native_tags = list(map(av_tag_to_native, out.av_tags)) - def flag_av_tags(self, text: str) -> str: - return self._run_command(pb.BackendInput(flag_av_tags=text)).flag_av_tags + return out.text, native_tags diff --git a/pylib/anki/sound.py b/pylib/anki/sound.py index fad5b6789..0d5f6d736 100644 --- a/pylib/anki/sound.py +++ b/pylib/anki/sound.py @@ -4,8 +4,7 @@ """ Sound/TTS references extracted from card text. -Use collection.backend.strip_av_tags(string) to remove all tags, -and collection.backend.get_av_tags(string) to get a list of AVTags. +These can be accessed via eg card.question_av_tags() """ from __future__ import annotations diff --git a/pylib/anki/template.py b/pylib/anki/template.py index 29ad541e2..5604cc87b 100644 --- a/pylib/anki/template.py +++ b/pylib/anki/template.py @@ -29,12 +29,14 @@ template_legacy.py file, using the legacy addHook() system. from __future__ import annotations import re +from dataclasses import dataclass from typing import Any, Dict, List, Optional, Tuple import anki from anki import hooks from anki.models import NoteType from anki.rsbackend import TemplateReplacementList +from anki.sound import AVTag QAData = Tuple[ # Card ID this QA comes from. Corresponds to 'cid' column. @@ -122,18 +124,34 @@ class TemplateRenderContext: return self._note_type +@dataclass +class RenderOutput: + question_text: str + answer_text: str + question_av_tags: List[AVTag] + answer_av_tags: List[AVTag] + + def render_card( col: anki.storage._Collection, qfmt: str, afmt: str, ctx: TemplateRenderContext -) -> Tuple[str, str]: - """Renders the provided templates, returning rendered q & a text. +) -> RenderOutput: + """Renders the provided templates, returning rendered output. Will raise if the template is invalid.""" (qnodes, anodes) = col.backend.render_card(qfmt, afmt, ctx.fields(), ctx.card_ord()) qtext = apply_custom_filters(qnodes, ctx, front_side=None) - atext = apply_custom_filters(anodes, ctx, front_side=qtext) + qtext, q_avtags = col.backend.extract_av_tags(qtext, True) - return qtext, atext + atext = apply_custom_filters(anodes, ctx, front_side=qtext) + atext, a_avtags = col.backend.extract_av_tags(atext, False) + + return RenderOutput( + question_text=qtext, + answer_text=atext, + question_av_tags=q_avtags, + answer_av_tags=a_avtags, + ) def apply_custom_filters( @@ -153,7 +171,7 @@ def apply_custom_filters( else: # do we need to inject in FrontSide? if node.field_name == "FrontSide" and front_side is not None: - node.current_text = ctx.col().backend.strip_av_tags(front_side) + node.current_text = front_side field_text = node.current_text for filter_name in node.filters: diff --git a/pylib/tests/test_collection.py b/pylib/tests/test_collection.py index 43ce5d058..829332a3f 100644 --- a/pylib/tests/test_collection.py +++ b/pylib/tests/test_collection.py @@ -142,7 +142,7 @@ def test_furigana(): # and should avoid sound n["Front"] = "foo[sound:abc.mp3]" n.flush() - assert "sound:" in c.q(reload=True) + assert "anki:play" in c.q(reload=True) # it shouldn't throw an error while people are editing m["tmpls"][0]["qfmt"] = "{{kana:}}" mm.save(m) diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 9b9790a2f..e0920cc70 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -32,7 +32,6 @@ from aqt.utils import ( askUser, getOnlyText, getTag, - mungeQA, openHelp, qtMenuShortcutWorkaround, restoreGeom, @@ -1682,9 +1681,9 @@ where id in %s""" # need to force reload even if answer txt = c.q(reload=True) - questionAudio = [] + question_audio = [] if self._previewBothSides: - questionAudio = self.mw.col.backend.get_av_tags(txt) + question_audio = c.question_av_tags() if self._previewState == "answer": func = "_showAnswer" txt = c.a() @@ -1694,13 +1693,13 @@ where id in %s""" if self.mw.reviewer.autoplay(c): # if we're showing both sides at once, play question audio first - av_player.play_tags(questionAudio) + av_player.play_tags(question_audio) # then play any audio that hasn't already been played - answer_audio = self.mw.col.backend.get_av_tags(txt) - unplayed_audio = [x for x in answer_audio if x not in questionAudio] + answer_audio = c.answer_av_tags() + unplayed_audio = [x for x in answer_audio if x not in question_audio] av_player.extend_and_play(unplayed_audio) - txt = mungeQA(self.col, txt) + txt = self.mw.prepare_card_text_for_display(txt) gui_hooks.card_will_show( txt, c, "preview" + self._previewState.capitalize() ) diff --git a/qt/aqt/clayout.py b/qt/aqt/clayout.py index 5ab4bafe2..c518abe91 100644 --- a/qt/aqt/clayout.py +++ b/qt/aqt/clayout.py @@ -5,8 +5,10 @@ import collections import json import re +from typing import Optional import aqt +from anki.cards import Card from anki.consts import * from anki.lang import _, ngettext from anki.utils import isMac, isWin, joinFields @@ -18,7 +20,6 @@ from aqt.utils import ( askUser, downArrow, getOnlyText, - mungeQA, openHelp, restoreGeom, saveGeom, @@ -29,6 +30,8 @@ from aqt.webview import AnkiWebView class CardLayout(QDialog): + card: Optional[Card] + def __init__(self, mw, note, ord=0, parent=None, addMode=False): QDialog.__init__(self, parent or mw, Qt.Window) mw.setupDialogGC(self) @@ -339,10 +342,10 @@ Please create a new card type first.""" bodyclass = theme_manager.body_classes_for_card_ord(c.ord) - q = ti(mungeQA(self.mw.col, c.q(reload=True))) + q = ti(self.mw.prepare_card_text_for_display(c.q(reload=True))) q = gui_hooks.card_will_show(q, c, "clayoutQuestion") - a = ti(mungeQA(self.mw.col, c.a()), type="a") + a = ti(self.mw.prepare_card_text_for_display(c.a()), type="a") a = gui_hooks.card_will_show(a, c, "clayoutAnswer") # use _showAnswer to avoid the longer delay @@ -350,7 +353,7 @@ Please create a new card type first.""" self.pform.backWeb.eval("_showAnswer(%s, '%s');" % (json.dumps(a), bodyclass)) if c.id not in self.playedAudio: - av_player.play_from_text(self.mw.col, c.q() + c.a()) + av_player.play_tags(c.question_av_tags() + c.answer_av_tags()) self.playedAudio[c.id] = True self.updateCardNames() diff --git a/qt/aqt/legacy.py b/qt/aqt/legacy.py index 50cb959ce..ea5dec1f5 100644 --- a/qt/aqt/legacy.py +++ b/qt/aqt/legacy.py @@ -24,11 +24,8 @@ def bodyClass(col, card) -> str: def allSounds(text) -> List: print("allSounds() deprecated") - return [ - x.filename - for x in aqt.mw.col.backend.get_av_tags(text) - if isinstance(x, SoundOrVideoTag) - ] + text, tags = aqt.mw.col.backend.extract_av_tags(text, True) + return [x.filename for x in tags if isinstance(x, SoundOrVideoTag)] def stripSounds(text) -> str: diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 650ed215d..d8e9ff8cb 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -400,14 +400,17 @@ close the profile or restart Anki.""" def setupSound(self) -> None: aqt.sound.setup_audio(self.taskman, self.pm.base) - def process_av_tags(self, text: str) -> Tuple[str, List[AVTag]]: - "Return card text with play buttons added, and the extracted AV tags." - tags = self.col.backend.get_av_tags(text) + def _add_play_buttons(self, text: str) -> str: + "Return card text with play buttons added, or stripped." if self.pm.profile.get("showPlayButtons", True): - text = aqt.sound.av_flags_to_html(self.col.backend.flag_av_tags(text)) + return aqt.sound.av_refs_to_play_icons(text) else: - text = self.col.backend.strip_av_tags(text) - return (text, tags) + return aqt.sound.strip_av_refs(text) + + def prepare_card_text_for_display(self, text: str) -> str: + text = self.col.media.escapeImages(text) + text = self._add_play_buttons(text) + return text # Collection load/unload ########################################################################## diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index c7c7371c5..163f6dd89 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -13,20 +13,13 @@ from typing import List, Optional from anki import hooks from anki.cards import Card from anki.lang import _, ngettext -from anki.sound import AVTag from anki.utils import stripHTML from aqt import AnkiQt, gui_hooks from aqt.qt import * -from aqt.sound import av_player, getAudio +from aqt.sound import av_player, getAudio, play_clicked_audio from aqt.theme import theme_manager from aqt.toolbar import BottomBar -from aqt.utils import ( - askUserDialog, - downArrow, - mungeQA, - qtMenuShortcutWorkaround, - tooltip, -) +from aqt.utils import askUserDialog, downArrow, qtMenuShortcutWorkaround, tooltip class Reviewer: @@ -40,7 +33,6 @@ class Reviewer: self.hadCardQueue = False self._answeredIds: List[int] = [] self._recordedAudio = None - self._current_side_audio: Optional[List[AVTag]] = None self.typeCorrect = None # web init happens before this is set self.state: Optional[str] = None self.bottom = BottomBar(mw, mw.bottomWeb) @@ -116,21 +108,12 @@ class Reviewer: state = self.state c = self.card if state == "question": - av_player.play_from_text(self.mw.col, c.q()) + av_player.play_tags(c.question_av_tags()) elif state == "answer": - txt = "" + tags = c.answer_av_tags() if self._replayq(c, previewer): - txt = c.q() - txt += c.a() - av_player.play_from_text(self.mw.col, txt) - - def on_play_button(self, idx: int): - try: - tag = self._current_side_audio[idx] - except IndexError: - return - - av_player.play_tags([tag]) + tags = c.question_av_tags() + tags + av_player.play_tags(tags) # Initializing the webview ########################################################################## @@ -176,7 +159,7 @@ class Reviewer: ########################################################################## def _mungeQA(self, buf): - return self.typeAnsFilter(mungeQA(self.mw.col, buf)) + return self.typeAnsFilter(self.mw.prepare_card_text_for_display(buf)) def _showQuestion(self) -> None: self._reps += 1 @@ -192,9 +175,9 @@ The front of this card is empty. Please run Tools>Empty Cards.""" else: q = c.q() - q, self._current_side_audio = self.mw.process_av_tags(q) + # play audio? if self.autoplay(c): - av_player.play_tags(self._current_side_audio) + av_player.play_tags(c.question_av_tags()) # render & update bottom q = self._mungeQA(q) @@ -236,9 +219,9 @@ The front of this card is empty. Please run Tools>Empty Cards.""" c = self.card a = c.a() # play audio? - a, self._current_side_audio = self.mw.process_av_tags(a) if self.autoplay(c): - av_player.play_tags(self._current_side_audio) + av_player.play_tags(c.answer_av_tags()) + a = self._mungeQA(a) a = gui_hooks.card_will_show(a, c, "reviewAnswer") # render and update bottom @@ -333,7 +316,7 @@ The front of this card is empty. Please run Tools>Empty Cards.""" elif url == "more": self.showContextMenu() elif url.startswith("play:"): - self.on_play_button(int(url.split(":")[1])) + play_clicked_audio(url, self.card) else: print("unrecognized anki link:", url) diff --git a/qt/aqt/sound.py b/qt/aqt/sound.py index 9ab2dabc6..0a7d02e11 100644 --- a/qt/aqt/sound.py +++ b/qt/aqt/sound.py @@ -16,8 +16,8 @@ from typing import Any, Callable, Dict, List, Optional, Tuple import pyaudio -import anki import aqt +from anki.cards import Card from anki.lang import _ from anki.sound import AVTag, SoundOrVideoTag from anki.utils import isLin, isMac, isWin @@ -104,14 +104,6 @@ class AVPlayer: self._enqueued.extend(tags) self._play_next_if_idle() - def play_from_text(self, col: anki.storage._Collection, text: str) -> None: - tags = col.backend.get_av_tags(text) - self.play_tags(tags) - - def extend_from_text(self, col: anki.storage._Collection, text: str) -> None: - tags = col.backend.get_av_tags(text) - self.extend_and_play(tags) - def stop_and_clear_queue(self) -> None: self._enqueued = [] self._stop_if_playing() @@ -572,9 +564,7 @@ def play(filename: str) -> None: def playFromText(text) -> None: - from aqt import mw - - av_player.extend_from_text(mw.col, text) + print("playFromText() deprecated") # legacy globals @@ -590,19 +580,39 @@ for (k, v) in _exports: # Tag handling ########################################################################## -AV_FLAG_RE = re.compile(r"\[anki:play\](\d+)\[/anki:play]") +AV_FLAG_RE = re.compile(r"\[anki:(play:.:\d+)\]") -def av_flags_to_html(text: str) -> str: +def strip_av_refs(text: str) -> str: + return AV_FLAG_RE.sub("", text) + + +def av_refs_to_play_icons(text: str) -> str: + """Add play icons into the HTML. + + When clicked, the icon will call eg pycmd('play:q:1'). + """ + def repl(match: re.Match) -> str: return f""" - + """ return AV_FLAG_RE.sub(repl, text) +def play_clicked_audio(pycmd: str, card: Card) -> None: + """eg. if pycmd is 'play:q:0', play the first audio on the question side.""" + play, context, str_idx = pycmd.split(":") + idx = int(str_idx) + if context == "q": + tags = card.question_av_tags() + else: + tags = card.answer_av_tags() + av_player.play_tags([tags[idx]]) + + # Init defaults ########################################################################## diff --git a/qt/aqt/utils.py b/qt/aqt/utils.py index 2691b4b4f..9143636cd 100644 --- a/qt/aqt/utils.py +++ b/qt/aqt/utils.py @@ -480,8 +480,8 @@ def restoreHeader(widget, key): def mungeQA(col, txt): + print("mungeQA() deprecated; use mw.prepare_card_text_for_display()") txt = col.media.escapeImages(txt) - txt = col.backend.strip_av_tags(txt) return txt diff --git a/rslib/src/backend.rs b/rslib/src/backend.rs index 201fbe7ce..2df96bd9f 100644 --- a/rslib/src/backend.rs +++ b/rslib/src/backend.rs @@ -10,7 +10,7 @@ use crate::template::{ render_card, without_legacy_template_directives, FieldMap, FieldRequirements, ParsedTemplate, RenderedNode, }; -use crate::text::{av_tags_in_string, flag_av_tags, strip_av_tags, AVTag}; +use crate::text::{extract_av_tags, strip_av_tags, AVTag}; use prost::Message; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; @@ -100,8 +100,7 @@ impl Backend { OValue::LocalMinutesWest(local_minutes_west_for_stamp(stamp)) } Value::StripAvTags(text) => OValue::StripAvTags(strip_av_tags(&text).into()), - Value::GetAvTags(text) => OValue::GetAvTags(self.get_av_tags(&text)), - Value::FlagAvTags(text) => OValue::FlagAvTags(flag_av_tags(&text).into()), + Value::ExtractAvTags(input) => OValue::ExtractAvTags(self.extract_av_tags(input)), }) } @@ -183,11 +182,13 @@ impl Backend { }) } - fn get_av_tags(&self, text: &str) -> pt::GetAvTagsOut { - let tags = av_tags_in_string(text) + fn extract_av_tags(&self, input: pt::ExtractAvTagsIn) -> pt::ExtractAvTagsOut { + let (text, tags) = extract_av_tags(&input.text, input.question_side); + let pt_tags = tags + .into_iter() .map(|avtag| match avtag { AVTag::SoundOrVideo(file) => pt::AvTag { - value: Some(pt::av_tag::Value::SoundOrVideo(file.to_string())), + value: Some(pt::av_tag::Value::SoundOrVideo(file)), }, AVTag::TextToSpeech { field_text, @@ -196,16 +197,19 @@ impl Backend { other_args, } => pt::AvTag { value: Some(pt::av_tag::Value::Tts(pt::TtsTag { - field_text: field_text.to_string(), - lang: lang.to_string(), - voices: voices.into_iter().map(ToOwned::to_owned).collect(), - other_args: other_args.into_iter().map(ToOwned::to_owned).collect(), + field_text, + lang, + voices, + other_args, })), }, }) .collect(); - pt::GetAvTagsOut { av_tags: tags } + pt::ExtractAvTagsOut { + text: text.into(), + av_tags: pt_tags, + } } } diff --git a/rslib/src/template.rs b/rslib/src/template.rs index 5197fb746..42deccb1e 100644 --- a/rslib/src/template.rs +++ b/rslib/src/template.rs @@ -3,7 +3,6 @@ use crate::err::{Result, TemplateError}; use crate::template_filters::apply_filters; -use crate::text::strip_av_tags; use lazy_static::lazy_static; use nom; use nom::branch::alt; @@ -269,7 +268,6 @@ pub(crate) struct RenderContext<'a> { pub nonempty_fields: &'a HashSet<&'a str>, pub question_side: bool, pub card_ord: u16, - pub front_text: Option>, } impl ParsedTemplate<'_> { @@ -302,19 +300,13 @@ fn render_into( key: key @ "FrontSide", .. } => { - if let Some(front_side) = &context.front_text { - // a fully rendered front side is available, so we can - // bake it into the output - append_str_to_nodes(rendered_nodes, front_side.as_ref()); - } else { - // the front side contains unknown filters, and must - // be completed by the Python code - rendered_nodes.push(RenderedNode::Replacement { - field_name: (*key).to_string(), - filters: vec![], - current_text: "".into(), - }); - } + // defer FrontSide rendering to Python, as extra + // filters may be required + rendered_nodes.push(RenderedNode::Replacement { + field_name: (*key).to_string(), + filters: vec![], + current_text: "".into(), + }); } Replacement { key: "", filters } if !filters.is_empty() => { // if a filter is provided, we accept an empty field name to @@ -435,19 +427,12 @@ pub fn render_card( nonempty_fields: &nonempty_fields(field_map), question_side: true, card_ord, - front_text: None, }; // question side let qnorm = without_legacy_template_directives(qfmt); let qnodes = ParsedTemplate::from_text(qnorm.as_ref())?.render(&context)?; - // if the question side didn't have any unknown filters, we can pass - // FrontSide in now - if let [RenderedNode::Text { ref text }] = *qnodes.as_slice() { - context.front_text = Some(strip_av_tags(text)); - } - // answer side context.question_side = false; let anorm = without_legacy_template_directives(afmt); @@ -517,10 +502,9 @@ mod test { use super::{FieldMap, ParsedNode::*, ParsedTemplate as PT}; use crate::err::TemplateError; use crate::template::{ - field_is_empty, nonempty_fields, render_card, without_legacy_template_directives, - FieldRequirements, RenderContext, RenderedNode, + field_is_empty, nonempty_fields, without_legacy_template_directives, FieldRequirements, + RenderContext, }; - use crate::text::strip_html; use std::collections::{HashMap, HashSet}; use std::iter::FromIterator; @@ -683,7 +667,6 @@ mod test { nonempty_fields: &nonempty_fields(&map), question_side: true, card_ord: 1, - front_text: None, }; use crate::template::RenderedNode as FN; @@ -780,39 +763,4 @@ mod test { }] ); } - - fn get_complete_template(nodes: &Vec) -> Option<&str> { - if let [RenderedNode::Text { ref text }] = nodes.as_slice() { - Some(text.as_str()) - } else { - None - } - } - - #[test] - fn test_render_full() { - // make sure front and back side renders cloze differently - let fmt = "{{cloze:Text}}"; - let clozed_text = "{{c1::one}} {{c2::two::hint}}"; - let map: HashMap<_, _> = vec![("Text", clozed_text)].into_iter().collect(); - - let (qnodes, anodes) = render_card(fmt, fmt, &map, 0).unwrap(); - assert_eq!( - strip_html(get_complete_template(&qnodes).unwrap()), - "[...] two" - ); - assert_eq!( - strip_html(get_complete_template(&anodes).unwrap()), - "one two" - ); - - // FrontSide should render if only standard modifiers were used - let (_qnodes, anodes) = - render_card("{{kana:text:Text}}", "{{FrontSide}}", &map, 1).unwrap(); - assert_eq!(get_complete_template(&anodes).unwrap(), clozed_text); - - // But if a custom modifier was used, it's deferred to the Python code - let (_qnodes, anodes) = render_card("{{custom:Text}}", "{{FrontSide}}", &map, 1).unwrap(); - assert_eq!(get_complete_template(&anodes).is_none(), true) - } } diff --git a/rslib/src/template_filters.rs b/rslib/src/template_filters.rs index b6d4bdae1..4519a480e 100644 --- a/rslib/src/template_filters.rs +++ b/rslib/src/template_filters.rs @@ -341,7 +341,6 @@ field nonempty_fields: &Default::default(), question_side: false, card_ord: 0, - front_text: None, }; assert_eq!( apply_filters("ignored", &["cloze", "type"], "Text", &ctx), @@ -357,7 +356,6 @@ field nonempty_fields: &Default::default(), question_side: true, card_ord: 0, - front_text: None, }; assert_eq!(strip_html(&cloze_filter(text, &ctx)).as_ref(), "[...] two"); assert_eq!( diff --git a/rslib/src/text.rs b/rslib/src/text.rs index 105643bba..154638c25 100644 --- a/rslib/src/text.rs +++ b/rslib/src/text.rs @@ -9,13 +9,13 @@ use std::collections::HashSet; use std::ptr; #[derive(Debug, PartialEq)] -pub enum AVTag<'a> { - SoundOrVideo(Cow<'a, str>), +pub enum AVTag { + SoundOrVideo(String), TextToSpeech { - field_text: Cow<'a, str>, - lang: &'a str, - voices: Vec<&'a str>, - other_args: Vec<&'a str>, + field_text: String, + lang: String, + voices: Vec, + other_args: Vec, }, } @@ -78,27 +78,29 @@ pub fn strip_av_tags(text: &str) -> Cow { AV_TAGS.replace_all(text, "") } -pub fn flag_av_tags(text: &str) -> Cow { - let mut idx = 0; - AV_TAGS.replace_all(text, |_caps: &Captures| { - let text = format!("[anki:play]{}[/anki:play]", idx); - idx += 1; - text - }) -} -pub fn av_tags_in_string(text: &str) -> impl Iterator { - AV_TAGS.captures_iter(text).map(|caps| { - if let Some(av_file) = caps.get(1) { - AVTag::SoundOrVideo(decode_entities(av_file.as_str())) +/// Extract audio tags from string, replacing them with [anki:play] refs +pub fn extract_av_tags<'a>(text: &'a str, question_side: bool) -> (Cow<'a, str>, Vec) { + let mut tags = vec![]; + let context = if question_side { 'q' } else { 'a' }; + let replaced_text = AV_TAGS.replace_all(text, |caps: &Captures| { + // extract + let tag = if let Some(av_file) = caps.get(1) { + AVTag::SoundOrVideo(decode_entities(av_file.as_str()).into()) } else { let args = caps.get(2).unwrap(); let field_text = caps.get(3).unwrap(); tts_tag_from_string(field_text.as_str(), args.as_str()) - } - }) + }; + tags.push(tag); + + // and replace with reference + format!("[anki:play:{}:{}]", context, tags.len() - 1) + }); + + (replaced_text, tags) } -fn tts_tag_from_string<'a>(field_text: &'a str, args: &'a str) -> AVTag<'a> { +fn tts_tag_from_string<'a>(field_text: &'a str, args: &'a str) -> AVTag { let mut other_args = vec![]; let mut split_args = args.split(' '); let lang = split_args.next().unwrap_or(""); @@ -109,15 +111,15 @@ fn tts_tag_from_string<'a>(field_text: &'a str, args: &'a str) -> AVTag<'a> { voices = remaining_arg .split('=') .nth(1) - .map(|voices| voices.split(',').collect()); + .map(|voices| voices.split(',').map(ToOwned::to_owned).collect()); } else { - other_args.push(remaining_arg); + other_args.push(remaining_arg.to_owned()); } } AVTag::TextToSpeech { - field_text: strip_html_for_tts(field_text), - lang, + field_text: strip_html_for_tts(field_text).into(), + lang: lang.into(), voices: voices.unwrap_or_else(Vec::new), other_args, } @@ -149,7 +151,7 @@ pub fn cloze_numbers_in_string(html: &str) -> HashSet { #[cfg(test)] mod test { use crate::text::{ - av_tags_in_string, cloze_numbers_in_string, flag_av_tags, strip_av_tags, strip_html, + cloze_numbers_in_string, extract_av_tags, strip_av_tags, strip_html, strip_html_preserving_image_filenames, AVTag, }; use std::collections::HashSet; @@ -188,22 +190,21 @@ mod test { let s = "abc[sound:fo&o.mp3]def[anki:tts][en_US voices=Bob,Jane]foo
1>2[/anki:tts]gh"; assert_eq!(strip_av_tags(s), "abcdefgh"); + + let (text, tags) = extract_av_tags(s, true); + assert_eq!(text, "abc[anki:play:q:0]def[anki:play:q:1]gh"); + assert_eq!( - av_tags_in_string(s).collect::>(), + tags, vec![ AVTag::SoundOrVideo("fo&o.mp3".into()), AVTag::TextToSpeech { field_text: "foo 1>2".into(), - lang: "en_US", - voices: vec!["Bob", "Jane"], + lang: "en_US".into(), + voices: vec!["Bob".into(), "Jane".into()], other_args: vec![] }, ] ); - - assert_eq!( - flag_av_tags(s), - "abc[anki:play]0[/anki:play]def[anki:play]1[/anki:play]gh" - ); } }