"use strict";
const DATA = window.JPLEARN_DATA;
const appEl = document.getElementById("app");
const toastEl = document.getElementById("toast");
const DEVICE_KEY = "jplearn.deviceId.v1";
const STATE_KEY = "jplearn.state.v1";
const LESSON_MAP = new Map(DATA.lessons.map((lesson) => [lesson.id, lesson]));
const KANJI_MAP = new Map(DATA.kanji.map((entry, index) => [index, entry]));
let route = "dashboard";
let routeParam = null;
let kanaScript = "hiragana";
let kanaMode = "glyph-to-romaji";
let kanaQuestion = null;
let kanaAnswered = false;
let selectedKanjiIndex = 0;
let reviewQueue = [];
let reviewIndex = 0;
let reviewRevealed = false;
let speakingIndex = 0;
let mediaRecorder = null;
let mediaChunks = [];
let recordedAudioUrl = null;
let recognition = null;
let saveTimer = null;
function createDeviceId() {
if (window.crypto && typeof window.crypto.randomUUID === "function") return window.crypto.randomUUID();
const bytes = new Uint8Array(24);
window.crypto.getRandomValues(bytes);
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
function getDeviceId() {
let id = localStorage.getItem(DEVICE_KEY);
if (!id || !/^[A-Za-z0-9_-]{12,128}$/.test(id)) {
id = createDeviceId();
localStorage.setItem(DEVICE_KEY, id);
}
return id;
}
const DEVICE_ID = getDeviceId();
function defaultState() {
return {
version: 1,
xp: 0,
completedLessons: {},
exerciseResults: {},
srs: {},
speakingLog: [],
speakingBest: {},
badges: [],
studyDays: [],
activityLog: [],
kanaCorrect: 0,
reviewCount: 0,
updatedAt: 0
};
}
function loadLocalState() {
try {
const parsed = JSON.parse(localStorage.getItem(STATE_KEY) || "null");
if (!parsed || typeof parsed !== "object") return defaultState();
return { ...defaultState(), ...parsed };
} catch {
return defaultState();
}
}
let state = loadLocalState();
function persistLocal() {
localStorage.setItem(STATE_KEY, JSON.stringify(state));
}
function saveState() {
state.updatedAt = Date.now();
persistLocal();
window.clearTimeout(saveTimer);
saveTimer = window.setTimeout(async () => {
try {
await fetch("/api/state", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device: DEVICE_ID, state })
});
} catch {
// The local mirror remains available when the network is offline.
}
}, 350);
}
async function loadRemoteState() {
try {
const response = await fetch(`/api/state?device=${encodeURIComponent(DEVICE_ID)}`, { headers: { Accept: "application/json" } });
if (!response.ok) return;
const payload = await response.json();
if (payload?.state && Number(payload.state.updatedAt || 0) >= Number(state.updatedAt || 0)) {
state = { ...defaultState(), ...payload.state };
persistLocal();
render();
} else if (!payload?.state && state.updatedAt) {
saveState();
}
} catch {
// Offline start uses the local mirror.
}
}
function esc(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function icon(name) {
return ``;
}
function showToast(message) {
toastEl.textContent = message;
toastEl.classList.add("show");
window.clearTimeout(showToast.timer);
showToast.timer = window.setTimeout(() => toastEl.classList.remove("show"), 2600);
}
function localDateKey(date = new Date()) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function addDays(date, days) {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
function recordActivity(type, amount = 1) {
const date = localDateKey();
state.activityLog.push({ date, type, amount, at: Date.now() });
if (state.activityLog.length > 3000) state.activityLog = state.activityLog.slice(-3000);
if (!state.studyDays.includes(date)) state.studyDays.push(date);
state.studyDays.sort();
}
function awardXP(amount) {
state.xp += amount;
}
function dateFromKey(key) {
const [year, month, day] = key.split("-").map(Number);
return new Date(year, month - 1, day);
}
function calculateStreak() {
if (!state.studyDays.length) return 0;
const days = new Set(state.studyDays);
let cursor = new Date();
if (!days.has(localDateKey(cursor))) cursor = addDays(cursor, -1);
let streak = 0;
while (days.has(localDateKey(cursor))) {
streak += 1;
cursor = addDays(cursor, -1);
}
return streak;
}
function weekRange() {
const now = new Date();
const day = now.getDay();
const mondayOffset = day === 0 ? -6 : 1 - day;
const monday = addDays(now, mondayOffset);
monday.setHours(0, 0, 0, 0);
const sunday = addDays(monday, 6);
sunday.setHours(23, 59, 59, 999);
return { monday, sunday };
}
function weeklySummary() {
const { monday, sunday } = weekRange();
const start = localDateKey(monday);
const end = localDateKey(sunday);
const inWeek = state.activityLog.filter((event) => event.date >= start && event.date <= end);
return {
days: new Set(inWeek.map((event) => event.date)).size,
lessons: inWeek.filter((event) => event.type === "lesson").reduce((sum, event) => sum + event.amount, 0),
reviews: inWeek.filter((event) => event.type === "review").reduce((sum, event) => sum + event.amount, 0),
speaking: inWeek.filter((event) => event.type === "speaking").reduce((sum, event) => sum + event.amount, 0)
};
}
function dailyKanji() {
const now = new Date();
const start = new Date(now.getFullYear(), 0, 0);
const day = Math.floor((now - start) / 86_400_000);
const index = day % DATA.kanji.length;
return { index, entry: DATA.kanji[index] };
}
function audioButtons(text, labelPrefix = "") {
const safeText = esc(text);
return `
`;
}
const audioObjectUrls = new Map();
let currentAudio = null;
async function playJapanese(text, speed = "normal") {
const cleanText = String(text || "").trim();
if (!cleanText) return;
const rate = speed === "slow" ? 0.55 : 0.95;
try {
const cacheKey = `${cleanText}:${speed}`;
if (!audioObjectUrls.has(cacheKey)) {
const response = await fetch("/api/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: cleanText })
});
if (!response.ok) throw new Error("TTS unavailable");
const blob = await response.blob();
if (!blob || blob.type.includes("json")) throw new Error("TTS unavailable");
audioObjectUrls.set(cacheKey, URL.createObjectURL(blob));
}
if (currentAudio) currentAudio.pause();
currentAudio = new Audio(audioObjectUrls.get(cacheKey));
currentAudio.playbackRate = speed === "slow" ? 0.72 : 1;
await currentAudio.play();
return;
} catch {
// Fall through to the device voice.
}
if (!("speechSynthesis" in window)) {
showToast("此裝置目前沒有可用的日語語音。");
return;
}
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(cleanText);
utterance.lang = "ja-JP";
utterance.rate = rate;
const japaneseVoice = window.speechSynthesis.getVoices().find((voice) => voice.lang && voice.lang.toLowerCase().startsWith("ja"));
if (japaneseVoice) utterance.voice = japaneseVoice;
window.speechSynthesis.speak(utterance);
}
function rubyWord(word, reading) {
if (!reading || word === reading) return esc(word);
return `${esc(word)}`;
}
function setRoute(nextRoute, param = null) {
const target = param ? `${nextRoute}/${param}` : nextRoute;
if (location.hash.replace(/^#/, "") === target) render();
else location.hash = target;
}
function parseHash() {
const raw = location.hash.replace(/^#/, "") || "dashboard";
const [name, param] = raw.split("/");
const valid = ["dashboard", "kana", "lessons", "lesson", "kanji", "review", "speaking"];
route = valid.includes(name) ? name : "dashboard";
routeParam = param || null;
}
function updateNav() {
const active = route === "lesson" ? "lessons" : route;
document.querySelectorAll("[data-route]").forEach((button) => {
button.classList.toggle("active", button.dataset.route === active);
});
}
function badgeHTML() {
return `${DATA.badges.map((badge) => {
const earned = state.badges.includes(badge.id);
return `
${esc(badge.label)}${esc(badge.description)}
`;
}).join("")}
`;
}
function awardBadge(id) {
if (state.badges.includes(id)) return;
const badge = DATA.badges.find((item) => item.id === id);
state.badges.push(id);
if (badge) showToast(`獲得徽章:${badge.label}`);
}
function checkBadges() {
const completed = Object.keys(state.completedLessons);
const n5Done = DATA.lessons.filter((lesson) => lesson.level === "N5").every((lesson) => completed.includes(lesson.id));
const n4Lessons = DATA.lessons.filter((lesson) => lesson.level === "N4");
const n4Done = n4Lessons.every((lesson) => completed.includes(lesson.id));
if (completed.length >= 1) awardBadge("first-step");
if (state.kanaCorrect >= 20) awardBadge("kana-reader");
if (state.speakingLog.length >= 1) awardBadge("first-speak");
if (state.speakingLog.length >= 20) awardBadge("speaking-master");
if (n5Done) awardBadge("n5-complete");
if (n4Lessons.some((lesson) => completed.includes(lesson.id))) awardBadge("n4-first");
if (n4Done) awardBadge("n4-complete");
if (completed.length === DATA.lessons.length) awardBadge("all-lessons");
if (state.reviewCount >= 100) awardBadge("review-100");
}
function renderDashboard() {
const streak = calculateStreak();
const weekly = weeklySummary();
const level = Math.floor(state.xp / 300) + 1;
const levelProgress = Math.round(((state.xp % 300) / 300) * 100);
const completedCount = Object.keys(state.completedLessons).length;
const dueCount = Object.values(state.srs).filter((card) => new Date(card.due) <= new Date()).length;
const { entry, index } = dailyKanji();
return `
Weekly plan · 今週
にほんご手帳
一天一點,讓日文成為穩定累積的能力。今天先完成一件小事,就很好。
${state.xp}XP 經驗值
${streak}連續學習天
${completedCount}/20已完成課程
Level ${level}${state.xp % 300}/300 XP
今日一字 · ${index + 1}/50
${esc(entry[0])}
${esc(entry[1])}|${esc(entry[2])}
${esc(entry[5])}
${audioButtons(entry[4], "今日一字例句")}
${weekly.days}學習天數
${weekly.lessons}完成課數
${weekly.reviews}複習張數
${weekly.speaking}開口說句數
今天的練習
${dueCount ? `有 ${dueCount} 張卡片到期` : "從想練的地方開始"}
`;
}
function kanaDisplayEntries(script) {
const nEntry = DATA[script][45];
const entries = DATA[script].slice(0, 45);
// Align the y-row and w-row to the five-column chart.
const result = [];
entries.forEach((entry, index) => {
result.push(entry);
if (index === 35 || index === 36) result.push(null);
if (index === 43) result.push(null, null, null);
});
result.push(null, null, nEntry, null, null);
return result;
}
function renderKana() {
const entries = kanaDisplayEntries(kanaScript);
return `
Kana · 五十音
五十音圖
點任何一個假名即可聆聽發音。先熟悉形狀與聲音,再用巨字測驗確認自己真的記得。
${entries.map((entry) => entry ? `` : ``).join("")}
${renderKanaQuiz()}
`;
}
function pickRandom(items, count, exclude) {
const pool = items.filter((item) => item !== exclude);
const shuffled = [...pool].sort(() => Math.random() - 0.5);
return shuffled.slice(0, count);
}
function ensureKanaQuestion() {
if (kanaQuestion) return;
const source = DATA[kanaScript];
const answer = source[Math.floor(Math.random() * source.length)];
const options = [answer, ...pickRandom(source, 3, answer)].sort(() => Math.random() - 0.5);
kanaQuestion = { answer, options };
kanaAnswered = false;
}
function renderKanaQuiz() {
ensureKanaQuestion();
const { answer, options } = kanaQuestion;
const isGlyphMode = kanaMode === "glyph-to-romaji";
const focus = isGlyphMode ? answer[0] : answer[1];
return `
Kana quiz · 巨字焦點
${isGlyphMode ? "這個假名怎麼讀?" : "哪一個是這個音?"}
${esc(focus)}
${options.map((option) => ``).join("")}
累積答對:${state.kanaCorrect} 題
`;
}
function renderKanji() {
const entry = DATA.kanji[selectedKanjiIndex];
return `
Kanji focus · 漢字
漢字焦點卡
一次專心看一個字:讀音、意思、筆順提示與例句。共 50 字。
${DATA.kanji.map((item, index) => ``).join("")}
${selectedKanjiIndex + 1} / 50
${esc(entry[0])}
${esc(entry[1])}|${esc(entry[2])}
筆順提示(簡化)
${esc(entry[6])} 畫。${esc(entry[7])}
這裡提供的是幫助記憶的簡化提示,不是完整標準筆順教材。
${esc(entry[3])}
${esc(entry[4])}
${esc(entry[5])}
${selectedKanjiIndex + 1} / 50
`;
}
const exerciseAttempts = {};
const matchSelections = {};
function lessonResult(lessonId) {
return state.exerciseResults[lessonId] || {};
}
function exerciseDoneCount(lessonId) {
return ["mc", "fill", "listen", "match"].filter((type) => lessonResult(lessonId)[type]?.done).length;
}
function renderLessons() {
const groups = ["N5", "N4"].map((level) => {
const lessons = DATA.lessons.filter((lesson) => lesson.level === level);
return `
${level} ${level === "N5" ? "基礎" : "進階"}
${lessons.length} 課
${lessons.map((lesson) => {
const completed = state.completedLessons[lesson.id];
const done = exerciseDoneCount(lesson.id);
return `
LESSON ${String(lesson.no).padStart(2, "0")}${esc(lesson.level)}
${esc(lesson.title)}
${esc(lesson.jpTitle)}
約 ${lesson.minutes} 分鐘
練習 ${done}/4
${completed ? `成績 ${completed.score} 分` : `未完成`}
`;
}).join("")}
`;
}).join("");
return `Lessons · 課程
二十課的路徑
從自我介紹到條件句,每課都包含單字、例句、文法與四種練習。完成後,句子會自動進入複習與開口說。
${groups}`;
}
function sentenceCard(example) {
return `
${esc(example[0])}
${esc(example[1])}
${esc(example[2])}
`;
}
function optionExercise(lesson, type, title, promptHTML, options, answerIndex) {
const result = lessonResult(lesson.id)[type];
return `
${icon(type === "listen" ? "sound" : "book")}${esc(title)}
${promptHTML}
${options.map((option, index) => {
const cls = result?.done && index === answerIndex ? "correct" : "";
return ``;
}).join("")}
${result?.done ? "已完成,做得好。" : ""}
`;
}
function seededShuffle(items, seedText) {
let seed = [...seedText].reduce((sum, char) => sum + char.charCodeAt(0), 0);
const result = [...items];
for (let i = result.length - 1; i > 0; i -= 1) {
seed = (seed * 9301 + 49297) % 233280;
const j = Math.floor((seed / 233280) * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
function matchingExercise(lesson) {
const pairs = lesson.vocab.slice(0, 4);
const result = lessonResult(lesson.id).match;
const right = seededShuffle(pairs, lesson.id);
return `
${icon("cards")}配對練習
先點日文,再點正確的中文意思。完成四組配對即可。
${pairs.map((pair, index) => ``).join("")}
${right.map((pair) => {
const originalIndex = pairs.indexOf(pair);
return ``;
}).join("")}
${result?.done ? "配對完成,做得好。" : ""}
`;
}
function renderLesson(lesson) {
const completed = state.completedLessons[lesson.id];
const mc = lesson.quiz.mc;
const fill = lesson.quiz.fill;
const listen = lesson.quiz.listen;
return `
${lesson.level} · Lesson ${lesson.no} · 約 ${lesson.minutes} 分鐘
${esc(lesson.title)}
${esc(lesson.jpTitle)}
${completed ? `本課已完成,成績 ${completed.score} 分。可以繼續複習單字與例句。
` : ""}
${lesson.vocab.map((vocab) => `
${rubyWord(vocab[0], vocab[1])}點一下看意思
${esc(vocab[2])}${esc(vocab[1])}
點卡片翻回去
`).join("")}
${lesson.examples.map(sentenceCard).join("")}
Grammar · 文法
${esc(lesson.grammar.title)}
${esc(lesson.grammar.body)}
${optionExercise(lesson, "mc", "選擇題", `${esc(mc[0])}
`, mc[1], mc[2])}
${optionExercise(lesson, "fill", "填空題", `${esc(fill[0])}
`, fill[1], fill[2])}
${optionExercise(lesson, "listen", "聽力題", `${esc(listen[1])}
`, listen[2], listen[3])}
${matchingExercise(lesson)}
`;
}
function createSrsCards(lesson) {
const now = new Date().toISOString();
lesson.vocab.forEach((vocab, index) => {
const id = `${lesson.id}:v:${index}`;
if (!state.srs[id]) {
state.srs[id] = { id, lessonId: lesson.id, kind: "vocab", front: vocab[0], kana: vocab[1], back: vocab[2], due: now, interval: 0, ease: 2.5, reps: 0, lapses: 0, mastered: false, lastReviewed: null };
}
});
lesson.examples.forEach((example, index) => {
const id = `${lesson.id}:s:${index}`;
if (!state.srs[id]) {
state.srs[id] = { id, lessonId: lesson.id, kind: "sentence", front: example[0], kana: example[1], back: example[2], due: now, interval: 0, ease: 2.5, reps: 0, lapses: 0, mastered: false, lastReviewed: null };
}
});
}
function completeLessonIfReady(lesson) {
const results = lessonResult(lesson.id);
const types = ["mc", "fill", "listen", "match"];
if (!types.every((type) => results[type]?.done) || state.completedLessons[lesson.id]) return;
const score = Math.round((types.filter((type) => results[type].correctFirstTry).length / types.length) * 100);
state.completedLessons[lesson.id] = { score, completedAt: Date.now() };
awardXP(25);
recordActivity("lesson", 1);
createSrsCards(lesson);
checkBadges();
saveState();
showToast(`第 ${lesson.no} 課完成,成績 ${score} 分,獲得 25 XP。`);
render();
}
function markExerciseDone(lesson, type, correctFirstTry) {
if (!state.exerciseResults[lesson.id]) state.exerciseResults[lesson.id] = {};
if (state.exerciseResults[lesson.id][type]?.done) return;
state.exerciseResults[lesson.id][type] = { done: true, correctFirstTry, completedAt: Date.now() };
awardXP(10);
recordActivity("exercise", 1);
saveState();
completeLessonIfReady(lesson);
}
function prepareReviewQueue() {
const now = new Date();
const due = Object.values(state.srs)
.filter((card) => new Date(card.due) <= now)
.sort((a, b) => new Date(a.due) - new Date(b.due));
reviewQueue = due.map((card) => card.id);
reviewIndex = 0;
reviewRevealed = false;
}
function renderReview() {
const cards = Object.values(state.srs);
const now = new Date();
const dueCount = cards.filter((card) => new Date(card.due) <= now).length;
const mastered = cards.filter((card) => card.mastered).length;
if (!reviewQueue.length || reviewIndex >= reviewQueue.length) prepareReviewQueue();
const card = state.srs[reviewQueue[reviewIndex]];
return `
Review · 間隔複習
把記得變成真的記得
完成課程後,單字與例句會進入這裡。依你的熟悉程度安排下一次出現,不需要一次背完。
${dueCount}今日到期
${mastered}已掌握
${cards.length}全部卡片
${card ? `
${card.kind === "sentence" ? "例句" : "單字"} · ${esc(LESSON_MAP.get(card.lessonId)?.title || "")}
${esc(card.front)}
${reviewRevealed ? `${esc(card.kana)}
${esc(card.back)}
${audioButtons(card.kana, "複習卡片")}
` : ``}
${reviewRevealed ? `
` : ""}
本輪進度 ${Math.min(reviewIndex + 1, reviewQueue.length)} / ${reviewQueue.length}
` : `目前沒有到期卡片
先完成一課,或等已排程的卡片到期。
`}
`;
}
function scheduleReview(card, rating) {
const previousInterval = Number(card.interval || 0);
const previousReps = Number(card.reps || 0);
let interval;
let ease = Number(card.ease || 2.5);
if (rating === 0) {
interval = 1;
card.reps = 0;
card.lapses = Number(card.lapses || 0) + 1;
ease = Math.max(1.3, ease - 0.2);
} else {
card.reps = previousReps + 1;
if (rating === 1) {
interval = previousReps === 0 ? 1 : Math.max(1, Math.round(previousInterval * 0.6));
ease = Math.max(1.3, ease - 0.05);
} else if (rating === 2) {
if (previousReps === 0) interval = 1;
else if (previousReps === 1) interval = 4;
else if (previousReps === 2) interval = 9;
else interval = Math.round(previousInterval * ease);
} else {
if (previousReps === 0) interval = 4;
else if (previousReps === 1) interval = 9;
else if (previousReps === 2) interval = 30;
else interval = Math.round(previousInterval * ease * 1.35);
ease = Math.min(3, ease + 0.1);
}
}
card.interval = Math.min(180, Math.max(1, interval));
card.ease = Math.round(ease * 100) / 100;
card.due = addDays(new Date(), card.interval).toISOString();
card.lastReviewed = new Date().toISOString();
card.mastered = card.interval >= 30 || card.reps >= 6;
}
function gradeReview(rating) {
const card = state.srs[reviewQueue[reviewIndex]];
if (!card) return;
scheduleReview(card, rating);
state.reviewCount += 1;
awardXP(2);
recordActivity("review", 1);
checkBadges();
saveState();
reviewIndex += 1;
reviewRevealed = false;
render();
}
function speakingPool() {
return Object.keys(state.completedLessons).flatMap((lessonId) => {
const lesson = LESSON_MAP.get(lessonId);
if (!lesson) return [];
return lesson.examples.map((example, index) => ({
id: `${lesson.id}:s:${index}`,
jp: example[0],
kana: example[1],
zh: example[2],
lessonTitle: lesson.title
}));
});
}
let lastTranscript = "";
let lastSpeakingScore = null;
let lastSpeakingSentenceId = null;
let isRecording = false;
let completedRecognitionKey = "";
function renderSpeaking() {
const pool = speakingPool();
if (!pool.length) {
return `Speaking · 開口說
先完成一課,再開口
開口說的句子會從你已完成的課程例句中選取,確保每句都學過、看得懂。
句子池還是空的
完成第一課後,例句會自動加入。
`;
}
const sentence = pool[speakingIndex % pool.length];
const recognitionSupported = Boolean(window.SpeechRecognition || window.webkitSpeechRecognition);
const best = state.speakingBest[sentence.id];
return `
Speaking · 開口說
聽,跟著說
句子來自已完成課程:${esc(sentence.lessonTitle)}。先聽慢速,再用原速挑戰。
${(speakingIndex % pool.length) + 1} / ${pool.length}
${esc(sentence.jp)}
${esc(sentence.kana)}
${esc(sentence.zh)}
${audioButtons(sentence.kana, "開口說例句")}
${best ? `本句最佳成績:${best} 分
` : ""}
${recognitionSupported ? `` : ""}
${recordedAudioUrl ? `` : ""}
${lastSpeakingSentenceId === sentence.id && lastTranscript ? `辨識結果${esc(lastTranscript)}
` : ""}
${lastSpeakingSentenceId === sentence.id && lastSpeakingScore !== null ? `${lastSpeakingScore}
分數採寬鬆比對,重點是願意開口與持續練習。
` : ""}
${!recognitionSupported ? `錄音自評模式此瀏覽器不支援日語語音辨識。請先錄下自己的聲音、播放比對,再誠實自評。
` : `辨識不可用時也可以錄音後自評,不會影響練習紀錄。
`}
練習紀錄
已完成 ${state.speakingLog.length} 次開口說練習。
`;
}
function normalizeJapanese(text) {
return String(text || "")
.normalize("NFKC")
.replace(/[\s。、!?,.・「」『』()()]/g, "")
.replace(/[ァ-ヶ]/g, (char) => String.fromCharCode(char.charCodeAt(0) - 0x60));
}
function editDistance(a, b) {
const matrix = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
for (let i = 0; i <= a.length; i += 1) matrix[i][0] = i;
for (let j = 0; j <= b.length; j += 1) matrix[0][j] = j;
for (let i = 1; i <= a.length; i += 1) {
for (let j = 1; j <= b.length; j += 1) {
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
);
}
}
return matrix[a.length][b.length];
}
function similarity(a, b) {
const left = normalizeJapanese(a);
const right = normalizeJapanese(b);
if (!left || !right) return 0;
const distance = editDistance(left, right);
return Math.max(0, 1 - distance / Math.max(left.length, right.length));
}
function scoreTranscript(sentence, transcript) {
const scores = [similarity(transcript, sentence.jp), similarity(transcript, sentence.kana)];
return Math.round(Math.max(...scores) * 100);
}
function completeSpeaking(sentence, score, mode, transcript = "") {
const firstForSentence = !state.speakingBest[sentence.id];
state.speakingBest[sentence.id] = Math.max(Number(state.speakingBest[sentence.id] || 0), score);
state.speakingLog.push({ sentenceId: sentence.id, score, mode, transcript, at: Date.now(), date: localDateKey() });
awardXP(firstForSentence ? 15 : 5);
recordActivity("speaking", 1);
lastSpeakingScore = score;
lastSpeakingSentenceId = sentence.id;
lastTranscript = transcript;
checkBadges();
saveState();
showToast(`跟讀完成:${score} 分,獲得 ${firstForSentence ? 15 : 5} XP。`);
render();
}
async function startRecorderOnly() {
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
showToast("此瀏覽器不支援錄音,請改用自評。");
return false;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaChunks = [];
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = (event) => { if (event.data.size) mediaChunks.push(event.data); };
mediaRecorder.onstop = () => {
stream.getTracks().forEach((track) => track.stop());
if (recordedAudioUrl) URL.revokeObjectURL(recordedAudioUrl);
recordedAudioUrl = URL.createObjectURL(new Blob(mediaChunks, { type: mediaRecorder.mimeType || "audio/webm" }));
isRecording = false;
render();
};
mediaRecorder.start();
isRecording = true;
render();
return true;
} catch {
showToast("麥克風未授權,可以使用自評模式繼續練習。");
return false;
}
}
function stopRecorderOnly() {
if (mediaRecorder && mediaRecorder.state !== "inactive") mediaRecorder.stop();
}
function startSpeaking(sentence) {
const Recognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!Recognition) {
startRecorderOnly();
return;
}
lastTranscript = "";
lastSpeakingScore = null;
lastSpeakingSentenceId = sentence.id;
try {
recognition = new Recognition();
} catch {
startRecorderOnly();
return;
}
recognition.lang = "ja-JP";
recognition.interimResults = true;
recognition.continuous = false;
recognition.maxAlternatives = 3;
recognition.onresult = (event) => {
let interim = "";
for (let i = event.resultIndex; i < event.results.length; i += 1) {
const result = event.results[i];
if (!result.isFinal) {
interim = result[0]?.transcript || interim;
continue;
}
const alternatives = [...result].map((alt) => alt.transcript).filter(Boolean);
let bestTranscript = alternatives[0] || "";
let bestScore = -1;
alternatives.forEach((candidate) => {
const candidateScore = scoreTranscript(sentence, candidate);
if (candidateScore > bestScore) { bestScore = candidateScore; bestTranscript = candidate; }
});
const completionKey = `${sentence.id}:${bestTranscript}:${bestScore}`;
if (completionKey !== completedRecognitionKey) {
completedRecognitionKey = completionKey;
completeSpeaking(sentence, bestScore, "recognition", bestTranscript);
}
}
if (interim) {
lastTranscript = interim;
const node = document.querySelector(".transcript p");
if (node) node.textContent = interim;
}
};
recognition.onerror = () => showToast("辨識未成功,可以播放錄音並使用自評。");
recognition.onend = () => { recognition = null; };
startRecorderOnly();
try { recognition.start(); } catch { /* The recorder/self-assessment path remains available. */ }
}
function render() {
parseHash();
let html = "";
if (route === "dashboard") html = renderDashboard();
else if (route === "kana") html = renderKana();
else if (route === "lessons") html = renderLessons();
else if (route === "lesson") {
const lesson = LESSON_MAP.get(routeParam);
html = lesson ? renderLesson(lesson) : renderLessons();
} else if (route === "kanji") html = renderKanji();
else if (route === "review") html = renderReview();
else if (route === "speaking") html = renderSpeaking();
appEl.innerHTML = html;
updateNav();
}
document.querySelectorAll("[data-route]").forEach((button) => {
button.addEventListener("click", () => setRoute(button.dataset.route));
});
window.addEventListener("hashchange", render);
document.addEventListener("click", (event) => {
const playButton = event.target.closest("[data-play]");
if (playButton) {
event.preventDefault();
event.stopPropagation();
playJapanese(playButton.dataset.play, playButton.dataset.speed || "normal");
return;
}
const navButton = event.target.closest("[data-nav]");
if (navButton) { setRoute(navButton.dataset.nav); return; }
const lessonButton = event.target.closest("[data-open-lesson]");
if (lessonButton) { setRoute("lesson", lessonButton.dataset.openLesson); return; }
const scriptButton = event.target.closest("[data-kana-script]");
if (scriptButton) { kanaScript = scriptButton.dataset.kanaScript; kanaQuestion = null; render(); return; }
const modeButton = event.target.closest("[data-kana-mode]");
if (modeButton) { kanaMode = modeButton.dataset.kanaMode; kanaQuestion = null; render(); return; }
const kanaAnswer = event.target.closest("[data-kana-answer]");
if (kanaAnswer && kanaQuestion && !kanaAnswered) {
kanaAnswered = true;
const correct = kanaAnswer.dataset.kanaAnswer === kanaQuestion.answer[0];
kanaAnswer.classList.add(correct ? "correct" : "wrong");
document.querySelectorAll("[data-kana-answer]").forEach((button) => { button.disabled = true; });
const feedback = document.getElementById("kana-feedback");
if (feedback) {
feedback.textContent = correct ? `答對了:${kanaQuestion.answer[0]} 讀作 ${kanaQuestion.answer[1]}。` : `這題是 ${kanaQuestion.answer[0]},讀作 ${kanaQuestion.answer[1]}。`;
feedback.classList.add(correct ? "good" : "bad");
}
const next = document.querySelector("[data-next-kana]");
if (next) next.disabled = false;
if (correct) {
state.kanaCorrect += 1;
awardXP(2);
recordActivity("kana", 1);
checkBadges();
saveState();
}
return;
}
if (event.target.closest("[data-next-kana]")) { kanaQuestion = null; render(); return; }
const kanjiChip = event.target.closest("[data-kanji-index]");
if (kanjiChip) { selectedKanjiIndex = Number(kanjiChip.dataset.kanjiIndex); render(); return; }
const kanjiStep = event.target.closest("[data-kanji-step]");
if (kanjiStep) {
selectedKanjiIndex = (selectedKanjiIndex + Number(kanjiStep.dataset.kanjiStep) + DATA.kanji.length) % DATA.kanji.length;
render();
return;
}
const flip = event.target.closest("[data-flip]");
if (flip) { flip.classList.toggle("flipped"); return; }
const option = event.target.closest("[data-exercise-option]");
if (option) {
const lesson = LESSON_MAP.get(option.dataset.lesson);
if (!lesson) return;
const type = option.dataset.type;
const answer = Number(option.dataset.answer);
const index = Number(option.dataset.index);
const key = `${lesson.id}:${type}`;
if (exerciseAttempts[key] === undefined) exerciseAttempts[key] = true;
const card = option.closest("[data-exercise-card]");
const feedback = card?.querySelector(`[data-feedback="${type}"]`);
if (index !== answer) {
exerciseAttempts[key] = false;
option.classList.add("wrong");
option.disabled = true;
if (feedback) { feedback.textContent = "再想想看,你可以的。"; feedback.classList.add("bad"); }
return;
}
option.classList.add("correct");
card?.querySelectorAll("[data-exercise-option]").forEach((button) => { button.disabled = true; });
if (feedback) { feedback.textContent = "答對了,練習完成。"; feedback.classList.remove("bad"); feedback.classList.add("good"); }
markExerciseDone(lesson, type, exerciseAttempts[key]);
return;
}
const matchButton = event.target.closest("[data-match-side]");
if (matchButton) {
const lesson = LESSON_MAP.get(matchButton.dataset.lesson);
if (!lesson || lessonResult(lesson.id).match?.done) return;
const selection = matchSelections[lesson.id] || (matchSelections[lesson.id] = { left: null, matched: new Set(), correctFirstTry: true });
const pair = Number(matchButton.dataset.pair);
const card = matchButton.closest("[data-exercise-card]");
const feedback = card?.querySelector('[data-feedback="match"]');
if (matchButton.dataset.matchSide === "left") {
card.querySelectorAll('[data-match-side="left"]').forEach((button) => button.classList.remove("selected"));
selection.left = pair;
matchButton.classList.add("selected");
if (feedback) feedback.textContent = "再選右邊的中文意思。";
return;
}
if (selection.left === null) {
if (feedback) feedback.textContent = "請先選左邊的日文。";
return;
}
if (selection.left === pair) {
card.querySelectorAll(".match-btn").forEach((button) => {
if (Number(button.dataset.pair) === pair) { button.classList.add("matched"); button.classList.remove("selected"); button.disabled = true; }
});
selection.matched.add(pair);
selection.left = null;
if (feedback) feedback.textContent = `已完成 ${selection.matched.size}/4 組配對。`;
if (selection.matched.size === 4) {
if (feedback) { feedback.textContent = "配對完成,做得好。"; feedback.classList.add("good"); }
markExerciseDone(lesson, "match", selection.correctFirstTry);
}
} else {
selection.correctFirstTry = false;
matchButton.classList.add("wrong");
window.setTimeout(() => matchButton.classList.remove("wrong"), 500);
if (feedback) feedback.textContent = "這兩個不是一組,再試一次。";
}
return;
}
if (event.target.closest("[data-reveal-review]")) { reviewRevealed = true; render(); return; }
const gradeButton = event.target.closest("[data-grade]");
if (gradeButton) { gradeReview(Number(gradeButton.dataset.grade)); return; }
if (event.target.closest("[data-start-speaking]")) {
const pool = speakingPool();
if (pool.length) startSpeaking(pool[speakingIndex % pool.length]);
return;
}
if (event.target.closest("[data-toggle-record]")) {
if (isRecording) stopRecorderOnly(); else startRecorderOnly();
return;
}
if (event.target.closest("[data-next-speaking]")) {
speakingIndex += 1;
if (recordedAudioUrl) { URL.revokeObjectURL(recordedAudioUrl); recordedAudioUrl = null; }
lastTranscript = "";
lastSpeakingScore = null;
render();
return;
}
const selfScore = event.target.closest("[data-self-score]");
if (selfScore) {
const pool = speakingPool();
if (pool.length) completeSpeaking(pool[speakingIndex % pool.length], Number(selfScore.dataset.selfScore), "self", "");
return;
}
});
document.addEventListener("keydown", (event) => {
const flip = event.target.closest?.("[data-flip]");
if (flip && (event.key === "Enter" || event.key === " ") && event.target === flip) {
event.preventDefault();
flip.classList.toggle("flipped");
}
});
render();
loadRemoteState();
if ("serviceWorker" in navigator && (location.protocol === "https:" || location.hostname === "localhost")) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(() => {});
});
}