-
Notifications
You must be signed in to change notification settings - Fork 0
/
main.js
404 lines (373 loc) · 13.7 KB
/
main.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
// ==UserScript==
// @name WaniKani Vocab Reading Analyzer
// @namespace wyverex
// @version 1.2.3
// @description Colors vocabulary on the lesson picker based on whether their readings are known
// @author Andreas Krügersen-Clark
// @match https://www.wanikani.com/
// @match https://www.wanikani.com/dashboard
// @match https://www.wanikani.com/subject-lessons/picker
// @grant none
// @require https://unpkg.com/wanakana
// @license MIT
// @run-at document-end
// ==/UserScript==
(function () {
if (!window.wkof) {
alert(
'"Wanikani Vocab Reading Analyzer" script requires Wanikani Open Framework.\nYou will now be forwarded to installation instructions.'
);
window.location.href = "https://community.wanikani.com/t/instructions-installing-wanikani-open-framework/28549";
return;
}
const StoreName = "cachedReadings";
const RendakuPrefixCandidates = {
か: ["が"],
き: ["ぎ"],
く: ["ぐ"],
け: ["げ"],
こ: ["ご"],
さ: ["ざ"],
し: ["じ"],
す: ["ず"],
せ: ["ぜ"],
そ: ["ぞ"],
た: ["だ"],
ち: ["ぢ"],
つ: ["づ"],
て: ["で"],
と: ["ど"],
は: ["ば", "ぱ"],
ひ: ["び", "ぴ"],
ふ: ["ぶ", "ぷ"],
へ: ["べ", "ぺ"],
ほ: ["ぼ", "ぽ"],
};
const RendakuSuffixCandidates = {
く: "っ",
つ: "っ",
ち: "っ",
};
const DefaultColors = {
easyColor: "#A1FA4F",
secondaryColor: "#6DA3EE",
rendakuColor: "#FFF200",
newColor: "#F06356",
};
const wkof = window.wkof;
const shared = {
settings: {},
db: undefined,
dialog: undefined,
vocab: undefined,
kanji: undefined,
learnedVocabProcessed: false,
// KanjiId -> [learned readings]
lastReadingCacheTime: new Date(0),
readingsCache: {},
};
wkof.include("ItemData,Menu,Settings");
if (window.location.href.includes("subject-lessons/picker")) {
wkof.ready("ItemData").then(openDB).catch(loadError);
}
wkof.ready("document,Menu,Settings").then(loadSettings).then(installMenu).catch(loadError);
function loadError(e) {
console.error('Failed to load data from WKOF for "Vocab Analyzer"', e);
}
function loadSettings() {
return wkof.Settings.load("wk_vocab_analyzer", DefaultColors).then(() => (shared.settings = wkof.settings.wk_vocab_analyzer));
}
function openDB() {
const dbRequest = window.indexedDB.open("wk-vocab-analyzer", 1);
dbRequest.onerror = (event) => {
console.error("Could not open database for Vocab Analyzer. Analyzing vocab with learned, secondary readings is not supported.");
startup();
};
dbRequest.onsuccess = (event) => {
shared.db = event.target.result;
const transaction = shared.db.transaction([StoreName], "readonly");
const store = transaction.objectStore(StoreName);
const request = store.get("main");
request.onsuccess = () => {
const data = request.result;
shared.lastReadingCacheTime = data.lastReadingCacheTime;
shared.readingsCache = data.cache;
startup();
};
};
dbRequest.onupgradeneeded = (event) => {
const db = event.target.result;
const store = db.createObjectStore(StoreName, { keyPath: "id" });
store.add({ id: "main", lastReadingCacheTime: new Date(0), cache: {} });
};
}
function startup() {
const kanjiConfig = { wk_items: { options: { subjects: true }, filters: { level: "1..+0", item_type: "kanji" } } };
wkof.ItemData.get_items(kanjiConfig).then(processKanji);
}
// ----------------------------------------------------------------------
function installMenu() {
if (window.location.href.includes("subject-lessons/picker")) {
return;
}
wkof.Menu.insert_script_link({
name: "wk_vocab_analyzer",
submenu: "Settings",
title: "Vocab Reading Analyzer",
on_click: openSettings,
});
}
// prettier-ignore
function openSettings() {
let config = {
script_id: 'wk_vocab_analyzer',
title: 'Vocab Reading Analyzer',
content: {
display: {
type: "group", label: "Colors", content: {
easyColor: { type: "color", label: "Easy reading", full_width: false },
secondaryColor: { type: "color", label: "Secondary reading" },
rendakuColor: { type: "color", label: "Rendaku reading" },
newColor: { type: "color", label: "New reading" },
reset: { type: "button", label: "Reset to defaults", text: "Reset", on_click: resetToDefaults }
}
}
}
};
shared.dialog = new wkof.Settings(config);
shared.dialog.open();
}
function resetToDefaults() {
shared.settings.easyColor = DefaultColors.easyColor;
shared.settings.secondaryColor = DefaultColors.secondaryColor;
shared.settings.rendakuColor = DefaultColors.rendakuColor;
shared.settings.newColor = DefaultColors.newColor;
shared.dialog.refresh();
}
// ----------------------------------------------------------------------
function processKanji(items) {
shared.kanji = items;
if (shared.db) {
// Get all learned vocab
const config = {
wk_items: { options: { subjects: true, assignments: true }, filters: { srs: { value: [-1, 0], invert: true }, item_type: "voc" } },
};
wkof.ItemData.get_items(config).then(cacheNewlyLearnedReadings);
} else {
processVocab();
}
}
function cacheNewlyLearnedReadings(items) {
if (items.length > 0) {
let hasUpdates = false;
for (let vocab of items) {
const startTime = new Date(vocab.assignments.started_at);
if (startTime > shared.lastReadingCacheTime) {
const analysis = analyzeVocab(vocab);
if (analysis) {
for (let kanji of analysis) {
if (shared.readingsCache[kanji.id] === undefined) {
shared.readingsCache[kanji.id] = new Set();
}
shared.readingsCache[kanji.id].add(kanji.reading);
hasUpdates = true;
}
}
}
}
if (hasUpdates) {
const transaction = shared.db.transaction([StoreName], "readwrite");
const store = transaction.objectStore(StoreName);
store.put({ id: "main", lastReadingCacheTime: new Date(), cache: shared.readingsCache });
}
}
processVocab();
}
function processVocab() {
// Get unlocked, not yet learned vocab
const vocabConfig = { wk_items: { options: { subjects: true }, filters: { srs: "init", item_type: "voc" } } };
wkof.ItemData.get_items(vocabConfig).then((items) => {
shared.vocab = items;
processData();
});
}
// ====================================================================================
function processData() {
if (window.location.href.includes("subject-lessons/picker")) {
const uiResults = {};
for (let vocab of shared.vocab) {
const analysis = analyzeVocab(vocab);
const isEasy = analysis !== undefined && analysis.reduce((p, c) => p && c.primary && !c.rendaku, true);
let isNewReading = false;
let hasRendaku = false;
if (!isEasy) {
if (analysis) {
for (const kanji of analysis) {
if (kanji.rendaku) {
hasRendaku = true;
} else if (!kanji.primary) {
const cachedReadings = shared.readingsCache[kanji.id];
if (!cachedReadings || !cachedReadings.has(kanji.reading)) {
isNewReading = true;
break;
}
}
}
} else {
isNewReading = true;
}
}
uiResults[vocab.id] = { isEasy, hasRendaku, isNewReading };
}
annotateVocabInLessonPicker(uiResults);
}
}
// Returns [kanjiMatch]
function analyzeVocab(vocab) {
const data = vocab.data;
const kanjiReadings = getKanjiReadings(data.component_subject_ids);
for (let reading of data.readings) {
if (reading.primary && reading.accepted_answer) {
const tokens = getCharacterTokens(data.characters);
const kanjiMatches = matchKanjiReadings(tokens, reading.reading, kanjiReadings);
return kanjiMatches;
}
}
}
// Returns an object of <kanji character> -> { primaryReading[], secondaryReading[] }
function getKanjiReadings(kanjiIds) {
const kanjiById = wkof.ItemData.get_index(shared.kanji, "subject_id");
let kanjiReadings = {};
for (let id of kanjiIds) {
let primaryReadings = [];
let secondaryReadings = [];
const kanji = kanjiById[id].data;
for (let reading of kanji.readings) {
if (reading.primary && reading.accepted_answer) {
primaryReadings.push(reading.reading);
} else {
secondaryReadings.push(reading.reading);
}
}
kanjiReadings[kanji.characters] = { id, primary: primaryReadings, secondary: secondaryReadings };
}
return kanjiReadings;
}
function getCharacterTokens(characters) {
let result = [];
const tokens = wanakana.tokenize(characters, { detailed: true });
for (let token of tokens) {
if (token.type === "kanji") {
// The tokenizer returns strings of subsequent kanji as a single token, e.g. 地中海. Split them
const subTokens = [...token.value];
for (let sub of subTokens) {
result.push({ type: "kanji", value: sub });
}
} else {
result.push(token);
}
}
return result;
}
function matchKanjiReadings(tokens, reading, kanjiReadings, lastChosenReading) {
if (tokens.length == 0) {
return reading.length == 0 ? [] : undefined;
}
const cToken = tokens[0];
if (cToken.type === "kanji") {
// Check which reading this is
const kReadings = kanjiReadings[cToken.value];
if (cToken.value === "々") {
// This is a repeater of the previous reading
if (reading.startsWith(lastChosenReading)) {
const subResult = matchKanjiReadings(tokens.slice(1), reading.slice(lastChosenReading.length), kanjiReadings, lastChosenReading);
if (subResult !== undefined) {
return [{ id: kReadings.id, character: cToken.value, reading: lastChosenReading, primary: true }, ...subResult];
}
}
}
for (let primary of kReadings.primary) {
const match = matchReading(reading, primary);
if (match.match) {
const subResult = matchKanjiReadings(tokens.slice(1), reading.slice(primary.length), kanjiReadings, primary);
if (subResult !== undefined) {
return [{ id: kReadings.id, character: cToken.value, reading: primary, primary: true, rendaku: match.rendaku }, ...subResult];
}
}
}
for (let secondary of kReadings.secondary) {
const match = matchReading(reading, secondary);
if (match.match) {
const subResult = matchKanjiReadings(tokens.slice(1), reading.slice(secondary.length), kanjiReadings, secondary);
if (subResult !== undefined) {
return [
{ id: kReadings.id, character: cToken.value, reading: secondary, primary: false, rendaku: match.rendaku },
...subResult,
];
}
}
}
return undefined;
} else if (cToken.type === "hiragana" || cToken.type === "katakana") {
const length = cToken.value.length;
if (length > reading.length) {
// This is a character vs reading mismatch due to a non-matching kanji
return undefined;
}
return matchKanjiReadings(tokens.slice(1), reading.slice(length), kanjiReadings);
} else if (cToken.type === "japanesePunctuation" && cToken.value === "ー") {
// Long vowel kana
return matchKanjiReadings(tokens.slice(1), reading.slice(1), kanjiReadings);
} else {
// Skip this token, it doesn't participate in the reading
return matchKanjiReadings(tokens.slice(1), reading, kanjiReadings);
}
}
function matchReading(reading, candidate) {
if (reading.startsWith(candidate)) {
return { match: true, rendaku: false };
}
const firstKana = candidate[0];
if (candidate.length > 1) {
const lastKana = candidate[candidate.length - 1];
// Try rendaku suffix
const suffixCandidate = RendakuSuffixCandidates[lastKana];
if (suffixCandidate !== undefined) {
const newCandidate = candidate.slice(0, candidate.length - 1) + suffixCandidate;
if (reading.startsWith(newCandidate)) {
return { match: true, rendaku: true };
}
}
}
// Try rendaku prefix
const prefixCandidates = RendakuPrefixCandidates[firstKana];
if (prefixCandidates !== undefined) {
for (const rendaku of prefixCandidates) {
const newCandidate = rendaku + candidate.slice(1);
if (reading.startsWith(newCandidate)) {
return { match: true, rendaku: true };
}
}
}
return { match: false, rendaku: false };
}
// ====================================================================================
function annotateVocabInLessonPicker(vocabResults) {
const subjectElements = document.querySelectorAll("[data-subject-id]");
for (let element of subjectElements) {
const id = element.getAttribute("data-subject-id");
if (id in vocabResults) {
const target = element.firstElementChild.firstElementChild.firstElementChild;
if (vocabResults[id].isEasy) {
target.style.color = shared.settings.easyColor;
} else if (vocabResults[id].isNewReading) {
target.style.color = shared.settings.newColor;
} else if (vocabResults[id].hasRendaku) {
target.style.color = shared.settings.rendakuColor;
} else {
target.style.color = shared.settings.secondaryColor;
}
}
}
}
})();