├── .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": "[](https://addons.mozilla.org/en-US/firefox/addon/youtube2anki/) [](https://chrome.google.com/webstore/detail/youtube2anki/boebbbjmbikafafhoelhdjeocceddngi) [](https://github.com/dobladov/youtube2Anki/blob/master/LICENSE) [](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 | [](https://addons.mozilla.org/en-US/firefox/addon/youtube2anki/)
2 | [](https://chrome.google.com/webstore/detail/youtube2anki/boebbbjmbikafafhoelhdjeocceddngi)
3 | [](https://microsoftedge.microsoft.com/addons/detail/youtube2anki/obnoeiopgjmkbmignpgocigpfeiahkhg)
4 | [](https://github.com/dobladov/youtube2Anki/blob/master/LICENSE)
5 | [](https://www.youtube.com/channel/UC-EX0GeexOudYIprC_WphWg)
6 | [](https://www.paypal.com/donate/?hosted_button_id=Z4D6849QVUXD2)
7 |
8 | # Youtube2Anki 
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 | 
24 |
25 | 
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 | [](https://www.youtube.com/watch?v=N0dBJWcWZLM)
40 |
41 | [](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 | 
50 |
51 | Add a new card with this fields
52 |
53 | 
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 |
75 |
76 |
77 | {{time}} - {{nextTime}}
78 |
79 | {{nextText}}
80 | ```
81 |
82 | *Styling*
83 | ```
84 | .card {
85 | font-family: futura-pt,sans-serif,sans-serif;
86 | font-size: 20px;
87 | text-align: center;
88 | color: black;
89 | background-color: #e9e9e9;
90 | }
91 |
92 | span {
93 | font-size: 0.9rem;
94 | color: #3c3c3c;
95 | }
96 |
97 | ```
98 | *Back Template*
99 | ```
100 | {{FrontSide}}
101 |
102 |
103 | ```
104 |
105 | 
106 |
107 |
108 | Now after exporting the cards using by using the extension icon, is possible to import the new cards using the new note type.
109 |
110 | > **Please be sure to open the transcript of the video before pressing the export button**, since the transcript is not created until the user opens it, this step is required.
111 |
112 | File -> Import
113 |
114 | 
115 |
--------------------------------------------------------------------------------
/src/popup/components/ExportAnki.js:
--------------------------------------------------------------------------------
1 | import { div, h2, button, p, text, a, datalist, form, input, option, br, label } from '../skruv/html.js'
2 |
3 | import { state as mainState } from '../state.js'
4 | import { sendNotification, getEnabledSubtitles } from '../utils.js'
5 |
6 | const scheme = 'http'
7 | const host = 'localhost'
8 | const port = 8765
9 |
10 | /**
11 | * Format the subtitles for Anki
12 | *
13 | * @param {Subtitle[]} subtitles
14 | * @param {string} deck
15 | * @param {string} model
16 | */
17 | const getNotes = (subtitles, deck, model) => (
18 | subtitles.map(subtitle => (
19 | {
20 | deckName: deck,
21 | modelName: model,
22 | fields: {
23 | time: subtitle.time,
24 | nextTime: subtitle.nextTime || '',
25 | text: subtitle.text,
26 | prevText: subtitle.prevText || '',
27 | nextText: subtitle.nextText || '',
28 | id: subtitle.id,
29 | startSeconds: (subtitle.startSeconds && subtitle.startSeconds.toString()) || '',
30 | endSeconds: (subtitle.endSeconds && subtitle.endSeconds.toString()) || '',
31 | title: subtitle.title,
32 | hash: subtitle.hash
33 | },
34 | tags: [],
35 | options: {
36 | allowDuplicate: false
37 | }
38 | }
39 | ))
40 | )
41 |
42 | /**
43 | * Obtains deck names form Anki
44 | * and stores them in the state
45 | *
46 | * @param {string} title
47 | */
48 | const initDecks = async (title) => {
49 | try {
50 | const { result: decks } = await (await fetch(`${scheme}://${host}:${port}`,
51 | {
52 | method: 'POST',
53 | body: JSON.stringify({
54 | action: 'deckNames',
55 | version: 6
56 | })
57 | }
58 | )).json()
59 |
60 | // Add the title if it does not exist as a deck in Anki
61 | if (!decks.includes(title)) {
62 | decks.push(title)
63 | }
64 |
65 | mainState.deckNames = decks.filter(Boolean)
66 | } catch (error) {
67 | console.warn(error)
68 | mainState.error = {
69 | message: chrome.i18n.getMessage('exportSendToAnkiError')
70 | }
71 | }
72 | }
73 |
74 | /**
75 | * Creates a new deck in Anki with the given name
76 | *
77 | * @param {string} deckName
78 | */
79 | const createDeck = async (deckName) => {
80 | const createDeckResponse = await fetch(`${scheme}://${host}:${port}`,
81 | {
82 | method: 'POST',
83 | body: JSON.stringify({
84 | action: 'createDeck',
85 | version: 6,
86 | params: {
87 | deck: deckName
88 | }
89 | })
90 | }
91 | )
92 |
93 | const { err } = await createDeckResponse.json()
94 | if (err) throw new Error(err)
95 | }
96 |
97 | /**
98 | * Creates the model in which the cards will be represented
99 | */
100 | const createModel = async () => {
101 | const createModelResponse = await fetch(`${scheme}://${host}:${port}`,
102 | {
103 | method: 'POST',
104 | body: JSON.stringify({
105 | action: 'createModel',
106 | version: 6,
107 | params: {
108 | modelName: 'Youtube2AnkiV2',
109 | inOrderFields: [
110 | 'time',
111 | 'nextTime',
112 | 'text',
113 | 'prevText',
114 | 'nextText',
115 | 'id',
116 | 'startSeconds',
117 | 'endSeconds',
118 | 'title',
119 | 'hash'
120 | ],
121 | css: `
122 | .card {
123 | font-family: futura-pt,sans-serif,sans-serif;
124 | font-size: 20px;
125 | text-align: center;
126 | color: black;
127 | background-color: #e9e9e9;
128 | }
129 |
130 | span {
131 | font-size: 0.9rem;
132 | color: #3c3c3c;
133 | }
134 | `,
135 | cardTemplates: [
136 | {
137 | Front: `
138 | {{title}}
139 |
140 |
141 | {{prevText}}
142 |
143 |
144 | {{text}}
145 |
146 |
147 |
154 |
155 |
156 | {{time}} - {{nextTime}}
157 |
158 | {{nextText}}
159 | `,
160 | Back: `
161 | {{FrontSide}}
162 |
163 | `
164 | }
165 | ]
166 | }
167 | })
168 | }
169 | )
170 |
171 | const { errorCreateModel } = await createModelResponse.json()
172 | if (errorCreateModel) throw new Error(errorCreateModel)
173 | }
174 |
175 | /**
176 | * Adds the given notes to the deck
177 | *
178 | * @param {Subtitle[]} notes
179 | * @param {string} deckName
180 | */
181 | const addNotes = async (notes, deckName) => {
182 | const addNotesResponse = await fetch(`${scheme}://${host}:${port}`,
183 | {
184 | method: 'POST',
185 | body: JSON.stringify({
186 | action: 'addNotes',
187 | version: 6,
188 | params: {
189 | notes: getNotes(notes, deckName, 'Youtube2AnkiV2')
190 | }
191 | })
192 | }
193 | )
194 | const { error } = await addNotesResponse.json()
195 | if (error) throw new Error(error)
196 | }
197 |
198 | /**
199 | * Component that handles Connection to Anki
200 | */
201 | export const ExportAnki = () => div(
202 | {
203 | class: 'card',
204 | oncreate: async () => {
205 | await initDecks(mainState.title)
206 | }
207 | },
208 | h2({}, chrome.i18n.getMessage('exportSendToAnkiTitle')),
209 | p({},
210 | text({}, chrome.i18n.getMessage('exportSendToAnkiDescription1')),
211 | a({
212 | href: 'https://ankiweb.net/shared/info/2055492159',
213 | target: '_blank'
214 | }, 'Anki Connect'),
215 | text({}, chrome.i18n.getMessage('exportSendToAnkiDescription2'))
216 | ),
217 | mainState.error?.message && p({}, mainState.error.message),
218 | mainState.error?.message && button({
219 | class: 'btn',
220 | onclick: async () => {
221 | mainState.error = {}
222 | await initDecks(mainState.title)
223 | }
224 | },
225 | chrome.i18n.getMessage('exportSendToAnkiReconnect')
226 | ),
227 | mainState.deckNames &&
228 | form(
229 | {
230 | onsubmit: async (/** @type {Event} */e) => {
231 | e.preventDefault()
232 | // @ts-ignore
233 | const formData = new FormData(e.target)
234 | const deckName = /** @type {string} */(formData.get('deckName'))
235 |
236 | try {
237 | const subtitles = getEnabledSubtitles(mainState.subtitles)
238 |
239 | await createDeck(deckName)
240 | await createModel()
241 | await addNotes(subtitles, deckName)
242 |
243 | sendNotification(
244 | chrome.i18n.getMessage('exportSendToAnkiNotificationTitle'),
245 | chrome.i18n.getMessage('exportSendToAnkiNotificationDescription', String(subtitles.length)),
246 | () => window.close()
247 | )
248 | } catch (error) {
249 | console.error(error)
250 | sendNotification(
251 | chrome.i18n.getMessage('exportSendToAnkiErrorCreating'),
252 | // @ts-expect-error
253 | error.message
254 | )
255 | }
256 | }
257 | },
258 | label({ for: 'deckName' }, chrome.i18n.getMessage('exportSendToAnkiDeckName')),
259 | input({
260 | id: 'deckName',
261 | name: 'deckName',
262 | type: 'text',
263 | required: true,
264 | list: 'deckList',
265 | value: mainState.title
266 | }),
267 | datalist({
268 | id: 'deckList'
269 | }, mainState.deckNames.map(name => option({
270 | value: name
271 | }, name))),
272 | br({}),
273 | br({}),
274 | button({
275 | type: 'submit',
276 | class: 'btn'
277 | }, chrome.i18n.getMessage('exportSendToAnkiSend'))
278 | )
279 | )
280 |
281 | /**
282 | * @typedef {import('../../interfaces').Subtitle} Subtitle
283 | */
284 |
--------------------------------------------------------------------------------
/src/popup/components/List.js:
--------------------------------------------------------------------------------
1 | import { div, css, ul, li, button, text, h2 } from '../skruv/html.js'
2 |
3 | import { state as mainState } from '../state.js'
4 | import { getEnabledSubtitles } from '../utils.js'
5 |
6 | /**
7 | * Gets all the cards that will be merged
8 | * according to the current selection
9 | */
10 | const getCardsToMerge = () => {
11 | if (isNaN(mainState.mergeStart) || isNaN(mainState.mergeEnd)) {
12 | return []
13 | }
14 |
15 | if (mainState.mergeStart <= mainState.mergeEnd) {
16 | return mainState.subtitles
17 | .slice(mainState.mergeStart, mainState.mergeEnd + 1)
18 | .map(v => ({ ...v }))
19 | }
20 |
21 | return mainState.subtitles
22 | .slice(mainState.mergeEnd, mainState.mergeStart + 1)
23 | .map(v => ({ ...v }))
24 | }
25 |
26 | /**
27 | * Generates a single card from cardsToMerge
28 | * and replaces it in the subtitles state
29 | *
30 | * @param {Subtitle[]} cardsToMerge
31 | */
32 | const mergeCards = (cardsToMerge) => {
33 | const lastCard = cardsToMerge[cardsToMerge.length - 1]
34 | const card = {
35 | ...cardsToMerge[0],
36 | text: cardsToMerge.map(({ text }) => text).join(' '),
37 | endSeconds: lastCard.endSeconds,
38 | nextTime: lastCard.nextTime,
39 | nextText: lastCard.nextText
40 | }
41 |
42 | // Insert the new card in between the selection indexes
43 | if (mainState.mergeStart < mainState.mergeEnd) {
44 | mainState.subtitles = [
45 | ...mainState.subtitles.slice(0, mainState.mergeStart),
46 | card,
47 | ...mainState.subtitles.slice(mainState.mergeEnd + 1, mainState.subtitles.length)
48 | ];
49 |
50 | /** @type {HTMLElement} */(document.querySelector(`[data-index="${mainState.mergeStart}"]`))?.scrollIntoView({ behavior: 'smooth' })
51 | } else {
52 | mainState.subtitles = [
53 | ...mainState.subtitles.slice(0, mainState.mergeEnd),
54 | card,
55 | ...mainState.subtitles.slice(mainState.mergeStart + 1, mainState.subtitles.length)
56 | ];
57 |
58 | /** @type {HTMLElement} */(document.querySelector(`[data-index="${mainState.mergeEnd}"]`))?.scrollIntoView({ behavior: 'smooth' })
59 | }
60 |
61 | // Reset selection
62 | mainState.mergeStart = NaN
63 | mainState.mergeEnd = NaN
64 | }
65 |
66 | // @ts-ignore
67 | const styling = css`
68 | .container {
69 | display: flex;
70 | flex-direction: column;
71 | max-height: 500px;
72 | padding-right: 0;
73 | align-items: center;
74 | padding: 10px 0;
75 | gap: 0.5rem;
76 | }
77 |
78 | .controls {
79 | display: flex;
80 | gap: .4rem;
81 | align-items: center;
82 | font-size: 1rem;
83 | width: 100%;
84 | padding: 0 .5rem;
85 | }
86 |
87 | .controls .btn {
88 | padding-left: 1rem;
89 | padding-right: 1rem;
90 | }
91 | .controls h2 {
92 | flex: 1;
93 | text-align: center;
94 | }
95 |
96 | ul {
97 | list-style-type: none;
98 | margin: 0;
99 | padding: 0;
100 | overflow-x: auto;
101 | }
102 |
103 | li {
104 | position: relative;
105 | }
106 |
107 | .text {
108 | padding: 10px;
109 | flex: 1;
110 | text-align: center;
111 | }
112 |
113 | .disabled {
114 | text-decoration: line-through;
115 | }
116 |
117 | .listButton {
118 | width: 100%;
119 | display: flex;
120 | align-items: center;
121 | cursor: pointer;
122 | margin: 0;
123 | padding: 10px;
124 | border: none;
125 | background: transparent;
126 | outline: none;
127 | transition: background .2s ease-in-out;
128 | }
129 |
130 | .listButton:focus {
131 | background-color: var(--main-bg-color);
132 | box-shadow: inset 0 0 0 2px var(--action-color);
133 | border-radius: 4px;
134 | z-index: 2;
135 | }
136 |
137 | .inRange {
138 | background-color: var(--action-disabled-color) !important;
139 | cursor: copy;
140 | }
141 |
142 | li:focus-within .floating {
143 | opacity: 1;
144 | pointer-events: all;
145 | }
146 |
147 | .floating {
148 | padding: .3rem 1rem;
149 | pointer-events: none;
150 | opacity: 0;
151 | z-index: 3;
152 | position: absolute;
153 | bottom: -0.5rem;
154 | }
155 |
156 | .left {
157 | left: 0.5rem;
158 | }
159 |
160 | .right {
161 | right: 0.5rem;
162 | }
163 |
164 | .hidden {
165 | transition: opacity .5s ease-in-out;
166 | pointer-events: none;
167 | opacity: 0 !important;
168 | }
169 |
170 | .reset {
171 | position: absolute;
172 | left: 50%;
173 | top: .2rem;
174 | border: none;
175 | background: none;
176 | color: var(--p-color);
177 | transform: translateX(-50%);
178 | font-size: .7rem;
179 | }
180 |
181 | .reset:hover {
182 | color: var(--action-color);
183 | }
184 |
185 | .selectBtn {
186 | padding-top: .2rem;
187 | padding-bottom: .2rem;
188 | }
189 | `
190 |
191 | /**
192 | * Toggle the item between enabled and disabled
193 | *
194 | * @param {number} index
195 | */
196 | const toggleItem = (index) => {
197 | const currentValue = mainState.subtitles[index].disabled
198 | mainState.subtitles[index].disabled = !currentValue
199 | }
200 |
201 | /**
202 | * Sets all items a enabled/disabled
203 | *
204 | * @param {boolean} state
205 | */
206 | const setAll = (state) => {
207 | mainState.subtitles.forEach(item => {
208 | item.disabled = state
209 | })
210 | }
211 |
212 | /**
213 | * Makes disabled property of all items random
214 | */
215 | const setRandom = () => {
216 | mainState.subtitles.forEach(item => {
217 | item.disabled = Math.random() < 0.5
218 | })
219 | }
220 |
221 | /**
222 | *
223 | * @param {string} storageId
224 | */
225 | export const List = (storageId) => {
226 | // const enabledCards = mainState.subtitles.filter(item => !item.disabled).length
227 | const enabledCards = getEnabledSubtitles(mainState.subtitles).length
228 | const cardsToMerge = getCardsToMerge()
229 |
230 | return div(
231 | {
232 | class: 'container card'
233 | },
234 | div(
235 | {
236 | class: 'controls'
237 | },
238 | h2({}, chrome.i18n.getMessage('listSelect')),
239 | button({
240 | disabled: enabledCards === mainState.subtitles.length,
241 | class: 'btn selectBtn',
242 | onclick: () => setAll(false)
243 | }, chrome.i18n.getMessage('listSelectAll')),
244 | button({
245 | class: 'btn selectBtn',
246 | disabled: enabledCards === 0,
247 | onclick: () => setAll(true)
248 | }, chrome.i18n.getMessage('listSelectNone')),
249 | button({
250 | class: 'btn selectBtn',
251 | onclick: setRandom
252 | }, chrome.i18n.getMessage('listSelectRandom'))
253 | ),
254 | ul({},
255 | mainState.subtitles.map((item, i) => (
256 | li({
257 | class: !!item.disabled && 'disabled'
258 | },
259 | button({
260 | onmouseover: (/** @type {{target: HTMLElement}}} */event) => {
261 | event.target.focus()
262 | // Select card for merge end if merge is started
263 | if (!isNaN(mainState.mergeStart)) {
264 | mainState.mergeEnd = i
265 | }
266 | },
267 | onfocus: () => {
268 | // Select card for merge with keyboard
269 | if (!isNaN(mainState.mergeStart)) {
270 | mainState.mergeEnd = i
271 | }
272 | },
273 | 'data-index': i,
274 | class: `listButton${cardsToMerge.some(card => card.hash === item.hash) ? ' inRange' : ''}`,
275 | onclick: () => {
276 | toggleItem(i)
277 | }
278 | },
279 | div({},
280 | item.time
281 | ),
282 | div({
283 | class: 'text'
284 | },
285 | item.text
286 | ),
287 | div({},
288 | item.nextTime
289 | )),
290 | button({
291 | class: 'btn floating left',
292 | onclick: () => {
293 | if (isNaN(mainState.mergeStart)) {
294 | mainState.mergeStart = i
295 | } else {
296 | mainState.mergeStart = NaN
297 | mainState.mergeEnd = NaN
298 | }
299 | }
300 | }, isNaN(mainState.mergeStart) ? chrome.i18n.getMessage('listMerge') : chrome.i18n.getMessage('listMergeStop')),
301 | button({
302 | class: `btn floating right${cardsToMerge.length > 1 ? '' : ' hidden'}`,
303 | onclick: () => {
304 | mergeCards(cardsToMerge)
305 | }
306 | }, chrome.i18n.getMessage('listMergeCards', String(cardsToMerge.length))))
307 | )),
308 | styling
309 | ),
310 | div({},
311 | enabledCards
312 | ? button(
313 | {
314 | disabled: enabledCards === 0,
315 | class: 'btn',
316 | onclick: () => {
317 | mainState.view = 'export'
318 | }
319 | },
320 | chrome.i18n.getMessage('listExportCards', String(enabledCards)))
321 | : text({}, chrome.i18n.getMessage('listExportCardsMinimum'))
322 | ),
323 | button({
324 | class: 'reset',
325 | title: chrome.i18n.getMessage('listDeleteSavedCards'),
326 | onclick: () => {
327 | // Remove the storage and force a reload
328 | chrome.tabs.sendMessage(mainState.activeTabId, { type: 'clearSubtitles', storageId }, async () => {
329 | location.reload()
330 | })
331 | }
332 | }, chrome.i18n.getMessage('listDeleteSavedCards'))
333 | )
334 | }
335 |
--------------------------------------------------------------------------------
/src/popup/skruv/html.js:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | export const h = (nodeName, attributes = {}, ...childNodes) => ({ nodeName, attributes, childNodes })
3 |
4 | // HTML
5 | export const a = (...args) => h('a', ...args)
6 | export const abbr = (...args) => h('abbr', ...args)
7 | export const acronym = (...args) => h('acronym', ...args)
8 | export const address = (...args) => h('address', ...args)
9 | export const applet = (...args) => h('applet', ...args)
10 | export const area = (...args) => h('area', ...args)
11 | export const article = (...args) => h('article', ...args)
12 | export const aside = (...args) => h('aside', ...args)
13 | export const audio = (...args) => h('audio', ...args)
14 | export const b = (...args) => h('b', ...args)
15 | export const base = (...args) => h('base', ...args)
16 | export const basefont = (...args) => h('basefont', ...args)
17 | export const bdi = (...args) => h('bdi', ...args)
18 | export const bdo = (...args) => h('bdo', ...args)
19 | export const bgsound = (...args) => h('bgsound', ...args)
20 | export const big = (...args) => h('big', ...args)
21 | export const blink = (...args) => h('blink', ...args)
22 | export const blockquote = (...args) => h('blockquote', ...args)
23 | export const body = (...args) => h('body', ...args)
24 | export const br = (...args) => h('br', ...args)
25 | export const button = (...args) => h('button', ...args)
26 | export const canvas = (...args) => h('canvas', ...args)
27 | export const caption = (...args) => h('caption', ...args)
28 | export const center = (...args) => h('center', ...args)
29 | export const cite = (...args) => h('cite', ...args)
30 | export const code = (...args) => h('code', ...args)
31 | export const col = (...args) => h('col', ...args)
32 | export const colgroup = (...args) => h('colgroup', ...args)
33 | export const command = (...args) => h('command', ...args)
34 | export const content = (...args) => h('content', ...args)
35 | export const data = (...args) => h('data', ...args)
36 | export const datalist = (...args) => h('datalist', ...args)
37 | export const dd = (...args) => h('dd', ...args)
38 | export const del = (...args) => h('del', ...args)
39 | export const details = (...args) => h('details', ...args)
40 | export const dfn = (...args) => h('dfn', ...args)
41 | export const dialog = (...args) => h('dialog', ...args)
42 | export const dir = (...args) => h('dir', ...args)
43 | export const div = (...args) => h('div', ...args)
44 | export const dl = (...args) => h('dl', ...args)
45 | export const dt = (...args) => h('dt', ...args)
46 | export const element = (...args) => h('element', ...args)
47 | export const em = (...args) => h('em', ...args)
48 | export const embed = (...args) => h('embed', ...args)
49 | export const fieldset = (...args) => h('fieldset', ...args)
50 | export const figcaption = (...args) => h('figcaption', ...args)
51 | export const figure = (...args) => h('figure', ...args)
52 | export const font = (...args) => h('font', ...args)
53 | export const footer = (...args) => h('footer', ...args)
54 | export const form = (...args) => h('form', ...args)
55 | export const frame = (...args) => h('frame', ...args)
56 | export const frameset = (...args) => h('frameset', ...args)
57 | export const h1 = (...args) => h('h1', ...args)
58 | export const h2 = (...args) => h('h2', ...args)
59 | export const h3 = (...args) => h('h3', ...args)
60 | export const h4 = (...args) => h('h4', ...args)
61 | export const h5 = (...args) => h('h5', ...args)
62 | export const h6 = (...args) => h('h6', ...args)
63 | export const head = (...args) => h('head', ...args)
64 | export const header = (...args) => h('header', ...args)
65 | export const hgroup = (...args) => h('hgroup', ...args)
66 | export const hr = (...args) => h('hr', ...args)
67 | export const html = (...args) => h('html', ...args)
68 | export const i = (...args) => h('i', ...args)
69 | export const iframe = (...args) => h('iframe', ...args)
70 | export const image = (...args) => h('image', ...args)
71 | export const img = (...args) => h('img', ...args)
72 | export const input = (...args) => h('input', ...args)
73 | export const ins = (...args) => h('ins', ...args)
74 | export const isindex = (...args) => h('isindex', ...args)
75 | export const kbd = (...args) => h('kbd', ...args)
76 | export const keygen = (...args) => h('keygen', ...args)
77 | export const label = (...args) => h('label', ...args)
78 | export const legend = (...args) => h('legend', ...args)
79 | export const li = (...args) => h('li', ...args)
80 | export const link = (...args) => h('link', ...args)
81 | export const listing = (...args) => h('listing', ...args)
82 | export const main = (...args) => h('main', ...args)
83 | export const map = (...args) => h('map', ...args)
84 | export const mark = (...args) => h('mark', ...args)
85 | export const marquee = (...args) => h('marquee', ...args)
86 | export const menu = (...args) => h('menu', ...args)
87 | export const menuitem = (...args) => h('menuitem', ...args)
88 | export const meta = (...args) => h('meta', ...args)
89 | export const meter = (...args) => h('meter', ...args)
90 | export const multicol = (...args) => h('multicol', ...args)
91 | export const nav = (...args) => h('nav', ...args)
92 | export const nextid = (...args) => h('nextid', ...args)
93 | export const nobr = (...args) => h('nobr', ...args)
94 | export const noembed = (...args) => h('noembed', ...args)
95 | export const noframes = (...args) => h('noframes', ...args)
96 | export const noscript = (...args) => h('noscript', ...args)
97 | export const object = (...args) => h('object', ...args)
98 | export const ol = (...args) => h('ol', ...args)
99 | export const optgroup = (...args) => h('optgroup', ...args)
100 | export const option = (...args) => h('option', ...args)
101 | export const output = (...args) => h('output', ...args)
102 | export const p = (...args) => h('p', ...args)
103 | export const param = (...args) => h('param', ...args)
104 | export const picture = (...args) => h('picture', ...args)
105 | export const plaintext = (...args) => h('plaintext', ...args)
106 | export const pre = (...args) => h('pre', ...args)
107 | export const progress = (...args) => h('progress', ...args)
108 | export const q = (...args) => h('q', ...args)
109 | export const rb = (...args) => h('rb', ...args)
110 | export const rp = (...args) => h('rp', ...args)
111 | export const rt = (...args) => h('rt', ...args)
112 | export const rtc = (...args) => h('rtc', ...args)
113 | export const ruby = (...args) => h('ruby', ...args)
114 | export const s = (...args) => h('s', ...args)
115 | export const samp = (...args) => h('samp', ...args)
116 | export const script = (...args) => h('script', ...args)
117 | export const section = (...args) => h('section', ...args)
118 | export const select = (...args) => h('select', ...args)
119 | export const shadow = (...args) => h('shadow', ...args)
120 | export const slot = (...args) => h('slot', ...args)
121 | export const small = (...args) => h('small', ...args)
122 | export const source = (...args) => h('source', ...args)
123 | export const spacer = (...args) => h('spacer', ...args)
124 | export const span = (...args) => h('span', ...args)
125 | export const strike = (...args) => h('strike', ...args)
126 | export const strong = (...args) => h('strong', ...args)
127 | export const style = (...args) => h('style', ...args)
128 | export const sub = (...args) => h('sub', ...args)
129 | export const summary = (...args) => h('summary', ...args)
130 | export const sup = (...args) => h('sup', ...args)
131 | export const table = (...args) => h('table', ...args)
132 | export const tbody = (...args) => h('tbody', ...args)
133 | export const td = (...args) => h('td', ...args)
134 | export const template = (...args) => h('template', ...args)
135 | export const textarea = (...args) => h('textarea', ...args)
136 | export const tfoot = (...args) => h('tfoot', ...args)
137 | export const th = (...args) => h('th', ...args)
138 | export const thead = (...args) => h('thead', ...args)
139 | export const time = (...args) => h('time', ...args)
140 | export const title = (...args) => h('title', ...args)
141 | export const tr = (...args) => h('tr', ...args)
142 | export const track = (...args) => h('track', ...args)
143 | export const tt = (...args) => h('tt', ...args)
144 | export const u = (...args) => h('u', ...args)
145 | export const ul = (...args) => h('ul', ...args)
146 | export const varElem = (...args) => h('var', ...args)
147 | export const video = (...args) => h('video', ...args)
148 | export const wbr = (...args) => h('wbr', ...args)
149 | export const xmp = (...args) => h('xmp', ...args)
150 |
151 | // SVG
152 | export const animate = (...args) => h('animate', ...args)
153 | export const animateMotion = (...args) => h('animateMotion', ...args)
154 | export const animateTransform = (...args) => h('animateTransform', ...args)
155 | export const circle = (...args) => h('circle', ...args)
156 | export const clipPath = (...args) => h('clipPath', ...args)
157 | export const colorProfile = (...args) => h('color, ...args-profile')
158 | export const defs = (...args) => h('defs', ...args)
159 | export const desc = (...args) => h('desc', ...args)
160 | export const discard = (...args) => h('discard', ...args)
161 | export const ellipse = (...args) => h('ellipse', ...args)
162 | export const feBlend = (...args) => h('feBlend', ...args)
163 | export const feColorMatrix = (...args) => h('feColorMatrix', ...args)
164 | export const feComponentTransfer = (...args) => h('feComponentTransfer', ...args)
165 | export const feComposite = (...args) => h('feComposite', ...args)
166 | export const feConvolveMatrix = (...args) => h('feConvolveMatrix', ...args)
167 | export const feDiffuseLighting = (...args) => h('feDiffuseLighting', ...args)
168 | export const feDisplacementMap = (...args) => h('feDisplacementMap', ...args)
169 | export const feDistantLight = (...args) => h('feDistantLight', ...args)
170 | export const feDropShadow = (...args) => h('feDropShadow', ...args)
171 | export const feFlood = (...args) => h('feFlood', ...args)
172 | export const feFuncA = (...args) => h('feFuncA', ...args)
173 | export const feFuncB = (...args) => h('feFuncB', ...args)
174 | export const feFuncG = (...args) => h('feFuncG', ...args)
175 | export const feFuncR = (...args) => h('feFuncR', ...args)
176 | export const feGaussianBlur = (...args) => h('feGaussianBlur', ...args)
177 | export const feImage = (...args) => h('feImage', ...args)
178 | export const feMerge = (...args) => h('feMerge', ...args)
179 | export const feMergeNode = (...args) => h('feMergeNode', ...args)
180 | export const feMorphology = (...args) => h('feMorphology', ...args)
181 | export const feOffset = (...args) => h('feOffset', ...args)
182 | export const fePointLight = (...args) => h('fePointLight', ...args)
183 | export const feSpecularLighting = (...args) => h('feSpecularLighting', ...args)
184 | export const feSpotLight = (...args) => h('feSpotLight', ...args)
185 | export const feTile = (...args) => h('feTile', ...args)
186 | export const feTurbulence = (...args) => h('feTurbulence', ...args)
187 | export const filter = (...args) => h('filter', ...args)
188 | export const foreignObject = (...args) => h('foreignObject', ...args)
189 | export const g = (...args) => h('g', ...args)
190 | export const hatch = (...args) => h('hatch', ...args)
191 | export const hatchpath = (...args) => h('hatchpath', ...args)
192 | export const line = (...args) => h('line', ...args)
193 | export const linearGradient = (...args) => h('linearGradient', ...args)
194 | export const marker = (...args) => h('marker', ...args)
195 | export const mask = (...args) => h('mask', ...args)
196 | export const mesh = (...args) => h('mesh', ...args)
197 | export const meshgradient = (...args) => h('meshgradient', ...args)
198 | export const meshpatch = (...args) => h('meshpatch', ...args)
199 | export const meshrow = (...args) => h('meshrow', ...args)
200 | export const metadata = (...args) => h('metadata', ...args)
201 | export const mpath = (...args) => h('mpath', ...args)
202 | export const path = (...args) => h('path', ...args)
203 | export const pattern = (...args) => h('pattern', ...args)
204 | export const polygon = (...args) => h('polygon', ...args)
205 | export const polyline = (...args) => h('polyline', ...args)
206 | export const radialGradient = (...args) => h('radialGradient', ...args)
207 | export const rect = (...args) => h('rect', ...args)
208 | export const set = (...args) => h('set', ...args)
209 | export const solidcolor = (...args) => h('solidcolor', ...args)
210 | export const stop = (...args) => h('stop', ...args)
211 | export const svg = (...args) => h('svg', ...args)
212 | export const switchElem = (...args) => h('switch', ...args)
213 | export const symbol = (...args) => h('symbol', ...args)
214 | export const text = (...args) => h('text', ...args)
215 | export const textPath = (...args) => h('textPath', ...args)
216 | export const tspan = (...args) => h('tspan', ...args)
217 | export const unknown = (...args) => h('unknown', ...args)
218 | export const use = (...args) => h('use', ...args)
219 | export const view = (...args) => h('view', ...args)
220 |
221 | // CSS template literal
222 | /**
223 | * @param {String[]} strings
224 | * @param {[String | Number | Boolean]} keys
225 | * @returns {Vnode}
226 | */
227 | export const css = (strings, ...keys) => {
228 | /** @type {Vnode[]} */
229 | const vNodeArr = []
230 | return style({}, strings.reduce((prev, curr, i) => {
231 | prev.push(curr)
232 | keys[i] && prev.push(keys[i])
233 | return prev
234 | }, vNodeArr).join(''))
235 | }
236 |
--------------------------------------------------------------------------------
/src/popup/skruv/vDOM.js:
--------------------------------------------------------------------------------
1 | /* global HTMLDocument HTMLElement SVGElement HTMLOptionElement HTMLInputElement HTMLButtonElement HTMLTextAreaElement ShadowRoot Text CustomEvent */
2 | // @ts-nocheck
3 |
4 | /**
5 | * @typedef Vnode
6 | * @prop {String} nodeName
7 | * @prop {String} [data]
8 | * @prop {Object<[String], [String | Boolean]>} attributes
9 | * @prop {Array} childNodes
10 | */
11 |
12 | /**
13 | * @typedef LooseVnode
14 | * @type {Boolean | String | Number | Vnode | Vnode[] | function(): LooseVnode | function(): LooseVnode[]}
15 | */
16 |
17 | /** @type WeakMap> */
18 | const listenerMap = new WeakMap()
19 | /** @type WeakMap