├── .gitignore ├── src ├── icons │ ├── icon16.png │ ├── icon24.png │ ├── icon48.png │ ├── icon96.png │ └── icon128.png ├── instructions │ ├── openTranscript.png │ ├── openTranscript.avif │ └── openTranscript.webp ├── popup │ ├── components │ │ ├── Loading.js │ │ ├── Instructions.js │ │ ├── About.js │ │ ├── Export.js │ │ ├── ExportAnki.js │ │ └── List.js │ ├── popup.html │ ├── state.js │ ├── utils.js │ ├── style.css │ ├── popup.js │ └── skruv │ │ ├── state.js │ │ ├── html.js │ │ └── vDOM.js ├── interfaces.d.ts ├── background.js ├── manifest.json ├── _locales │ ├── en │ │ └── messages.json │ └── es │ │ └── messages.json └── youtube2Anki.js ├── .github ├── FUNDING.yml └── workflows │ └── test.yml ├── .eslintrc.js ├── tsconfig.json ├── package.json ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | web-ext-artifacts/ 3 | -------------------------------------------------------------------------------- /src/icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dobladov/youtube2Anki/HEAD/src/icons/icon16.png -------------------------------------------------------------------------------- /src/icons/icon24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dobladov/youtube2Anki/HEAD/src/icons/icon24.png -------------------------------------------------------------------------------- /src/icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dobladov/youtube2Anki/HEAD/src/icons/icon48.png -------------------------------------------------------------------------------- /src/icons/icon96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dobladov/youtube2Anki/HEAD/src/icons/icon96.png -------------------------------------------------------------------------------- /src/icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dobladov/youtube2Anki/HEAD/src/icons/icon128.png -------------------------------------------------------------------------------- /src/instructions/openTranscript.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dobladov/youtube2Anki/HEAD/src/instructions/openTranscript.png -------------------------------------------------------------------------------- /src/instructions/openTranscript.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dobladov/youtube2Anki/HEAD/src/instructions/openTranscript.avif -------------------------------------------------------------------------------- /src/instructions/openTranscript.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dobladov/youtube2Anki/HEAD/src/instructions/openTranscript.webp -------------------------------------------------------------------------------- /src/popup/components/Loading.js: -------------------------------------------------------------------------------- 1 | import { h2 } from '../skruv/html.js' 2 | 3 | export const Loading = () => h2({}, 'Loading...') 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | custom: ["https://www.paypal.com/donate/?hosted_button_id=Z4D6849QVUXD2"] 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true 5 | }, 6 | extends: [ 7 | 'standard' 8 | ], 9 | parserOptions: { 10 | ecmaVersion: 12, 11 | sourceType: 'module' 12 | }, 13 | globals: { 14 | chrome: 'readonly' 15 | }, 16 | rules: { 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/interfaces.d.ts: -------------------------------------------------------------------------------- 1 | export interface Subtitle { 2 | time: number; 3 | nextTime?: number; 4 | text: string; 5 | prevText: string 6 | nextText: string; 7 | id: string; 8 | startSeconds: number; 9 | endSeconds: number; 10 | title: string; 11 | disabled?: boolean; 12 | hash: string; 13 | } 14 | -------------------------------------------------------------------------------- /src/popup/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Popup 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | types: [ 6 | opened, 7 | ready_for_review, 8 | reopened, 9 | synchronize, 10 | ] 11 | branches: 12 | - master 13 | 14 | jobs: 15 | statics: 16 | name: Test 17 | if: github.event.pull_request.draft == false 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v3 21 | - uses: actions/setup-node@v3 22 | with: 23 | node-version: '18' 24 | - run: npm ci -D 25 | - name: Statics 26 | run: npm run test 27 | 28 | -------------------------------------------------------------------------------- /src/popup/state.js: -------------------------------------------------------------------------------- 1 | import { createState } from './skruv/state.js' 2 | 3 | /** @type {State} */ 4 | export const state = createState({ 5 | view: 'loading', 6 | title: null, 7 | subtitles: null, 8 | deckNames: null, 9 | error: null, 10 | activeTabId: null, 11 | mergeStart: NaN, 12 | mergeEnd: NaN 13 | }) 14 | 15 | /** 16 | * @typedef {Object} State 17 | * @prop {string} view 18 | * @prop {number} activeTabId - tab to get and send information 19 | * @prop {string} title - used for the deck name 20 | * @prop {Subtitle[]} subtitles 21 | * @prop {string[]} deckNames 22 | * @prop {{message?: string}} error 23 | * @prop {number} mergeStart 24 | * @prop {number} mergeEnd 25 | */ 26 | 27 | /** 28 | * @typedef {import('../interfaces').Subtitle} Subtitle 29 | */ 30 | -------------------------------------------------------------------------------- /src/popup/components/Instructions.js: -------------------------------------------------------------------------------- 1 | import { div, h2, p, img, css, picture, source } from '../skruv/html.js' 2 | 3 | // @ts-ignore 4 | const style = css` 5 | img { 6 | max-width: 100%; 7 | } 8 | ` 9 | 10 | export const Instructions = () => div({}, 11 | h2({}, chrome.i18n.getMessage('instructionsTitle')), 12 | p({}, chrome.i18n.getMessage('instructionsDescription')), 13 | picture( 14 | {}, 15 | source({ 16 | srcset: chrome.runtime.getURL('instructions/openTranscript.avif'), 17 | type: 'image/avif' 18 | }), 19 | source({ 20 | srcset: chrome.runtime.getURL('instructions/openTranscript.webp'), 21 | type: 'image/webp' 22 | }), 23 | img({ 24 | src: chrome.runtime.getURL('instructions/openTranscript.png') 25 | }) 26 | ), 27 | style 28 | ) 29 | -------------------------------------------------------------------------------- /src/background.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Enables the extension if the context is YouTube 3 | * 4 | * @param {number} id 5 | * @param {string} url 6 | */ 7 | const enableDisableTab = (id, url) => { 8 | const re = /(http|https):\/\/(www.)?youtube\.com\\*/ 9 | if (re.test(url)) { 10 | chrome.action.enable(id) 11 | } else { 12 | chrome.action.disable(id) 13 | } 14 | } 15 | 16 | chrome.tabs.onActivated.addListener(() => { 17 | chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { 18 | const [activeTab] = tabs 19 | const { url, id } = activeTab 20 | id && url && enableDisableTab(id, url) 21 | }) 22 | }) 23 | 24 | chrome.tabs.onUpdated.addListener((id, changeInfo, tab) => { 25 | if (changeInfo.status === 'complete') { 26 | id && tab?.url && enableDisableTab(id, tab.url) 27 | } 28 | }) 29 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "__MSG_extensionName__", 4 | "version": "1.4.0", 5 | "default_locale": "en", 6 | 7 | "description": "__MSG_extensionDescription__", 8 | 9 | "author": "Daniel Doblado", 10 | "homepage_url": "https://github.com/dobladov/youtube2Anki", 11 | 12 | "icons": { 13 | "16": "icons/icon16.png", 14 | "24": "icons/icon24.png", 15 | "48": "icons/icon48.png", 16 | "96": "icons/icon96.png" 17 | }, 18 | 19 | "action": { 20 | "browser_style": true, 21 | "default_icon": "icons/icon48.png", 22 | "default_title": "__MSG_exportTitle__", 23 | "default_popup": "popup/popup.html" 24 | }, 25 | 26 | "background": { 27 | "service_worker": "background.js" 28 | }, 29 | 30 | "permissions": [ 31 | "tabs", 32 | "notifications" 33 | ], 34 | 35 | "content_scripts": [ 36 | { 37 | "matches": ["*://*.youtube.com/*"], 38 | "js": ["youtube2Anki.js"], 39 | "run_at": "document_start" 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /src/popup/components/About.js: -------------------------------------------------------------------------------- 1 | import { div, a, css } from '../skruv/html.js' 2 | 3 | // @ts-ignore 4 | const style = css` 5 | .about { 6 | text-align: center; 7 | display: flex; 8 | justify-content: space-around; 9 | list-style-type: none; 10 | margin: 0; 11 | padding: 0; 12 | padding-top: .4rem; 13 | } 14 | 15 | .aboutItem { 16 | text-align: center; 17 | display: block; 18 | flex: 1; 19 | } 20 | ` 21 | 22 | export const About = () => 23 | div({ class: 'about' }, [ 24 | a({ 25 | target: '_blank', 26 | class: 'aboutItem', 27 | href: 'https://www.paypal.com/donate/?hosted_button_id=Z4D6849QVUXD2' 28 | }, chrome.i18n.getMessage('aboutDonate')), 29 | a({ 30 | target: '_blank', 31 | class: 'aboutItem', 32 | href: 'https://github.com/dobladov/youtube2Anki/issues' 33 | }, chrome.i18n.getMessage('aboutIssues')), 34 | a({ 35 | target: '_blank', 36 | class: 'aboutItem', 37 | href: 'https://github.com/dobladov/youtube2Anki/discussions/categories/ideas' 38 | }, chrome.i18n.getMessage('aboutSuggestions')) 39 | ], style) 40 | -------------------------------------------------------------------------------- /src/popup/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a new notification with the given parameters 3 | * 4 | * @param {string} title 5 | * @param {string} message 6 | * @param {VoidFunction} [callback] 7 | */ 8 | export const sendNotification = (title, message, callback) => { 9 | const id = '_' + Math.random().toString(36).substr(2, 9) 10 | 11 | chrome.notifications.create(id, { 12 | type: 'basic', 13 | iconUrl: chrome.runtime.getURL('icons/icon128.png'), 14 | title, 15 | message 16 | }, () => { 17 | callback && callback() 18 | }) 19 | } 20 | 21 | /** 22 | * Gives only the subtitles that are enabled 23 | * 24 | * @param {Subtitle[]} subtitles 25 | */ 26 | export const getEnabledSubtitles = (subtitles) => { 27 | return subtitles.filter(item => !item.disabled) 28 | } 29 | 30 | /** 31 | * Extracts and returns the id of a YouTube url 32 | * 33 | * @param {string} url 34 | */ 35 | export const getId = (url) => { 36 | const match = url.match(/^.*(youtu.be\/|v\/|e\/|u\/\w+\/|embed\/|v=)(?[^#&?]*).*/) 37 | return match?.groups?.id 38 | } 39 | 40 | /** 41 | * @typedef {import('../interfaces').Subtitle} Subtitle 42 | */ 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 4 | "module": "esnext", /* Specify what module code is generated. */ 5 | "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 6 | "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 7 | "noEmit": true, /* Disable emitting files from a compilation. */ 8 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 9 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 10 | "strict": true, /* Enable all strict type-checking options. */ 11 | "skipLibCheck": true, /* Skip type checking all .d.ts files. */ 12 | "moduleResolution": "node", 13 | "noUnusedParameters": true, 14 | "noUnusedLocals": true, 15 | }, 16 | } 17 | -------------------------------------------------------------------------------- /src/popup/components/Export.js: -------------------------------------------------------------------------------- 1 | import { div, css, h2, button, p } from '../skruv/html.js' 2 | 3 | import { state as mainState } from '../state.js' 4 | import { ExportAnki } from './ExportAnki.js' 5 | import { getEnabledSubtitles } from '../utils.js' 6 | 7 | // @ts-ignore 8 | const styling = css` 9 | .container { 10 | display: flex; 11 | flex-direction: column; 12 | gap: 20px; 13 | } 14 | 15 | .card { 16 | padding: 20px; 17 | } 18 | ` 19 | 20 | export const Export = () => div( 21 | { 22 | class: 'container' 23 | }, 24 | button( 25 | { 26 | class: 'btn', 27 | onclick: () => { 28 | mainState.view = 'list' 29 | } 30 | }, chrome.i18n.getMessage('exportEditCards') 31 | ), 32 | ExportAnki, 33 | div( 34 | { 35 | class: 'card' 36 | }, 37 | h2({}, chrome.i18n.getMessage('exportExportTitle')), 38 | p({}, chrome.i18n.getMessage('exportExportDescription')), 39 | button({ 40 | class: 'btn', 41 | onclick: () => { 42 | chrome.tabs.sendMessage(mainState.activeTabId, 43 | { 44 | type: 'download', 45 | title: mainState.title, 46 | subtitles: getEnabledSubtitles(mainState.subtitles.map(v => ({ ...v }))) 47 | } 48 | ) 49 | } 50 | }, chrome.i18n.getMessage('exportExportDownload')) 51 | ), 52 | styling 53 | ) 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "youtube2anki", 3 | "version": "1.4.0", 4 | "description": "[![Firefox](https://img.shields.io/amo/v/youtube2anki.svg?label=Firefox)](https://addons.mozilla.org/en-US/firefox/addon/youtube2anki/) [![Chrome](https://img.shields.io/chrome-web-store/v/boebbbjmbikafafhoelhdjeocceddngi.svg?color=%234A88EE&label=Chrome)](https://chrome.google.com/webstore/detail/youtube2anki/boebbbjmbikafafhoelhdjeocceddngi) [![License](https://img.shields.io/github/license/dobladov/youtube2anki.svg?color=%23B70000)](https://github.com/dobladov/youtube2Anki/blob/master/LICENSE) [![PayPal](https://img.shields.io/badge/Support%20this%20project-PayPal-009CDE.svg)](https://www.paypal.me/dobladov)", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "npm run lint && npm run types", 8 | "lint": "eslint .", 9 | "types": "tsc", 10 | "build": "npx web-ext build -s src/" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/dobladov/youtube2Anki.git" 15 | }, 16 | "keywords": [ 17 | "anki" 18 | ], 19 | "author": "Daniel Doblado", 20 | "license": "GPL3", 21 | "bugs": { 22 | "url": "https://github.com/dobladov/youtube2Anki/issues" 23 | }, 24 | "homepage": "https://github.com/dobladov/youtube2Anki#readme", 25 | "devDependencies": { 26 | "@types/chrome": "0.0.193", 27 | "eslint": "8.21.0", 28 | "eslint-config-standard": "17.0.0", 29 | "eslint-plugin-import": "2.26.0", 30 | "eslint-plugin-node": "11.1.0", 31 | "eslint-plugin-promise": "6.0.0", 32 | "typescript": "4.7.4" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/popup/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --main-bg-color: #F4F6FA; 3 | --action-color: #ec6d6d; 4 | --action-hover-color: #d63f3f; 5 | --card-bg-color: #F2F2F7; 6 | --card-bg-hover-color: #FEFEFE; 7 | --p-color: #5B6171; 8 | --invalid-color: #ffe1e7; 9 | --action-disabled-color: #d8d8d8; 10 | } 11 | 12 | html { 13 | box-sizing: border-box; 14 | } 15 | 16 | *, *:before, *:after { 17 | box-sizing: inherit; 18 | } 19 | 20 | a { 21 | color: var(--action-color); 22 | font-weight: 400; 23 | text-decoration: none; 24 | } 25 | 26 | a:hover, 27 | a:focus { 28 | color: var(--action-hover-color); 29 | outline-color: var(--action-color); 30 | } 31 | 32 | body { 33 | min-width: 400px; 34 | max-width: 450px; 35 | background-color: var(--main-bg-color); 36 | font-family: futura-pt, sans-serif,sans-serif; 37 | color: #3c3c3c; 38 | padding: 1rem; 39 | padding-top: 1.2rem; 40 | padding-bottom: 0.5rem; 41 | margin: 0; 42 | } 43 | 44 | .card { 45 | background-color: var(--card-bg-color); 46 | box-shadow: 0 -5px 28px rgba(0,0,0,0.15), 0 10px 10px rgba(0,0,0,0.12); 47 | } 48 | 49 | .card:hover { 50 | background-color: var(--card-bg-hover-color); 51 | } 52 | 53 | .btn { 54 | background-color: var(--action-color); 55 | border: none; 56 | color: var(--card-bg-hover-color); 57 | padding: 10px 20px; 58 | border-radius: 10px; 59 | font-weight: 600; 60 | font-size: 13px; 61 | cursor: pointer; 62 | outline-color: var(--action-color); 63 | } 64 | 65 | .btn:hover, 66 | .btn:focus { 67 | background-color: var(--action-hover-color); 68 | } 69 | 70 | .btn:disabled { 71 | background-color: var(--action-disabled-color); 72 | } 73 | 74 | h2 { 75 | margin: 0; 76 | color: var(--action-color); 77 | font-weight: 300; 78 | font-size: 22px; 79 | } 80 | 81 | p { 82 | font-weight: 300; 83 | font-size: 17px; 84 | color: var(--p-color); 85 | } 86 | 87 | input[type=text] { 88 | border: 2px solid var(--action-color); 89 | padding: 5px; 90 | width: 100%; 91 | background-color: transparent; 92 | outline: none; 93 | border-radius: 5px; 94 | position: relative; 95 | } 96 | 97 | label { 98 | display: block; 99 | padding-bottom: 0.5rem; 100 | } 101 | 102 | input[type=text]:focus { 103 | border-color: var(--action-hover-color); 104 | } 105 | 106 | .invalid { 107 | color: var(--action-color); 108 | } 109 | 110 | .hide { 111 | display: none; 112 | } 113 | -------------------------------------------------------------------------------- /src/_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "exportTitle": { 3 | "message": "Export to Anki CSV" 4 | }, 5 | "extensionDescription": { 6 | "message": "Convert YouTube Transcripts to Anki cards" 7 | }, 8 | "extensionName": { 9 | "message": "Youtube2Anki" 10 | }, 11 | "instructionsTitle": { 12 | "message": "Transcript not found" 13 | }, 14 | "instructionsDescription": { 15 | "message": "Please, open the transcript of the video" 16 | }, 17 | "aboutDonate": { 18 | "message": "Donate" 19 | }, 20 | "aboutIssues": { 21 | "message": "Issues" 22 | }, 23 | "aboutSuggestions": { 24 | "message": "Suggestions" 25 | }, 26 | "listDeleteSavedCards": { 27 | "message": "Delete saved cards" 28 | }, 29 | "listDeleteSavedCardsTitle": { 30 | "message": "Delete stored cards and get new ones" 31 | }, 32 | "listSelect": { 33 | "message": "Select" 34 | }, 35 | "listSelectAll": { 36 | "message": "All" 37 | }, 38 | "listSelectNone": { 39 | "message": "None" 40 | }, 41 | "listSelectRandom": { 42 | "message": "Random" 43 | }, 44 | "listMerge": { 45 | "message": "Merge" 46 | }, 47 | "listMergeStop": { 48 | "message": "Stop merge" 49 | }, 50 | "listMergeCards": { 51 | "message": "Merge $1 cards" 52 | }, 53 | "listExportCards": { 54 | "message": "Export $1 cards" 55 | }, 56 | "listExportCardsMinimum": { 57 | "message": "⚠️ Select at least 1 card" 58 | }, 59 | "exportEditCards": { 60 | "message": "Edit cards" 61 | }, 62 | "exportSendToAnkiTitle": { 63 | "message": "Send to Anki" 64 | }, 65 | "exportSendToAnkiDescription1": { 66 | "message": "Use " 67 | }, 68 | "exportSendToAnkiDescription2": { 69 | "message": " to add cards directly to a deck" 70 | }, 71 | "exportSendToAnkiError": { 72 | "message": "⚠️ It's not possible to connect with Anki, make sure it's running" 73 | }, 74 | "exportSendToAnkiReconnect": { 75 | "message": "Reconnect" 76 | }, 77 | "exportSendToAnkiDeckName": { 78 | "message": "Deck name" 79 | }, 80 | "exportSendToAnkiSend": { 81 | "message": "Send" 82 | }, 83 | "exportSendToAnkiErrorCreating": { 84 | "message": "⚠️ Error creating the cards" 85 | }, 86 | "exportSendToAnkiNotificationTitle": { 87 | "message": "✅ Success" 88 | }, 89 | "exportSendToAnkiNotificationDescription": { 90 | "message": "Added $1 new cards" 91 | }, 92 | "exportExportTitle": { 93 | "message": "Export" 94 | }, 95 | "exportExportDescription": { 96 | "message": "Export the transcript as a CSV file" 97 | }, 98 | "exportExportDownload": { 99 | "message": "Download" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/_locales/es/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "exportTitle": { 3 | "message": "Exportar a Anki CSV" 4 | }, 5 | "extensionDescription": { 6 | "message": "Convierte transcripciones de YouTube tarjetas de Anki" 7 | }, 8 | "extensionName": { 9 | "message": "Youtube2Anki" 10 | }, 11 | "instructionsTitle": { 12 | "message": "Transcripción no encontrada" 13 | }, 14 | "instructionsDescription": { 15 | "message": "Por favor, abre la transcipción del video" 16 | }, 17 | "aboutDonate": { 18 | "message": "Donar" 19 | }, 20 | "aboutIssues": { 21 | "message": "Problemas" 22 | }, 23 | "aboutSuggestions": { 24 | "message": "Sugerencias" 25 | }, 26 | "listDeleteSavedCards": { 27 | "message": "Eliminar tarjetas guardadas" 28 | }, 29 | "listDeleteSavedCardsTitle": { 30 | "message": "Borra las tarjetas guardadas y obtiene nuevas" 31 | }, 32 | "listSelect": { 33 | "message": "Seleccionar" 34 | }, 35 | "listSelectAll": { 36 | "message": "Todas" 37 | }, 38 | "listSelectNone": { 39 | "message": "Ninguna" 40 | }, 41 | "listSelectRandom": { 42 | "message": "Aleatorias" 43 | }, 44 | "listMerge": { 45 | "message": "Combinar" 46 | }, 47 | "listMergeStop": { 48 | "message": "Detener combinar" 49 | }, 50 | "listMergeCards": { 51 | "message": "Combinar $1 tarjeta" 52 | }, 53 | "listExportCards": { 54 | "message": "Exportar $1 tarjeta" 55 | }, 56 | "listExportCardsMinimum": { 57 | "message": "⚠️ Selecciona al menos una tarjeta" 58 | }, 59 | "exportEditCards": { 60 | "message": "Editar tarjetas" 61 | }, 62 | "exportSendToAnkiTitle": { 63 | "message": "Enviar a Anki" 64 | }, 65 | "exportSendToAnkiDescription1": { 66 | "message": "Utiliza " 67 | }, 68 | "exportSendToAnkiDescription2": { 69 | "message": " para enviar las tarjetas directamente a un mazo" 70 | }, 71 | "exportSendToAnkiError": { 72 | "message": "⚠️ La conección con Anki no es posible, asegurese de que esta abierto" 73 | }, 74 | "exportSendToAnkiReconnect": { 75 | "message": "Reconnectar" 76 | }, 77 | "exportSendToAnkiDeckName": { 78 | "message": "Nombre del mazo" 79 | }, 80 | "exportSendToAnkiSend": { 81 | "message": "Enviar" 82 | }, 83 | "exportSendToAnkiErrorCreating": { 84 | "message": "⚠️ Error creando las tarjetas" 85 | }, 86 | "exportSendToAnkiNotificationTitle": { 87 | "message": "✅ Exito" 88 | }, 89 | "exportSendToAnkiNotificationDescription": { 90 | "message": "Añadidas $1 nuevas tarjetas" 91 | }, 92 | "exportExportTitle": { 93 | "message": "Exportar" 94 | }, 95 | "exportExportDescription": { 96 | "message": "Exporta la transciption a una fichero CSV" 97 | }, 98 | "exportExportDownload": { 99 | "message": "Descargar" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/popup/popup.js: -------------------------------------------------------------------------------- 1 | 2 | import { body } from './skruv/html.js' 3 | import { renderNode } from './skruv/vDOM.js' 4 | 5 | import { state as mainState } from './state.js' 6 | 7 | import { About } from './components/About.js' 8 | import { Export } from './components/Export.js' 9 | import { List } from './components/List.js' 10 | import { Instructions } from './components/Instructions.js' 11 | import { Loading } from './components/Loading.js' 12 | import { getId } from './utils.js' 13 | 14 | /** 15 | * @param {chrome.tabs.Tab} tab 16 | */ 17 | const getTabInfo = (tab) => { 18 | const { id, url, title } = tab 19 | 20 | const youTubeId = getId(String(url)) 21 | const storageId = `youTube2AnkiSubtitles-${youTubeId}` 22 | const formattedTitle = String(title).replace('- YouTube', '').trim() || 'Untitled' 23 | return { id, title: formattedTitle, storageId } 24 | } 25 | 26 | document.addEventListener('DOMContentLoaded', () => { 27 | (async () => { 28 | // @ts-expect-error Skruv initialization 29 | // eslint-disable-next-line no-unused-vars 30 | for await (const stateItem of mainState) { 31 | chrome.tabs.query({ currentWindow: true, active: true }, (tabs) => { 32 | const { id, storageId, title } = getTabInfo(tabs[0]) 33 | 34 | // Store subtitles on changes of state 35 | const stateSubtitles = [...stateItem?.subtitles?.values() || []].map(v => ({ ...v })) 36 | if (id && storageId && Boolean(stateSubtitles.length)) { 37 | chrome.tabs.sendMessage(id, { type: 'storeSubtitles', storageId, subtitles: stateSubtitles }) 38 | } 39 | 40 | renderNode( 41 | body({ 42 | oncreate: () => { 43 | // Connect to the page script and request the subtitles 44 | mainState.title = title 45 | 46 | if (id) { 47 | // Store subtitles in storage on changes 48 | mainState.activeTabId = id 49 | chrome.tabs.sendMessage(id, { type: 'getSubtitles', title, storageId }, async (response) => { 50 | const { subtitles } = response || {} 51 | 52 | // If no subtitles where found, show the instructions 53 | if (subtitles) { 54 | mainState.subtitles = subtitles 55 | mainState.view = 'list' 56 | } else { 57 | mainState.view = 'instructions' 58 | } 59 | }) 60 | } 61 | } 62 | }, 63 | // Views of the extension 64 | mainState.view === 'loading' && Loading(), 65 | mainState.view === 'list' && List(storageId), 66 | mainState.view === 'export' && Export, 67 | mainState.view === 'instructions' && Instructions, 68 | About 69 | ), 70 | document.body 71 | ) 72 | }) 73 | } 74 | })() 75 | }) 76 | -------------------------------------------------------------------------------- /src/popup/skruv/state.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | export const createState = (stateObj) => { 4 | const Handler = class Handler { 5 | constructor (name) { 6 | this.name = name 7 | this._scheduled = false 8 | this._skruv_promise = new Promise(resolve => { this._skruv_resolve = resolve }) 9 | } 10 | 11 | _resolve () { 12 | if (this._skruv_parent && this._skruv_parent._resolve) { 13 | this._skruv_parent._resolve() 14 | } 15 | if (this._scheduled) { return } 16 | this._scheduled = true 17 | window.requestAnimationFrame(() => { 18 | this._skruv_resolve() 19 | this._skruv_promise = new Promise(resolve => { this._skruv_resolve = resolve }) 20 | this._scheduled = false 21 | }) 22 | } 23 | 24 | set (target, key, value) { 25 | if (key === '_skruv_parent') { 26 | this._skruv_parent = value 27 | return true 28 | } 29 | if (target[key] !== value) { 30 | target[key] = this.recurse(key, value) 31 | this._resolve() 32 | } 33 | return true 34 | } 35 | 36 | get (target, key, proxy) { 37 | if (key === 'skruv_resolve') { 38 | return () => this._resolve() 39 | } 40 | if (key === 'skruv_unwrap_proxy') { 41 | return target 42 | } 43 | if (key === Symbol.asyncIterator) { 44 | return () => { 45 | // If this is the first loop for this sub we should return directly for first value 46 | let booted = false 47 | return { 48 | next: async () => { 49 | if (booted) { 50 | await this._skruv_promise 51 | } else { 52 | booted = true 53 | } 54 | return { done: false, value: proxy } 55 | } 56 | } 57 | } 58 | } 59 | return target[key] 60 | } 61 | 62 | deleteProperty (target, key) { 63 | const res = delete target[key] 64 | this._resolve() 65 | return res 66 | } 67 | 68 | recurse (path, value) { 69 | // check for falsy values 70 | if (value && value.constructor) { 71 | if (value.constructor === Object) { 72 | const subProxy = new this.constructor(`${this.name}.${path}`) 73 | // check object properties for other objects or arrays 74 | value = Object.keys(value).reduce((acc, key) => { 75 | acc[key] = this.recurse(`${path}.${key}`, value[key]) 76 | if (typeof acc[key] === 'object' && acc[key] !== null) acc[key]._skruv_parent = subProxy 77 | return acc 78 | }, {}) 79 | value = new Proxy(value, subProxy) 80 | value._skruv_parent = this 81 | } else if (value.constructor === Array) { 82 | const subProxy = new this.constructor(`${this.name}.${path}`) 83 | // check arrays for objects or arrays 84 | value = value.map((child, key) => { 85 | const newValue = this.recurse(`${path}[${key}]`, child) 86 | if (typeof newValue === 'object' && newValue !== null) newValue._skruv_parent = subProxy 87 | return newValue 88 | }) 89 | value = new Proxy(value, subProxy) 90 | value._skruv_parent = this 91 | } 92 | } 93 | return value 94 | } 95 | } 96 | 97 | // create root proxy 98 | const rootProxy = new Proxy({}, new Handler('root')) 99 | Object.assign(rootProxy, stateObj) 100 | return rootProxy 101 | } 102 | -------------------------------------------------------------------------------- /src/youtube2Anki.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns the given time string in seconds 3 | * 4 | * @param {string} time 5 | */ 6 | const toSeconds = (time) => { 7 | const [minutes, seconds] = time.split(':') 8 | return (+minutes * 60) + (+seconds) 9 | } 10 | 11 | /** 12 | * Transforms the given object to CSV 13 | * 14 | * @param {Subtitle[]} subtitles 15 | */ 16 | const toCSV = (subtitles) => { 17 | const str = '' 18 | return [...subtitles].reduce((str, next) => { 19 | str += `${Object.values(next).map(value => `"${value}"`).join(',')}` + '\r\n' 20 | return str 21 | }, str) 22 | } 23 | 24 | /** 25 | * Starts the download of the given text 26 | * 27 | * @param {string} filename 28 | * @param {string} text 29 | */ 30 | const download = (filename, text) => { 31 | const link = document.createElement('a') 32 | link.setAttribute('href', 'data:text/csv;charset=utf-8,' + encodeURIComponent(text)) 33 | link.setAttribute('download', filename) 34 | link.style.display = 'none' 35 | document.body.appendChild(link) 36 | link.click() 37 | document.body.removeChild(link) 38 | } 39 | 40 | /** 41 | * Crawl the subtitles from the YouTube transcript 42 | * 43 | * @param {Element[]} cues 44 | * @param {string} title 45 | */ 46 | const getSubtitles = (cues, title) => { 47 | const id = getId(window.location.href) 48 | 49 | return cues.map((cue, i) => { 50 | const time = /** @type {HTMLElement} */(cue.querySelector('.segment-timestamp')).innerText 51 | const nextTime = (cues[i + 1] && 52 | /** @type {HTMLElement} */(cues[i + 1].querySelector('.segment-timestamp')).innerText 53 | ) || null 54 | const text = /** @type {HTMLElement} */(cue.querySelector('.segment-text')).innerText 55 | const prevText = (cues[i - 1] && /** @type {HTMLElement} */(cues[i - 1].querySelector('.segment-text')).innerText) || null 56 | const nextText = (cues[i + 1] && /** @type {HTMLElement} */(cues[i + 1].querySelector('.segment-text')).innerText) || null 57 | const endSeconds = nextTime ? toSeconds(nextTime) + 1 : null 58 | const hash = (Math.random() + 1).toString(36).substring(2) 59 | 60 | return { 61 | time, 62 | nextTime: nextTime || time, 63 | text, 64 | prevText, 65 | nextText, 66 | id, 67 | startSeconds: toSeconds(time), 68 | endSeconds, 69 | title, 70 | hash 71 | } 72 | }) 73 | } 74 | 75 | /** 76 | * Extracts and returns the id of a YouTube url 77 | * 78 | * @param {string} url 79 | */ 80 | const getId = (url) => { 81 | const match = url.match(/^.*(youtu.be\/|v\/|e\/|u\/\w+\/|embed\/|v=)(?[^#&?]*).*/) 82 | return match?.groups?.id 83 | } 84 | 85 | /** 86 | * Listen to messages from popup 87 | */ 88 | chrome.runtime.onMessage.addListener((/** @type {Message} */request, _, sendResponse) => { 89 | const { type } = request 90 | 91 | // Saves the subtitles on the sessionStorage 92 | if (type === 'storeSubtitles') { 93 | sessionStorage.setItem(request.storageId, JSON.stringify(request.subtitles)) 94 | return 95 | } 96 | 97 | // Deletes the subtitles on the sessionStorage 98 | if (type === 'clearSubtitles') { 99 | sessionStorage.removeItem(request.storageId) 100 | return 101 | } 102 | 103 | // Obtains the subtitles from the transcript 104 | if (type === 'getSubtitles') { 105 | // Get subtitles form the storage 106 | const storedSubtitles = JSON.parse(sessionStorage.getItem(request.storageId) || '[]') 107 | if (storedSubtitles.length) { 108 | sendResponse({ subtitles: storedSubtitles }) 109 | return 110 | } 111 | 112 | const cues = [...document.querySelectorAll('.segment')] 113 | 114 | if (cues.length) { 115 | const subtitles = getSubtitles(cues, request.title) 116 | sendResponse({ subtitles }) 117 | } else { 118 | sendResponse({ subtitles: null }) 119 | } 120 | } 121 | 122 | if (type === 'download') { 123 | /** @type {{title: string, subtitles: Subtitle[]}} */ 124 | const { title, subtitles } = request 125 | const cleanSubtitles = subtitles.map(({ disabled, ...rest }) => rest) 126 | const csv = toCSV(cleanSubtitles) 127 | download(`${title}.csv`, csv) 128 | } 129 | }) 130 | 131 | /** 132 | * @typedef {object} Message 133 | * @prop {'getSubtitles'| 'clearSubtitles' | 'storeSubtitles' | 'download'} type 134 | * @prop {string} title 135 | * @prop {string} storageId 136 | * @prop {Subtitle[]} subtitles 137 | */ 138 | 139 | /** 140 | * @typedef {import('./interfaces').Subtitle} Subtitle 141 | */ 142 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Firefox](https://img.shields.io/amo/v/youtube2anki.svg?label=Firefox)](https://addons.mozilla.org/en-US/firefox/addon/youtube2anki/) 2 | [![Chrome](https://img.shields.io/chrome-web-store/v/boebbbjmbikafafhoelhdjeocceddngi.svg?color=%234A88EE&label=Chrome)](https://chrome.google.com/webstore/detail/youtube2anki/boebbbjmbikafafhoelhdjeocceddngi) 3 | [![Edge](https://img.shields.io/chrome-web-store/v/boebbbjmbikafafhoelhdjeocceddngi.svg?color=%234A88EE&label=Edge)](https://microsoftedge.microsoft.com/addons/detail/youtube2anki/obnoeiopgjmkbmignpgocigpfeiahkhg) 4 | [![License](https://img.shields.io/github/license/dobladov/youtube2anki.svg?color=%23B70000)](https://github.com/dobladov/youtube2Anki/blob/master/LICENSE) 5 | [![YouTube](https://img.shields.io/badge/Tutorials-YouTube-FF0000.svg)](https://www.youtube.com/channel/UC-EX0GeexOudYIprC_WphWg) 6 | [![PayPal](https://img.shields.io/badge/Support%20this%20project-PayPal-009CDE.svg)](https://www.paypal.com/donate/?hosted_button_id=Z4D6849QVUXD2) 7 | 8 | # Youtube2Anki ![Logo](https://github.com/dobladov/youtube2Anki/raw/master/src/icons/icon48.png) 9 | 10 | ## Web Extension to convert **Youtube transcripts** to **Anki cards**. 11 | 12 | > :warning: If AnkiConnect does not work add "*" to your AnkiConnect configuration in Anki -> Tools -> Add-ons -> AnkiConnect -> Config. 13 | 14 | ```javascript 15 | "webCorsOriginList": [ 16 | "*", 17 | "http://localhost" 18 | ] 19 | ``` 20 | 21 | This extension allows to download the transcript of a YouTube video to a csv that can be imported into Anki or directly send the cards to Anki using AnkiConnect, allowing to use the original audio/video of the current sentence and without having to download the original media. 22 | 23 | ![example](https://user-images.githubusercontent.com/1938043/60365436-00b80380-99e9-11e9-8524-02916a2619a9.gif) 24 | 25 | ![Anki Card](https://user-images.githubusercontent.com/1938043/59226287-ebfb0380-8bd2-11e9-8f11-0ef5bd789801.png) 26 | 27 | 28 | ## Install 29 | 30 | Install the extension for your prefered browser 31 | 32 | + [Firefox](https://addons.mozilla.org/en-US/firefox/addon/youtube2anki/) 33 | + [Chrome](https://chrome.google.com/webstore/detail/youtube2anki/boebbbjmbikafafhoelhdjeocceddngi) 34 | 35 | ## Send to Anki [AnkiConnect] 36 | 37 | Install [AnkiConnect]("https://ankiweb.net/shared/info/2055492159") to add the cards directly to a deck, once the plugin is installed, keep Anki open and press the `Send` button from the extension. 38 | 39 | [![How to install Anki Connect](https://img.shields.io/badge/How%20to%20install%20AnkiConnect%20for%20Youtube2Anki-YouTube-FF0000.svg)](https://www.youtube.com/watch?v=N0dBJWcWZLM) 40 | 41 | [![YouTube](https://img.shields.io/badge/How%20to%20use%20Youtube2Anki%20with%20AnkiConnect-YouTube-FF0000.svg)](https://www.youtube.com/watch?v=N0dBJWcWZLM) 42 | 43 | There is no need for the following steps. 44 | 45 | ## Instructions for export CSV 46 | 47 | In Anki, create a new note. Tools -> Manage Note Types 48 | 49 | ![Manage Notes](https://user-images.githubusercontent.com/1938043/59226841-2a44f280-8bd4-11e9-89f4-b402e818ead8.png) 50 | 51 | Add a new card with this fields 52 | 53 | ![Note Fields](https://user-images.githubusercontent.com/1938043/60300182-b7a37900-992e-11e9-9fe1-3979ab2b6328.png) 54 | 55 | Edit the card fields of the new note created with the correspondent code. 56 | 57 | *Front Template* 58 | ``` 59 | {{title}} 60 |
61 | 62 | {{prevText}} 63 |
64 | 65 | {{text}} 66 |
67 | 68 |