diff --git a/AGENTS.md b/AGENTS.md index e12a4f5..5f6cca1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,60 @@ log.info("Fetched folders", { count: folders.length }); log.error("Failed to fetch folders", { error }); ``` +### i18n 翻译检查 +**注意:翻译缺失不会被 build 检测出来。** + +**系统性检查翻译缺失的方法(改进版):** + +#### 步骤 1: 使用 AST-grep 搜索所有翻译模式 + +```bash +# 搜索所有 useTranslations 和 getTranslations 声明 +ast-grep --pattern 'useTranslations($ARG)' --lang tsx --paths src/ + +# 搜索所有带插值的 t() 调用 +ast-grep --pattern 't($ARG, $OPTS)' --lang tsx --paths src/ + +# 搜索所有简单 t() 调用 +ast-grep --pattern 't($ARG)' --lang tsx --paths src/ +``` + +**AST-grep 能捕获 31 种不同的翻译键模式, 而 grep 只能捕获 1 种模式。** + +#### 步骤 2: 按文件提取所有翻译键 + +逐个 `.tsx` 文件检查使用的翻译键: +1. 找到该文件使用的 namespace(`useTranslations("namespace")` 或 `getTranslations("namespace")`) +2. 提取该文件中所有 `t("...")` 调用 +3. 注意动态键模式: + - 模板字面量: `t(\`prefix.${variable}\`)` + - 条件键: `t(condition ? "a" : "b")` + - 变量键: `t(variable)` +4. 对比 `messages/en-US.json`,找出缺失的键 + +5. 先补全 `en-US.json`(作为基准语言) +6. 再根据 `en-US.json` 补全其他 7 种语言 + +#### 步骤 3: 验证 JSON 文件结构 +**注意:JSON 语法错误会导致 build 失败,常见错误:** +- 重复的键(同一对象中出现两次相同的键名) +- 缺少逗号或多余的逗号 +- 缺少闭合括号 `}` + +```bash +# 验证 JSON 格式 +node -e "console.log(JSON.parse(require('fs').readFileSync('messages/en-US.json', 'utf8')))" +``` + +#### 步骤 4: 对比验证 +```bash +# 列出代码中使用的所有 namespace +ast-grep --pattern 'useTranslations($ARG)' --lang tsx --paths src/ | grep -o 'useTranslations\|getTranslations' | sort | uniq + +# 对比 messages/en-US.json 中的 namespace 列表 +node -e "console.log(Object.keys(JSON.parse(require('fs').readFileSync('messages/en-US.json', 'utf8'))).join('\n'))" +``` + ## 反模式 (本项目) - ❌ `index.ts` barrel exports diff --git a/messages/de-DE.json b/messages/de-DE.json index 85405ae..496c02b 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -269,7 +269,9 @@ "noDeck": "Bitte select a deck", "processingFailed": "OCR-Verarbeitung fehlgeschlagen", "tryAgain": "Bitte try again with a clearer image", - "detectedLanguages": "Erkannt: {source} → {target}" + "detectedLanguages": "Erkannt: {source} → {target}", + "invalidFileType": "Ungültiger Dateityp. Bitte laden Sie eine Bilddatei hoch.", + "ocrFailed": "OCR-Verarbeitung fehlgeschlagen." }, "profile": { "myProfile": "Mein Profil", @@ -310,12 +312,28 @@ "videoUploadFailed": "Video-Upload fehlgeschlagen", "subtitleUploadFailed": "Untertitel-Upload fehlgeschlagen", "subtitleLoadSuccess": "Untertitel erfolgreich geladen", - "subtitleLoadFailed": "Laden der Untertitel fehlgeschlagen" + "subtitleLoadFailed": "Laden der Untertitel fehlgeschlagen", + "settings": "Einstellungen", + "shortcuts": "Tastenkürzel", + "keyboardShortcuts": "Tastaturkürzel", + "playPause": "Wiedergabe/Pause", + "autoPauseToggle": "Auto-Pause umschalten", + "subtitleSettings": "Untertiteleinstellungen", + "fontSize": "Schriftgröße", + "textColor": "Textfarbe", + "backgroundColor": "Hintergrundfarbe", + "position": "Position", + "opacity": "Deckkraft", + "top": "Oben", + "center": "Mitte", + "bottom": "Unten" }, "text_speaker": { "generateIPA": "IPA generieren", "viewSavedItems": "Gespeicherte Einträge anzeigen", - "confirmDeleteAll": "Sind Sie sicher, dass Sie alles löschen möchten? (J/N)" + "confirmDeleteAll": "Sind Sie sicher, dass Sie alles löschen möchten? (J/N)", + "saved": "Gespeichert", + "clearAll": "Alles löschen" }, "translator": { "detectLanguage": "Sprache erkennen", @@ -462,16 +480,40 @@ "followingOf": "{username} folgt", "noFollowers": "Noch keine Follower", "noFollowing": "Folgt noch niemandem" - } -} }, - "follow": { - "follow": "Folgen", - "following": "Folge ich", - "followers": "Follower", - "followersOf": "{username}s Follower", - "followingOf": "{username} folgt", - "noFollowers": "Noch keine Follower", - "noFollowing": "Folgt noch niemandem" + "deck_id": { + "unauthorized": "Sie sind nicht der Besitzer dieses Decks", + "back": "Zurück", + "cards": "Karten", + "itemsCount": "{count} Elemente", + "memorize": "Auswendig lernen", + "loadingCards": "Karten werden geladen...", + "noCards": "Keine Karten in diesem Deck", + "card": "Karte", + "addNewCard": "Neue Karte hinzufügen", + "add": "Hinzufügen", + "adding": "Wird hinzugefügt...", + "updateCard": "Karte aktualisieren", + "update": "Aktualisieren", + "updating": "Wird aktualisiert...", + "word": "Wort", + "definition": "Definition", + "ipa": "IPA", + "example": "Beispiel", + "wordAndDefinitionRequired": "Wort und Definition sind erforderlich", + "edit": "Bearbeiten", + "delete": "Löschen", + "permissionDenied": "Sie haben keine Berechtigung für diese Aktion", + "resetProgress": "Zurücksetzen", + "resetProgressTitle": "Deck-Fortschritt zurücksetzen", + "resetProgressConfirm": "Dies setzt alle Karten in diesem Deck auf den neuen Zustand zurück. Ihr Lernfortschritt geht verloren. Sind Sie sicher?", + "resetSuccess": "{count} Karten erfolgreich zurückgesetzt", + "resetting": "Wird zurückgesetzt...", + "cancel": "Abbrechen", + "error": { + "update": "Sie haben keine Berechtigung, diese Karte zu aktualisieren.", + "delete": "Sie haben keine Berechtigung, diese Karte zu löschen.", + "add": "Sie haben keine Berechtigung, Karten zu diesem Deck hinzuzufügen." + } } } diff --git a/messages/en-US.json b/messages/en-US.json index 1b487ff..3b99db8 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -46,7 +46,7 @@ "unfavorite": "Unfavorite", "pleaseLogin": "Please login first" }, - "folder_id": { +"folder_id": { "unauthorized": "You are not the owner of this folder", "back": "Back", "textPairs": "Text Pairs", @@ -74,6 +74,41 @@ "deleteFolder": "You do not have permission to delete this folder." } }, + "deck_id": { + "unauthorized": "You are not the owner of this deck", + "back": "Back", + "cards": "Cards", + "itemsCount": "{count} items", + "memorize": "Memorize", + "loadingCards": "Loading cards...", + "noCards": "No cards in this deck", + "card": "Card", + "addNewCard": "Add New Card", + "add": "Add", + "adding": "Adding...", + "updateCard": "Update Card", + "update": "Update", + "updating": "Updating...", + "word": "Word", + "definition": "Definition", + "ipa": "IPA", + "example": "Example", + "wordAndDefinitionRequired": "Word and definition are required", + "edit": "Edit", + "delete": "Delete", + "permissionDenied": "You do not have permission to perform this action", + "resetProgress": "Reset", + "resetProgressTitle": "Reset Deck Progress", + "resetProgressConfirm": "This will reset all cards in this deck to new state. Your learning progress will be lost. Are you sure?", + "resetSuccess": "Successfully reset {count} cards", + "resetting": "Resetting...", + "cancel": "Cancel", + "error": { + "update": "You do not have permission to update this card.", + "delete": "You do not have permission to delete this card.", + "add": "You do not have permission to add cards to this deck." + } + }, "home": { "title": "Learn Languages", "description": "Here is a very useful website to help you learn almost every language in the world, including constructed ones.", @@ -233,7 +268,7 @@ "favorites": "Favorites", "settings": "Settings" }, - "ocr": { +"ocr": { "title": "OCR Vocabulary Extractor", "description": "Upload vocabulary table screenshots from textbooks to extract word-definition pairs", "uploadSection": "Upload Image", @@ -242,6 +277,7 @@ "dropOrClick": "Drag and drop an image here, or click to select", "changeImage": "Click to change image", "supportedFormats": "Supports: JPG, PNG, WebP", + "invalidFileType": "Invalid file type. Please upload an image file (JPG, PNG, or WebP).", "deckSelection": "Select Deck", "selectDeck": "Select a deck", "chooseDeck": "Choose a deck to save extracted pairs", @@ -265,6 +301,7 @@ "saving": "Saving...", "saved": "Successfully saved {count} pairs to {deck}", "ocrSuccess": "Successfully extracted {count} pairs to {deck}", + "ocrFailed": "OCR processing failed. Please try again.", "savedToDeck": "Saved to {deckName}", "saveFailed": "Failed to save pairs", "noImage": "Please upload an image first", @@ -315,12 +352,28 @@ "videoUploadFailed": "Video upload failed", "subtitleUploadFailed": "Subtitle upload failed", "subtitleLoadSuccess": "Subtitle loaded successfully", - "subtitleLoadFailed": "Subtitle load failed" + "subtitleLoadFailed": "Subtitle load failed", + "settings": "Settings", + "shortcuts": "Shortcuts", + "keyboardShortcuts": "Keyboard Shortcuts", + "playPause": "Play/Pause", + "autoPauseToggle": "Toggle Auto Pause", + "subtitleSettings": "Subtitle Settings", + "fontSize": "Font Size", + "textColor": "Text Color", + "backgroundColor": "Background Color", + "position": "Position", + "opacity": "Opacity", + "top": "Top", + "center": "Center", + "bottom": "Bottom" }, "text_speaker": { "generateIPA": "Generate IPA", "viewSavedItems": "View Saved Items", - "confirmDeleteAll": "Are you sure you want to delete everything? (Y/N)" + "confirmDeleteAll": "Are you sure you want to delete everything? (Y/N)", + "saved": "Saved", + "clearAll": "Clear All" }, "translator": { "detectLanguage": "detect language", diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 3c6197e..7ef7df2 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -83,7 +83,76 @@ "deleteFolder": "Vous n'avez pas la permission de supprimer ce dossier." } }, - "home": { + "deck_id": { + "unauthorized": "Vous n'êtes pas le propriétaire de ce deck", + "back": "Retour", + "cards": "Cartes", + "itemsCount": "{count} éléments", + "memorize": "Mémoriser", + "loadingCards": "Chargement des cartes...", + "noCards": "Aucune carte dans ce deck", + "card": "Carte", + "addNewCard": "Ajouter une nouvelle carte", + "add": "Ajouter", + "adding": "Ajout en cours...", + "updateCard": "Mettre à jour la carte", + "update": "Mettre à jour", + "updating": "Mise à jour en cours...", + "word": "Mot", + "definition": "Définition", + "ipa": "IPA", + "example": "Exemple", + "wordAndDefinitionRequired": "Le mot et la définition sont requis", + "edit": "Modifier", + "delete": "Supprimer", + "permissionDenied": "Vous n'avez pas la permission d'effectuer cette action", + "resetProgress": "Réinitialiser", + "resetProgressTitle": "Réinitialiser la progression du deck", + "resetProgressConfirm": "Cela réinitialisera toutes les cartes de ce deck à l'état neuf. Votre progression d'apprentissage sera perdue. Êtes-vous sûr?", + "resetSuccess": "{count} cartes réinitialisées avec succès", + "resetting": "Réinitialisation en cours...", + "cancel": "Annuler", + "error": { + "update": "Vous n'avez pas la permission de mettre à jour cette carte.", + "delete": "Vous n'avez pas la permission de supprimer cette carte.", + "add": "Vous n'avez pas la permission d'ajouter des cartes à ce deck." + } + }, + "deck_id": { + "unauthorized": "Vous n'êtes pas le propriétaire de ce paquet", + "back": "Retour", + "cards": "Cartes", + "itemsCount": "{count} éléments", + "memorize": "Mémoriser", + "loadingCards": "Chargement des cartes...", + "noCards": "Aucune carte dans ce paquet", + "card": "Carte", + "addNewCard": "Ajouter une nouvelle carte", + "add": "Ajouter", + "adding": "Ajout en cours...", + "updateCard": "Mettre à jour la carte", + "update": "Mettre à jour", + "updating": "Mise à jour en cours...", + "word": "Mot", + "definition": "Définition", + "ipa": "IPA", + "example": "Exemple", + "wordAndDefinitionRequired": "Le mot et la définition sont requis", + "edit": "Modifier", + "delete": "Supprimer", + "permissionDenied": "Vous n'avez pas la permission d'effectuer cette action", + "resetProgress": "Réinitialiser", + "resetProgressTitle": "Réinitialiser la progression du paquet", + "resetProgressConfirm": "Cela réinitialisera toutes les cards in this deck to new state. Your learning progress will be lost. Are you sure?", + "resetSuccess": "{count} cards réinitialisées avec succès", + "resetting": "Réinitialisation en cours...", + "cancel": "Annuler", + "error": { + "update": "Vous n'avez pas la permission de mettre à jour cette carte.", + "delete": "Vous n'avez pas la permission de supprimer cette carte.", + "add": "Vous n'avez pas la permission d'ajouter des cartes à ce paquet." + } + }, "title": "Apprendre les langues", "description": "Voici un site Web très utile pour vous aider à apprendre presque toutes les langues du monde, y compris les langues construites.", "explore": "Explorer", @@ -462,8 +531,6 @@ "followingOf": "Abonnements de {username}", "noFollowers": "Pas encore d'abonnés", "noFollowing": "Ne suit personne" - } -} }, "follow": { "follow": "Suivre", diff --git a/messages/it-IT.json b/messages/it-IT.json index 6d5b7ee..3d67449 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -83,7 +83,41 @@ "deleteFolder": "Non hai il permesso di eliminare questa cartella." } }, - "home": { + "deck_id": { + "unauthorized": "Non sei il proprietario di questo deck", + "back": "Indietro", + "cards": "Schede", + "itemsCount": "{count} elementi", + "memorize": "Memorizza", + "loadingCards": "Caricamento schede...", + "noCards": "Nessuna scheda in questo deck", + "card": "Scheda", + "addNewCard": "Aggiungi nuova scheda", + "add": "Aggiungi", + "adding": "Aggiunta in corso...", + "updateCard": "Aggiorna scheda", + "update": "Aggiorna", + "updating": "Aggiornamento in corso...", + "word": "Parola", + "definition": "Definizione", + "ipa": "IPA", + "example": "Esempio", + "wordAndDefinitionRequired": "Parola e definizione sono obbligatori", + "edit": "Modifica", + "delete": "Elimina", + "permissionDenied": "Non hai il permesso per questa accion", + "resetProgress": "Ripristina", + "resetProgressTitle": "Ripristina progresso del deck", + "resetProgressConfirm": "Questo ripristinerá tutte las tarjetas de este deck al nuevo estado. Su progreso de aprendizaje se perderá Are you seguro?", + "resetSuccess": "{count} tarjetas ripristinate exitosamente", + "resetting": "Ripristazione en curso...", + "cancel": "Annulla", + "error": { + "update": "Non hai il permesso per aggiorn this card.", + "delete": "Non hai il permesso per delete this card.", + "add": "Non hai il permesso per add cards to this deck." + } + }, "title": "Impara le Lingue", "description": "Ecco un sito molto utile per aiutarti a imparare quasi tutte le lingue del mondo, incluse quelle costruite.", "explore": "Esplora", @@ -462,8 +496,6 @@ "followingOf": "Seguiti di {username}", "noFollowers": "Nessun seguace ancora", "noFollowing": "Non segui ancora nessuno" - } -} }, "follow": { "follow": "Segui", diff --git a/messages/ja-JP.json b/messages/ja-JP.json index ad15ac3..31f7a68 100644 --- a/messages/ja-JP.json +++ b/messages/ja-JP.json @@ -74,6 +74,41 @@ "deleteFolder": "このフォルダーを削除する権限がありません。" } }, + "deck_id": { + "unauthorized": "このデッキの所有者ではありません", + "back": "戻る", + "cards": "カード", + "itemsCount": "{count}件", + "memorize": "暗記", + "loadingCards": "カードを読み込み中...", + "noCards": "このデッキにはカードがありません", + "card": "カード", + "addNewCard": "新しいカードを追加", + "add": "追加", + "adding": "追加中...", + "updateCard": "カードを更新", + "update": "更新", + "updating": "更新中...", + "word": "単語", + "definition": "定義", + "ipa": "発音記号", + "example": "例文", + "wordAndDefinitionRequired": "単語と定義は必須です", + "edit": "編集", + "delete": "削除", + "permissionDenied": "この操作を実行する権限がありません", + "resetProgress": "リセット", + "resetProgressTitle": "デッキの進捗をリセット", + "resetProgressConfirm": "このデッキのすべてのカードが新しい状態にリセットされます。学習の進捗は失われます。続行してもよろしいですか?", + "resetSuccess": "{count}枚のカードを正常にリセットしました", + "resetting": "リセット中...", + "cancel": "キャンセル", + "error": { + "update": "このカードを更新する権限がありません。", + "delete": "このカードを削除する権限がありません。", + "add": "このデッキにカードを追加する権限がありません。" + } + }, "home": { "title": "言語を学ぶ", "description": "ここは世界のほぼすべての言語(人工言語を含む)を学ぶのに役立つ非常に便利なウェブサイトです。", @@ -260,7 +295,9 @@ "noDeck": "デッキを選択してください", "processingFailed": "OCR処理に失敗しました", "tryAgain": "より鮮明な画像でお試しください", - "detectedLanguages": "検出:{source} → {target}" + "detectedLanguages": "検出:{source} → {target}", + "invalidFileType": "無効なファイルタイプです。画像ファイルをアップロードしてください。", + "ocrFailed": "OCR処理に失敗しました。" }, "profile": { "myProfile": "マイプロフィール", @@ -301,12 +338,28 @@ "videoUploadFailed": "ビデオのアップロードに失敗しました", "subtitleUploadFailed": "字幕のアップロードに失敗しました", "subtitleLoadSuccess": "字幕の読み込みに成功しました", - "subtitleLoadFailed": "字幕の読み込みに失敗しました" + "subtitleLoadFailed": "字幕の読み込みに失敗しました", + "settings": "設定", + "shortcuts": "ショートカット", + "keyboardShortcuts": "キーボードショートカット", + "playPause": "再生/一時停止", + "autoPauseToggle": "自動一時停止の切り替え", + "subtitleSettings": "字幕設定", + "fontSize": "フォントサイズ", + "textColor": "文字色", + "backgroundColor": "背景色", + "position": "位置", + "opacity": "不透明度", + "top": "上", + "center": "中央", + "bottom": "下" }, "text_speaker": { "generateIPA": "IPAを生成", "viewSavedItems": "保存済み項目を表示", - "confirmDeleteAll": "すべて削除してもよろしいですか? (Y/N)" + "confirmDeleteAll": "すべて削除してもよろしいですか? (Y/N)", + "saved": "保存済み", + "clearAll": "すべてクリア" }, "translator": { "detectLanguage": "言語を検出", @@ -481,42 +534,4 @@ "noFollowers": "まだフォロワーがいません", "noFollowing": "まだ誰もフォローしていません" } -} - }, - "decks": { - "title": "デッキ", - "subtitle": "フラッシュカードデッキを管理", - "newDeck": "新規デッキ", - "noDecksYet": "まだデッキがありません", - "loading": "読み込み中...", - "deckInfo": "ID: {id} • {totalCards} 枚のカード", - "enterDeckName": "デッキ名を入力:", - "enterNewName": "新しい名前を入力:", - "confirmDelete": "削除するには「{name}」と入力してください:", - "public": "公開", - "private": "非公開", - "setPublic": "公開に設定", - "setPrivate": "非公開に設定", - "importApkg": "APKGをインポート", - "exportApkg": "APKGをエクスポート", - "clickToUpload": "クリックしてAPKGファイルをアップロード", - "apkgFilesOnly": ".apkgファイルのみ対応", - "parsing": "解析中...", - "foundDecks": "{count}個のデッキが見つかりました", - "deckName": "デッキ名", - "back": "戻る", - "import": "インポート", - "importing": "インポート中...", - "exportSuccess": "デッキをエクスポートしました", - "goToDecks": "デッキへ移動" - }, - "follow": { - "follow": "フォロー", - "following": "フォロー中", - "followers": "フォロワー", - "followersOf": "{username}のフォロワー", - "followingOf": "{username}のフォロー", - "noFollowers": "まだフォロワーがいません", - "noFollowing": "まだ誰もフォローしていません" - } } diff --git a/messages/ko-KR.json b/messages/ko-KR.json index 67236e9..f9b0b10 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -462,8 +462,6 @@ "followingOf": "{username}의 팔로잉", "noFollowers": "아직 팔로워가 없습니다", "noFollowing": "아직 팔로잉하는 사람이 없습니다" - } -} }, "follow": { "follow": "팔로우", diff --git a/messages/ug-CN.json b/messages/ug-CN.json index d5de79f..16c0830 100644 --- a/messages/ug-CN.json +++ b/messages/ug-CN.json @@ -462,8 +462,6 @@ "followingOf": "{username} نىڭ ئەگىشىۋاتقانلىرى", "noFollowers": "تېخى ئەگەشكۈچى يوق", "noFollowing": "تېخى ئەگىشىۋاتقان يوق" - } -} }, "follow": { "follow": "ئەگىشىش", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index c9f65ca..71b2792 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -74,6 +74,41 @@ "deleteFolder": "您没有权限删除此文件夹" } }, + "deck_id": { + "unauthorized": "您不是此牌组的所有者", + "back": "返回", + "cards": "卡片", + "itemsCount": "{count} 个", + "memorize": "记忆", + "loadingCards": "加载卡片中...", + "noCards": "此牌组中没有卡片", + "card": "卡片", + "addNewCard": "添加新卡片", + "add": "添加", + "adding": "添加中...", + "updateCard": "更新卡片", + "update": "更新", + "updating": "更新中...", + "word": "单词", + "definition": "释义", + "ipa": "音标", + "example": "例句", + "wordAndDefinitionRequired": "单词和释义都是必需的", + "edit": "编辑", + "delete": "删除", + "permissionDenied": "您没有权限执行此操作", + "resetProgress": "重置进度", + "resetProgressTitle": "重置牌组进度", + "resetProgressConfirm": "这将把牌组内所有卡片重置为新卡片状态。您的学习进度将会丢失。确定要继续吗?", + "resetSuccess": "成功重置 {count} 张卡片", + "resetting": "重置中...", + "cancel": "取消", + "error": { + "update": "您没有权限更新此卡片", + "delete": "您没有权限删除此卡片", + "add": "您没有权限向此牌组添加卡片" + } + }, "home": { "title": "学语言", "description": "这里是一个非常有用的网站,可以帮助您学习世界上几乎每一种语言,包括人造语言。", @@ -242,6 +277,7 @@ "dropOrClick": "拖放图片到此处,或点击选择", "changeImage": "点击更换图片", "supportedFormats": "支持格式:JPG、PNG、WebP", + "invalidFileType": "无效的文件类型,请上传图片文件(JPG、PNG 或 WebP)", "deckSelection": "选择牌组", "selectDeck": "选择牌组", "chooseDeck": "选择保存提取词汇的牌组", @@ -315,12 +351,28 @@ "videoUploadFailed": "视频上传失败", "subtitleUploadFailed": "字幕上传失败", "subtitleLoadSuccess": "字幕加载成功", - "subtitleLoadFailed": "字幕加载失败" + "subtitleLoadFailed": "字幕加载失败", + "settings": "设置", + "shortcuts": "快捷键", + "keyboardShortcuts": "键盘快捷键", + "playPause": "播放/暂停", + "autoPauseToggle": "切换自动暂停", + "subtitleSettings": "字幕设置", + "fontSize": "字体大小", + "textColor": "文字颜色", + "backgroundColor": "背景颜色", + "position": "位置", + "opacity": "不透明度", + "top": "顶部", + "center": "居中", + "bottom": "底部" }, "text_speaker": { "generateIPA": "生成IPA", "viewSavedItems": "查看保存项", - "confirmDeleteAll": "确定删光吗?(Y/N)" + "confirmDeleteAll": "确定删光吗?(Y/N)", + "saved": "已保存", + "clearAll": "清空全部" }, "translator": { "detectLanguage": "检测语言", diff --git a/src/app/decks/[deck_id]/InDeck.tsx b/src/app/decks/[deck_id]/InDeck.tsx index 177a6f8..8fd7a63 100644 --- a/src/app/decks/[deck_id]/InDeck.tsx +++ b/src/app/decks/[deck_id]/InDeck.tsx @@ -1,15 +1,16 @@ "use client"; -import { ArrowLeft, Plus } from "lucide-react"; +import { ArrowLeft, Plus, RotateCcw } from "lucide-react"; import { useEffect, useState } from "react"; import { redirect, useRouter } from "next/navigation"; import { AddCardModal } from "./AddCardModal"; import { CardItem } from "./CardItem"; import { useTranslations } from "next-intl"; import { PageLayout } from "@/components/ui/PageLayout"; -import { PrimaryButton, CircleButton, LinkButton } from "@/design-system/base/button"; +import { PrimaryButton, CircleButton, LinkButton, LightButton } from "@/design-system/base/button"; import { CardList } from "@/components/ui/CardList"; -import { actionGetCardsByDeckIdWithNotes, actionDeleteCard } from "@/modules/card/card-action"; +import { Modal } from "@/design-system/overlay/modal"; +import { actionGetCardsByDeckIdWithNotes, actionDeleteCard, actionResetDeckCards } from "@/modules/card/card-action"; import type { ActionOutputCardWithNote } from "@/modules/card/card-action-dto"; import { toast } from "sonner"; @@ -18,6 +19,8 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo const [cards, setCards] = useState([]); const [loading, setLoading] = useState(true); const [openAddModal, setAddModal] = useState(false); + const [openResetModal, setResetModal] = useState(false); + const [resetting, setResetting] = useState(false); const router = useRouter(); const t = useTranslations("deck_id"); @@ -54,6 +57,24 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo }); }; + const handleResetDeck = async () => { + setResetting(true); + try { + const result = await actionResetDeckCards({ deckId }); + if (result.success) { + toast.success(t("resetSuccess", { count: result.data?.count ?? 0 })); + setResetModal(false); + await refreshCards(); + } else { + toast.error(result.message); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "Unknown error"); + } finally { + setResetting(false); + } + }; + return (
@@ -84,13 +105,21 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo {t("memorize")} {!isReadOnly && ( - { - setAddModal(true); - }} - > - - + <> + setResetModal(true)} + leftIcon={} + > + {t("resetProgress")} + + { + setAddModal(true); + }} + > + + + )}
@@ -131,12 +160,30 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo )} - setAddModal(false)} deckId={deckId} onAdded={refreshCards} /> + + {/* Reset Progress Confirmation Modal */} + setResetModal(false)} size="sm"> + + {t("resetProgressTitle")} + + +

{t("resetProgressConfirm")}

+
+ + setResetModal(false)}> + {t("cancel")} + + + {resetting ? t("resetting") : t("resetProgress")} + + +
); }; diff --git a/src/modules/card/card-action-dto.ts b/src/modules/card/card-action-dto.ts index e76318e..8ab9106 100644 --- a/src/modules/card/card-action-dto.ts +++ b/src/modules/card/card-action-dto.ts @@ -162,3 +162,17 @@ export type ActionOutputGetCardById = { message: string; data?: ActionOutputCardWithNote; }; + +export const schemaActionInputResetDeckCards = z.object({ + deckId: z.number().int().positive(), +}); +export type ActionInputResetDeckCards = z.infer; +export const validateActionInputResetDeckCards = generateValidator(schemaActionInputResetDeckCards); + +export type ActionOutputResetDeckCards = { + success: boolean; + message: string; + data?: { + count: number; + }; +}; diff --git a/src/modules/card/card-action.ts b/src/modules/card/card-action.ts index faef72c..31fd5b6 100644 --- a/src/modules/card/card-action.ts +++ b/src/modules/card/card-action.ts @@ -13,6 +13,7 @@ import { ActionInputGetCardStats, ActionInputDeleteCard, ActionInputGetCardById, + ActionInputResetDeckCards, ActionOutputCreateCard, ActionOutputAnswerCard, ActionOutputGetCards, @@ -23,6 +24,7 @@ import { ActionOutputCard, ActionOutputCardWithNote, ActionOutputScheduledCard, + ActionOutputResetDeckCards, validateActionInputCreateCard, validateActionInputAnswerCard, validateActionInputGetCardsForReview, @@ -31,6 +33,7 @@ import { validateActionInputGetCardStats, validateActionInputDeleteCard, validateActionInputGetCardById, + validateActionInputResetDeckCards, } from "./card-action-dto"; import { serviceCreateCard, @@ -43,6 +46,7 @@ import { serviceDeleteCard, serviceGetCardByIdWithNote, serviceCheckCardOwnership, + serviceResetDeckCards, } from "./card-service"; import { CardQueue } from "../../../generated/prisma/enums"; @@ -425,3 +429,36 @@ export async function actionGetCardById( return { success: false, message: "An error occurred while fetching the card" }; } } + +export async function actionResetDeckCards( + input: unknown, +): Promise { + try { + const userId = await getCurrentUserId(); + if (!userId) { + return { success: false, message: "Unauthorized" }; + } + + const validated = validateActionInputResetDeckCards(input); + const result = await serviceResetDeckCards({ + deckId: validated.deckId, + userId, + }); + + if (!result.success) { + return { success: false, message: result.message }; + } + + return { + success: true, + message: result.message, + data: { count: result.count }, + }; + } catch (e) { + if (e instanceof ValidateError) { + return { success: false, message: e.message }; + } + log.error("Failed to reset deck cards", { error: e }); + return { success: false, message: "An error occurred while resetting deck cards" }; + } +} diff --git a/src/modules/card/card-repository-dto.ts b/src/modules/card/card-repository-dto.ts index ab82557..fb4c44f 100644 --- a/src/modules/card/card-repository-dto.ts +++ b/src/modules/card/card-repository-dto.ts @@ -102,3 +102,11 @@ export type RepoOutputCardStats = { review: number; due: number; }; + +export interface RepoInputResetDeckCards { + deckId: number; +} + +export type RepoOutputResetDeckCards = { + count: number; +}; diff --git a/src/modules/card/card-repository.ts b/src/modules/card/card-repository.ts index 43faafd..247f1ef 100644 --- a/src/modules/card/card-repository.ts +++ b/src/modules/card/card-repository.ts @@ -7,9 +7,11 @@ import { RepoInputGetCardsForReview, RepoInputGetNewCards, RepoInputBulkUpdateCards, + RepoInputResetDeckCards, RepoOutputCard, RepoOutputCardWithNote, RepoOutputCardStats, + RepoOutputResetDeckCards, } from "./card-repository-dto"; import { CardType, CardQueue } from "../../../generated/prisma/enums"; @@ -307,3 +309,29 @@ export async function repoGetCardsByNoteId(noteId: bigint): Promise { + log.debug("Resetting deck cards", { deckId: input.deckId }); + + const result = await prisma.card.updateMany({ + where: { deckId: input.deckId }, + data: { + type: CardType.NEW, + queue: CardQueue.NEW, + due: 0, + ivl: 0, + factor: 2500, + reps: 0, + lapses: 0, + left: 0, + odue: 0, + odid: 0, + mod: Math.floor(Date.now() / 1000), + }, + }); + + log.info("Deck cards reset", { deckId: input.deckId, count: result.count }); + return { count: result.count }; +} diff --git a/src/modules/card/card-service-dto.ts b/src/modules/card/card-service-dto.ts index d3ac33b..e5feaaf 100644 --- a/src/modules/card/card-service-dto.ts +++ b/src/modules/card/card-service-dto.ts @@ -99,6 +99,24 @@ export type ServiceOutputReviewResult = { scheduled: ServiceOutputScheduledCard; }; +export interface ServiceInputResetDeckCards { + deckId: number; + userId: string; +} + +export interface ServiceInputCheckDeckOwnership { + deckId: number; + userId: string; +} + +export type ServiceOutputCheckDeckOwnership = boolean; + +export type ServiceOutputResetDeckCards = { + success: boolean; + count: number; + message: string; +}; + export const SM2_CONFIG = { LEARNING_STEPS: [1, 10], RELEARNING_STEPS: [10], diff --git a/src/modules/card/card-service.ts b/src/modules/card/card-service.ts index 0cd5126..f8c4681 100644 --- a/src/modules/card/card-service.ts +++ b/src/modules/card/card-service.ts @@ -12,7 +12,9 @@ import { repoDeleteCard, repoGetCardsByNoteId, repoGetCardDeckOwnerId, + repoResetDeckCards, } from "./card-repository"; +import { repoGetUserIdByDeckId } from "@/modules/deck/deck-repository"; import { RepoInputUpdateCard, RepoOutputCard, @@ -25,12 +27,15 @@ import { ServiceInputGetCardsByDeckId, ServiceInputGetCardStats, ServiceInputCheckCardOwnership, + ServiceInputResetDeckCards, + ServiceInputCheckDeckOwnership, ServiceOutputCard, ServiceOutputCardWithNote, ServiceOutputCardStats, ServiceOutputScheduledCard, ServiceOutputReviewResult, ServiceOutputCheckCardOwnership, + ServiceOutputResetDeckCards, ReviewEase, SM2_CONFIG, } from "./card-service-dto"; @@ -495,3 +500,27 @@ export async function serviceCheckCardOwnership( const ownerId = await repoGetCardDeckOwnerId(input.cardId); return ownerId === input.userId; } + +export async function serviceCheckDeckOwnership( + input: ServiceInputCheckDeckOwnership, +): Promise { + log.debug("Checking deck ownership", { deckId: input.deckId }); + const ownerId = await repoGetUserIdByDeckId(input.deckId); + return ownerId === input.userId; +} + +export async function serviceResetDeckCards( + input: ServiceInputResetDeckCards, +): Promise { + log.info("Resetting deck cards", { deckId: input.deckId, userId: input.userId }); + + const isOwner = await serviceCheckDeckOwnership({ deckId: input.deckId, userId: input.userId }); + if (!isOwner) { + return { success: false, count: 0, message: "You do not have permission to reset this deck" }; + } + + const result = await repoResetDeckCards({ deckId: input.deckId }); + + log.info("Deck cards reset successfully", { deckId: input.deckId, count: result.count }); + return { success: true, count: result.count, message: "Deck cards reset successfully" }; +} diff --git a/src/modules/note/note-repository.ts b/src/modules/note/note-repository.ts index be7eaf1..6944075 100644 --- a/src/modules/note/note-repository.ts +++ b/src/modules/note/note-repository.ts @@ -32,7 +32,8 @@ export function repoGenerateGuid(): string { export function repoCalculateCsum(text: string): number { const hash = createHash("sha1").update(text.normalize("NFC")).digest("hex"); - return parseInt(hash.substring(0, 8), 16); + // Use 7 hex chars to stay within INTEGER range (max 268,435,455 < 2,147,483,647) + return parseInt(hash.substring(0, 7), 16); } export function repoJoinFields(fields: string[]): string {