├── .eslintrc.json
├── .gitignore
├── CHANGELOG.md
├── README.md
├── __mocks__
├── .eslintrc.json
└── anki-apkg-export.js
├── __tests__
├── .eslintrc.json
├── configs
│ └── index.test.js
├── file_serializer.test.js
├── models
│ ├── card.test.js
│ ├── deck.test.js
│ └── media.test.js
├── parsers
│ ├── card_parser.test.js
│ ├── md_parser.test.js
│ └── media_parser.test.js
├── transformer.test.js
└── utils.test.js
├── jest.config.js
├── package-lock.json
├── package.json
├── resources
├── dark.css
├── default.css
├── highlight.js
└── prism.js
├── samples
├── complex.md
├── media.md
├── media_remote.md
├── resources
│ ├── nodejs.png
│ └── ruby_on_rails.png
└── simple.md
└── src
├── configs
├── index.js
└── settings.js
├── file_serializer.js
├── index.js
├── models
├── card.js
├── deck.js
├── media.js
└── template.js
├── parsers
├── base_parser.js
├── card_parser.js
├── md_parser.js
└── media_parser.js
├── transformer.js
└── utils.js
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "commonjs": true,
5 | "es6": true
6 | },
7 | "extends": "airbnb-base",
8 | "globals": {
9 | "Atomics": "readonly",
10 | "SharedArrayBuffer": "readonly"
11 | },
12 | "parserOptions": {
13 | "ecmaVersion": 2018
14 | },
15 | "rules": {
16 | "class-methods-use-this": "off",
17 | "no-console": "off",
18 | "key-spacing": ["error", { "align": "colon" }],
19 | "no-await-in-loop": "off",
20 | "no-restricted-syntax": "off",
21 | "no-multi-spaces": ["error", {
22 | "exceptions": {
23 | "VariableDeclarator": true,
24 | "PropertyAssignment": true,
25 | "AssignmentExpression": true
26 | }
27 | }]
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.apkg
2 | .vscode
3 | node_modules
4 | tmp
5 | .DS_Store
6 | coverage
7 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # MDAnki Changelog
2 |
3 | #### v1.1.0 - 2020.12.14
4 | * Add remote media loading ([#20](https://github.com/ashlinchak/mdanki/pull/20))
5 | #### v1.0.2 - 2020.02.21
6 | * Prefer the explicit deck name over calculated ([#2](https://github.com/ashlinchak/mdanki/pull/2))
7 |
8 | #### v1.0.1 - 2020.02.21
9 | * Add tests ([#1](https://github.com/ashlinchak/mdanki/pull/1))
10 | * Fix minor bugs ([#1](https://github.com/ashlinchak/mdanki/pull/1))
11 |
12 | #### v0.0.1 - 2020.02.11
13 | * Initial release
14 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # MDAnki
2 |
3 | Converts Markdown file(s) to the Anki cards.
4 |
5 | - [MDAnki](#mdanki)
6 | - [Requirements](#requirements)
7 | - [Install](#install)
8 | - [Usage](#usage)
9 | - [Overriding default settings](#overriding-default-settings)
10 | - [Supported files](#supported-files)
11 | - [Cards](#cards)
12 | - [Tags](#tags)
13 | - [Code and syntax highlighting](#code-and-syntax-highlighting)
14 | - [Supported languages](#supported-languages)
15 | - [Images](#images)
16 | - [LaTeX](#latex)
17 | - [Memory limit](#memory-limit)
18 | - [License](#license)
19 | - [Changelog](#changelog)
20 |
21 | ## Requirements
22 |
23 | Node.js v10.0+
24 |
25 | ## Install
26 | ```bash
27 | npm install -g mdanki
28 | ```
29 |
30 | ## Usage
31 |
32 | Convert a single markdown file:
33 |
34 | ```bash
35 | mdanki library.md anki.apkg
36 | ```
37 |
38 | Convert files from directory recursively:
39 |
40 | ```bash
41 | mdanki ./documents/library ./documents/anki.apkg
42 | ```
43 |
44 | Using all available options:
45 |
46 | ```bash
47 | mdanki library.md anki.apkg --deck Library --config config.json
48 | ```
49 |
50 | Import just generated `.apkg` file to Anki ("File" - "Import").
51 |
52 | ## Overriding default settings
53 |
54 | To override [default settings](./src/configs/settings.js) use `--config` option:
55 |
56 | ```bash
57 | mdanki library.md anki.apkg --config faworite-settings.json
58 | ```
59 |
60 | The JSON file, for example, would look like the following if you were to change
61 | the mdanki card template to the default that Anki has:
62 |
63 | ```json
64 | {
65 | "template": {
66 | "formats": {
67 | "question": "{{Front}}",
68 | "answer" : "{{FrontSide}}\n\n
\n\n{{Back}}",
69 | "css" : ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}"
70 | }
71 | }
72 | }
73 | ```
74 |
75 |
76 | ## Supported files
77 |
78 | MDAnki supports `.md` and `.markdown` files.
79 |
80 | ## Cards
81 |
82 | By default, MDAnki splits cards by `## ` headline. For example, below markdown will generate 2 cards where headlines will be on the front side and its description - on the back.
83 |
84 | ```
85 | ## What's the Markdown?
86 |
87 | Markdown is a lightweight markup language with plain-text-formatting syntax.
88 | Its design allows it to be converted to many output formats,
89 | but the original tool by the same name only supports HTML.
90 |
91 | ## Who created Markdown?
92 |
93 | John Gruber created the Markdown language in 2004 in collaboration with
94 | Aaron Swartz on the syntax.
95 |
96 | ```
97 |
98 | If you want to have multiple lines on the card's front side - use `%` symbol for splitting front and back sides:
99 |
100 | ```
101 | ## YAGNI
102 |
103 | Describe this acronym and why it's so important.
104 |
105 | %
106 |
107 | "You aren't gonna need it" (YAGNI) is a principle of extreme programming
108 | (XP) that states a programmer should not add functionality until deemed
109 | necessary.
110 |
111 | ```
112 |
113 | When parsing only one markdown file, the title of the deck could be generated based on the top-level headline (`# `).
114 |
115 | ## Tags
116 |
117 | Cards can have tags in their markdown sources. For adding tags to cart it should follow some rules:
118 | * tags start from a new line
119 | * only one line with tags per card
120 | * a tag should be written in the link format
121 | * tag (link text) should start from `#` symbol
122 |
123 | MDAnki uses `'^\\[#(.*)\\]'` pattern for searching tags. This pattern could be overwritten by specifying custom settings. The source file in the tag link is optional.
124 |
125 | The below example will generate a card with 3 tags: _algorithms_, _OOP_, and _binary_tree_.
126 |
127 | ```
128 | ## Binary tree
129 |
130 | In computer science, a binary tree is a tree data structure in which each node has at most two children, which are referred to as the left child and the right child.
131 |
132 | [#algorithms](./algorityms.md) [#OOP]() [#binary tree]()
133 | ```
134 |
135 | ## Code and syntax highlighting
136 |
137 | Code blocks can be written with and without specifying a language name:
138 |
139 |
140 | ```java
141 | public static void main(String[] args) {
142 | System.out.println("Hello, World!");
143 | }
144 | ```
145 |
146 |
147 | ```
148 | echo "Hello, World!"
149 | ```
150 |
151 |
152 | The last code block will be treated by MDAnki as Bash code. The default language can be configured by specifying `--config` with an appropriate **defaultLanguage** [setting](../src/configs/settings.js).
153 |
154 | **Note!** Creating a block without language name is not fully supported and should be eliminated in usage. Take a look at this:
155 | ```bash
156 | echo "Code block with language name"
157 | ```
158 | ```
159 | echo "Code block without language name"
160 | ```
161 |
162 | ## Supported languages
163 |
164 | MDAnki supports code highlighting for these languages:
165 |
166 | > actionscript, applescript, aspnet, bash, basic, batch, c, coffeescript, cpp, csharp, d, dart, erlang, fsharp, go, graphql, groovy, handlebars, java, json, latex, less, livescript, lua, makefile, markdown, markup-templating, nginx, objectivec, pascal, perl, php, powershell, python, r, ruby, rust, sass, scheme, smalltalk, smarty, sql, stylus, swift, typescript, vim, yaml.
167 |
168 |
169 | ## Images
170 |
171 | You can use links to image files inside markdown, MDAnki will parse them and add those images to the import collection. It's allowed to use two styles for writing images:
172 |
173 | 1. Inline:
174 | 
175 |
176 | 1. Reference:
177 | ![alt text][ROR]
178 |
179 | [ROR]: samples/resources/ruby_on_rails.png "Logo Title Text 2"
180 |
181 | ## LaTeX
182 |
183 | MDAnki and Anki can support LaTeX. Install LaTeX for your OS and use the `[latex]` attribute within Markdown files.
184 |
185 | ```
186 | [latex]\\[e^x -1 = 3\\][/latex]
187 | ```
188 |
189 | ## Memory limit
190 |
191 | Converting a big Markdown file you can get a memory limit error like this:
192 |
193 | > Cannot enlarge memory arrays. Either (1) compile with -s TOTAL_MEMORY=X with X higher than the current value 16777216...
194 |
195 | For overcoming this error, replace `sql.js`:
196 |
197 | ```bash
198 | cp node_modules/sql.js/js/sql-memory-growth.js node_modules/sql.js/js/sql.js
199 | ```
200 |
201 | More info [here](https://github.com/sql-js/sql.js#versions-of-sqljs-included-in-the-distributed-artifacts).
202 |
203 | ## License
204 | MIT License, Copyright (c) 2020, Oleksandr Shlinchak.
205 |
206 | ## Changelog
207 | [Changelog](./CHANGELOG.md)
208 |
209 |
--------------------------------------------------------------------------------
/__mocks__/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "jest": true
4 | },
5 | "rules": {
6 | "no-underscore-dangle": "off",
7 | "global-require": "off"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/__mocks__/anki-apkg-export.js:
--------------------------------------------------------------------------------
1 | const ankiExport = jest.genMockFromModule('anki-apkg-export');
2 |
3 | let saveReturnValue = null;
4 |
5 | function __setSaveReturnValue(value) {
6 | saveReturnValue = value;
7 | }
8 | ankiExport.default.mockImplementation(() => ({
9 | addCard : jest.fn(),
10 | addMedia: jest.fn(),
11 | save : jest.fn(() => saveReturnValue),
12 | }));
13 |
14 | ankiExport.__setSaveReturnValue = __setSaveReturnValue;
15 |
16 | module.exports = ankiExport;
17 |
--------------------------------------------------------------------------------
/__tests__/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "jest": true
4 | },
5 | "rules": {
6 | "no-underscore-dangle": "off",
7 | "global-require": "off",
8 | "import/order": "off"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/__tests__/configs/index.test.js:
--------------------------------------------------------------------------------
1 | jest.mock('../../src/configs/settings', () => ({
2 | setting: 'value',
3 | }));
4 |
5 |
6 | describe('configs', () => {
7 | let configs;
8 |
9 | afterEach(() => {
10 | jest.restoreAllMocks();
11 | jest.resetModules();
12 | });
13 |
14 | describe('without provided config file', () => {
15 | beforeEach(() => {
16 | jest.mock('yargs', () => ({
17 | argv: {},
18 | }));
19 | });
20 |
21 | test('returns default settings', () => {
22 | configs = require('../../src/configs');
23 |
24 | expect(configs).toEqual({ setting: 'value' });
25 | });
26 | });
27 |
28 | describe('with provided config file', () => {
29 | beforeEach(() => {
30 | jest.mock('fs', () => ({
31 | readFileSync: () => '{"setting":"another value"}',
32 | }));
33 | jest.mock('yargs', () => ({
34 | argv: { config: './config.json' },
35 | }));
36 | });
37 |
38 | test('returns overridden settings', () => {
39 | configs = require('../../src/configs');
40 |
41 | expect(configs).toEqual({ setting: 'another value' });
42 | });
43 |
44 | test('handles an error', () => {
45 | jest.spyOn(JSON, 'parse').mockImplementation(() => {
46 | throw new Error('cannot parse');
47 | });
48 | jest.spyOn(console, 'log').mockImplementationOnce(() => {});
49 |
50 | configs = require('../../src/configs');
51 |
52 | expect(console.log).toHaveBeenCalledTimes(1);
53 | expect(console.log.mock.calls[0][0].message).toEqual('cannot parse');
54 | });
55 | });
56 | });
57 |
--------------------------------------------------------------------------------
/__tests__/file_serializer.test.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const FileSerializer = require('../src/file_serializer');
3 |
4 | jest.mock('fs');
5 |
6 |
7 | describe('FileSerializer', () => {
8 | let serializer;
9 | const filePath = 'file.md';
10 | const mediaPath = 'image.png';
11 | let markdown;
12 |
13 | afterEach(() => {
14 | jest.restoreAllMocks();
15 | });
16 |
17 | describe('#transform', () => {
18 | const readFileSyncMock = (p) => {
19 | if (p === filePath) { return markdown; }
20 | if (p.includes(mediaPath)) { return 'data'; }
21 | return null;
22 | };
23 |
24 | beforeEach(() => {
25 | markdown = `# Deck name\n## Title\nbody\n`;
26 | jest.spyOn(fs, 'readFileSync').mockImplementation(readFileSyncMock);
27 | });
28 |
29 | test('serializes a markdown to the deck data', async () => {
30 | serializer = new FileSerializer(filePath);
31 |
32 | const {
33 | deckName,
34 | cards,
35 | media,
36 | } = await serializer.transform();
37 |
38 | expect(deckName).toEqual('Deck name');
39 | expect(cards.length).toEqual(1);
40 | expect(cards[0].front.replace(/\n/g, '')).toEqual('Title ');
41 | expect(cards[0].back.replace(/\n/g, '')).toEqual('body
');
42 | expect(media.length).toEqual(1);
43 | expect(media[0].data).toEqual('data');
44 | expect(media[0].fileName).toEqual('8d777f385d3dfec8815d20f7496026dc.png');
45 | });
46 |
47 | test('returns without a deck name if it\'s not specified in the markdown', async () => {
48 | markdown = `## Title\nbody\n`;
49 | serializer = new FileSerializer(filePath);
50 |
51 | const { deckName } = await serializer.transform();
52 |
53 | expect(deckName).toEqual(null);
54 | });
55 |
56 | test('keeps media unique', async () => {
57 | markdown = `## Title\nbody\n`;
58 | serializer = new FileSerializer(filePath);
59 |
60 | const { media } = await serializer.transform();
61 |
62 | expect(media.length).toEqual(1);
63 | });
64 | });
65 | });
66 |
--------------------------------------------------------------------------------
/__tests__/models/card.test.js:
--------------------------------------------------------------------------------
1 | const Card = require('../../src/models/card');
2 |
3 | describe('Card', () => {
4 | let card;
5 |
6 | beforeEach(() => {
7 | card = new Card('front', 'back');
8 | });
9 |
10 | describe('#addTag', () => {
11 | test('adds a tag to tags', () => {
12 | card.addTag('tag');
13 |
14 | expect(card.tags).toEqual(['tag']);
15 | });
16 |
17 | test('sanitizes tag names', () => {
18 | card.addTag(' tag1 ');
19 | card.addTag('tag 2');
20 |
21 | expect(card.tags[0]).toEqual('tag1');
22 | expect(card.tags[1]).toEqual('tag_2');
23 | });
24 |
25 | test('does not add empty string', () => {
26 | card.addTag(' ');
27 |
28 | expect(card.tags.length).toEqual(0);
29 | });
30 | });
31 | });
32 |
--------------------------------------------------------------------------------
/__tests__/models/deck.test.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const ankiExport = require('anki-apkg-export');
3 |
4 | const Deck = require('../../src/models/deck');
5 | const Card = require('../../src/models/card');
6 | const Media = require('../../src/models/media');
7 |
8 | jest.mock('fs');
9 | jest.mock('anki-apkg-export');
10 |
11 |
12 | describe('Deck', () => {
13 | let deck;
14 |
15 | beforeEach(() => {
16 | deck = new Deck('deck name');
17 | });
18 |
19 | afterEach(() => {
20 | jest.restoreAllMocks();
21 | });
22 |
23 | describe('#addCard', () => {
24 | test('adds a card to the collection', () => {
25 | deck.addCard('card');
26 |
27 | expect(deck.cards.length).toEqual(1);
28 | });
29 | });
30 |
31 | describe('#addMedia', () => {
32 | test('adds a media item to the collection', () => {
33 | deck.addMedia('media');
34 |
35 | expect(deck.mediaCollection.length).toEqual(1);
36 | });
37 | });
38 |
39 | describe('#save', () => {
40 | beforeEach(() => {
41 | ankiExport.__setSaveReturnValue('zip');
42 | jest.spyOn(console, 'log').mockImplementation(() => {});
43 | deck = new Deck('deck name');
44 | deck.addCard(new Card('front', 'back', ['tag', 'another_tag']));
45 | deck.addMedia(new Media('data', 'image.png'));
46 | });
47 |
48 | test('exports a card and a media item to the apkg file', async () => {
49 | await deck.save('anki.apkg');
50 |
51 | expect(fs.writeFileSync).toHaveBeenCalledWith('anki.apkg', 'zip', 'binary');
52 | expect(console.log).toHaveBeenCalledWith('The deck "deck name" has been generated in anki.apkg');
53 | });
54 |
55 | test('catches an error', async () => {
56 | jest.spyOn(fs, 'writeFileSync').mockImplementationOnce(() => { throw new Error('Cannot write file'); });
57 |
58 | await deck.save('anki.apkg');
59 |
60 | expect(console.log).toHaveBeenCalledTimes(1);
61 | expect(console.log.mock.calls[0][0].message).toEqual('Cannot write file');
62 | });
63 | });
64 | });
65 |
--------------------------------------------------------------------------------
/__tests__/models/media.test.js:
--------------------------------------------------------------------------------
1 | const Media = require('../../src/models/media');
2 |
3 | describe('Media', () => {
4 | let media;
5 |
6 | beforeEach(() => {
7 | media = new Media('data', 'image.png');
8 | });
9 |
10 | describe('#checksum', () => {
11 | test('generates a digest', () => {
12 | expect(media.checksum).toEqual('8d777f385d3dfec8815d20f7496026dc');
13 | });
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/__tests__/parsers/card_parser.test.js:
--------------------------------------------------------------------------------
1 | const CardParser = require('../../src/parsers/card_parser');
2 |
3 |
4 | describe('CardParser', () => {
5 | let parser;
6 | const markdown = '## Title\nbody\n[#tag]()';
7 | const markdownWithMultipleLines = '## Title\nfront\n%\nback\n[#tag]()';
8 |
9 | beforeEach(() => {
10 | parser = new CardParser();
11 | });
12 |
13 | afterEach(() => {
14 | jest.restoreAllMocks();
15 | });
16 |
17 | describe('#parse', () => {
18 | test('returns null for a blank string', async () => {
19 | const data = await parser.parse(' ');
20 |
21 | expect(data).toEqual(null);
22 | });
23 |
24 | test('returns null when undefined is passed', async () => {
25 | const data = await parser.parse(undefined);
26 |
27 | expect(data).toEqual(null);
28 | });
29 |
30 | test('creates a card with HTML sides', async () => {
31 | const card = await parser.parse(markdown);
32 |
33 | expect(card.front).toEqual('Title \n');
34 | expect(card.back).toEqual('body
\n');
35 | expect(card.tags.length).toEqual(1);
36 | expect(card.tags[0]).toEqual('tag');
37 | });
38 |
39 | test('creates a card with raw sides', async () => {
40 | parser.options.convertToHtml = false;
41 | const card = await parser.parse(markdown);
42 |
43 | expect(card.front).toEqual('## Title');
44 | expect(card.back).toEqual('body');
45 | expect(card.tags.length).toEqual(1);
46 | expect(card.tags[0]).toEqual('tag');
47 | });
48 |
49 | test('creates a multi-line card with HTML sides', async () => {
50 | const card = await parser.parse(markdownWithMultipleLines);
51 |
52 | expect(card.front.replace(/\n/g, '')).toEqual('Title front
');
53 | expect(card.back).toEqual('back
\n');
54 | expect(card.tags.length).toEqual(1);
55 | expect(card.tags[0]).toEqual('tag');
56 | });
57 |
58 | test('skips first blank lines for back', async () => {
59 | const card = await parser.parse('## Title\n \nbody');
60 |
61 | expect(card.front.replace(/\n/g, '')).toEqual('Title ');
62 | expect(card.back.replace(/\n/g, '')).toEqual('body
');
63 | });
64 | });
65 | });
66 |
--------------------------------------------------------------------------------
/__tests__/parsers/md_parser.test.js:
--------------------------------------------------------------------------------
1 | const MdParser = require('../../src/parsers/md_parser');
2 |
3 | jest.mock('marked');
4 | jest.mock('prismjs');
5 | const marked = require('marked');
6 | const Prism = require('prismjs');
7 |
8 | describe('MdParser', () => {
9 | let parser;
10 |
11 | beforeEach(() => {
12 | parser = new MdParser();
13 | });
14 |
15 | afterEach(() => {
16 | jest.restoreAllMocks();
17 | });
18 |
19 | describe('#parse', () => {
20 | test('calls the marked parse method with the specified string', async () => {
21 | jest.spyOn(marked, 'parse').mockImplementation((str, cb) => {
22 | cb(null, str);
23 | });
24 |
25 | const data = await parser.parse('string');
26 |
27 | expect(data).toEqual('string');
28 | });
29 |
30 | test('returns an error', async () => {
31 | jest.spyOn(marked, 'parse').mockImplementation((str, cb) => {
32 | cb(new Error('cannot parse'));
33 | });
34 |
35 | await parser.parse('string')
36 | .catch((err) => {
37 | expect(err.message).toEqual('cannot parse');
38 | });
39 | });
40 | });
41 |
42 | describe('#highlight', () => {
43 | const originLanguages = Prism.languages;
44 |
45 | beforeEach(() => {
46 | Prism.languages = {
47 | js : 'js lang',
48 | bash: 'bash lang',
49 | };
50 | jest.spyOn(Prism, 'highlight').mockReturnValue('highlighted code');
51 | });
52 |
53 | afterEach(() => {
54 | Prism.languages = originLanguages;
55 | });
56 |
57 | test('calls the prism highlight method', () => {
58 | const data = parser.highlight('code', 'js');
59 |
60 | expect(data).toEqual('highlighted code');
61 | });
62 |
63 | test('calls the prism highlight method with a default language', () => {
64 | const data = parser.highlight('code', null);
65 |
66 | expect(data).toEqual('highlighted code');
67 | expect(Prism.highlight).toHaveBeenCalledWith('code', 'bash lang', 'bash');
68 | });
69 |
70 | test('returns a raw code for not aware language in prism', () => {
71 | const data = parser.highlight('code', 'fake');
72 |
73 | expect(data).toEqual('code');
74 | });
75 | });
76 | });
77 |
--------------------------------------------------------------------------------
/__tests__/parsers/media_parser.test.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const axios = require('axios');
3 |
4 | const MediaParser = require('../../src/parsers/media_parser');
5 |
6 | jest.mock('fs');
7 | jest.mock('axios');
8 |
9 | describe('MediaParser', () => {
10 | let parser;
11 |
12 | beforeEach(() => {
13 | parser = new MediaParser('source.md');
14 | });
15 |
16 | afterEach(() => {
17 | jest.restoreAllMocks();
18 | });
19 |
20 | describe('#parse', () => {
21 | test('returns a blank string', async () => {
22 | const data = await parser.parse('');
23 |
24 | expect(data).toEqual('');
25 | });
26 |
27 | test('returns the same card data', async () => {
28 | const data = await parser.parse('Title ');
29 |
30 | expect(data).toEqual('Title ');
31 | });
32 |
33 | test('parses locale media file', async () => {
34 | jest.spyOn(fs, 'readFileSync').mockImplementationOnce(() => 'data');
35 |
36 | const data = await parser.parse(' ');
37 |
38 | expect(data).toEqual(' ');
39 | expect(parser.mediaList.length).toEqual(1);
40 | expect(parser.mediaList[0].data).toEqual('data');
41 | expect(parser.mediaList[0].fileName).toEqual('8d777f385d3dfec8815d20f7496026dc.png');
42 | });
43 |
44 | test('parses remote media file', async () => {
45 | jest.spyOn(axios, 'get').mockImplementationOnce(() => ({
46 | data: 'data',
47 | }));
48 |
49 | const data = await parser.parse(' ');
50 |
51 | expect(data).toEqual(' ');
52 | expect(parser.mediaList.length).toEqual(1);
53 | expect(parser.mediaList[0].data).toEqual('data');
54 | expect(parser.mediaList[0].fileName).toEqual('8d777f385d3dfec8815d20f7496026dc.png');
55 | });
56 | });
57 | });
58 |
--------------------------------------------------------------------------------
/__tests__/transformer.test.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const glob = require('glob');
3 |
4 | let Transformer = require('../src/transformer');
5 | const Deck = require('../src/models/deck');
6 |
7 | jest.mock('fs');
8 | jest.mock('glob');
9 |
10 |
11 | describe('Transformer', () => {
12 | let transformer;
13 | const sourceFilePath = 'file.md';
14 |
15 | describe('#transform', () => {
16 | const markdown = '# Deck title\n## Title\nbody\n';
17 | const sourceDirectoryPath = '/path/to/directory';
18 |
19 | beforeEach(() => {
20 | jest.spyOn(console, 'log').mockImplementationOnce(() => {});
21 | jest.spyOn(process, 'exit').mockImplementationOnce(() => {});
22 | });
23 |
24 | describe('validations', () => {
25 | test('validates presence of source file', async () => {
26 | transformer = new Transformer('fake/path', 'path/to/anki.apkg');
27 | jest.spyOn(transformer, 'transformToDeck').mockResolvedValue();
28 | jest.spyOn(transformer, 'validateExt').mockImplementation();
29 |
30 | await transformer.transform();
31 |
32 | expect(console.log).toHaveBeenLastCalledWith('fake/path does not exists');
33 | expect(process.exit).toHaveBeenLastCalledWith(1);
34 | });
35 |
36 | test('validates file extension', async () => {
37 | transformer = new Transformer('path/to/file.txt', 'path/to/anki.apkg');
38 | jest.spyOn(transformer, 'transformToDeck').mockResolvedValue();
39 | jest.spyOn(transformer, 'validatePath').mockImplementation();
40 |
41 | await transformer.transform();
42 |
43 | expect(console.log).toHaveBeenLastCalledWith('path/to/file.txt has not allowed extension');
44 | expect(process.exit).toHaveBeenLastCalledWith(1);
45 | });
46 | });
47 |
48 | describe('parse a directory', () => {
49 | beforeEach(() => {
50 | jest.spyOn(Transformer.prototype, 'addResourcesToDeck').mockImplementation();
51 | jest.spyOn(Transformer.prototype, 'calculateDeckName').mockReturnValue('deck name');
52 | jest.spyOn(Deck.prototype, 'save').mockResolvedValue({
53 | save: jest.fn(),
54 | });
55 | fs.lstatSync.mockReturnValue({
56 | isDirectory: () => true,
57 | });
58 | fs.readFileSync.mockReturnValue(markdown);
59 | fs.existsSync.mockReturnValue(true);
60 | glob.sync.mockReturnValue([sourceFilePath]);
61 | });
62 |
63 | beforeEach(async () => {
64 | transformer = new Transformer(sourceDirectoryPath, 'path/to/anki.apkg');
65 | await transformer.transform();
66 | });
67 |
68 | test('creates a deck', () => {
69 | expect(transformer.deck.save).toHaveBeenCalledWith('path/to/anki.apkg');
70 | });
71 |
72 | test('sets the default deck name', () => {
73 | expect(transformer.deck.name).toEqual('deck name');
74 | });
75 | });
76 |
77 | describe('parse a file', () => {
78 | beforeEach(() => {
79 | jest.spyOn(Transformer.prototype, 'addResourcesToDeck').mockImplementation();
80 | jest.spyOn(Deck.prototype, 'save').mockResolvedValue({
81 | save: jest.fn(),
82 | });
83 | fs.lstatSync.mockReturnValue({
84 | isDirectory: () => false,
85 | });
86 | fs.readFileSync.mockReturnValue(markdown);
87 | fs.existsSync.mockReturnValue(true);
88 | });
89 |
90 | beforeEach(async () => {
91 | transformer = new Transformer(sourceFilePath, 'path/to/anki.apkg');
92 | await transformer.transform();
93 | });
94 |
95 | test('creates a deck', () => {
96 | expect(transformer.deck.save).toHaveBeenCalledWith('path/to/anki.apkg');
97 | });
98 |
99 | test('sets a deck name based on the title from the markdown', () => {
100 | expect(transformer.deck.name).toEqual('deck name');
101 | });
102 | });
103 |
104 | describe('no cards found', () => {
105 | beforeEach(() => {
106 | jest.spyOn(Transformer.prototype, 'validate').mockImplementation();
107 | jest.spyOn(Transformer.prototype, 'addResourcesToDeck').mockImplementation();
108 | jest.spyOn(Deck.prototype, 'save').mockResolvedValue({
109 | save: jest.fn(),
110 | });
111 | fs.lstatSync.mockReturnValue({
112 | isDirectory: () => false,
113 | });
114 | fs.readFileSync.mockReturnValue('markdown without cards');
115 | });
116 |
117 | beforeEach(async () => {
118 | transformer = new Transformer(sourceFilePath, 'path/to/anki.apkg');
119 | await transformer.transform();
120 | });
121 |
122 | test('exits process', () => {
123 | expect(console.log).toHaveBeenLastCalledWith('No cards found. Check you markdown file(s)');
124 | expect(process.exit).toHaveBeenLastCalledWith(1);
125 | });
126 | });
127 | });
128 |
129 | describe('#calculateDeckName', () => {
130 | beforeEach(() => {
131 | jest.resetModules();
132 | });
133 |
134 | test('generates deck name from passed arguments', () => {
135 | jest.mock('yargs', () => ({
136 | argv: { deck: 'deck name' },
137 | }));
138 | Transformer = require('../src/transformer');
139 | transformer = new Transformer(sourceFilePath, 'anki.apkg');
140 |
141 | expect(
142 | transformer.calculateDeckName(),
143 | ).toEqual('deck name');
144 | });
145 |
146 | test('generates deck name from markdown title', () => {
147 | jest.mock('yargs', () => ({
148 | argv: { deck: null },
149 | }));
150 | Transformer = require('../src/transformer');
151 | transformer = new Transformer(sourceFilePath, 'anki.apkg');
152 |
153 | expect(
154 | transformer.calculateDeckName('calculated deck name'),
155 | ).toEqual('calculated deck name');
156 | });
157 |
158 | test('generates deck name from default configs', () => {
159 | jest.mock('yargs', () => ({
160 | argv: { deck: null },
161 | }));
162 | Transformer = require('../src/transformer');
163 | transformer = new Transformer(sourceFilePath, 'anki.apkg');
164 |
165 | expect(
166 | transformer.calculateDeckName(),
167 | ).toEqual('mdanki');
168 | });
169 | });
170 |
171 | describe('required resources', () => {
172 | let deck;
173 |
174 | beforeEach(() => {
175 | deck = new Deck();
176 | jest.spyOn(deck, 'addMedia');
177 | });
178 |
179 | test('adds with dark template', () => {
180 | Transformer = require('../src/transformer');
181 | transformer = new Transformer(sourceFilePath, 'anki.apkg');
182 | transformer.deck = deck;
183 |
184 | transformer.addResourcesToDeck();
185 |
186 | expect(deck.addMedia).toHaveBeenCalledTimes(3);
187 | expect(deck.addMedia.mock.calls[2][0].fileName).toEqual('_highlight_dark.css');
188 | });
189 |
190 | test('adds with default template', () => {
191 | jest.resetModules();
192 |
193 | jest.mock('fs', () => ({
194 | readFileSync: () => '{"code":{"template":"default"}}',
195 | }));
196 | jest.mock('yargs', () => ({
197 | argv: { config: './config.json' },
198 | }));
199 |
200 | Transformer = require('../src/transformer');
201 | transformer = new Transformer(sourceFilePath, 'anki.apkg');
202 | transformer.deck = deck;
203 |
204 | transformer.addResourcesToDeck();
205 |
206 | expect(deck.addMedia).toHaveBeenCalledTimes(3);
207 | expect(deck.addMedia.mock.calls[2][0].fileName).toEqual('_highlight_default.css');
208 | });
209 | });
210 | });
211 |
--------------------------------------------------------------------------------
/__tests__/utils.test.js:
--------------------------------------------------------------------------------
1 | const {
2 | sanitizeString,
3 | trimArrayStart,
4 | trimArrayEnd,
5 | trimArray,
6 | } = require('../src/utils');
7 |
8 | describe('#sanitizeString', () => {
9 | test('trims a string', () => {
10 | expect(sanitizeString(' tag ')).toEqual('tag');
11 | });
12 |
13 | test('replaces spaces with underscore', () => {
14 | expect(sanitizeString('tag 1')).toEqual('tag_1');
15 | });
16 | });
17 |
18 | describe('#trimArray', () => {
19 | test('removes empty values from both sides of the array', () => {
20 | const array = [null, 1, ''];
21 | expect(trimArray(array)).toEqual([1]);
22 | });
23 | });
24 |
25 | describe('#trimArrayStart', () => {
26 | test('removes empty values in the array from the begging', () => {
27 | const array = [null, 1, ''];
28 | expect(trimArrayStart(array)).toEqual([1, '']);
29 | });
30 | });
31 |
32 | describe('#trimArrayEnd', () => {
33 | test('removes empty values in the array from the end', () => {
34 | const array = [null, 1, ''];
35 | expect(trimArrayEnd(array)).toEqual([null, 1]);
36 | });
37 | });
38 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | const testPathIgnorePatterns = [
2 | '/node_modules/',
3 | ];
4 |
5 | module.exports = {
6 | testPathIgnorePatterns,
7 | coverageDirectory : './coverage/',
8 | testEnvironment : 'node',
9 | coverageThreshold : {
10 | global: {
11 | branches : 100,
12 | functions : 100,
13 | lines : 100,
14 | statements: 100,
15 | },
16 | },
17 | };
18 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mdanki",
3 | "version": "1.1.2",
4 | "description": "Convert markdown files to anki cards",
5 | "keywords": [
6 | "anki",
7 | "markdown"
8 | ],
9 | "main": "src/index.js",
10 | "bin": {
11 | "mdanki": "src/index.js"
12 | },
13 | "scripts": {
14 | "ci": "npm run coverage",
15 | "coverage": "jest --coverage",
16 | "test": "jest",
17 | "watch-test": "jest --coverage --watchAll"
18 | },
19 | "author": "Oleksandr Shlinchak ",
20 | "homepage": "https://github.com/ashlinchak/mdanki",
21 | "repository": {
22 | "type": "git",
23 | "url": "https://github.com/ashlinchak/mdanki.git"
24 | },
25 | "license": "MIT",
26 | "dependencies": {
27 | "anki-apkg-export": "^4.0.3",
28 | "axios": "^0.23.0",
29 | "bluebird": "^3.7.2",
30 | "glob": "^7.2.0",
31 | "lodash": "^4.17.21",
32 | "marked": "^0.8.0",
33 | "prismjs": "^1.25.0",
34 | "yargs": "^17.2.1"
35 | },
36 | "devDependencies": {
37 | "@types/jest": "^27.0.2",
38 | "eslint": "^8.0.0",
39 | "eslint-config-airbnb-base": "^14.2.1",
40 | "eslint-plugin-import": "^2.25.2",
41 | "jest": "^27.2.5"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/resources/dark.css:
--------------------------------------------------------------------------------
1 | /* PrismJS 1.19.0
2 | https://prismjs.com/download.html#themes=prism-okaidia&languages=markup+css+clike+javascript+actionscript+applescript+aspnet+bash+basic+batch+c+csharp+cpp+coffeescript+d+dart+erlang+fsharp+go+graphql+groovy+handlebars+java+json+latex+less+livescript+lua+makefile+markdown+markup-templating+nginx+objectivec+pascal+perl+php+powershell+python+r+ruby+rust+sass+scheme+smalltalk+smarty+sql+stylus+swift+typescript+vim+yaml */
3 | /**
4 | * okaidia theme for JavaScript, CSS and HTML
5 | * Loosely based on Monokai textmate theme by http://www.monokai.nl/
6 | * @author ocodia
7 | */
8 |
9 | code[class*="language-"],
10 | pre[class*="language-"] {
11 | color: #f8f8f2;
12 | background: none;
13 | text-shadow: 0 1px rgba(0, 0, 0, 0.3);
14 | font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
15 | font-size: 1em;
16 | text-align: left;
17 | white-space: pre;
18 | word-spacing: normal;
19 | word-break: normal;
20 | word-wrap: normal;
21 | line-height: 1.5;
22 |
23 | -moz-tab-size: 4;
24 | -o-tab-size: 4;
25 | tab-size: 4;
26 |
27 | -webkit-hyphens: none;
28 | -moz-hyphens: none;
29 | -ms-hyphens: none;
30 | hyphens: none;
31 | }
32 |
33 | /* Code blocks */
34 | pre[class*="language-"] {
35 | padding: 1em;
36 | margin: .5em 0;
37 | overflow: auto;
38 | border-radius: 0.3em;
39 | }
40 |
41 | :not(pre) > code[class*="language-"],
42 | pre[class*="language-"] {
43 | background: #272822;
44 | }
45 |
46 | /* Inline code */
47 | :not(pre) > code[class*="language-"] {
48 | padding: .1em;
49 | border-radius: .3em;
50 | white-space: normal;
51 | }
52 |
53 | .token.comment,
54 | .token.prolog,
55 | .token.doctype,
56 | .token.cdata {
57 | color: slategray;
58 | }
59 |
60 | .token.punctuation {
61 | color: #f8f8f2;
62 | }
63 |
64 | .token.namespace {
65 | opacity: .7;
66 | }
67 |
68 | .token.property,
69 | .token.tag,
70 | .token.constant,
71 | .token.symbol,
72 | .token.deleted {
73 | color: #f92672;
74 | }
75 |
76 | .token.boolean,
77 | .token.number {
78 | color: #ae81ff;
79 | }
80 |
81 | .token.selector,
82 | .token.attr-name,
83 | .token.string,
84 | .token.char,
85 | .token.builtin,
86 | .token.inserted {
87 | color: #a6e22e;
88 | }
89 |
90 | .token.operator,
91 | .token.entity,
92 | .token.url,
93 | .language-css .token.string,
94 | .style .token.string,
95 | .token.variable {
96 | color: #f8f8f2;
97 | }
98 |
99 | .token.atrule,
100 | .token.attr-value,
101 | .token.function,
102 | .token.class-name {
103 | color: #e6db74;
104 | }
105 |
106 | .token.keyword {
107 | color: #66d9ef;
108 | }
109 |
110 | .token.regex,
111 | .token.important {
112 | color: #fd971f;
113 | }
114 |
115 | .token.important,
116 | .token.bold {
117 | font-weight: bold;
118 | }
119 | .token.italic {
120 | font-style: italic;
121 | }
122 |
123 | .token.entity {
124 | cursor: help;
125 | }
126 |
127 |
--------------------------------------------------------------------------------
/resources/default.css:
--------------------------------------------------------------------------------
1 | /* PrismJS 1.19.0
2 | https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript+actionscript+applescript+aspnet+bash+basic+batch+c+csharp+cpp+coffeescript+d+dart+erlang+fsharp+go+graphql+groovy+handlebars+java+json+latex+less+livescript+lua+makefile+markdown+markup-templating+nginx+objectivec+pascal+perl+php+powershell+python+r+ruby+rust+sass+scheme+smalltalk+smarty+sql+stylus+swift+typescript+vim+yaml */
3 | /**
4 | * prism.js default theme for JavaScript, CSS and HTML
5 | * Based on dabblet (http://dabblet.com)
6 | * @author Lea Verou
7 | */
8 |
9 | code[class*="language-"],
10 | pre[class*="language-"] {
11 | color: black;
12 | background: none;
13 | text-shadow: 0 1px white;
14 | font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
15 | font-size: 1em;
16 | text-align: left;
17 | white-space: pre;
18 | word-spacing: normal;
19 | word-break: normal;
20 | word-wrap: normal;
21 | line-height: 1.5;
22 |
23 | -moz-tab-size: 4;
24 | -o-tab-size: 4;
25 | tab-size: 4;
26 |
27 | -webkit-hyphens: none;
28 | -moz-hyphens: none;
29 | -ms-hyphens: none;
30 | hyphens: none;
31 | }
32 |
33 | pre[class*="language-"]::-moz-selection, pre[class*="language-"] ::-moz-selection,
34 | code[class*="language-"]::-moz-selection, code[class*="language-"] ::-moz-selection {
35 | text-shadow: none;
36 | background: #b3d4fc;
37 | }
38 |
39 | pre[class*="language-"]::selection, pre[class*="language-"] ::selection,
40 | code[class*="language-"]::selection, code[class*="language-"] ::selection {
41 | text-shadow: none;
42 | background: #b3d4fc;
43 | }
44 |
45 | @media print {
46 | code[class*="language-"],
47 | pre[class*="language-"] {
48 | text-shadow: none;
49 | }
50 | }
51 |
52 | /* Code blocks */
53 | pre[class*="language-"] {
54 | padding: 1em;
55 | margin: .5em 0;
56 | overflow: auto;
57 | }
58 |
59 | :not(pre) > code[class*="language-"],
60 | pre[class*="language-"] {
61 | background: #f5f2f0;
62 | }
63 |
64 | /* Inline code */
65 | :not(pre) > code[class*="language-"] {
66 | padding: .1em;
67 | border-radius: .3em;
68 | white-space: normal;
69 | }
70 |
71 | .token.comment,
72 | .token.prolog,
73 | .token.doctype,
74 | .token.cdata {
75 | color: slategray;
76 | }
77 |
78 | .token.punctuation {
79 | color: #999;
80 | }
81 |
82 | .token.namespace {
83 | opacity: .7;
84 | }
85 |
86 | .token.property,
87 | .token.tag,
88 | .token.boolean,
89 | .token.number,
90 | .token.constant,
91 | .token.symbol,
92 | .token.deleted {
93 | color: #905;
94 | }
95 |
96 | .token.selector,
97 | .token.attr-name,
98 | .token.string,
99 | .token.char,
100 | .token.builtin,
101 | .token.inserted {
102 | color: #690;
103 | }
104 |
105 | .token.operator,
106 | .token.entity,
107 | .token.url,
108 | .language-css .token.string,
109 | .style .token.string {
110 | color: #9a6e3a;
111 | background: hsla(0, 0%, 100%, .5);
112 | }
113 |
114 | .token.atrule,
115 | .token.attr-value,
116 | .token.keyword {
117 | color: #07a;
118 | }
119 |
120 | .token.function,
121 | .token.class-name {
122 | color: #DD4A68;
123 | }
124 |
125 | .token.regex,
126 | .token.important,
127 | .token.variable {
128 | color: #e90;
129 | }
130 |
131 | .token.important,
132 | .token.bold {
133 | font-weight: bold;
134 | }
135 | .token.italic {
136 | font-style: italic;
137 | }
138 |
139 | .token.entity {
140 | cursor: help;
141 | }
142 |
143 |
--------------------------------------------------------------------------------
/resources/highlight.js:
--------------------------------------------------------------------------------
1 | document.querySelectorAll('pre code').forEach((block) => {
2 | // eslint-disable-next-line no-undef
3 | hljs.highlightBlock(block);
4 | });
5 |
--------------------------------------------------------------------------------
/resources/prism.js:
--------------------------------------------------------------------------------
1 | /* PrismJS 1.19.0
2 | https://prismjs.com/download.html#themes=prism-okaidia&languages=markup+css+clike+javascript+actionscript+applescript+aspnet+bash+basic+batch+c+csharp+cpp+coffeescript+d+dart+erlang+fsharp+go+graphql+groovy+handlebars+java+json+latex+less+livescript+lua+makefile+markdown+markup-templating+nginx+objectivec+pascal+perl+php+powershell+python+r+ruby+rust+sass+scheme+smalltalk+smarty+sql+stylus+swift+typescript+vim+yaml */
3 | var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(u){var c=/\blang(?:uage)?-([\w-]+)\b/i,n=0,C={manual:u.Prism&&u.Prism.manual,disableWorkerMessageHandler:u.Prism&&u.Prism.disableWorkerMessageHandler,util:{encode:function(e){return e instanceof _?new _(e.type,C.util.encode(e.content),e.alias):Array.isArray(e)?e.map(C.util.encode):e.replace(/&/g,"&").replace(/e.length)return;if(!(k instanceof _)){if(h&&y!=n.length-1){if(c.lastIndex=v,!(O=c.exec(e)))break;for(var b=O.index+(f&&O[1]?O[1].length:0),w=O.index+O[0].length,A=y,P=v,x=n.length;A"+r.content+""+r.tag+">"},!u.document)return u.addEventListener&&(C.disableWorkerMessageHandler||u.addEventListener("message",function(e){var n=JSON.parse(e.data),r=n.language,t=n.code,a=n.immediateClose;u.postMessage(C.highlight(t,C.languages[r],r)),a&&u.close()},!1)),C;var e=C.util.currentScript();function r(){C.manual||C.highlightAll()}if(e&&(C.filename=e.src,e.hasAttribute("data-manual")&&(C.manual=!0)),!C.manual){var t=document.readyState;"loading"===t||"interactive"===t&&e&&e.defer?document.addEventListener("DOMContentLoaded",r):window.requestAnimationFrame?window.requestAnimationFrame(r):window.setTimeout(r,16)}return C}(_self);"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism);
4 | Prism.languages.markup={comment://,prolog:/<\?[\s\S]+?\?>/,doctype:{pattern:/"'[\]]|"[^"]*"|'[^']*')+(?:\[(?:(?!)*\]\s*)?>/i,greedy:!0},cdata://i,tag:{pattern:/<\/?(?!\d)[^\s>\/=$<%]+(?:\s(?:\s*[^\s>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))|(?=[\s/>])))+)?\s*\/?>/i,greedy:!0,inside:{tag:{pattern:/^<\/?[^\s>\/]+/i,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"attr-value":{pattern:/=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+)/i,inside:{punctuation:[/^=/,{pattern:/^(\s*)["']|["']$/,lookbehind:!0}]}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:/?[\da-z]{1,8};/i},Prism.languages.markup.tag.inside["attr-value"].inside.entity=Prism.languages.markup.entity,Prism.hooks.add("wrap",function(a){"entity"===a.type&&(a.attributes.title=a.content.replace(/&/,"&"))}),Object.defineProperty(Prism.languages.markup.tag,"addInlined",{value:function(a,e){var s={};s["language-"+e]={pattern:/(^$)/i,lookbehind:!0,inside:Prism.languages[e]},s.cdata=/^$/i;var n={"included-cdata":{pattern://i,inside:s}};n["language-"+e]={pattern:/[\s\S]+/,inside:Prism.languages[e]};var t={};t[a]={pattern:RegExp("(<__[\\s\\S]*?>)(?:\\s*|[\\s\\S])*?(?=<\\/__>)".replace(/__/g,a),"i"),lookbehind:!0,greedy:!0,inside:n},Prism.languages.insertBefore("markup","cdata",t)}}),Prism.languages.xml=Prism.languages.extend("markup",{}),Prism.languages.html=Prism.languages.markup,Prism.languages.mathml=Prism.languages.markup,Prism.languages.svg=Prism.languages.markup;
5 | !function(s){var e=/("|')(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/;s.languages.css={comment:/\/\*[\s\S]*?\*\//,atrule:{pattern:/@[\w-]+[\s\S]*?(?:;|(?=\s*\{))/,inside:{rule:/^@[\w-]+/,"selector-function-argument":{pattern:/(\bselector\s*\((?!\s*\))\s*)(?:[^()]|\((?:[^()]|\([^()]*\))*\))+?(?=\s*\))/,lookbehind:!0,alias:"selector"}}},url:{pattern:RegExp("url\\((?:"+e.source+"|[^\n\r()]*)\\)","i"),inside:{function:/^url/i,punctuation:/^\(|\)$/}},selector:RegExp("[^{}\\s](?:[^{};\"']|"+e.source+")*?(?=\\s*\\{)"),string:{pattern:e,greedy:!0},property:/[-_a-z\xA0-\uFFFF][-\w\xA0-\uFFFF]*(?=\s*:)/i,important:/!important\b/i,function:/[-a-z0-9]+(?=\()/i,punctuation:/[(){};:,]/},s.languages.css.atrule.inside.rest=s.languages.css;var t=s.languages.markup;t&&(t.tag.addInlined("style","css"),s.languages.insertBefore("inside","attr-value",{"style-attr":{pattern:/\s*style=("|')(?:\\[\s\S]|(?!\1)[^\\])*\1/i,inside:{"attr-name":{pattern:/^\s*style/i,inside:t.tag.inside},punctuation:/^\s*=\s*['"]|['"]\s*$/,"attr-value":{pattern:/.+/i,inside:s.languages.css}},alias:"language-css"}},t.tag))}(Prism);
6 | Prism.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\s\S]*?(?:\*\/|$)/,lookbehind:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0,greedy:!0}],string:{pattern:/(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/(\b(?:class|interface|extends|implements|trait|instanceof|new)\s+|\bcatch\s+\()[\w.\\]+/i,lookbehind:!0,inside:{punctuation:/[.\\]/}},keyword:/\b(?:if|else|while|do|for|return|in|instanceof|function|new|try|throw|catch|finally|null|break|continue)\b/,boolean:/\b(?:true|false)\b/,function:/\w+(?=\()/,number:/\b0x[\da-f]+\b|(?:\b\d+\.?\d*|\B\.\d+)(?:e[+-]?\d+)?/i,operator:/[<>]=?|[!=]=?=?|--?|\+\+?|&&?|\|\|?|[?*/~^%]/,punctuation:/[{}[\];(),.:]/};
7 | Prism.languages.javascript=Prism.languages.extend("clike",{"class-name":[Prism.languages.clike["class-name"],{pattern:/(^|[^$\w\xA0-\uFFFF])[_$A-Z\xA0-\uFFFF][$\w\xA0-\uFFFF]*(?=\.(?:prototype|constructor))/,lookbehind:!0}],keyword:[{pattern:/((?:^|})\s*)(?:catch|finally)\b/,lookbehind:!0},{pattern:/(^|[^.]|\.\.\.\s*)\b(?:as|async(?=\s*(?:function\b|\(|[$\w\xA0-\uFFFF]|$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\b/,lookbehind:!0}],number:/\b(?:(?:0[xX](?:[\dA-Fa-f](?:_[\dA-Fa-f])?)+|0[bB](?:[01](?:_[01])?)+|0[oO](?:[0-7](?:_[0-7])?)+)n?|(?:\d(?:_\d)?)+n|NaN|Infinity)\b|(?:\b(?:\d(?:_\d)?)+\.?(?:\d(?:_\d)?)*|\B\.(?:\d(?:_\d)?)+)(?:[Ee][+-]?(?:\d(?:_\d)?)+)?/,function:/#?[_$a-zA-Z\xA0-\uFFFF][$\w\xA0-\uFFFF]*(?=\s*(?:\.\s*(?:apply|bind|call)\s*)?\()/,operator:/--|\+\+|\*\*=?|=>|&&|\|\||[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\.{3}|\?[.?]?|[~:]/}),Prism.languages.javascript["class-name"][0].pattern=/(\b(?:class|interface|extends|implements|instanceof|new)\s+)[\w.\\]+/,Prism.languages.insertBefore("javascript","keyword",{regex:{pattern:/((?:^|[^$\w\xA0-\uFFFF."'\])\s])\s*)\/(?:\[(?:[^\]\\\r\n]|\\.)*]|\\.|[^/\\\[\r\n])+\/[gimyus]{0,6}(?=(?:\s|\/\*[\s\S]*?\*\/)*(?:$|[\r\n,.;:})\]]|\/\/))/,lookbehind:!0,greedy:!0},"function-variable":{pattern:/#?[_$a-zA-Z\xA0-\uFFFF][$\w\xA0-\uFFFF]*(?=\s*[=:]\s*(?:async\s*)?(?:\bfunction\b|(?:\((?:[^()]|\([^()]*\))*\)|[_$a-zA-Z\xA0-\uFFFF][$\w\xA0-\uFFFF]*)\s*=>))/,alias:"function"},parameter:[{pattern:/(function(?:\s+[_$A-Za-z\xA0-\uFFFF][$\w\xA0-\uFFFF]*)?\s*\(\s*)(?!\s)(?:[^()]|\([^()]*\))+?(?=\s*\))/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/[_$a-z\xA0-\uFFFF][$\w\xA0-\uFFFF]*(?=\s*=>)/i,inside:Prism.languages.javascript},{pattern:/(\(\s*)(?!\s)(?:[^()]|\([^()]*\))+?(?=\s*\)\s*=>)/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/((?:\b|\s|^)(?!(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)(?![$\w\xA0-\uFFFF]))(?:[_$A-Za-z\xA0-\uFFFF][$\w\xA0-\uFFFF]*\s*)\(\s*)(?!\s)(?:[^()]|\([^()]*\))+?(?=\s*\)\s*\{)/,lookbehind:!0,inside:Prism.languages.javascript}],constant:/\b[A-Z](?:[A-Z_]|\dx?)*\b/}),Prism.languages.insertBefore("javascript","string",{"template-string":{pattern:/`(?:\\[\s\S]|\${(?:[^{}]|{(?:[^{}]|{[^}]*})*})+}|(?!\${)[^\\`])*`/,greedy:!0,inside:{"template-punctuation":{pattern:/^`|`$/,alias:"string"},interpolation:{pattern:/((?:^|[^\\])(?:\\{2})*)\${(?:[^{}]|{(?:[^{}]|{[^}]*})*})+}/,lookbehind:!0,inside:{"interpolation-punctuation":{pattern:/^\${|}$/,alias:"punctuation"},rest:Prism.languages.javascript}},string:/[\s\S]+/}}}),Prism.languages.markup&&Prism.languages.markup.tag.addInlined("script","javascript"),Prism.languages.js=Prism.languages.javascript;
8 | Prism.languages.actionscript=Prism.languages.extend("javascript",{keyword:/\b(?:as|break|case|catch|class|const|default|delete|do|else|extends|finally|for|function|if|implements|import|in|instanceof|interface|internal|is|native|new|null|package|private|protected|public|return|super|switch|this|throw|try|typeof|use|var|void|while|with|dynamic|each|final|get|include|namespace|native|override|set|static)\b/,operator:/\+\+|--|(?:[+\-*\/%^]|&&?|\|\|?|<|>>?>?|[!=]=?)=?|[~?@]/}),Prism.languages.actionscript["class-name"].alias="function",Prism.languages.markup&&Prism.languages.insertBefore("actionscript","string",{xml:{pattern:/(^|[^.])<\/?\w+(?:\s+[^\s>\/=]+=("|')(?:\\[\s\S]|(?!\2)[^\\])*\2)*\s*\/?>/,lookbehind:!0,inside:Prism.languages.markup}});
9 | Prism.languages.applescript={comment:[/\(\*(?:\(\*[\s\S]*?\*\)|[\s\S])*?\*\)/,/--.+/,/#.+/],string:/"(?:\\.|[^"\\\r\n])*"/,number:/(?:\b\d+\.?\d*|\B\.\d+)(?:e-?\d+)?\b/i,operator:[/[&=≠≤≥*+\-\/÷^]|[<>]=?/,/\b(?:(?:start|begin|end)s? with|(?:(?:does not|doesn't) contain|contains?)|(?:is|isn't|is not) (?:in|contained by)|(?:(?:is|isn't|is not) )?(?:greater|less) than(?: or equal)?(?: to)?|(?:(?:does not|doesn't) come|comes) (?:before|after)|(?:is|isn't|is not) equal(?: to)?|(?:(?:does not|doesn't) equal|equals|equal to|isn't|is not)|(?:a )?(?:ref(?: to)?|reference to)|(?:and|or|div|mod|as|not))\b/],keyword:/\b(?:about|above|after|against|apart from|around|aside from|at|back|before|beginning|behind|below|beneath|beside|between|but|by|considering|continue|copy|does|eighth|else|end|equal|error|every|exit|false|fifth|first|for|fourth|from|front|get|given|global|if|ignoring|in|instead of|into|is|it|its|last|local|me|middle|my|ninth|of|on|onto|out of|over|prop|property|put|repeat|return|returning|second|set|seventh|since|sixth|some|tell|tenth|that|the|then|third|through|thru|timeout|times|to|transaction|true|try|until|where|while|whose|with|without)\b/,class:{pattern:/\b(?:alias|application|boolean|class|constant|date|file|integer|list|number|POSIX file|real|record|reference|RGB color|script|text|centimetres|centimeters|feet|inches|kilometres|kilometers|metres|meters|miles|yards|square feet|square kilometres|square kilometers|square metres|square meters|square miles|square yards|cubic centimetres|cubic centimeters|cubic feet|cubic inches|cubic metres|cubic meters|cubic yards|gallons|litres|liters|quarts|grams|kilograms|ounces|pounds|degrees Celsius|degrees Fahrenheit|degrees Kelvin)\b/,alias:"builtin"},punctuation:/[{}():,¬«»《》]/};
10 | Prism.languages.csharp=Prism.languages.extend("clike",{keyword:/\b(?:abstract|add|alias|as|ascending|async|await|base|bool|break|byte|case|catch|char|checked|class|const|continue|decimal|default|delegate|descending|do|double|dynamic|else|enum|event|explicit|extern|false|finally|fixed|float|for|foreach|from|get|global|goto|group|if|implicit|in|int|interface|internal|into|is|join|let|lock|long|namespace|new|null|object|operator|orderby|out|override|params|partial|private|protected|public|readonly|ref|remove|return|sbyte|sealed|select|set|short|sizeof|stackalloc|static|string|struct|switch|this|throw|true|try|typeof|uint|ulong|unchecked|unsafe|ushort|using|value|var|virtual|void|volatile|where|while|yield)\b/,string:[{pattern:/@("|')(?:\1\1|\\[\s\S]|(?!\1)[^\\])*\1/,greedy:!0},{pattern:/("|')(?:\\.|(?!\1)[^\\\r\n])*?\1/,greedy:!0}],"class-name":[{pattern:/\b[A-Z]\w*(?:\.\w+)*\b(?=\s+\w+)/,inside:{punctuation:/\./}},{pattern:/(\[)[A-Z]\w*(?:\.\w+)*\b/,lookbehind:!0,inside:{punctuation:/\./}},{pattern:/(\b(?:class|interface)\s+[A-Z]\w*(?:\.\w+)*\s*:\s*)[A-Z]\w*(?:\.\w+)*\b/,lookbehind:!0,inside:{punctuation:/\./}},{pattern:/((?:\b(?:class|interface|new)\s+)|(?:catch\s+\())[A-Z]\w*(?:\.\w+)*\b/,lookbehind:!0,inside:{punctuation:/\./}}],number:/\b0x[\da-f]+\b|(?:\b\d+\.?\d*|\B\.\d+)f?/i,operator:/>>=?|<<=?|[-=]>|([-+&|?])\1|~|[-+*/%&|^!=<>]=?/,punctuation:/\?\.?|::|[{}[\];(),.:]/}),Prism.languages.insertBefore("csharp","class-name",{"generic-method":{pattern:/\w+\s*<[^>\r\n]+?>\s*(?=\()/,inside:{function:/^\w+/,"class-name":{pattern:/\b[A-Z]\w*(?:\.\w+)*\b/,inside:{punctuation:/\./}},keyword:Prism.languages.csharp.keyword,punctuation:/[<>(),.:]/}},preprocessor:{pattern:/(^\s*)#.*/m,lookbehind:!0,alias:"property",inside:{directive:{pattern:/(\s*#)\b(?:define|elif|else|endif|endregion|error|if|line|pragma|region|undef|warning)\b/,lookbehind:!0,alias:"keyword"}}}}),Prism.languages.dotnet=Prism.languages.cs=Prism.languages.csharp;
11 | Prism.languages.aspnet=Prism.languages.extend("markup",{"page-directive":{pattern:/<%\s*@.*%>/i,alias:"tag",inside:{"page-directive":{pattern:/<%\s*@\s*(?:Assembly|Control|Implements|Import|Master(?:Type)?|OutputCache|Page|PreviousPageType|Reference|Register)?|%>/i,alias:"tag"},rest:Prism.languages.markup.tag.inside}},directive:{pattern:/<%.*%>/i,alias:"tag",inside:{directive:{pattern:/<%\s*?[$=%#:]{0,2}|%>/i,alias:"tag"},rest:Prism.languages.csharp}}}),Prism.languages.aspnet.tag.pattern=/<(?!%)\/?[^\s>\/]+(?:\s+[^\s>\/=]+(?:=(?:("|')(?:\\[\s\S]|(?!\1)[^\\])*\1|[^\s'">=]+))?)*\s*\/?>/i,Prism.languages.insertBefore("inside","punctuation",{directive:Prism.languages.aspnet.directive},Prism.languages.aspnet.tag.inside["attr-value"]),Prism.languages.insertBefore("aspnet","comment",{"asp-comment":{pattern:/<%--[\s\S]*?--%>/,alias:["asp","comment"]}}),Prism.languages.insertBefore("aspnet",Prism.languages.javascript?"script":"tag",{"asp-script":{pattern:/(',
18 | answer : '{{FrontSide}}\n\n \n\n{{Back}}',
19 | css : '.card {\n font-family: Arial,"Helvetica Neue",Helvetica,sans-serif;\n font-size: 16px;\n color: black;\nbackground-color: white;\n}\ncode[class*="language-"],pre[class*="language-"] {\n font-size: 0.9em !important;\n}',
20 | },
21 | },
22 | };
23 |
24 | module.exports = settings;
25 |
--------------------------------------------------------------------------------
/src/file_serializer.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-param-reassign */
2 | const fs = require('fs');
3 |
4 | const configs = require('./configs');
5 | const CardParser = require('./parsers/card_parser');
6 | const MediaParser = require('./parsers/media_parser');
7 |
8 | /**
9 | * @typedef {import('./models/card').Card} Card
10 | * @typedef {import('./models/media').Media} Media
11 | */
12 |
13 | /**
14 | * Serialize file to cards, media and deck name
15 | * @typedef {Object} ParsedData
16 | * @property {string} deckName
17 | * @property {[Card]} cards
18 | * @property {[Media]} media
19 | */
20 |
21 | class FileSerializer {
22 | /**
23 | * @param {string} source File path
24 | */
25 | constructor(source) {
26 | this.source = source;
27 | }
28 |
29 | /**
30 | * @returns {ParsedData}
31 | */
32 | async transform() {
33 | const mdString = fs.readFileSync(this.source).toString();
34 | return this.splitByCards(mdString);
35 | }
36 |
37 | /**
38 | * @param {string} mdString Markdown string
39 | * @returns {ParsedData}
40 | * @private
41 | */
42 | async splitByCards(mdString) {
43 | let rawCards = mdString
44 | .split(new RegExp(configs.card.separator, 'm'))
45 | .map((line) => line.trim());
46 |
47 | const deckName = this.deckName(rawCards);
48 |
49 | // filter out deck title
50 | rawCards = rawCards.filter((str) => !str.startsWith(configs.deck.titleSeparator));
51 |
52 | const dirtyCards = await Promise.all(rawCards.map((str) => CardParser.parse(str)));
53 | const cards = dirtyCards
54 | .filter((card) => card)
55 | // card should have front and back sides
56 | .filter((card) => card.front && card.back);
57 |
58 | // get media from markdown file
59 | const media = await this.mediaFromCards(cards);
60 |
61 | return {
62 | deckName,
63 | cards,
64 | media,
65 | };
66 | }
67 |
68 | /**
69 | * @param {[string]} rawCards The array of strings which represent cards' data
70 | * @returns {string}
71 | * @private
72 | */
73 | deckName(rawCards) {
74 | const deckName = rawCards
75 | .find((str) => str.match(new RegExp(configs.deck.titleSeparator)));
76 |
77 | if (!deckName) { return null; }
78 |
79 | return deckName.replace(/(#\s|\n)/g, '');
80 | }
81 |
82 | /**
83 | * Search media in cards and add it to the media collection
84 | * @param {[Card]} cards
85 | * @returns {[Media]}
86 | * @private
87 | */
88 | async mediaFromCards(cards) {
89 | const mediaParser = new MediaParser(this.source);
90 |
91 | for (const card of cards) {
92 | card.front = await mediaParser.parse(card.front);
93 | card.back = await mediaParser.parse(card.back);
94 | }
95 |
96 | return mediaParser.mediaList;
97 | }
98 | }
99 |
100 | module.exports = FileSerializer;
101 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | const path = require('path');
3 | const Transformer = require('./transformer');
4 |
5 |
6 | // eslint-disable-next-line no-unused-expressions
7 | require('yargs')
8 | .command(
9 | '$0 ',
10 | 'Convert Markdown file into anki\'s apkg file for importing.',
11 | () => {},
12 | async (argv) => {
13 | const transformer = new Transformer(
14 | path.resolve(argv.source),
15 | path.resolve(argv.target),
16 | );
17 | await transformer.transform();
18 | },
19 | )
20 | .example('$0 study.md anki.apk --deck Study')
21 | .option('config', {
22 | type : 'string',
23 | description: 'Configuration file location',
24 | })
25 | .option('deck', {
26 | type : 'string',
27 | description: 'Deck name',
28 | })
29 | .argv;
30 |
--------------------------------------------------------------------------------
/src/models/card.js:
--------------------------------------------------------------------------------
1 | const { sanitizeString } = require('../utils');
2 |
3 | /**
4 | * @typedef {Object} Card
5 | * @property {string} front
6 | * @property {string} back
7 | * @property {[string]} [tags=[]]
8 | */
9 |
10 | class Card {
11 | /**
12 | * @param {string} front
13 | * @param {string} back
14 | * @param {[string]} tags
15 | */
16 | constructor(front, back, tags = []) {
17 | this.front = front;
18 | this.back = back;
19 | this.tags = tags;
20 | }
21 |
22 | /**
23 | * Add tag to card in supported format
24 | * @param {string} dirtyTag
25 | * @returns {void}
26 | */
27 | addTag(dirtyTag) {
28 | const tag = sanitizeString(dirtyTag);
29 | if (tag) {
30 | this.tags.push(tag);
31 | }
32 | }
33 | }
34 |
35 | module.exports = Card;
36 |
--------------------------------------------------------------------------------
/src/models/deck.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const { default: AnkiExport } = require('anki-apkg-export');
3 |
4 | const Template = require('./template');
5 |
6 | /**
7 | * @typedef {import('./template').Template} Template
8 | * @typedef {import('./card').Card} Card
9 | * @typedef {import('./media').Media} Media
10 | * @typedef {import('anki-apkg-export').default} AnkiExport
11 | */
12 |
13 | /**
14 | * @typedef {Object} Deck
15 | * @property {string} name
16 | * @property {Template} template
17 | * @property {[Card]} cards
18 | * @property {Object} [options]
19 | */
20 |
21 | class Deck {
22 | /**
23 | * @param {string} name
24 | * @param {Template} template
25 | * @param {Object} [options={}]
26 | */
27 | constructor(name, options = {}) {
28 | this.name = name;
29 | this.options = options;
30 | this.cards = [];
31 | this.mediaCollection = [];
32 | this.template = new Template();
33 | this.ankiExport = new AnkiExport(this.name, this.template);
34 | }
35 |
36 | /**
37 | * @param {Card} card
38 | * @returns {void}
39 | */
40 | addCard(card) {
41 | this.cards.push(card);
42 | }
43 |
44 | /**
45 | * @param {Media} media
46 | * @returns {void}
47 | */
48 | addMedia(media) {
49 | this.mediaCollection.push(media);
50 | }
51 |
52 | /**
53 | * Save deck in a file which is available for importing to Anki
54 | * @param {string} target File path
55 | * @returns {void}
56 | */
57 | async save(target) {
58 | this.addDataToAnkiExporter();
59 | await this.export(target);
60 | }
61 |
62 | /**
63 | * Add cards and media to Anki exporter
64 | * @returns {void}
65 | * @private
66 | */
67 | addDataToAnkiExporter() {
68 | // cards
69 | this.cards.forEach((card) => {
70 | const { front, back, tags } = card;
71 | this.ankiExport.addCard(front, back, { tags });
72 | });
73 |
74 | // media
75 | this.mediaCollection.forEach((media) => {
76 | this.ankiExport.addMedia(media.fileName, media.data);
77 | });
78 | }
79 |
80 | /**
81 | * @param {string} target File path
82 | * @private
83 | */
84 | async export(target) {
85 | try {
86 | const zip = await this.ankiExport.save();
87 | fs.writeFileSync(target, zip, 'binary');
88 | console.log(`The deck "${this.name}" has been generated in ${target}`);
89 | } catch (error) {
90 | console.log(error);
91 | }
92 | }
93 | }
94 |
95 | module.exports = Deck;
96 |
--------------------------------------------------------------------------------
/src/models/media.js:
--------------------------------------------------------------------------------
1 | const crypto = require('crypto');
2 |
3 | /**
4 | * @typedef {Object} Media
5 | * @property {string} fileName
6 | * @property {any} data
7 | */
8 |
9 | class Media {
10 | /**
11 | * @param {any} data
12 | * @param {string} [fileName] Checksum is the default value if not other specified
13 | */
14 | constructor(data, fileName) {
15 | this.data = data;
16 | this.fileName = fileName;
17 | }
18 |
19 | /**
20 | * @returns {string} File data digest
21 | */
22 | get checksum() {
23 | return crypto
24 | .createHash('md5')
25 | .update(this.data, 'utf8')
26 | .digest('hex');
27 | }
28 | }
29 |
30 | module.exports = Media;
31 |
--------------------------------------------------------------------------------
/src/models/template.js:
--------------------------------------------------------------------------------
1 | const {
2 | question: defaultQuestion,
3 | answer: defaultAnswer,
4 | css: defaultCss,
5 | } = require('../configs').template.formats;
6 |
7 | /**
8 | * @typedef {Object} Template
9 | * @property {string} questionFormat
10 | * @property {string} answerFormat
11 | * @property {string} css
12 | */
13 |
14 | class Template {
15 | /**
16 | * @param {string} questionFormat
17 | * @param {string} answerFormat
18 | * @param {string} css
19 | */
20 | constructor(questionFormat, answerFormat, css) {
21 | this.questionFormat = questionFormat || defaultQuestion;
22 | this.answerFormat = answerFormat || defaultAnswer;
23 | this.css = css || defaultCss;
24 | }
25 | }
26 |
27 | module.exports = Template;
28 |
--------------------------------------------------------------------------------
/src/parsers/base_parser.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Base parser which should be inherited
3 | * @typedef {Object} BaseParser
4 | */
5 |
6 | class BaseParser {
7 | constructor(options = {}) {
8 | this.options = options;
9 | }
10 |
11 | /**
12 | * Create a new instance of parser and run parse() method
13 | * parse() method should be implemented in the inherited class
14 | * @param {string} data
15 | * @param {Object} options
16 | */
17 | static parse(data, options = {}) {
18 | return new this(options).parse(data);
19 | }
20 | }
21 |
22 | module.exports = BaseParser;
23 |
--------------------------------------------------------------------------------
/src/parsers/card_parser.js:
--------------------------------------------------------------------------------
1 | const BaseParser = require('./base_parser');
2 | const MdParser = require('./md_parser');
3 | const Card = require('../models/card');
4 | const configs = require('../configs');
5 | const { trimArray } = require('../utils');
6 |
7 | /**
8 | * @typedef {import('../models/card').Card} Card
9 | * @typedef {import('./base_parser').BaseParser} BaseParser
10 | * /
11 |
12 | /**
13 | * Parse a string to Card model
14 | * @typedef {Object} ParsedCardLine
15 | * @implements {BaseParser}
16 | * @property {[string]} front
17 | * @property {[string]} back
18 | * @property {[string]} tags
19 | */
20 |
21 | class CardParser extends BaseParser {
22 | constructor({ convertToHtml = true } = {}) {
23 | super({ convertToHtml });
24 | this.splitRe = new RegExp(`^${configs.card.frontBackSeparator}$`, 'm');
25 | this.tagRe = new RegExp(configs.card.tagPattern);
26 | }
27 |
28 | /**
29 | * Parse a string to Card model
30 | * @param {string} string Card in string
31 | * @returns {Promise}
32 | */
33 | async parse(string = '') {
34 | const cardLines = string
35 | .split(this.splitRe)
36 | .map((item) => item.split('\n'))
37 | .map((arr) => arr.map((str) => str.trimEnd()));
38 |
39 | // not allowed cards with only front side
40 | if (cardLines.length === 1 && !cardLines[0].filter((line) => line).length) {
41 | return null;
42 | }
43 | const { front, back, tags } = this.parseCardLines(cardLines);
44 |
45 | if (!this.options.convertToHtml) {
46 | return new Card(front.join(), back.join(), tags);
47 | }
48 | const frontHtml = await this.linesToHtml(front);
49 | const backHtml = await this.linesToHtml(back);
50 |
51 | return new Card(frontHtml, backHtml, tags);
52 | }
53 |
54 | /**
55 | * @param {[string]} cardLines
56 | * @returns {ParsedCardLine}
57 | * @private
58 | */
59 | parseCardLines(cardLines) {
60 | const front = [];
61 | const back = [];
62 | const tags = [];
63 |
64 | const fillBackAndTags = (line) => {
65 | // set tags
66 | if (this.tagRe.test(line)) {
67 | tags.push(...this.parseTags(line));
68 | return;
69 | }
70 |
71 | // set back
72 | // skip first blank lines
73 | if (back.length === 0 && !line) { return; }
74 |
75 | back.push(line);
76 | };
77 |
78 | if (cardLines.length === 1) {
79 | trimArray(cardLines[0])
80 | .forEach((line) => {
81 | // we should set front first
82 | if (front.length === 0) {
83 | front.push(line);
84 | return;
85 | }
86 |
87 | fillBackAndTags(line);
88 | });
89 | } else {
90 | // front card has multiple lines
91 | front.push(...cardLines[0]);
92 |
93 | trimArray(cardLines[1])
94 | .forEach((line) => fillBackAndTags(line));
95 | }
96 |
97 | return {
98 | front: trimArray(front),
99 | back : trimArray(back),
100 | tags : trimArray(tags),
101 | };
102 | }
103 |
104 | /**
105 | * @param {string} line
106 | * @returns {[string]}
107 | * @private
108 | */
109 | parseTags(line) {
110 | const data = line.split(' ')
111 | .map((str) => str.trim())
112 | .map((str) => {
113 | const parts = this.tagRe.exec(str);
114 | return parts[1];
115 | })
116 | .filter((str) => str);
117 |
118 | return data;
119 | }
120 |
121 | /**
122 | * Convert card lines to html
123 | * @param {[string]} lines
124 | * @returns {Promise}
125 | * @private
126 | */
127 | async linesToHtml(lines) {
128 | const string = lines.join('\n');
129 |
130 | return MdParser.parse(string);
131 | }
132 | }
133 |
134 |
135 | module.exports = CardParser;
136 |
--------------------------------------------------------------------------------
/src/parsers/md_parser.js:
--------------------------------------------------------------------------------
1 | const marked = require('marked');
2 | const Prism = require('prismjs');
3 |
4 | const BaseParser = require('./base_parser');
5 | const configs = require('../configs');
6 |
7 | // languages
8 | require('prismjs/components/prism-actionscript');
9 | require('prismjs/components/prism-applescript');
10 | require('prismjs/components/prism-aspnet');
11 | require('prismjs/components/prism-bash');
12 | require('prismjs/components/prism-basic');
13 | require('prismjs/components/prism-batch');
14 | require('prismjs/components/prism-c');
15 | require('prismjs/components/prism-coffeescript');
16 | require('prismjs/components/prism-cpp');
17 | require('prismjs/components/prism-csharp');
18 | require('prismjs/components/prism-d');
19 | require('prismjs/components/prism-dart');
20 | require('prismjs/components/prism-erlang');
21 | require('prismjs/components/prism-fsharp');
22 | require('prismjs/components/prism-go');
23 | require('prismjs/components/prism-graphql');
24 | require('prismjs/components/prism-groovy');
25 | require('prismjs/components/prism-handlebars');
26 | require('prismjs/components/prism-java');
27 | require('prismjs/components/prism-json');
28 | require('prismjs/components/prism-latex');
29 | require('prismjs/components/prism-less');
30 | require('prismjs/components/prism-livescript');
31 | require('prismjs/components/prism-lua');
32 | require('prismjs/components/prism-makefile');
33 | require('prismjs/components/prism-markdown');
34 | require('prismjs/components/prism-markup-templating');
35 | require('prismjs/components/prism-nginx');
36 | require('prismjs/components/prism-objectivec');
37 | require('prismjs/components/prism-pascal');
38 | require('prismjs/components/prism-perl');
39 | require('prismjs/components/prism-php');
40 | require('prismjs/components/prism-powershell');
41 | require('prismjs/components/prism-python');
42 | require('prismjs/components/prism-r');
43 | require('prismjs/components/prism-ruby');
44 | require('prismjs/components/prism-rust');
45 | require('prismjs/components/prism-sass');
46 | require('prismjs/components/prism-scheme');
47 | require('prismjs/components/prism-smalltalk');
48 | require('prismjs/components/prism-smarty');
49 | require('prismjs/components/prism-sql');
50 | require('prismjs/components/prism-stylus');
51 | require('prismjs/components/prism-swift');
52 | require('prismjs/components/prism-typescript');
53 | require('prismjs/components/prism-vim');
54 | require('prismjs/components/prism-yaml.min');
55 |
56 |
57 | // aliases
58 | Prism.languages['c#'] = Prism.languages.csharp;
59 | Prism.languages['f#'] = Prism.languages.fsharp;
60 | Prism.languages.sh = Prism.languages.bash;
61 | Prism.languages.md = Prism.languages.markdown;
62 | Prism.languages.py = Prism.languages.python;
63 | Prism.languages.yml = Prism.languages.yaml;
64 | Prism.languages.rb = Prism.languages.ruby;
65 |
66 | /**
67 | * @typedef {import('marked').Renderer} MarkedRenderer
68 | * @typedef {import('./base_parser').BaseParser} BaseParser
69 | */
70 |
71 | /**
72 | * Parse a markdown string to HTML
73 | * @typedef {Object} MdParser
74 | * @implements {BaseParser}
75 | * @property {MarkedRenderer} renderer
76 | */
77 |
78 | class MdParser extends BaseParser {
79 | constructor(options) {
80 | super(options);
81 | this.initMarked();
82 | this.renderer = new marked.Renderer();
83 | }
84 |
85 | /**
86 | * Highlight a code with prism.js
87 | * @param {string} code
88 | * @param {string} lang
89 | */
90 | highlight(code, lang) {
91 | const parsedLang = lang || configs.code.defaultLanguage;
92 | if (Prism.languages[parsedLang]) {
93 | return Prism.highlight(code, Prism.languages[parsedLang], parsedLang);
94 | }
95 | return code;
96 | }
97 |
98 | /**
99 | * Init marked with Prismjs
100 | * @returns {void}
101 | * @private
102 | */
103 | initMarked() {
104 | marked.setOptions({
105 | renderer : this.renderer,
106 | gfm : true,
107 | tables : true,
108 | breaks : true,
109 | pedantic : false,
110 | sanitize : false,
111 | smartLists : true,
112 | smartypants: false,
113 | mangle : false,
114 | highlight : this.highlight,
115 | });
116 | }
117 |
118 | /**
119 | *
120 | * @param {string} mdString Markdown string
121 | * @returns {Promise}
122 | */
123 | async parse(mdString) {
124 | return new Promise((resolve, reject) => {
125 | marked.parse(mdString, (err, result) => {
126 | if (err) {
127 | return reject(err);
128 | }
129 |
130 | return resolve(result);
131 | });
132 | });
133 | }
134 | }
135 |
136 | module.exports = MdParser;
137 |
--------------------------------------------------------------------------------
/src/parsers/media_parser.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const axios = require('axios');
3 | const fs = require('fs');
4 |
5 | const BaseParser = require('./base_parser');
6 | const Media = require('../models/media');
7 | const {
8 | getExtensionFromUrl,
9 | replaceAsync,
10 | } = require('../utils');
11 |
12 | /**
13 | * @typedef {import('../models/media').Media} Media
14 | * @typedef {import('./base_parser').BaseParser} BaseParser
15 | */
16 |
17 | /**
18 | * @typedef {Object} MediaParser
19 | * @property {string} source
20 | * @property {[Media]} mediaList=[]
21 | */
22 |
23 | class MediaParser extends BaseParser {
24 | constructor(source, options = {}) {
25 | super(options);
26 | this.source = source;
27 | this.mediaList = [];
28 | this.srcRe = new RegExp('src="([^"]*?)"', 'g');
29 | }
30 |
31 | /**
32 | * Prepare media from card's side
33 | * @param {string} side
34 | */
35 | parse(side) {
36 | return replaceAsync(side, this.srcRe, this.replacer.bind(this));
37 | }
38 |
39 | async replacer(match, p1) {
40 | let data;
41 | let fileExt;
42 |
43 | if (p1.startsWith('http')) {
44 | const resp = await axios.get(p1, {
45 | responseType: 'arraybuffer',
46 | });
47 | data = resp.data;
48 | fileExt = getExtensionFromUrl(p1);
49 | } else {
50 | const filePath = path.resolve(path.dirname(this.source), p1);
51 | fileExt = path.extname(filePath);
52 | data = fs.readFileSync(filePath);
53 | }
54 |
55 | const media = new Media(data);
56 | media.fileName = `${media.checksum}${fileExt}`;
57 |
58 | this.addMedia(media);
59 |
60 | return `src="${media.fileName}"`;
61 | }
62 |
63 | addMedia(media) {
64 | const hasMedia = this.mediaList.some((item) => item.checksum === media.checksum);
65 | if (hasMedia) { return; }
66 |
67 | this.mediaList.push(media);
68 | }
69 | }
70 |
71 | module.exports = MediaParser;
72 |
--------------------------------------------------------------------------------
/src/transformer.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 | const { argv } = require('yargs');
4 | const glob = require('glob');
5 | const Promise = require('bluebird');
6 |
7 | const FileSerializer = require('./file_serializer');
8 | const configs = require('./configs');
9 | const Deck = require('./models/deck');
10 | const Media = require('./models/media');
11 |
12 | const AVAILABLE_FILE_EXTENSIONS = ['.md', '.markdown'];
13 |
14 | /**
15 | * @typedef {import('./models/deck').Deck} Deck
16 | * @typedef {import('./models/card').Card} Card
17 | * @typedef {import('./models/media').Media} Media
18 | */
19 |
20 | /**
21 | * Create anki cards from markdown files
22 | * @typedef {Object} Transformer
23 | * @property {string} sourcePath Path to markdown file(s)
24 | * @property {string} targetPath Path for storing .apkg file
25 | * @property {Deck} deck
26 | */
27 |
28 | class Transformer {
29 | /**
30 | * @param {string} sourcePath Path to markdown file(s)
31 | * @param {string} targetPath Path for storing .apkg file
32 | */
33 | constructor(sourcePath, targetPath) {
34 | this.sourcePath = sourcePath;
35 | this.targetPath = targetPath;
36 | this.deck = null;
37 | }
38 |
39 | /**
40 | * Transform markdown files to .apkg file
41 | * @returns {Promise}
42 | */
43 | async transform() {
44 | this.validate();
45 | await this.transformToDeck();
46 | }
47 |
48 | /**
49 | * Transform markdown to deck
50 | * @returns {Promise}
51 | * @private
52 | */
53 | async transformToDeck() {
54 | let deckName;
55 | const cards = [];
56 | const media = [];
57 |
58 | if (fs.lstatSync(this.sourcePath).isDirectory()) {
59 | const allowedExtStr = AVAILABLE_FILE_EXTENSIONS.map((ex) => ex.replace('.', '')).join(',');
60 | const files = glob.sync(`${this.sourcePath}/**/*.{${allowedExtStr}}`);
61 |
62 | await Promise.each(files, async (file) => {
63 | const fileSerializer = new FileSerializer(file);
64 |
65 | const {
66 | cards : fileCards,
67 | media : fileMedia,
68 | } = await fileSerializer.transform();
69 | cards.push(...fileCards);
70 | media.push(...fileMedia);
71 | });
72 | } else {
73 | const fileSerializer = new FileSerializer(this.sourcePath);
74 | const {
75 | deckName: fileDeckName,
76 | cards : fileCards,
77 | media : fileMedia,
78 | } = await fileSerializer.transform();
79 | deckName = fileDeckName;
80 | cards.push(...fileCards);
81 | media.push(...fileMedia);
82 | }
83 |
84 | if (!cards.length) {
85 | console.log('No cards found. Check you markdown file(s)');
86 | process.exit(1);
87 | }
88 |
89 | this.deck = new Deck(this.calculateDeckName(deckName));
90 |
91 | await this.exportCards(cards, media);
92 | }
93 |
94 | /**
95 | * @param {string} generatedName
96 | * @returns {string} Default deck name
97 | * @private
98 | */
99 | calculateDeckName(generatedName = null) {
100 | return argv.deck || generatedName || configs.deck.defaultName;
101 | }
102 |
103 | /**
104 | * @param {[Card]} cards
105 | * @param {[Media]} media
106 | * @returns {void}
107 | * @private
108 | */
109 | async exportCards(cards, media) {
110 | this.addResourcesToDeck();
111 | this.addCardsToDeck(cards);
112 | this.addMediaItemsToDeck(media);
113 |
114 | await this.deck.save(this.targetPath);
115 | }
116 |
117 | /**
118 | * Adds required resources to deck
119 | * @returns {void}
120 | * @private
121 | */
122 | addResourcesToDeck() {
123 | // add media for code highlighting
124 | this.deck.addMedia(this.toMedia('_highlight.js', path.resolve(__dirname, '../resources/highlight.js')));
125 | this.deck.addMedia(this.toMedia('_prism.js', path.resolve(__dirname, '../resources/prism.js')));
126 |
127 | if (configs.code.template === 'dark') {
128 | this.deck.addMedia(this.toMedia('_highlight_dark.css', path.resolve(__dirname, '../resources/dark.css')));
129 | } else {
130 | this.deck.addMedia(this.toMedia('_highlight_default.css', path.resolve(__dirname, '../resources/default.css')));
131 | }
132 | }
133 |
134 | /**
135 | * @param {[Card]} cards
136 | * @returns {void}
137 | * @private
138 | */
139 | addCardsToDeck(cards) {
140 | cards.forEach((card) => this.deck.addCard(card));
141 | }
142 |
143 | /**
144 | * @param {[Media]} items
145 | * @returns {void}
146 | * @private
147 | */
148 | addMediaItemsToDeck(items) {
149 | items.forEach((item) => this.deck.addMedia(item));
150 | }
151 |
152 | /**
153 | * @param {string} fileName
154 | * @param {string} filePath
155 | * @returns {Media}
156 | * @private
157 | */
158 | toMedia(fileName, filePath) {
159 | const data = fs.readFileSync(filePath);
160 | return new Media(data, fileName);
161 | }
162 |
163 | /**
164 | * @returns {void}
165 | * @private
166 | */
167 | validate() {
168 | this.validatePath(this.sourcePath);
169 | this.validateExt(this.sourcePath);
170 | }
171 |
172 | /**
173 | * @param {string} checkPath
174 | * @returns {void|process.exit(1)}
175 | * @private
176 | */
177 | validatePath(checkPath) {
178 | if (!fs.existsSync(checkPath)) {
179 | console.log(`${checkPath} does not exists`);
180 | process.exit(1);
181 | }
182 | }
183 |
184 | /**
185 | * @param {string} filePath
186 | * @returns {void|process.exit(1)}
187 | * @private
188 | */
189 | validateExt(filePath) {
190 | const ext = path.extname(filePath);
191 |
192 | if (ext && !AVAILABLE_FILE_EXTENSIONS.includes(ext)) {
193 | console.log(`${filePath} has not allowed extension`);
194 | process.exit(1);
195 | }
196 | }
197 | }
198 |
199 |
200 | module.exports = Transformer;
201 |
--------------------------------------------------------------------------------
/src/utils.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Trim string and replaces spaces with underscore
3 | * Used for making tags
4 | * @param {string} str
5 | * @returns {string}
6 | */
7 | const sanitizeString = (str) => str
8 | .trim()
9 | .replace(/\s/g, '_');
10 |
11 | /**
12 | * Trim array from end
13 | * @param {[string]} array
14 | * @returns {[string]}
15 | */
16 | const trimArrayEnd = (array) => {
17 | const trimmedArray = [];
18 | let added = false;
19 |
20 | for (let i = array.length - 1; i >= 0; i -= 1) {
21 | if (array[i] || added) {
22 | trimmedArray.unshift(array[i]);
23 | added = true;
24 | }
25 | }
26 |
27 | return trimmedArray;
28 | };
29 |
30 | /**
31 | * Trim array from start
32 | * @param {[string]} array
33 | * @returns {[string]}
34 | */
35 | const trimArrayStart = (array) => {
36 | const trimmedArray = [];
37 | let added = false;
38 |
39 | for (let i = 0; i < array.length; i += 1) {
40 | if (array[i] || added) {
41 | trimmedArray.push(array[i]);
42 | added = true;
43 | }
44 | }
45 |
46 | return trimmedArray;
47 | };
48 |
49 | /**
50 | * Trim array
51 | * @param {[string]} array
52 | * @returns {[string]}
53 | */
54 | const trimArray = (array) => trimArrayEnd(
55 | trimArrayStart(array),
56 | );
57 |
58 | /**
59 | * Get extension from URL
60 | * @param {[string]} url
61 | * @returns {[string]}
62 | */
63 | const getExtensionFromUrl = (url) => {
64 | const extension = url
65 | .split(/[#?]/)[0]
66 | .split('.')
67 | .pop()
68 | .trim();
69 |
70 | return `.${extension}`;
71 | };
72 |
73 | async function replaceAsync(str, regex, asyncFn) {
74 | const tasks = [];
75 |
76 | // fill replacers with fake call
77 | str.replace(regex, (match, ...args) => {
78 | const promise = asyncFn(match, ...args);
79 | tasks.push(promise);
80 | });
81 |
82 | const data = await Promise.all(tasks);
83 |
84 | return str.replace(regex, () => data.shift());
85 | }
86 |
87 | module.exports = {
88 | sanitizeString,
89 | trimArrayStart,
90 | trimArrayEnd,
91 | trimArray,
92 | getExtensionFromUrl,
93 | replaceAsync,
94 | };
95 |
--------------------------------------------------------------------------------