├── screenshots ├── words.PNG ├── fields.PNG └── settings.png ├── LLW_To_Anki_Example_Deck.apkg ├── background.js ├── manifest.json ├── resources ├── toastify.css └── toastify.js ├── popup ├── style.css ├── popup.html └── popup.js ├── README.md └── content_script.js /screenshots/words.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ClearlyKyle/Language-Learning-With-Anki/HEAD/screenshots/words.PNG -------------------------------------------------------------------------------- /screenshots/fields.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ClearlyKyle/Language-Learning-With-Anki/HEAD/screenshots/fields.PNG -------------------------------------------------------------------------------- /screenshots/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ClearlyKyle/Language-Learning-With-Anki/HEAD/screenshots/settings.png -------------------------------------------------------------------------------- /LLW_To_Anki_Example_Deck.apkg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ClearlyKyle/Language-Learning-With-Anki/HEAD/LLW_To_Anki_Example_Deck.apkg -------------------------------------------------------------------------------- /background.js: -------------------------------------------------------------------------------- 1 | chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) 2 | { 3 | if (request.action === 'captureVisibleTab') 4 | { 5 | chrome.tabs.captureVisibleTab(null, { format: 'png' }, function (data_url) 6 | { 7 | sendResponse({ imageData: data_url }); 8 | console.log(data_url); 9 | }); 10 | } 11 | 12 | // Return true to indicate that sendResponse will be called asynchronously 13 | return true; 14 | }); -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "LLW to Anki", 4 | "description": "Save words from languagereactor video subtitles and send to Anki", 5 | "version": "4.0", 6 | "author": "@clearlykyle", 7 | "permissions": [ 8 | "storage" 9 | ], 10 | "host_permissions": [ 11 | "" 12 | ], 13 | "content_scripts": [ 14 | { 15 | "all_frames": true, 16 | "js": [ 17 | "content_script.js", 18 | "resources/toastify.js" 19 | ], 20 | "css": [ 21 | "resources/toastify.css" 22 | ], 23 | "matches": [ 24 | "*://*.youtube.com/*", 25 | "*://*.netflix.com/*" 26 | ], 27 | "run_at": "document_end" 28 | } 29 | ], 30 | "action": { 31 | "default_title": "LLW to Anki", 32 | "default_popup": "popup/popup.html" 33 | }, 34 | "background": { 35 | "service_worker": "background.js" 36 | } 37 | } -------------------------------------------------------------------------------- /resources/toastify.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Toastify js 1.11.2 3 | * https://github.com/apvarun/toastify-js 4 | * @license MIT licensed 5 | * 6 | * Copyright (C) 2018 Varun A P 7 | */ 8 | 9 | .toastify { 10 | padding: 12px 20px; 11 | color: #ffffff; 12 | display: inline-block; 13 | box-shadow: 0 3px 6px -1px rgba(0, 0, 0, 0.12), 0 10px 36px -4px rgba(77, 96, 232, 0.3); 14 | background: -webkit-linear-gradient(315deg, #73a5ff, #5477f5); 15 | background: linear-gradient(135deg, #73a5ff, #5477f5); 16 | position: fixed; 17 | opacity: 0; 18 | transition: all 0.4s cubic-bezier(0.215, 0.61, 0.355, 1); 19 | border-radius: 2px; 20 | cursor: pointer; 21 | text-decoration: none; 22 | max-width: calc(50% - 20px); 23 | z-index: 2147483647; 24 | } 25 | 26 | .toastify.on { 27 | opacity: 1; 28 | } 29 | 30 | .toast-close { 31 | opacity: 0.4; 32 | padding: 0 5px; 33 | } 34 | 35 | .toastify-right { 36 | right: 15px; 37 | } 38 | 39 | .toastify-left { 40 | left: 15px; 41 | } 42 | 43 | .toastify-top { 44 | top: -150px; 45 | } 46 | 47 | .toastify-bottom { 48 | bottom: -150px; 49 | } 50 | 51 | .toastify-rounded { 52 | border-radius: 25px; 53 | } 54 | 55 | .toastify-avatar { 56 | width: 1.5em; 57 | height: 1.5em; 58 | margin: -7px 5px; 59 | border-radius: 2px; 60 | } 61 | 62 | .toastify-center { 63 | margin-left: auto; 64 | margin-right: auto; 65 | left: 0; 66 | right: 0; 67 | max-width: fit-content; 68 | max-width: -moz-fit-content; 69 | } 70 | 71 | @media only screen and (max-width: 360px) { 72 | .toastify-right, .toastify-left { 73 | margin-left: auto; 74 | margin-right: auto; 75 | left: 0; 76 | right: 0; 77 | max-width: fit-content; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /popup/style.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | width: 400px; 4 | height: 600px; 5 | margin: 0; 6 | padding: 0; 7 | overflow: hidden; 8 | font-size: 14px; 9 | } 10 | 11 | label { 12 | display: block; 13 | margin: 8px 0; 14 | } 15 | 16 | label span { 17 | display: inline-block; 18 | vertical-align: middle; 19 | text-align: right; 20 | width: 140px; 21 | } 22 | 23 | label select { 24 | width: 190px; 25 | vertical-align: middle; 26 | margin-left: 10px; 27 | height: 20px; 28 | } 29 | 30 | label input { 31 | vertical-align: middle; 32 | } 33 | 34 | #ankiPuaseOnSavedWord { 35 | margin-left: 43px; 36 | } 37 | 38 | #ankiConnectUrl { 39 | width: 190px; 40 | height: 20px; 41 | margin-left: 10px; 42 | } 43 | 44 | #saveAnkiBtn { 45 | display: block; 46 | width: 100px; 47 | margin: 0 auto; 48 | } 49 | 50 | #ankiHighLightColour { 51 | display: inline-block; 52 | width: 100px; 53 | height: 20px; 54 | border: none; 55 | cursor: pointer; 56 | } 57 | 58 | #ankiHighLightSavedWords { 59 | display: inline-block; 60 | width: 100px; 61 | margin: 0 auto; 62 | } 63 | 64 | .tab { 65 | overflow: hidden; 66 | border: 1px solid #ccc; 67 | background-color: #f1f1f1; 68 | } 69 | 70 | .tab button { 71 | background-color: inherit; 72 | float: left; 73 | border: none; 74 | outline: none; 75 | cursor: pointer; 76 | padding: 6px 16px; 77 | transition: 0.3s; 78 | } 79 | 80 | .tab button:hover { 81 | background-color: #ddd; 82 | } 83 | 84 | .tab button.active { 85 | background-color: #ccc; 86 | } 87 | 88 | .tabcontent { 89 | overflow: hidden; 90 | /*max-height: calc(600px - 33px);*/ 91 | display: none; 92 | } 93 | 94 | #Words { 95 | overflow-y: auto; 96 | flex: 1 1 auto; 97 | } 98 | 99 | #wordsHeader { 100 | display: flex; 101 | justify-content: space-between; 102 | align-items: center; 103 | flex-wrap: nowrap; 104 | margin-top: 3px; 105 | padding-left: 10px; 106 | } 107 | 108 | #wordsButtons { 109 | display: flex; 110 | gap: 4px; 111 | flex-shrink: 0; 112 | } 113 | 114 | #wordsList { 115 | padding-left: 40px; 116 | margin: 5px; 117 | line-height: 1; 118 | height: calc(600px - 70px); 119 | } 120 | 121 | #wordsList li { 122 | margin-bottom: 6px; 123 | } 124 | 125 | #wordsList { 126 | list-style-type: decimal; 127 | } 128 | 129 | .deleteWordBtn { 130 | background-color: transparent; 131 | border: none; 132 | color: red; 133 | font-weight: bold; 134 | cursor: pointer; 135 | padding: 0 4px; 136 | } 137 | 138 | .deleteWordBtn:hover { 139 | color: darkred; 140 | } -------------------------------------------------------------------------------- /popup/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 |
14 | 15 | 16 |
17 |
18 | 22 |
23 | 27 | 31 |
32 | 36 | 40 | 44 | 48 | 52 | 56 | 65 | 69 | 73 | 77 | 81 | 85 | 90 | 97 |
98 | 99 |
100 |
101 | 102 | 103 |
104 |
105 | Total words : 106 |
107 | 108 | 109 | 110 | 111 | 112 |
113 |
114 |
    115 |
    116 | 117 | 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Language Learning With Anki 2 | 3 | ## Langauge Reactor to Anki 4 | 5 | Adds an option to create Anki flash cards with the Language Reactor chrome extension 6 | 7 | Language Reactor can be found here: 8 | > https://chrome.google.com/webstore/detail/language-reactor/hoombieeljmmljlkjmnheibnpciblicm 9 | 10 | 11 | ## Setup 12 | 13 | 1) Must install the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) plugin. 14 | 2) Download the extension source code: 15 | - Blue "code" button then "Download Zip". 16 | - Unzip the download, to have a folder called "Language-Learning-With-Anki-master", this is the extension source code. 17 | 3) Install the unpacked `Language-Learning-With-Anki` extension. 18 | - Open a new chrome tab and go to : 19 | - In the top right, toggle ON the "Developer mode". 20 | - Click "Load Unpacked" and navigate to where the "Language-Learning-With-Anki-master" folder is, and choose that folder. 21 | 4) Setup the URL (default is `http://localhost:8765`), deck and model values, making sure the top field of your note type has a [valid field](#empty-note-error) value. 22 | 5) You **must** leave the Anki desktop application open in order for the extension to communicate with Ankiconnect. 23 | 24 | ## Usage 25 | 26 | Click a word to bring up the definition popup. Clicking the Anki button will gather the relevant data and send it to Anki. To change what data is collected, click the extension icon to show the settings page. 27 | 28 | The "RC" option will remove the currently selected word from the list of saved words (can be viewed in the settings page). Everytime a word is clicked and sent to Anki it is saved in a list of words, this is to be used for highlighting purposes. Words can be removed in the settings and with the use of the "RC" button. 29 | 30 | Regardless of whether the highlighting words option is turned on or off, words used to create cards will be saved in the words list within the settings page. 31 | 32 | When using the audio field, the extension will replay the subtitle again to collect the audio. Let the video play and wait for the success popup before doing anything else, interrupting the playback may cause a half finished audio track. Subsequent cards made with the same subtitle will not require the audio to be re-record. If an issue occurs with the audio and you wish to re-record it for the current subtitle, use the "RA" button to remove the saved audio file and try making the card again. 33 | 34 | The ai field will be filled with whatever Ai mode you have currently selected (Explain, Examples, Grammar). 35 | 36 | ![bubble-screenshot](screenshots/fields.PNG) 37 | 38 | ## Settings 39 | 40 | Exported data fields: 41 | 42 | 1) `Screenshot` - an image of video taken at time when button is pressed 43 | 2) `Subtitle` - the current subtitle visible on screen 44 | 3) `Subtitle Translation` - this is the translated subtitle when using the 'Show machine translation' option 45 | 4) `Word` - selected word in the subtitle 46 | 5) `Basic Translation` - the translation of the selected word 47 | 6) `Example Sentences` - examples below the definitions in the popup, either from the current video or Tatoeba 48 | 7) `Example Source` - Tatoeba or Current video 49 | 8) `Other Translation` - the extra translations of the word, formatted in HTML 50 | 9) `Base Form` - "dictionary" form of the word, without declensions 51 | 10) `Ai` - save the text output from the Ai assistant 52 | 11) `Audio` - audio for the current subtitle (limited to 16s) 53 | 12) `URL` - URL of current video with the current timestamp 54 | 13) `Highlight` - toggle whether to highlight words exported to Anki in the chosen colour 55 | 14) `Pause on Saved` - auto pause the subtitle if it contains a word you have made an Anki card for 56 | 57 | Settings allow you to choose which fields are filled with what data. A `` options means that data is skipped 58 | 59 | ![options-screenshot](/screenshots/settings.png) 60 | ![options-screenshot](/screenshots/words.PNG) 61 | 62 | ## Possible Errors 63 | 64 | - `Access to fetch at 'http://localhost:8765' from origin 'https://www.netflix.com' has been blocked by CORS policy` 65 | 66 | You need to make sure Netflix and Youtube are added to the "webCorsOriginList" in your AnkiConnect config. To do this, go to: 67 | 68 | `Anki > tools > Add-ons > AnkiConnect > config` 69 | 70 | Example of "webCorsOriginList" 71 | ```json 72 | "webCorsOriginList": [ 73 | "http://localhost", 74 | "https://www.netflix.com", 75 | "https://www.youtube.com" 76 | ] 77 | ``` 78 | 79 | - `cannot create note because it is empty` 80 | 81 | Make sure the field at position 1 in your Anki note type (Tools > Manage Note Types) is set to a value in the extensions settings page, if not, then you will get this error. See [also](https://github.com/ClearlyKyle/Language-Learning-With-Anki/issues/7#issuecomment-2510020695) -------------------------------------------------------------------------------- /popup/popup.js: -------------------------------------------------------------------------------- 1 | console.log("----- [background.js] LOADED"); 2 | 3 | // 4 | // DEBUG MODE 5 | // 6 | const CONSOLE_LOGGING = true; 7 | if (!CONSOLE_LOGGING) console.log = function () { }; 8 | 9 | // 10 | // GLOBALS 11 | // 12 | let anki_url = 'http://localhost:8765'; 13 | let anki_field_data = {}; 14 | let anki_field_promises = {}; 15 | const anki_field_elements = {}; // saved elements, to reduce calls to getElementById 16 | 17 | // These are the names of the field elements in the html, they must match! 18 | // The elements we want to be filled with the list of field names 19 | const anki_field_names = [ 20 | "ankiFieldScreenshotSelected", 21 | "ankiSubtitleSelected", 22 | "ankiSubtitleTranslation", 23 | "ankiWordSelected", 24 | "ankiBasicTranslationSelected", 25 | "ankiExampleSentencesSelected", 26 | "ankiOtherTranslationSelected", 27 | "ankiBaseFormSelected", 28 | "ankiAiAssistantSelected", 29 | "ankiAudioSelected", 30 | "ankiFieldURL" 31 | ]; 32 | 33 | // Names of other elements that dont need the field values added 34 | const anki_id_names = [ 35 | "ankiNoteNameSelected", 36 | "ankiDeckNameSelected", 37 | "ankiConnectUrl", 38 | "ankiExampleSentenceSource", 39 | "ankiHighLightSavedWords", 40 | "ankiHighLightColour", 41 | "ankiPuaseOnSavedWord", 42 | 43 | ...anki_field_names]; 44 | 45 | // Generate our structure for saving values with associated element id 46 | //{ 47 | // "ankiNoteNameSelected": value, 48 | // "ankiDeckNameSelected": value, 49 | // ... 50 | //} 51 | let anki_storage_values = Object.fromEntries(anki_id_names.map((key) => [key, ""])); 52 | 53 | // 54 | // STARTUP 55 | // 56 | (document.readyState === "loading") ? 57 | document.addEventListener("DOMContentLoaded", () => startup()) : 58 | startup(); 59 | 60 | function startup() 61 | { 62 | tab_setup(); 63 | settings_setup(); 64 | words_setup(); 65 | } 66 | 67 | // 68 | // TABS 69 | // 70 | 71 | let tab_contents = []; 72 | let tab_links = []; 73 | 74 | // TODO : another method instead of this id 75 | function tab_open(tab_id) 76 | { 77 | tab_contents.forEach(tc => tc.style.display = "none"); 78 | tab_links.forEach(btn => btn.classList.remove("active")); 79 | 80 | tab_contents[tab_id].style.display = "block"; 81 | tab_links[tab_id].classList.add("active"); 82 | } 83 | 84 | function tab_setup() 85 | { 86 | tab_contents = document.querySelectorAll(".tabcontent"); 87 | 88 | tab_links = [ 89 | document.querySelector(".tablinks.tabSettings"), 90 | document.querySelector(".tablinks.tabWords") 91 | ]; 92 | 93 | tab_links[0].addEventListener("click", () => tab_open(0)); 94 | tab_links[1].addEventListener("click", () => tab_open(1)); 95 | 96 | tab_open(0); // default tab 97 | } 98 | 99 | // 100 | // WORDS 101 | // 102 | 103 | function words_setup() 104 | { 105 | words_load(); 106 | 107 | // DOWNLOAD 108 | const words_download_btn = document.getElementById("wordsDownloadBtn"); 109 | 110 | words_download_btn.addEventListener("click", () => 111 | { 112 | chrome.storage.local.get({ ankiHighlightWordList: [] }, (data) => 113 | { 114 | const words = data.ankiHighlightWordList; 115 | const blob = new Blob([words.join("\n")], { type: "text/plain" }); 116 | const url = URL.createObjectURL(blob); 117 | 118 | const a = document.createElement("a"); 119 | a.href = url; 120 | a.download = "words.txt"; 121 | document.body.appendChild(a); 122 | a.click(); 123 | document.body.removeChild(a); 124 | 125 | URL.revokeObjectURL(url); 126 | }); 127 | }); 128 | 129 | // UPLOAD 130 | const words_upload_btn = document.getElementById("wordsUploadBtn"); 131 | const words_upload_input = document.getElementById("wordsUploadInput"); 132 | 133 | words_upload_btn.addEventListener("click", () => 134 | { 135 | words_upload_input.click(); // open file picker 136 | }); 137 | 138 | words_upload_input.addEventListener("change", (event) => 139 | { 140 | const file = event.target.files[0]; 141 | if (!file) return; 142 | 143 | const reader = new FileReader(); 144 | reader.onload = function (e) 145 | { 146 | const text = e.target.result; 147 | 148 | // split words by newlines, trim whitespace, remove empty lines 149 | const new_words = text.split(/\r?\n/).map(w => w.trim()).filter(Boolean); 150 | 151 | // NOTE : do we want to upload and add the words to the current list, 152 | // or replace the list entirely 153 | chrome.storage.local.get({ ankiHighlightWordList: [] }, (data) => 154 | { 155 | const all_words = Array.from(new Set([...data.ankiHighlightWordList, ...new_words])); 156 | chrome.storage.local.set({ ankiHighlightWordList: all_words }, () => 157 | { 158 | words_load(); 159 | }); 160 | }); 161 | }; 162 | reader.readAsText(file); 163 | 164 | words_upload_input.value = ""; 165 | }); 166 | 167 | // USER ADD 168 | const words_add_input = document.getElementById("wordsAddInput"); 169 | const words_add_button = document.getElementById("wordsAddBtn"); 170 | 171 | words_add_button.addEventListener("click", () => 172 | { 173 | const word = words_add_input.value.trim(); 174 | if (!word) return; 175 | 176 | chrome.storage.local.get({ ankiHighlightWordList: [] }, (data) => 177 | { 178 | const words = data.ankiHighlightWordList; 179 | 180 | if (!words.includes(word)) 181 | { 182 | words.push(word); 183 | chrome.storage.local.set({ ankiHighlightWordList: words }, () => 184 | { 185 | words_load(); 186 | words_add_input.value = ""; 187 | }); 188 | } 189 | else 190 | { 191 | alert("Word already exists!"); 192 | } 193 | }); 194 | }); 195 | 196 | // allow pressing Enter in the input to add 197 | words_add_input.addEventListener("keydown", (e) => 198 | { 199 | if (e.key === "Enter") 200 | { 201 | words_add_button.click(); 202 | e.preventDefault(); 203 | } 204 | }); 205 | 206 | } 207 | 208 | function words_load() 209 | { 210 | const words_total = document.getElementById("wordsTotal"); 211 | const word_list = document.getElementById("wordsList"); 212 | 213 | chrome.storage.local.get({ ankiHighlightWordList: [] }, (data) => 214 | { 215 | const words = data.ankiHighlightWordList; 216 | const list = document.getElementById("wordsList"); 217 | 218 | console.log(words); 219 | 220 | words_total.textContent = `Total words: ${words.length}`; 221 | 222 | list.innerHTML = ""; // clear old entries 223 | 224 | if (words.length === 0) 225 | { 226 | const li = document.createElement("li"); 227 | li.textContent = "(No words saved yet)"; 228 | list.appendChild(li); 229 | } 230 | else 231 | { 232 | words.forEach((word, index) => 233 | { 234 | const li = document.createElement("li"); 235 | 236 | const delete_btn = document.createElement("button"); 237 | delete_btn.textContent = "x"; 238 | delete_btn.className = "deleteWordBtn"; 239 | delete_btn.title = "Delete word"; 240 | delete_btn.addEventListener("click", () => 241 | { 242 | words.splice(index, 1); 243 | chrome.storage.local.set({ ankiHighlightWordList: words }, words_load); 244 | }); 245 | 246 | const span = document.createElement("span"); 247 | span.textContent = word; 248 | 249 | li.appendChild(delete_btn); 250 | li.appendChild(span); 251 | word_list.appendChild(li); 252 | }); 253 | } 254 | }); 255 | } 256 | 257 | // 258 | // SETTINGS 259 | // 260 | 261 | function settings_setup() 262 | { 263 | for (let i = 0; i < anki_id_names.length; i++) 264 | { 265 | const element_name = anki_id_names[i]; 266 | anki_field_elements[element_name] = document.getElementById(element_name); // Remove this element storage? 267 | } 268 | 269 | const submit_element = document.getElementById('saveAnkiBtn'); 270 | submit_element.addEventListener('click', (e) => 271 | { 272 | for (let i = 0; i < anki_field_names.length; i++) 273 | { 274 | const element_name = anki_field_names[i]; 275 | anki_storage_values[element_name] = anki_field_elements[element_name].value; 276 | } 277 | 278 | anki_storage_values["ankiNoteNameSelected"] = anki_field_elements.ankiNoteNameSelected.value; 279 | anki_storage_values["ankiDeckNameSelected"] = anki_field_elements.ankiDeckNameSelected.value; 280 | anki_storage_values["ankiConnectUrl"] = anki_field_elements.ankiConnectUrl.value; 281 | 282 | anki_storage_values["ankiExampleSentenceSource"] = anki_field_elements.ankiExampleSentenceSource.value; 283 | anki_storage_values["ankiHighLightSavedWords"] = anki_field_elements.ankiHighLightSavedWords.checked; 284 | anki_storage_values["ankiHighLightColour"] = anki_field_elements.ankiHighLightColour.value; 285 | 286 | anki_storage_values["ankiPuaseOnSavedWord"] = anki_field_elements.ankiPuaseOnSavedWord.checked; 287 | 288 | console.log(anki_storage_values); 289 | 290 | chrome.storage.local.set(anki_storage_values, () => 291 | { 292 | if (chrome.runtime.lastError) 293 | { 294 | alert("Error saving to storage:", chrome.runtime.lastError); 295 | } 296 | else 297 | { 298 | alert(`Options saved!`); 299 | } 300 | }); 301 | }); 302 | 303 | anki_field_promises = anki_field_names.map((field_name) => 304 | { 305 | return () => 306 | { 307 | return add_options_to_field_dropdown_promise(field_name, anki_field_data, anki_storage_values[field_name]); 308 | }; 309 | }); 310 | 311 | chrome.storage.local.get(["ankiConnectUrl"], ({ ankiConnectUrl }) => 312 | { 313 | const url_element = anki_field_elements.ankiConnectUrl; 314 | url_element.value = anki_url = (ankiConnectUrl || url_element.value); 315 | 316 | fetch(anki_url, { 317 | method: "POST", 318 | body: '{"action":"requestPermission","version":6}', 319 | }) 320 | .then((res) => res.json()) 321 | .then((data) => 322 | { 323 | if (data.error) 324 | { 325 | reject(data.error); 326 | } 327 | else 328 | { 329 | update_selections_with_saved_values(); 330 | } 331 | }) 332 | .catch(error => alert(`Failed to connect to Anki ${anki_url}, make sure Anki is open and AnkiConnect is installed : ${error}`)); 333 | }); 334 | } 335 | 336 | // 337 | // // // // // // // // // // // // // // // // // // // // // // // // // // // // // // 338 | // 339 | 340 | function fetch_from_anki(body) 341 | { 342 | return new Promise((resolve, reject) => 343 | { 344 | fetch(anki_url, { 345 | method: 'POST', 346 | body: body, 347 | }) 348 | .then(response => response.json()) 349 | .then(data => 350 | { 351 | if (data.error) 352 | reject(data.error); 353 | resolve(data); 354 | }) 355 | .catch(error => alert("Failed with body:", body)); 356 | }); 357 | } 358 | 359 | function add_options_to_dropdown(dropdown, data) 360 | { 361 | dropdown.length = 0; 362 | 363 | for (let i = 0; i < data.length; i++) 364 | { 365 | const option = document.createElement('option'); 366 | option.value = option.text = data[i]; 367 | dropdown.add(option); 368 | } 369 | } 370 | 371 | function add_options_to_field_dropdown_promise(element_id, data, saved_value) 372 | { 373 | return new Promise((resolve, reject) => 374 | { 375 | console.log("Data for dropdown", data); 376 | const dropdown = anki_field_elements[element_id]; 377 | 378 | dropdown.length = 0; 379 | 380 | for (let i = 0; i < data.length; i++) 381 | { 382 | const option = document.createElement('option'); 383 | option.value = option.text = data[i]; 384 | dropdown.add(option); 385 | } 386 | 387 | const blank = document.createElement("option"); 388 | blank.value = ""; 389 | blank.text = ""; 390 | dropdown.add(blank); 391 | 392 | dropdown.value = saved_value; 393 | 394 | resolve(); 395 | }); 396 | } 397 | 398 | function update_selections_with_saved_values() 399 | { 400 | chrome.storage.local.get(anki_id_names, res => 401 | { 402 | anki_storage_values = res; 403 | 404 | anki_field_elements.ankiExampleSentenceSource.value = res.ankiExampleSentenceSource || "None"; 405 | 406 | anki_field_elements.ankiHighLightSavedWords.checked = res.ankiHighLightSavedWords || false; 407 | anki_field_elements.ankiHighLightColour.value = res.ankiHighLightColour || "#ffffff"; 408 | 409 | anki_field_elements.ankiPuaseOnSavedWord.checked = res.ankiPuaseOnSavedWord || false; 410 | 411 | // Frist we need to get all deck names and note types, 412 | // after we get a note type, we can then fetch for all the fields of that note type 413 | 414 | const deck_names_element = anki_field_elements.ankiDeckNameSelected; 415 | const note_names_element = anki_field_elements.ankiNoteNameSelected; 416 | 417 | note_names_element.addEventListener('change', update_field_dropdown); 418 | 419 | fetch_from_anki('{"action":"multi","params":{"actions":[{"action":"deckNames"},{"action":"modelNames"}]}}') 420 | .then((data) => 421 | { 422 | if (data.length === 2) 423 | { 424 | const [deck_names, note_names] = data; 425 | 426 | add_options_to_dropdown(deck_names_element, deck_names); 427 | add_options_to_dropdown(note_names_element, note_names); 428 | 429 | const ankiDeckNameSelected = res.ankiDeckNameSelected; 430 | const ankiNoteNameSelected = res.ankiNoteNameSelected; 431 | 432 | if (ankiDeckNameSelected) 433 | deck_names_element.value = ankiDeckNameSelected; 434 | 435 | if (ankiNoteNameSelected) 436 | note_names_element.value = ankiNoteNameSelected; 437 | 438 | update_field_dropdown(); 439 | } 440 | }) 441 | .catch(error => console.error("Unable to get deck and model names", error)); 442 | }); 443 | } 444 | 445 | function update_field_dropdown() 446 | { 447 | const note_names_element = anki_field_elements.ankiNoteNameSelected; 448 | 449 | fetch_from_anki(`{"action": "modelFieldNames","params":{"modelName":"${note_names_element.value}"}}`) 450 | .then((data) => 451 | { 452 | // NOTE : if we switch to another note type that has the same named field, they will not be reset 453 | if (data.length) 454 | { 455 | anki_field_data = data; 456 | Promise.all(anki_field_promises.map((func) => func())); 457 | } 458 | }) 459 | .catch(error => console.error("Unable to model fields", error)); 460 | } -------------------------------------------------------------------------------- /resources/toastify.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Toastify js 1.11.2 3 | * https://github.com/apvarun/toastify-js 4 | * @license MIT licensed 5 | * 6 | * Copyright (C) 2018 Varun A P 7 | */ 8 | (function(root, factory) { 9 | if (typeof module === "object" && module.exports) { 10 | module.exports = factory(); 11 | } else { 12 | root.Toastify = factory(); 13 | } 14 | })(this, function(global) { 15 | // Object initialization 16 | var Toastify = function(options) { 17 | // Returning a new init object 18 | return new Toastify.lib.init(options); 19 | }, 20 | // Library version 21 | version = "1.11.2"; 22 | 23 | // Set the default global options 24 | Toastify.defaults = { 25 | oldestFirst: true, 26 | text: "Toastify is awesome!", 27 | node: undefined, 28 | duration: 3000, 29 | selector: undefined, 30 | callback: function () { 31 | }, 32 | destination: undefined, 33 | newWindow: false, 34 | close: false, 35 | gravity: "toastify-top", 36 | positionLeft: false, 37 | position: '', 38 | backgroundColor: '', 39 | avatar: "", 40 | className: "", 41 | stopOnFocus: true, 42 | onClick: function () { 43 | }, 44 | offset: {x: 0, y: 0}, 45 | escapeMarkup: true, 46 | style: {background: ''} 47 | }; 48 | 49 | // Defining the prototype of the object 50 | Toastify.lib = Toastify.prototype = { 51 | toastify: version, 52 | 53 | constructor: Toastify, 54 | 55 | // Initializing the object with required parameters 56 | init: function(options) { 57 | // Verifying and validating the input object 58 | if (!options) { 59 | options = {}; 60 | } 61 | 62 | // Creating the options object 63 | this.options = {}; 64 | 65 | this.toastElement = null; 66 | 67 | // Validating the options 68 | this.options.text = options.text || Toastify.defaults.text; // Display message 69 | this.options.node = options.node || Toastify.defaults.node; // Display content as node 70 | this.options.duration = options.duration === 0 ? 0 : options.duration || Toastify.defaults.duration; // Display duration 71 | this.options.selector = options.selector || Toastify.defaults.selector; // Parent selector 72 | this.options.callback = options.callback || Toastify.defaults.callback; // Callback after display 73 | this.options.destination = options.destination || Toastify.defaults.destination; // On-click destination 74 | this.options.newWindow = options.newWindow || Toastify.defaults.newWindow; // Open destination in new window 75 | this.options.close = options.close || Toastify.defaults.close; // Show toast close icon 76 | this.options.gravity = options.gravity === "bottom" ? "toastify-bottom" : Toastify.defaults.gravity; // toast position - top or bottom 77 | this.options.positionLeft = options.positionLeft || Toastify.defaults.positionLeft; // toast position - left or right 78 | this.options.position = options.position || Toastify.defaults.position; // toast position - left or right 79 | this.options.backgroundColor = options.backgroundColor || Toastify.defaults.backgroundColor; // toast background color 80 | this.options.avatar = options.avatar || Toastify.defaults.avatar; // img element src - url or a path 81 | this.options.className = options.className || Toastify.defaults.className; // additional class names for the toast 82 | this.options.stopOnFocus = options.stopOnFocus === undefined ? Toastify.defaults.stopOnFocus : options.stopOnFocus; // stop timeout on focus 83 | this.options.onClick = options.onClick || Toastify.defaults.onClick; // Callback after click 84 | this.options.offset = options.offset || Toastify.defaults.offset; // toast offset 85 | this.options.escapeMarkup = options.escapeMarkup !== undefined ? options.escapeMarkup : Toastify.defaults.escapeMarkup; 86 | this.options.style = options.style || Toastify.defaults.style; 87 | if(options.backgroundColor) { 88 | this.options.style.background = options.backgroundColor; 89 | } 90 | 91 | // Returning the current object for chaining functions 92 | return this; 93 | }, 94 | 95 | // Building the DOM element 96 | buildToast: function() { 97 | // Validating if the options are defined 98 | if (!this.options) { 99 | throw "Toastify is not initialized"; 100 | } 101 | 102 | // Creating the DOM object 103 | var divElement = document.createElement("div"); 104 | divElement.className = "toastify on " + this.options.className; 105 | 106 | // Positioning toast to left or right or center 107 | if (!!this.options.position) { 108 | divElement.className += " toastify-" + this.options.position; 109 | } else { 110 | // To be depreciated in further versions 111 | if (this.options.positionLeft === true) { 112 | divElement.className += " toastify-left"; 113 | console.warn('Property `positionLeft` will be depreciated in further versions. Please use `position` instead.') 114 | } else { 115 | // Default position 116 | divElement.className += " toastify-right"; 117 | } 118 | } 119 | 120 | // Assigning gravity of element 121 | divElement.className += " " + this.options.gravity; 122 | 123 | if (this.options.backgroundColor) { 124 | // This is being deprecated in favor of using the style HTML DOM property 125 | console.warn('DEPRECATION NOTICE: "backgroundColor" is being deprecated. Please use the "style.background" property.'); 126 | } 127 | 128 | // Loop through our style object and apply styles to divElement 129 | for (var property in this.options.style) { 130 | divElement.style[property] = this.options.style[property]; 131 | } 132 | 133 | // Adding the toast message/node 134 | if (this.options.node && this.options.node.nodeType === Node.ELEMENT_NODE) { 135 | // If we have a valid node, we insert it 136 | divElement.appendChild(this.options.node) 137 | } else { 138 | if (this.options.escapeMarkup) { 139 | divElement.innerText = this.options.text; 140 | } else { 141 | divElement.innerHTML = this.options.text; 142 | } 143 | 144 | if (this.options.avatar !== "") { 145 | var avatarElement = document.createElement("img"); 146 | avatarElement.src = this.options.avatar; 147 | 148 | avatarElement.className = "toastify-avatar"; 149 | 150 | if (this.options.position == "left" || this.options.positionLeft === true) { 151 | // Adding close icon on the left of content 152 | divElement.appendChild(avatarElement); 153 | } else { 154 | // Adding close icon on the right of content 155 | divElement.insertAdjacentElement("afterbegin", avatarElement); 156 | } 157 | } 158 | } 159 | 160 | // Adding a close icon to the toast 161 | if (this.options.close === true) { 162 | // Create a span for close element 163 | var closeElement = document.createElement("span"); 164 | closeElement.innerHTML = "✖"; 165 | 166 | closeElement.className = "toast-close"; 167 | 168 | // Triggering the removal of toast from DOM on close click 169 | closeElement.addEventListener( 170 | "click", 171 | function(event) { 172 | event.stopPropagation(); 173 | this.removeElement(this.toastElement); 174 | window.clearTimeout(this.toastElement.timeOutValue); 175 | }.bind(this) 176 | ); 177 | 178 | //Calculating screen width 179 | var width = window.innerWidth > 0 ? window.innerWidth : screen.width; 180 | 181 | // Adding the close icon to the toast element 182 | // Display on the right if screen width is less than or equal to 360px 183 | if ((this.options.position == "left" || this.options.positionLeft === true) && width > 360) { 184 | // Adding close icon on the left of content 185 | divElement.insertAdjacentElement("afterbegin", closeElement); 186 | } else { 187 | // Adding close icon on the right of content 188 | divElement.appendChild(closeElement); 189 | } 190 | } 191 | 192 | // Clear timeout while toast is focused 193 | if (this.options.stopOnFocus && this.options.duration > 0) { 194 | var self = this; 195 | // stop countdown 196 | divElement.addEventListener( 197 | "mouseover", 198 | function(event) { 199 | window.clearTimeout(divElement.timeOutValue); 200 | } 201 | ) 202 | // add back the timeout 203 | divElement.addEventListener( 204 | "mouseleave", 205 | function() { 206 | divElement.timeOutValue = window.setTimeout( 207 | function() { 208 | // Remove the toast from DOM 209 | self.removeElement(divElement); 210 | }, 211 | self.options.duration 212 | ) 213 | } 214 | ) 215 | } 216 | 217 | // Adding an on-click destination path 218 | if (typeof this.options.destination !== "undefined") { 219 | divElement.addEventListener( 220 | "click", 221 | function(event) { 222 | event.stopPropagation(); 223 | if (this.options.newWindow === true) { 224 | window.open(this.options.destination, "_blank"); 225 | } else { 226 | window.location = this.options.destination; 227 | } 228 | }.bind(this) 229 | ); 230 | } 231 | 232 | if (typeof this.options.onClick === "function" && typeof this.options.destination === "undefined") { 233 | divElement.addEventListener( 234 | "click", 235 | function(event) { 236 | event.stopPropagation(); 237 | this.options.onClick(); 238 | }.bind(this) 239 | ); 240 | } 241 | 242 | // Adding offset 243 | if(typeof this.options.offset === "object") { 244 | 245 | var x = getAxisOffsetAValue("x", this.options); 246 | var y = getAxisOffsetAValue("y", this.options); 247 | 248 | var xOffset = this.options.position == "left" ? x : "-" + x; 249 | var yOffset = this.options.gravity == "toastify-top" ? y : "-" + y; 250 | 251 | divElement.style.transform = "translate(" + xOffset + "," + yOffset + ")"; 252 | 253 | } 254 | 255 | // Returning the generated element 256 | return divElement; 257 | }, 258 | 259 | // Displaying the toast 260 | showToast: function() { 261 | // Creating the DOM object for the toast 262 | this.toastElement = this.buildToast(); 263 | 264 | // Getting the root element to with the toast needs to be added 265 | var rootElement; 266 | if (typeof this.options.selector === "string") { 267 | rootElement = document.getElementById(this.options.selector); 268 | } else if (this.options.selector instanceof HTMLElement || (typeof ShadowRoot !== 'undefined' && this.options.selector instanceof ShadowRoot)) { 269 | rootElement = this.options.selector; 270 | } else { 271 | rootElement = document.body; 272 | } 273 | 274 | // Validating if root element is present in DOM 275 | if (!rootElement) { 276 | throw "Root element is not defined"; 277 | } 278 | 279 | // Adding the DOM element 280 | var elementToInsert = Toastify.defaults.oldestFirst ? rootElement.firstChild : rootElement.lastChild; 281 | rootElement.insertBefore(this.toastElement, elementToInsert); 282 | 283 | // Repositioning the toasts in case multiple toasts are present 284 | Toastify.reposition(); 285 | 286 | if (this.options.duration > 0) { 287 | this.toastElement.timeOutValue = window.setTimeout( 288 | function() { 289 | // Remove the toast from DOM 290 | this.removeElement(this.toastElement); 291 | }.bind(this), 292 | this.options.duration 293 | ); // Binding `this` for function invocation 294 | } 295 | 296 | // Supporting function chaining 297 | return this; 298 | }, 299 | 300 | hideToast: function() { 301 | if (this.toastElement.timeOutValue) { 302 | clearTimeout(this.toastElement.timeOutValue); 303 | } 304 | this.removeElement(this.toastElement); 305 | }, 306 | 307 | // Removing the element from the DOM 308 | removeElement: function(toastElement) { 309 | // Hiding the element 310 | // toastElement.classList.remove("on"); 311 | toastElement.className = toastElement.className.replace(" on", ""); 312 | 313 | // Removing the element from DOM after transition end 314 | window.setTimeout( 315 | function() { 316 | // remove options node if any 317 | if (this.options.node && this.options.node.parentNode) { 318 | this.options.node.parentNode.removeChild(this.options.node); 319 | } 320 | 321 | // Remove the element from the DOM, only when the parent node was not removed before. 322 | if (toastElement.parentNode) { 323 | toastElement.parentNode.removeChild(toastElement); 324 | } 325 | 326 | // Calling the callback function 327 | this.options.callback.call(toastElement); 328 | 329 | // Repositioning the toasts again 330 | Toastify.reposition(); 331 | }.bind(this), 332 | 400 333 | ); // Binding `this` for function invocation 334 | }, 335 | }; 336 | 337 | // Positioning the toasts on the DOM 338 | Toastify.reposition = function() { 339 | 340 | // Top margins with gravity 341 | var topLeftOffsetSize = { 342 | top: 15, 343 | bottom: 15, 344 | }; 345 | var topRightOffsetSize = { 346 | top: 15, 347 | bottom: 15, 348 | }; 349 | var offsetSize = { 350 | top: 15, 351 | bottom: 15, 352 | }; 353 | 354 | // Get all toast messages on the DOM 355 | var allToasts = document.getElementsByClassName("toastify"); 356 | 357 | var classUsed; 358 | 359 | // Modifying the position of each toast element 360 | for (var i = 0; i < allToasts.length; i++) { 361 | // Getting the applied gravity 362 | if (containsClass(allToasts[i], "toastify-top") === true) { 363 | classUsed = "toastify-top"; 364 | } else { 365 | classUsed = "toastify-bottom"; 366 | } 367 | 368 | var height = allToasts[i].offsetHeight; 369 | classUsed = classUsed.substr(9, classUsed.length-1) 370 | // Spacing between toasts 371 | var offset = 15; 372 | 373 | var width = window.innerWidth > 0 ? window.innerWidth : screen.width; 374 | 375 | // Show toast in center if screen with less than or equal to 360px 376 | if (width <= 360) { 377 | // Setting the position 378 | allToasts[i].style[classUsed] = offsetSize[classUsed] + "px"; 379 | 380 | offsetSize[classUsed] += height + offset; 381 | } else { 382 | if (containsClass(allToasts[i], "toastify-left") === true) { 383 | // Setting the position 384 | allToasts[i].style[classUsed] = topLeftOffsetSize[classUsed] + "px"; 385 | 386 | topLeftOffsetSize[classUsed] += height + offset; 387 | } else { 388 | // Setting the position 389 | allToasts[i].style[classUsed] = topRightOffsetSize[classUsed] + "px"; 390 | 391 | topRightOffsetSize[classUsed] += height + offset; 392 | } 393 | } 394 | } 395 | 396 | // Supporting function chaining 397 | return this; 398 | }; 399 | 400 | // Helper function to get offset. 401 | function getAxisOffsetAValue(axis, options) { 402 | 403 | if(options.offset[axis]) { 404 | if(isNaN(options.offset[axis])) { 405 | return options.offset[axis]; 406 | } 407 | else { 408 | return options.offset[axis] + 'px'; 409 | } 410 | } 411 | 412 | return '0px'; 413 | 414 | } 415 | 416 | function containsClass(elem, yourClass) { 417 | if (!elem || typeof yourClass !== "string") { 418 | return false; 419 | } else if ( 420 | elem.className && 421 | elem.className 422 | .trim() 423 | .split(/\s+/gi) 424 | .indexOf(yourClass) > -1 425 | ) { 426 | return true; 427 | } else { 428 | return false; 429 | } 430 | } 431 | 432 | // Setting up the prototype for the init object 433 | Toastify.lib.init.prototype = Toastify.lib; 434 | 435 | // Returning the Toastify function to be assigned to the window object/module 436 | return Toastify; 437 | }); 438 | -------------------------------------------------------------------------------- /content_script.js: -------------------------------------------------------------------------------- 1 | (function () 2 | { 3 | console.log("----- [content_script.js] LOADED"); 4 | 5 | const CONSOLE_LOGGING = true; 6 | if (!CONSOLE_LOGGING) console.log = function () { }; 7 | 8 | // 9 | // GLOBALS 10 | // 11 | 12 | // NOTE : None of this will be presist between page loads 13 | let llw_screenshot_filenames = []; 14 | let llw_audio_filenames = []; 15 | 16 | let llw_saved_words = []; // NOTE : On extension removal, this stored list will be lost! 17 | let llw_highlight_colour = ""; 18 | let llw_highlight_words = false; 19 | let llw_pause_on_saved_word = false; 20 | 21 | const llw_anki_btn = document.createElement("div"); 22 | llw_anki_btn.className = "llw_anki_btn lln-external-dict-btn tippy"; 23 | llw_anki_btn.innerHTML = "Anki"; 24 | llw_anki_btn.setAttribute("data-tippy-content", "Send to Anki"); 25 | 26 | const llw_remove_highlight_word_btn = document.createElement("div"); 27 | llw_remove_highlight_word_btn.className = "llw_remove_highlight_word_btn-btn lln-external-dict-btn tippy"; 28 | llw_remove_highlight_word_btn.innerHTML = "RC"; 29 | llw_remove_highlight_word_btn.setAttribute("data-tippy-content", "Remove word from being highlighted"); 30 | llw_remove_highlight_word_btn.onclick = Highlight_Words_Remove_Word; 31 | 32 | const llw_remove_audio_btn = document.createElement("div"); 33 | llw_remove_audio_btn.className = "llw_remove_audio_btn-btn lln-external-dict-btn tippy"; 34 | llw_remove_audio_btn.innerHTML = "RA"; 35 | llw_remove_audio_btn.setAttribute("data-tippy-content", "Remove audio for current sub from the cache"); 36 | llw_remove_audio_btn.onclick = Subtitle_Audio_Remove; 37 | 38 | // 39 | // STARTUP 40 | // 41 | 42 | if (document.readyState === "loading") 43 | { 44 | document.addEventListener("DOMContentLoaded", () => Init()); 45 | } 46 | else 47 | { 48 | Init(); 49 | } 50 | 51 | function Init() 52 | { 53 | let llw_search_class_name = ""; 54 | 55 | if (window.location.href.includes("netflix")) 56 | { 57 | llw_search_class_name = "lln-netflix"; 58 | } 59 | else if (window.location.href.includes("youtube")) 60 | { 61 | llw_search_class_name = "lln-youtube"; 62 | } 63 | else 64 | { 65 | alert("Wrong website!"); 66 | return; 67 | } 68 | 69 | // We loop for the body to had the correct "lln" class name set 70 | let check_dict_wrap_exists = setInterval(function () 71 | { 72 | const lln_element = document.getElementsByClassName(llw_search_class_name)[0]; 73 | if (lln_element) 74 | { 75 | clearInterval(check_dict_wrap_exists); 76 | console.log(`'${llw_search_class_name}' class has been found!`) 77 | 78 | Add_Functions_To_Side_Bar_Subs(); 79 | Highlight_Words_Setup(); 80 | } 81 | }, 100); 82 | } 83 | 84 | // 85 | // DICTIONARY SETUP 86 | // 87 | 88 | function Add_Anki_Button_To_Popup_Dictionary() 89 | { 90 | if (document.getElementsByClassName('llw_anki_btn').length) 91 | { 92 | console.log("The Anki button is somewhere, so we wont add it again"); 93 | return; 94 | } 95 | 96 | const btn_location = document.getElementsByClassName('lln-external-dicts-container')[0]; 97 | if (!btn_location) 98 | { 99 | console.log("Error finding element 'lln-external-dicts-container', unable to add the Anki button"); 100 | return; 101 | } 102 | 103 | const popup_dict_element = document.getElementsByClassName('lln-full-dict')[0]; 104 | if (!popup_dict_element) 105 | { 106 | console.log("Error finding element 'lln-full-dict', unable to add the Anki button"); 107 | return; 108 | } 109 | 110 | llw_anki_btn.onclick = popup_dict_element.classList.contains("right") ? 111 | Handle_Jump_To_Subtitle_With_Sidebar : 112 | Subtitle_Dictionary_Get_Data; 113 | 114 | btn_location.append(llw_anki_btn, llw_remove_highlight_word_btn, llw_remove_audio_btn); 115 | 116 | console.log("Anki button has been added!!"); 117 | } 118 | 119 | function Handle_Jump_To_Subtitle_With_Sidebar() 120 | { 121 | // When we click a word in the sidebar that is not the active subtitle, we will 122 | // will jump to that subtitle in the video, pause, then send the relevant data 123 | // to anki 124 | console.log("[Handle_Subtitle_Dictionary] Side Bar Dictionary has been clicked...") 125 | 126 | // If there is no current "active" subtitle, then we cannot remove the "active" 127 | let active_element = document.querySelector('.lln-vertical-view-sub.lln-with-play-btn.active'); 128 | if (active_element) 129 | { 130 | active_element.classList.remove("active") 131 | } 132 | 133 | const active_side_bar_subtitile = document.getElementsByClassName('anki-active-sidebar-sub'); 134 | if (active_side_bar_subtitile.length) 135 | { 136 | // Get the video element 137 | // NOTE : This might be different on Netflix... 138 | const video_element = document.getElementsByTagName('video')[0]; 139 | 140 | // Add "active" to the current "anki-active-sidebar-sub", "anki-active-sidebar-sub" is set when we 141 | // click on a word in the sidebar 142 | active_side_bar_subtitile[0].classList.add("active"); 143 | 144 | // Jump video to the subtitle with the word we want 145 | document.querySelector('.anki-onclick.active').click(); 146 | 147 | console.log("[Handle_Jump_To_Subtitle_With_Sidebar] pause the video!") 148 | video_element.pause(); 149 | 150 | let checkExist = setInterval(function () 151 | { 152 | // Wait for the video to jump to time and be paused... 153 | console.log("[Handle_Jump_To_Subtitle_With_Sidebar] Video state = " + video_element.readyState) 154 | if (video_element.readyState === 4) 155 | { 156 | clearInterval(checkExist) 157 | Subtitle_Dictionary_Get_Data(); 158 | } 159 | }, 250); 160 | } 161 | else 162 | { 163 | console.warn("Handle_Jump_To_Subtitle_With_Sidebar - Error with 'anki-active-sidebar-sub'"); 164 | } 165 | } 166 | 167 | function Add_Functions_To_Side_Bar_Subs() 168 | { 169 | console.log("[Add_Functions_To_Side_Bar_Subs] Adding all 'onclick' events...") 170 | 171 | let wait_for_subtitle_list = setInterval(function () 172 | { 173 | const sub_list_element = document.getElementById("lln-vertical-view-subs"); 174 | if (sub_list_element) 175 | { 176 | clearInterval(wait_for_subtitle_list); 177 | 178 | const sub_list_observer = new MutationObserver(function (mutations) 179 | { 180 | mutations.forEach(function (mutation) 181 | { 182 | let elements_in_view = document.querySelectorAll('.in-scroll'); 183 | 184 | elements_in_view.forEach(function (element) 185 | { 186 | if (!element.classList.contains("anki-onclick")) 187 | { 188 | element.classList.add("anki-onclick"); 189 | element.onclick = function (event) 190 | { 191 | const parent_with_data_index = event.target.parentNode.parentNode; 192 | 193 | // Set current "data-index" as the "anki-active-sidebar-sub" 194 | if (parent_with_data_index.classList.contains("anki-active-sidebar-sub")) 195 | return; 196 | 197 | // We need to search for any element other than the current one that has 198 | // the classname 'anki-active-sidebar-sub' 199 | const elem_with_anki_active = document.getElementsByClassName("anki-active-sidebar-sub")[0]; 200 | 201 | if (elem_with_anki_active) 202 | { 203 | elem_with_anki_active.classList.remove("anki-active-sidebar-sub") 204 | } 205 | parent_with_data_index.classList.add("anki-active-sidebar-sub"); 206 | }; 207 | } 208 | }); 209 | }); 210 | }); 211 | 212 | sub_list_observer.observe(sub_list_element, { attributes: true, attributeFilter: ['class'] }); 213 | } 214 | }, 100); 215 | } 216 | 217 | function Get_Video_URL() 218 | { 219 | let time_stamped_url = "url_here"; 220 | let video_id = "1234"; 221 | let current_time = 0; 222 | 223 | if (window.location.href.includes("youtube.com/watch")) 224 | { 225 | const rawid = window.location.search.split('v=')[1]; 226 | const ampersand_position = rawid.indexOf('&'); 227 | 228 | if (ampersand_position != -1) 229 | { 230 | video_id = rawid.substring(0, ampersand_position); 231 | } 232 | else 233 | { 234 | video_id = rawid; 235 | } 236 | 237 | const video_element = document.getElementsByClassName("video-stream")[0]; 238 | if (!video_element) 239 | { 240 | console.warn("Get_Video_URL YT: Missing video element!"); 241 | } 242 | else 243 | { 244 | current_time = video_element.currentTime; 245 | 246 | // example: https://youtu.be/RksaXQ4C1TA?t=123 247 | time_stamped_url = "https://youtu.be/" + video_id + "?t=" + current_time.toFixed(); 248 | } 249 | } 250 | else if (window.location.href.includes("netflix.com/watch")) 251 | { 252 | const page_url = window.location.href; 253 | const pattern = /(?:title|watch)\/(\d+)/; 254 | const match = page_url.match(pattern); 255 | 256 | if (match && match[1]) 257 | { 258 | video_id = match[1]; 259 | } 260 | 261 | const video_element = document.querySelector("video"); 262 | if (!video_element) 263 | { 264 | console.warn("Get_Video_URL NF: Missing video element!"); 265 | } 266 | else 267 | { 268 | current_time = video_element.currentTime; 269 | 270 | // https://www.netflix.com/watch/70196252?t=349 271 | time_stamped_url = "https://www.netflix.com/watch/" + video_id + "?t=" + current_time.toFixed(); 272 | } 273 | } 274 | else 275 | { 276 | console.error("What website are we on?"); 277 | } 278 | 279 | return [time_stamped_url, video_id, current_time]; 280 | } 281 | 282 | 283 | function Get_Screenshot() 284 | { 285 | if (window.location.href.includes("youtube.com/watch")) 286 | { 287 | const canvas = document.createElement('canvas'); 288 | const video = document.getElementsByTagName('video')[0]; 289 | const ctx = canvas.getContext('2d'); 290 | 291 | // Change the size here 292 | canvas.width = 640; 293 | canvas.height = 360; 294 | 295 | ctx.drawImage(video, 0, 0, 640, 360); 296 | 297 | let image_data = canvas.toDataURL("image/png"); 298 | image_data = image_data.replace(/^data:image\/(png|jpg);base64,/, "") 299 | 300 | return Promise.resolve(image_data); 301 | } 302 | else if (window.location.href.includes("netflix.com/watch")) 303 | { 304 | const dictionary_element = document.getElementsByClassName('lln-full-dict')[0]; 305 | const extern_dict_row_element = document.getElementsByClassName('lln-external-dicts-row')[0]; 306 | 307 | dictionary_element.style.visibility = "hidden"; 308 | extern_dict_row_element.style.visibility = "hidden"; 309 | 310 | console.log('Dictionary Element is now hidden, I hope'); 311 | 312 | return new Promise(function (resolve, reject) 313 | { 314 | setTimeout(function () 315 | { 316 | chrome.runtime.sendMessage({ action: 'captureVisibleTab' }, function (response) 317 | { 318 | if (response && response.imageData) 319 | { 320 | // Do we need to rest this as visible again? 321 | dictionary_element.style.visibility = "visible"; 322 | extern_dict_row_element.style.visibility = "visible"; 323 | 324 | const img = new Image(); 325 | img.onload = function () 326 | { 327 | const image_data = img.src.replace(/^data:image\/(png|jpg);base64,/, ""); 328 | resolve(image_data); 329 | }; 330 | img.src = response.imageData; 331 | } 332 | else 333 | { 334 | reject(new Error('Failed to capture image data')); 335 | } 336 | }); 337 | }, 500); 338 | }); 339 | } 340 | 341 | return Promise.resolve([100, 100, 0]); 342 | } 343 | 344 | async function Get_Audio() 345 | { 346 | let video_element = null; 347 | 348 | // NOTE : URL and Screenshot functions use this too, set global instead? 349 | if (window.location.href.includes("youtube.com/watch")) 350 | { 351 | video_element = document.getElementsByTagName('video')[0]; 352 | } 353 | else if (window.location.href.includes("netflix.com/watch")) 354 | { 355 | // TODO : Netflix audio collecting 356 | //video_element = document.querySelector("video"); 357 | return null; 358 | } 359 | 360 | if (!video_element) 361 | { 362 | console.warn("No video element found to get audio"); 363 | return null; 364 | } 365 | 366 | const audio_play_button = document.getElementsByClassName('lln-subs-replay-btn')[0]; 367 | if (!audio_play_button) 368 | { 369 | console.warn("No subtitle audio play button!"); 370 | return null; 371 | } 372 | 373 | let auto_stop_initial_state = false; // Should we even bother saving this? 374 | let auto_pause_element = document.getElementsByClassName('lln-toggle')[0]; 375 | if (auto_pause_element) 376 | { 377 | auto_stop_initial_state = auto_pause_element.checked; 378 | if (!auto_stop_initial_state) 379 | { 380 | auto_pause_element.click() // Turn on autopause 381 | console.log("Autopause has been turned ON"); 382 | } 383 | } 384 | 385 | const stream = video_element.captureStream(); 386 | const audioStream = new MediaStream(stream.getAudioTracks()); 387 | 388 | const recorder = new MediaRecorder(audioStream); 389 | const chunks = []; 390 | 391 | recorder.ondataavailable = event => chunks.push(event.data); 392 | const audio_promise = new Promise((resolve, reject) => 393 | { 394 | recorder.onstop = () => 395 | { 396 | const blob = new Blob(chunks, { type: 'audio/webm' }); 397 | const reader = new FileReader(); 398 | reader.onloadend = () => 399 | { 400 | const audio_data = reader.result.split(',')[1]; 401 | resolve(audio_data); 402 | }; 403 | reader.readAsDataURL(blob); 404 | }; 405 | }); 406 | 407 | function onTimeUpdate() 408 | { 409 | if (video_element.paused && video_element.readyState === 4) 410 | { 411 | recorder.stop(); 412 | clearTimeout(audio_recording_timeout); 413 | 414 | console.log("Audio recording automatically stopped") 415 | video_element.removeEventListener('timeupdate', onTimeUpdate); 416 | 417 | if (!auto_stop_initial_state) 418 | { 419 | auto_pause_element.click() // Turn off autopause 420 | console.log("Autopause has been turned back OFF"); 421 | } 422 | video_element.pause(); 423 | } 424 | } 425 | 426 | video_element.addEventListener('timeupdate', onTimeUpdate); 427 | 428 | const audio_maximum_recording_time = 16; // seconds 429 | const audio_recording_timeout = setTimeout(() => 430 | { 431 | recorder.stop(); 432 | console.log(`Audio recording stopped after ${audio_maximum_recording_time} seconds`); 433 | 434 | video_element.removeEventListener('timeupdate', onTimeUpdate); 435 | 436 | if (!auto_stop_initial_state) 437 | { 438 | auto_pause_element.click(); // Turn off autopause 439 | console.log("Autopause has been turned back OFF"); 440 | } 441 | }, audio_maximum_recording_time * 1000); // ms = s * 1000 442 | 443 | console.log("Audio recording started"); 444 | audio_play_button.click(); 445 | recorder.start(); 446 | 447 | return audio_promise; 448 | } 449 | 450 | function Subtitle_Audio_Remove() 451 | { 452 | let sub_index = 0; 453 | const element = document.querySelector('#lln-subs'); 454 | if (element) 455 | { 456 | sub_index = element.dataset.index; 457 | } 458 | 459 | [video_url, video_id, video_current_time] = Get_Video_URL(); 460 | 461 | const audio_filename = `LLW_to_Anki_${video_id}_${sub_index}.webm`; 462 | 463 | llw_audio_filenames = llw_audio_filenames.filter(name => name !== audio_filename); 464 | } 465 | 466 | async function Subtitle_Dictionary_Get_Data() // This is where we pull all the data we want from the popup dictionary 467 | { 468 | const permission_data = '{"action":"requestPermission","version":6}'; 469 | 470 | // since we check for permission every call, it can also be used as a way to check if anki is open 471 | // then we will only collect the data for the card when anki is open 472 | 473 | chrome.storage.local.get( 474 | [ 475 | "ankiDeckNameSelected", 476 | "ankiNoteNameSelected", 477 | "ankiFieldScreenshotSelected", 478 | "ankiSubtitleSelected", 479 | "ankiSubtitleTranslation", 480 | "ankiWordSelected", 481 | "ankiBasicTranslationSelected", 482 | "ankiExampleSentencesSelected", 483 | "ankiOtherTranslationSelected", 484 | "ankiBaseFormSelected", 485 | "ankiAiAssistantSelected", 486 | "ankiAudioSelected", 487 | "ankiFieldURL", 488 | "ankiConnectUrl", 489 | "ankiExampleSentenceSource", 490 | "ankiHighLightSavedWords", 491 | ], 492 | async ({ 493 | ankiDeckNameSelected, 494 | ankiNoteNameSelected, 495 | ankiFieldScreenshotSelected, 496 | ankiSubtitleSelected, 497 | ankiSubtitleTranslation, 498 | ankiWordSelected, 499 | ankiBasicTranslationSelected, 500 | ankiExampleSentencesSelected, 501 | ankiOtherTranslationSelected, 502 | ankiBaseFormSelected, 503 | ankiAiAssistantSelected, 504 | ankiAudioSelected, 505 | ankiFieldURL, 506 | ankiConnectUrl, 507 | ankiExampleSentenceSource, 508 | ankiHighLightSavedWords, 509 | }) => 510 | { 511 | fetch(ankiConnectUrl, { // get this URL earlier and only do one permission check? 512 | method: "POST", 513 | body: permission_data, 514 | }) 515 | .then(async () => 516 | { 517 | console.log("[Subtitle_Dictionary_Get_Data] Getting Data for Anki..."); 518 | 519 | let card_data = {}; 520 | let image_data = {}; 521 | let audio_data = {}; 522 | 523 | [video_url, video_id, video_current_time] = Get_Video_URL(); 524 | if (video_current_time === 0) console.warn("We did not get a current time"); 525 | 526 | if (ankiFieldURL) 527 | { 528 | console.log("Fill ankiFieldURL"); 529 | 530 | card_data[ankiFieldURL] = video_url; 531 | } 532 | 533 | if (ankiFieldScreenshotSelected) 534 | { 535 | console.log("Fill ankiFieldScreenshotSelected"); 536 | 537 | const image_filename = `LLW_to_Anki_${video_id}_${video_current_time}.png`; 538 | 539 | if (!llw_screenshot_filenames.includes(image_filename)) 540 | { 541 | const captured_image_data = await Get_Screenshot(); 542 | if (captured_image_data) 543 | { 544 | llw_screenshot_filenames.push(image_filename); 545 | console.log(`${image_filename} added to screenshot list`); 546 | 547 | image_data['data'] = captured_image_data; 548 | image_data['filename'] = image_filename; 549 | 550 | card_data[ankiFieldScreenshotSelected] = ''; 551 | } 552 | else 553 | { 554 | console.log("We did not get anything back for the Screenshot data"); 555 | } 556 | } 557 | else 558 | { 559 | console.log(`${image_filename} already exists`); 560 | card_data[ankiFieldScreenshotSelected] = ''; 561 | } 562 | } 563 | 564 | // The popup dictionary window 565 | let selected_word = ""; 566 | let word_base_form = ""; 567 | const dict_context = document.getElementsByClassName('lln-dict-contextual'); 568 | if (dict_context.length) 569 | { 570 | // Get word selected, the word which is visible in the subtitle 571 | selected_word = dict_context[0].children[1].innerText; 572 | 573 | const selected_element = document.getElementsByClassName('lln-is-open-in-full-dict')[0]; 574 | if (selected_element) 575 | { 576 | const data_word_key = selected_element.getAttribute('data-word-key'); 577 | word_base_form = data_word_key.split('|')[1]; // "пешеходный" 578 | 579 | // NOTE : should we always be storing the saved word, regardless of highliting? 580 | console.log(`Adding base form to word list : ${word_base_form}`); 581 | if (!llw_saved_words.includes(word_base_form)) 582 | { 583 | llw_saved_words.push(word_base_form); 584 | 585 | Highlight_Words_Store(); 586 | } 587 | } 588 | 589 | 590 | if (ankiBaseFormSelected) 591 | { 592 | console.log("Fill ankiBaseFormSelected"); 593 | 594 | card_data[ankiBaseFormSelected] = word_base_form; 595 | } 596 | 597 | if (ankiWordSelected) 598 | { 599 | console.log("Fill ankiWordSelected"); 600 | 601 | card_data[ankiWordSelected] = selected_word; 602 | } 603 | 604 | // Get basic translation (this is top of the popup dic) 605 | if (ankiBasicTranslationSelected) 606 | { 607 | console.log("Fill ankiBasicTranslationSelected"); 608 | 609 | const translation_text = dict_context[0].innerText; // ex: '3k\nвпечатлениях\nimpressions' 610 | 611 | const translation_text_without_most_common_number = translation_text.split("\n").slice(1); // removing the 3k, 2k, 4k, from the translation 612 | 613 | card_data[ankiBasicTranslationSelected] = translation_text_without_most_common_number.join('
    '); // replace line brea '\n' with
    tag 614 | } 615 | } 616 | 617 | // Get full definition (this is the difinitions provided bellow the AI part) 618 | // base form is the form of the word found in a dictionary 619 | const full_definition_element = document.getElementsByClassName('lln-dict-section-full'); 620 | if (full_definition_element.length) 621 | { 622 | if (ankiOtherTranslationSelected) 623 | { 624 | console.log("Fill ankiOtherTranslationSelected"); 625 | 626 | card_data[ankiOtherTranslationSelected] = full_definition_element[0].innerHTML; 627 | } 628 | 629 | // TODO : we can get the word grammar form here: "noun", "verb"... 630 | //if (ankiGrammarForm) 631 | //{ 632 | // const grammar = full_definition_element[0].querySelector("span"); 633 | 634 | // card_data[ankiOtherTranslationSelected] = grammar.innerText; 635 | //} 636 | } 637 | 638 | if (ankiSubtitleSelected) 639 | { 640 | console.log("Fill ankiSubtitleSelected"); 641 | 642 | const subtitle_element = document.getElementsByClassName('lln-subs'); 643 | if (subtitle_element.length) 644 | { 645 | const subtitle = subtitle_element[0].innerText; 646 | 647 | card_data[ankiSubtitleSelected] = subtitle; 648 | 649 | if (selected_word) // If we are storing the word too, we will highlight in the subtitle 650 | { 651 | // Make selected word bold in the subtitles, might not work for all languages :( 652 | card_data[ankiSubtitleSelected] = subtitle.replace(new RegExp(`(?" + selected_word + ""); 653 | } 654 | } 655 | } 656 | 657 | // Get the translation text (will fail if its not loaded) 658 | if (ankiSubtitleTranslation) 659 | { 660 | console.log("Fill ankiSubtitleTranslation"); 661 | 662 | const subtitle_translation_element = document.getElementsByClassName('lln-whole-title-translation'); 663 | if (subtitle_translation_element.length) 664 | { 665 | card_data[ankiSubtitleTranslation] = subtitle_translation_element[0].innerText; 666 | } 667 | } 668 | 669 | // Getting Example sentences 670 | // There are two sets of example sentences, so we can choose between which set we want 671 | if (ankiExampleSentencesSelected) 672 | { 673 | console.log("Fill ankiExampleSentencesSelected, from", ankiExampleSentenceSource); 674 | 675 | const example_sentences_element = document.getElementsByClassName('lln-word-examples'); 676 | if (example_sentences_element.length) 677 | { 678 | let example_sentences_list = []; 679 | switch (ankiExampleSentenceSource) 680 | { 681 | case "Both": 682 | if (example_sentences_element[0]) 683 | { 684 | example_sentences_list = Array.from(example_sentences_element[0].children).slice(1); 685 | } 686 | if (example_sentences_element[1]) 687 | { 688 | const tmp = Array.from(example_sentences_element[1].children).slice(1); 689 | example_sentences_list = example_sentences_list.concat(tmp); 690 | } 691 | 692 | break; 693 | case "Current": 694 | if (example_sentences_element[0]) 695 | example_sentences_list = Array.from(example_sentences_element[0].children).slice(1); 696 | break; 697 | case "Tatoeba": 698 | if (example_sentences_element[1]) 699 | example_sentences_list = Array.from(example_sentences_element[1].children).slice(1); 700 | break; 701 | case "None": // fallthrough 702 | default: 703 | example_sentences_list = []; 704 | break; 705 | } 706 | 707 | console.log("Example sentences :", example_sentences_list); 708 | 709 | card_data[ankiExampleSentencesSelected] = ''; // initialize or we get a 'undefined' as first value 710 | 711 | example_sentences_list.forEach(element => 712 | { 713 | card_data[ankiExampleSentencesSelected] += element.innerText + "
    "; 714 | }); 715 | } 716 | } 717 | 718 | // Get audio for subtitle we are on, do it last, as sometimes we can run past the end of the 719 | // subtitle we want, then we end up saving the next subtitle instead. 720 | if (ankiAudioSelected) 721 | { 722 | console.log("Fill ankiAudioSelected"); 723 | 724 | let sub_index = 0; 725 | const element = document.querySelector('#lln-subs'); 726 | if (element) 727 | { 728 | sub_index = element.dataset.index; 729 | } 730 | const audio_filename = `LLW_to_Anki_${video_id}_${sub_index}.webm`; 731 | 732 | if (!llw_audio_filenames.includes(audio_filename)) 733 | { 734 | const audio_raw_data = await Get_Audio(); 735 | if (audio_raw_data) 736 | { 737 | llw_audio_filenames.push(audio_filename); 738 | console.log(`${audio_filename} added to audio list`); 739 | 740 | audio_data['data'] = audio_raw_data; 741 | audio_data['filename'] = audio_filename; 742 | 743 | card_data[ankiAudioSelected] = '[sound:' + audio_filename + ']'; 744 | } 745 | else 746 | { 747 | console.log("We did not get anything back for the Audio data") 748 | } 749 | } 750 | else 751 | { 752 | console.log(`${audio_filename} already exists.`); 753 | card_data[ankiAudioSelected] = '[sound:' + audio_filename + ']'; 754 | } 755 | } 756 | 757 | if (ankiAiAssistantSelected) 758 | { 759 | console.log("Fill ankiAiAssistantSelected"); 760 | 761 | const ai_element = document.getElementsByClassName('lexa-html')[0]; 762 | if (ai_element) 763 | { 764 | const ai_text = ai_element.textContent; 765 | card_data[ankiAiAssistantSelected] = ai_text; 766 | 767 | console.log(ai_text); 768 | } 769 | } 770 | 771 | console.log("Card data to send to Anki :", card_data); 772 | 773 | console.log("Audio Data :", audio_data); 774 | console.log("Image Data :", image_data); 775 | 776 | const anki_settings = { 777 | "deck": ankiDeckNameSelected, 778 | "note": ankiNoteNameSelected, 779 | "url": ankiConnectUrl || 'http://localhost:8765', 780 | } 781 | 782 | // TODO : if anki is not open, then all the data for screenshots, audio, card data is still 783 | // collected, we should check way earlier if it is open or not 784 | LLW_Send_Data_To_Anki(anki_settings, card_data, image_data, audio_data); 785 | }).catch((error) => 786 | { 787 | show_error_message("Permission Error, check Anki is open and extension has permission to connect to Anki (AnkiConnect config 'webCorsOriginList') :" + error); 788 | 789 | // Since Anki could be closed for this error to happen, it would be possible for the audio or screenshot data not to be sent to Anki, 790 | // resulting in the next time a card is made from the same subtitle, an audio or screenshot field is filled with a filename and no data. 791 | // We need to remove the filenames from our "cached" list 792 | 793 | //if (image_data && typeof image_data.filename === "string") 794 | //{ 795 | // const index = llw_screenshot_filenames.indexOf(image_data.filename); 796 | // if (index !== -1) 797 | // { 798 | // llw_screenshot_filenames.splice(index, 1); 799 | // } 800 | //} 801 | 802 | //if (audio_data && typeof audio_data.filename === "string") 803 | //{ 804 | // const index = llw_audio_filenames.indexOf(audio_data.filename); 805 | // if (index !== -1) 806 | // { 807 | // llw_audio_filenames.splice(index, 1); 808 | // } 809 | //} 810 | 811 | // NOTE : Should we remove the word from the highlight list? 812 | //if (fields && fields.ankiWordSelected) 813 | // Highlight_Words_Remove_Word(); 814 | }); 815 | 816 | }); 817 | } 818 | 819 | 820 | // 821 | // HIGHLIGHT WORDS 822 | // 823 | 824 | // Here we are checking if the user toggles the highlighting words setting, this will save the user 825 | // having to refresh the page to get or remove highlighting, same for the colour value 826 | chrome.storage.onChanged.addListener(function (changes, areaName) 827 | { 828 | if (areaName === 'local') // or 'sync' if using sync storage 829 | { 830 | // oldValue, newValue, are special words, dont change them 831 | for (let [key, { oldValue, newValue }] of Object.entries(changes)) 832 | { 833 | if (key === 'ankiHighLightSavedWords') 834 | { 835 | console.log(`${key} has changed from '${oldValue}' to '${newValue}'`); 836 | llw_highlight_words = newValue; 837 | } 838 | 839 | if (key === 'ankiHighLightColour') 840 | { 841 | console.log(`${key} has changed from '${oldValue}' to '${newValue}'`); 842 | llw_highlight_colour = newValue; 843 | } 844 | 845 | if (key === 'ankiPuaseOnSavedWord') 846 | { 847 | console.log(`${key} has changed from '${oldValue}' to '${newValue}'`); 848 | llw_pause_on_saved_word = newValue; 849 | } 850 | } 851 | } 852 | }); 853 | 854 | function Highlight_Words_Setup() 855 | { 856 | chrome.storage.local.get(['ankiHighlightWordList', 'ankiHighLightColour', 'ankiHighLightSavedWords', 'ankiPuaseOnSavedWord'], 857 | ({ ankiHighlightWordList, ankiHighLightColour, ankiHighLightSavedWords, ankiPuaseOnSavedWord }) => 858 | { 859 | llw_saved_words = ankiHighlightWordList || []; 860 | llw_highlight_colour = ankiHighLightColour || 'LightCoral'; 861 | llw_highlight_words = ankiHighLightSavedWords; 862 | llw_pause_on_saved_word = ankiPuaseOnSavedWord; 863 | 864 | if (!Array.isArray(llw_saved_words)) 865 | { 866 | console.error("llw_saved_words is not an array."); 867 | } 868 | 869 | console.log("Highlight word settings:", { llw_saved_words, llw_highlight_colour, llw_highlight_words, llw_pause_on_saved_word }); 870 | 871 | console.log("Waiting for subtitle content element..."); 872 | const wait_for_subtitles_to_show = setInterval(function () 873 | { 874 | const sub_content_element = document.getElementById('lln-subs-content'); 875 | if (sub_content_element) 876 | { 877 | console.log("Subtitle content element found!"); 878 | clearInterval(wait_for_subtitles_to_show); 879 | 880 | const observer = new MutationObserver((mutationList) => 881 | { 882 | for (const mutation of mutationList) 883 | { 884 | if (mutation.type === 'attributes') 885 | { 886 | const element = document.getElementById('lln-subs'); 887 | if (element) 888 | { 889 | console.log("We need to update the subtitle highlights"); 890 | 891 | Highlight_Words_In_Current_Subtitle(llw_highlight_words, llw_pause_on_saved_word); 892 | 893 | Add_Anki_Button_To_Popup_Dictionary(); 894 | break; 895 | } 896 | } 897 | } 898 | }); 899 | observer.observe(sub_content_element, { attributes: true, subtree: true }); 900 | } 901 | }, 100); 902 | } 903 | ); 904 | } 905 | 906 | function Highlight_Words_In_Current_Subtitle(should_highlight, should_pause) 907 | { 908 | const subtitle_element = document.getElementsByClassName('lln-subs')[0]; 909 | let a_saved_word_was_found_in_subtitle = false; 910 | 911 | subtitle_element.querySelectorAll('[data-word-key*="WORD|"]').forEach((element) => 912 | { 913 | const inner_word = element.innerText.toLowerCase(); 914 | 915 | const data_word_key = element.getAttribute('data-word-key'); 916 | const key_parts = data_word_key ? data_word_key.split('|') : []; 917 | const base_form_word = key_parts.length >= 2 ? key_parts[1].toLowerCase() : null; 918 | 919 | if (llw_saved_words.includes(inner_word) || (base_form_word && llw_saved_words.includes(base_form_word))) 920 | { 921 | if (should_highlight) element.style.color = llw_highlight_colour || 'LightCoral'; // #F08080 922 | a_saved_word_was_found_in_subtitle = true; 923 | } 924 | }); 925 | 926 | if (a_saved_word_was_found_in_subtitle && should_pause) 927 | { 928 | const auto_pause_element = document.getElementsByClassName('lln-toggle')[0]; 929 | if (auto_pause_element && !auto_pause_element.checked) 930 | { 931 | auto_pause_element.click(); 932 | console.log(`Autopause should be 'ON'`); 933 | 934 | // TODO : wait for video to pause then toggle it back to initial state? 935 | } 936 | } 937 | } 938 | 939 | function Highlight_Words_Store() 940 | { 941 | // NOTE : Does this need to be done everytime? 942 | //const unique_words_only = llw_saved_words.reduce((accumulator, current) => 943 | //{ 944 | // if (!accumulator.includes(current)) 945 | // { 946 | // accumulator.push(current); 947 | // } 948 | // return accumulator; 949 | //}, []); 950 | //llw_saved_words = unique_words_only; 951 | //chrome.storage.local.set({ ankiHighlightWordList: unique_words_only }); 952 | 953 | chrome.storage.local.set({ ankiHighlightWordList: llw_saved_words }); 954 | } 955 | 956 | function Highlight_Words_Remove_Word() 957 | { 958 | // 1 - Get the base form of the word 959 | // 2 - Remove from list 960 | // 3 - Update Store 961 | 962 | const selected_element = document.getElementsByClassName('lln-is-open-in-full-dict')[0]; 963 | if (selected_element) 964 | { 965 | const data_word_key = selected_element.getAttribute('data-word-key'); 966 | const base_word_form = data_word_key.split('|')[1]; // "пешеходный" 967 | 968 | const index = llw_saved_words.indexOf(base_word_form); 969 | if (index !== -1) 970 | { 971 | llw_saved_words.splice(index, 1); 972 | 973 | console.log(`Removed '${base_word_form}' from highlight list`); 974 | 975 | Highlight_Words_Store(); 976 | } 977 | else 978 | { 979 | console.log(`Cannot remove ${base_word_form} from highlight list`); 980 | } 981 | } 982 | else 983 | { 984 | console.log("Unable to find element 'lln-is-open-in-full-dict'"); 985 | } 986 | } 987 | 988 | // 989 | // SEND TO ANKI 990 | // 991 | 992 | function LLW_Send_Data_To_Anki(anki_settings, fields, image_data, audio_data) 993 | { 994 | console.log("Destination : ", anki_settings); 995 | 996 | if (Object.keys(fields).length === 0) 997 | { 998 | show_error_message("No fields were set, please set a field in the settings"); 999 | return; 1000 | } 1001 | 1002 | console.log("fields : ", fields); 1003 | 1004 | let actions = []; 1005 | 1006 | if (image_data.data) 1007 | { 1008 | console.log("Adding image to note :", image_data); 1009 | actions.push({ 1010 | "action": "storeMediaFile", 1011 | "params": { 1012 | "filename": image_data.filename, 1013 | "data": image_data.data 1014 | } 1015 | }); 1016 | } 1017 | 1018 | if (audio_data.data) 1019 | { 1020 | console.log("Adding audio to note :", audio_data); 1021 | actions.push({ 1022 | "action": "storeMediaFile", 1023 | "params": { 1024 | "filename": audio_data.filename, 1025 | "data": audio_data.data 1026 | } 1027 | }); 1028 | } 1029 | 1030 | actions.push({ 1031 | "action": "addNote", 1032 | "params": { 1033 | "note": { 1034 | "modelName": anki_settings.note, 1035 | "deckName": anki_settings.deck, 1036 | "fields": fields, 1037 | "tags": ["LLW_to_Anki"], 1038 | "options": { 1039 | "allowDuplicate": true, 1040 | } 1041 | } 1042 | } 1043 | }); 1044 | 1045 | console.log("actions : ", actions); 1046 | 1047 | const body = { 1048 | "action": "multi", 1049 | "params": { 1050 | "actions": actions 1051 | } 1052 | }; 1053 | 1054 | console.log("body : ", body); 1055 | 1056 | 1057 | fetch(anki_settings.url, { 1058 | method: "POST", 1059 | body: JSON.stringify(body), 1060 | }) 1061 | .then((res) => res.json()) 1062 | .then((data) => 1063 | { 1064 | console.log("Fetch Return : ", data); 1065 | let has_error = false; 1066 | 1067 | data.forEach((response, index) => 1068 | { 1069 | if (response.result === null) 1070 | { 1071 | show_error_message(`Error in response ${index + 1}: ${response.error}`); 1072 | has_error = true; 1073 | } 1074 | }); 1075 | 1076 | if (!has_error) 1077 | { 1078 | show_success_message(`Successfully added to ANKI`); 1079 | } 1080 | }) 1081 | .catch((error) => 1082 | { 1083 | show_error_message("Anki Post Error! " + error); 1084 | }) 1085 | } 1086 | 1087 | // 1088 | // DELICIOUS TOAST 1089 | // 1090 | 1091 | function show_success_message(message) 1092 | { 1093 | Toastify({ 1094 | text: message, 1095 | duration: 3000, 1096 | style: { 1097 | background: "light blue", 1098 | } 1099 | }).showToast(); 1100 | console.log(message); 1101 | } 1102 | 1103 | function show_error_message(message) 1104 | { 1105 | Toastify({ 1106 | text: message, 1107 | duration: 3000, 1108 | style: { 1109 | background: "red", 1110 | } 1111 | }).showToast(); 1112 | console.error(message); 1113 | } 1114 | 1115 | })(); 1116 | 1117 | --------------------------------------------------------------------------------