diff --git a/scripts/get-kanji-level.ts b/scripts/get-kanji-level.ts index 3284ef2..e0f59db 100644 --- a/scripts/get-kanji-level.ts +++ b/scripts/get-kanji-level.ts @@ -1,95 +1,9 @@ -import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { readFileSync, writeFileSync } from 'fs'; -import { IKanji, WaniKani } from '@/wanikani'; +import { ILevelMap } from '@/level'; +import { WaniKani } from '@/wanikani'; import yaml from 'js-yaml'; -interface ILevelMap { - [category: string]: { - [levelString: string]: string[]; - }; -} - -async function makeWaniKaniKanjiLevels( - opts: { cache?: boolean; force?: boolean } = {}, -) { - const FILENAME = 'cache/wanikani-kanji.yaml'; - if (opts.cache && existsSync(FILENAME)) { - return yaml.load(readFileSync(FILENAME, 'utf-8')) as ILevelMap; - } - - const wkKanji = await new WaniKani() - .subjects({ force: !!opts.force }) - .then((vs) => vs.filter((v) => v.object === 'kanji') as IKanji[]); - - const levelMap: ILevelMap = {}; - - 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 = levelMap[category] || {}; - const levelString = level.toString().padStart(2, '0'); - - const ks = catMap[levelString] || []; - ks.push(characters); - catMap[levelString] = ks; - - levelMap[category] = catMap; - } - - writeFileSync( - FILENAME, - yaml.dump(levelMap, { - sortKeys: true, - flowLevel: 2, - }), - ); - - return levelMap; -} - -export async function makeKanjiLevels(opts: { useBeyond: boolean }) { - const wk = await makeWaniKaniKanjiLevels({ cache: true }); - const beyond = opts.useBeyond - ? (yaml.load(readFileSync('assets/beyond.yaml', 'utf-8')) as ILevelMap) - : {}; - - const kanjiToLevel = 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 = kanjiToLevel.get(k); - if (!prev || prev.level > level) { - prev = { - level, - deckName: cat + '::' + levelString, - }; - kanjiToLevel.set(k, prev); - } - }); - }); - }); - }; - setKanjiToLevel(wk); - setKanjiToLevel(beyond); - - return kanjiToLevel; -} - export async function repairBeyond() { const beyond = yaml.load( readFileSync('cache/beyond.yaml', 'utf-8'), @@ -119,7 +33,7 @@ export async function repairBeyond() { } writeFileSync( - 'assets/beyond.yaml', + WaniKani.files.beyond, yaml.dump(levelMap, { sortKeys: true, flowLevel: 2, diff --git a/scripts/populate-from-wanikani.ts b/scripts/populate-from-wanikani.ts index 49a5195..87366f6 100644 --- a/scripts/populate-from-wanikani.ts +++ b/scripts/populate-from-wanikani.ts @@ -1,22 +1,29 @@ import { WaniKani } from '@/wanikani'; -if (require.main === module) { - // new WaniKani().populateSound( - // 'note:jp.takoboto', - // { ja: 'Japanese', audio: 'JapaneseAudio' }, - // { mode: { online: true } }, - // ); - // new WaniKani().populateSentence( - // 'note:jp.takoboto', - // { - // vocabJa: 'Japanese', - // sentenceJa: 'Sentence', - // sentenceAudio: 'SentenceAudio', - // sentenceEn: 'SentenceMeaning', - // }, - // { overwrite: true }, - // ); - new WaniKani().addTags('note:jp.takoboto', { +async function main() { + const wk = new WaniKani(); + + await wk.sortByLevels('Takoboto', { ja: 'Japanese' }, { useBeyond: true }); + await wk.populateSound( + 'note:jp.takoboto', + { ja: 'Japanese', audio: 'JapaneseAudio' }, + { mode: { online: true } }, + ); + await wk.populateSentence( + 'note:jp.takoboto', + { + vocabJa: 'Japanese', + sentenceJa: 'Sentence', + sentenceAudio: 'SentenceAudio', + sentenceEn: 'SentenceMeaning', + }, + { overwrite: true }, + ); + await wk.addTags('note:jp.takoboto', { ja: 'Japanese', }); } + +if (require.main === module) { + main(); +} diff --git a/scripts/sort-anki.ts b/scripts/sort-anki.ts index 97bbbb6..d5f1f83 100644 --- a/scripts/sort-anki.ts +++ b/scripts/sort-anki.ts @@ -1,6 +1,4 @@ -import { AnkiConnect, IAnkiConnectActions } from '@/ankiconnect'; - -import { makeKanjiLevels } from './get-kanji-level'; +import { WaniKani } from '@/wanikani'; const DECK = 'Takoboto'; const KANJI_FIELD = 'Japanese'; @@ -12,135 +10,8 @@ const KANJI_FIELD = 'Japanese'; const USE_BEYOND = true; async function main() { - const anki = new AnkiConnect(); - - const deckConfig = await anki.api('getDeckConfig', { - deck: DECK, - }); - - const deckQuery = `deck:${DECK} -deck:${DECK}::*`; - const kanjiLevels = await makeKanjiLevels({ useBeyond: USE_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[KANJI_FIELD] || {}; - 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; - } = {}; - - const cardsByTemplateName = { - data: new Map(), - 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, - }); - } - }); + const wk = new WaniKani(); + await wk.sortByLevels(DECK, { ja: KANJI_FIELD }, { useBeyond: USE_BEYOND }); } if (require.main === module) { diff --git a/src/level.ts b/src/level.ts index f987bab..b691ac3 100644 --- a/src/level.ts +++ b/src/level.ts @@ -3,3 +3,9 @@ export interface ILevelLabel { ja: string; en: string; } + +export interface ILevelMap { + [category: string]: { + [levelString: string]: string[]; + }; +} diff --git a/src/wanikani.ts b/src/wanikani.ts index d610a76..7b3846e 100644 --- a/src/wanikani.ts +++ b/src/wanikani.ts @@ -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; + } = {}; + + const cardsByTemplateName = { + data: new Map(), + 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}:`;