Super Anki

Supercharges AnkiWeb. Currently Supported Functionality - Show card total, done and remaining count per session - Dark Mode

  1. // ==UserScript==
  2. // @name Super Anki
  3. // @version 9
  4. // @grant none
  5. // @match https://*.ankiuser.net/**
  6. // @match https://*.ankiweb.net/decks
  7. // @description Supercharges AnkiWeb. Currently Supported Functionality - Show card total, done and remaining count per session - Dark Mode
  8. // @namespace asleepysamurai.com
  9. // @license BSD Zero Clause License
  10. // ==/UserScript==
  11.  
  12. const version = GM_info.script.version
  13. const key = 'super-anki-data'
  14.  
  15. function readLocalStorage(){
  16. return JSON.parse(localStorage.getItem(key)) || {}
  17. }
  18.  
  19. function writeLocalStorage(dataDiff = {}){
  20. const data = {...readLocalStorage(), ...dataDiff}
  21. localStorage.setItem(key, JSON.stringify(data))
  22. return data
  23. }
  24.  
  25. function initSpeechSynthesis(){
  26. addSpeakButtons()
  27. //speakAllOnCardSide()
  28. const observer = new MutationObserver(() => {
  29. addSpeakButtons()
  30. //speakAllOnCardSide()
  31. })
  32.  
  33. const qaNode = document.querySelector('#qa')
  34. observer.observe(qaNode, { characterData: false, attributes: false, childList: true, subtree: false });
  35. }
  36.  
  37. let isSpeaking = false
  38. const speakerIcon = String.fromCodePoint(0x1F508)
  39. const speakingIcon = String.fromCodePoint(0x1F50A)
  40.  
  41. function say(voice, text, button){
  42. if(isSpeaking){
  43. return
  44. }
  45. isSpeaking = true
  46. const utterThis = new SpeechSynthesisUtterance(text);
  47. utterThis.voice = voice
  48. utterThis.addEventListener('end', (evt) => {
  49. button.textContent = speakerIcon
  50. isSpeaking = false
  51. })
  52.  
  53. button.textContent = speakingIcon
  54. window.speechSynthesis.speak(utterThis)
  55. }
  56.  
  57. function addSpeakButton(voice, speakableTextNode, childNodes = [speakableTextNode]){
  58. if(!speakableTextNode.textContent.trim()){
  59. return
  60. }
  61.  
  62. const speakButton = document.createElement('div')
  63. speakButton.textContent = speakerIcon
  64. speakButton.setAttribute('style', 'padding-right: 0.5rem;font-size: 2rem;cursor: pointer')
  65. speakButton.classList.add('speak-button')
  66. speakButton.addEventListener('click', (ev)=>{
  67. say(voice, speakableTextNode.textContent.trim(), ev.currentTarget)
  68. })
  69.  
  70. const container = document.createElement('div')
  71. const wordContainer = document.createElement('div')
  72.  
  73. container.appendChild(speakButton)
  74. container.appendChild(wordContainer)
  75. container.setAttribute('style', 'display: flex;justify-content: center;align-items: center;')
  76.  
  77. speakableTextNode.parentNode.insertBefore(container, speakableTextNode)
  78. childNodes.forEach(node => wordContainer.appendChild(node))
  79. }
  80.  
  81. function addDESpeakButton(speakableTextNode, childNodes = [speakableTextNode]){
  82. const deVoice = window.speechSynthesis.getVoices().find(v=>v.lang==='de-DE')
  83. if(!deVoice){
  84. console.log('No German Support')
  85. return false
  86. }
  87. addSpeakButton(deVoice, speakableTextNode, childNodes)
  88. }
  89.  
  90. function addENSpeakButton(speakableTextNodes = []){
  91. const enVoice = window.speechSynthesis.getVoices().find(v=>v.lang==='en-US')
  92. if(!enVoice){
  93. console.log('No English Support')
  94. return false
  95. }
  96. const speakButton = document.createElement('span')
  97. speakButton.textContent = speakerIcon
  98. speakButton.setAttribute('style', 'padding-right: 0.5rem;font-size: 2rem;cursor: pointer')
  99. speakButton.classList.add('speak-button')
  100.  
  101. speakableTextNodes.forEach(node=>addSpeakButton(enVoice, node))
  102. }
  103.  
  104. function addSpeakButtons(){
  105. const word = document.querySelector('.word')
  106. const ipa = document.querySelector('.ipa')
  107. addDESpeakButton(word, [word,ipa])
  108.  
  109. const deSentence = document.querySelectorAll('.spanish')
  110. deSentence.forEach(deSentence=>addDESpeakButton(deSentence))
  111.  
  112. const definitions = Array.from(document.querySelectorAll('.definition'))
  113. addENSpeakButton(definitions)
  114.  
  115. const enSentences = document.querySelectorAll('.english')
  116. addENSpeakButton(enSentences)
  117. }
  118.  
  119. function speakAllOnCardSide(){
  120. const [deVoice, enVoice] = window.speechSynthesis.getVoices().reduce((voices,v)=>{
  121. if(v.lang==='en-US'){
  122. voices[1] = v
  123. } else if(v.lang === 'de-DE'){
  124. voices[0] = v
  125. }
  126.  
  127. return voices
  128. },[])
  129.  
  130. function getUtterance(node, voice){
  131. const text = node?.innerText?.trim() || ''
  132. const utterance = new SpeechSynthesisUtterance(text);
  133. utterance.voice = voice
  134. return utterance
  135. }
  136.  
  137. const utterances = [
  138. document.querySelector('.word'),
  139. ...Array.from(document.querySelectorAll('.definition'))
  140. ].map((node,i)=>getUtterance(node, i === 0 ? deVoice : enVoice))
  141.  
  142. utterances.forEach(u=>{
  143. window.speechSynthesis.speak(u)
  144. const pause = new SpeechSynthesisUtterance(', !')
  145. pause.voice = enVoice
  146. window.speechSynthesis.speak(pause)
  147. })
  148. }
  149.  
  150. function initMediaSession(){
  151. if (! "mediaSession" in navigator) {
  152. return
  153. }
  154.  
  155. navigator.mediaSession.metadata = new MediaMetadata({
  156. title: "SuperAnki",
  157. artist: `v${version}`,
  158. artwork: [
  159. {
  160. src: "https://ankiuser.net/logo.png",
  161. type: "image/png",
  162. },
  163. ],
  164. });
  165.  
  166. navigator.mediaSession.setActionHandler("play", () => {
  167. speakAllOnCardSide()
  168. });
  169. navigator.mediaSession.setActionHandler("pause", () => {
  170. window.speechSynthesis.cancel()
  171. });
  172. navigator.mediaSession.setActionHandler("stop", () => {
  173. window.speechSynthesis.cancel()
  174. });
  175.  
  176. navigator.mediaSession.setActionHandler("previoustrack", () => {
  177. const againButton = Array.from(document.querySelectorAll('.btn.m-1')).find(b=>b.innerText.toLowerCase() === 'again')
  178. againButton.dispatchEvent(new PointerEvent('click'))
  179. });
  180. navigator.mediaSession.setActionHandler("nexttrack", () => {
  181. const goodButton = Array.from(document.querySelectorAll('.btn.m-1')).find(b=>b.innerText.toLowerCase() === 'good')
  182. const showAnswerButton = Array.from(document.querySelectorAll('.btn.btn-lg')).find(b=>b.innerText.toLowerCase() === 'show answer')
  183. (goodButton||showAnswerButton).dispatchEvent(new PointerEvent('click'))
  184. });
  185. }
  186.  
  187. function getTodaysDoneCount(done){
  188. const now = new Date()
  189. const cutOffTime = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 4, 0, 0)
  190. if(now.getHours() < 4){
  191. cutOffTime.setTime(cutOffTime.getTime() - (1000 * 60 * 60 * 24))
  192. }
  193.  
  194. const savedData = readLocalStorage()
  195. if(done === 0 && savedData.doneCount !== undefined && savedData.lastDoneTime && savedData.lastDoneTime >= cutOffTime.getTime()){
  196. done = savedData.doneCount
  197. }
  198. writeLocalStorage({doneCount: done, lastDoneTime: now.getTime()})
  199. return done
  200. }
  201.  
  202. function formatCounts(total, remaining){
  203. const done = getTodaysDoneCount(total - remaining)
  204. return `${remaining} Left + ${done} Done = ${total}`
  205. }
  206.  
  207. function addTotalCount(){
  208. const counts = Array.from(document.querySelectorAll('.count'))
  209. const equals = document.createTextNode(' = ')
  210. const totalCards = counts.reduce((total, thisCount) => total + parseInt(thisCount.innerText), 0)
  211. const totalCount = counts[0].cloneNode(true)
  212. totalCount.innerText = formatCounts(totalCards, totalCards)
  213. totalCount.classList.remove('active', 'new', 'learn', 'review')
  214. const countParent = counts[0].parentElement
  215. countParent.appendChild(equals)
  216. countParent.appendChild(totalCount)
  217. const observer = new MutationObserver(() => {
  218. const restCards = counts.reduce((total, thisCount) => total + parseInt(thisCount.innerText), 0)
  219. totalCount.innerText = formatCounts(totalCards, restCards)
  220. })
  221.  
  222. counts.forEach(countNode => observer.observe(countNode, { characterData: true, attributes: false, childList: true, subtree: true }));
  223. }
  224.  
  225. function setupObserver(){
  226. try{
  227. init()
  228. }catch(err){
  229. setTimeout(() => {
  230. setupObserver()
  231. }, 100)
  232. }
  233. }
  234.  
  235. function enableDarkMode(){
  236. const style = document.documentElement.getAttribute('style')
  237. document.documentElement.setAttribute('style', `${style || ''}; filter: invert(0.9);`)
  238. }
  239.  
  240. function updateBranding(){
  241. document.querySelector('.navbar-brand > span').innerHTML = `SuperAnki <small><small>v${version}</small></small>`
  242. }
  243.  
  244. function init(){
  245. updateBranding()
  246. enableDarkMode()
  247. if(window.location.pathname.toLowerCase().startsWith('/study')){
  248. addTotalCount()
  249. initSpeechSynthesis()
  250. initMediaSession()
  251. }
  252. }
  253.  
  254. setupObserver()