Repository: repeat-space/anki-apkg-export
Branch: master
Commit: 8ad8d7efab5f
Files: 37
Total size: 38.9 KB
Directory structure:
gitextract_0s2q98dy/
├── .babelrc
├── .editorconfig
├── .eslintrc.json
├── .gitignore
├── .npmignore
├── .travis.yml
├── README.md
├── ava.config.js
├── examples/
│ ├── browser/
│ │ ├── .babelrc
│ │ ├── .gitignore
│ │ ├── index.html
│ │ ├── index.js
│ │ ├── package.json
│ │ └── webpack.config.js
│ ├── browser-media-ajax/
│ │ ├── .babelrc
│ │ ├── .gitignore
│ │ ├── index.html
│ │ ├── index.js
│ │ ├── package.json
│ │ └── webpack.config.js
│ ├── browser-media-file-input/
│ │ ├── .babelrc
│ │ ├── .gitignore
│ │ ├── index.html
│ │ ├── index.js
│ │ ├── package.json
│ │ └── webpack.config.js
│ └── server/
│ └── server.js
├── husky.config.js
├── package.json
├── src/
│ ├── exporter.js
│ ├── index.js
│ └── template.js
├── templates/
│ └── template.sql
└── test/
├── _helpers.js
├── exporter.js
├── fixtures/
│ └── output.apkg
└── test.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .babelrc
================================================
{
"presets": ["@babel/preset-env"]
}
================================================
FILE: .editorconfig
================================================
# http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
================================================
FILE: .eslintrc.json
================================================
{
"parser": "babel-eslint",
"globals": {
"Promise": true
},
"env": {
"browser": true,
"node": true
},
"extends": ["plugin:prettier/recommended"],
"rules": {
"arrow-parens": [
2,
"as-needed"
],
"keyword-spacing": 2,
"no-extra-semi": 2,
"no-undef": 2,
"no-unused-vars": 2,
"no-var": 2,
"prettier/prettier": [
2,
{
"printWidth": 120,
"singleQuote": true,
"trailingComma": "none"
}
],
"semi": [
2,
"always"
]
}
}
================================================
FILE: .gitignore
================================================
/node_modules/
/dist/
*.log
play.js
================================================
FILE: .npmignore
================================================
.gitignore
================================================
FILE: .travis.yml
================================================
language: node_js
node_js:
- '12'
- '10'
================================================
FILE: README.md
================================================
# anki-apkg-export
[](https://travis-ci.org/repeat-space/anki-apkg-export)
Universal module for generating decks for Anki.
Port of the Ruby gem https://github.com/albertzak/anki2
## Install
```
$ npm install anki-apkg-export --save
```
## Usage
### server
```js
const fs = require('fs');
const AnkiExport = require('anki-apkg-export').default;
const apkg = new AnkiExport('deck-name');
apkg.addMedia('anki.png', fs.readFileSync('anki.png'));
apkg.addCard('card #1 front', 'card #1 back');
apkg.addCard('card #2 front', 'card #2 back', { tags: ['nice', 'better card'] });
apkg.addCard('card #3 with image
', 'card #3 back');
apkg
.save()
.then(zip => {
fs.writeFileSync('./output.apkg', zip, 'binary');
console.log(`Package has been generated: output.pkg`);
})
.catch(err => console.log(err.stack || err));
```
### browser
Intended to be used with [`webpack`](https://github.com/webpack/webpack)
```js
const webpack = require('webpack');
module.exports = {
entry: './index.js',
module: {
loaders: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel'
},
]
},
plugins: [
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify(process.env.NODE_ENV || 'development')
},
})
],
output: {
path: __dirname,
filename: 'bundle.js'
}
};
```
Required loaders:
- [`script-loader`](https://github.com/webpack/script-loader)
```js
import { saveAs } from 'file-saver';
import AnkiExport from 'anki-apkg-export';
const apkg = new AnkiExport('deck-name');
// could be a File from or a Blob from fetch
// take a look at the example folder for a complete overview
apkg.addMedia('anki.png', file);
apkg.addCard('card #1 front', 'card #1 back');
apkg.addCard('card #2 front', 'card #2 back', { tags: ['nice', 'better card'] });
apkg.addCard('card #3 with image
', 'card #3 back');
apkg
.save()
.then(zip => {
saveAs(zip, 'output.apkg');
})
.catch(err => console.log(err.stack || err));
```
## Examples
- [server from above](examples/server)
- [browser from above](examples/browser)
- [browser usage with media attachments via ajax](examples/browser-media-ajax)
- [browser usage with media attachments via
](examples/browser-media-file-input)
## Changelog
- `v4.0.0` - expose template variables (frontside, backside and css)
- `v3.1.0` - make setting APP_ENV optional
- `v3.0.0` - add tags, ES6 refactor (breaking)
- `v2.0.0` - add media support, update jszip dependency (breaking)
- `v1.0.0` - initial rewrite
## Tips
- [issue#25](https://github.com/ewnd9/anki-apkg-export/issues/25) - Dealing with `sql.js` memory limits
## Related
- [apkg format documentation](http://decks.wikia.com/wiki/Anki_APKG_format_documentation)
- [anki-apkg-export-cli](https://github.com/ewnd9/anki-apkg-export-cli) - CLI for this module
- [anki-apkg-export-app](https://github.com/ewnd9/anki-apkg-export-app) - Simple web app to generate cards online
## License
MIT © [ewnd9](http://ewnd9.com)
================================================
FILE: ava.config.js
================================================
export default {
require: ['@babel/register', '@babel/polyfill']
};
================================================
FILE: examples/browser/.babelrc
================================================
{
"presets": ["es2015"]
}
================================================
FILE: examples/browser/.gitignore
================================================
node_modules
bundle.js
================================================
FILE: examples/browser/index.html
================================================
================================================
FILE: examples/browser/index.js
================================================
import { saveAs } from 'file-saver';
import AnkiExport from '../../src';
// import AnkiExport from 'anki-apkg-export';
const apkg = new AnkiExport('deck-name');
apkg.addCard('card #1 front', 'card #1 back');
apkg.addCard('card #2 front', 'card #2 back');
apkg
.save()
.then(zip => {
saveAs(zip, 'output.apkg');
})
.catch(err => console.log(err.stack || err));
================================================
FILE: examples/browser/package.json
================================================
{
"private": true,
"scripts": {
"build": "webpack"
},
"dependencies": {
"anki-apkg-export": "^3.0.1",
"babel-loader": "^6.2.4",
"babel-preset-es2015": "^6.6.0",
"file-saver": "^1.3.3"
},
"devDependencies": {
"script-loader": "^0.6.1",
"webpack": "^1.12.14"
}
}
================================================
FILE: examples/browser/webpack.config.js
================================================
'use strict';
const webpack = require('webpack');
module.exports = {
entry: './index.js',
module: {
loaders: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel'
},
]
},
plugins: [
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify(process.env.NODE_ENV || 'development'),
APP_ENV: JSON.stringify('browser')
},
})
],
output: {
path: __dirname,
filename: 'bundle.js'
}
};
================================================
FILE: examples/browser-media-ajax/.babelrc
================================================
{
"presets": ["es2015"]
}
================================================
FILE: examples/browser-media-ajax/.gitignore
================================================
node_modules
bundle.js
================================================
FILE: examples/browser-media-ajax/index.html
================================================
================================================
FILE: examples/browser-media-ajax/index.js
================================================
import { saveAs } from 'file-saver';
import AnkiExport from '../../src';
// import AnkiExport from 'anki-apkg-export';
const apkg = new AnkiExport('deck-name-ajax');
const params = {
method: 'GET'
};
fetch('https://raw.githubusercontent.com/ewnd9/anki-apkg-export/39ebdd664ab23b5237eee95b7dd88c457e263a20/example/assets/anki.png', params)
.then(function(response) {
return response.blob();
})
.then(function(myBlob) {
apkg.addMedia('anki.png', myBlob);
apkg.addCard('card #1 with image
', 'card #1 back');
return apkg.save()
})
.then(function(zip) {
saveAs(zip, 'output.apkg');
})
.catch(err => console.log(err.stack || err));
================================================
FILE: examples/browser-media-ajax/package.json
================================================
{
"private": true,
"scripts": {
"build": "webpack"
},
"dependencies": {
"anki-apkg-export": "^3.0.1",
"babel-loader": "^6.2.4",
"babel-preset-es2015": "^6.6.0",
"file-saver": "^1.3.3"
},
"devDependencies": {
"script-loader": "^0.6.1",
"webpack": "^1.12.14"
}
}
================================================
FILE: examples/browser-media-ajax/webpack.config.js
================================================
'use strict';
const webpack = require('webpack');
module.exports = {
entry: './index.js',
module: {
loaders: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel'
},
]
},
plugins: [
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify(process.env.NODE_ENV || 'development'),
APP_ENV: JSON.stringify('browser')
},
})
],
output: {
path: __dirname,
filename: 'bundle.js'
}
};
================================================
FILE: examples/browser-media-file-input/.babelrc
================================================
{
"presets": ["es2015"]
}
================================================
FILE: examples/browser-media-file-input/.gitignore
================================================
node_modules
bundle.js
================================================
FILE: examples/browser-media-file-input/index.html
================================================
================================================
FILE: examples/browser-media-file-input/index.js
================================================
import { saveAs } from 'file-saver';
import AnkiExport from '../../src';
// import AnkiExport from 'anki-apkg-export';
const apkg = new AnkiExport('deck-name');
const input = document.getElementById('input');
input.onchange = function(e) {
const reader = new FileReader();
const filename = e.target.files[0].name;
reader.onload = function(e) {
const file = e.target.result;
apkg.addMedia('anki.png', file);
apkg.addCard('card #1 with image
', 'card #1 back');
apkg
.save()
.then(zip => {
saveAs(zip, 'output.apkg');
})
.catch(err => console.log(err.stack || err));
};
reader.readAsArrayBuffer(e.target.files[0]);
};
================================================
FILE: examples/browser-media-file-input/package.json
================================================
{
"private": true,
"scripts": {
"build": "webpack"
},
"dependencies": {
"anki-apkg-export": "^3.0.1",
"babel-loader": "^6.2.4",
"babel-preset-es2015": "^6.6.0",
"file-saver": "^1.3.3"
},
"devDependencies": {
"script-loader": "^0.6.1",
"webpack": "^1.12.14"
}
}
================================================
FILE: examples/browser-media-file-input/webpack.config.js
================================================
'use strict';
const webpack = require('webpack');
module.exports = {
entry: './index.js',
module: {
loaders: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel'
},
]
},
plugins: [
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify(process.env.NODE_ENV || 'development'),
APP_ENV: JSON.stringify('browser')
},
})
],
output: {
path: __dirname,
filename: 'bundle.js'
}
};
================================================
FILE: examples/server/server.js
================================================
'use strict';
const fs = require('fs');
// const { default: AnkiExport } = require('anki-apkg-export');
const { default: AnkiExport } = require('../../dist');
const apkg = new AnkiExport('deck-name-node');
apkg.addMedia('anki.png', fs.readFileSync('../assets/anki.png'));
apkg.addCard('card #1 front', 'card #1 back');
apkg.addCard('card #2 front', 'card #2 back');
apkg.addCard('card #3 with image
', 'card #3 back');
apkg
.save()
.then(zip => {
fs.writeFileSync('./output.apkg', zip, 'binary');
console.log(`Package has been generated: output.apkg`);
})
.catch(err => console.log(err.stack || err));
================================================
FILE: husky.config.js
================================================
module.exports = {
hooks: {
'pre-commit': 'npm test'
}
};
================================================
FILE: package.json
================================================
{
"name": "anki-apkg-export",
"description": "Generate decks for Anki (spaced repetition software)",
"version": "4.0.3",
"main": "dist/index.js",
"dependencies": {
"jszip": "^3.2.2",
"sha1": "^1.1.1",
"sql.js": "^0.5.0"
},
"devDependencies": {
"@babel/cli": "^7.0.0",
"@babel/core": "^7.0.0",
"@babel/polyfill": "^7.0.0",
"@babel/preset-env": "^7.0.0",
"@babel/register": "^7.0.0",
"arraybuffer-equal": "1.0.4",
"ava": "^2.4.0",
"babel-eslint": "^10.0.3",
"eslint": "^6.8.0",
"eslint-config-prettier": "^6.9.0",
"eslint-plugin-prettier": "^3.1.2",
"husky": "^3.1.0",
"lodash.sortby": "4.7.0",
"mkdirp": "0.5.1",
"pify": "^4.0.1",
"prettier": "^1.19.1",
"proxyquire": "^2.1.3",
"sinon": "^8.0.2",
"sqlite3": "4.1.1"
},
"author": "ewnd9 ",
"keywords": [
"anki",
"spaced repetition software",
"webpack"
],
"license": "MIT",
"preferGlobal": "true",
"repository": {
"type": "git",
"url": "git+https://github.com/ewnd9/anki-apkg-export.git"
},
"scripts": {
"build": "babel -d dist src",
"build:watch": "babel -w -d dist src",
"lint": "eslint 'src/**/*.js' 'test/**/*.js'",
"postpublish": "rm -rf dist",
"prepare": "yarn run build",
"test": "yarn run lint && ava",
"test:watch": "yarn run test -- --watch"
}
}
================================================
FILE: src/exporter.js
================================================
import sha1 from 'sha1';
import Zip from 'jszip';
export default class {
constructor(deckName, { template, sql }) {
this.db = new sql.Database();
this.db.run(template);
const now = Date.now();
const topDeckId = this._getId('cards', 'did', now);
const topModelId = this._getId('notes', 'mid', now);
this.deckName = deckName;
this.zip = new Zip();
this.media = [];
this.topDeckId = topDeckId;
this.topModelId = topModelId;
this.separator = '\u001F';
const decks = this._getInitialRowValue('col', 'decks');
const deck = getLastItem(decks);
deck.name = this.deckName;
deck.id = topDeckId;
decks[topDeckId + ''] = deck;
this._update('update col set decks=:decks where id=1', { ':decks': JSON.stringify(decks) });
const models = this._getInitialRowValue('col', 'models');
const model = getLastItem(models);
model.name = this.deckName;
model.did = this.topDeckId;
model.id = topModelId;
models[`${topModelId}`] = model;
this._update('update col set models=:models where id=1', { ':models': JSON.stringify(models) });
}
save(options) {
const { zip, db, media } = this;
const binaryArray = db.export();
const mediaObj = media.reduce((prev, curr, idx) => {
prev[idx] = curr.filename;
return prev;
}, {});
zip.file('collection.anki2', new Buffer(binaryArray));
zip.file('media', JSON.stringify(mediaObj));
media.forEach((item, i) => zip.file(i, item.data));
if (process.env.APP_ENV === 'browser' || typeof window !== 'undefined') {
return zip.generateAsync(Object.assign({}, { type: 'blob' }, options));
} else {
return zip.generateAsync(
Object.assign(
{},
{
type: 'nodebuffer',
base64: false,
compression: 'DEFLATE'
},
options
)
);
}
}
addMedia(filename, data) {
this.media.push({ filename, data });
}
addCard(front, back, { tags } = {}) {
const { topDeckId, topModelId, separator } = this;
const now = Date.now();
const note_guid = this._getNoteGuid(topDeckId, front, back);
const note_id = this._getNoteId(note_guid, now);
let strTags = '';
if (typeof tags === 'string') {
strTags = tags;
} else if (Array.isArray(tags)) {
strTags = this._tagsToStr(tags);
}
this._update('insert or replace into notes values(:id,:guid,:mid,:mod,:usn,:tags,:flds,:sfld,:csum,:flags,:data)', {
':id': note_id, // integer primary key,
':guid': note_guid, // text not null,
':mid': topModelId, // integer not null,
':mod': this._getId('notes', 'mod', now), // integer not null,
':usn': -1, // integer not null,
':tags': strTags, // text not null,
':flds': front + separator + back, // text not null,
':sfld': front, // integer not null,
':csum': this._checksum(front + separator + back), //integer not null,
':flags': 0, // integer not null,
':data': '' // text not null,
});
return this._update(
'insert or replace into cards values(:id,:nid,:did,:ord,:mod,:usn,:type,:queue,:due,:ivl,:factor,:reps,:lapses,:left,:odue,:odid,:flags,:data)',
{
':id': this._getCardId(note_id, now), // integer primary key,
':nid': note_id, // integer not null,
':did': topDeckId, // integer not null,
':ord': 0, // integer not null,
':mod': this._getId('cards', 'mod', now), // integer not null,
':usn': -1, // integer not null,
':type': 0, // integer not null,
':queue': 0, // integer not null,
':due': 179, // integer not null,
':ivl': 0, // integer not null,
':factor': 0, // integer not null,
':reps': 0, // integer not null,
':lapses': 0, // integer not null,
':left': 0, // integer not null,
':odue': 0, // integer not null,
':odid': 0, // integer not null,
':flags': 0, // integer not null,
':data': '' // text not null
}
);
}
_update(query, obj) {
this.db.prepare(query).getAsObject(obj);
}
_getInitialRowValue(table, column = 'id') {
const query = `select ${column} from ${table}`;
return this._getFirstVal(query);
}
_checksum(str) {
return parseInt(sha1(str).substr(0, 8), 16);
}
_getFirstVal(query) {
return JSON.parse(this.db.exec(query)[0].values[0]);
}
_tagsToStr(tags = []) {
return ' ' + tags.map(tag => tag.replace(/ /g, '_')).join(' ') + ' ';
}
_getId(table, col, ts) {
const query = `SELECT ${col} from ${table} WHERE ${col} >= :ts ORDER BY ${col} DESC LIMIT 1`;
const rowObj = this.db.prepare(query).getAsObject({ ':ts': ts });
return rowObj[col] ? +rowObj[col] + 1 : ts;
}
_getNoteId(guid, ts) {
const query = `SELECT id from notes WHERE guid = :guid ORDER BY id DESC LIMIT 1`;
const rowObj = this.db.prepare(query).getAsObject({ ':guid': guid });
return rowObj.id || this._getId('notes', 'id', ts);
}
_getNoteGuid(topDeckId, front, back) {
return sha1(`${topDeckId}${front}${back}`);
}
_getCardId(note_id, ts) {
const query = `SELECT id from cards WHERE nid = :note_id ORDER BY id DESC LIMIT 1`;
const rowObj = this.db.prepare(query).getAsObject({ ':note_id': note_id });
return rowObj.id || this._getId('cards', 'id', ts);
}
}
export const getLastItem = obj => {
const keys = Object.keys(obj);
const lastKey = keys[keys.length - 1];
const item = obj[lastKey];
delete obj[lastKey];
return item;
};
================================================
FILE: src/index.js
================================================
import Exporter from './exporter';
import createTemplate from './template';
let sql;
if (process.env.APP_ENV === 'browser' || typeof window !== 'undefined') {
require('script-loader!sql.js');
sql = window.SQL;
} else {
sql = require('sql.js');
}
export { Exporter };
export default function(deckName, template) {
return new Exporter(deckName, {
template: createTemplate(template),
sql
});
}
================================================
FILE: src/template.js
================================================
export default function createTemplate({
questionFormat = '{{Front}}',
answerFormat = '{{FrontSide}}\n\n
\n\n{{Back}}',
css = '.card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\nbackground-color: white;\n}\n'
} = {}) {
const conf = {
nextPos: 1,
estTimes: true,
activeDecks: [1],
sortType: 'noteFld',
timeLim: 0,
sortBackwards: false,
addToCur: true,
curDeck: 1,
newBury: true,
newSpread: 0,
dueCounts: true,
curModel: '1435645724216',
collapseTime: 1200
};
const models = {
1388596687391: {
veArs: [],
name: 'Basic-f15d2',
tags: ['Tag'],
did: 1435588830424,
usn: -1,
req: [[0, 'all', [0]]],
flds: [
{
name: 'Front',
media: [],
sticky: false,
rtl: false,
ord: 0,
font: 'Arial',
size: 20
},
{
name: 'Back',
media: [],
sticky: false,
rtl: false,
ord: 1,
font: 'Arial',
size: 20
}
],
sortf: 0,
latexPre:
'\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n',
tmpls: [
{
name: 'Card 1',
qfmt: questionFormat,
did: null,
bafmt: '',
afmt: answerFormat,
ord: 0,
bqfmt: ''
}
],
latexPost: '\\end{document}',
type: 0,
id: 1388596687391,
css,
mod: 1435645658
}
};
const decks = {
1: {
desc: '',
name: 'Default',
extendRev: 50,
usn: 0,
collapsed: false,
newToday: [0, 0],
timeToday: [0, 0],
dyn: 0,
extendNew: 10,
conf: 1,
revToday: [0, 0],
lrnToday: [0, 0],
id: 1,
mod: 1435645724
},
1435588830424: {
desc: '',
name: 'Template',
extendRev: 50,
usn: -1,
collapsed: false,
newToday: [545, 0],
timeToday: [545, 0],
dyn: 0,
extendNew: 10,
conf: 1,
revToday: [545, 0],
lrnToday: [545, 0],
id: 1435588830424,
mod: 1435588830
}
};
const dconf = {
1: {
name: 'Default',
replayq: true,
lapse: {
leechFails: 8,
minInt: 1,
delays: [10],
leechAction: 0,
mult: 0
},
rev: {
perDay: 100,
fuzz: 0.05,
ivlFct: 1,
maxIvl: 36500,
ease4: 1.3,
bury: true,
minSpace: 1
},
timer: 0,
maxTaken: 60,
usn: 0,
new: {
perDay: 20,
delays: [1, 10],
separate: true,
ints: [1, 4, 7],
initialFactor: 2500,
bury: true,
order: 1
},
mod: 0,
id: 1,
autoplay: true
}
};
return `
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE col (
id integer primary key,
crt integer not null,
mod integer not null,
scm integer not null,
ver integer not null,
dty integer not null,
usn integer not null,
ls integer not null,
conf text not null,
models text not null,
decks text not null,
dconf text not null,
tags text not null
);
INSERT INTO "col" VALUES(
1,
1388548800,
1435645724219,
1435645724215,
11,
0,
0,
0,
'${JSON.stringify(conf)}',
'${JSON.stringify(models)}',
'${JSON.stringify(decks)}',
'${JSON.stringify(dconf)}',
'{}'
);
CREATE TABLE notes (
id integer primary key, /* 0 */
guid text not null, /* 1 */
mid integer not null, /* 2 */
mod integer not null, /* 3 */
usn integer not null, /* 4 */
tags text not null, /* 5 */
flds text not null, /* 6 */
sfld integer not null, /* 7 */
csum integer not null, /* 8 */
flags integer not null, /* 9 */
data text not null /* 10 */
);
CREATE TABLE cards (
id integer primary key, /* 0 */
nid integer not null, /* 1 */
did integer not null, /* 2 */
ord integer not null, /* 3 */
mod integer not null, /* 4 */
usn integer not null, /* 5 */
type integer not null, /* 6 */
queue integer not null, /* 7 */
due integer not null, /* 8 */
ivl integer not null, /* 9 */
factor integer not null, /* 10 */
reps integer not null, /* 11 */
lapses integer not null, /* 12 */
left integer not null, /* 13 */
odue integer not null, /* 14 */
odid integer not null, /* 15 */
flags integer not null, /* 16 */
data text not null /* 17 */
);
CREATE TABLE revlog (
id integer primary key,
cid integer not null,
usn integer not null,
ease integer not null,
ivl integer not null,
lastIvl integer not null,
factor integer not null,
time integer not null,
type integer not null
);
CREATE TABLE graves (
usn integer not null,
oid integer not null,
type integer not null
);
ANALYZE sqlite_master;
INSERT INTO "sqlite_stat1" VALUES('col',NULL,'1');
CREATE INDEX ix_notes_usn on notes (usn);
CREATE INDEX ix_cards_usn on cards (usn);
CREATE INDEX ix_revlog_usn on revlog (usn);
CREATE INDEX ix_cards_nid on cards (nid);
CREATE INDEX ix_cards_sched on cards (did, queue, due);
CREATE INDEX ix_revlog_cid on revlog (cid);
CREATE INDEX ix_notes_csum on notes (csum);
COMMIT;
`;
}
================================================
FILE: templates/template.sql
================================================
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE col (
id integer primary key,
crt integer not null,
mod integer not null,
scm integer not null,
ver integer not null,
dty integer not null,
usn integer not null,
ls integer not null,
conf text not null,
models text not null,
decks text not null,
dconf text not null,
tags text not null
);
INSERT INTO "col" VALUES(
1,
1388548800,
1435645724219,
1435645724215,
11,
0,
0,
0,
'{"nextPos": 1, "estTimes": true, "activeDecks": [1], "sortType": "noteFld", "timeLim": 0, "sortBackwards": false, "addToCur": true, "curDeck": 1, "newBury": true, "newSpread": 0, "dueCounts": true, "curModel": "1435645724216", "collapseTime": 1200}',
'{"1388596687391": {"vers": [], "name": "Basic-f15d2", "tags": ["Tag"], "did": 1435588830424, "usn": -1, "req": [[0, "all", [0]]], "flds": [{"name": "Front", "media": [], "sticky": false, "rtl": false, "ord": 0, "font": "Arial", "size": 20}, {"name": "Back", "media": [], "sticky": false, "rtl": false, "ord": 1, "font": "Arial", "size": 20}], "sortf": 0, "latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n", "tmpls": [{"name": "Card 1", "qfmt": "{{Front}}", "did": null, "bafmt": "", "afmt": "{{Back}}", "ord": 0, "bqfmt": ""}], "latexPost": "\\end{document}", "type": 0, "id": 1388596687391, "css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n", "mod": 1435645658}}',
'{"1": {"desc": "", "name": "Default", "extendRev": 50, "usn": 0, "collapsed": false, "newToday": [0, 0], "timeToday": [0, 0], "dyn": 0, "extendNew": 10, "conf": 1, "revToday": [0, 0], "lrnToday": [0, 0], "id": 1, "mod": 1435645724}, "1435588830424": {"desc": "", "name": "Template", "extendRev": 50, "usn": -1, "collapsed": false, "newToday": [545, 0], "timeToday": [545, 0], "dyn": 0, "extendNew": 10, "conf": 1, "revToday": [545, 0], "lrnToday": [545, 0], "id": 1435588830424, "mod": 1435588830}}',
'{"1": {"name": "Default", "replayq": true, "lapse": {"leechFails": 8, "minInt": 1, "delays": [10], "leechAction": 0, "mult": 0}, "rev": {"perDay": 100, "fuzz": 0.05, "ivlFct": 1, "maxIvl": 36500, "ease4": 1.3, "bury": true, "minSpace": 1}, "timer": 0, "maxTaken": 60, "usn": 0, "new": {"perDay": 20, "delays": [1, 10], "separate": true, "ints": [1, 4, 7], "initialFactor": 2500, "bury": true, "order": 1}, "mod": 0, "id": 1, "autoplay": true}}',
'{}'
);
CREATE TABLE notes (
id integer primary key, /* 0 */
guid text not null, /* 1 */
mid integer not null, /* 2 */
mod integer not null, /* 3 */
usn integer not null, /* 4 */
tags text not null, /* 5 */
flds text not null, /* 6 */
sfld integer not null, /* 7 */
csum integer not null, /* 8 */
flags integer not null, /* 9 */
data text not null /* 10 */
);
CREATE TABLE cards (
id integer primary key, /* 0 */
nid integer not null, /* 1 */
did integer not null, /* 2 */
ord integer not null, /* 3 */
mod integer not null, /* 4 */
usn integer not null, /* 5 */
type integer not null, /* 6 */
queue integer not null, /* 7 */
due integer not null, /* 8 */
ivl integer not null, /* 9 */
factor integer not null, /* 10 */
reps integer not null, /* 11 */
lapses integer not null, /* 12 */
left integer not null, /* 13 */
odue integer not null, /* 14 */
odid integer not null, /* 15 */
flags integer not null, /* 16 */
data text not null /* 17 */
);
CREATE TABLE revlog (
id integer primary key,
cid integer not null,
usn integer not null,
ease integer not null,
ivl integer not null,
lastIvl integer not null,
factor integer not null,
time integer not null,
type integer not null
);
CREATE TABLE graves (
usn integer not null,
oid integer not null,
type integer not null
);
ANALYZE sqlite_master;
INSERT INTO "sqlite_stat1" VALUES('col',NULL,'1');
CREATE INDEX ix_notes_usn on notes (usn);
CREATE INDEX ix_cards_usn on cards (usn);
CREATE INDEX ix_revlog_usn on revlog (usn);
CREATE INDEX ix_cards_nid on cards (nid);
CREATE INDEX ix_cards_sched on cards (did, queue, due);
CREATE INDEX ix_revlog_cid on revlog (cid);
CREATE INDEX ix_notes_csum on notes (csum);
COMMIT;
================================================
FILE: test/_helpers.js
================================================
import fs from 'fs';
import path from 'path';
import Zip from 'jszip';
import mkdirp from 'mkdirp';
export const addCards = (apkg, list) => list.forEach(({ front, back }) => apkg.addCard(front, back));
export const unzipDeckToDir = (pathToDeck, pathToUnzipTo) => {
mkdirp.sync(pathToUnzipTo);
const zipContent = fs.readFileSync(pathToDeck);
const zip = new Zip();
return zip.loadAsync(zipContent, { createFolders: true }).then(() =>
Promise.all(
Object.keys(zip.files).map(i => {
const file = zip.files[i];
const filePath = path.join(pathToUnzipTo, file.name);
return file.async('nodebuffer').then(data => fs.writeFileSync(filePath, data));
})
)
);
};
================================================
FILE: test/exporter.js
================================================
import test from 'ava';
import sinon from 'sinon';
import proxyquire from 'proxyquire';
import fs from 'fs';
import sql from 'sql.js';
const template = fs.readFileSync(__dirname + '/../templates/template.sql', 'utf-8');
const now = Date.now();
const { Exporter } = proxyquire('../src', {
jszip: function() {
this.file = () => null;
this.generateAsync = () => null;
}
});
test.beforeEach(t => {
t.context.clock = sinon.useFakeTimers(now);
t.context.exporter = new Exporter('testDeckName', {
template,
sql
});
});
test.afterEach(t => {
sinon.restore();
t.context.clock.restore();
});
test('Exporter.save', t => {
const { exporter } = t.context;
const dbExportSpy = sinon.spy(exporter.db, 'export');
const zipFileSpy = sinon.spy(exporter.zip, 'file');
const zipGenerateAsyncSpy = sinon.spy(exporter.zip, 'generateAsync');
exporter.media = [{ filename: '1.jpg' }, { filename: '2.bmp' }];
exporter.save({ some: 'options', should: { be: 'here' } });
t.truthy(dbExportSpy.called, 'should call .export on db');
t.truthy(zipFileSpy.calledWithMatch('collection.anki2'), 'should save notes/cards db');
t.truthy(zipFileSpy.calledWithMatch('media'), 'should save media');
t.truthy(zipFileSpy.calledWithMatch(0), 'should save media with two files');
t.truthy(zipFileSpy.calledWithMatch(1), 'should save media with two files');
t.truthy(zipGenerateAsyncSpy.called, 'should call zip.generateAsync');
t.truthy(['blob', 'nodebuffer'].includes(zipGenerateAsyncSpy.args[0][0].type), 'zip generates binary file');
});
test('Exporter.addCard', t => {
const { exporter } = t.context;
const { topDeckId, topModelId, separator } = exporter;
const [front, back] = ['Test Front', 'Test back'];
const exporterUpdateSpy = sinon.spy(exporter, '_update');
exporter.addCard(front, back);
t.is(exporterUpdateSpy.callCount, 2, 'should made two requests');
t.is(
exporterUpdateSpy.args[0][0],
'insert or replace into notes values(:id,:guid,:mid,:mod,:usn,:tags,:flds,:sfld,:csum,:flags,:data)'
);
const notesUpdate = exporterUpdateSpy.args[0][1];
t.is(notesUpdate[':sfld'], front);
t.is(notesUpdate[':flds'], front + separator + back);
t.is(notesUpdate[':mid'], topModelId);
t.is(
exporterUpdateSpy.args[1][0],
'insert or replace into cards values(:id,:nid,:did,:ord,:mod,:usn,:type,:queue,:due,:ivl,:factor,:reps,:lapses,:left,:odue,:odid,:flags,:data)'
);
const cardsUpdate = exporterUpdateSpy.args[1][1];
t.is(cardsUpdate[':did'], topDeckId);
t.is(cardsUpdate[':nid'], notesUpdate[':id'], 'should link both tables via the same note_id');
});
test('Exporter.addCard with options (tags is array)', t => {
const { exporter } = t.context;
const { topModelId, separator } = exporter;
const [front, back] = ['Test Front', 'Test back'];
const tags = ['tag1', 'tag2', 'multiple words tag'];
const exporterUpdateSpy = sinon.spy(exporter, '_update');
exporter.addCard(front, back, { tags });
t.is(exporterUpdateSpy.callCount, 2, 'should made two requests');
t.is(
exporterUpdateSpy.args[0][0],
'insert or replace into notes values(:id,:guid,:mid,:mod,:usn,:tags,:flds,:sfld,:csum,:flags,:data)'
);
const notesUpdate = exporterUpdateSpy.args[0][1];
const notesTags = notesUpdate[':tags'].split(' ');
t.is(notesUpdate[':sfld'], front);
t.is(notesUpdate[':flds'], front + separator + back);
t.is(notesUpdate[':mid'], topModelId);
t.deepEqual(notesTags, [''].concat(tags.map(tag => tag.replace(/ /g, '_'))).concat(['']));
});
test('Exporter.addCard with options (tags is string)', t => {
const { exporter } = t.context;
const { topDeckId, topModelId, separator } = exporter;
const [front, back, tags] = ['Test Front', 'Test back', 'Some string with_delimiters'];
const exporterUpdateSpy = sinon.spy(exporter, '_update');
exporter.addCard(front, back, { tags });
t.is(exporterUpdateSpy.callCount, 2, 'should made two requests');
t.is(
exporterUpdateSpy.args[0][0],
'insert or replace into notes values(:id,:guid,:mid,:mod,:usn,:tags,:flds,:sfld,:csum,:flags,:data)'
);
const notesUpdate = exporterUpdateSpy.args[0][1];
t.is(notesUpdate[':sfld'], front);
t.is(notesUpdate[':flds'], front + separator + back);
t.is(notesUpdate[':mid'], topModelId);
t.is(notesUpdate[':tags'], tags);
t.is(
exporterUpdateSpy.args[1][0],
'insert or replace into cards values(:id,:nid,:did,:ord,:mod,:usn,:type,:queue,:due,:ivl,:factor,:reps,:lapses,:left,:odue,:odid,:flags,:data)'
);
const cardsUpdate = exporterUpdateSpy.args[1][1];
t.is(cardsUpdate[':did'], topDeckId);
t.is(cardsUpdate[':nid'], notesUpdate[':id'], 'should link both tables via the same note_id');
});
test('Exporter.addCard updates note if it is a duplicate', t => {
const { exporter } = t.context;
const { topDeckId, topModelId, separator } = exporter;
const [front, back] = ['Test Front', 'Test back'];
const exporterUpdateSpy = sinon.spy(exporter, '_update');
exporter.addCard(front, back);
exporter.addCard(front, back);
t.is(exporterUpdateSpy.callCount, 4, 'should made four requests');
t.is(
exporterUpdateSpy.args[0][0],
'insert or replace into notes values(:id,:guid,:mid,:mod,:usn,:tags,:flds,:sfld,:csum,:flags,:data)'
);
const notesUpdate = exporterUpdateSpy.args[0][1];
const secondNotesUpdate = exporterUpdateSpy.args[2][1];
t.is(notesUpdate[':id'], secondNotesUpdate[':id']);
t.is(notesUpdate[':guid'], secondNotesUpdate[':guid']);
t.is(notesUpdate[':sfld'], front);
t.is(notesUpdate[':flds'], front + separator + back);
t.is(notesUpdate[':mid'], topModelId);
t.is(
exporterUpdateSpy.args[1][0],
'insert or replace into cards values(:id,:nid,:did,:ord,:mod,:usn,:type,:queue,:due,:ivl,:factor,:reps,:lapses,:left,:odue,:odid,:flags,:data)'
);
const cardsUpdate = exporterUpdateSpy.args[1][1];
const secondCardsUpdate = exporterUpdateSpy.args[3][1];
t.is(cardsUpdate[':id'], secondCardsUpdate[':id']);
t.is(cardsUpdate[':did'], topDeckId);
t.is(cardsUpdate[':nid'], notesUpdate[':id'], 'should link both tables via the same note_id');
});
test('Exporter._getId', t => {
const { exporter } = t.context;
const numberOfCards = 5;
const [front, back] = ['Test Front', 'Test back'];
for (let i = 0; i < numberOfCards; i++) {
exporter.addCard(`${front} ${i}`, `${back} ${i}`);
}
const noteIdsResult = exporter.db.exec('SELECT id FROM notes ORDER BY id DESC');
t.deepEqual(
noteIdsResult,
[
{
columns: ['id'],
values: new Array(numberOfCards)
.fill(0)
.map((el, index) => [now + index])
.sort((a, b) => b[0] - a[0])
}
],
'It should increment values inserted at the same time'
);
});
================================================
FILE: test/test.js
================================================
import test from 'ava';
import fs from 'fs';
import { execFile } from 'child_process';
import sqlite3 from 'sqlite3';
import isArrayBufferEqual from 'arraybuffer-equal';
import pify from 'pify';
import sinon from 'sinon';
import sortBy from 'lodash.sortby';
import AnkiExport from '../src';
import { addCards, unzipDeckToDir } from './_helpers';
const tmpDir = '/tmp';
const dest = tmpDir + '/result.apkg';
const destUnpacked = tmpDir + '/unpacked_result';
const destUnpackedDb = destUnpacked + '/collection.anki2';
const SEPARATOR = '\u001F';
test.beforeEach(async () => pify(execFile)('rm', ['-rf', dest, destUnpacked]));
test('equals to sample', async t => {
const now = 1482680798652;
const clock = sinon.useFakeTimers(now);
const apkg = new AnkiExport('deck-name');
apkg.addMedia('anki.png', fs.readFileSync(__dirname + '/fixtures/anki.png'));
apkg.addCard('card #1 front', 'card #1 back', { tags: ['food', 'fruit'] });
apkg.addCard('card #2 front', 'card #2 back');
apkg.addCard('card #3 with image
', 'card #3 back');
const zip = await apkg.save();
fs.writeFileSync(dest, zip, 'binary');
t.true(zip instanceof Buffer);
const sampleZip = fs.readFileSync(`${__dirname}/fixtures/output.apkg`);
const destZip = fs.readFileSync(dest);
t.true(isArrayBufferEqual(destZip.buffer, sampleZip.buffer));
sinon.restore();
clock.restore();
});
test('check internal structure', async t => {
// Create deck as in previous example
const apkg = new AnkiExport('deck-name');
const cards = [
{ front: 'card #1 front', back: 'card #1 back' },
{ front: 'card #2 front', back: 'card #2 back' },
{ front: 'card #3 with image
', back: 'card #3 back' }
];
addCards(apkg, cards);
const zip = await apkg.save();
fs.writeFileSync(dest, zip, 'binary');
// extract dec to tmp directory
await unzipDeckToDir(dest, destUnpacked);
// analize db via sqlite
const db = new sqlite3.Database(destUnpackedDb);
const result = await pify(db.all.bind(db))(
`SELECT
notes.sfld as front,
notes.flds as back
from cards JOIN notes where cards.nid = notes.id ORDER BY cards.id`
);
db.close();
// compare content from just created db with original list of cards
const normilizedResult = sortBy(
result.map(({ front, back }) => ({
front,
back: back.split(SEPARATOR).pop()
})),
'front'
);
t.deepEqual(normilizedResult, cards);
});
test('check internal structure on adding card with tags', async t => {
const decFile = `${dest}_with_tags.apkg`;
const unzipedDeck = `${destUnpacked}_with_tags`;
const apkg = new AnkiExport('deck-name');
const [front1, back1, tags1] = ['Card front side 1', 'Card back side 1', ['some', 'tag', 'tags with multiple words']];
const [front2, back2, tags2] = ['Card front side 2', 'Card back side 2', 'some strin_tags'];
const [front3, back3] = ['Card front side 3', 'Card back side 3'];
apkg.addCard(front1, back1, { tags: tags1 });
apkg.addCard(front2, back2, { tags: tags2 });
apkg.addCard(front3, back3);
const zip = await apkg.save();
fs.writeFileSync(decFile, zip, 'binary');
await unzipDeckToDir(decFile, unzipedDeck);
const db = new sqlite3.Database(`${unzipedDeck}/collection.anki2`);
const results = await pify(db.all.bind(db))(
`SELECT
notes.sfld as front,
notes.flds as back,
notes.tags as tags
from cards JOIN notes where cards.nid = notes.id ORDER BY front`
);
db.close();
t.deepEqual(results, [
{
front: front1,
back: `${front1}${SEPARATOR}${back1}`,
tags: ' ' + tags1.map(tag => tag.replace(/ /g, '_')).join(' ') + ' '
},
{ front: front2, back: `${front2}${SEPARATOR}${back2}`, tags: tags2 },
{ front: front3, back: `${front3}${SEPARATOR}${back3}`, tags: '' }
]);
});