Browse Source

update wk populate

main
parent
commit
6d46308e78
5 changed files with 244 additions and 242 deletions
  1. +4
    -90
      scripts/get-kanji-level.ts
  2. +24
    -17
      scripts/populate-from-wanikani.ts
  3. +3
    -132
      scripts/sort-anki.ts
  4. +6
    -0
      src/level.ts
  5. +207
    -3
      src/wanikani.ts

+ 4
- 90
scripts/get-kanji-level.ts View File

@ -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,

+ 24
- 17
scripts/populate-from-wanikani.ts View File

@ -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();
}

+ 3
- 132
scripts/sort-anki.ts View File

@ -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<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,
});
}
});
const wk = new WaniKani();
await wk.sortByLevels(DECK, { ja: KANJI_FIELD }, { useBeyond: USE_BEYOND });
}
if (require.main === module) {

+ 6
- 0
src/level.ts View File

@ -3,3 +3,9 @@ export interface ILevelLabel {
ja: string;
en: string;
}
export interface ILevelMap {
[category: string]: {
[levelString: string]: string[];
};
}

+ 207
- 3
src/wanikani.ts View File

@ -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}:`;

Loading…
Cancel
Save