-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlibrary.js
More file actions
181 lines (164 loc) · 6.92 KB
/
Copy pathlibrary.js
File metadata and controls
181 lines (164 loc) · 6.92 KB
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
// 詞彙庫 tab: the only screen that shows the words themselves rather than counts, and the only
// place a word's status can be changed without finding it in a video again.
// Matches across everything worth remembering a word by — you rarely recall the headword, you
// recall the sentence it was in.
function searchWords(words, q) {
const needle = q.trim().toLowerCase();
if (!needle) return words;
return words.filter((w) =>
[w.word, w.contextZh, w.context, w.sentence, w.title]
.filter(Boolean)
.some((f) => String(f).toLowerCase().includes(needle)),
);
}
const filterWords = (words, status) =>
status === "all" ? words : words.filter((w) => (status === "known") === !!w.suspended);
const dueLabel = (w, now = Date.now()) => {
if (w.suspended) return "已掌握";
if (!w.due) return "尚未複習";
const days = Math.round((w.due - now) / 86400000);
return days <= 0 ? "今天到期" : `${days} 天後`;
};
// Grouped by the day it was added, newest first — "what did I pick up today" is the question the
// list is actually asked. keyOf is injected so there is still only one definition of a day.
function groupWords(words, keyOf, now = Date.now()) {
const groups = new Map();
for (const w of [...words].sort((a, b) => (b.addedAt ?? 0) - (a.addedAt ?? 0))) {
const k = w.addedAt ? keyOf(w.addedAt) : "—";
if (!groups.has(k)) groups.set(k, []);
groups.get(k).push(w);
}
const today = keyOf(now);
const yesterday = keyOf(now - 86400000);
return [...groups.entries()].map(([k, items]) => ({
key: k,
label: k === today ? "今天" : k === yesterday ? "昨天" : k === "—" ? "更早" : k,
items,
}));
}
function wireLibrary() {
const $ = (id) => document.getElementById(id);
const el = (tag, cls, text) => {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text !== undefined) e.textContent = text;
return e;
};
let words = [];
let marks = {};
let status = "all";
let openId = null;
// Every write re-reads storage first and touches only the row it means to: this tab's copy is
// as old as the last time the tab was shown, and writing it back wholesale used to wipe every
// word marked on a video since then — the "已掌握 sometimes doesn't stick" bug.
function paint() {
const shown = searchWords(filterWords(words, status), $("q").value);
$("libCount").textContent = `您已標記 ${words.length} 個詞彙${
shown.length === words.length ? "" : `,符合 ${shown.length} 個`
}`;
const out = [];
for (const g of groupWords(shown, dayKey)) {
const head = el("div", "ghead");
head.append(el("span", null, g.label), el("span", null, `${g.items.length} 個詞彙`));
out.push(head, ...g.items.map(render));
}
$("list").replaceChildren(...(out.length ? out : [el("div", "vmeta", "沒有符合的詞彙。")]));
}
// Status lives in two places on purpose: `words` drives the review queue, `marks` drives the
// colour on the captions. Changing it here has to move both or the video stops agreeing.
async function setStatus(w, how) {
w.suspended = how === "known";
if (w.suspended) w.knownAt = Date.now();
else delete w.knownAt; // demoting un-masters it — the weekly count must not keep it
const r = await chrome.storage.local.get(["words", "marks"]);
words = r.words ?? [];
marks = r.marks ?? {};
const at = words.findIndex((x) => x.id === w.id);
if (at >= 0)
words[at] = { ...words[at], suspended: w.suspended, knownAt: w.knownAt, updatedAt: Date.now() };
marks[w.id] = how;
await chrome.storage.local.set({ words, marks });
paint();
}
async function remove(w) {
const r = await chrome.storage.local.get(["words", "marks", "deleted"]);
words = (r.words ?? []).filter((x) => x.id !== w.id);
marks = r.marks ?? {};
delete marks[w.id];
// Tombstone, so the removal survives a merge with a device that still has the row.
const deleted = { ...(r.deleted ?? {}), [w.id]: Date.now() };
await chrome.storage.local.set({ words, marks, deleted });
paint();
}
function render(w) {
const item = el("div", "item");
const top = el("div", "itop");
const dot = el("span", `dot ${w.suspended ? "d-known" : "d-learn"}`, w.suspended ? "✓" : "");
const word = el("span", "iword", w.word);
const due = el("span", "idue", dueLabel(w));
top.append(dot, word, due);
top.style.cursor = "pointer";
top.addEventListener("click", () => {
openId = openId === w.id ? null : w.id; // click again to collapse
paint();
});
item.append(top);
item.append(el("div", "imean", w.contextZh || w.context || ""));
if (openId !== w.id) return item;
// Expanded: everything the popup on the video showed, plus the controls that were only
// reachable there.
if (w.context) item.append(el("div", "ien", w.context));
for (const s of w.senses ?? []) {
item.append(
el("div", "sense", [`${s.pos} ${s.gloss}`, s.example, s.zh].filter(Boolean).join("\n")),
);
}
if (w.sentence) item.append(el("div", "isent", `「${w.sentence}」`));
if (w.videoId) {
const src = el("div", "isrc");
const a = document.createElement("a");
a.href = `https://youtu.be/${w.videoId}?t=${Math.floor(w.t ?? 0)}`;
a.target = "_blank";
a.textContent = w.title || w.videoId;
src.append("▶ ", a);
item.append(src);
}
const bar = el("div", "ibtns");
for (const [how, label] of [["learning", "學習中"], ["known", "已掌握"]]) {
const b = el("button", (w.suspended ? "known" : "learning") === how ? "on" : "", label);
b.addEventListener("click", () => setStatus(w, how));
bar.append(b);
}
const del = el("button", "del", "從詞彙庫移除");
del.addEventListener("click", () => remove(w));
bar.append(del);
item.append(bar);
return item;
}
$("q").addEventListener("input", paint);
for (const b of document.querySelectorAll(".filters button")) {
b.addEventListener("click", () => {
for (const o of document.querySelectorAll(".filters button")) o.classList.toggle("on", o === b);
status = b.dataset.status;
paint();
});
}
document.addEventListener("im-view", (e) => {
if (e.detail !== "viewLib") return;
chrome.storage.local.get(["words", "marks"]).then((r) => {
words = r.words ?? [];
marks = r.marks ?? {};
paint();
});
});
// Marks made on videos while this tab sits open arrive as storage events, not tab switches.
chrome.storage.onChanged?.addListener((ch, area) => {
if (area !== "local" || (!ch.words && !ch.marks)) return;
if (ch.words) words = ch.words.newValue ?? [];
if (ch.marks) marks = ch.marks.newValue ?? {};
if (!document.getElementById("viewLib").hidden) paint();
});
}
if (typeof document !== "undefined") wireLibrary();
if (typeof module !== "undefined")
module.exports = { searchWords, filterWords, dueLabel, groupWords };