|
|
@ -1,15 +1,21 @@ |
|
|
|
import { existsSync, readFileSync, writeFileSync } from 'fs'; |
|
|
|
|
|
|
|
import axios, { AxiosInstance } from 'axios'; |
|
|
|
import yaml from 'js-yaml'; |
|
|
|
|
|
|
|
import { soundTag } from './anki'; |
|
|
|
import { AnkiConnect, IAnkiConnectActions } from './ankiconnect'; |
|
|
|
import { ILevelLabel } from './level'; |
|
|
|
import { ILevelLabel, ILevelMap } from './level'; |
|
|
|
import { logger } from './logger'; |
|
|
|
|
|
|
|
export class WaniKani { |
|
|
|
$axios: AxiosInstance; |
|
|
|
|
|
|
|
static files = { |
|
|
|
wanikani: 'cache/wanikani.json', |
|
|
|
beyond: 'assets/beyond.yaml', |
|
|
|
}; |
|
|
|
|
|
|
|
constructor(public apiKey = process.env['WANIKANI_API_KEY']) { |
|
|
|
this.$axios = axios.create({ |
|
|
|
baseURL: 'https://api.wanikani.com/v2/', |
|
|
@ -68,7 +74,7 @@ export class WaniKani { |
|
|
|
} = {}) { |
|
|
|
const subjects = { |
|
|
|
data: [] as ISubject[], |
|
|
|
filename: 'cache/wanikani.json', |
|
|
|
filename: WaniKani.files.wanikani, |
|
|
|
load() { |
|
|
|
this.data = existsSync(this.filename) |
|
|
|
? JSON.parse(readFileSync(this.filename, 'utf-8')) |
|
|
@ -109,6 +115,204 @@ export class WaniKani { |
|
|
|
return data; |
|
|
|
} |
|
|
|
|
|
|
|
async sortByLevels( |
|
|
|
deck: string, |
|
|
|
fields: { ja: string }, |
|
|
|
opts: { |
|
|
|
/** |
|
|
|
* Whether to use Level > 60, i.e. outside WaniKani |
|
|
|
* |
|
|
|
* @link https://community.wanikani.com/t/fake-levels-61-70-or-%E7%84%A1%E9%99%90-infinity/16399
|
|
|
|
*/ |
|
|
|
useBeyond?: boolean; |
|
|
|
} = {}, |
|
|
|
) { |
|
|
|
const anki = new AnkiConnect(); |
|
|
|
|
|
|
|
const deckConfig = await anki.api('getDeckConfig', { |
|
|
|
deck, |
|
|
|
}); |
|
|
|
|
|
|
|
const deckQuery = `deck:${deck} -deck:${deck}::*`; |
|
|
|
|
|
|
|
const wkLevelMap: ILevelMap = {}; |
|
|
|
const wkKanji = await this.subjects().then( |
|
|
|
(vs) => vs.filter((v) => v.object === 'kanji') as IKanji[], |
|
|
|
); |
|
|
|
|
|
|
|
for (const k of wkKanji) { |
|
|
|
const { level, characters } = k.data; |
|
|
|
const label = WaniKani.getLevelLabel(level); |
|
|
|
if (!label) throw new Error(`Invalid level: ${level}`); |
|
|
|
|
|
|
|
const category = `${label.range}: ${ |
|
|
|
label.ja |
|
|
|
} ${label.en.toLocaleUpperCase()}`;
|
|
|
|
|
|
|
|
const catMap = wkLevelMap[category] || {}; |
|
|
|
const levelString = level.toString().padStart(2, '0'); |
|
|
|
|
|
|
|
const ks = catMap[levelString] || []; |
|
|
|
ks.push(characters); |
|
|
|
catMap[levelString] = ks; |
|
|
|
|
|
|
|
wkLevelMap[category] = catMap; |
|
|
|
} |
|
|
|
|
|
|
|
const beyond = opts.useBeyond |
|
|
|
? (yaml.load(readFileSync(WaniKani.files.beyond, 'utf-8')) as ILevelMap) |
|
|
|
: {}; |
|
|
|
|
|
|
|
const kanjiLevels = new Map< |
|
|
|
string, |
|
|
|
{ |
|
|
|
level: number; |
|
|
|
deckName: string; |
|
|
|
} |
|
|
|
>(); |
|
|
|
|
|
|
|
const setKanjiToLevel = (lvMap: ILevelMap) => { |
|
|
|
Object.entries(lvMap).map(([cat, map]) => { |
|
|
|
Object.entries(map).map(([levelString, ks]) => { |
|
|
|
const level = Number(levelString); |
|
|
|
ks.map((k) => { |
|
|
|
let prev = kanjiLevels.get(k); |
|
|
|
if (!prev || prev.level > level) { |
|
|
|
prev = { |
|
|
|
level, |
|
|
|
deckName: cat + '::' + levelString, |
|
|
|
}; |
|
|
|
kanjiLevels.set(k, prev); |
|
|
|
} |
|
|
|
}); |
|
|
|
}); |
|
|
|
}); |
|
|
|
}; |
|
|
|
setKanjiToLevel(wkLevelMap); |
|
|
|
setKanjiToLevel(beyond); |
|
|
|
|
|
|
|
await anki |
|
|
|
.api('findCards', { |
|
|
|
query: deckQuery, |
|
|
|
}) |
|
|
|
.then((cards) => anki.api('cardsInfo', { cards })) |
|
|
|
.then(async (rs) => { |
|
|
|
const modelToSubdecks = new Map< |
|
|
|
string, |
|
|
|
{ |
|
|
|
[subdeck: string]: number[]; |
|
|
|
} |
|
|
|
>(); |
|
|
|
rs.map((r) => { |
|
|
|
const vs = modelToSubdecks.get(r.modelName) || {}; |
|
|
|
const { value } = r.fields[fields.ja] || {}; |
|
|
|
if (value) { |
|
|
|
// Get the subdeck names from Kanji
|
|
|
|
let subdeck = ''; |
|
|
|
let level = 0; |
|
|
|
|
|
|
|
Array.from(value).map((k) => { |
|
|
|
const m = kanjiLevels.get(k); |
|
|
|
if (m && m.level > level) { |
|
|
|
level = m.level; |
|
|
|
subdeck = m.deckName; |
|
|
|
} |
|
|
|
}); |
|
|
|
|
|
|
|
if (subdeck) { |
|
|
|
const cardIds = vs[subdeck] || []; |
|
|
|
cardIds.push(r.cardId); |
|
|
|
vs[subdeck] = cardIds; |
|
|
|
|
|
|
|
modelToSubdecks.set(r.modelName, vs); |
|
|
|
} |
|
|
|
} |
|
|
|
}); |
|
|
|
|
|
|
|
return Promise.all( |
|
|
|
Array.from(modelToSubdecks).map(([modelName, subdecks]) => |
|
|
|
anki.api('modelTemplates', { modelName }).then((templates) => |
|
|
|
Object.entries(subdecks).map(([subdeck, cardIds]) => ({ |
|
|
|
modelName, |
|
|
|
templateNames: Object.keys(templates), |
|
|
|
subdeck, |
|
|
|
cardIdsSet: new Set(cardIds), |
|
|
|
})), |
|
|
|
), |
|
|
|
), |
|
|
|
); |
|
|
|
}) |
|
|
|
.then(async (rs) => { |
|
|
|
const remaining: { |
|
|
|
[templateName: string]: Set<number>; |
|
|
|
} = {}; |
|
|
|
|
|
|
|
const cardsByTemplateName = { |
|
|
|
data: new Map<string, number[]>(), |
|
|
|
async get(templateName: string) { |
|
|
|
let v = this.data.get(templateName); |
|
|
|
if (!v) { |
|
|
|
v = await anki.api('findCards', { |
|
|
|
query: `${deckQuery} card:${templateName}`, |
|
|
|
}); |
|
|
|
remaining[templateName] = new Set(v); |
|
|
|
} |
|
|
|
this.data.set(templateName, v); |
|
|
|
return v; |
|
|
|
}, |
|
|
|
}; |
|
|
|
|
|
|
|
const toChangeDeck: IAnkiConnectActions['changeDeck']['params'][] = []; |
|
|
|
|
|
|
|
for (const { templateName, subdeck, cardIdsSet } of rs |
|
|
|
.flat() |
|
|
|
.flatMap((r) => |
|
|
|
r.templateNames.map((templateName) => ({ |
|
|
|
...r, |
|
|
|
templateName, |
|
|
|
})), |
|
|
|
)) { |
|
|
|
const cards = await cardsByTemplateName.get(templateName); |
|
|
|
|
|
|
|
toChangeDeck.push({ |
|
|
|
cards: cards.filter((c) => { |
|
|
|
const r = remaining[templateName]; |
|
|
|
if (r) { |
|
|
|
r.delete(c); |
|
|
|
} |
|
|
|
return cardIdsSet.has(c); |
|
|
|
}), |
|
|
|
deck: `${deck}::${subdeck}::${templateName}`, |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
Object.entries(remaining).map(([templateName, cards]) => { |
|
|
|
if (cards.size) { |
|
|
|
toChangeDeck.push({ |
|
|
|
cards: [...cards], |
|
|
|
deck: `${deck}::独習::${templateName}`, |
|
|
|
}); |
|
|
|
} |
|
|
|
}); |
|
|
|
|
|
|
|
if (toChangeDeck.length) { |
|
|
|
const batchSize = 20; |
|
|
|
for (let i = 0; i < toChangeDeck.length; i += batchSize) { |
|
|
|
await Promise.all( |
|
|
|
toChangeDeck |
|
|
|
.slice(i, i + batchSize) |
|
|
|
.map((params) => anki.api('changeDeck', params)), |
|
|
|
); |
|
|
|
} |
|
|
|
|
|
|
|
await anki.api('setDeckConfigId', { |
|
|
|
decks: toChangeDeck.map((r) => r.deck), |
|
|
|
configId: deckConfig.id, |
|
|
|
}); |
|
|
|
} |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
async populateSound( |
|
|
|
query: string, |
|
|
|
fields: { |
|
|
@ -236,7 +440,7 @@ export class WaniKani { |
|
|
|
|
|
|
|
query += ` -${fields.vocabJa}:`; |
|
|
|
if (fields.sentenceAudio) { |
|
|
|
query += ` -${fields.sentenceAudio}:`; |
|
|
|
query += ` ${fields.sentenceAudio}:`; |
|
|
|
} |
|
|
|
if (!opts.overwrite) { |
|
|
|
query += ` -${fields.sentenceJa}: -${fields.sentenceEn}:`; |
|
|
|