Extra contents beyond WaniKani
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

742 lines
19 KiB

2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
  1. import { existsSync, readFileSync, writeFileSync } from 'fs';
  2. import axios, { AxiosInstance } from 'axios';
  3. import yaml from 'js-yaml';
  4. import { soundTag } from './anki';
  5. import { AnkiConnect, IAnkiConnectActions } from './ankiconnect';
  6. import { ILevelLabel, ILevelMap } from './level';
  7. import { logger } from './logger';
  8. export class WaniKani {
  9. $axios: AxiosInstance;
  10. static files = {
  11. wanikani: 'cache/wanikani.json',
  12. beyond: 'assets/beyond.yaml',
  13. };
  14. constructor(public apiKey = process.env['WANIKANI_API_KEY']) {
  15. this.$axios = axios.create({
  16. baseURL: 'https://api.wanikani.com/v2/',
  17. headers: {
  18. Authorization: apiKey ? `Bearer ${apiKey}` : '',
  19. },
  20. });
  21. }
  22. static getLevelLabel(level: number): ILevelLabel | null {
  23. if (level < 11) {
  24. return {
  25. range: '01-10',
  26. ja: '快',
  27. en: 'PLEASANT',
  28. };
  29. } else if (level < 21) {
  30. return {
  31. range: '11-20',
  32. ja: '苦',
  33. en: 'PAINFUL',
  34. };
  35. } else if (level < 31) {
  36. return {
  37. range: '21-30',
  38. ja: '死',
  39. en: 'DEATH',
  40. };
  41. } else if (level < 41) {
  42. return {
  43. range: '31-40',
  44. ja: '地獄',
  45. en: 'HELL',
  46. };
  47. } else if (level < 51) {
  48. return {
  49. range: '41-50',
  50. ja: '天国',
  51. en: 'PARADISE',
  52. };
  53. } else if (level < 61) {
  54. return {
  55. range: '51-60',
  56. ja: '現実',
  57. en: 'REALITY',
  58. };
  59. }
  60. return null;
  61. }
  62. async subjects({
  63. force,
  64. }: {
  65. force?: boolean;
  66. } = {}) {
  67. const subjects = {
  68. data: [] as ISubject[],
  69. filename: WaniKani.files.wanikani,
  70. load() {
  71. this.data = existsSync(this.filename)
  72. ? JSON.parse(readFileSync(this.filename, 'utf-8'))
  73. : [];
  74. return this.data;
  75. },
  76. dump(d: ISubject[]) {
  77. this.data = d;
  78. this.finalize();
  79. },
  80. finalize() {
  81. writeFileSync(this.filename, JSON.stringify(this.data));
  82. },
  83. };
  84. let data = subjects.load();
  85. if (data.length && !force) {
  86. return data;
  87. }
  88. data = [];
  89. let nextURL = '/subjects';
  90. while (nextURL) {
  91. const r = await this.$axios
  92. .get<{
  93. pages: {
  94. next_url?: string;
  95. };
  96. data: ISubject[];
  97. }>(nextURL)
  98. .then((r) => r.data);
  99. data.push(...r.data);
  100. logger('WaniKani API:', r.pages.next_url);
  101. nextURL = r.pages.next_url || '';
  102. }
  103. subjects.dump(data);
  104. return data;
  105. }
  106. async sortByLevels(
  107. deck: string,
  108. fields: { ja: string },
  109. opts: {
  110. /**
  111. * Whether to use Level > 60, i.e. outside WaniKani
  112. *
  113. * @link https://community.wanikani.com/t/fake-levels-61-70-or-%E7%84%A1%E9%99%90-infinity/16399
  114. */
  115. useBeyond?: boolean;
  116. } = {},
  117. ) {
  118. const anki = new AnkiConnect();
  119. const deckConfig = await anki.api('getDeckConfig', {
  120. deck,
  121. });
  122. const deckQuery = `deck:${deck} -deck:${deck}::*`;
  123. const wkLevelMap: ILevelMap = {};
  124. const wkKanji = await this.subjects().then(
  125. (vs) => vs.filter((v) => v.object === 'kanji') as IKanji[],
  126. );
  127. for (const k of wkKanji) {
  128. const { level, characters } = k.data;
  129. const label = WaniKani.getLevelLabel(level);
  130. if (!label) throw new Error(`Invalid level: ${level}`);
  131. const category = `${label.range}: ${
  132. label.ja
  133. } ${label.en.toLocaleUpperCase()}`;
  134. const catMap = wkLevelMap[category] || {};
  135. const levelString = level.toString().padStart(2, '0');
  136. const ks = catMap[levelString] || [];
  137. ks.push(characters);
  138. catMap[levelString] = ks;
  139. wkLevelMap[category] = catMap;
  140. }
  141. const beyond = opts.useBeyond
  142. ? (yaml.load(readFileSync(WaniKani.files.beyond, 'utf-8')) as ILevelMap)
  143. : {};
  144. const kanjiLevels = new Map<
  145. string,
  146. {
  147. level: number;
  148. deckName: string;
  149. }
  150. >();
  151. const setKanjiToLevel = (lvMap: ILevelMap) => {
  152. Object.entries(lvMap).map(([cat, map]) => {
  153. Object.entries(map).map(([levelString, ks]) => {
  154. const level = Number(levelString);
  155. ks.map((k) => {
  156. let prev = kanjiLevels.get(k);
  157. if (!prev || prev.level > level) {
  158. prev = {
  159. level,
  160. deckName: cat + '::' + levelString,
  161. };
  162. kanjiLevels.set(k, prev);
  163. }
  164. });
  165. });
  166. });
  167. };
  168. setKanjiToLevel(wkLevelMap);
  169. setKanjiToLevel(beyond);
  170. await anki
  171. .api('findCards', {
  172. query: deckQuery,
  173. })
  174. .then((cards) => anki.api('cardsInfo', { cards }))
  175. .then(async (rs) => {
  176. const modelToSubdecks = new Map<
  177. string,
  178. {
  179. [subdeck: string]: number[];
  180. }
  181. >();
  182. rs.map((r) => {
  183. const vs = modelToSubdecks.get(r.modelName) || {};
  184. const { value } = r.fields[fields.ja] || {};
  185. if (value) {
  186. // Get the subdeck names from Kanji
  187. let subdeck = '';
  188. let level = 0;
  189. Array.from(value).map((k) => {
  190. const m = kanjiLevels.get(k);
  191. if (m && m.level > level) {
  192. level = m.level;
  193. subdeck = m.deckName;
  194. }
  195. });
  196. if (subdeck) {
  197. const cardIds = vs[subdeck] || [];
  198. cardIds.push(r.cardId);
  199. vs[subdeck] = cardIds;
  200. modelToSubdecks.set(r.modelName, vs);
  201. }
  202. }
  203. });
  204. return Promise.all(
  205. Array.from(modelToSubdecks).map(([modelName, subdecks]) =>
  206. anki.api('modelTemplates', { modelName }).then((templates) =>
  207. Object.entries(subdecks).map(([subdeck, cardIds]) => ({
  208. modelName,
  209. templateNames: Object.keys(templates),
  210. subdeck,
  211. cardIdsSet: new Set(cardIds),
  212. })),
  213. ),
  214. ),
  215. );
  216. })
  217. .then(async (rs) => {
  218. const remaining: {
  219. [templateName: string]: Set<number>;
  220. } = {};
  221. const cardsByTemplateName = {
  222. data: new Map<string, number[]>(),
  223. async get(templateName: string) {
  224. let v = this.data.get(templateName);
  225. if (!v) {
  226. v = await anki.api('findCards', {
  227. query: `${deckQuery} card:${templateName}`,
  228. });
  229. remaining[templateName] = new Set(v);
  230. }
  231. this.data.set(templateName, v);
  232. return v;
  233. },
  234. };
  235. const toChangeDeck: IAnkiConnectActions['changeDeck']['params'][] = [];
  236. for (const { templateName, subdeck, cardIdsSet } of rs
  237. .flat()
  238. .flatMap((r) =>
  239. r.templateNames.map((templateName) => ({
  240. ...r,
  241. templateName,
  242. })),
  243. )) {
  244. const cards = await cardsByTemplateName.get(templateName);
  245. toChangeDeck.push({
  246. cards: cards.filter((c) => {
  247. const r = remaining[templateName];
  248. if (r) {
  249. r.delete(c);
  250. }
  251. return cardIdsSet.has(c);
  252. }),
  253. deck: `${deck}::${subdeck}::${templateName}`,
  254. });
  255. }
  256. Object.entries(remaining).map(([templateName, cards]) => {
  257. if (cards.size) {
  258. toChangeDeck.push({
  259. cards: [...cards],
  260. deck: `${deck}::独習::${templateName}`,
  261. });
  262. }
  263. });
  264. if (toChangeDeck.length) {
  265. const batchSize = 20;
  266. for (let i = 0; i < toChangeDeck.length; i += batchSize) {
  267. await Promise.all(
  268. toChangeDeck
  269. .slice(i, i + batchSize)
  270. .map((params) => anki.api('changeDeck', params)),
  271. );
  272. }
  273. await anki.api('setDeckConfigId', {
  274. decks: toChangeDeck.map((r) => r.deck),
  275. configId: deckConfig.id,
  276. });
  277. }
  278. });
  279. }
  280. async populateSound(
  281. query: string,
  282. fields: {
  283. ja: string;
  284. audio: string;
  285. },
  286. opts: {
  287. mode?: {
  288. online?: boolean;
  289. };
  290. } = {},
  291. ) {
  292. const subjects = await this.subjects();
  293. const vocabularies = subjects.filter(
  294. (s) => s.object === 'vocabulary',
  295. ) as IVocabulary[];
  296. const audioMap = new Map<
  297. string,
  298. {
  299. url: string;
  300. filename: string;
  301. }
  302. >();
  303. vocabularies.map((v) => {
  304. if (audioMap.has(v.data.characters)) return;
  305. const audio = v.data.pronunciation_audios[0];
  306. if (!audio) return;
  307. audioMap.set(v.data.characters, {
  308. url: audio.url,
  309. filename: `wanikani_${v.data.characters}_${audio.metadata.source_id}${
  310. audio.content_type === 'audio/ogg' ? '.ogg' : '.mp3'
  311. }`,
  312. });
  313. });
  314. if (!audioMap.size) return;
  315. query += ` -${fields.ja}: ${fields.audio}:`;
  316. const anki = new AnkiConnect();
  317. anki
  318. .api('findNotes', {
  319. query,
  320. })
  321. .then((notes) => anki.api('notesInfo', { notes }))
  322. .then(async (notes) => {
  323. const notesToUpdate: IAnkiConnectActions['updateNoteFields']['params']['note'][] =
  324. [];
  325. for (const n of notes) {
  326. const { value: ja } = n.fields[fields.ja] || {};
  327. if (ja) {
  328. // const cleanJa = ja.replace(/\[.+?\]/g, '').replace(/ /g, '')
  329. const cleanJa = ja;
  330. const audio = audioMap.get(cleanJa);
  331. if (audio) {
  332. notesToUpdate.push({
  333. id: n.noteId,
  334. ...(opts.mode?.online
  335. ? {
  336. fields: {
  337. [fields.audio]: soundTag.make(audio.url),
  338. },
  339. }
  340. : {
  341. audio: [
  342. {
  343. url: audio.url,
  344. filename: audio.filename,
  345. fields: [fields.audio],
  346. },
  347. ],
  348. }),
  349. });
  350. }
  351. }
  352. }
  353. if (!notesToUpdate.length) return;
  354. while (notesToUpdate.length) {
  355. await anki.multi<'updateNoteFields'[]>({
  356. actions: notesToUpdate.splice(0, 100).map((note) => ({
  357. action: 'updateNoteFields',
  358. params: {
  359. note,
  360. },
  361. })),
  362. });
  363. }
  364. });
  365. }
  366. async populateSentence(
  367. query: string,
  368. fields: {
  369. vocabJa: string;
  370. sentenceJa: string;
  371. sentenceAudio?: string;
  372. sentenceEn: string;
  373. sentenceCloze?: string;
  374. },
  375. opts: {
  376. prependEnglishCloze?: boolean;
  377. overwrite?: boolean;
  378. } = {},
  379. ) {
  380. const subjects = await this.subjects();
  381. const vocabularies = subjects.filter(
  382. (s) => s.object === 'vocabulary',
  383. ) as IVocabulary[];
  384. const sentenceMap = new Map<
  385. string,
  386. {
  387. ja: string;
  388. en: string;
  389. sentences: {
  390. ja: string;
  391. en: string;
  392. }[];
  393. meaning: string;
  394. }
  395. >();
  396. vocabularies.map((v) => {
  397. if (sentenceMap.has(v.data.characters)) return;
  398. const sent = v.data.context_sentences[0];
  399. if (!sent || !sent.ja.trim()) return;
  400. sentenceMap.set(v.data.characters, {
  401. ...sent,
  402. sentences: v.data.context_sentences,
  403. meaning: v.data.meanings
  404. .sort((m1, m2) => Number(m2.primary) - Number(m1.primary))
  405. .map((m) => m.meaning)
  406. .join('; '),
  407. });
  408. });
  409. if (!sentenceMap.size) return;
  410. query += ` -${fields.vocabJa}:`;
  411. if (fields.sentenceAudio) {
  412. query += ` ${fields.sentenceAudio}:`;
  413. }
  414. if (!opts.overwrite) {
  415. query += ` -${fields.sentenceJa}: -${fields.sentenceEn}:`;
  416. }
  417. const anki = new AnkiConnect();
  418. anki
  419. .api('findNotes', {
  420. query,
  421. })
  422. .then((notes) => anki.api('notesInfo', { notes }))
  423. .then(async (notes) => {
  424. const notesToUpdate: IAnkiConnectActions['updateNoteFields']['params']['note'][] =
  425. [];
  426. for (const n of notes) {
  427. const { value: ja } = n.fields[fields.vocabJa] || {};
  428. if (ja) {
  429. // const cleanJa = ja.replace(/\[.+?\]/g, '').replace(/ /g, '');
  430. const cleanJa = ja;
  431. const sent = sentenceMap.get(cleanJa);
  432. if (sent) {
  433. const fieldUpdate = {
  434. [fields.sentenceJa]: sent.ja,
  435. [fields.sentenceEn]: sent.en,
  436. };
  437. if (fields.sentenceCloze) {
  438. let clozeSent = n.fields[fields.sentenceCloze]?.value || '';
  439. if (!clozeSent && sent.sentences.length) {
  440. const clozeChar = '__';
  441. let newSent = sent.sentences
  442. .map(({ ja, en }) => `${ja}<br/>${en}`)
  443. .join('<br/>')
  444. .replace(cleanJa, clozeChar);
  445. cleanJa.split(/[\p{sc=Hiragana}]+/gu).map((c) => {
  446. if (c) {
  447. newSent = newSent.replace(c, clozeChar);
  448. }
  449. });
  450. fieldUpdate[fields.sentenceCloze] = newSent;
  451. clozeSent = newSent;
  452. }
  453. const notJa = '[^\\p{sc=Han}\\p{sc=Katakana}\\p{sc=Hiragana}]+';
  454. if (
  455. opts.prependEnglishCloze &&
  456. clozeSent &&
  457. !new RegExp(
  458. `(^${notJa}<br/?>|<br/?><br/?>${notJa}$)`,
  459. 'u',
  460. ).test(clozeSent)
  461. ) {
  462. fieldUpdate[fields.sentenceCloze] =
  463. sent.meaning + '<br><br>' + clozeSent;
  464. }
  465. }
  466. notesToUpdate.push({
  467. id: n.noteId,
  468. fields: fieldUpdate,
  469. });
  470. }
  471. }
  472. }
  473. if (!notesToUpdate.length) return;
  474. await anki.multi<'updateNoteFields'[]>({
  475. actions: notesToUpdate.map((note) => ({
  476. action: 'updateNoteFields',
  477. params: {
  478. note,
  479. },
  480. })),
  481. });
  482. });
  483. }
  484. async addTags(
  485. query: string,
  486. fields: {
  487. ja: string;
  488. },
  489. ) {
  490. const subjects = await this.subjects();
  491. const vocabularies = subjects.filter(
  492. (s) => s.object === 'vocabulary',
  493. ) as IVocabulary[];
  494. const vocabMap = new Map<
  495. string,
  496. {
  497. level: number;
  498. label: ILevelLabel;
  499. }
  500. >();
  501. vocabularies.map((v) => {
  502. if (vocabMap.has(v.data.characters)) return;
  503. const level = v.data.level;
  504. const label = WaniKani.getLevelLabel(level);
  505. if (!label) throw new Error(`Invalid level: ${level}`);
  506. vocabMap.set(v.data.characters, {
  507. level,
  508. label,
  509. });
  510. });
  511. if (!vocabMap.size) return;
  512. query += ` -tag:wanikani -${fields.ja}:`;
  513. const anki = new AnkiConnect();
  514. anki
  515. .api('findNotes', {
  516. query,
  517. })
  518. .then((notes) => anki.api('notesInfo', { notes }))
  519. .then(async (notes) => {
  520. const notesToUpdate: {
  521. id: SubjectID;
  522. tags: string[];
  523. }[] = [];
  524. for (const n of notes) {
  525. const { value: ja } = n.fields[fields.ja] || {};
  526. if (ja) {
  527. // const cleanJa = ja.replace(/\[.+?\]/g, '').replace(/ /g, '')
  528. const cleanJa = ja;
  529. const vocab = vocabMap.get(cleanJa);
  530. if (vocab) {
  531. notesToUpdate.push({
  532. id: n.noteId,
  533. tags: [
  534. 'wanikani',
  535. `level-${vocab.level}`,
  536. `wanikani-level-${vocab.level}`,
  537. `wanikani-level-${vocab.label.range}-${vocab.label.ja}`,
  538. `wanikani-level-${
  539. vocab.label.range
  540. }-${vocab.label.en.toLocaleLowerCase()}`,
  541. ],
  542. });
  543. }
  544. }
  545. }
  546. if (!notesToUpdate.length) return;
  547. const addTagsMap = new Map<
  548. string,
  549. {
  550. notes: SubjectID[];
  551. tags: string;
  552. }
  553. >();
  554. notesToUpdate.map(({ id, tags }) => {
  555. const identifier = JSON.stringify(tags);
  556. const v = addTagsMap.get(identifier) || {
  557. notes: [],
  558. tags: tags.join(' '),
  559. };
  560. v.notes.push(id);
  561. addTagsMap.set(identifier, v);
  562. });
  563. await anki.multi<'addTags'[]>({
  564. actions: Array.from(addTagsMap.values()).map((params) => ({
  565. action: 'addTags',
  566. params,
  567. })),
  568. });
  569. });
  570. }
  571. }
  572. type WaniKaniDate = string;
  573. type Integer = number;
  574. type Level = Integer;
  575. type SubjectID = Integer;
  576. interface IMeaning {
  577. meaning: string;
  578. primary: boolean;
  579. }
  580. interface ISubjectBase<
  581. T extends 'radical' | 'kanji' | 'vocabulary',
  582. Data extends {},
  583. > {
  584. id: SubjectID;
  585. object: T;
  586. url: string;
  587. data_updated_at: WaniKaniDate;
  588. data: {
  589. auxillary_meanings: IMeaning[];
  590. characters: string;
  591. created_at: WaniKaniDate;
  592. document_url: string;
  593. hidden_at: WaniKaniDate | null;
  594. lesson_position: Integer;
  595. level: Level;
  596. meaning_mnemonic: string;
  597. meanings: IMeaning[];
  598. slug: string;
  599. spaced_repetition_system_id: Integer;
  600. } & Data;
  601. }
  602. export type IRadical = ISubjectBase<
  603. 'radical',
  604. {
  605. character_images: ({
  606. url: string;
  607. } & (
  608. | {
  609. metadata: {
  610. inline_styles?: boolean;
  611. dimensions?: string;
  612. };
  613. content_type: 'image/svg+xml';
  614. }
  615. | {
  616. metadata: {
  617. color: string;
  618. dimensions: string;
  619. style_name: string;
  620. };
  621. content_type: 'image/png';
  622. }
  623. ))[];
  624. amalgamation_subject_ids: SubjectID[];
  625. }
  626. >;
  627. export type IKanji = ISubjectBase<
  628. 'kanji',
  629. {
  630. readings: {
  631. reading: string;
  632. primary: boolean;
  633. accepted_answer: boolean;
  634. type: 'kunyomi' | 'onyomi' | 'nanori';
  635. }[];
  636. component_subject_ids: SubjectID[];
  637. visually_similar_subject_ids: SubjectID[];
  638. }
  639. >;
  640. export type IVocabulary = ISubjectBase<
  641. 'vocabulary',
  642. {
  643. component_subject_ids: SubjectID[];
  644. context_sentences: {
  645. en: string;
  646. ja: string;
  647. }[];
  648. part_of_speech: string[];
  649. pronunciation_audios: {
  650. url: string;
  651. metadata: {
  652. gender: 'male' | 'female';
  653. source_id: Integer;
  654. pronunciation: string;
  655. voice_actor_id: Integer;
  656. voice_actor_name: string;
  657. voice_description: string;
  658. };
  659. content_type: 'audio/mpeg' | 'audio/ogg';
  660. }[];
  661. readings: {
  662. accepted_answer: boolean;
  663. primary: boolean;
  664. reading: string;
  665. }[];
  666. reading_mnemonic: string;
  667. }
  668. >;
  669. export type ISubject = IRadical | IKanji | IVocabulary;