Compare commits

...

22 Commits

Author SHA1 Message Date
dda7d64dee fix: add scrollable container to Memorize card to prevent button jumping 2026-03-18 14:22:00 +08:00
911343ce0d fix: add missing noIpa translation 2026-03-18 10:19:36 +08:00
130ab226ff fix: JSON syntax errors and add missing translations
- Fix missing comma in en-US.json
- Add noIpa translation to all locale files
2026-03-18 10:16:46 +08:00
59d22ccf4c fix: add missing comma in en-US.json 2026-03-18 09:48:45 +08:00
06012c43f2 feat: add study modes to Memorize page
- Add 4 study modes: order-limited, order-infinite, random-limited, random-infinite
- Add mode selector buttons with icons
- Update progress display for infinite modes
- Add translations for all 8 locales
2026-03-18 08:52:45 +08:00
c54376cbe6 feat: display card meanings as table in Memorize
- Change card back display from joined string to structured table
- Each meaning shows part of speech and definition separately
- Improved readability for multiple meanings
2026-03-18 08:42:22 +08:00
3ed3478c66 fix: change default theme color to mist in CSS
Prevent FOUC (Flash of Unstyled Content) on page refresh by aligning
CSS default colors with the DEFAULT_THEME setting in theme-presets.ts
2026-03-18 08:36:22 +08:00
bc7608e049 fix: add missing translations and fix namespace usage
- Fix srt-player to use srtT namespace for error messages
- Add deck_id.enterLanguageName and language labels (english, chinese, japanese, korean)
- Add memorize.review.nextCard translation
- Update all 8 locales with consistent translations
2026-03-18 08:34:04 +08:00
1ef337801d refactor: unify i18n function calls and simplify scripts
- Replace dynamic t(lang.labelKey) with static t(lang.label) using helper functions
- Add getLanguageLabel/getLangLabel/getLocaleLabel helper functions for switch-based label lookup
- Simplify translation check scripts to only detect literal string calls
- Fix namespace lookup for dotted namespaces like 'memorize.review'
2026-03-18 08:13:58 +08:00
286add7fff fix: rewrite translation check scripts with proper regex
- Fix regex to handle 'await getTranslations' pattern
- Add word boundary to prevent false matches like 'get("q")'
- Improve namespace detection for dotted namespaces
- Reduce false positives in both scripts
2026-03-18 07:59:21 +08:00
de7c1321c2 refactor: remove Anki import/export and simplify card system
- Remove Anki apkg import/export functionality
- Remove OCR feature module
- Remove note and note-type modules
- Simplify card/deck modules (remove spaced repetition complexity)
- Update translator and dictionary features
- Clean up unused translations and update i18n files
- Simplify prisma schema
2026-03-17 20:24:42 +08:00
95ce49378b feat: add translation check scripts
- find-missing-translations.ts: detect translation keys used in code but missing in message files
- find-unused-translations.ts: detect translation keys in message files but not used in code
2026-03-17 20:24:06 +08:00
2f5ec1c0f0 feat(translator): add custom target language input
- Replace Select with Input for custom language entry
- Users can now type any target language they want
- Add i18n translations for all 8 languages
2026-03-16 12:07:46 +08:00
f53fa5e2a1 refactor: unify design-system components across pages
- Replace native textarea with Textarea in translator and text-speaker pages
- Replace custom loading spinners with Skeleton in InDeck and FavoritesClient pages
- Add shared constants DEFAULT_NEW_PER_DAY, DEFAULT_REV_PER_DAY
2026-03-16 09:44:51 +08:00
1d5732abc8 refactor: optimize repoGetTodayStudyStats with SQL aggregation and use shared constants
- Replace JS counting with Prisma groupBy for better performance
- Add DEFAULT_NEW_PER_DAY and DEFAULT_REV_PER_DAY constants
- Use constants in InDeck.tsx
2026-03-16 09:31:21 +08:00
ada2f249ee refactor: add shared utilities and replace console.log with logger
- Add shared action-utils.ts with getCurrentUserId and requireAuth helpers
- Add shared constants for anki defaults (FIELD_SEPARATOR, DEFAULT_NEW_PER_DAY, DEFAULT_REV_PER_DAY)
- Add shared time constants (SECONDS_PER_DAY, MS_PER_DAY, etc.)
- Replace console.error with logger in auth.ts
2026-03-16 09:24:57 +08:00
bc0b392875 feat(deck): add daily learning limits and today's study stats
- Add newPerDay and revPerDay fields to Deck model (Anki-style)
- Add settings modal to configure daily limits per deck
- Display today's studied counts (new/review/learning) on deck page
- Add i18n translations for all 8 languages
- Fix JSON syntax errors in fr-FR.json and it-IT.json
- Fix double counting bug in repoGetTodayStudyStats
2026-03-16 09:01:55 +08:00
a68951f1d3 refactor(ui): use design-system components across pages
- Replace custom spinners with Skeleton
- Replace native inputs/select with design-system components
- Simplify dictation mode (user self-judges instead of input)
- Set body background to primary-50
- Clean up answer button shortcuts
2026-03-16 07:58:43 +08:00
c525bd4591 feat(learn): add reverse and dictation modes for card review
- Add reverse mode to swap card front/back
- Add dictation mode with TTS audio playback and answer verification
- Add i18n translations for new features in all 8 languages
- Integrate useAudioPlayer hook for TTS playback
2026-03-14 11:52:56 +08:00
6213dd2338 refactor: move memorize feature to /decks/[deck_id]/learn route
- Delete (features)/memorize directory
- Create /decks/[deck_id]/learn with Memorize component and page
- Update InDeck.tsx to navigate to new learn route
- Fix homepage memorize link to point to /decks
2026-03-14 11:34:46 +08:00
af684a15ce feat: add reset deck progress feature for deck detail page 2026-03-13 22:02:55 +08:00
279eee2953 i18n: fix navbar 'folders' to 'decks' and add follow section 2026-03-13 19:30:44 +08:00
94 changed files with 4215 additions and 8032 deletions

View File

@@ -104,6 +104,60 @@ log.info("Fetched folders", { count: folders.length });
log.error("Failed to fetch folders", { error }); 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 -`index.ts` barrel exports

View File

@@ -53,7 +53,30 @@
"totalCards": "Gesamtkarten", "totalCards": "Gesamtkarten",
"createdAt": "Erstellt am", "createdAt": "Erstellt am",
"actions": "Aktionen", "actions": "Aktionen",
"view": "Anzeigen" "view": "Anzeigen",
"subtitle": "Lern-Decks verwalten",
"newDeck": "Neues Deck",
"noDecksYet": "Noch keine Decks",
"loading": "Laden...",
"deckInfo": "ID: {id} · {totalCards} Karten",
"enterDeckName": "Deck-Name eingeben:",
"enterNewName": "Neuen Namen eingeben:",
"confirmDelete": "\"{name}\" eingeben zum Löschen:",
"public": "Öffentlich",
"private": "Privat",
"setPublic": "Öffentlich machen",
"setPrivate": "Privat machen",
"importApkg": "APKG importieren",
"exportApkg": "APKG exportieren",
"clickToUpload": "Klicken zum Hochladen",
"apkgFilesOnly": "Nur .apkg Dateien",
"parsing": "Analysieren...",
"foundDecks": "{count} Decks gefunden",
"back": "Zurück",
"import": "Importieren",
"importing": "Importieren...",
"exportSuccess": "Export erfolgreich",
"goToDecks": "Zu Decks"
}, },
"folder_id": { "folder_id": {
"unauthorized": "Sie sind nicht der Besitzer dieses Ordners", "unauthorized": "Sie sind nicht der Besitzer dieses Ordners",
@@ -187,8 +210,8 @@
}, },
"memorize": { "memorize": {
"deck_selector": { "deck_selector": {
"selectDeck": "Deck auswählen", "selectDeck": "Deck wählen",
"noDecks": "Keine Decks gefunden", "noDecks": "Keine Decks",
"goToDecks": "Zu Decks", "goToDecks": "Zu Decks",
"noCards": "Keine Karten", "noCards": "Keine Karten",
"new": "Neu", "new": "Neu",
@@ -199,37 +222,26 @@
"review": { "review": {
"loading": "Laden...", "loading": "Laden...",
"backToDecks": "Zurück zu Decks", "backToDecks": "Zurück zu Decks",
"allDone": "Fertig!", "allDone": "Alles erledigt!",
"allDoneDesc": "Alle fälligen Karten wurden wiederholt.", "allDoneDesc": "Lernen für heute abgeschlossen!",
"reviewedCount": "{count} Karten wiederholt", "reviewedCount": "{count} Karten wiederholt",
"progress": "{current} / {total}", "progress": "{current} / {total}",
"nextReview": "Nächste Wiederholung", "nextReview": "Nächste Wiederholung",
"interval": "Intervall", "interval": "Intervall",
"ease": "Leichtigkeit", "ease": "Schwierigkeit",
"lapses": "Verlernungen", "lapses": "Fehler",
"showAnswer": "Antwort zeigen", "showAnswer": "Antwort zeigen",
"nextCard": "Weiter",
"again": "Nochmal", "again": "Nochmal",
"hard": "Schwer", "restart": "Neustart",
"good": "Gut", "orderLimited": "Reihenfolge begrenzt",
"easy": "Leicht", "orderInfinite": "Reihenfolge unbegrenzt",
"now": "jetzt", "randomLimited": "Zufällig begrenzt",
"lessThanMinute": "<1 Min", "randomInfinite": "Zufällig unbegrenzt",
"inMinutes": "{count} Min", "noIpa": "Kein IPA verfügbar"
"inHours": "{count} Std",
"inDays": "{count} Tage",
"inMonths": "{count} Monate",
"minutes": "<1 Min",
"days": "{count} Tage",
"months": "{count} Monate",
"minAbbr": "m",
"dayAbbr": "T",
"cardTypeNew": "Neu",
"cardTypeLearning": "Lernen",
"cardTypeReview": "Wiederholung",
"cardTypeRelearning": "Neu lernen"
}, },
"page": { "page": {
"unauthorized": "Sie sind nicht berechtigt, auf dieses Deck zuzugreifen" "unauthorized": "Nicht autorisiert"
} }
}, },
"navbar": { "navbar": {
@@ -237,39 +249,55 @@
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "Anmelden", "sign_in": "Anmelden",
"profile": "Profil", "profile": "Profil",
"folders": "Ordner", "folders": "Decks",
"explore": "Erkunden", "explore": "Erkunden",
"favorites": "Favoriten", "favorites": "Favoriten",
"settings": "Einstellungen" "settings": "Einstellungen"
}, },
"ocr": { "ocr": {
"title": "OCR Vokabel-Extraktion", "title": "OCR-Erkennung",
"description": "Laden Sie Screenshots von Vokabeltabellen aus Lehrbüchern hoch, um Wort-Definition-Paare zu extrahieren", "description": "Text aus Bildern extrahieren",
"uploadImage": "Bild hochladen", "uploadImage": "Bild hochladen",
"dragDropHint": "Ziehen Sie ein Bild hierher oder klicken Sie zum Auswählen", "dragDropHint": "Ziehen und ablegen",
"supportedFormats": "Unterstüt: JPG, PNG, WebP", "supportedFormats": "Unterstützt: JPG, PNG, WEBP",
"selectDeck": "Deck auswählen", "selectDeck": "Deck wählen",
"chooseDeck": "Wählen Sie einen Deck zum Speichern der extrahierten Paare", "chooseDeck": "Deck wählen",
"noDecks": "Keine Decks verfügbar. Bitte create a deck first.", "noDecks": "Keine Decks verfügbar",
"languageHints": "Sprachhinweise (Optional)", "languageHints": "Sprachhinweise",
"sourceLanguageHint": "Quellsprache (z.B. Englisch)", "sourceLanguageHint": "Quellsprache",
"targetLanguageHint": "Ziel-/Übersetzungssprache (z.B. Chinesisch)", "targetLanguageHint": "Zielsprache",
"process": "Bild verarbeiten", "process": "Verarbeiten",
"processing": "Verarbeitung...", "processing": "Verarbeiten...",
"preview": "Vorschau", "preview": "Vorschau",
"extractedPairs": "Extrahierte Paare", "extractedPairs": "Extrahierte Paare",
"word": "Wort", "word": "Wort",
"definition": "Definition", "definition": "Definition",
"pairsCount": "{count} Paare extrahiert", "pairsCount": "{count} Paare",
"savePairs": "In Deck speichern", "savePairs": "Speichern",
"saving": "Speichern...", "saving": "Speichern...",
"saved": "{count} Paare erfolgreich in {deck} gespeichert", "saved": "Gespeichert",
"saveFailed": "Speichern fehlgeschlagen", "saveFailed": "Speichern fehlgeschlagen",
"noImage": "Bitte laden Sie zuerst ein Bild hoch", "noImage": "Bitte Bild hochladen",
"noDeck": "Bitte select a deck", "noDeck": "Bitte Deck wählen",
"processingFailed": "OCR-Verarbeitung fehlgeschlagen", "processingFailed": "Verarbeitung fehlgeschlagen",
"tryAgain": "Bitte try again with a clearer image", "tryAgain": "Erneut versuchen",
"detectedLanguages": "Erkannt: {source} → {target}" "detectedLanguages": "Erkannte Sprachen",
"invalidFileType": "Ungültiger Dateityp",
"ocrFailed": "OCR fehlgeschlagen",
"uploadSection": "Bild hochladen",
"dropOrClick": "Ablegen oder klicken",
"changeImage": "Bild ändern",
"deckSelection": "Deck wählen",
"sourceLanguagePlaceholder": "z.B. Englisch",
"targetLanguagePlaceholder": "z.B. Deutsch",
"processButton": "Erkennung starten",
"resultsPreview": "Ergebnisvorschau",
"saveButton": "In Deck speichern",
"ocrSuccess": "OCR erfolgreich",
"savedToDeck": "In Deck gespeichert",
"noResultsToSave": "Keine Ergebnisse",
"detectedSourceLanguage": "Erkannte Quellsprache",
"detectedTargetLanguage": "Erkannte Zielsprache"
}, },
"profile": { "profile": {
"myProfile": "Mein Profil", "myProfile": "Mein Profil",
@@ -310,12 +338,43 @@
"videoUploadFailed": "Video-Upload fehlgeschlagen", "videoUploadFailed": "Video-Upload fehlgeschlagen",
"subtitleUploadFailed": "Untertitel-Upload fehlgeschlagen", "subtitleUploadFailed": "Untertitel-Upload fehlgeschlagen",
"subtitleLoadSuccess": "Untertitel erfolgreich geladen", "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",
"subtitleSettings": "Untertiteleinstellungen",
"fontSize": "Schriftgröße",
"textColor": "Textfarbe",
"backgroundColor": "Hintergrundfarbe",
"position": "Position",
"opacity": "Deckkraft",
"top": "Oben",
"center": "Mitte",
"bottom": "Unten"
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "IPA generieren", "generateIPA": "IPA generieren",
"viewSavedItems": "Gespeicherte Einträge anzeigen", "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",
"language": "Sprache",
"customLanguage": "oder Sprache eingeben...",
"languages": {
"auto": "Automatisch",
"chinese": "Chinesisch",
"english": "Englisch",
"japanese": "Japanisch",
"korean": "Koreanisch",
"french": "Französisch",
"german": "Deutsch",
"italian": "Italienisch",
"spanish": "Spanisch",
"portuguese": "Portugiesisch",
"russian": "Russisch"
}
}, },
"translator": { "translator": {
"detectLanguage": "Sprache erkennen", "detectLanguage": "Sprache erkennen",
@@ -348,7 +407,20 @@
"success": "Textpaar zum Ordner hinzugefügt", "success": "Textpaar zum Ordner hinzugefügt",
"error": "Fehler beim Hinzufügen des Textpaars zum Ordner" "error": "Fehler beim Hinzufügen des Textpaars zum Ordner"
}, },
"autoSave": "Autom. Speichern" "autoSave": "Autom. Speichern",
"customLanguage": "oder Sprache eingeben...",
"pleaseLogin": "Bitte anmelden um Karten zu speichern",
"pleaseCreateDeck": "Bitte erst zuerst ein Deck",
"noTranslationToSave": "Keine Übersetzung zum Speichern",
"noDeckSelected": "Kein Deck ausgewählt",
"saveAsCard": "Als Karte speichern",
"selectDeck": "Deck wählen",
"front": "Vorderseite",
"back": "Rückseite",
"cancel": "Abbrechen",
"save": "Speichern",
"savedToDeck": "Karte in {deckName} gespeichert",
"saveFailed": "Karte speichern fehlgeschlagen"
}, },
"dictionary": { "dictionary": {
"title": "Wörterbuch", "title": "Wörterbuch",
@@ -393,7 +465,9 @@
"unfavorite": "Aus Favoriten entfernen", "unfavorite": "Aus Favoriten entfernen",
"pleaseLogin": "Bitte melden Sie sich zuerst an", "pleaseLogin": "Bitte melden Sie sich zuerst an",
"sortByFavorites": "Nach Favoriten sortieren", "sortByFavorites": "Nach Favoriten sortieren",
"sortByFavoritesActive": "Sortierung nach Favoriten aufheben" "sortByFavoritesActive": "Sortierung nach Favoriten aufheben",
"noDecks": "Keine öffentlichen Decks",
"deckInfo": "{userName} · {totalCards} Karten"
}, },
"exploreDetail": { "exploreDetail": {
"title": "Ordnerdetails", "title": "Ordnerdetails",
@@ -407,7 +481,8 @@
"unfavorite": "Aus Favoriten entfernen", "unfavorite": "Aus Favoriten entfernen",
"favorited": "Favorisiert", "favorited": "Favorisiert",
"unfavorited": "Aus Favoriten entfernt", "unfavorited": "Aus Favoriten entfernt",
"pleaseLogin": "Bitte melden Sie sich zuerst an" "pleaseLogin": "Bitte melden Sie sich zuerst an",
"totalCards": "{count} Karten"
}, },
"favorites": { "favorites": {
"title": "Meine Favoriten", "title": "Meine Favoriten",
@@ -452,6 +527,16 @@
"createdAt": "Erstellt am", "createdAt": "Erstellt am",
"actions": "Aktionen", "actions": "Aktionen",
"view": "Anzeigen" "view": "Anzeigen"
},
"joined": "Beigetreten",
"decks": {
"title": "Meine Decks",
"noDecks": "Keine Decks",
"deckName": "Deck-Name",
"totalCards": "Gesamtkarten",
"createdAt": "Erstellt am",
"actions": "Aktionen",
"view": "Ansehen"
} }
}, },
"follow": { "follow": {
@@ -462,5 +547,76 @@
"followingOf": "{username} folgt", "followingOf": "{username} folgt",
"noFollowers": "Noch keine Follower", "noFollowers": "Noch keine Follower",
"noFollowing": "Folgt noch niemandem" "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": "Fortschritt zurücksetzen",
"resetProgressTitle": "Lernfortschritt zurücksetzen",
"resetProgressConfirm": "Fortschritt wirklich zurücksetzen?",
"resetSuccess": "Fortschritt zurückgesetzt",
"resetting": "Zurücksetzen...",
"cancel": "Abbrechen",
"settings": "Einstellungen",
"settingsTitle": "Deck-Einstellungen",
"newPerDay": "Neue pro Tag",
"newPerDayHint": "Neue Karten pro Tag",
"revPerDay": "Wiederholungen pro Tag",
"revPerDayHint": "Wiederholungen pro Tag",
"save": "Speichern",
"saving": "Speichern...",
"settingsSaved": "Einstellungen gespeichert",
"todayNew": "Heute neu",
"todayReview": "Heute wiederholen",
"todayLearning": "Lernen",
"error": {
"update": "Keine Berechtigung zum Aktualisieren",
"delete": "Keine Berechtigung zum Löschen",
"add": "Keine Berechtigung zum Hinzufügen"
},
"ipaPlaceholder": "IPA eingeben",
"examplePlaceholder": "Beispiel eingeben",
"wordRequired": "Bitte Wort eingeben",
"definitionRequired": "Bitte Definition eingeben",
"cardAdded": "Karte hinzugefügt",
"cardType": "Kartentyp",
"wordCard": "Wortkarte",
"phraseCard": "Phrasenkarte",
"sentenceCard": "Satzkarte",
"sentence": "Satz",
"sentencePlaceholder": "Satz eingeben",
"wordPlaceholder": "Wort eingeben",
"queryLang": "Abfragesprache",
"enterLanguageName": "Bitte Sprachnamen eingeben",
"english": "Englisch",
"chinese": "Chinesisch",
"japanese": "Japanisch",
"korean": "Koreanisch",
"meanings": "Bedeutungen",
"addMeaning": "Bedeutung hinzufügen",
"partOfSpeech": "Wortart",
"deleteConfirm": "Karte wirklich löschen?",
"cardDeleted": "Karte gelöscht",
"cardUpdated": "Karte aktualisiert"
} }
} }

View File

@@ -74,6 +74,77 @@
"deleteFolder": "You do not have permission to delete this folder." "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",
"ipaPlaceholder": "Enter IPA pronunciation",
"example": "Example",
"examplePlaceholder": "Enter an example sentence",
"wordAndDefinitionRequired": "Word and definition are required",
"wordRequired": "Word is required",
"definitionRequired": "At least one definition is required",
"cardAdded": "Card added successfully",
"cardType": "Card Type",
"wordCard": "Word",
"phraseCard": "Phrase",
"sentenceCard": "Sentence",
"sentence": "Sentence",
"sentencePlaceholder": "Enter a sentence",
"wordPlaceholder": "Enter a word",
"queryLang": "Language",
"enterLanguageName": "Please enter language name",
"english": "English",
"chinese": "Chinese",
"japanese": "Japanese",
"korean": "Korean",
"meanings": "Meanings",
"addMeaning": "Add Meaning",
"partOfSpeech": "Part of Speech",
"edit": "Edit",
"delete": "Delete",
"deleteConfirm": "Are you sure you want to delete this card?",
"cardDeleted": "Card deleted",
"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",
"settings": "Settings",
"settingsTitle": "Deck Settings",
"newPerDay": "New Cards Per Day",
"newPerDayHint": "Maximum new cards to learn each day",
"revPerDay": "Review Cards Per Day",
"revPerDayHint": "Maximum review cards each day",
"save": "Save",
"saving": "Saving...",
"settingsSaved": "Settings saved",
"todayNew": "New",
"todayReview": "Review",
"todayLearning": "Learning",
"cardUpdated": "Card updated",
"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": { "home": {
"title": "Learn Languages", "title": "Learn Languages",
"description": "Here is a very useful website to help you learn almost every language in the world, including constructed ones.", "description": "Here is a very useful website to help you learn almost every language in the world, including constructed ones.",
@@ -199,6 +270,7 @@
"ease": "Ease", "ease": "Ease",
"lapses": "Lapses", "lapses": "Lapses",
"showAnswer": "Show Answer", "showAnswer": "Show Answer",
"nextCard": "Next",
"again": "Again", "again": "Again",
"hard": "Hard", "hard": "Hard",
"good": "Good", "good": "Good",
@@ -217,7 +289,20 @@
"cardTypeNew": "New", "cardTypeNew": "New",
"cardTypeLearning": "Learning", "cardTypeLearning": "Learning",
"cardTypeReview": "Review", "cardTypeReview": "Review",
"cardTypeRelearning": "Relearning" "cardTypeRelearning": "Relearning",
"reverse": "Reverse",
"dictation": "Dictation",
"clickToPlay": "Click to play audio",
"restart": "Restart",
"yourAnswer": "Your answer",
"typeWhatYouHear": "Type what you hear...",
"correct": "Correct",
"incorrect": "Incorrect",
"orderLimited": "Order",
"orderInfinite": "Loop",
"randomLimited": "Random",
"randomInfinite": "Random Loop",
"noIpa": "No IPA available"
}, },
"page": { "page": {
"unauthorized": "You are not authorized to access this deck" "unauthorized": "You are not authorized to access this deck"
@@ -228,7 +313,7 @@
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "Sign In", "sign_in": "Sign In",
"profile": "Profile", "profile": "Profile",
"folders": "Folders", "folders": "Decks",
"explore": "Explore", "explore": "Explore",
"favorites": "Favorites", "favorites": "Favorites",
"settings": "Settings" "settings": "Settings"
@@ -242,6 +327,7 @@
"dropOrClick": "Drag and drop an image here, or click to select", "dropOrClick": "Drag and drop an image here, or click to select",
"changeImage": "Click to change image", "changeImage": "Click to change image",
"supportedFormats": "Supports: JPG, PNG, WebP", "supportedFormats": "Supports: JPG, PNG, WebP",
"invalidFileType": "Invalid file type. Please upload an image file (JPG, PNG, or WebP).",
"deckSelection": "Select Deck", "deckSelection": "Select Deck",
"selectDeck": "Select a deck", "selectDeck": "Select a deck",
"chooseDeck": "Choose a deck to save extracted pairs", "chooseDeck": "Choose a deck to save extracted pairs",
@@ -265,6 +351,7 @@
"saving": "Saving...", "saving": "Saving...",
"saved": "Successfully saved {count} pairs to {deck}", "saved": "Successfully saved {count} pairs to {deck}",
"ocrSuccess": "Successfully extracted {count} pairs to {deck}", "ocrSuccess": "Successfully extracted {count} pairs to {deck}",
"ocrFailed": "OCR processing failed. Please try again.",
"savedToDeck": "Saved to {deckName}", "savedToDeck": "Saved to {deckName}",
"saveFailed": "Failed to save pairs", "saveFailed": "Failed to save pairs",
"noImage": "Please upload an image first", "noImage": "Please upload an image first",
@@ -315,12 +402,43 @@
"videoUploadFailed": "Video upload failed", "videoUploadFailed": "Video upload failed",
"subtitleUploadFailed": "Subtitle upload failed", "subtitleUploadFailed": "Subtitle upload failed",
"subtitleLoadSuccess": "Subtitle loaded successfully", "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": { "text_speaker": {
"generateIPA": "Generate IPA", "generateIPA": "Generate IPA",
"viewSavedItems": "View Saved Items", "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",
"language": "Language",
"customLanguage": "or type language...",
"languages": {
"auto": "Auto",
"chinese": "Chinese",
"english": "English",
"japanese": "Japanese",
"korean": "Korean",
"french": "French",
"german": "German",
"italian": "Italian",
"spanish": "Spanish",
"portuguese": "Portuguese",
"russian": "Russian"
}
}, },
"translator": { "translator": {
"detectLanguage": "detect language", "detectLanguage": "detect language",
@@ -328,6 +446,7 @@
"auto": "Auto", "auto": "Auto",
"generateIPA": "generate ipa", "generateIPA": "generate ipa",
"translateInto": "translate into", "translateInto": "translate into",
"customLanguage": "or type language...",
"chinese": "Chinese", "chinese": "Chinese",
"english": "English", "english": "English",
"french": "French", "french": "French",
@@ -353,7 +472,19 @@
"success": "Text pair added to folder", "success": "Text pair added to folder",
"error": "Failed to add text pair to folder" "error": "Failed to add text pair to folder"
}, },
"autoSave": "Auto Save" "autoSave": "Auto Save",
"pleaseLogin": "Please login to save cards",
"pleaseCreateDeck": "Please create a deck first",
"noTranslationToSave": "No translation to save",
"noDeckSelected": "No deck selected",
"saveAsCard": "Save as Card",
"selectDeck": "Select Deck",
"front": "Front",
"back": "Back",
"cancel": "Cancel",
"save": "Save",
"savedToDeck": "Card saved to {deckName}",
"saveFailed": "Failed to save card"
}, },
"dictionary": { "dictionary": {
"title": "Dictionary", "title": "Dictionary",

View File

@@ -53,7 +53,30 @@
"totalCards": "Total des cartes", "totalCards": "Total des cartes",
"createdAt": "Créé le", "createdAt": "Créé le",
"actions": "Actions", "actions": "Actions",
"view": "Voir" "view": "Voir",
"subtitle": "Gérer vos decks d'apprentissage",
"newDeck": "Nouveau deck",
"noDecksYet": "Pas encore de decks",
"loading": "Chargement...",
"deckInfo": "ID: {id} · {totalCards} cartes",
"enterDeckName": "Nom du deck:",
"enterNewName": "Nouveau nom:",
"confirmDelete": "Tapez \"{name}\" pour supprimer:",
"public": "Public",
"private": "Privé",
"setPublic": "Rendre public",
"setPrivate": "Rendre privé",
"importApkg": "Importer APKG",
"exportApkg": "Exporter APKG",
"clickToUpload": "Cliquez pour télécharger",
"apkgFilesOnly": "Fichiers .apkg uniquement",
"parsing": "Analyse...",
"foundDecks": "{count} decks trouvés",
"back": "Retour",
"import": "Importer",
"importing": "Import...",
"exportSuccess": "Export réussi",
"goToDecks": "Aller aux decks"
}, },
"folder_id": { "folder_id": {
"unauthorized": "Vous n'êtes pas le propriétaire de ce dossier", "unauthorized": "Vous n'êtes pas le propriétaire de ce dossier",
@@ -83,6 +106,77 @@
"deleteFolder": "Vous n'avez pas la permission de supprimer ce dossier." "deleteFolder": "Vous n'avez pas la permission de supprimer ce dossier."
} }
}, },
"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 progression",
"resetProgressTitle": "Réinitialiser la progression",
"resetProgressConfirm": "Réinitialiser la progression?",
"resetSuccess": "Progression réinitialisée",
"resetting": "Réinitialisation...",
"cancel": "Annuler",
"settings": "Paramètres",
"settingsTitle": "Paramètres du deck",
"newPerDay": "Nouvelles par jour",
"newPerDayHint": "Nouvelles cartes par jour",
"revPerDay": "Révisions par jour",
"revPerDayHint": "Révisions par jour",
"save": "Enregistrer",
"saving": "Enregistrement...",
"settingsSaved": "Paramètres enregistrés",
"todayNew": "Nouvelles aujourd'hui",
"todayReview": "Révisions aujourd'hui",
"todayLearning": "En apprentissage",
"error": {
"update": "Pas autorisé à modifier",
"delete": "Pas autorisé à supprimer",
"add": "Pas autorisé à ajouter"
},
"ipaPlaceholder": "Entrer IPA",
"examplePlaceholder": "Entrer exemple",
"wordRequired": "Veuillez entrer un mot",
"definitionRequired": "Veuillez entrer une définition",
"cardAdded": "Carte ajoutée",
"cardType": "Type de carte",
"wordCard": "Carte mot",
"phraseCard": "Carte phrase",
"sentenceCard": "Carte phrase",
"sentence": "Phrase",
"sentencePlaceholder": "Entrer phrase",
"wordPlaceholder": "Entrer mot",
"queryLang": "Langue de requête",
"enterLanguageName": "Veuillez entrer le nom de la langue",
"english": "Anglais",
"chinese": "Chinois",
"japanese": "Japonais",
"korean": "Coréen",
"meanings": "Significations",
"addMeaning": "Ajouter signification",
"partOfSpeech": "Partie du discours",
"deleteConfirm": "Supprimer cette carte?",
"cardDeleted": "Carte supprimée",
"cardUpdated": "Carte mise à jour"
},
"home": { "home": {
"title": "Apprendre les langues", "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.", "description": "Voici un site Web très utile pour vous aider à apprendre presque toutes les langues du monde, y compris les langues construites.",
@@ -187,10 +281,10 @@
}, },
"memorize": { "memorize": {
"deck_selector": { "deck_selector": {
"selectDeck": "Sélectionner un deck", "selectDeck": "Choisir deck",
"noDecks": "Aucun deck trouvé", "noDecks": "Pas de decks",
"goToDecks": "Aller aux decks", "goToDecks": "Aller aux decks",
"noCards": "Aucune carte", "noCards": "Pas de cartes",
"new": "Nouveau", "new": "Nouveau",
"learning": "Apprentissage", "learning": "Apprentissage",
"review": "Révision", "review": "Révision",
@@ -199,37 +293,26 @@
"review": { "review": {
"loading": "Chargement...", "loading": "Chargement...",
"backToDecks": "Retour aux decks", "backToDecks": "Retour aux decks",
"allDone": "Terminé !", "allDone": "Tout terminé!",
"allDoneDesc": "Vous avez révisé toutes les cartes dues.", "allDoneDesc": "Apprentissage terminé pour aujourd'hui!",
"reviewedCount": "{count} cartes révisées", "reviewedCount": "{count} cartes révisées",
"progress": "{current} / {total}", "progress": "{current} / {total}",
"nextReview": "Prochaine révision", "nextReview": "Prochaine révision",
"interval": "Intervalle", "interval": "Intervalle",
"ease": "Facilité", "ease": "Facilité",
"lapses": "Oublis", "lapses": "Erreurs",
"showAnswer": "Afficher la réponse", "showAnswer": "Montrer réponse",
"nextCard": "Suivant",
"again": "Encore", "again": "Encore",
"hard": "Difficile", "restart": "Recommencer",
"good": "Bien", "orderLimited": "Ordre limité",
"easy": "Facile", "orderInfinite": "Ordre infini",
"now": "maintenant", "randomLimited": "Aléatoire limité",
"lessThanMinute": "<1 min", "randomInfinite": "Aléatoire infini",
"inMinutes": "{count} min", "noIpa": "Pas d'IPA disponible"
"inHours": "{count}h",
"inDays": "{count}j",
"inMonths": "{count}mois",
"minutes": "<1 min",
"days": "{count}j",
"months": "{count}mois",
"minAbbr": "m",
"dayAbbr": "j",
"cardTypeNew": "Nouveau",
"cardTypeLearning": "Apprentissage",
"cardTypeReview": "Révision",
"cardTypeRelearning": "Réapprentissage"
}, },
"page": { "page": {
"unauthorized": "Vous n'êtes pas autorisé à accéder à ce deck" "unauthorized": "Non autorisé"
} }
}, },
"navbar": { "navbar": {
@@ -237,39 +320,55 @@
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "Connexion", "sign_in": "Connexion",
"profile": "Profil", "profile": "Profil",
"folders": "Dossiers", "folders": "Decks",
"explore": "Explorer", "explore": "Explorer",
"favorites": "Favoris", "favorites": "Favoris",
"settings": "Paramètres" "settings": "Paramètres"
}, },
"ocr": { "ocr": {
"title": "Extraction OCR de vocabulaire", "title": "Reconnaissance OCR",
"description": "Téléchargez des captures d'écran de tableaux de vocabulaire pour extraire les paires mot-définition", "description": "Extraire le texte des images",
"uploadImage": "Télécharger une image", "uploadImage": "Télécharger image",
"dragDropHint": "Glissez-déposez une image ici, ou cliquez pour sélectionner", "dragDropHint": "Glisser-déposer",
"supportedFormats": "Supportés : JPG, PNG, WebP", "supportedFormats": "Formats: JPG, PNG, WEBP",
"selectDeck": "Sélectionner un deck", "selectDeck": "Choisir deck",
"chooseDeck": "Choisissez a deck to save the extracted pairs", "chooseDeck": "Choisir un deck",
"noDecks": "Aucun deck disponible. Please create a deck first.", "noDecks": "Pas de decks disponibles",
"languageHints": "Indices de langue (Optionnel)", "languageHints": "Indications de langue",
"sourceLanguageHint": "Langue source (ex : Anglais)", "sourceLanguageHint": "Langue source",
"targetLanguageHint": "Langue cible/traduction (ex : Chinois)", "targetLanguageHint": "Langue cible",
"process": "Traiter l'image", "process": "Traiter",
"processing": "Traitement...", "processing": "Traitement...",
"preview": "Aperçu", "preview": "Aperçu",
"extractedPairs": "Paires extraites", "extractedPairs": "Paires extraites",
"word": "Mot", "word": "Mot",
"definition": "Définition", "definition": "Définition",
"pairsCount": "{count} paires extraites", "pairsCount": "{count} paires",
"savePairs": "Sauvegarder dans le deck", "savePairs": "Enregistrer",
"saving": "Sauvegarde...", "saving": "Enregistrement...",
"saved": "{count} paires sauvegardées dans {deck}", "saved": "Enregistré",
"saveFailed": "Échec de la sauvegarde", "saveFailed": "Échec de l'enregistrement",
"noImage": "Veuillez first upload an image", "noImage": "Veuillez télécharger une image",
"noDeck": "Please select a deck", "noDeck": "Veuillez choisir un deck",
"processingFailed": "Échec du traitement OCR", "processingFailed": "Traitement échoué",
"tryAgain": "Please try again with a clearer image", "tryAgain": "Réessayer",
"detectedLanguages": "Détecté : {source} → {target}" "detectedLanguages": "Langues détectées",
"uploadSection": "Télécharger image",
"dropOrClick": "Déposer ou cliquer",
"changeImage": "Changer image",
"invalidFileType": "Type de fichier invalide",
"deckSelection": "Choisir deck",
"sourceLanguagePlaceholder": "ex: Anglais",
"targetLanguagePlaceholder": "ex: Français",
"processButton": "Démarrer reconnaissance",
"resultsPreview": "Aperçu des résultats",
"saveButton": "Enregistrer dans le deck",
"ocrSuccess": "OCR réussi",
"ocrFailed": "OCR échoué",
"savedToDeck": "Enregistré dans le deck",
"noResultsToSave": "Pas de résultats",
"detectedSourceLanguage": "Langue source détectée",
"detectedTargetLanguage": "Langue cible détectée"
}, },
"profile": { "profile": {
"myProfile": "Mon profil", "myProfile": "Mon profil",
@@ -310,12 +409,43 @@
"videoUploadFailed": "Échec du téléchargement de la vidéo", "videoUploadFailed": "Échec du téléchargement de la vidéo",
"subtitleUploadFailed": "Échec du téléchargement des sous-titres", "subtitleUploadFailed": "Échec du téléchargement des sous-titres",
"subtitleLoadSuccess": "Sous-titres chargés avec succès", "subtitleLoadSuccess": "Sous-titres chargés avec succès",
"subtitleLoadFailed": "Échec du chargement des sous-titres" "subtitleLoadFailed": "Échec du chargement des sous-titres",
"settings": "Paramètres",
"shortcuts": "Raccourcis",
"keyboardShortcuts": "Raccourcis clavier",
"playPause": "Lecture/Pause",
"autoPauseToggle": "Pause auto",
"subtitleSettings": "Paramètres sous-titres",
"fontSize": "Taille police",
"textColor": "Couleur texte",
"backgroundColor": "Couleur fond",
"position": "Position",
"opacity": "Opacité",
"top": "Haut",
"center": "Centre",
"bottom": "Bas"
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "Générer l'API", "generateIPA": "Générer l'API",
"viewSavedItems": "Voir les éléments enregistrés", "viewSavedItems": "Voir les éléments enregistrés",
"confirmDeleteAll": "Êtes-vous sûr de vouloir tout supprimer ? (O/N)" "confirmDeleteAll": "Êtes-vous sûr de vouloir tout supprimer ? (O/N)",
"saved": "Enregistré",
"clearAll": "Tout effacer",
"language": "Langue",
"customLanguage": "ou entrer une langue...",
"languages": {
"auto": "Auto",
"chinese": "Chinois",
"english": "Anglais",
"japanese": "Japonais",
"korean": "Coréen",
"french": "Français",
"german": "Allemand",
"italian": "Italien",
"spanish": "Espagnol",
"portuguese": "Portugais",
"russian": "Russe"
}
}, },
"translator": { "translator": {
"detectLanguage": "détecter la langue", "detectLanguage": "détecter la langue",
@@ -348,7 +478,20 @@
"success": "Paire de texte ajoutée au dossier", "success": "Paire de texte ajoutée au dossier",
"error": "Échec de l'ajout de la paire de texte au dossier" "error": "Échec de l'ajout de la paire de texte au dossier"
}, },
"autoSave": "Sauvegarde automatique" "autoSave": "Sauvegarde automatique",
"customLanguage": "ou tapez la langue...",
"pleaseLogin": "Connectez-vous pour sauvegarder",
"pleaseCreateDeck": "Créez d'abord un deck",
"noTranslationToSave": "Pas de traduction à sauvegarder",
"noDeckSelected": "Aucun deck sélectionné",
"saveAsCard": "Sauvegarder comme carte",
"selectDeck": "Sélectionner deck",
"front": "Recto",
"back": "Verso",
"cancel": "Annuler",
"save": "Sauvegarder",
"savedToDeck": "Carte sauvegardée dans {deckName}",
"saveFailed": "Échec de la sauvegarde"
}, },
"dictionary": { "dictionary": {
"title": "Dictionnaire", "title": "Dictionnaire",
@@ -393,7 +536,9 @@
"unfavorite": "Retirer des favoris", "unfavorite": "Retirer des favoris",
"pleaseLogin": "Veuillez vous connecter d'abord", "pleaseLogin": "Veuillez vous connecter d'abord",
"sortByFavorites": "Trier par favoris", "sortByFavorites": "Trier par favoris",
"sortByFavoritesActive": "Annuler le tri par favoris" "sortByFavoritesActive": "Annuler le tri par favoris",
"noDecks": "Pas de decks publics",
"deckInfo": "{userName} · {totalCards} cartes"
}, },
"exploreDetail": { "exploreDetail": {
"title": "Détails du dossier", "title": "Détails du dossier",
@@ -407,7 +552,8 @@
"unfavorite": "Retirer des favoris", "unfavorite": "Retirer des favoris",
"favorited": "Ajouté aux favoris", "favorited": "Ajouté aux favoris",
"unfavorited": "Retiré des favoris", "unfavorited": "Retiré des favoris",
"pleaseLogin": "Veuillez vous connecter d'abord" "pleaseLogin": "Veuillez vous connecter d'abord",
"totalCards": "{count} cartes"
}, },
"favorites": { "favorites": {
"title": "Mes favoris", "title": "Mes favoris",
@@ -452,7 +598,8 @@
"createdAt": "Créé le", "createdAt": "Créé le",
"actions": "Actions", "actions": "Actions",
"view": "Voir" "view": "Voir"
} },
"joined": "Inscrit le"
}, },
"follow": { "follow": {
"follow": "Suivre", "follow": "Suivre",

View File

@@ -53,7 +53,30 @@
"totalCards": "Totale carte", "totalCards": "Totale carte",
"createdAt": "Creato il", "createdAt": "Creato il",
"actions": "Azioni", "actions": "Azioni",
"view": "Visualizza" "view": "Visualizza",
"subtitle": "Gestisci i tuoi deck",
"newDeck": "Nuovo deck",
"noDecksYet": "Nessun deck ancora",
"loading": "Caricamento...",
"deckInfo": "ID: {id} · {totalCards} carte",
"enterDeckName": "Nome deck:",
"enterNewName": "Nuovo nome:",
"confirmDelete": "Digita \"{name}\" per eliminare:",
"public": "Pubblico",
"private": "Privato",
"setPublic": "Rendi pubblico",
"setPrivate": "Rendi privato",
"importApkg": "Importa APKG",
"exportApkg": "Esporta APKG",
"clickToUpload": "Clicca per caricare",
"apkgFilesOnly": "Solo file .apkg",
"parsing": "Analisi...",
"foundDecks": "{count} deck trovati",
"back": "Indietro",
"import": "Importa",
"importing": "Importazione...",
"exportSuccess": "Esportazione riuscita",
"goToDecks": "Vai ai deck"
}, },
"folder_id": { "folder_id": {
"unauthorized": "Non sei il proprietario di questa cartella", "unauthorized": "Non sei il proprietario di questa cartella",
@@ -83,6 +106,77 @@
"deleteFolder": "Non hai il permesso di eliminare questa cartella." "deleteFolder": "Non hai il permesso di eliminare questa cartella."
} }
}, },
"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": "Reimposta progresso",
"resetProgressTitle": "Reimposta progresso di apprendimento",
"resetProgressConfirm": "Reimpostare il progresso?",
"resetSuccess": "Progresso reimpostato",
"resetting": "Reimpostazione...",
"cancel": "Annulla",
"settings": "Impostazioni",
"settingsTitle": "Impostazioni deck",
"newPerDay": "Nuove al giorno",
"newPerDayHint": "Nuove carte al giorno",
"revPerDay": "Ripassate al giorno",
"revPerDayHint": "Ripassi al giorno",
"save": "Salva",
"saving": "Salvataggio...",
"settingsSaved": "Impostazioni salvate",
"todayNew": "Oggi nuove",
"todayReview": "Oggi ripasso",
"todayLearning": "In apprendimento",
"error": {
"update": "Nessun permesso di aggiornare",
"delete": "Nessun permesso di eliminare",
"add": "Nessun permesso di aggiungere"
},
"ipaPlaceholder": "Inserisci IPA",
"examplePlaceholder": "Inserisci esempio",
"wordRequired": "Inserisci una parola",
"definitionRequired": "Inserisci una definizione",
"cardAdded": "Carta aggiunta",
"cardType": "Tipo di carta",
"wordCard": "Carta parola",
"phraseCard": "Carta frase",
"sentenceCard": "Carta frase",
"sentence": "Frase",
"sentencePlaceholder": "Inserisci frase",
"wordPlaceholder": "Inserisci parola",
"queryLang": "Lingua di query",
"enterLanguageName": "Inserisci il nome della lingua",
"english": "Inglese",
"chinese": "Cinese",
"japanese": "Giapponese",
"korean": "Coreano",
"meanings": "Significati",
"addMeaning": "Aggiungi significato",
"partOfSpeech": "Parte del discorso",
"deleteConfirm": "Eliminare questa carta?",
"cardDeleted": "Carta eliminata",
"cardUpdated": "Carta aggiornata"
},
"home": { "home": {
"title": "Impara le Lingue", "title": "Impara le Lingue",
"description": "Ecco un sito molto utile per aiutarti a imparare quasi tutte le lingue del mondo, incluse quelle costruite.", "description": "Ecco un sito molto utile per aiutarti a imparare quasi tutte le lingue del mondo, incluse quelle costruite.",
@@ -187,49 +281,63 @@
}, },
"memorize": { "memorize": {
"deck_selector": { "deck_selector": {
"selectDeck": "Seleziona un mazzo", "selectDeck": "Seleziona deck",
"noDecks": "Nessun mazzo trovato", "noDecks": "Nessun deck",
"goToDecks": "Vai ai mazzi", "goToDecks": "Vai ai deck",
"noCards": "Nessuna carta", "noCards": "Nessuna carta",
"new": "Nuove", "new": "Nuovo",
"learning": "In apprendimento", "learning": "Apprendimento",
"review": "Ripasso", "review": "Ripasso",
"due": "In scadenza" "due": "In scadenza"
}, },
"review": { "review": {
"loading": "Caricamento...", "loading": "Caricamento...",
"backToDecks": "Torna ai mazzi", "backToDecks": "Torna ai deck",
"allDone": "Fatto!", "allDone": "Tutto fatto!",
"allDoneDesc": "Hai ripassato tutte le carte in scadenza.", "allDoneDesc": "Apprendimento di oggi completato!",
"reviewedCount": "{count} carte ripassate", "reviewedCount": "{count} carte ripassate",
"progress": "{current} / {total}", "progress": "{current} / {total}",
"nextReview": "Prossima revisione", "nextReview": "Prossimo ripasso",
"interval": "Intervallo", "interval": "Intervallo",
"ease": "Facilità", "ease": "Difficoltà",
"lapses": "Dimenticanze", "lapses": "Errori",
"showAnswer": "Mostra risposta", "showAnswer": "Mostra risposta",
"nextCard": "Prossima",
"again": "Ancora", "again": "Ancora",
"restart": "Ricomincia",
"hard": "Difficile", "hard": "Difficile",
"good": "Bene", "good": "Buono",
"easy": "Facile", "easy": "Facile",
"now": "ora", "now": "Ora",
"lessThanMinute": "<1 min", "lessThanMinute": "meno di 1 minuto",
"inMinutes": "{count} min", "inMinutes": "tra {n} minuti",
"inHours": "{count}h", "inHours": "tra {n} ore",
"inDays": "{count}g", "inDays": "tra {n} giorni",
"inMonths": "{count}mesi", "inMonths": "tra {n} mesi",
"minutes": "<1 min", "minutes": "minuti",
"days": "{count}g", "days": "giorni",
"months": "{count}mesi", "months": "mesi",
"minAbbr": "m", "minAbbr": "min",
"dayAbbr": "g", "dayAbbr": "g",
"cardTypeNew": "Nuovo", "cardTypeNew": "Nuovo",
"cardTypeLearning": "Apprendimento", "cardTypeLearning": "Apprendimento",
"cardTypeReview": "Ripasso", "cardTypeReview": "Ripasso",
"cardTypeRelearning": "Riapprendimento" "cardTypeRelearning": "Riapprendimento",
"reverse": "Inverti",
"dictation": "Dettato",
"clickToPlay": "Clicca per riprodurre",
"yourAnswer": "La tua risposta",
"typeWhatYouHear": "Scrivi cosa senti",
"correct": "Corretto!",
"incorrect": "Errato",
"orderLimited": "Ordine limitato",
"orderInfinite": "Ordine infinito",
"randomLimited": "Casuale limitato",
"randomInfinite": "Casuale infinito",
"noIpa": "Nessun IPA disponibile"
}, },
"page": { "page": {
"unauthorized": "Non sei autorizzato ad accedere a questo mazzo" "unauthorized": "Non autorizzato"
} }
}, },
"navbar": { "navbar": {
@@ -237,39 +345,55 @@
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "Accedi", "sign_in": "Accedi",
"profile": "Profilo", "profile": "Profilo",
"folders": "Cartelle", "folders": "Mazzi",
"explore": "Esplora", "explore": "Esplora",
"favorites": "Preferiti", "favorites": "Preferiti",
"settings": "Impostazioni" "settings": "Impostazioni"
}, },
"ocr": { "ocr": {
"title": "Estrazione vocaboli OCR", "title": "Riconoscimento OCR",
"description": "Carica screenshot di tabelle di vocaboli per estrarre coppie parola-definizione", "description": "Estrai testo dalle immagini",
"uploadImage": "Carica immagine", "uploadImage": "Carica immagine",
"dragDropHint": "Trascina e rilascia un'immagine qui, o clicca per selezionare", "dragDropHint": "Trascina e rilascia",
"supportedFormats": "Supportati: JPG, PNG, WebP", "supportedFormats": "Supportati: JPG, PNG, WEBP",
"selectDeck": "Seleziona un mazzo", "selectDeck": "Seleziona deck",
"chooseDeck": "Scegli un mazzo per salvare le coppie estratte", "chooseDeck": "Scegli un deck",
"noDecks": "Nessun mazzo disponibile. Creane prima un mazzo.", "noDecks": "Nessun deck disponibile",
"languageHints": "Suggerimenti lingua (Opzionale)", "languageHints": "Suggerimenti lingua",
"sourceLanguageHint": "Lingua sorgente (es: Inglese)", "sourceLanguageHint": "Lingua sorgente",
"targetLanguageHint": "Lingua target/traduzione (es: Cinese)", "targetLanguageHint": "Lingua target",
"process": "Elabora immagine", "process": "Elabora",
"processing": "Elaborazione...", "processing": "Elaborazione...",
"preview": "Anteprima", "preview": "Anteprima",
"extractedPairs": "Coppie estratte", "extractedPairs": "Coppie estratte",
"word": "Parola", "word": "Parola",
"definition": "Definizione", "definition": "Definizione",
"pairsCount": "{count} coppie estratte", "pairsCount": "{count} coppie",
"savePairs": "Salva nel mazzo", "savePairs": "Salva",
"saving": "Salvataggio...", "saving": "Salvataggio...",
"saved": "{count} coppie salvate in {deck}", "saved": "Salvato",
"saveFailed": "Salvataggio fallito", "saveFailed": "Salvataggio fallito",
"noImage": "Carica prima un'immagine", "noImage": "Carica un'immagine",
"noDeck": "Seleziona un mazzo", "noDeck": "Seleziona un deck",
"processingFailed": "Elaborazione OCR fallita", "processingFailed": "Elaborazione fallita",
"tryAgain": "Riprova con un'immagine più chiara", "tryAgain": "Riprova",
"detectedLanguages": "Rilevato: {source} → {target}" "detectedLanguages": "Lingue rilevate",
"uploadSection": "Carica immagine",
"dropOrClick": "Rilascia o clicca",
"changeImage": "Cambia immagine",
"invalidFileType": "Tipo di file non valido",
"deckSelection": "Seleziona deck",
"sourceLanguagePlaceholder": "es: Inglese",
"targetLanguagePlaceholder": "es: Italiano",
"processButton": "Avvia riconoscimento",
"resultsPreview": "Anteprima risultati",
"saveButton": "Salva nel deck",
"ocrSuccess": "OCR riuscito",
"ocrFailed": "OCR fallito",
"savedToDeck": "Salvato nel deck",
"noResultsToSave": "Nessun risultato",
"detectedSourceLanguage": "Lingua sorgente rilevata",
"detectedTargetLanguage": "Lingua target rilevata"
}, },
"profile": { "profile": {
"myProfile": "Il Mio Profilo", "myProfile": "Il Mio Profilo",
@@ -310,12 +434,43 @@
"videoUploadFailed": "Caricamento video fallito", "videoUploadFailed": "Caricamento video fallito",
"subtitleUploadFailed": "Caricamento sottotitoli fallito", "subtitleUploadFailed": "Caricamento sottotitoli fallito",
"subtitleLoadSuccess": "Sottotitoli caricati con successo", "subtitleLoadSuccess": "Sottotitoli caricati con successo",
"subtitleLoadFailed": "Caricamento sottotitoli fallito" "subtitleLoadFailed": "Caricamento sottotitoli fallito",
"settings": "Impostazioni",
"shortcuts": "Scorciatoie",
"keyboardShortcuts": "Scorciatoie tastiera",
"playPause": "Riproduci/Pausa",
"autoPauseToggle": "Auto-pausa",
"subtitleSettings": "Impostazioni sottotitoli",
"fontSize": "Dimensione carattere",
"textColor": "Colore testo",
"backgroundColor": "Colore sfondo",
"position": "Posizione",
"opacity": "Opacità",
"top": "Alto",
"center": "Centro",
"bottom": "Basso"
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "Genera IPA", "generateIPA": "Genera IPA",
"viewSavedItems": "Visualizza Elementi Salvati", "viewSavedItems": "Visualizza Elementi Salvati",
"confirmDeleteAll": "Sei sicuro di voler eliminare tutto? (S/N)" "confirmDeleteAll": "Sei sicuro di voler eliminare tutto? (S/N)",
"saved": "Salvato",
"clearAll": "Cancella tutto",
"language": "Lingua",
"customLanguage": "o inserisci lingua...",
"languages": {
"auto": "Auto",
"chinese": "Cinese",
"english": "Inglese",
"japanese": "Giapponese",
"korean": "Coreano",
"french": "Francese",
"german": "Tedesco",
"italian": "Italiano",
"spanish": "Spagnolo",
"portuguese": "Portoghese",
"russian": "Russo"
}
}, },
"translator": { "translator": {
"detectLanguage": "rileva lingua", "detectLanguage": "rileva lingua",
@@ -348,7 +503,20 @@
"success": "Coppia di testo aggiunta alla cartella", "success": "Coppia di testo aggiunta alla cartella",
"error": "Impossibile aggiungere coppia di testo alla cartella" "error": "Impossibile aggiungere coppia di testo alla cartella"
}, },
"autoSave": "Salvataggio Automatico" "autoSave": "Salvataggio Automatico",
"customLanguage": "o digita lingua...",
"pleaseLogin": "Accedi per salvare le carte",
"pleaseCreateDeck": "Crea prima un deck",
"noTranslationToSave": "Nessuna traduzione da salvare",
"noDeckSelected": "Nessun deck selezionato",
"saveAsCard": "Salva come carta",
"selectDeck": "Seleziona deck",
"front": "Fronte",
"back": "Retro",
"cancel": "Annulla",
"save": "Salva",
"savedToDeck": "Carta salvata in {deckName}",
"saveFailed": "Salvataggio fallito"
}, },
"dictionary": { "dictionary": {
"title": "Dizionario", "title": "Dizionario",
@@ -393,7 +561,9 @@
"unfavorite": "Rimuovi dai preferiti", "unfavorite": "Rimuovi dai preferiti",
"pleaseLogin": "Per favore accedi prima", "pleaseLogin": "Per favore accedi prima",
"sortByFavorites": "Ordina per preferiti", "sortByFavorites": "Ordina per preferiti",
"sortByFavoritesActive": "Annulla ordinamento per preferiti" "sortByFavoritesActive": "Annulla ordinamento per preferiti",
"noDecks": "Nessun deck pubblico",
"deckInfo": "{userName} · {totalCards} carte"
}, },
"exploreDetail": { "exploreDetail": {
"title": "Dettagli Cartella", "title": "Dettagli Cartella",
@@ -407,7 +577,8 @@
"unfavorite": "Rimuovi dai preferiti", "unfavorite": "Rimuovi dai preferiti",
"favorited": "Aggiunto ai preferiti", "favorited": "Aggiunto ai preferiti",
"unfavorited": "Rimosso dai preferiti", "unfavorited": "Rimosso dai preferiti",
"pleaseLogin": "Per favore accedi prima" "pleaseLogin": "Per favore accedi prima",
"totalCards": "{count} carte"
}, },
"favorites": { "favorites": {
"title": "I Miei Preferiti", "title": "I Miei Preferiti",
@@ -447,12 +618,13 @@
"decks": { "decks": {
"title": "Mazzi", "title": "Mazzi",
"noDecks": "Nessun mazzo ancora", "noDecks": "Nessun mazzo ancora",
"deckName": "Nome Mazzo", "deckName": "Nome del mazzo",
"totalCards": "Carte Totali", "totalCards": "Totale carte",
"createdAt": "Creata Il", "createdAt": "Creata Il",
"actions": "Azioni", "actions": "Azioni",
"view": "Visualizza" "view": "Visualizza"
} },
"joined": "Iscritto il"
}, },
"follow": { "follow": {
"follow": "Segui", "follow": "Segui",

View File

@@ -74,6 +74,77 @@
"deleteFolder": "このフォルダーを削除する権限がありません。" "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": "リセットしました",
"resetting": "リセット中...",
"cancel": "キャンセル",
"settings": "設定",
"settingsTitle": "デッキ設定",
"newPerDay": "1日の新規カード",
"newPerDayHint": "毎日の新規カード数",
"revPerDay": "1日の復習",
"revPerDayHint": "毎日の復習数",
"save": "保存",
"saving": "保存中...",
"settingsSaved": "設定を保存しました",
"todayNew": "今日の新規",
"todayReview": "今日の復習",
"todayLearning": "学習中",
"error": {
"update": "更新する権限がありません",
"delete": "削除する権限がありません",
"add": "追加する権限がありません"
},
"ipaPlaceholder": "IPAを入力",
"examplePlaceholder": "例文を入力",
"wordRequired": "単語を入力してください",
"definitionRequired": "定義を入力してください",
"cardAdded": "カードを追加しました",
"cardType": "カードタイプ",
"wordCard": "単語カード",
"phraseCard": "フレーズカード",
"sentenceCard": "文章カード",
"sentence": "文章",
"sentencePlaceholder": "文章を入力",
"wordPlaceholder": "単語を入力",
"queryLang": "検索言語",
"enterLanguageName": "言語名を入力してください",
"english": "英語",
"chinese": "中国語",
"japanese": "日本語",
"korean": "韓国語",
"meanings": "意味",
"addMeaning": "意味を追加",
"partOfSpeech": "品詞",
"deleteConfirm": "このカードを削除しますか?",
"cardDeleted": "カードを削除しました",
"cardUpdated": "カードを更新しました"
},
"home": { "home": {
"title": "言語を学ぶ", "title": "言語を学ぶ",
"description": "ここは世界のほぼすべての言語(人工言語を含む)を学ぶのに役立つ非常に便利なウェブサイトです。", "description": "ここは世界のほぼすべての言語(人工言語を含む)を学ぶのに役立つ非常に便利なウェブサイトです。",
@@ -199,6 +270,7 @@
"ease": "易しさ", "ease": "易しさ",
"lapses": "忘回数", "lapses": "忘回数",
"showAnswer": "答えを表示", "showAnswer": "答えを表示",
"nextCard": "次へ",
"again": "もう一度", "again": "もう一度",
"hard": "難しい", "hard": "難しい",
"good": "普通", "good": "普通",
@@ -217,7 +289,20 @@
"cardTypeNew": "新規", "cardTypeNew": "新規",
"cardTypeLearning": "学習中", "cardTypeLearning": "学習中",
"cardTypeReview": "復習", "cardTypeReview": "復習",
"cardTypeRelearning": "再学習" "cardTypeRelearning": "再学習",
"reverse": "反転",
"dictation": "聴き取り",
"clickToPlay": "クリックして再生",
"yourAnswer": "あなたの答え",
"typeWhatYouHear": "聞こえた内容を入力",
"correct": "正解",
"incorrect": "不正解",
"restart": "最初から",
"orderLimited": "順序制限",
"orderInfinite": "順序無限",
"randomLimited": "ランダム制限",
"randomInfinite": "ランダム無限",
"noIpa": "IPAなし"
}, },
"page": { "page": {
"unauthorized": "このデッキにアクセスする権限がありません" "unauthorized": "このデッキにアクセスする権限がありません"
@@ -228,39 +313,55 @@
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "サインイン", "sign_in": "サインイン",
"profile": "プロフィール", "profile": "プロフィール",
"folders": "フォルダー", "folders": "デッキ",
"explore": "探索", "explore": "探索",
"favorites": "お気に入り", "favorites": "お気に入り",
"settings": "設定" "settings": "設定"
}, },
"ocr": { "ocr": {
"title": "OCR語彙抽出", "title": "OCR認識",
"description": "教科書の語彙表のスクリーンショットをアップロードして単語と定義のペアを抽出", "description": "画像からテキストを抽出",
"uploadImage": "画像をアップロード", "uploadImage": "画像をアップロード",
"dragDropHint": "ここに画像をドラッグ&ドロップ、またはクリックして選択", "dragDropHint": "ドラッグ&ドロップ",
"supportedFormats": "対応形式JPGPNG、WebP", "supportedFormats": "対応形式JPG, PNG, WEBP",
"selectDeck": "デッキを選択", "selectDeck": "デッキを選択",
"chooseDeck": "抽出したペアを保存するデッキを選択", "chooseDeck": "デッキを選択",
"noDecks": "デッキがありません。まずデッキを作成してください。", "noDecks": "デッキがありません",
"languageHints": "言語ヒント(オプション)", "languageHints": "言語ヒント",
"sourceLanguageHint": "ソース言語(例:英語)", "sourceLanguageHint": "ソース言語ヒント",
"targetLanguageHint": "ターゲット/翻訳言語(例:中国語)", "targetLanguageHint": "ターゲット言語ヒント",
"process": "画像を処理", "process": "処理",
"processing": "処理中...", "processing": "処理中...",
"preview": "プレビュー", "preview": "プレビュー",
"extractedPairs": "抽出されたペア", "extractedPairs": "抽出ペア",
"word": "単語", "word": "単語",
"definition": "定義", "definition": "定義",
"pairsCount": "{count} ペアを抽出", "pairsCount": "{count}ペア",
"savePairs": "デッキに保存", "savePairs": "保存",
"saving": "保存中...", "saving": "保存中...",
"saved": "{count} ペアを {deck} に保存しました", "saved": "保存済み",
"saveFailed": "保存失敗しました", "saveFailed": "保存失敗",
"noImage": "先に画像をアップロードしてください", "noImage": "画像をアップロードしてください",
"noDeck": "デッキを選択してください", "noDeck": "デッキを選択してください",
"processingFailed": "OCR処理失敗しました", "processingFailed": "処理失敗",
"tryAgain": "より鮮明な画像でお試しください", "tryAgain": "再試行",
"detectedLanguages": "検出{source} → {target}" "detectedLanguages": "検出言語",
"invalidFileType": "無効なファイル形式",
"ocrFailed": "OCR失敗",
"uploadSection": "画像をアップロード",
"dropOrClick": "ドロップまたはクリック",
"changeImage": "画像を変更",
"deckSelection": "デッキを選択",
"sourceLanguagePlaceholder": "例:英語",
"targetLanguagePlaceholder": "例:日本語",
"processButton": "認識開始",
"resultsPreview": "結果プレビュー",
"saveButton": "デッキに保存",
"ocrSuccess": "OCR成功",
"savedToDeck": "デッキに保存しました",
"noResultsToSave": "結果がありません",
"detectedSourceLanguage": "検出ソース言語",
"detectedTargetLanguage": "検出ターゲット言語"
}, },
"profile": { "profile": {
"myProfile": "マイプロフィール", "myProfile": "マイプロフィール",
@@ -301,12 +402,43 @@
"videoUploadFailed": "ビデオのアップロードに失敗しました", "videoUploadFailed": "ビデオのアップロードに失敗しました",
"subtitleUploadFailed": "字幕のアップロードに失敗しました", "subtitleUploadFailed": "字幕のアップロードに失敗しました",
"subtitleLoadSuccess": "字幕の読み込みに成功しました", "subtitleLoadSuccess": "字幕の読み込みに成功しました",
"subtitleLoadFailed": "字幕の読み込みに失敗しました" "subtitleLoadFailed": "字幕の読み込みに失敗しました",
"settings": "設定",
"shortcuts": "ショートカット",
"keyboardShortcuts": "キーボードショートカット",
"playPause": "再生/一時停止",
"autoPauseToggle": "自動一時停止",
"subtitleSettings": "字幕設定",
"fontSize": "フォントサイズ",
"textColor": "文字色",
"backgroundColor": "背景色",
"position": "位置",
"opacity": "不透明度",
"top": "上",
"center": "中央",
"bottom": "下"
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "IPAを生成", "generateIPA": "IPAを生成",
"viewSavedItems": "保存済み項目を表示", "viewSavedItems": "保存済み項目を表示",
"confirmDeleteAll": "すべて削除してもよろしいですか? (Y/N)" "confirmDeleteAll": "すべて削除してもよろしいですか? (Y/N)",
"saved": "保存済み",
"clearAll": "すべてクリア",
"language": "言語",
"customLanguage": "または言語を入力...",
"languages": {
"auto": "自動",
"chinese": "中国語",
"english": "英語",
"japanese": "日本語",
"korean": "韓国語",
"french": "フランス語",
"german": "ドイツ語",
"italian": "イタリア語",
"spanish": "スペイン語",
"portuguese": "ポルトガル語",
"russian": "ロシア語"
}
}, },
"translator": { "translator": {
"detectLanguage": "言語を検出", "detectLanguage": "言語を検出",
@@ -339,7 +471,20 @@
"success": "テキストペアがフォルダーに追加されました", "success": "テキストペアがフォルダーに追加されました",
"error": "テキストペアをフォルダーに追加できませんでした" "error": "テキストペアをフォルダーに追加できませんでした"
}, },
"autoSave": "自動保存" "autoSave": "自動保存",
"customLanguage": "または言語を入力...",
"pleaseLogin": "ログインしてカードを保存",
"pleaseCreateDeck": "先にデッキを作成",
"noTranslationToSave": "保存する翻訳なし",
"noDeckSelected": "デッキ未選択",
"saveAsCard": "カードとして保存",
"selectDeck": "デッキ選択",
"front": "表面",
"back": "裏面",
"cancel": "キャンセル",
"save": "保存",
"savedToDeck": "{deckName}に保存",
"saveFailed": "保存失敗"
}, },
"dictionary": { "dictionary": {
"title": "辞書", "title": "辞書",
@@ -384,7 +529,9 @@
"unfavorite": "お気に入り解除", "unfavorite": "お気に入り解除",
"pleaseLogin": "まずログインしてください", "pleaseLogin": "まずログインしてください",
"sortByFavorites": "お気に入り順に並べ替え", "sortByFavorites": "お気に入り順に並べ替え",
"sortByFavoritesActive": "お気に入り順の並べ替えを解除" "sortByFavoritesActive": "お気に入り順の並べ替えを解除",
"noDecks": "公開デッキなし",
"deckInfo": "{userName} · {totalCards}枚"
}, },
"exploreDetail": { "exploreDetail": {
"title": "フォルダー詳細", "title": "フォルダー詳細",
@@ -398,7 +545,8 @@
"unfavorite": "お気に入り解除", "unfavorite": "お気に入り解除",
"favorited": "お気に入りに追加しました", "favorited": "お気に入りに追加しました",
"unfavorited": "お気に入りから削除しました", "unfavorited": "お気に入りから削除しました",
"pleaseLogin": "まずログインしてください" "pleaseLogin": "まずログインしてください",
"totalCards": "{count}枚"
}, },
"favorites": { "favorites": {
"title": "マイお気に入り", "title": "マイお気に入り",
@@ -443,41 +591,42 @@
"createdAt": "作成日", "createdAt": "作成日",
"actions": "アクション", "actions": "アクション",
"view": "表示" "view": "表示"
} },
"joined": "登録日"
}, },
"decks": { "decks": {
"title": "デッキ", "title": "デッキ",
"subtitle": "フラッシュカードデッキを管理", "subtitle": "学習デッキを管理",
"newDeck": "新規デッキ", "newDeck": "新規デッキ",
"noDecksYet": "まだデッキがありません", "noDecksYet": "デッキなし",
"loading": "読み込み中...", "loading": "読中...",
"deckInfo": "ID: {id} {totalCards} 枚のカード", "deckInfo": "ID: {id} · {totalCards}",
"enterDeckName": "デッキ名を入力:", "enterDeckName": "デッキ名",
"enterNewName": "新しい名前を入力:", "enterNewName": "新しい名前",
"confirmDelete": "削除するには「{name}」入力してください:", "confirmDelete": "削除確認:「{name}」入力",
"public": "公開", "public": "公開",
"private": "非公開", "private": "非公開",
"setPublic": "公開に設定", "setPublic": "公開に設定",
"setPrivate": "非公開に設定", "setPrivate": "非公開に設定",
"importApkg": "APKGインポート", "importApkg": "APKGインポート",
"exportApkg": "APKGエクスポート", "exportApkg": "APKGエクスポート",
"clickToUpload": "クリックしてAPKGファイルをアップロード", "clickToUpload": "クリックアップロード",
"apkgFilesOnly": ".apkgファイルのみ対応", "apkgFilesOnly": ".apkgのみ",
"parsing": "解析中...", "parsing": "解析中...",
"foundDecks": "{count}個のデッキが見つかりました", "foundDecks": "{count}デッキ発見",
"deckName": "デッキ名", "deckName": "デッキ名",
"back": "戻る", "back": "戻る",
"import": "インポート", "import": "インポート",
"importing": "インポート中...", "importing": "インポート中...",
"exportSuccess": "デッキをエクスポートしました", "exportSuccess": "エクスポート成功",
"goToDecks": "デッキへ移動" "goToDecks": "デッキへ"
}, },
"follow": { "follow": {
"follow": "フォロー", "follow": "フォロー",
"following": "フォロー中", "following": "フォロー中",
"followers": "フォロワー", "followers": "フォロワー",
"followersOf": "{username}のフォロワー", "followersOf": "{username}のフォロワー",
"followingOf": "{username}のフォロー", "followingOf": "{username}のフォロー",
"noFollowers": "まだフォロワーがいません", "noFollowers": "まだフォロワーがいません",
"noFollowing": "まだ誰もフォローしていません" "noFollowing": "まだ誰もフォローしていません"
} }

View File

@@ -53,7 +53,30 @@
"totalCards": "총 카드", "totalCards": "총 카드",
"createdAt": "생성일", "createdAt": "생성일",
"actions": "작업", "actions": "작업",
"view": "보기" "view": "보기",
"subtitle": "학습 덱 관리",
"newDeck": "새 덱",
"noDecksYet": "덱이 없습니다",
"loading": "로딩 중...",
"deckInfo": "ID: {id} · {totalCards}장",
"enterDeckName": "덱 이름 입력:",
"enterNewName": "새 이름 입력:",
"confirmDelete": "삭제하려면 \"{name}\" 입력:",
"public": "공개",
"private": "비공개",
"setPublic": "공개로 설정",
"setPrivate": "비공개로 설정",
"importApkg": "APKG 가져오기",
"exportApkg": "APKG 내보내기",
"clickToUpload": "클릭하여 업로드",
"apkgFilesOnly": ".apkg 파일만",
"parsing": "파싱 중...",
"foundDecks": "{count}개 덱 발견",
"back": "뒤로",
"import": "가져오기",
"importing": "가져오는 중...",
"exportSuccess": "내보내기 성공",
"goToDecks": "덱으로"
}, },
"folder_id": { "folder_id": {
"unauthorized": "이 폴더의 소유자가 아닙니다", "unauthorized": "이 폴더의 소유자가 아닙니다",
@@ -83,6 +106,77 @@
"deleteFolder": "이 폴더를 삭제할 권한이 없습니다." "deleteFolder": "이 폴더를 삭제할 권한이 없습니다."
} }
}, },
"deck_id": {
"unauthorized": "이 덱의 소유자가 아닙니다",
"back": "뒤로",
"cards": "카드",
"itemsCount": "{count}개",
"memorize": "암기",
"loadingCards": "카드 불러오는 중...",
"noCards": "이 덱에 카드가 없습니다",
"card": "카드",
"addNewCard": "새 카드 추가",
"add": "추가",
"adding": "추가 중...",
"updateCard": "카드 업데이트",
"update": "업데이트",
"updating": "업데이트 중...",
"word": "단어",
"definition": "정의",
"ipa": "IPA",
"example": "예문",
"wordAndDefinitionRequired": "단어와 정의는 필수입니다",
"edit": "편집",
"delete": "삭제",
"permissionDenied": "이 작업을 수행할 권한이 없습니다",
"resetProgress": "진행 초기화",
"resetProgressTitle": "학습 진행 초기화",
"resetProgressConfirm": "이 덱의 학습 진행을 초기화하시겠습니까?",
"resetSuccess": "초기화됨",
"resetting": "초기화 중...",
"cancel": "취소",
"settings": "설정",
"settingsTitle": "덱 설정",
"newPerDay": "일일 새 카드",
"newPerDayHint": "매일 학습할 새 카드 수",
"revPerDay": "일일 복습",
"revPerDayHint": "매일 복습할 카드 수",
"save": "저장",
"saving": "저장 중...",
"settingsSaved": "설정 저장됨",
"todayNew": "오늘 새 카드",
"todayReview": "오늘 복습",
"todayLearning": "학습 중",
"error": {
"update": "업데이트 권한이 없습니다",
"delete": "삭제 권한이 없습니다",
"add": "추가 권한이 없습니다"
},
"ipaPlaceholder": "IPA 입력",
"examplePlaceholder": "예문 입력",
"wordRequired": "단어를 입력하세요",
"definitionRequired": "정의를 입력하세요",
"cardAdded": "카드 추가됨",
"cardType": "카드 유형",
"wordCard": "단어 카드",
"phraseCard": "구문 카드",
"sentenceCard": "문장 카드",
"sentence": "문장",
"sentencePlaceholder": "문장 입력",
"wordPlaceholder": "단어 입력",
"queryLang": "검색 언어",
"enterLanguageName": "언어 이름을 입력하세요",
"english": "영어",
"chinese": "중국어",
"japanese": "일본어",
"korean": "한국어",
"meanings": "의미",
"addMeaning": "의미 추가",
"partOfSpeech": "품사",
"deleteConfirm": "이 카드를 삭제하시겠습니까?",
"cardDeleted": "카드 삭제됨",
"cardUpdated": "카드 업데이트됨"
},
"home": { "home": {
"title": "언어 배우기", "title": "언어 배우기",
"description": "세계의 거의 모든 언어(인공어 포함)를 배우는 데 도움이 되는 매우 유용한 웹사이트입니다.", "description": "세계의 거의 모든 언어(인공어 포함)를 배우는 데 도움이 되는 매우 유용한 웹사이트입니다.",
@@ -188,10 +282,10 @@
"memorize": { "memorize": {
"deck_selector": { "deck_selector": {
"selectDeck": "덱 선택", "selectDeck": "덱 선택",
"noDecks": "덱을 찾을 수 없습니다", "noDecks": "덱 없습니다",
"goToDecks": "덱으로 이동", "goToDecks": "덱으로 이동",
"noCards": "카드 없", "noCards": "카드습니다",
"new": "새 카드", "new": "새",
"learning": "학습 중", "learning": "학습 중",
"review": "복습", "review": "복습",
"due": "예정" "due": "예정"
@@ -199,37 +293,26 @@
"review": { "review": {
"loading": "로딩 중...", "loading": "로딩 중...",
"backToDecks": "덱으로 돌아가기", "backToDecks": "덱으로 돌아가기",
"allDone": "완료!", "allDone": "모두 완료!",
"allDoneDesc": "모든 복습 카드를 완료했습니다.", "allDoneDesc": "오늘의 학습을 완료했습니다!",
"reviewedCount": "{count}장의 카드 복습함", "reviewedCount": "{count}장 복습 완료",
"progress": "{current} / {total}", "progress": "{current} / {total}",
"nextReview": "다음 복습", "nextReview": "다음 복습",
"interval": "간격", "interval": "간격",
"ease": "난이도", "ease": "난이도",
"lapses": "망각 횟수", "lapses": "실패 횟수",
"showAnswer": "정답 보기", "showAnswer": "정답 보기",
"nextCard": "다음",
"again": "다시", "again": "다시",
"hard": "어려움", "restart": "다시 시작",
"good": "보통", "orderLimited": "순서 제한",
"easy": "쉬움", "orderInfinite": "순서 무제한",
"now": "지금", "randomLimited": "무작위 제한",
"lessThanMinute": "<1분", "randomInfinite": "무작위 무제한",
"inMinutes": "{count}분", "noIpa": "IPA 없음"
"inHours": "{count}시간",
"inDays": "{count}일",
"inMonths": "{count}개월",
"minutes": "<1분",
"days": "{count}일",
"months": "{count}개월",
"minAbbr": "분",
"dayAbbr": "일",
"cardTypeNew": "새 카드",
"cardTypeLearning": "학습 중",
"cardTypeReview": "복습 중",
"cardTypeRelearning": "재학습 중"
}, },
"page": { "page": {
"unauthorized": "이 덱에 접근할 권한이 없습니다" "unauthorized": "권한이 없습니다"
} }
}, },
"navbar": { "navbar": {
@@ -237,39 +320,55 @@
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "로그인", "sign_in": "로그인",
"profile": "프로필", "profile": "프로필",
"folders": "폴더", "folders": "",
"explore": "탐색", "explore": "탐색",
"favorites": "즐겨찾기", "favorites": "즐겨찾기",
"settings": "설정" "settings": "설정"
}, },
"ocr": { "ocr": {
"title": "OCR 어휘 추출", "title": "OCR 인식",
"description": "교과서 어휘표 스크린샷 only어업로드하여 단어-정의 쌍 추출", "description": "이미지에서 텍스트 추출",
"uploadImage": "이미지 업로드", "uploadImage": "이미지 업로드",
"dragDropHint": "이미지를 여기에 끌어다 놓거나 클릭하여 선택", "dragDropHint": "드래그 앤 드롭",
"supportedFormats": "지원 형식: JPG, PNG, WebP", "supportedFormats": "지원 형식: JPG, PNG, WEBP",
"selectDeck": "덱 선택", "selectDeck": "덱 선택",
"chooseDeck": "추출된 쌍을 저장할 덱 선택", "chooseDeck": "덱 선택",
"noDecks": "덱이 없습니다. 먼저 덱을 만드세요.", "noDecks": "덱이 없습니다",
"languageHints": "언어 힌트 (선택사항)", "languageHints": "언어 힌트",
"sourceLanguageHint": "소스 언어 (예: 영어)", "sourceLanguageHint": "원본 언어 힌트",
"targetLanguageHint": "대상/번역 언어 (예: 중국어)", "targetLanguageHint": "대상 언어 힌트",
"process": "이미지 처리", "process": "처리",
"processing": "처리...", "processing": "처리...",
"preview": "미리보기", "preview": "미리보기",
"extractedPairs": "추출된 쌍", "extractedPairs": "추출된 쌍",
"word": "단어", "word": "단어",
"definition": "정의", "definition": "정의",
"pairsCount": "{count} 쌍 추출됨", "pairsCount": "{count}",
"savePairs": "덱에 저장", "savePairs": "저장",
"saving": "저장...", "saving": "저장...",
"saved": "{deck}에 {count} 쌍 저장 완료", "saved": "저장됨",
"saveFailed": "저장 실패", "saveFailed": "저장 실패",
"noImage": "먼저 이미지를 업로드하세요", "noImage": "이미지를 업로드하세요",
"noDeck": "덱을 선택하세요", "noDeck": "덱을 선택하세요",
"processingFailed": "OCR 처리 실패", "processingFailed": "처리 실패",
"tryAgain": "더 선晰的图像로 다시 시도하세요", "tryAgain": "재시도",
"detectedLanguages": "감지됨: {source} → {target}" "detectedLanguages": "감지된 언어",
"uploadSection": "이미지 업로드",
"dropOrClick": "드롭 또는 클릭",
"changeImage": "이미지 변경",
"invalidFileType": "잘못된 파일 형식",
"deckSelection": "덱 선택",
"sourceLanguagePlaceholder": "예: 영어",
"targetLanguagePlaceholder": "예: 한국어",
"processButton": "인식 시작",
"resultsPreview": "결과 미리보기",
"saveButton": "덱에 저장",
"ocrSuccess": "OCR 성공",
"ocrFailed": "OCR 실패",
"savedToDeck": "덱에 저장됨",
"noResultsToSave": "저장할 결과 없음",
"detectedSourceLanguage": "감지된 원본 언어",
"detectedTargetLanguage": "감지된 대상 언어"
}, },
"profile": { "profile": {
"myProfile": "내 프로필", "myProfile": "내 프로필",
@@ -310,12 +409,43 @@
"videoUploadFailed": "비디오 업로드 실패", "videoUploadFailed": "비디오 업로드 실패",
"subtitleUploadFailed": "자막 업로드 실패", "subtitleUploadFailed": "자막 업로드 실패",
"subtitleLoadSuccess": "자막 로드 성공", "subtitleLoadSuccess": "자막 로드 성공",
"subtitleLoadFailed": "자막 로드 실패" "subtitleLoadFailed": "자막 로드 실패",
"settings": "설정",
"shortcuts": "단축키",
"keyboardShortcuts": "키보드 단축키",
"playPause": "재생/일시정지",
"autoPauseToggle": "자동 일시정지",
"subtitleSettings": "자막 설정",
"fontSize": "글꼴 크기",
"textColor": "글자 색",
"backgroundColor": "배경색",
"position": "위치",
"opacity": "불투명도",
"top": "위",
"center": "중앙",
"bottom": "아래"
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "IPA 생성", "generateIPA": "IPA 생성",
"viewSavedItems": "저장된 항목 보기", "viewSavedItems": "저장된 항목 보기",
"confirmDeleteAll": "모든 것을 삭제하시겠습니까? (Y/N)" "confirmDeleteAll": "모든 것을 삭제하시겠습니까? (Y/N)",
"saved": "저장됨",
"clearAll": "모두 지우기",
"language": "언어",
"customLanguage": "또는 언어 입력...",
"languages": {
"auto": "자동",
"chinese": "중국어",
"english": "영어",
"japanese": "일본어",
"korean": "한국어",
"french": "프랑스어",
"german": "독일어",
"italian": "이탈리아어",
"spanish": "스페인어",
"portuguese": "포르투갈어",
"russian": "러시아어"
}
}, },
"translator": { "translator": {
"detectLanguage": "언어 감지", "detectLanguage": "언어 감지",
@@ -348,7 +478,20 @@
"success": "텍스트 쌍이 폴더에 추가됨", "success": "텍스트 쌍이 폴더에 추가됨",
"error": "폴더에 텍스트 쌍 추가 실패" "error": "폴더에 텍스트 쌍 추가 실패"
}, },
"autoSave": "자동 저장" "autoSave": "자동 저장",
"customLanguage": "또는 언어 입력...",
"pleaseLogin": "카드를 저장하려면 로그인하세요",
"pleaseCreateDeck": "먼저 덱을 만드세요",
"noTranslationToSave": "저장할 번역이 없습니다",
"noDeckSelected": "덱이 선택되지 않았습니다",
"saveAsCard": "카드로 저장",
"selectDeck": "덱 선택",
"front": "앞면",
"back": "뒷면",
"cancel": "취소",
"save": "저장",
"savedToDeck": "{deckName}에 카드 저장됨",
"saveFailed": "카드 저장 실패"
}, },
"dictionary": { "dictionary": {
"title": "사전", "title": "사전",
@@ -393,7 +536,9 @@
"unfavorite": "즐겨찾기 해제", "unfavorite": "즐겨찾기 해제",
"pleaseLogin": "먼저 로그인해주세요", "pleaseLogin": "먼저 로그인해주세요",
"sortByFavorites": "즐겨찾기순 정렬", "sortByFavorites": "즐겨찾기순 정렬",
"sortByFavoritesActive": "즐겨찾기순 정렬 해제" "sortByFavoritesActive": "즐겨찾기순 정렬 해제",
"noDecks": "공개 덱 없음",
"deckInfo": "{userName} · {totalCards}장"
}, },
"exploreDetail": { "exploreDetail": {
"title": "폴더 상세", "title": "폴더 상세",
@@ -407,7 +552,8 @@
"unfavorite": "즐겨찾기 해제", "unfavorite": "즐겨찾기 해제",
"favorited": "즐겨찾기됨", "favorited": "즐겨찾기됨",
"unfavorited": "즐겨찾기 해제됨", "unfavorited": "즐겨찾기 해제됨",
"pleaseLogin": "먼저 로그인해주세요" "pleaseLogin": "먼저 로그인해주세요",
"totalCards": "총 {count}장"
}, },
"favorites": { "favorites": {
"title": "내 즐겨찾기", "title": "내 즐겨찾기",
@@ -452,6 +598,16 @@
"createdAt": "생성일", "createdAt": "생성일",
"actions": "작업", "actions": "작업",
"view": "보기" "view": "보기"
},
"joined": "가입일",
"decks": {
"title": "내 덱",
"noDecks": "덱이 없습니다",
"deckName": "덱 이름",
"totalCards": "총 카드",
"createdAt": "생성일",
"actions": "작업",
"view": "보기"
} }
}, },
"follow": { "follow": {

View File

@@ -53,7 +53,30 @@
"totalCards": "جەمئىي كارتا", "totalCards": "جەمئىي كارتا",
"createdAt": "قۇرۇلغان ۋاقتى", "createdAt": "قۇرۇلغان ۋاقتى",
"actions": "مەشغۇلاتلار", "actions": "مەشغۇلاتلار",
"view": "كۆرۈش" "view": "كۆرۈش",
"subtitle": "دېكلەرنى باشقۇرۇڭ",
"newDeck": "يېڭى دېك",
"noDecksYet": "دېك يوق",
"loading": "يۈكلىنىۋاتىدۇ...",
"deckInfo": "ID: {id} · {totalCards} كارتا",
"enterDeckName": "دېك ئاتى:",
"enterNewName": "يېڭى ئات:",
"confirmDelete": "ئۆچۈرۈش: \"{name}\"",
"public": "ئاممىۋىي",
"private": "شەخسىي",
"setPublic": "ئاممىۋىي قىلىش",
"setPrivate": "شەخسىي قىلىش",
"importApkg": "APKG ئەكىرىش",
"exportApkg": "APKG چىقىرىش",
"clickToUpload": "چېكىپ يۈكلەش",
"apkgFilesOnly": ".apkg ھۆججىتىلا",
"parsing": "تەھلىل قىلىنىۋاتىدۇ...",
"foundDecks": "{count} دېك تېپىلدى",
"back": "قايتىش",
"import": "ئەكىرىش",
"importing": "ئەكىرىلىۋاتىدۇ...",
"exportSuccess": "چىقىرىش مۇۋەپپەقىيەتلىك",
"goToDecks": "دېكلەرگە بېرىش"
}, },
"folder_id": { "folder_id": {
"unauthorized": "بۇ قىسقۇچنىڭ ئىگىسى ئەمەسسىز", "unauthorized": "بۇ قىسقۇچنىڭ ئىگىسى ئەمەسسىز",
@@ -83,6 +106,77 @@
"deleteFolder": "بۇ قىسقۇچنى ئۆچۈرۈش ھوقۇقىڭىز يوق." "deleteFolder": "بۇ قىسقۇچنى ئۆچۈرۈش ھوقۇقىڭىز يوق."
} }
}, },
"deck_id": {
"unauthorized": "بۇ دېكنىڭ ئىگىسى ئەمەس",
"back": "قايتىش",
"cards": "كارتلار",
"itemsCount": "{count} تۈر",
"memorize": "يادلاش",
"loadingCards": "كارتلار يۈكلىنىۋاتىدۇ...",
"noCards": "بۇ دېكتا كارت يوق",
"card": "كارتا",
"addNewCard": "يېڭى كارتا قوشۇش",
"add": "قوشۇش",
"adding": "قوشۇلىۋاتىدۇ...",
"updateCard": "كارتىنى يېڭىلاش",
"update": "يېڭىلاش",
"updating": "يېڭىلىنىۋاتىدۇ...",
"word": "سۆز",
"definition": "ئېنىقلىما",
"ipa": "IPA",
"example": "مىسال",
"wordAndDefinitionRequired": "سۆز ۋە ئېنىقلىما زۆرۈر",
"edit": "تەھرىرلەش",
"delete": "ئۆچۈرۈش",
"permissionDenied": "بۇ مەشغۇلاتنى ئېلىپ بېرىش ھوقۇقىڭىز يوق",
"resetProgress": "ئىلگىرىلەشنى ئەسلىگە قايتۇرۇش",
"resetProgressTitle": "ئۆگىنىش ئىلگىرىلەشىنى ئەسلىگە قايتۇرۇش",
"resetProgressConfirm": "ئىلگىرىلەشنى ئەسلىگە قايتۇرامسىز؟",
"resetSuccess": "ئەسلىگە قايتۇرۇلدى",
"resetting": "ئەسلىگە قايتۇرۇۋاتىدۇ...",
"cancel": "بىكار قىلىش",
"settings": "تەڭشەكلەر",
"settingsTitle": "دېك تەڭشەكلىرى",
"newPerDay": "كۈندىلىك يېڭى",
"newPerDayHint": "كۈندە يېڭى كارتا سانى",
"revPerDay": "كۈندىلىك تەكرار",
"revPerDayHint": "كۈندە تەكرار سانى",
"save": "ساقلاش",
"saving": "ساقلاۋاتىدۇ...",
"settingsSaved": "تەڭشەكلەر ساقلاندى",
"todayNew": "بۈگۈنكى يېڭى",
"todayReview": "بۈگۈنكى تەكرار",
"todayLearning": "ئۆگىنىۋاتىدۇ",
"error": {
"update": "يېڭىلاش ھوقۇقى يوق",
"delete": "ئۆچۈرۈش ھوقۇقى يوق",
"add": "قوشۇش ھوقۇقى يوق"
},
"ipaPlaceholder": "IPA كىرگۈزۈڭ",
"examplePlaceholder": "مىسال كىرگۈزۈڭ",
"wordRequired": "سۆز كىرگۈزۈڭ",
"definitionRequired": "ئېنىقلىما كىرگۈزۈڭ",
"cardAdded": "كارتا قوشۇلدى",
"cardType": "كارتا تىپى",
"wordCard": "سۆز كارتىسى",
"phraseCard": "جۈملە كارتىسى",
"sentenceCard": "جۈملە كارتىسى",
"sentence": "جۈملە",
"sentencePlaceholder": "جۈملە كىرگۈزۈڭ",
"wordPlaceholder": "سۆز كىرگۈزۈڭ",
"queryLang": "سۈرۈشتۈرۈش تىلى",
"enterLanguageName": "تىل ئاتىنى كىرگۈزۈڭ",
"english": "ئىنگىلىزچە",
"chinese": "خەنزۇچە",
"japanese": "ياپونچە",
"korean": "كورىيەچە",
"meanings": "مەنىلىرى",
"addMeaning": "مەنا قوشۇش",
"partOfSpeech": "سۆز بۆلىكى",
"deleteConfirm": "بۇ كارتىنى ئۆچۈرەمسىز؟",
"cardDeleted": "كارتا ئۆچۈرۈلدى",
"cardUpdated": "كارتا يېڭىلاندى"
},
"home": { "home": {
"title": "تىل ئۆگىنىش", "title": "تىل ئۆگىنىش",
"description": "بۇ دۇنيادىكى almost ھەر بىر تىلنى، جۈملىدىن سۈنئىي تىللارنى ئۆگىنىشىڭىزگە ياردەم بېرىدىغان ئىنتايىن قوللىنىشلىق تور بېكەت.", "description": "بۇ دۇنيادىكى almost ھەر بىر تىلنى، جۈملىدىن سۈنئىي تىللارنى ئۆگىنىشىڭىزگە ياردەم بېرىدىغان ئىنتايىن قوللىنىشلىق تور بېكەت.",
@@ -187,49 +281,63 @@
}, },
"memorize": { "memorize": {
"deck_selector": { "deck_selector": {
"selectDeck": "بىر دېك تاللاڭ", "selectDeck": "دېك تاللاش",
"noDecks": "دېك تېپىلمىدى", "noDecks": "دېك يوق",
"goToDecks": "دېكلارغا بېرىڭ", "goToDecks": "دېكلەرگە بار",
"noCards": "كارتا يوق", "noCards": "كارتا يوق",
"new": "يېڭى", "new": "يېڭى",
"learning": "ئۆگىنىۋاتىدۇ", "learning": "ئۆگىنىش",
"review": "تەكرار", "review": "تەكرار",
"due": "ۋاقتى كەلدى" "due": "ۋاقتى كەلدى"
}, },
"review": { "review": {
"loading": "يۈكلىنىۋاتىدۇ...", "loading": "يۈكلىنىۋاتىدۇ...",
"backToDecks": "دېكلارغا قايتىڭ", "backToDecks": "دېكلەرگە قايتىش",
"allDone": "تامام!", "allDone": "ھەممىسى تامام!",
"allDoneDesc": ارلىق تەكرارلاش كارتلىرى تاماملاندى.", "allDoneDesc": ۈگۈنكى ئۆگىنىش تامام!",
"reviewedCount": "{count} كارتا تەكرارلاندى", "reviewedCount": "{count} كارتا تەكرارلاندى",
"progress": "{current} / {total}", "progress": "{current} / {total}",
"nextReview": "كېيىنكى تەكرار", "nextReview": "كېيىنكى تەكرار",
"interval": "ئارىلىق", "interval": "ئارىلىق",
"ease": "ئاسانلىق", "ease": "قىيىنلىق",
"lapses": "ئۇنتۇش سانى", "lapses": "خاتالىق",
"showAnswer": "جاۋابنى كۆرسەت", "showAnswer": "جاۋابنى كۆرسەت",
"nextCard": "كېيىنكى",
"again": "يەنە", "again": "يەنە",
"hard": "قىيىن", "hard": "قىيىن",
"good": "ياخشى", "good": "ياخشى",
"easy": "ئاسان", "easy": "ئاسان",
"now": "ھازىر", "now": "ھازىر",
"lessThanMinute": "<1 مىنۇت", "lessThanMinute": "1 مىنۇتتىن ئاز",
"inMinutes": "{count} مىنۇت", "inMinutes": "{n} مىنۇتتىن كېيىن",
"inHours": "{count} سائەت", "inHours": "{n} سائەتتىن كېيىن",
"inDays": "{count} كۈن", "inDays": "{n} كۈندىن كېيىن",
"inMonths": "{count} ئاي", "inMonths": "{n} ئايدىن كېيىن",
"minutes": "<1 مىنۇت", "minutes": "مىنۇت",
"days": "{count} كۈن", "days": "كۈن",
"months": "{count} ئاي", "months": "ئاي",
"minAbbr": "م", "minAbbr": ىن",
"dayAbbr": "ك", "dayAbbr": ۈن",
"cardTypeNew": "يېڭى", "cardTypeNew": "يېڭى",
"cardTypeLearning": "ئۆگىنىۋاتىدۇ", "cardTypeLearning": "ئۆگىنىش",
"cardTypeReview": "تەكرارلاش", "cardTypeReview": "تەكرار",
"cardTypeRelearning": "قايتا ئۆگىنىش" "cardTypeRelearning": "قايتا ئۆگىنىش",
"reverse": "ئەكسىچە",
"dictation": "ئىملا",
"clickToPlay": "چېكىپ قويۇش",
"yourAnswer": "جاۋابىڭىز",
"typeWhatYouHear": "ئاڭلىغىنىڭىزنى يېزىڭ",
"correct": "توغرا!",
"incorrect": "خاتا",
"restart": "قايتا باشلا",
"orderLimited": "تەرتىپلى چەكلەنگەن",
"orderInfinite": "تەرتىپلى چەكسىز",
"randomLimited": "ئىختىيارى چەكلەنگەن",
"randomInfinite": "ئىختىيارى چەكسىز",
"noIpa": "IPA يوق"
}, },
"page": { "page": {
"unauthorized": "بۇ دېكنى زىيارەت قىلىش ھوقۇقىڭىز يوق" "unauthorized": "ھوقۇقسىز"
} }
}, },
"navbar": { "navbar": {
@@ -237,39 +345,55 @@
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "كىرىش", "sign_in": "كىرىش",
"profile": "شەخسىي ئۇچۇر", "profile": "شەخسىي ئۇچۇر",
"folders": "قىسقۇچلار", "folders": "دېكلار",
"explore": "ئىزدىنىش", "explore": "ئىزدىنىش",
"favorites": "يىغىپ ساقلاش", "favorites": "يىغىپ ساقلاش",
"settings": "تەڭشەكلەر" "settings": "تەڭشەكلەر"
}, },
"ocr": { "ocr": {
"title": "OCR سۆز ئاستىرىش", "title": "OCR تونۇش",
"description": "دەرىسلىك كىتابىدىكى سۆز جەدۋىلى سۈرەتلىرىنى يۈكلەپ سۆز-مەنا جۈپلىرىنى ئاستىرىڭ", "description": "رەسىمدىن تېكىست ئېلىش",
"uploadImage": "سۈرەت يۈكلەش", "uploadImage": "رەسىم يۈكلەش",
"dragDropHint": ۈرەتنى بۇ يەرگە سۆرەڭ ياكى چېكىپ تاللاڭ", "dragDropHint": ۆرەپ تاشلاش",
"supportedFormats": "قوللايدىغان فورماتلار: JPG، PNG، WebP", "supportedFormats": "قوللايدىغان فورمات: JPG, PNG, WEBP",
"selectDeck": "دېك تاللاش", "selectDeck": "دېك تاللاش",
"chooseDeck": "ئاستىرىلغان جۈپلەرنى ساقلاش ئۈچۈن دېك تاللاڭ", "chooseDeck": "دېك تاللاڭ",
"noDecks": "دېك يوق. ئاۋۋال دېك قۇرۇڭ.", "noDecks": "دېك يوق",
"languageHints": "تىل ئۇچۇرلىرى (ئىختىيارىي)", "languageHints": "تىل بېشارىتى",
"sourceLanguageHint": "مەنبە تىلى (مىسال: ئىنگىلىزچە)", "sourceLanguageHint": "مەنبە تىلى",
"targetLanguageHint": "نىشان/تەرجىمە تىلى (مىسال: خەنزۇچە)", "targetLanguageHint": "نىشان تىلى",
"process": "سۈرەتنى بىر تەرەپ قىلىش", "process": "بىر تەرەپ قىلىش",
"processing": "بىر تەرەپ قىلىۋاتىدۇ...", "processing": "بىر تەرەپ قىلىنىۋاتىدۇ...",
"preview": "ئالدىن كۆرۈش", "preview": "ئالدىن كۆرۈش",
"extractedPairs": استىرىلغان جۈپلەر", "extractedPairs": ېلىنغان جۈپلەر",
"word": "سۆز", "word": "سۆز",
"definition": ەنا", "definition": "ئېنىقلىما",
"pairsCount": "{count} جۈپ ئاستىرىلدى", "pairsCount": "{count} جۈپ",
"savePairs": "دېككە ساقلاش", "savePairs": "ساقلاش",
"saving": "ساقلاۋاتىدۇ...", "saving": "ساقلاۋاتىدۇ...",
"saved": "{deck} غا {count} جۈپ ساقلاندى", "saved": "ساقلاندى",
"saveFailed": "ساقلاش مەغلۇپ بولدى", "saveFailed": "ساقلاش مەغلۇپ بولدى",
"noImage": "ئاۋۋال سۈرەت يۈكلەڭ", "noImage": "رەسىم يۈكلەڭ",
"noDeck": "دېك تاللاڭ", "noDeck": "دېك تاللاڭ",
"processingFailed": "OCR بىر تەرەپ قىلىش مەغلۇپ بولدى", "processingFailed": "بىر تەرەپ قىلىش مەغلۇپ بولدى",
"tryAgain": "تېخىمۇ ئېنىق سۈرەت بىلەن قايتا سىناڭ", "tryAgain": "قايتا سىناڭ",
"detectedLanguages": "بايقالدى: {source} → {target}" "detectedLanguages": "تونۇلغان تىللار",
"uploadSection": "رەسىم يۈكلەش",
"dropOrClick": "تاشلاش ياكى چېكىش",
"changeImage": "رەسىم ئالماشتۇرۇش",
"invalidFileType": "ئىناۋەتسىز فايىل تىپى",
"deckSelection": "دېك تاللاش",
"sourceLanguagePlaceholder": "مەسىلەن: ئىنگلىزچە",
"targetLanguagePlaceholder": "مەسىلەن: ئۇيغۇرچە",
"processButton": "تونۇشنى باشلاش",
"resultsPreview": "نەتىجە ئالدىن كۆرۈش",
"saveButton": "دېككە ساقلاش",
"ocrSuccess": "OCR مۇۋەپپەقىيەتلىك",
"ocrFailed": "OCR مەغلۇپ بولدى",
"savedToDeck": "دېككە ساقلاندى",
"noResultsToSave": "نەتىجە يوق",
"detectedSourceLanguage": "تونۇلغان مەنبە تىلى",
"detectedTargetLanguage": "تونۇلغان نىشان تىلى"
}, },
"profile": { "profile": {
"myProfile": "شەخسىي ئۇچۇرۇم", "myProfile": "شەخسىي ئۇچۇرۇم",
@@ -310,12 +434,43 @@
"videoUploadFailed": "ۋىدېئو يۈكلەش مەغلۇپ بولدى", "videoUploadFailed": "ۋىدېئو يۈكلەش مەغلۇپ بولدى",
"subtitleUploadFailed": "تر پودكاست يۈكلەش مەغلۇپ بولدى", "subtitleUploadFailed": "تر پودكاست يۈكلەش مەغلۇپ بولدى",
"subtitleLoadSuccess": "تر پودكاست مۇۋەپپەقىيەتلىك يۈكلەندى", "subtitleLoadSuccess": "تر پودكاست مۇۋەپپەقىيەتلىك يۈكلەندى",
"subtitleLoadFailed": "تر پودكاست يۈكلەش مەغلۇپ بولدى" "subtitleLoadFailed": "تر پودكاست يۈكلەش مەغلۇپ بولدى",
"settings": "تەڭشەكلەر",
"shortcuts": "تېزلەتمەلەر",
"keyboardShortcuts": "كۇنۇپكا تاختىسى تېزلەتمەلىرى",
"playPause": "قويۇش/توختىتىش",
"autoPauseToggle": "ئاپتوماتىك توختىتىش",
"subtitleSettings": "ئاستى سىزىق تەڭشەكلىرى",
"fontSize": "خەت چوڭلۇقى",
"textColor": "خەت رەڭگى",
"backgroundColor": "تەگلىك رەڭگى",
"position": "ئورنى",
"opacity": "سۈزۈكلۈك",
"top": "ئۈستى",
"center": "ئوتتۇرا",
"bottom": "ئاستى"
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "IPA ھاسىل قىلىش", "generateIPA": "IPA ھاسىل قىلىش",
"viewSavedItems": "ساقلانغان تۈرلەرنى كۆرۈش", "viewSavedItems": "ساقلانغان تۈرلەرنى كۆرۈش",
"confirmDeleteAll": "ھەممىنى ئۆچۈرەمسىز؟ (Y/N)" "confirmDeleteAll": "ھەممىنى ئۆچۈرەمسىز؟ (Y/N)",
"saved": "ساقلاندى",
"clearAll": "ھەممىنى تازىلاش",
"language": "تىل",
"customLanguage": "ياكى تىل كىرگۈزۈڭ...",
"languages": {
"auto": "ئاپتوماتىك",
"chinese": "خەنزۇچە",
"english": "ئىنگلىزچە",
"japanese": "ياپونچە",
"korean": "كورېيەچە",
"french": "فرانسۇزچە",
"german": "گېرمانچە",
"italian": "ئىتاليانچە",
"spanish": "ئىسپانچە",
"portuguese": "پورتۇگالچە",
"russian": "رۇسچە"
}
}, },
"translator": { "translator": {
"detectLanguage": "تىلنى تونۇش", "detectLanguage": "تىلنى تونۇش",
@@ -348,7 +503,20 @@
"success": "تېكىست جۈپى قىسقۇچقا قوشۇلدى", "success": "تېكىست جۈپى قىسقۇچقا قوشۇلدى",
"error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇپ بولدى" "error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇپ بولدى"
}, },
"autoSave": "ئاپتوماتىك ساقلاش" "autoSave": "ئاپتوماتىك ساقلاش",
"customLanguage": "ياكى تىل تىل كىرۇڭ...",
"pleaseLogin": "كارتا ساقلاش ئۈچۈن كىرىڭ",
"pleaseCreateDeck": "ئاۋۋال دېك قۇرۇڭ",
"noTranslationToSave": "ساقلايدىغان تەرجىمە يوق",
"noDeckSelected": "دېك تاللانمىدى",
"saveAsCard": "كارتا ساقلاش",
"selectDeck": "دېك تاللاش",
"front": "ئالدى",
"back": "كەينى",
"cancel": "بىكار قىلىش",
"save": "ساقلاش",
"savedToDeck": "{deckName} غا ساقلاندى",
"saveFailed": "ساقلاش مەغلۇپ"
}, },
"dictionary": { "dictionary": {
"title": "لۇغەت", "title": "لۇغەت",
@@ -393,7 +561,9 @@
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل", "unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ", "pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ",
"sortByFavorites": "يىغىپ ساقلاش بويىچە تەرتىپلەش", "sortByFavorites": "يىغىپ ساقلاش بويىچە تەرتىپلەش",
"sortByFavoritesActive": "يىغىپ ساقلاش بويىچە تەرتىپلەشنى بىكار قىلىش" "sortByFavoritesActive": "يىغىپ ساقلاش بويىچە تەرتىپلەشنى بىكار قىلىش",
"noDecks": "ئاممىۋىي دېك يوق",
"deckInfo": "{userName} · {totalCards} كارتا"
}, },
"exploreDetail": { "exploreDetail": {
"title": "قىسقۇچ تەپسىلاتلىرى", "title": "قىسقۇچ تەپسىلاتلىرى",
@@ -407,7 +577,8 @@
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل", "unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
"favorited": "يىغىپ ساقلاندى", "favorited": "يىغىپ ساقلاندى",
"unfavorited": "يىغىپ ساقلاش بىكار قىلىندى", "unfavorited": "يىغىپ ساقلاش بىكار قىلىندى",
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ" "pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ",
"totalCards": "{count} كارتا"
}, },
"favorites": { "favorites": {
"title": "يىغىپ ساقلىغانلىرىم", "title": "يىغىپ ساقلىغانلىرىم",
@@ -452,7 +623,8 @@
"createdAt": "قۇرۇلغان ۋاقتى", "createdAt": "قۇرۇلغان ۋاقتى",
"actions": "مەشغۇلاتلار", "actions": "مەشغۇلاتلار",
"view": "كۆرۈش" "view": "كۆرۈش"
} },
"joined": "قوشۇلدى"
}, },
"follow": { "follow": {
"follow": "ئەگىشىش", "follow": "ئەگىشىش",

View File

@@ -74,6 +74,77 @@
"deleteFolder": "您没有权限删除此文件夹" "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": "进度已重置",
"resetting": "重置中...",
"cancel": "取消",
"settings": "设置",
"settingsTitle": "卡组设置",
"newPerDay": "每日新卡",
"newPerDayHint": "每天学习的新卡片数量",
"revPerDay": "每日复习",
"revPerDayHint": "每天复习的卡片数量",
"save": "保存",
"saving": "保存中...",
"settingsSaved": "设置已保存",
"todayNew": "今日新卡",
"todayReview": "今日复习",
"todayLearning": "学习中",
"error": {
"update": "您没有权限更新此卡片",
"delete": "您没有权限删除此卡片",
"add": "您没有权限向此牌组添加卡片"
},
"ipaPlaceholder": "输入IPA音标",
"examplePlaceholder": "输入例句",
"wordRequired": "请输入单词",
"definitionRequired": "请输入至少一个释义",
"cardAdded": "卡片已添加",
"cardType": "卡片类型",
"wordCard": "单词卡",
"phraseCard": "短语卡",
"sentenceCard": "句子卡",
"sentence": "句子",
"sentencePlaceholder": "输入句子",
"wordPlaceholder": "输入单词",
"queryLang": "查询语言",
"enterLanguageName": "请输入语言名称",
"english": "英语",
"chinese": "中文",
"japanese": "日语",
"korean": "韩语",
"meanings": "释义",
"addMeaning": "添加释义",
"partOfSpeech": "词性",
"deleteConfirm": "确定删除这张卡片吗?",
"cardDeleted": "卡片已删除",
"cardUpdated": "卡片已更新"
},
"home": { "home": {
"title": "学语言", "title": "学语言",
"description": "这里是一个非常有用的网站,可以帮助您学习世界上几乎每一种语言,包括人造语言。", "description": "这里是一个非常有用的网站,可以帮助您学习世界上几乎每一种语言,包括人造语言。",
@@ -199,6 +270,7 @@
"ease": "难度系数", "ease": "难度系数",
"lapses": "遗忘次数", "lapses": "遗忘次数",
"showAnswer": "显示答案", "showAnswer": "显示答案",
"nextCard": "下一张",
"again": "重来", "again": "重来",
"hard": "困难", "hard": "困难",
"good": "良好", "good": "良好",
@@ -217,7 +289,20 @@
"cardTypeNew": "新卡片", "cardTypeNew": "新卡片",
"cardTypeLearning": "学习中", "cardTypeLearning": "学习中",
"cardTypeReview": "复习中", "cardTypeReview": "复习中",
"cardTypeRelearning": "重学中" "cardTypeRelearning": "重学中",
"reverse": "反向",
"dictation": "听写",
"clickToPlay": "点击播放",
"yourAnswer": "你的答案",
"typeWhatYouHear": "输入你听到的内容",
"correct": "正确",
"incorrect": "错误",
"restart": "重新开始",
"orderLimited": "顺序有限",
"orderInfinite": "顺序无限",
"randomLimited": "随机有限",
"randomInfinite": "随机无限",
"noIpa": "无音标"
}, },
"page": { "page": {
"unauthorized": "您无权访问该牌组" "unauthorized": "您无权访问该牌组"
@@ -228,53 +313,55 @@
"sourceCode": "源码", "sourceCode": "源码",
"sign_in": "登录", "sign_in": "登录",
"profile": "个人资料", "profile": "个人资料",
"folders": "文件夹", "folders": "牌组",
"explore": "探索", "explore": "探索",
"favorites": "收藏", "favorites": "收藏",
"settings": "设置" "settings": "设置"
}, },
"ocr": { "ocr": {
"title": "OCR 词汇提取", "title": "OCR文字识别",
"description": "上传教材词汇表截图,提取单词-释义对", "description": "从图片中提取文字创建学习卡片",
"uploadSection": "上传图片", "uploadSection": "上传图片",
"uploadImage": "上传图片", "uploadImage": "上传图片",
"dragDropHint": "拖放图片到此处,或点击选择", "dragDropHint": "拖放或点击上传",
"dropOrClick": "拖放图片到此处,或点击选择", "dropOrClick": "拖放或点击",
"changeImage": "点击更换图片", "changeImage": "更换图片",
"supportedFormats": "支持格式JPGPNG、WebP", "supportedFormats": "支持格式JPG, PNG, WEBP",
"deckSelection": "选择牌组", "invalidFileType": "无效的文件类型",
"selectDeck": "选择组", "deckSelection": "选择组",
"chooseDeck": "选择保存提取词汇的牌组", "selectDeck": "选择组",
"noDecks": "暂无牌组,请先创建牌组", "chooseDeck": "选择卡组保存",
"languageHints": "语言提示(可选)", "noDecks": "没有可用的卡组",
"sourceLanguageHint": "语言(如:英语)", "languageHints": "语言提示",
"targetLanguageHint": "目标/翻译语言(如:中文)", "sourceLanguageHint": "源语言提示",
"sourceLanguagePlaceholder": "源语言(如:英语)", "targetLanguageHint": "目标语言提示",
"targetLanguagePlaceholder": "目标/翻译语言(如:中文)", "sourceLanguagePlaceholder": "如:英语",
"process": "处理图片", "targetLanguagePlaceholder": "如:中文",
"processButton": "处理图片", "process": "处理",
"processButton": "开始识别",
"processing": "处理中...", "processing": "处理中...",
"preview": "预览", "preview": "预览",
"resultsPreview": "结果预览", "resultsPreview": "结果预览",
"extractedPairs": "提取 {count} 个词汇对", "extractedPairs": "提取的语言对",
"word": "单词", "word": "单词",
"definition": "释义", "definition": "释义",
"pairsCount": "{count} 个词汇对", "pairsCount": "{count}对",
"savePairs": "保存到牌组", "savePairs": "保存",
"saveButton": "保存", "saveButton": "保存到卡组",
"saving": "保存中...", "saving": "保存中...",
"saved": "成功将 {count} 个词汇对保存到 {deck}", "saved": "已保存",
"ocrSuccess": "成功将 {count} 个词汇对保存到 {deck}", "ocrSuccess": "OCR识别成功",
"savedToDeck": "已保存到 {deckName}", "savedToDeck": "已保存到卡组",
"saveFailed": "保存失败", "saveFailed": "保存失败",
"noImage": "请上传图片", "noImage": "请上传图片",
"noDeck": "请选择组", "noDeck": "请选择组",
"noResultsToSave": "没有可保存的结果", "noResultsToSave": "无结果可保存",
"processingFailed": "OCR 处理失败", "processingFailed": "处理失败",
"tryAgain": "请尝试上传更清晰的图片", "tryAgain": "重试",
"detectedLanguages": "检测到{source} → {target}", "detectedLanguages": "检测到的语言",
"detectedSourceLanguage": "检测到的源语言", "detectedSourceLanguage": "检测到的源语言",
"detectedTargetLanguage": "检测到的目标语言" "detectedTargetLanguage": "检测到的目标语言",
"ocrFailed": "OCR识别失败"
}, },
"profile": { "profile": {
"myProfile": "我的个人资料", "myProfile": "我的个人资料",
@@ -315,12 +402,43 @@
"videoUploadFailed": "视频上传失败", "videoUploadFailed": "视频上传失败",
"subtitleUploadFailed": "字幕上传失败", "subtitleUploadFailed": "字幕上传失败",
"subtitleLoadSuccess": "字幕加载成功", "subtitleLoadSuccess": "字幕加载成功",
"subtitleLoadFailed": "字幕加载失败" "subtitleLoadFailed": "字幕加载失败",
"settings": "设置",
"shortcuts": "快捷键",
"keyboardShortcuts": "键盘快捷键",
"playPause": "播放/暂停",
"autoPauseToggle": "自动暂停开关",
"subtitleSettings": "字幕设置",
"fontSize": "字体大小",
"textColor": "文字颜色",
"backgroundColor": "背景颜色",
"position": "位置",
"opacity": "透明度",
"top": "顶部",
"center": "居中",
"bottom": "底部"
}, },
"text_speaker": { "text_speaker": {
"generateIPA": "生成IPA", "generateIPA": "生成IPA",
"viewSavedItems": "查看保存项", "viewSavedItems": "查看保存项",
"confirmDeleteAll": "确定删光吗?(Y/N)" "confirmDeleteAll": "确定删光吗?(Y/N)",
"saved": "已保存",
"clearAll": "清空全部",
"language": "语言",
"customLanguage": "或输入语言...",
"languages": {
"auto": "自动",
"chinese": "中文",
"english": "英语",
"japanese": "日语",
"korean": "韩语",
"french": "法语",
"german": "德语",
"italian": "意大利语",
"spanish": "西班牙语",
"portuguese": "葡萄牙语",
"russian": "俄语"
}
}, },
"translator": { "translator": {
"detectLanguage": "检测语言", "detectLanguage": "检测语言",
@@ -353,7 +471,20 @@
"success": "文本对已添加到文件夹", "success": "文本对已添加到文件夹",
"error": "添加文本对到文件夹失败" "error": "添加文本对到文件夹失败"
}, },
"autoSave": "自动保存" "autoSave": "自动保存",
"customLanguage": "或输入语言...",
"pleaseLogin": "请登录后保存卡片",
"pleaseCreateDeck": "请先创建卡组",
"noTranslationToSave": "没有可保存的翻译",
"noDeckSelected": "未选择卡组",
"saveAsCard": "保存为卡片",
"selectDeck": "选择卡组",
"front": "正面",
"back": "背面",
"cancel": "取消",
"save": "保存",
"savedToDeck": "已保存到 {deckName}",
"saveFailed": "保存失败"
}, },
"dictionary": { "dictionary": {
"title": "词典", "title": "词典",
@@ -391,8 +522,8 @@
"subtitle": "发现公开牌组", "subtitle": "发现公开牌组",
"searchPlaceholder": "搜索公开牌组...", "searchPlaceholder": "搜索公开牌组...",
"loading": "加载中...", "loading": "加载中...",
"noDecks": "没有找到公开组", "noDecks": "暂无公开组",
"deckInfo": "{userName} {cardCount} 张卡片", "deckInfo": "{userName} · {totalCards} 张",
"unknownUser": "未知用户", "unknownUser": "未知用户",
"favorite": "收藏", "favorite": "收藏",
"unfavorite": "取消收藏", "unfavorite": "取消收藏",
@@ -404,7 +535,7 @@
"title": "牌组详情", "title": "牌组详情",
"createdBy": "创建者:{name}", "createdBy": "创建者:{name}",
"unknownUser": "未知用户", "unknownUser": "未知用户",
"totalCards": "卡片数量", "totalCards": "共 {count} 张",
"favorites": "收藏数", "favorites": "收藏数",
"createdAt": "创建时间", "createdAt": "创建时间",
"viewContent": "查看内容", "viewContent": "查看内容",
@@ -433,7 +564,7 @@
"displayName": "显示名称", "displayName": "显示名称",
"notSet": "未设置", "notSet": "未设置",
"memberSince": "注册时间", "memberSince": "注册时间",
"joined": "加入于", "joined": "注册于",
"logout": "登出", "logout": "登出",
"deleteAccount": { "deleteAccount": {
"button": "注销账号", "button": "注销账号",
@@ -462,30 +593,30 @@
}, },
"decks": { "decks": {
"title": "牌组", "title": "牌组",
"subtitle": "管理您的闪卡牌组", "subtitle": "管理你的学习卡组",
"newDeck": "新建组", "newDeck": "新建组",
"noDecksYet": "还没有牌组", "noDecksYet": "暂无卡组",
"loading": "加载中...", "loading": "加载中...",
"deckInfo": "ID: {id} {totalCards} 张卡片", "deckInfo": "ID: {id} · {totalCards} 张",
"enterDeckName": "输入组名称:", "enterDeckName": "输入组名称:",
"enterNewName": "输入新名称:", "enterNewName": "输入新名称:",
"confirmDelete": "输入 \"{name}\" 删除:", "confirmDelete": "输入 \"{name}\" 确认删除:",
"public": "公开", "public": "公开",
"private": "私有", "private": "私有",
"setPublic": "设为公开", "setPublic": "设为公开",
"setPrivate": "设为私有", "setPrivate": "设为私有",
"importApkg": "导入 APKG", "importApkg": "导入 APKG",
"exportApkg": "导出 APKG", "exportApkg": "导出 APKG",
"clickToUpload": "点击上传 APKG 文件", "clickToUpload": "点击上传",
"apkgFilesOnly": "仅支持 .apkg 文件", "apkgFilesOnly": "仅支持 .apkg 文件",
"parsing": "解析中...", "parsing": "解析中...",
"foundDecks": "找到 {count} 个组", "foundDecks": "发现 {count} 个组",
"deckName": "组名称", "deckName": "组名称",
"back": "返回", "back": "返回",
"import": "导入", "import": "导入",
"importing": "导入中...", "importing": "导入中...",
"exportSuccess": "牌组导出成功", "exportSuccess": "导出成功",
"goToDecks": "前往组" "goToDecks": "前往组"
}, },
"follow": { "follow": {
"follow": "关注", "follow": "关注",

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "decks" ADD COLUMN "new_per_day" INTEGER NOT NULL DEFAULT 20,
ADD COLUMN "rev_per_day" INTEGER NOT NULL DEFAULT 200;

View File

@@ -23,14 +23,9 @@ model User {
username String @unique username String @unique
bio String? bio String?
accounts Account[] accounts Account[]
dictionaryLookUps DictionaryLookUp[]
// Anki-compatible relations
decks Deck[] decks Deck[]
deckFavorites DeckFavorite[] deckFavorites DeckFavorite[]
noteTypes NoteType[]
notes Note[]
sessions Session[] sessions Session[]
translationHistories TranslationHistory[]
followers Follow[] @relation("UserFollowers") followers Follow[] @relation("UserFollowers")
following Follow[] @relation("UserFollowing") following Follow[] @relation("UserFollowing")
@@ -85,70 +80,28 @@ model Verification {
} }
// ============================================ // ============================================
// Anki-compatible Models // Deck & Card
// ============================================ // ============================================
/// Card type: 0=new, 1=learning, 2=review, 3=relearning
enum CardType {
NEW
LEARNING
REVIEW
RELEARNING
}
/// Card queue: -3=user buried, -2=sched buried, -1=suspended, 0=new, 1=learning, 2=review, 3=in learning, 4=preview
enum CardQueue {
USER_BURIED
SCHED_BURIED
SUSPENDED
NEW
LEARNING
REVIEW
IN_LEARNING
PREVIEW
}
/// Note type: 0=standard, 1=cloze
enum NoteKind {
STANDARD
CLOZE
}
/// Deck visibility (our extension, not in Anki)
enum Visibility { enum Visibility {
PRIVATE
PUBLIC PUBLIC
PRIVATE
} }
/// NoteType (Anki: models) - Defines fields and templates for notes enum CardType {
model NoteType { WORD
id Int @id @default(autoincrement()) PHRASE
name String SENTENCE
kind NoteKind @default(STANDARD)
css String @default("")
fields Json @default("[]")
templates Json @default("[]")
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
notes Note[]
@@index([userId])
@@map("note_types")
} }
/// Deck (Anki: decks) - Container for cards
model Deck { model Deck {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
desc String @default("") desc String @db.Text @default("")
userId String @map("user_id") userId String
visibility Visibility @default(PRIVATE) visibility Visibility @default(PRIVATE)
collapsed Boolean @default(false) createdAt DateTime @default(now())
conf Json @default("{}") updatedAt DateTime @updatedAt
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
cards Card[] cards Card[]
favorites DeckFavorite[] favorites DeckFavorite[]
@@ -158,13 +111,42 @@ model Deck {
@@map("decks") @@map("decks")
} }
/// DeckFavorite - Users can favorite public decks model Card {
id Int @id @default(autoincrement())
deckId Int
word String
ipa String?
queryLang String
cardType CardType @default(WORD)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
meanings CardMeaning[]
@@index([deckId])
@@index([word])
@@map("cards")
}
model CardMeaning {
id Int @id @default(autoincrement())
cardId Int
partOfSpeech String?
definition String
example String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
@@index([cardId])
@@map("card_meanings")
}
model DeckFavorite { model DeckFavorite {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId String @map("user_id") userId String
deckId Int @map("deck_id") deckId Int
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade) deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
@@ -174,156 +156,10 @@ model DeckFavorite {
@@map("deck_favorites") @@map("deck_favorites")
} }
/// Note (Anki: notes) - Contains field data, one note can have multiple cards
model Note {
id BigInt @id
guid String @unique
noteTypeId Int @map("note_type_id")
mod Int
usn Int @default(-1)
tags String @default(" ")
flds String
sfld String
csum Int
flags Int @default(0)
data String @default("")
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
noteType NoteType @relation(fields: [noteTypeId], references: [id], onDelete: Cascade)
cards Card[]
@@index([userId])
@@index([noteTypeId])
@@index([csum])
@@map("notes")
}
/// Card (Anki: cards) - Scheduling information, what you review
model Card {
id BigInt @id
noteId BigInt @map("note_id")
deckId Int @map("deck_id")
ord Int
mod Int
usn Int @default(-1)
type CardType @default(NEW)
queue CardQueue @default(NEW)
due Int
ivl Int @default(0)
factor Int @default(2500)
reps Int @default(0)
lapses Int @default(0)
left Int @default(0)
odue Int @default(0)
odid Int @default(0)
flags Int @default(0)
data String @default("")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
revlogs Revlog[]
@@index([noteId])
@@index([deckId])
@@index([deckId, queue, due])
@@map("cards")
}
/// Revlog (Anki: revlog) - Review history
model Revlog {
id BigInt @id
cardId BigInt @map("card_id")
usn Int @default(-1)
ease Int
ivl Int
lastIvl Int
factor Int
time Int
type Int
card Card @relation(fields: [cardId], references: [id], onDelete: Cascade)
@@index([cardId])
@@map("revlogs")
}
// ============================================ // ============================================
// Other Models // Social
// ============================================ // ============================================
model DictionaryLookUp {
id Int @id @default(autoincrement())
userId String? @map("user_id")
text String
queryLang String @map("query_lang")
definitionLang String @map("definition_lang")
createdAt DateTime @default(now()) @map("created_at")
dictionaryItemId Int? @map("dictionary_item_id")
normalizedText String @default("") @map("normalized_text")
dictionaryItem DictionaryItem? @relation(fields: [dictionaryItemId], references: [id], onDelete: SetNull)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([userId])
@@index([createdAt])
@@index([normalizedText])
@@map("dictionary_lookups")
}
model DictionaryItem {
id Int @id @default(autoincrement())
frequency Int @default(1)
standardForm String @map("standard_form")
queryLang String @map("query_lang")
definitionLang String @map("definition_lang")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
entries DictionaryEntry[]
lookups DictionaryLookUp[]
@@unique([standardForm, queryLang, definitionLang])
@@index([standardForm])
@@index([queryLang, definitionLang])
@@map("dictionary_items")
}
model DictionaryEntry {
id Int @id @default(autoincrement())
itemId Int @map("item_id")
ipa String?
definition String
partOfSpeech String? @map("part_of_speech")
example String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
item DictionaryItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
@@index([itemId])
@@index([createdAt])
@@map("dictionary_entries")
}
model TranslationHistory {
id Int @id @default(autoincrement())
userId String? @map("user_id")
sourceText String @map("source_text")
sourceLanguage String @map("source_language")
targetLanguage String @map("target_language")
translatedText String @map("translated_text")
sourceIpa String? @map("source_ipa")
targetIpa String? @map("target_ipa")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User? @relation(fields: [userId], references: [id])
@@index([userId])
@@index([createdAt])
@@index([sourceText, targetLanguage])
@@index([translatedText, sourceLanguage, targetLanguage])
@@map("translation_history")
}
model Follow { model Follow {
id String @id @default(cuid()) id String @id @default(cuid())
followerId String @map("follower_id") followerId String @map("follower_id")

View File

@@ -0,0 +1,147 @@
/**
* 查找缺失的翻译键
* 用法: npx tsx scripts/find-missing-translations.ts [locale]
*/
import * as fs from "fs";
import * as path from "path";
const SRC_DIR = "./src";
const MESSAGES_DIR = "./messages";
const ALL_LOCALES = ["en-US", "zh-CN", "ja-JP", "ko-KR", "de-DE", "fr-FR", "it-IT", "ug-CN"];
function parseString(s: string): string | null {
s = s.trim();
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1);
}
if (s.startsWith("`") && s.endsWith("`") && !s.includes("${")) {
return s.slice(1, -1);
}
return null;
}
function getBindings(content: string): Map<string, string> {
const bindings = new Map<string, string>();
const pattern = /(?:const|let|var)\s+(\w+)\s*=\s*(?:await\s+)?(?:useTranslations|getTranslations)\s*\(\s*([^)]*)\s*\)/g;
let match;
while ((match = pattern.exec(content)) !== null) {
const varName = match[1];
const arg = match[2].trim();
bindings.set(varName, arg ? parseString(arg) || "" : "__ROOT__");
}
return bindings;
}
function getUsages(content: string, file: string): { file: string; line: number; ns: string; key: string }[] {
const usages: { file: string; line: number; ns: string; key: string }[] = [];
const bindings = getBindings(content);
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
for (const [varName, ns] of bindings) {
const pattern = new RegExp(`\\b${varName}\\s*\\(\\s*("[^"]*"|'[^']*'|\`[^\`]*\`)(?:\\s*,|\\s*\\))`, "g");
let match;
while ((match = pattern.exec(line)) !== null) {
const key = parseString(match[1]);
if (key) usages.push({ file, line: i + 1, ns, key });
}
}
}
return usages;
}
function getFiles(dir: string): string[] {
const files: string[] = [];
if (!fs.existsSync(dir)) return files;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...getFiles(p));
else if (entry.isFile() && /\.(tsx?|ts)$/.test(entry.name)) files.push(p);
}
return files;
}
function keyExists(key: string, ns: string, trans: Record<string, unknown>): boolean {
let obj: unknown;
if (ns === "__ROOT__") {
obj = trans;
} else {
obj = trans[ns];
if (typeof obj !== "object" || obj === null) {
obj = trans;
for (const part of ns.split(".")) {
if (typeof obj !== "object" || obj === null) return false;
obj = (obj as Record<string, unknown>)[part];
}
}
}
if (typeof obj !== "object" || obj === null) return false;
for (const part of key.split(".")) {
if (typeof obj !== "object" || obj === null) return false;
obj = (obj as Record<string, unknown>)[part];
}
return typeof obj === "string";
}
function main() {
const locales = process.argv[2] ? [process.argv[2]] : ALL_LOCALES;
const files = getFiles(SRC_DIR);
const usages: { file: string; line: number; ns: string; key: string }[] = [];
for (const f of files) {
usages.push(...getUsages(fs.readFileSync(f, "utf-8"), f));
}
const unique = new Map<string, { file: string; line: number; ns: string; key: string }>();
for (const u of usages) {
unique.set(`${u.file}:${u.line}:${u.ns}:${u.key}`, u);
}
console.log(`Scanned ${files.length} files, ${unique.size} usages\n`);
for (const locale of locales) {
console.log(`\n${"=".repeat(50)}\nLocale: ${locale}\n${"=".repeat(50)}`);
const filePath = path.join(MESSAGES_DIR, `${locale}.json`);
if (!fs.existsSync(filePath)) {
console.log(`File not found: ${filePath}`);
continue;
}
const trans = JSON.parse(fs.readFileSync(filePath, "utf-8"));
const missing = Array.from(unique.values()).filter(u => !keyExists(u.key, u.ns, trans));
if (missing.length === 0) {
console.log("All translations exist!");
} else {
console.log(`\nMissing ${missing.length} translations:\n`);
const byFile = new Map<string, typeof missing>();
for (const u of missing) {
if (!byFile.has(u.file)) byFile.set(u.file, []);
byFile.get(u.file)!.push(u);
}
for (const [file, list] of byFile) {
console.log(file);
for (const u of list) {
console.log(` L${u.line} [${u.ns === "__ROOT__" ? "root" : u.ns}] ${u.key}`);
}
console.log();
}
}
}
console.log("\nDone!");
}
main();

View File

@@ -0,0 +1,154 @@
/**
* 查找多余的翻译键
* 用法: npx tsx scripts/find-unused-translations.ts [locale]
*/
import * as fs from "fs";
import * as path from "path";
const SRC_DIR = "./src";
const MESSAGES_DIR = "./messages";
const ALL_LOCALES = ["en-US", "zh-CN", "ja-JP", "ko-KR", "de-DE", "fr-FR", "it-IT", "ug-CN"];
function parseString(s: string): string | null {
s = s.trim();
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1);
}
if (s.startsWith("`") && s.endsWith("`") && !s.includes("${")) {
return s.slice(1, -1);
}
return null;
}
function getBindings(content: string): Map<string, string> {
const bindings = new Map<string, string>();
const pattern = /(?:const|let|var)\s+(\w+)\s*=\s*(?:await\s+)?(?:useTranslations|getTranslations)\s*\(\s*([^)]*)\s*\)/g;
let match;
while ((match = pattern.exec(content)) !== null) {
const varName = match[1];
const arg = match[2].trim();
bindings.set(varName, arg ? parseString(arg) || "" : "__ROOT__");
}
return bindings;
}
function getUsedKeys(content: string): Map<string, Set<string>> {
const used = new Map<string, Set<string>>();
const bindings = getBindings(content);
for (const [varName, ns] of bindings) {
const pattern = new RegExp(`\\b${varName}\\s*\\(\\s*("[^"]*"|'[^']*'|\`[^\`]*\`)(?:\\s*,|\\s*\\))`, "g");
let match;
while ((match = pattern.exec(content)) !== null) {
const key = parseString(match[1]);
if (key) {
if (!used.has(ns)) used.set(ns, new Set());
used.get(ns)!.add(key);
}
}
}
return used;
}
function getFiles(dir: string): string[] {
const files: string[] = [];
if (!fs.existsSync(dir)) return files;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...getFiles(p));
else if (entry.isFile() && /\.(tsx?|ts)$/.test(entry.name)) files.push(p);
}
return files;
}
function flattenKeys(obj: Record<string, unknown>, prefix = ""): string[] {
const keys: string[] = [];
for (const key of Object.keys(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof obj[key] === "object" && obj[key] !== null) {
keys.push(...flattenKeys(obj[key] as Record<string, unknown>, fullKey));
} else if (typeof obj[key] === "string") {
keys.push(fullKey);
}
}
return keys;
}
function isUsed(fullKey: string, used: Map<string, Set<string>>): boolean {
const parts = fullKey.split(".");
for (let i = 1; i < parts.length; i++) {
const ns = parts.slice(0, i).join(".");
const key = parts.slice(i).join(".");
const nsKeys = used.get(ns);
if (nsKeys) {
if (nsKeys.has(key)) return true;
for (const k of nsKeys) {
if (key.startsWith(k + ".")) return true;
}
}
}
const rootKeys = used.get("__ROOT__");
return rootKeys?.has(fullKey) ?? false;
}
function main() {
const locales = process.argv[2] ? [process.argv[2]] : ALL_LOCALES;
const files = getFiles(SRC_DIR);
const allUsed = new Map<string, Set<string>>();
for (const f of files) {
const used = getUsedKeys(fs.readFileSync(f, "utf-8"));
for (const [ns, keys] of used) {
if (!allUsed.has(ns)) allUsed.set(ns, new Set());
for (const k of keys) allUsed.get(ns)!.add(k);
}
}
console.log(`Scanned ${files.length} files, ${allUsed.size} namespaces\n`);
for (const locale of locales) {
console.log(`\n${"=".repeat(50)}\nLocale: ${locale}\n${"=".repeat(50)}`);
const filePath = path.join(MESSAGES_DIR, `${locale}.json`);
if (!fs.existsSync(filePath)) {
console.log(`File not found: ${filePath}`);
continue;
}
const trans = JSON.parse(fs.readFileSync(filePath, "utf-8"));
const allKeys = flattenKeys(trans);
const unused = allKeys.filter(k => !isUsed(k, allUsed));
console.log(`Total: ${allKeys.length} keys`);
if (unused.length === 0) {
console.log("No unused translations!");
} else {
console.log(`\n${unused.length} potentially unused:\n`);
const grouped = new Map<string, string[]>();
for (const k of unused) {
const [ns, ...rest] = k.split(".");
if (!grouped.has(ns)) grouped.set(ns, []);
grouped.get(ns)!.push(rest.join("."));
}
for (const [ns, keys] of grouped) {
console.log(`${ns}`);
for (const k of keys) console.log(` ${k}`);
console.log();
}
}
}
console.log("\nDone!");
}
main();

View File

@@ -7,19 +7,22 @@ import { useDictionaryStore } from "./stores/dictionaryStore";
import { PageLayout } from "@/components/ui/PageLayout"; import { PageLayout } from "@/components/ui/PageLayout";
import { LightButton } from "@/design-system/base/button"; import { LightButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input"; import { Input } from "@/design-system/base/input";
import { Select } from "@/design-system/base/select";
import { Skeleton } from "@/design-system/feedback/skeleton";
import { HStack, VStack } from "@/design-system/layout/stack";
import { Plus, RefreshCw } from "lucide-react"; import { Plus, RefreshCw } from "lucide-react";
import { DictionaryEntry } from "./DictionaryEntry"; import { DictionaryEntry } from "./DictionaryEntry";
import { LanguageSelector } from "./LanguageSelector"; import { LanguageSelector } from "./LanguageSelector";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { actionGetDecksByUserId } from "@/modules/deck/deck-action"; import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
import { actionCreateNote } from "@/modules/note/note-action";
import { actionCreateCard } from "@/modules/card/card-action"; import { actionCreateCard } from "@/modules/card/card-action";
import { actionGetNoteTypesByUserId, actionCreateDefaultBasicNoteType } from "@/modules/note-type/note-type-action"; import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
import type { TSharedDeck } from "@/shared/anki-type"; import type { CardType } from "@/modules/card/card-action-dto";
import { toast } from "sonner"; import { toast } from "sonner";
import { getNativeName } from "./stores/dictionaryStore";
interface DictionaryClientProps { interface DictionaryClientProps {
initialDecks: TSharedDeck[]; initialDecks: ActionOutputDeck[];
} }
export function DictionaryClient({ initialDecks }: DictionaryClientProps) { export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
@@ -42,8 +45,7 @@ export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
} = useDictionaryStore(); } = useDictionaryStore();
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
const [decks, setDecks] = useState<TSharedDeck[]>(initialDecks); const [decks, setDecks] = useState<ActionOutputDeck[]>(initialDecks);
const [defaultNoteTypeId, setDefaultNoteTypeId] = useState<number | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
useEffect(() => { useEffect(() => {
@@ -62,29 +64,7 @@ export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
if (session?.user?.id) { if (session?.user?.id) {
actionGetDecksByUserId(session.user.id).then((result) => { actionGetDecksByUserId(session.user.id).then((result) => {
if (result.success && result.data) { if (result.success && result.data) {
setDecks(result.data as TSharedDeck[]); setDecks(result.data);
}
});
}
}, [session?.user?.id]);
useEffect(() => {
if (session?.user?.id) {
actionGetNoteTypesByUserId().then(async (result) => {
if (result.success && result.data) {
const basicNoteType = result.data.find(
(nt) => nt.name === "Basic Vocabulary"
);
if (basicNoteType) {
setDefaultNoteTypeId(basicNoteType.id);
} else if (result.data.length > 0) {
setDefaultNoteTypeId(result.data[0].id);
} else {
const createResult = await actionCreateDefaultBasicNoteType();
if (createResult.success && createResult.data) {
setDefaultNoteTypeId(createResult.data.id);
}
}
} }
}); });
} }
@@ -113,11 +93,10 @@ export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
toast.error(t("pleaseCreateFolder")); toast.error(t("pleaseCreateFolder"));
return; return;
} }
if (!defaultNoteTypeId) { if (!searchResult?.entries?.length) {
toast.error("No note type available. Please try again."); toast.error("No dictionary item to save. Please search first.");
return; return;
} }
if (!searchResult?.entries?.length) return;
const deckSelect = document.getElementById("deck-select") as HTMLSelectElement; const deckSelect = document.getElementById("deck-select") as HTMLSelectElement;
const deckId = deckSelect?.value ? Number(deckSelect.value) : decks[0]?.id; const deckId = deckSelect?.value ? Number(deckSelect.value) : decks[0]?.id;
@@ -129,43 +108,38 @@ export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
setIsSaving(true); setIsSaving(true);
const definition = searchResult.entries
.map((e) => e.definition)
.join(" | ");
const ipa = searchResult.entries[0]?.ipa || "";
const example = searchResult.entries
.map((e) => e.example)
.filter(Boolean)
.join(" | ") || "";
try { try {
const noteResult = await actionCreateNote({ const hasIpa = searchResult.entries.some((e) => e.ipa);
noteTypeId: defaultNoteTypeId, const hasSpaces = searchResult.standardForm.includes(" ");
fields: [searchResult.standardForm, definition, ipa, example], let cardType: CardType = "WORD";
tags: ["dictionary"], if (!hasIpa) {
cardType = "SENTENCE";
} else if (hasSpaces) {
cardType = "PHRASE";
}
const ipa = searchResult.entries.find((e) => e.ipa)?.ipa || null;
const meanings = searchResult.entries.map((e) => ({
partOfSpeech: e.partOfSpeech || null,
definition: e.definition,
example: e.example || null,
}));
const cardResult = await actionCreateCard({
deckId,
word: searchResult.standardForm,
ipa,
queryLang: getNativeName(queryLang),
cardType,
meanings,
}); });
if (!noteResult.success || !noteResult.data) { if (!cardResult.success) {
toast.error(t("saveFailed")); toast.error(cardResult.message || t("saveFailed"));
setIsSaving(false); setIsSaving(false);
return; return;
} }
const noteId = BigInt(noteResult.data.id);
await actionCreateCard({
noteId,
deckId,
ord: 0,
});
await actionCreateCard({
noteId,
deckId,
ord: 1,
});
const deckName = decks.find((d) => d.id === deckId)?.name || "Unknown"; const deckName = decks.find((d) => d.id === deckId)?.name || "Unknown";
toast.success(t("savedToFolder", { folderName: deckName })); toast.success(t("savedToFolder", { folderName: deckName }));
} catch (error) { } catch (error) {
@@ -231,10 +205,10 @@ export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
<div className="mt-8"> <div className="mt-8">
{isSearching ? ( {isSearching ? (
<div className="text-center py-12"> <VStack align="center" className="py-12">
<div className="w-8 h-8 border-2 border-gray-200 border-t-primary-500 rounded-full animate-spin mx-auto mb-3"></div> <Skeleton variant="circular" className="w-8 h-8 mb-3" />
<p className="text-gray-600">{t("searching")}</p> <p className="text-gray-600">{t("searching")}</p>
</div> </VStack>
) : query && !searchResult ? ( ) : query && !searchResult ? (
<div className="text-center py-12 bg-white/20 rounded-lg"> <div className="text-center py-12 bg-white/20 rounded-lg">
<p className="text-gray-800 text-xl">{t("noResults")}</p> <p className="text-gray-800 text-xl">{t("noResults")}</p>
@@ -248,18 +222,19 @@ export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
{searchResult.standardForm} {searchResult.standardForm}
</h2> </h2>
</div> </div>
<div className="flex items-center gap-2 ml-4"> <HStack align="center" gap={2} className="ml-4">
{session && decks.length > 0 && ( {session && decks.length > 0 && (
<select <Select
id="deck-select" id="deck-select"
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[#35786f]" variant="bordered"
size="sm"
> >
{decks.map((deck) => ( {decks.map((deck) => (
<option key={deck.id} value={deck.id}> <option key={deck.id} value={deck.id}>
{deck.name} {deck.name}
</option> </option>
))} ))}
</select> </Select>
)} )}
<LightButton <LightButton
onClick={handleSave} onClick={handleSave}
@@ -270,7 +245,7 @@ export function DictionaryClient({ initialDecks }: DictionaryClientProps) {
> >
<Plus /> <Plus />
</LightButton> </LightButton>
</div> </HStack>
</div> </div>
<div className="space-y-6"> <div className="space-y-6">

View File

@@ -2,17 +2,17 @@ import { DictionaryClient } from "./DictionaryClient";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { actionGetDecksByUserId } from "@/modules/deck/deck-action"; import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
import type { TSharedDeck } from "@/shared/anki-type"; import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
export default async function DictionaryPage() { export default async function DictionaryPage() {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
let decks: TSharedDeck[] = []; let decks: ActionOutputDeck[] = [];
if (session?.user?.id) { if (session?.user?.id) {
const result = await actionGetDecksByUserId(session.user.id as string); const result = await actionGetDecksByUserId(session.user.id as string);
if (result.success && result.data) { if (result.success && result.data) {
decks = result.data as TSharedDeck[]; decks = result.data;
} }
} }

View File

@@ -7,6 +7,9 @@ import {
ArrowUpDown, ArrowUpDown,
} from "lucide-react"; } from "lucide-react";
import { CircleButton } from "@/design-system/base/button"; import { CircleButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { Skeleton } from "@/design-system/feedback/skeleton";
import { HStack } from "@/design-system/layout/stack";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -148,18 +151,16 @@ export function ExploreClient({ initialPublicDecks }: ExploreClientProps) {
<PageLayout> <PageLayout>
<PageHeader title={t("title")} subtitle={t("subtitle")} /> <PageHeader title={t("title")} subtitle={t("subtitle")} />
<div className="flex items-center gap-2 mb-6"> <HStack align="center" gap={2} className="mb-6">
<div className="relative flex-1"> <Input
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" /> variant="bordered"
<input
type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()} onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder={t("searchPlaceholder")} placeholder={t("searchPlaceholder")}
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" leftIcon={<Search size={18} />}
containerClassName="flex-1"
/> />
</div>
<CircleButton <CircleButton
onClick={handleToggleSort} onClick={handleToggleSort}
title={sortByFavorites ? t("sortByFavoritesActive") : t("sortByFavorites")} title={sortByFavorites ? t("sortByFavoritesActive") : t("sortByFavorites")}
@@ -170,11 +171,11 @@ export function ExploreClient({ initialPublicDecks }: ExploreClientProps) {
<CircleButton onClick={handleSearch}> <CircleButton onClick={handleSearch}>
<Search size={18} /> <Search size={18} />
</CircleButton> </CircleButton>
</div> </HStack>
{loading ? ( {loading ? (
<div className="p-8 text-center"> <div className="p-8 text-center">
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div> <Skeleton variant="circular" className="w-8 h-8 mx-auto mb-3" />
<p className="text-sm text-gray-500">{t("loading")}</p> <p className="text-sm text-gray-500">{t("loading")}</p>
</div> </div>
) : sortedDecks.length === 0 ? ( ) : sortedDecks.length === 0 ? (

View File

@@ -12,6 +12,8 @@ import { toast } from "sonner";
import { PageLayout } from "@/components/ui/PageLayout"; import { PageLayout } from "@/components/ui/PageLayout";
import { PageHeader } from "@/components/ui/PageHeader"; import { PageHeader } from "@/components/ui/PageHeader";
import { CardList } from "@/components/ui/CardList"; import { CardList } from "@/components/ui/CardList";
import { VStack } from "@/design-system/layout/stack";
import { Skeleton } from "@/design-system/feedback/skeleton";
import { actionGetUserFavoriteDecks, actionToggleDeckFavorite } from "@/modules/deck/deck-action"; import { actionGetUserFavoriteDecks, actionToggleDeckFavorite } from "@/modules/deck/deck-action";
import type { ActionOutputUserFavoriteDeck } from "@/modules/deck/deck-action-dto"; import type { ActionOutputUserFavoriteDeck } from "@/modules/deck/deck-action-dto";
@@ -102,10 +104,10 @@ export function FavoritesClient({ initialFavorites }: FavoritesClientProps) {
<CardList> <CardList>
{loading ? ( {loading ? (
<div className="p-8 text-center"> <VStack align="center" className="p-8">
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div> <Skeleton variant="circular" className="w-8 h-8" />
<p className="text-sm text-gray-500">{t("loading")}</p> <p className="text-sm text-gray-500">{t("loading")}</p>
</div> </VStack>
) : favorites.length === 0 ? ( ) : favorites.length === 0 ? (
<div className="text-center py-12 text-gray-400"> <div className="text-center py-12 text-gray-400">
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center"> <div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">

View File

@@ -1,114 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { Layers } from "lucide-react";
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
import type { ActionOutputCardStats } from "@/modules/card/card-action-dto";
import { PageLayout } from "@/components/ui/PageLayout";
import { PrimaryButton } from "@/design-system/base/button";
interface DeckWithStats extends ActionOutputDeck {
stats?: ActionOutputCardStats;
}
interface DeckSelectorProps {
decks: ActionOutputDeck[];
deckStats: Map<number, ActionOutputCardStats | undefined>;
}
const DeckSelector: React.FC<DeckSelectorProps> = ({ decks, deckStats }) => {
const t = useTranslations("memorize.deck_selector");
const router = useRouter();
const formatCardStats = (stats: ActionOutputCardStats | undefined): string => {
if (!stats) return t("noCards");
const parts: string[] = [];
if (stats.new > 0) parts.push(`${t("new")}: ${stats.new}`);
if (stats.learning > 0) parts.push(`${t("learning")}: ${stats.learning}`);
if (stats.review > 0) parts.push(`${t("review")}: ${stats.review}`);
if (stats.due > 0) parts.push(`${t("due")}: ${stats.due}`);
return parts.length > 0 ? parts.join(" • ") : t("noCards");
};
const getDueCount = (deckId: number): number => {
const stats = deckStats.get(deckId);
return stats?.due ?? 0;
};
return (
<PageLayout>
{decks.length === 0 ? (
<div className="text-center">
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-4">
{t("noDecks")}
</h1>
<Link href="/decks">
<PrimaryButton className="px-6 py-2">
{t("goToDecks")}
</PrimaryButton>
</Link>
</div>
) : (
<>
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-6">
{t("selectDeck")}
</h1>
<div className="border border-gray-200 rounded-lg max-h-96 overflow-y-auto">
{decks
.toSorted((a, b) => a.id - b.id)
.map((deck) => {
const stats = deckStats.get(deck.id);
const dueCount = getDueCount(deck.id);
return (
<div
key={deck.id}
onClick={() =>
router.push(`/memorize?deck_id=${deck.id}`)
}
className="flex flex-row items-center p-4 gap-3 hover:cursor-pointer hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0"
>
<div className="shrink-0">
<Layers className="text-gray-600 w-5 h-5" />
</div>
<div className="flex-1">
<div className="font-medium text-gray-900">
{deck.name}
</div>
<div className="text-sm text-gray-500">
{formatCardStats(stats)}
</div>
</div>
{dueCount > 0 && (
<div className="bg-blue-500 text-white text-xs font-bold px-2 py-1 rounded-full">
{dueCount}
</div>
)}
<div className="text-gray-400">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>
);
})}
</div>
</>
)}
</PageLayout>
);
};
export { DeckSelector };

View File

@@ -1,371 +0,0 @@
"use client";
import { useState, useEffect, useTransition, useCallback } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import localFont from "next/font/local";
import { Layers, Check, Clock, Sparkles } from "lucide-react";
import type { ActionOutputCardWithNote, ActionOutputScheduledCard } from "@/modules/card/card-action-dto";
import { actionGetCardsForReview, actionAnswerCard } from "@/modules/card/card-action";
import { PageLayout } from "@/components/ui/PageLayout";
import { LightButton } from "@/design-system/base/button";
import { CardType } from "../../../../generated/prisma/enums";
import { calculatePreviewIntervals, formatPreviewInterval, type CardPreview } from "./interval-preview";
const myFont = localFont({
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
});
interface MemorizeProps {
deckId: number;
deckName: string;
}
type ReviewEase = 1 | 2 | 3 | 4;
const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
const t = useTranslations("memorize.review");
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [cards, setCards] = useState<ActionOutputCardWithNote[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [showAnswer, setShowAnswer] = useState(false);
const [lastScheduled, setLastScheduled] = useState<ActionOutputScheduledCard | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let ignore = false;
const loadCards = async () => {
setIsLoading(true);
setError(null);
startTransition(async () => {
const result = await actionGetCardsForReview({ deckId, limit: 50 });
if (!ignore) {
if (result.success && result.data) {
setCards(result.data);
setCurrentIndex(0);
setShowAnswer(false);
setLastScheduled(null);
} else {
setError(result.message);
}
setIsLoading(false);
}
});
};
loadCards();
return () => {
ignore = true;
};
}, [deckId]);
const getCurrentCard = (): ActionOutputCardWithNote | null => {
return cards[currentIndex] ?? null;
};
const getNoteFields = (card: ActionOutputCardWithNote): string[] => {
return card.note.flds.split('\x1f');
};
const handleShowAnswer = useCallback(() => {
setShowAnswer(true);
}, []);
const handleAnswer = useCallback((ease: ReviewEase) => {
const card = getCurrentCard();
if (!card) return;
startTransition(async () => {
const result = await actionAnswerCard({
cardId: BigInt(card.id),
ease,
});
if (result.success && result.data) {
setLastScheduled(result.data.scheduled);
const remainingCards = cards.filter((_, idx) => idx !== currentIndex);
setCards(remainingCards);
if (remainingCards.length === 0) {
setCurrentIndex(0);
} else if (currentIndex >= remainingCards.length) {
setCurrentIndex(remainingCards.length - 1);
}
setShowAnswer(false);
} else {
setError(result.message);
}
});
}, [cards, currentIndex]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
if (!showAnswer) {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
handleShowAnswer();
}
} else {
if (e.key === "1") {
e.preventDefault();
handleAnswer(1);
} else if (e.key === "2") {
e.preventDefault();
handleAnswer(2);
} else if (e.key === "3" || e.key === " " || e.key === "Enter") {
e.preventDefault();
handleAnswer(3);
} else if (e.key === "4") {
e.preventDefault();
handleAnswer(4);
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [showAnswer, handleShowAnswer, handleAnswer]);
const formatNextReview = (scheduled: ActionOutputScheduledCard): string => {
const now = new Date();
const nextReview = new Date(scheduled.nextReviewDate);
const diffMs = nextReview.getTime() - now.getTime();
if (diffMs < 0) return t("now");
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return t("lessThanMinute");
if (diffMins < 60) return t("inMinutes", { count: diffMins });
if (diffHours < 24) return t("inHours", { count: diffHours });
if (diffDays < 30) return t("inDays", { count: diffDays });
return t("inMonths", { count: Math.floor(diffDays / 30) });
};
const formatInterval = (ivl: number): string => {
if (ivl < 1) return t("minutes");
if (ivl < 30) return t("days", { count: ivl });
return t("months", { count: Math.floor(ivl / 30) });
};
const getCardTypeLabel = (type: CardType): string => {
switch (type) {
case CardType.NEW:
return t("cardTypeNew");
case CardType.LEARNING:
return t("cardTypeLearning");
case CardType.REVIEW:
return t("cardTypeReview");
case CardType.RELEARNING:
return t("cardTypeRelearning");
default:
return "";
}
};
const getCardTypeColor = (type: CardType): string => {
switch (type) {
case CardType.NEW:
return "bg-blue-100 text-blue-700";
case CardType.LEARNING:
return "bg-yellow-100 text-yellow-700";
case CardType.REVIEW:
return "bg-green-100 text-green-700";
case CardType.RELEARNING:
return "bg-purple-100 text-purple-700";
default:
return "bg-gray-100 text-gray-700";
}
};
if (isLoading) {
return (
<PageLayout>
<div className="text-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 mx-auto mb-4"></div>
<p className="text-gray-600">{t("loading")}</p>
</div>
</PageLayout>
);
}
if (error) {
return (
<PageLayout>
<div className="text-center py-12">
<p className="text-red-600 mb-4">{error}</p>
<LightButton onClick={() => router.push("/memorize")} className="px-4 py-2">
{t("backToDecks")}
</LightButton>
</div>
</PageLayout>
);
}
if (cards.length === 0) {
return (
<PageLayout>
<div className="text-center py-12">
<div className="text-green-500 mb-4">
<Check className="w-16 h-16 mx-auto" />
</div>
<h2 className="text-2xl font-bold text-gray-800 mb-2">{t("allDone")}</h2>
<p className="text-gray-600 mb-6">{t("allDoneDesc")}</p>
<LightButton onClick={() => router.push("/memorize")} className="px-4 py-2">
{t("backToDecks")}
</LightButton>
</div>
</PageLayout>
);
}
const currentCard = getCurrentCard()!;
const fields = getNoteFields(currentCard);
const front = fields[0] ?? "";
const back = fields[1] ?? "";
const cardPreview: CardPreview = {
type: currentCard.type,
ivl: currentCard.ivl,
factor: currentCard.factor,
left: currentCard.left,
};
const previewIntervals = calculatePreviewIntervals(cardPreview);
return (
<PageLayout>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2 text-gray-600">
<Layers className="w-5 h-5" />
<span className="font-medium">{deckName}</span>
</div>
<div className="flex items-center gap-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${getCardTypeColor(currentCard.type)}`}>
{getCardTypeLabel(currentCard.type)}
</span>
<span className="text-sm text-gray-500">
{t("progress", { current: currentIndex + 1, total: cards.length + currentIndex })}
</span>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2 mb-6">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${Math.max(0, ((currentIndex) / (cards.length + currentIndex)) * 100)}%` }}
/>
</div>
{lastScheduled && (
<div className="mb-4 p-3 bg-gray-50 rounded-lg text-sm text-gray-600">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
<span>
{t("nextReview")}: {formatNextReview(lastScheduled)}
</span>
</div>
</div>
)}
<div className={`bg-white border border-gray-200 rounded-xl shadow-sm mb-6 ${myFont.className}`}>
<div className="p-8 min-h-[20dvh] flex items-center justify-center">
<div className="text-gray-900 text-xl md:text-2xl text-center">
{front}
</div>
</div>
{showAnswer && (
<>
<div className="border-t border-gray-200" />
<div className="p-8 min-h-[20dvh] flex items-center justify-center bg-gray-50 rounded-b-xl">
<div className="text-gray-900 text-xl md:text-2xl text-center">
{back}
</div>
</div>
</>
)}
</div>
<div className="flex justify-center gap-4 mb-6 text-sm text-gray-500">
<span>{t("interval")}: {formatInterval(currentCard.ivl)}</span>
<span></span>
<span>{t("ease")}: {currentCard.factor / 10}%</span>
<span></span>
<span>{t("lapses")}: {currentCard.lapses}</span>
</div>
<div className="flex justify-center">
{!showAnswer ? (
<LightButton
onClick={handleShowAnswer}
disabled={isPending}
className="px-8 py-3 text-lg rounded-full"
>
{t("showAnswer")}
<span className="ml-2 text-xs opacity-60">Space</span>
</LightButton>
) : (
<div className="flex flex-wrap justify-center gap-3">
<button
onClick={() => handleAnswer(1)}
disabled={isPending}
className="flex flex-col items-center px-5 py-3 rounded-xl bg-red-100 hover:bg-red-200 text-red-700 transition-colors disabled:opacity-50 min-w-[80px]"
>
<span className="font-medium">{t("again")}</span>
<span className="text-xs opacity-75">{formatPreviewInterval(previewIntervals.again)}</span>
<span className="text-xs opacity-50 mt-1">1</span>
</button>
<button
onClick={() => handleAnswer(2)}
disabled={isPending}
className="flex flex-col items-center px-5 py-3 rounded-xl bg-orange-100 hover:bg-orange-200 text-orange-700 transition-colors disabled:opacity-50 min-w-[80px]"
>
<span className="font-medium">{t("hard")}</span>
<span className="text-xs opacity-75">{formatPreviewInterval(previewIntervals.hard)}</span>
<span className="text-xs opacity-50 mt-1">2</span>
</button>
<button
onClick={() => handleAnswer(3)}
disabled={isPending}
className="flex flex-col items-center px-5 py-3 rounded-xl bg-green-100 hover:bg-green-200 text-green-700 transition-colors disabled:opacity-50 min-w-[80px] ring-2 ring-green-300"
>
<div className="flex items-center gap-1">
<span className="font-medium">{t("good")}</span>
<Sparkles className="w-3 h-3 opacity-60" />
</div>
<span className="text-xs opacity-75">{formatPreviewInterval(previewIntervals.good)}</span>
<span className="text-xs opacity-50 mt-1">3/Space</span>
</button>
<button
onClick={() => handleAnswer(4)}
disabled={isPending}
className="flex flex-col items-center px-5 py-3 rounded-xl bg-blue-100 hover:bg-blue-200 text-blue-700 transition-colors disabled:opacity-50 min-w-[80px]"
>
<span className="font-medium">{t("easy")}</span>
<span className="text-xs opacity-75">{formatPreviewInterval(previewIntervals.easy)}</span>
<span className="text-xs opacity-50 mt-1">4</span>
</button>
</div>
)}
</div>
</PageLayout>
);
};
export { Memorize };

View File

@@ -1,85 +0,0 @@
import { CardType } from "../../../../generated/prisma/enums";
import { SM2_CONFIG } from "@/modules/card/card-service-dto";
export interface CardPreview {
type: CardType;
ivl: number;
factor: number;
left: number;
}
export interface PreviewIntervals {
again: number;
hard: number;
good: number;
easy: number;
}
function calculateReviewIntervals(ivl: number, factor: number): PreviewIntervals {
const MINUTES_PER_DAY = 1440;
return {
again: Math.max(1, Math.floor(ivl * SM2_CONFIG.NEW_INTERVAL)) * MINUTES_PER_DAY,
hard: Math.floor(ivl * SM2_CONFIG.HARD_INTERVAL * SM2_CONFIG.INTERVAL_MODIFIER) * MINUTES_PER_DAY,
good: Math.floor(ivl * (factor / 1000) * SM2_CONFIG.INTERVAL_MODIFIER) * MINUTES_PER_DAY,
easy: Math.floor(ivl * (factor / 1000) * SM2_CONFIG.EASY_BONUS * SM2_CONFIG.INTERVAL_MODIFIER) * MINUTES_PER_DAY,
};
}
function calculateNewCardIntervals(): PreviewIntervals {
const steps = SM2_CONFIG.LEARNING_STEPS;
return {
again: steps[0],
hard: steps.length >= 2 ? (steps[0] + steps[1]) / 2 : steps[0],
good: steps.length >= 2 ? steps[1] : SM2_CONFIG.GRADUATING_INTERVAL_GOOD * 1440,
easy: SM2_CONFIG.EASY_INTERVAL * 1440,
};
}
function calculateLearningIntervals(left: number, isRelearning: boolean): PreviewIntervals {
const steps = isRelearning ? SM2_CONFIG.RELEARNING_STEPS : SM2_CONFIG.LEARNING_STEPS;
const stepIndex = Math.floor(left % 1000);
const again = steps[0] ?? 1;
let hard: number;
if (stepIndex === 0 && steps.length >= 2) {
const step0 = steps[0] ?? 1;
const step1 = steps[1] ?? step0;
hard = (step0 + step1) / 2;
} else {
hard = steps[stepIndex] ?? steps[0] ?? 1;
}
let good: number;
if (stepIndex < steps.length - 1) {
good = steps[stepIndex + 1] ?? steps[0] ?? 1;
} else {
good = SM2_CONFIG.GRADUATING_INTERVAL_GOOD * 1440;
}
const easy = SM2_CONFIG.GRADUATING_INTERVAL_EASY * 1440;
return { again, hard, good, easy };
}
export function calculatePreviewIntervals(card: CardPreview): PreviewIntervals {
switch (card.type) {
case CardType.NEW:
return calculateNewCardIntervals();
case CardType.LEARNING:
return calculateLearningIntervals(card.left, false);
case CardType.RELEARNING:
return calculateLearningIntervals(card.left, true);
case CardType.REVIEW:
default:
return calculateReviewIntervals(card.ivl, card.factor);
}
}
export function formatPreviewInterval(minutes: number): string {
if (minutes < 1) return "<1";
if (minutes < 60) return `${Math.round(minutes)}`;
if (minutes < 1440) return `${Math.round(minutes / 60)}h`;
return `${Math.round(minutes / 1440)}d`;
}

View File

@@ -1,57 +0,0 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { isNonNegativeInteger } from "@/utils/random";
import { DeckSelector } from "./DeckSelector";
import { Memorize } from "./Memorize";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
import { actionGetCardStats } from "@/modules/card/card-action";
export default async function MemorizePage({
searchParams,
}: {
searchParams: Promise<{ deck_id?: string; }>;
}) {
const deckIdParam = (await searchParams).deck_id;
const t = await getTranslations("memorize.page");
const deckId = deckIdParam
? isNonNegativeInteger(deckIdParam)
? parseInt(deckIdParam)
: null
: null;
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login?redirect=/memorize");
if (!deckId) {
const decksResult = await actionGetDecksByUserId(session.user.id);
const decks = decksResult.data ?? [];
const deckStats = new Map<number, Awaited<ReturnType<typeof actionGetCardStats>>["data"]>();
for (const deck of decks) {
const statsResult = await actionGetCardStats({ deckId: deck.id });
if (statsResult.success && statsResult.data) {
deckStats.set(deck.id, statsResult.data);
}
}
return (
<DeckSelector
decks={decks}
deckStats={deckStats}
/>
);
}
const decksResult = await actionGetDecksByUserId(session.user.id);
const deck = decksResult.data?.find(d => d.id === deckId);
if (!deck) {
redirect("/memorize");
}
return <Memorize deckId={deckId} deckName={deck.name} />;
}

View File

@@ -1,286 +0,0 @@
"use client";
import { useState, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import { PageLayout } from "@/components/ui/PageLayout";
import { PrimaryButton, LightButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { Select } from "@/design-system/base/select";
import { Card } from "@/design-system/base/card";
import { toast } from "sonner";
import { Upload, FileImage, Loader2 } from "lucide-react";
import { actionProcessOCR } from "@/modules/ocr/ocr-action";
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
interface ActionOutputProcessOCR {
success: boolean;
message: string;
data?: {
pairsCreated: number;
sourceLanguage?: string;
targetLanguage?: string;
};
}
interface OCRClientProps {
initialDecks: ActionOutputDeck[];
}
export function OCRClient({ initialDecks }: OCRClientProps) {
const t = useTranslations("ocr");
const fileInputRef = useRef<HTMLInputElement>(null);
const [decks, setDecks] = useState<ActionOutputDeck[]>(initialDecks);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [selectedDeckId, setSelectedDeckId] = useState<number | null>(
initialDecks.length > 0 ? initialDecks[0].id : null
);
const [sourceLanguage, setSourceLanguage] = useState<string>("");
const [targetLanguage, setTargetLanguage] = useState<string>("");
const [isProcessing, setIsProcessing] = useState(false);
const [ocrResult, setOcrResult] = useState<ActionOutputProcessOCR | null>(null);
const handleFileChange = useCallback((file: File | null) => {
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error(t("invalidFileType"));
return;
}
const url = URL.createObjectURL(file);
setPreviewUrl(url);
setSelectedFile(file);
setOcrResult(null);
}, [t]);
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
handleFileChange(file);
}, [handleFileChange]);
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
}, []);
const fileToBase64 = async (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
const base64 = result.split(",")[1];
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
const handleProcess = async () => {
if (!selectedFile) {
toast.error(t("noImage"));
return;
}
if (!selectedDeckId) {
toast.error(t("noDeck"));
return;
}
setIsProcessing(true);
setOcrResult(null);
try {
const base64 = await fileToBase64(selectedFile);
const result = await actionProcessOCR({
imageBase64: base64,
deckId: selectedDeckId,
sourceLanguage: sourceLanguage || undefined,
targetLanguage: targetLanguage || undefined,
});
if (result.success && result.data) {
setOcrResult(result);
const deckName = decks.find(d => d.id === selectedDeckId)?.name || "";
toast.success(t("ocrSuccess", { count: result.data.pairsCreated, deck: deckName }));
} else {
toast.error(result.message || t("ocrFailed"));
}
} catch {
toast.error(t("processingFailed"));
} finally {
setIsProcessing(false);
}
};
const handleSave = async () => {
if (!ocrResult || !selectedDeckId) {
toast.error(t("noResultsToSave"));
return;
}
try {
const deckName = decks.find(d => d.id === selectedDeckId)?.name || "Unknown";
toast.success(t("savedToDeck", { deckName }));
} catch (error) {
toast.error(t("saveFailed"));
}
};
const clearImage = () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setPreviewUrl(null);
setSelectedFile(null);
setOcrResult(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
return (
<PageLayout variant="centered-card">
<div className="text-center mb-8">
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
{t("title")}
</h1>
<p className="text-gray-700 text-lg">
{t("description")}
</p>
</div>
<Card variant="bordered" padding="lg">
<div className="space-y-6">
{/* Upload Section */}
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-4">
{t("uploadSection")}
</h2>
<div
onClick={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-primary-500 hover:bg-primary-50 transition-colors"
>
{previewUrl ? (
<div className="space-y-4">
<img
src={previewUrl}
alt="Preview"
className="mx-auto max-w-full h-64 object-contain rounded-lg"
/>
<p className="text-gray-600">{t("changeImage")}</p>
</div>
) : (
<div className="space-y-4">
<Upload className="mx-auto h-12 w-12 text-gray-400" />
<p className="text-gray-600">{t("dropOrClick")}</p>
<p className="text-sm text-gray-500">{t("supportedFormats")}</p>
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={(e) => handleFileChange(e.target.files?.[0] || null)}
className="hidden"
/>
</div>
{/* Deck Selection */}
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-4">
{t("deckSelection")}
</h2>
<Select
value={selectedDeckId?.toString() || ""}
onChange={(e) => setSelectedDeckId(Number(e.target.value))}
className="w-full"
>
<option value="">{t("selectDeck")}</option>
{decks.map((deck) => (
<option key={deck.id} value={deck.id}>
{deck.name}
</option>
))}
</Select>
</div>
{/* Language Hints */}
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-4">
{t("languageHints")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
type="text"
placeholder={t("sourceLanguagePlaceholder")}
value={sourceLanguage}
onChange={(e) => setSourceLanguage(e.target.value)}
className="w-full"
/>
<Input
type="text"
placeholder={t("targetLanguagePlaceholder")}
value={targetLanguage}
onChange={(e) => setTargetLanguage(e.target.value)}
className="w-full"
/>
</div>
</div>
{/* Process Button */}
<div className="flex justify-center">
<PrimaryButton
onClick={handleProcess}
disabled={!selectedFile || !selectedDeckId || isProcessing}
loading={isProcessing}
className="px-8 py-3 text-lg"
>
{t("processButton")}
</PrimaryButton>
</div>
{/* Results Preview */}
{ocrResult && ocrResult.data && (
<div>
<h2 className="text-xl font-semibold text-gray-800 mb-4">
{t("resultsPreview")}
</h2>
<div className="bg-gray-50 rounded-lg p-4">
<div className="space-y-3">
<div className="text-center py-4">
<p className="text-gray-800">{t("extractedPairs", { count: ocrResult.data.pairsCreated })}</p>
</div>
</div>
{ocrResult.data.sourceLanguage && (
<div className="mt-4 text-sm text-gray-500">
{t("detectedSourceLanguage")}: {ocrResult.data.sourceLanguage}
</div>
)}
{ocrResult.data.targetLanguage && (
<div className="mt-1 text-sm text-gray-500">
{t("detectedTargetLanguage")}: {ocrResult.data.targetLanguage}
</div>
)}
</div>
<div className="mt-4 flex justify-center">
<LightButton
onClick={handleSave}
disabled={!selectedDeckId}
className="px-6 py-2"
>
{t("saveButton")}
</LightButton>
</div>
</div>
)}
</div>
</Card>
</PageLayout>
);
}

View File

@@ -1,20 +0,0 @@
import { OCRClient } from "./OCRClient";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
export default async function OCRPage() {
const session = await auth.api.getSession({ headers: await headers() });
let decks: ActionOutputDeck[] = [];
if (session?.user?.id) {
const result = await actionGetDecksByUserId(session.user.id as string);
if (result.success && result.data) {
decks = result.data;
}
}
return <OCRClient initialDecks={decks} />;
}

View File

@@ -70,7 +70,7 @@ export default function SrtPlayerPage() {
uploadVideo((url) => { uploadVideo((url) => {
setVideoUrl(url); setVideoUrl(url);
}, (error) => { }, (error) => {
toast.error(t('videoUploadFailed') + ': ' + error.message); toast.error(srtT('videoUploadFailed') + ': ' + error.message);
}); });
}; };
@@ -78,7 +78,7 @@ export default function SrtPlayerPage() {
uploadSubtitle((url) => { uploadSubtitle((url) => {
setSubtitleUrl(url); setSubtitleUrl(url);
}, (error) => { }, (error) => {
toast.error(t('subtitleUploadFailed') + ': ' + error.message); toast.error(srtT('subtitleUploadFailed') + ': ' + error.message);
}); });
}; };

View File

@@ -1,7 +1,8 @@
"use client"; "use client";
import { LightButton } from "@/design-system/base/button"; import { LightButton, IconClick } from "@/design-system/base/button";
import { IconClick } from "@/design-system/base/button"; import { Input } from "@/design-system/base/input";
import { Textarea } from "@/design-system/base/textarea";
import { IMAGES } from "@/config/images"; import { IMAGES } from "@/config/images";
import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { import {
@@ -18,6 +19,38 @@ import { genIPA, genLanguage } from "@/modules/translator/translator-action";
import { PageLayout } from "@/components/ui/PageLayout"; import { PageLayout } from "@/components/ui/PageLayout";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts"; import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
const TTS_LANGUAGES = [
{ value: "Auto", label: "auto" },
{ value: "Chinese", label: "chinese" },
{ value: "English", label: "english" },
{ value: "Japanese", label: "japanese" },
{ value: "Korean", label: "korean" },
{ value: "French", label: "french" },
{ value: "German", label: "german" },
{ value: "Italian", label: "italian" },
{ value: "Spanish", label: "spanish" },
{ value: "Portuguese", label: "portuguese" },
{ value: "Russian", label: "russian" },
] as const;
type TTSLabel = typeof TTS_LANGUAGES[number]["label"];
function getLanguageLabel(t: (key: string) => string, label: TTSLabel): string {
switch (label) {
case "auto": return t("languages.auto");
case "chinese": return t("languages.chinese");
case "english": return t("languages.english");
case "japanese": return t("languages.japanese");
case "korean": return t("languages.korean");
case "french": return t("languages.french");
case "german": return t("languages.german");
case "italian": return t("languages.italian");
case "spanish": return t("languages.spanish");
case "portuguese": return t("languages.portuguese");
case "russian": return t("languages.russian");
}
}
export default function TextSpeakerPage() { export default function TextSpeakerPage() {
const t = useTranslations("text_speaker"); const t = useTranslations("text_speaker");
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -30,6 +63,8 @@ export default function TextSpeakerPage() {
const [autopause, setAutopause] = useState(true); const [autopause, setAutopause] = useState(true);
const textRef = useRef(""); const textRef = useRef("");
const [language, setLanguage] = useState<string | null>(null); const [language, setLanguage] = useState<string | null>(null);
const [selectedLanguage, setSelectedLanguage] = useState<string>("Auto");
const [customLanguage, setCustomLanguage] = useState<string>("");
const [ipa, setIPA] = useState<string>(""); const [ipa, setIPA] = useState<string>("");
const objurlRef = useRef<string | null>(null); const objurlRef = useRef<string | null>(null);
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
@@ -93,8 +128,15 @@ export default function TextSpeakerPage() {
} else { } else {
// 第一次播放 // 第一次播放
try { try {
let theLanguage = language; let theLanguage: string;
if (!theLanguage) {
if (customLanguage.trim()) {
theLanguage = customLanguage.trim();
} else if (selectedLanguage !== "Auto") {
theLanguage = selectedLanguage;
} else if (language) {
theLanguage = language;
} else {
const tmp_language = await genLanguage(textRef.current.slice(0, 30)); const tmp_language = await genLanguage(textRef.current.slice(0, 30));
setLanguage(tmp_language); setLanguage(tmp_language);
theLanguage = tmp_language; theLanguage = tmp_language;
@@ -102,7 +144,6 @@ export default function TextSpeakerPage() {
theLanguage = theLanguage.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase()); theLanguage = theLanguage.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase());
// 检查语言是否在 TTS 支持列表中
const supportedLanguages: TTS_SUPPORTED_LANGUAGES[] = [ const supportedLanguages: TTS_SUPPORTED_LANGUAGES[] = [
"Auto", "Chinese", "English", "German", "Italian", "Portuguese", "Auto", "Chinese", "English", "German", "Italian", "Portuguese",
"Spanish", "Japanese", "Korean", "French", "Russian" "Spanish", "Japanese", "Korean", "French", "Russian"
@@ -138,6 +179,8 @@ export default function TextSpeakerPage() {
const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => { const handleInputChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
textRef.current = e.target.value.trim(); textRef.current = e.target.value.trim();
setLanguage(null); setLanguage(null);
setSelectedLanguage("Auto");
setCustomLanguage("");
setIPA(""); setIPA("");
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current); if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
objurlRef.current = null; objurlRef.current = null;
@@ -226,11 +269,12 @@ export default function TextSpeakerPage() {
style={{ fontFamily: "Times New Roman, serif" }} style={{ fontFamily: "Times New Roman, serif" }}
> >
{/* 文本输入框 */} {/* 文本输入框 */}
<textarea <Textarea
className="text-2xl resize-none focus:outline-0 min-h-64 w-full border-gray-200 border-b p-4" variant="bordered"
className="text-2xl min-h-64"
onChange={handleInputChange} onChange={handleInputChange}
ref={textareaRef} ref={textareaRef}
></textarea> />
{/* IPA 显示区域 */} {/* IPA 显示区域 */}
{(ipa.length !== 0 && ( {(ipa.length !== 0 && (
<div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b px-4"> <div className="overflow-auto text-gray-600 h-18 border-gray-200 border-b px-4">
@@ -317,6 +361,40 @@ export default function TextSpeakerPage() {
alt="save" alt="save"
className={`${saving ? "bg-gray-200" : ""}`} className={`${saving ? "bg-gray-200" : ""}`}
></IconClick> ></IconClick>
{/* 语言选择器 */}
<div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center">
<span className="text-sm text-gray-600">{t("language")}</span>
{TTS_LANGUAGES.slice(0, 6).map((lang) => (
<LightButton
key={lang.value}
selected={!customLanguage && selectedLanguage === lang.value}
onClick={() => {
setSelectedLanguage(lang.value);
setCustomLanguage("");
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
objurlRef.current = null;
setPause(true);
}}
size="sm"
>
{getLanguageLabel(t, lang.label)}
</LightButton>
))}
<Input
variant="bordered"
size="sm"
value={customLanguage}
onChange={(e) => {
setCustomLanguage(e.target.value);
setSelectedLanguage("Auto");
if (objurlRef.current) URL.revokeObjectURL(objurlRef.current);
objurlRef.current = null;
setPause(true);
}}
placeholder={t("customLanguage")}
className="w-auto min-w-[120px]"
/>
</div>
{/* 功能开关按钮 */} {/* 功能开关按钮 */}
<div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center"> <div className="w-full flex flex-row flex-wrap gap-2 justify-center items-center">
<LightButton <LightButton

View File

@@ -1,49 +1,85 @@
"use client"; "use client";
import { LightButton, PrimaryButton, IconClick } from "@/design-system/base/button"; import { LightButton, PrimaryButton, IconClick, CircleButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { Textarea } from "@/design-system/base/textarea";
import { Select } from "@/design-system/base/select"; import { Select } from "@/design-system/base/select";
import { IMAGES } from "@/config/images"; import { IMAGES } from "@/config/images";
import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { actionTranslateText } from "@/modules/translator/translator-action"; import { actionTranslateText } from "@/modules/translator/translator-action";
import { actionCreateCard } from "@/modules/card/card-action";
import { actionGetDecksByUserId } from "@/modules/deck/deck-action";
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
import type { CardType } from "@/modules/card/card-action-dto";
import { toast } from "sonner"; import { toast } from "sonner";
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts"; import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
import { TSharedTranslationResult } from "@/shared/translator-type"; import { TSharedTranslationResult } from "@/shared/translator-type";
import { Plus } from "lucide-react";
import { authClient } from "@/lib/auth-client";
const SOURCE_LANGUAGES = [ const SOURCE_LANGUAGES = [
{ value: "Auto", labelKey: "auto" }, { value: "Auto", label: "auto" },
{ value: "Chinese", labelKey: "chinese" }, { value: "Chinese", label: "chinese" },
{ value: "English", labelKey: "english" }, { value: "English", label: "english" },
{ value: "Japanese", labelKey: "japanese" }, { value: "Japanese", label: "japanese" },
{ value: "Korean", labelKey: "korean" }, { value: "Korean", label: "korean" },
{ value: "French", labelKey: "french" }, { value: "French", label: "french" },
{ value: "German", labelKey: "german" }, { value: "German", label: "german" },
{ value: "Italian", labelKey: "italian" }, { value: "Italian", label: "italian" },
{ value: "Spanish", labelKey: "spanish" }, { value: "Spanish", label: "spanish" },
{ value: "Portuguese", labelKey: "portuguese" }, { value: "Portuguese", label: "portuguese" },
{ value: "Russian", labelKey: "russian" }, { value: "Russian", label: "russian" },
] as const; ] as const;
const TARGET_LANGUAGES = [ const TARGET_LANGUAGES = [
{ value: "Chinese", labelKey: "chinese" }, { value: "Chinese", label: "chinese" },
{ value: "English", labelKey: "english" }, { value: "English", label: "english" },
{ value: "Japanese", labelKey: "japanese" }, { value: "Japanese", label: "japanese" },
{ value: "Korean", labelKey: "korean" }, { value: "Korean", label: "korean" },
{ value: "French", labelKey: "french" }, { value: "French", label: "french" },
{ value: "German", labelKey: "german" }, { value: "German", label: "german" },
{ value: "Italian", labelKey: "italian" }, { value: "Italian", label: "italian" },
{ value: "Spanish", labelKey: "spanish" }, { value: "Spanish", label: "spanish" },
{ value: "Portuguese", labelKey: "portuguese" }, { value: "Portuguese", label: "portuguese" },
{ value: "Russian", labelKey: "russian" }, { value: "Russian", label: "russian" },
] as const; ] as const;
type LangLabel = typeof SOURCE_LANGUAGES[number]["label"];
function getLangLabel(t: (key: string) => string, label: LangLabel): string {
switch (label) {
case "auto": return t("auto");
case "chinese": return t("chinese");
case "english": return t("english");
case "japanese": return t("japanese");
case "korean": return t("korean");
case "french": return t("french");
case "german": return t("german");
case "italian": return t("italian");
case "spanish": return t("spanish");
case "portuguese": return t("portuguese");
case "russian": return t("russian");
}
}
// Estimated button width in pixels (including gap)
const BUTTON_WIDTH = 80;
const LABEL_WIDTH = 100;
const INPUT_WIDTH = 140;
const IPA_BUTTON_WIDTH = 100;
export default function TranslatorPage() { export default function TranslatorPage() {
const t = useTranslations("translator"); const t = useTranslations("translator");
const taref = useRef<HTMLTextAreaElement>(null); const taref = useRef<HTMLTextAreaElement>(null);
const sourceContainerRef = useRef<HTMLDivElement>(null);
const targetContainerRef = useRef<HTMLDivElement>(null);
const [sourceLanguage, setSourceLanguage] = useState<string>("Auto"); const [sourceLanguage, setSourceLanguage] = useState<string>("Auto");
const [targetLanguage, setTargetLanguage] = useState<string>("Chinese"); const [targetLanguage, setTargetLanguage] = useState<string>("Chinese");
const [customSourceLanguage, setCustomSourceLanguage] = useState<string>("");
const [customTargetLanguage, setCustomTargetLanguage] = useState<string>("");
const [translationResult, setTranslationResult] = useState<TSharedTranslationResult | null>(null); const [translationResult, setTranslationResult] = useState<TSharedTranslationResult | null>(null);
const [needIpa, setNeedIpa] = useState(true); const [needIpa, setNeedIpa] = useState(true);
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
@@ -52,14 +88,51 @@ export default function TranslatorPage() {
sourceLanguage: string; sourceLanguage: string;
targetLanguage: string; targetLanguage: string;
} | null>(null); } | null>(null);
const [sourceButtonCount, setSourceButtonCount] = useState(2);
const [targetButtonCount, setTargetButtonCount] = useState(2);
const { load, play } = useAudioPlayer(); const { load, play } = useAudioPlayer();
const lastTTS = useRef({
text: "",
url: "",
});
const tts = async (text: string, locale: string) => { const { data: session } = authClient.useSession();
if (lastTTS.current.text !== text) { const [decks, setDecks] = useState<ActionOutputDeck[]>([]);
const [showSaveModal, setShowSaveModal] = useState(false);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (session?.user?.id) {
actionGetDecksByUserId(session.user.id).then((result) => {
if (result.success && result.data) {
setDecks(result.data);
}
});
}
}, [session?.user?.id]);
// Calculate how many buttons to show based on container width
const calculateButtonCount = useCallback((containerWidth: number, hasIpa: boolean) => {
// Reserve space for label, input, and IPA button (for source)
const reservedWidth = LABEL_WIDTH + INPUT_WIDTH + (hasIpa ? IPA_BUTTON_WIDTH : 0);
const availableWidth = containerWidth - reservedWidth;
return Math.max(0, Math.floor(availableWidth / BUTTON_WIDTH));
}, []);
useEffect(() => {
const updateButtonCounts = () => {
if (sourceContainerRef.current) {
const width = sourceContainerRef.current.offsetWidth;
setSourceButtonCount(calculateButtonCount(width, true));
}
if (targetContainerRef.current) {
const width = targetContainerRef.current.offsetWidth;
setTargetButtonCount(calculateButtonCount(width, false));
}
};
updateButtonCounts();
window.addEventListener("resize", updateButtonCounts);
return () => window.removeEventListener("resize", updateButtonCounts);
}, [calculateButtonCount]);
const tts = useCallback(async (text: string, locale: string) => {
try { try {
// Map language name to TTS format // Map language name to TTS format
let theLanguage = locale.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase()); let theLanguage = locale.toLowerCase().replace(/[^a-z]/g, '').replace(/^./, match => match.toUpperCase());
@@ -77,13 +150,10 @@ export default function TranslatorPage() {
const url = await getTTSUrl(text, theLanguage as TTS_SUPPORTED_LANGUAGES); const url = await getTTSUrl(text, theLanguage as TTS_SUPPORTED_LANGUAGES);
await load(url); await load(url);
await play(); await play();
lastTTS.current.text = text;
lastTTS.current.url = url;
} catch (error) { } catch (error) {
toast.error("Failed to generate audio"); toast.error("Failed to generate audio");
} }
} }, [load, play]);
};
const translate = async () => { const translate = async () => {
if (!taref.current || processing) return; if (!taref.current || processing) return;
@@ -91,29 +161,30 @@ export default function TranslatorPage() {
setProcessing(true); setProcessing(true);
const sourceText = taref.current.value; const sourceText = taref.current.value;
const effectiveSourceLanguage = customSourceLanguage.trim() || sourceLanguage;
const effectiveTargetLanguage = customTargetLanguage.trim() || targetLanguage;
// 判断是否需要强制重新翻译 // 判断是否需要强制重新翻译
// 只有当源文本、源语言和目标语言都与上次相同时,才强制重新翻译
const forceRetranslate = const forceRetranslate =
lastTranslation?.sourceText === sourceText && lastTranslation?.sourceText === sourceText &&
lastTranslation?.sourceLanguage === sourceLanguage && lastTranslation?.sourceLanguage === effectiveSourceLanguage &&
lastTranslation?.targetLanguage === targetLanguage; lastTranslation?.targetLanguage === effectiveTargetLanguage;
try { try {
const result = await actionTranslateText({ const result = await actionTranslateText({
sourceText, sourceText,
targetLanguage, targetLanguage: effectiveTargetLanguage,
forceRetranslate, forceRetranslate,
needIpa, needIpa,
sourceLanguage: sourceLanguage === "Auto" ? undefined : sourceLanguage, sourceLanguage: effectiveSourceLanguage === "Auto" ? undefined : effectiveSourceLanguage,
}); });
if (result.success && result.data) { if (result.success && result.data) {
setTranslationResult(result.data); setTranslationResult(result.data);
setLastTranslation({ setLastTranslation({
sourceText, sourceText,
sourceLanguage, sourceLanguage: effectiveSourceLanguage,
targetLanguage, targetLanguage: effectiveTargetLanguage,
}); });
} else { } else {
toast.error(result.message || "翻译失败,请重试"); toast.error(result.message || "翻译失败,请重试");
@@ -126,6 +197,66 @@ export default function TranslatorPage() {
} }
}; };
const visibleSourceButtons = SOURCE_LANGUAGES.slice(0, sourceButtonCount);
const visibleTargetButtons = TARGET_LANGUAGES.slice(0, targetButtonCount);
const handleSaveCard = async () => {
if (!session) {
toast.error(t("pleaseLogin"));
return;
}
if (decks.length === 0) {
toast.error(t("pleaseCreateDeck"));
return;
}
if (!lastTranslation?.sourceText || !translationResult?.translatedText) {
toast.error(t("noTranslationToSave"));
return;
}
const deckSelect = document.getElementById("deck-select-translator") as HTMLSelectElement;
const deckId = deckSelect?.value ? Number(deckSelect.value) : decks[0]?.id;
if (!deckId) {
toast.error(t("noDeckSelected"));
return;
}
setIsSaving(true);
try {
const sourceText = lastTranslation.sourceText;
const hasSpaces = sourceText.includes(" ");
let cardType: CardType = "WORD";
if (!translationResult.sourceIpa) {
cardType = "SENTENCE";
} else if (hasSpaces) {
cardType = "PHRASE";
}
await actionCreateCard({
deckId,
word: sourceText,
ipa: translationResult.sourceIpa || null,
queryLang: lastTranslation.sourceLanguage,
cardType,
meanings: [{
partOfSpeech: null,
definition: translationResult.translatedText,
example: null,
}],
});
const deckName = decks.find((d) => d.id === deckId)?.name || "Unknown";
toast.success(t("savedToDeck", { deckName }));
setShowSaveModal(false);
} catch (error) {
toast.error(t("saveFailed"));
} finally {
setIsSaving(false);
}
};
return ( return (
<div className="min-h-[calc(100vh-64px)] bg-white"> <div className="min-h-[calc(100vh-64px)] bg-white">
{/* TCard Component */} {/* TCard Component */}
@@ -134,13 +265,13 @@ export default function TranslatorPage() {
<div className="w-full md:w-1/2 flex flex-col-reverse gap-2"> <div className="w-full md:w-1/2 flex flex-col-reverse gap-2">
{/* ICard1 Component */} {/* ICard1 Component */}
<div className="border border-gray-200 rounded-lg w-full h-64 p-2"> <div className="border border-gray-200 rounded-lg w-full h-64 p-2">
<textarea <Textarea
className="resize-none h-8/12 w-full focus:outline-0" className="resize-none h-8/12 w-full"
ref={taref} ref={taref}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.ctrlKey && e.key === "Enter") translate(); if (e.ctrlKey && e.key === "Enter") translate();
}} }}
></textarea> />
<div className="ipa w-full h-2/12 overflow-auto text-gray-600"> <div className="ipa w-full h-2/12 overflow-auto text-gray-600">
{translationResult?.sourceIpa || ""} {translationResult?.sourceIpa || ""}
</div> </div>
@@ -158,49 +289,36 @@ export default function TranslatorPage() {
src={IMAGES.play_arrow} src={IMAGES.play_arrow}
alt="play" alt="play"
onClick={() => { onClick={() => {
const t = taref.current?.value; const text = taref.current?.value;
if (!t) return; if (!text) return;
tts(t, translationResult?.sourceLanguage || ""); tts(text, translationResult?.sourceLanguage || "");
}} }}
></IconClick> ></IconClick>
</div> </div>
</div> </div>
<div className="option1 w-full flex gap-1 items-center overflow-x-auto"> <div ref={sourceContainerRef} className="option1 w-full flex gap-1 items-center overflow-x-auto">
<span className="shrink-0">{t("sourceLanguage")}</span> <span className="shrink-0">{t("sourceLanguage")}</span>
{visibleSourceButtons.map((lang) => (
<LightButton <LightButton
selected={sourceLanguage === "Auto"} key={lang.value}
onClick={() => setSourceLanguage("Auto")} selected={!customSourceLanguage && sourceLanguage === lang.value}
className="shrink-0 hidden lg:inline-flex" onClick={() => {
setSourceLanguage(lang.value);
setCustomSourceLanguage("");
}}
className="shrink-0"
> >
{t("auto")} {getLangLabel(t, lang.label)}
</LightButton> </LightButton>
<LightButton
selected={sourceLanguage === "Chinese"}
onClick={() => setSourceLanguage("Chinese")}
className="shrink-0 hidden lg:inline-flex"
>
{t("chinese")}
</LightButton>
<LightButton
selected={sourceLanguage === "English"}
onClick={() => setSourceLanguage("English")}
className="shrink-0 hidden xl:inline-flex"
>
{t("english")}
</LightButton>
<Select
value={sourceLanguage}
onChange={(e) => setSourceLanguage(e.target.value)}
variant="light"
size="sm"
className="w-auto min-w-[100px] shrink-0"
>
{SOURCE_LANGUAGES.map((lang) => (
<option key={lang.value} value={lang.value}>
{t(lang.labelKey)}
</option>
))} ))}
</Select> <Input
variant="bordered"
size="sm"
value={customSourceLanguage}
onChange={(e) => setCustomSourceLanguage(e.target.value)}
placeholder={t("customLanguage")}
className="w-auto min-w-[120px] shrink-0"
/>
<div className="flex-1"></div> <div className="flex-1"></div>
<LightButton <LightButton
selected={needIpa} selected={needIpa}
@@ -241,48 +359,35 @@ export default function TranslatorPage() {
></IconClick> ></IconClick>
</div> </div>
</div> </div>
<div className="option2 w-full flex gap-1 items-center overflow-x-auto"> <div ref={targetContainerRef} className="option2 w-full flex gap-1 items-center overflow-x-auto">
<span className="shrink-0">{t("translateInto")}</span> <span className="shrink-0">{t("translateInto")}</span>
{visibleTargetButtons.map((lang) => (
<LightButton <LightButton
selected={targetLanguage === "Chinese"} key={lang.value}
onClick={() => setTargetLanguage("Chinese")} selected={!customTargetLanguage && targetLanguage === lang.value}
className="shrink-0 hidden lg:inline-flex" onClick={() => {
setTargetLanguage(lang.value);
setCustomTargetLanguage("");
}}
className="shrink-0"
> >
{t("chinese")} {getLangLabel(t, lang.label)}
</LightButton> </LightButton>
<LightButton
selected={targetLanguage === "English"}
onClick={() => setTargetLanguage("English")}
className="shrink-0 hidden lg:inline-flex"
>
{t("english")}
</LightButton>
<LightButton
selected={targetLanguage === "Japanese"}
onClick={() => setTargetLanguage("Japanese")}
className="shrink-0 hidden xl:inline-flex"
>
{t("japanese")}
</LightButton>
<Select
value={targetLanguage}
onChange={(e) => setTargetLanguage(e.target.value)}
variant="light"
size="sm"
className="w-auto min-w-[100px] shrink-0"
>
{TARGET_LANGUAGES.map((lang) => (
<option key={lang.value} value={lang.value}>
{t(lang.labelKey)}
</option>
))} ))}
</Select> <Input
variant="bordered"
size="sm"
value={customTargetLanguage}
onChange={(e) => setCustomTargetLanguage(e.target.value)}
placeholder={t("customLanguage")}
className="w-auto min-w-[120px] shrink-0"
/>
</div> </div>
</div> </div>
</div> </div>
{/* TranslateButton Component */} {/* TranslateButton Component */}
<div className="w-screen flex justify-center items-center"> <div className="w-screen flex justify-center items-center gap-4">
<PrimaryButton <PrimaryButton
onClick={translate} onClick={translate}
disabled={processing} disabled={processing}
@@ -291,7 +396,49 @@ export default function TranslatorPage() {
> >
{t("translate")} {t("translate")}
</PrimaryButton> </PrimaryButton>
{translationResult && session && decks.length > 0 && (
<CircleButton
onClick={() => setShowSaveModal(true)}
title={t("saveAsCard")}
>
<Plus size={20} />
</CircleButton>
)}
</div> </div>
{showSaveModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
<h2 className="text-xl font-semibold mb-4">{t("saveAsCard")}</h2>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("selectDeck")}
</label>
<Select id="deck-select-translator" className="w-full">
{decks.map((deck) => (
<option key={deck.id} value={deck.id}>
{deck.name}
</option>
))}
</Select>
</div>
<div className="mb-4 p-3 bg-gray-50 rounded text-sm">
<div className="font-medium mb-1">{t("front")}:</div>
<div className="text-gray-700 mb-2">{lastTranslation?.sourceText}</div>
<div className="font-medium mb-1">{t("back")}:</div>
<div className="text-gray-700">{translationResult?.translatedText}</div>
</div>
<div className="flex justify-end gap-2">
<LightButton onClick={() => setShowSaveModal(false)}>
{t("cancel")}
</LightButton>
<PrimaryButton onClick={handleSaveCard} loading={isSaving}>
{t("save")}
</PrimaryButton>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -10,6 +10,8 @@ import {
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { CircleButton, LightButton } from "@/design-system/base/button"; import { CircleButton, LightButton } from "@/design-system/base/button";
import { Skeleton } from "@/design-system/feedback/skeleton";
import { VStack } from "@/design-system/layout/stack";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -25,7 +27,6 @@ import {
actionGetDeckById, actionGetDeckById,
} from "@/modules/deck/deck-action"; } from "@/modules/deck/deck-action";
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto"; import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
import { ImportButton, ExportButton } from "@/components/deck/ImportExport";
interface DeckCardProps { interface DeckCardProps {
deck: ActionOutputDeck; deck: ActionOutputDeck;
@@ -197,15 +198,14 @@ export function DecksClient({ userId }: DecksClientProps) {
<Plus size={18} /> <Plus size={18} />
{t("newDeck")} {t("newDeck")}
</LightButton> </LightButton>
<ImportButton onImportComplete={loadDecks} />
</div> </div>
<CardList> <CardList>
{loading ? ( {loading ? (
<div className="p-8 text-center"> <VStack align="center" className="p-8">
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div> <Skeleton variant="circular" className="w-8 h-8 mb-3" />
<p className="text-sm text-gray-500">{t("loading")}</p> <p className="text-sm text-gray-500">{t("loading")}</p>
</div> </VStack>
) : decks.length === 0 ? ( ) : decks.length === 0 ? (
<div className="text-center py-12 text-gray-400"> <div className="text-center py-12 text-gray-400">
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center"> <div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">

View File

@@ -1,15 +1,32 @@
"use client"; "use client";
import { LightButton } from "@/design-system/base/button"; import { LightButton, PrimaryButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input"; import { Input } from "@/design-system/base/input";
import { X } from "lucide-react"; import { Select } from "@/design-system/base/select";
import { useRef, useState } from "react"; import { Textarea } from "@/design-system/base/textarea";
import { Modal } from "@/design-system/overlay/modal";
import { VStack, HStack } from "@/design-system/layout/stack";
import { Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { actionCreateNote } from "@/modules/note/note-action";
import { actionCreateCard } from "@/modules/card/card-action"; import { actionCreateCard } from "@/modules/card/card-action";
import { actionGetNoteTypesByUserId, actionCreateDefaultBasicNoteType } from "@/modules/note-type/note-type-action"; import type { CardType, CardMeaning } from "@/modules/card/card-action-dto";
import { toast } from "sonner"; import { toast } from "sonner";
const QUERY_LANGUAGE_LABELS = {
english: "english",
chinese: "chinese",
japanese: "japanese",
korean: "korean",
} as const;
const QUERY_LANGUAGES = [
{ value: "en", label: "english" as const },
{ value: "zh", label: "chinese" as const },
{ value: "ja", label: "japanese" as const },
{ value: "ko", label: "korean" as const },
] as const;
interface AddCardModalProps { interface AddCardModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@@ -24,75 +41,89 @@ export function AddCardModal({
onAdded, onAdded,
}: AddCardModalProps) { }: AddCardModalProps) {
const t = useTranslations("deck_id"); const t = useTranslations("deck_id");
const wordRef = useRef<HTMLInputElement>(null);
const definitionRef = useRef<HTMLInputElement>(null); const [cardType, setCardType] = useState<CardType>("WORD");
const ipaRef = useRef<HTMLInputElement>(null); const [word, setWord] = useState("");
const exampleRef = useRef<HTMLInputElement>(null); const [ipa, setIpa] = useState("");
const [queryLang, setQueryLang] = useState("en");
const [customQueryLang, setCustomQueryLang] = useState("");
const [meanings, setMeanings] = useState<CardMeaning[]>([
{ partOfSpeech: null, definition: "", example: null }
]);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
if (!isOpen) return null; const showIpa = cardType === "WORD" || cardType === "PHRASE";
const addMeaning = () => {
setMeanings([...meanings, { partOfSpeech: null, definition: "", example: null }]);
};
const removeMeaning = (index: number) => {
if (meanings.length > 1) {
setMeanings(meanings.filter((_, i) => i !== index));
}
};
const updateMeaning = (
index: number,
field: "partOfSpeech" | "definition" | "example",
value: string
) => {
const updated = [...meanings];
updated[index] = {
...updated[index],
[field]: value || null
};
setMeanings(updated);
};
const resetForm = () => {
setCardType("WORD");
setWord("");
setIpa("");
setQueryLang("en");
setCustomQueryLang("");
setMeanings([{ partOfSpeech: null, definition: "", example: null }]);
};
const handleAdd = async () => { const handleAdd = async () => {
const word = wordRef.current?.value?.trim(); if (!word.trim()) {
const definition = definitionRef.current?.value?.trim(); toast.error(t("wordRequired"));
return;
}
if (!word || !definition) { const validMeanings = meanings.filter(m => m.definition?.trim());
toast.error(t("wordAndDefinitionRequired")); if (validMeanings.length === 0) {
toast.error(t("definitionRequired"));
return; return;
} }
setIsSubmitting(true); setIsSubmitting(true);
const effectiveQueryLang = customQueryLang.trim() || queryLang;
try { try {
let noteTypesResult = await actionGetNoteTypesByUserId();
if (!noteTypesResult.success || !noteTypesResult.data || noteTypesResult.data.length === 0) {
const createResult = await actionCreateDefaultBasicNoteType();
if (!createResult.success || !createResult.data) {
throw new Error(createResult.message || "Failed to create note type");
}
noteTypesResult = await actionGetNoteTypesByUserId();
}
if (!noteTypesResult.success || !noteTypesResult.data || noteTypesResult.data.length === 0) {
throw new Error("No note type available");
}
const noteTypeId = noteTypesResult.data[0].id;
const fields = [
word,
definition,
ipaRef.current?.value?.trim() || "",
exampleRef.current?.value?.trim() || "",
];
const noteResult = await actionCreateNote({
noteTypeId,
fields,
tags: [],
});
if (!noteResult.success || !noteResult.data) {
throw new Error(noteResult.message || "Failed to create note");
}
const cardResult = await actionCreateCard({ const cardResult = await actionCreateCard({
noteId: BigInt(noteResult.data.id),
deckId, deckId,
word: word.trim(),
ipa: showIpa && ipa.trim() ? ipa.trim() : null,
queryLang: effectiveQueryLang,
cardType,
meanings: validMeanings.map(m => ({
partOfSpeech: cardType === "SENTENCE" ? null : (m.partOfSpeech?.trim() || null),
definition: m.definition!.trim(),
example: m.example?.trim() || null,
})),
}); });
if (!cardResult.success) { if (!cardResult.success) {
throw new Error(cardResult.message || "Failed to create card"); throw new Error(cardResult.message || "Failed to create card");
} }
if (wordRef.current) wordRef.current.value = ""; resetForm();
if (definitionRef.current) definitionRef.current.value = "";
if (ipaRef.current) ipaRef.current.value = "";
if (exampleRef.current) exampleRef.current.value = "";
onAdded(); onAdded();
onClose(); onClose();
toast.success(t("cardAdded") || "Card added successfully");
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "Unknown error"); toast.error(error instanceof Error ? error.message : "Unknown error");
} finally { } finally {
@@ -100,55 +131,155 @@ export function AddCardModal({
} }
}; };
const handleClose = () => {
resetForm();
onClose();
};
return ( return (
<div <Modal open={isOpen} onClose={handleClose} size="md">
className="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50" <Modal.Header>
onKeyDown={(e) => { <Modal.Title>{t("addNewCard")}</Modal.Title>
if (e.key === "Enter") { <Modal.CloseButton onClick={handleClose} />
e.preventDefault(); </Modal.Header>
handleAdd();
} <Modal.Body className="space-y-4">
}} <HStack gap={3}>
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("cardType")}
</label>
<Select
value={cardType}
onChange={(e) => setCardType(e.target.value as CardType)}
className="w-full"
> >
<div className="bg-white rounded-md p-6 w-full max-w-md mx-4"> <option value="WORD">{t("wordCard")}</option>
<div className="flex"> <option value="PHRASE">{t("phraseCard")}</option>
<h2 className="flex-1 text-xl font-light mb-4 text-center"> <option value="SENTENCE">{t("sentenceCard")}</option>
{t("addNewCard")} </Select>
</h2>
<X onClick={onClose} className="hover:cursor-pointer"></X>
</div> </div>
<div className="space-y-4"> </HStack>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
{t("word")} * {t("queryLang")}
</label> </label>
<Input ref={wordRef} className="w-full"></Input> <HStack gap={2} className="flex-wrap">
{QUERY_LANGUAGES.map((lang) => (
<LightButton
key={lang.value}
selected={!customQueryLang && queryLang === lang.value}
onClick={() => {
setQueryLang(lang.value);
setCustomQueryLang("");
}}
size="sm"
>
{t(lang.label)}
</LightButton>
))}
<Input
value={customQueryLang}
onChange={(e) => setCustomQueryLang(e.target.value)}
placeholder={t("enterLanguageName")}
className="w-auto min-w-[100px] flex-1"
size="sm"
/>
</HStack>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
{t("definition")} * {cardType === "SENTENCE" ? t("sentence") : t("word")} *
</label> </label>
<Input ref={definitionRef} className="w-full"></Input> <Input
value={word}
onChange={(e) => setWord(e.target.value)}
className="w-full"
placeholder={cardType === "SENTENCE" ? t("sentencePlaceholder") : t("wordPlaceholder")}
/>
</div> </div>
{showIpa && (
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
{t("ipa")} {t("ipa")}
</label> </label>
<Input ref={ipaRef} className="w-full"></Input> <Input
value={ipa}
onChange={(e) => setIpa(e.target.value)}
className="w-full"
placeholder={t("ipaPlaceholder")}
/>
</div> </div>
)}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <HStack justify="between" className="mb-2">
{t("example")} <label className="block text-sm font-medium text-gray-700">
{t("meanings")} *
</label> </label>
<Input ref={exampleRef} className="w-full"></Input> <button
onClick={addMeaning}
className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1"
>
<Plus size={14} />
{t("addMeaning")}
</button>
</HStack>
<VStack gap={4}>
{meanings.map((meaning, index) => (
<div key={index} className="p-3 bg-gray-50 rounded-lg space-y-2">
<HStack gap={2}>
{cardType !== "SENTENCE" && (
<div className="w-28 shrink-0">
<Input
value={meaning.partOfSpeech || ""}
onChange={(e) => updateMeaning(index, "partOfSpeech", e.target.value)}
placeholder={t("partOfSpeech")}
className="w-full"
/>
</div> </div>
)}
<div className="flex-1">
<Input
value={meaning.definition || ""}
onChange={(e) => updateMeaning(index, "definition", e.target.value)}
placeholder={t("definition")}
className="w-full"
/>
</div> </div>
<div className="mt-4"> {meanings.length > 1 && (
<LightButton onClick={handleAdd} disabled={isSubmitting}> <button
{isSubmitting ? t("adding") : t("add")} onClick={() => removeMeaning(index)}
className="p-2 text-gray-400 hover:text-red-500"
>
<Trash2 size={16} />
</button>
)}
</HStack>
<Textarea
value={meaning.example || ""}
onChange={(e) => updateMeaning(index, "example", e.target.value)}
placeholder={t("examplePlaceholder")}
className="w-full min-h-[40px] text-sm"
/>
</div>
))}
</VStack>
</div>
</Modal.Body>
<Modal.Footer>
<LightButton onClick={handleClose}>
{t("cancel")}
</LightButton> </LightButton>
</div> <PrimaryButton onClick={handleAdd} loading={isSubmitting}>
</div> {isSubmitting ? t("adding") : t("add")}
</div> </PrimaryButton>
</Modal.Footer>
</Modal>
); );
} }

View File

@@ -1,32 +1,57 @@
import { Edit, Trash2 } from "lucide-react"; import { Trash2, Pencil } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { CircleButton } from "@/design-system/base/button"; import { CircleButton } from "@/design-system/base/button";
import { UpdateCardModal } from "./UpdateCardModal";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import type { ActionOutputCardWithNote } from "@/modules/card/card-action-dto"; import type { ActionOutputCard, CardType } from "@/modules/card/card-action-dto";
import { toast } from "sonner"; import { toast } from "sonner";
import { actionDeleteCard } from "@/modules/card/card-action";
import { EditCardModal } from "./EditCardModal";
interface CardItemProps { interface CardItemProps {
card: ActionOutputCardWithNote; card: ActionOutputCard;
isReadOnly: boolean; isReadOnly: boolean;
onDel: () => void; onDel: () => void;
refreshCards: () => void; onUpdated: () => void;
} }
const CARD_TYPE_LABELS: Record<CardType, string> = {
WORD: "Word",
PHRASE: "Phrase",
SENTENCE: "Sentence",
};
export function CardItem({ export function CardItem({
card, card,
isReadOnly, isReadOnly,
onDel, onDel,
refreshCards, onUpdated,
}: CardItemProps) { }: CardItemProps) {
const [openUpdateModal, setOpenUpdateModal] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const t = useTranslations("deck_id"); const t = useTranslations("deck_id");
const fields = card.note.flds.split('\x1f'); const frontText = card.word;
const field1 = fields[0] || ""; const backText = card.meanings.map((m) =>
const field2 = fields[1] || ""; m.partOfSpeech ? `${m.partOfSpeech}: ${m.definition}` : m.definition
).join("; ");
const handleDelete = async () => {
try {
const result = await actionDeleteCard({ cardId: card.id });
if (result.success) {
toast.success(t("cardDeleted"));
onDel();
} else {
toast.error(result.message);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Unknown error");
}
setShowDeleteConfirm(false);
};
return ( return (
<>
<div className="group border-b border-gray-100 hover:bg-gray-50 transition-colors"> <div className="group border-b border-gray-100 hover:bg-gray-50 transition-colors">
<div className="p-4"> <div className="p-4">
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
@@ -34,20 +59,23 @@ export function CardItem({
<span className="px-2 py-1 bg-gray-100 rounded-md"> <span className="px-2 py-1 bg-gray-100 rounded-md">
{t("card")} {t("card")}
</span> </span>
<span className="px-2 py-1 bg-blue-50 text-blue-600 rounded-md">
{CARD_TYPE_LABELS[card.cardType]}
</span>
</div> </div>
<div className="flex items-center gap-1 opacity-50 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-1 opacity-50 group-hover:opacity-100 transition-opacity">
{!isReadOnly && ( {!isReadOnly && (
<> <>
<CircleButton <CircleButton
onClick={() => setOpenUpdateModal(true)} onClick={() => setShowEditModal(true)}
title={t("edit")} title={t("edit")}
className="text-gray-400 hover:text-gray-600" className="text-gray-400 hover:text-blue-500 hover:bg-blue-50"
> >
<Edit size={14} /> <Pencil size={14} />
</CircleButton> </CircleButton>
<CircleButton <CircleButton
onClick={onDel} onClick={() => setShowDeleteConfirm(true)}
title={t("delete")} title={t("delete")}
className="text-gray-400 hover:text-red-500 hover:bg-red-50" className="text-gray-400 hover:text-red-500 hover:bg-red-50"
> >
@@ -59,26 +87,47 @@ export function CardItem({
</div> </div>
<div className="text-gray-900 grid grid-cols-2 gap-4 w-3/4"> <div className="text-gray-900 grid grid-cols-2 gap-4 w-3/4">
<div> <div>
{field1.length > 30 {frontText.length > 30
? field1.substring(0, 30) + "..." ? frontText.substring(0, 30) + "..."
: field1} : frontText}
</div> </div>
<div> <div>
{field2.length > 30 {backText.length > 30
? field2.substring(0, 30) + "..." ? backText.substring(0, 30) + "..."
: field2} : backText}
</div> </div>
</div> </div>
</div> </div>
<UpdateCardModal </div>
isOpen={openUpdateModal}
onClose={() => setOpenUpdateModal(false)} {showDeleteConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-4 max-w-sm mx-4">
<p className="text-gray-700 mb-4">{t("deleteConfirm")}</p>
<div className="flex gap-2 justify-end">
<button
onClick={() => setShowDeleteConfirm(false)}
className="px-3 py-1 text-gray-600 hover:bg-gray-100 rounded"
>
{t("cancel")}
</button>
<button
onClick={handleDelete}
className="px-3 py-1 text-red-600 hover:bg-red-50 rounded"
>
{t("delete")}
</button>
</div>
</div>
</div>
)}
<EditCardModal
isOpen={showEditModal}
onClose={() => setShowEditModal(false)}
card={card} card={card}
onUpdated={() => { onUpdated={onUpdated}
setOpenUpdateModal(false);
refreshCards();
}}
/> />
</div> </>
); );
} }

View File

@@ -0,0 +1,229 @@
"use client";
import { LightButton, PrimaryButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { Textarea } from "@/design-system/base/textarea";
import { Modal } from "@/design-system/overlay/modal";
import { VStack, HStack } from "@/design-system/layout/stack";
import { Plus, Trash2 } from "lucide-react";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { actionUpdateCard } from "@/modules/card/card-action";
import type { ActionOutputCard, CardMeaning } from "@/modules/card/card-action-dto";
import { toast } from "sonner";
interface EditCardModalProps {
isOpen: boolean;
onClose: () => void;
card: ActionOutputCard | null;
onUpdated: () => void;
}
export function EditCardModal({
isOpen,
onClose,
card,
onUpdated,
}: EditCardModalProps) {
const t = useTranslations("deck_id");
const [word, setWord] = useState("");
const [ipa, setIpa] = useState("");
const [meanings, setMeanings] = useState<CardMeaning[]>([
{ partOfSpeech: null, definition: "", example: null }
]);
const [isSubmitting, setIsSubmitting] = useState(false);
const showIpa = card?.cardType === "WORD" || card?.cardType === "PHRASE";
useEffect(() => {
if (card) {
setWord(card.word);
setIpa(card.ipa || "");
setMeanings(
card.meanings.length > 0
? card.meanings
: [{ partOfSpeech: null, definition: "", example: null }]
);
}
}, [card]);
const addMeaning = () => {
setMeanings([...meanings, { partOfSpeech: null, definition: "", example: null }]);
};
const removeMeaning = (index: number) => {
if (meanings.length > 1) {
setMeanings(meanings.filter((_, i) => i !== index));
}
};
const updateMeaning = (index: number, field: keyof CardMeaning, value: string) => {
const updated = [...meanings];
updated[index] = { ...updated[index], [field]: value || null };
setMeanings(updated);
};
const handleUpdate = async () => {
if (!card) return;
if (!word.trim()) {
toast.error(t("wordRequired"));
return;
}
const validMeanings = meanings.filter(m => m.definition?.trim());
if (validMeanings.length === 0) {
toast.error(t("definitionRequired"));
return;
}
setIsSubmitting(true);
try {
const result = await actionUpdateCard({
cardId: card.id,
word: word.trim(),
ipa: showIpa && ipa.trim() ? ipa.trim() : null,
meanings: validMeanings.map(m => ({
partOfSpeech: card.cardType === "SENTENCE" ? null : (m.partOfSpeech?.trim() || null),
definition: m.definition!.trim(),
example: m.example?.trim() || null,
})),
});
if (!result.success) {
throw new Error(result.message || "Failed to update card");
}
onUpdated();
onClose();
toast.success(t("cardUpdated") || "Card updated successfully");
} catch (error) {
toast.error(error instanceof Error ? error.message : "Unknown error");
} finally {
setIsSubmitting(false);
}
};
if (!card) return null;
const cardTypeLabel = card.cardType === "WORD"
? t("wordCard")
: card.cardType === "PHRASE"
? t("phraseCard")
: t("sentenceCard");
return (
<Modal open={isOpen} onClose={onClose} size="md">
<Modal.Header>
<Modal.Title>{t("updateCard")}</Modal.Title>
<Modal.CloseButton onClick={onClose} />
</Modal.Header>
<Modal.Body className="space-y-4">
<HStack gap={2} className="text-sm text-gray-500">
<span className="px-2 py-1 bg-gray-100 rounded-md">
{t("card")}
</span>
<span className="px-2 py-1 bg-blue-50 text-blue-600 rounded-md">
{cardTypeLabel}
</span>
<span className="px-2 py-1 bg-gray-100 rounded-md">
{card.queryLang}
</span>
</HStack>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{card.cardType === "SENTENCE" ? t("sentence") : t("word")} *
</label>
<Input
value={word}
onChange={(e) => setWord(e.target.value)}
className="w-full"
/>
</div>
{showIpa && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("ipa")}
</label>
<Input
value={ipa}
onChange={(e) => setIpa(e.target.value)}
className="w-full"
placeholder={t("ipaPlaceholder")}
/>
</div>
)}
<div>
<HStack justify="between" className="mb-2">
<label className="block text-sm font-medium text-gray-700">
{t("meanings")} *
</label>
<button
onClick={addMeaning}
className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1"
>
<Plus size={14} />
{t("addMeaning")}
</button>
</HStack>
<VStack gap={4}>
{meanings.map((meaning, index) => (
<div key={index} className="p-3 bg-gray-50 rounded-lg space-y-2">
<HStack gap={2}>
{card.cardType !== "SENTENCE" && (
<div className="w-28 shrink-0">
<Input
value={meaning.partOfSpeech || ""}
onChange={(e) => updateMeaning(index, "partOfSpeech", e.target.value)}
placeholder={t("partOfSpeech")}
className="w-full"
/>
</div>
)}
<div className="flex-1">
<Input
value={meaning.definition || ""}
onChange={(e) => updateMeaning(index, "definition", e.target.value)}
placeholder={t("definition")}
className="w-full"
/>
</div>
{meanings.length > 1 && (
<button
onClick={() => removeMeaning(index)}
className="p-2 text-gray-400 hover:text-red-500"
>
<Trash2 size={16} />
</button>
)}
</HStack>
<Textarea
value={meaning.example || ""}
onChange={(e) => updateMeaning(index, "example", e.target.value)}
placeholder={t("examplePlaceholder")}
className="w-full min-h-[40px] text-sm"
/>
</div>
))}
</VStack>
</div>
</Modal.Body>
<Modal.Footer>
<LightButton onClick={onClose}>
{t("cancel")}
</LightButton>
<PrimaryButton onClick={handleUpdate} loading={isSubmitting}>
{isSubmitting ? t("updating") : t("update")}
</PrimaryButton>
</Modal.Footer>
</Modal>
);
}

View File

@@ -2,56 +2,76 @@
import { ArrowLeft, Plus } from "lucide-react"; import { ArrowLeft, Plus } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { redirect, useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { AddCardModal } from "./AddCardModal";
import { CardItem } from "./CardItem"; import { CardItem } from "./CardItem";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { PageLayout } from "@/components/ui/PageLayout"; import { PageLayout } from "@/components/ui/PageLayout";
import { PrimaryButton, CircleButton, LinkButton } from "@/design-system/base/button"; import { PrimaryButton, CircleButton, LinkButton } from "@/design-system/base/button";
import { CardList } from "@/components/ui/CardList"; import { CardList } from "@/components/ui/CardList";
import { actionGetCardsByDeckIdWithNotes, actionDeleteCard } from "@/modules/card/card-action"; import { VStack } from "@/design-system/layout/stack";
import type { ActionOutputCardWithNote } from "@/modules/card/card-action-dto"; import { Skeleton } from "@/design-system/feedback/skeleton";
import { actionGetCardsByDeckId, actionDeleteCard } from "@/modules/card/card-action";
import { actionGetDeckById } from "@/modules/deck/deck-action";
import type { ActionOutputCard } from "@/modules/card/card-action-dto";
import type { ActionOutputDeck } from "@/modules/deck/deck-action-dto";
import { toast } from "sonner"; import { toast } from "sonner";
import { AddCardModal } from "./AddCardModal";
export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boolean }) {
export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boolean; }) { const [cards, setCards] = useState<ActionOutputCard[]>([]);
const [cards, setCards] = useState<ActionOutputCardWithNote[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [openAddModal, setAddModal] = useState(false); const [openAddModal, setAddModal] = useState(false);
const [deckInfo, setDeckInfo] = useState<ActionOutputDeck | null>(null);
const router = useRouter(); const router = useRouter();
const t = useTranslations("deck_id"); const t = useTranslations("deck_id");
useEffect(() => { useEffect(() => {
const fetchCards = async () => { const fetchCards = async () => {
setLoading(true); setLoading(true);
await actionGetCardsByDeckIdWithNotes({ deckId }) try {
.then(result => { const [cardsResult, deckResult] = await Promise.all([
if (!result.success || !result.data) { actionGetCardsByDeckId({ deckId }),
throw new Error(result.message || "Failed to load cards"); actionGetDeckById({ deckId }),
]);
if (!cardsResult.success || !cardsResult.data) {
throw new Error(cardsResult.message || "Failed to load cards");
} }
return result.data; setCards(cardsResult.data);
}).then(setCards)
.catch((error) => { if (deckResult.success && deckResult.data) {
setDeckInfo(deckResult.data);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Unknown error"); toast.error(error instanceof Error ? error.message : "Unknown error");
}) } finally {
.finally(() => {
setLoading(false); setLoading(false);
}); }
}; };
fetchCards(); fetchCards();
}, [deckId]); }, [deckId]);
const refreshCards = async () => { const refreshCards = async () => {
await actionGetCardsByDeckIdWithNotes({ deckId }) const result = await actionGetCardsByDeckId({ deckId });
.then(result => { if (result.success && result.data) {
if (!result.success || !result.data) { setCards(result.data);
throw new Error(result.message || "Failed to refresh cards"); } else {
toast.error(result.message);
} }
return result.data; };
}).then(setCards)
.catch((error) => { const handleDeleteCard = async (cardId: number) => {
try {
const result = await actionDeleteCard({ cardId });
if (result.success) {
toast.success(t("cardDeleted"));
await refreshCards();
} else {
toast.error(result.message);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Unknown error"); toast.error(error instanceof Error ? error.message : "Unknown error");
}); }
}; };
return ( return (
@@ -68,7 +88,7 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-1"> <h1 className="text-2xl md:text-3xl font-bold text-gray-800 mb-1">
{t("cards")} {deckInfo?.name || t("cards")}
</h1> </h1>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
{t("itemsCount", { count: cards.length })} {t("itemsCount", { count: cards.length })}
@@ -78,7 +98,7 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<PrimaryButton <PrimaryButton
onClick={() => { onClick={() => {
redirect(`/memorize?deck_id=${deckId}`); router.push(`/decks/${deckId}/learn`);
}} }}
> >
{t("memorize")} {t("memorize")}
@@ -98,33 +118,23 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo
<CardList> <CardList>
{loading ? ( {loading ? (
<div className="p-8 text-center"> <VStack align="center" className="p-8">
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div> <Skeleton variant="circular" className="w-8 h-8" />
<p className="text-sm text-gray-500">{t("loadingCards")}</p> <p className="text-sm text-gray-500">{t("loadingCards")}</p>
</div> </VStack>
) : cards.length === 0 ? ( ) : cards.length === 0 ? (
<div className="p-12 text-center"> <div className="p-12 text-center">
<p className="text-sm text-gray-500 mb-2">{t("noCards")}</p> <p className="text-sm text-gray-500 mb-2">{t("noCards")}</p>
</div> </div>
) : ( ) : (
<div className="divide-y divide-gray-100"> <div className="divide-y divide-gray-100">
{cards {cards.map((card) => (
.toSorted((a, b) => Number(BigInt(a.id) - BigInt(b.id)))
.map((card) => (
<CardItem <CardItem
key={card.id} key={card.id}
card={card} card={card}
isReadOnly={isReadOnly} isReadOnly={isReadOnly}
onDel={() => { onDel={() => handleDeleteCard(card.id)}
actionDeleteCard({ cardId: BigInt(card.id) }) onUpdated={refreshCards}
.then(result => {
if (!result.success) throw new Error(result.message || "Delete failed");
}).then(refreshCards)
.catch((error) => {
toast.error(error instanceof Error ? error.message : "Unknown error");
});
}}
refreshCards={refreshCards}
/> />
))} ))}
</div> </div>
@@ -139,4 +149,4 @@ export function InDeck({ deckId, isReadOnly }: { deckId: number; isReadOnly: boo
/> />
</PageLayout> </PageLayout>
); );
}; }

View File

@@ -1,132 +0,0 @@
"use client";
import { LightButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { X } from "lucide-react";
import { useRef, useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { actionUpdateNote } from "@/modules/note/note-action";
import type { ActionOutputCardWithNote } from "@/modules/card/card-action-dto";
import { toast } from "sonner";
interface UpdateCardModalProps {
isOpen: boolean;
onClose: () => void;
card: ActionOutputCardWithNote;
onUpdated: () => void;
}
export function UpdateCardModal({
isOpen,
onClose,
card,
onUpdated,
}: UpdateCardModalProps) {
const t = useTranslations("deck_id");
const wordRef = useRef<HTMLInputElement>(null);
const definitionRef = useRef<HTMLInputElement>(null);
const ipaRef = useRef<HTMLInputElement>(null);
const exampleRef = useRef<HTMLInputElement>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (isOpen && card) {
const fields = card.note.flds.split('\x1f');
if (wordRef.current) wordRef.current.value = fields[0] || "";
if (definitionRef.current) definitionRef.current.value = fields[1] || "";
if (ipaRef.current) ipaRef.current.value = fields[2] || "";
if (exampleRef.current) exampleRef.current.value = fields[3] || "";
}
}, [isOpen, card]);
if (!isOpen) return null;
const handleUpdate = async () => {
const word = wordRef.current?.value?.trim();
const definition = definitionRef.current?.value?.trim();
if (!word || !definition) {
toast.error(t("wordAndDefinitionRequired"));
return;
}
setIsSubmitting(true);
try {
const fields = [
word,
definition,
ipaRef.current?.value?.trim() || "",
exampleRef.current?.value?.trim() || "",
];
const result = await actionUpdateNote({
noteId: BigInt(card.note.id),
fields,
});
if (!result.success) {
throw new Error(result.message || "Failed to update note");
}
toast.success(result.message);
onUpdated();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Unknown error");
} finally {
setIsSubmitting(false);
}
};
return (
<div
className="fixed inset-0 bg-black/50 backdrop-blur flex items-center justify-center z-50"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleUpdate();
}
}}
>
<div className="bg-white rounded-md p-6 w-full max-w-md mx-4">
<div className="flex">
<h2 className="flex-1 text-xl font-light mb-4 text-center">
{t("updateCard")}
</h2>
<X onClick={onClose} className="hover:cursor-pointer"></X>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("word")} *
</label>
<Input ref={wordRef} className="w-full"></Input>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("definition")} *
</label>
<Input ref={definitionRef} className="w-full"></Input>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("ipa")}
</label>
<Input ref={ipaRef} className="w-full"></Input>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("example")}
</label>
<Input ref={exampleRef} className="w-full"></Input>
</div>
</div>
<div className="mt-4">
<LightButton onClick={handleUpdate} disabled={isSubmitting}>
{isSubmitting ? t("updating") : t("update")}
</LightButton>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,468 @@
"use client";
import { useState, useEffect, useTransition, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import localFont from "next/font/local";
import { Layers, Check, RotateCcw, Volume2, Headphones, ChevronLeft, ChevronRight, Shuffle, List, Repeat, Infinity } from "lucide-react";
import { actionGetCardsByDeckId } from "@/modules/card/card-action";
import type { ActionOutputCard } from "@/modules/card/card-action-dto";
import { PageLayout } from "@/components/ui/PageLayout";
import { LightButton, CircleButton } from "@/design-system/base/button";
import { Progress } from "@/design-system/feedback/progress";
import { Skeleton } from "@/design-system/feedback/skeleton";
import { HStack, VStack } from "@/design-system/layout/stack";
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
import { getTTSUrl, type TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
const myFont = localFont({
src: "../../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
});
type StudyMode = "order-limited" | "order-infinite" | "random-limited" | "random-infinite";
interface MemorizeProps {
deckId: number;
deckName: string;
}
const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
const t = useTranslations("memorize.review");
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [originalCards, setOriginalCards] = useState<ActionOutputCard[]>([]);
const [cards, setCards] = useState<ActionOutputCard[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [showAnswer, setShowAnswer] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isReversed, setIsReversed] = useState(false);
const [isDictation, setIsDictation] = useState(false);
const [studyMode, setStudyMode] = useState<StudyMode>("order-limited");
const { play, stop, load } = useAudioPlayer();
const audioUrlRef = useRef<string | null>(null);
const [isAudioLoading, setIsAudioLoading] = useState(false);
const shuffleCards = useCallback((cardArray: ActionOutputCard[]): ActionOutputCard[] => {
const shuffled = [...cardArray];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}, []);
useEffect(() => {
let ignore = false;
const loadCards = async () => {
setIsLoading(true);
setError(null);
startTransition(async () => {
const result = await actionGetCardsByDeckId({ deckId, limit: 100 });
if (!ignore) {
if (result.success && result.data) {
setOriginalCards(result.data);
setCards(result.data);
setCurrentIndex(0);
setShowAnswer(false);
setIsReversed(false);
setIsDictation(false);
} else {
setError(result.message);
}
setIsLoading(false);
}
});
};
loadCards();
return () => {
ignore = true;
};
}, [deckId]);
useEffect(() => {
if (studyMode.startsWith("random")) {
setCards(shuffleCards(originalCards));
} else {
setCards(originalCards);
}
setCurrentIndex(0);
setShowAnswer(false);
}, [studyMode, originalCards, shuffleCards]);
const getCurrentCard = (): ActionOutputCard | null => {
return cards[currentIndex] ?? null;
};
const getFrontText = (card: ActionOutputCard): string => {
if (isReversed) {
return card.meanings.map((m) =>
m.partOfSpeech ? `${m.partOfSpeech}: ${m.definition}` : m.definition
).join("; ");
}
return card.word;
};
const getBackContent = (card: ActionOutputCard): React.ReactNode => {
if (isReversed) {
return <span className="text-gray-900 text-xl md:text-2xl text-center">{card.word}</span>;
}
return (
<VStack align="stretch" gap={2} className="w-full max-w-lg">
{card.meanings.map((m, idx) => (
<div key={idx} className="flex gap-3 text-left">
{m.partOfSpeech && (
<span className="text-primary-600 text-sm font-medium min-w-[60px] shrink-0">
{m.partOfSpeech}
</span>
)}
<span className="text-gray-800">{m.definition}</span>
</div>
))}
</VStack>
);
};
const handleShowAnswer = useCallback(() => {
setShowAnswer(true);
}, []);
const isInfinite = studyMode.endsWith("infinite");
const handleNextCard = useCallback(() => {
if (isInfinite) {
if (currentIndex >= cards.length - 1) {
if (studyMode.startsWith("random")) {
setCards(shuffleCards(originalCards));
}
setCurrentIndex(0);
} else {
setCurrentIndex(currentIndex + 1);
}
} else {
if (currentIndex < cards.length - 1) {
setCurrentIndex(currentIndex + 1);
}
}
setShowAnswer(false);
setIsReversed(false);
setIsDictation(false);
cleanupAudio();
}, [currentIndex, cards.length, isInfinite, studyMode, originalCards, shuffleCards]);
const handlePrevCard = useCallback(() => {
if (isInfinite) {
if (currentIndex <= 0) {
setCurrentIndex(cards.length - 1);
} else {
setCurrentIndex(currentIndex - 1);
}
} else {
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1);
}
}
setShowAnswer(false);
setIsReversed(false);
setIsDictation(false);
cleanupAudio();
}, [currentIndex, cards.length, isInfinite]);
const cleanupAudio = useCallback(() => {
if (audioUrlRef.current) {
URL.revokeObjectURL(audioUrlRef.current);
audioUrlRef.current = null;
}
stop();
}, [stop]);
const playTTS = useCallback(async (text: string) => {
if (isAudioLoading) return;
setIsAudioLoading(true);
try {
const hasChinese = /[\u4e00-\u9fff]/.test(text);
const hasJapanese = /[\u3040-\u309f\u30a0-\u30ff]/.test(text);
const hasKorean = /[\uac00-\ud7af]/.test(text);
let lang: TTS_SUPPORTED_LANGUAGES = "Auto";
if (hasChinese) lang = "Chinese";
else if (hasJapanese) lang = "Japanese";
else if (hasKorean) lang = "Korean";
else if (/^[a-zA-Z\s]/.test(text)) lang = "English";
const audioUrl = await getTTSUrl(text, lang);
if (audioUrl && audioUrl !== "error") {
audioUrlRef.current = audioUrl;
await load(audioUrl);
play();
}
} catch (e) {
console.error("TTS playback failed", e);
} finally {
setIsAudioLoading(false);
}
}, [isAudioLoading, load, play]);
const playCurrentCard = useCallback(() => {
const currentCard = getCurrentCard();
if (!currentCard) return;
const text = isReversed
? currentCard.meanings.map((m) => m.definition).join("; ")
: currentCard.word;
if (text) {
playTTS(text);
}
}, [isReversed, playTTS]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
if (!showAnswer) {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
handleShowAnswer();
}
} else {
if (e.key === "ArrowRight" || e.key === " " || e.key === "Enter") {
e.preventDefault();
handleNextCard();
} else if (e.key === "ArrowLeft") {
e.preventDefault();
handlePrevCard();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [showAnswer, handleShowAnswer, handleNextCard, handlePrevCard]);
if (isLoading) {
return (
<PageLayout>
<VStack align="center" className="py-12">
<Skeleton variant="circular" className="h-12 w-12 mb-4" />
<p className="text-gray-600">{t("loading")}</p>
</VStack>
</PageLayout>
);
}
if (error) {
return (
<PageLayout>
<VStack align="center" className="py-12">
<div className="text-red-600 mb-4 px-4 py-3 bg-red-50 border border-red-200 rounded-lg max-w-md">
{error}
</div>
<LightButton onClick={() => router.push("/decks")} className="px-4 py-2">
{t("backToDecks")}
</LightButton>
</VStack>
</PageLayout>
);
}
if (cards.length === 0) {
return (
<PageLayout>
<VStack align="center" className="py-12">
<div className="text-green-500 mb-4">
<Check className="w-16 h-16 mx-auto" />
</div>
<h2 className="text-2xl font-bold text-gray-800 mb-2">{t("allDone")}</h2>
<p className="text-gray-600 mb-6">{t("allDoneDesc")}</p>
<LightButton onClick={() => router.push("/decks")} className="px-4 py-2">
{t("backToDecks")}
</LightButton>
</VStack>
</PageLayout>
);
}
const currentCard = getCurrentCard()!;
const displayFront = getFrontText(currentCard);
const isFinished = !isInfinite && currentIndex === cards.length - 1 && showAnswer;
const studyModeOptions: { value: StudyMode; label: string; icon: React.ReactNode }[] = [
{ value: "order-limited", label: t("orderLimited"), icon: <List className="w-4 h-4" /> },
{ value: "order-infinite", label: t("orderInfinite"), icon: <Repeat className="w-4 h-4" /> },
{ value: "random-limited", label: t("randomLimited"), icon: <Shuffle className="w-4 h-4" /> },
{ value: "random-infinite", label: t("randomInfinite"), icon: <Infinity className="w-4 h-4" /> },
];
return (
<PageLayout>
<HStack justify="between" className="mb-4">
<HStack gap={2} className="text-gray-600">
<Layers className="w-5 h-5" />
<span className="font-medium">{deckName}</span>
</HStack>
{!isInfinite && (
<span className="text-sm text-gray-500">
{t("progress", { current: currentIndex + 1, total: cards.length })}
</span>
)}
</HStack>
{!isInfinite && (
<Progress
value={((currentIndex + 1) / cards.length) * 100}
showLabel={false}
animated={false}
className="mb-6"
/>
)}
<VStack gap={2} className="mb-4">
<HStack justify="center" gap={1} className="flex-wrap">
{studyModeOptions.map((option) => (
<LightButton
key={option.value}
onClick={() => setStudyMode(option.value)}
selected={studyMode === option.value}
leftIcon={option.icon}
size="sm"
>
{option.label}
</LightButton>
))}
</HStack>
<HStack justify="center" gap={2}>
<LightButton
onClick={() => {
setIsReversed(!isReversed);
setShowAnswer(false);
}}
selected={isReversed}
leftIcon={<RotateCcw className="w-4 h-4" />}
size="sm"
>
{t("reverse")}
</LightButton>
<LightButton
onClick={() => {
setIsDictation(!isDictation);
}}
selected={isDictation}
leftIcon={<Headphones className="w-4 h-4" />}
size="sm"
>
{t("dictation")}
</LightButton>
</HStack>
</VStack>
<div className={`bg-white border border-gray-200 rounded-xl shadow-sm mb-6 h-[50dvh] flex flex-col ${myFont.className}`}>
<div className="flex-1 overflow-y-auto">
{isDictation ? (
<>
<VStack align="center" justify="center" gap={4} className="p-8 min-h-[20dvh]">
{currentCard.ipa ? (
<div className="text-gray-700 text-2xl text-center font-mono">
{currentCard.ipa}
</div>
) : (
<div className="text-gray-400 text-lg">
{t("noIpa")}
</div>
)}
</VStack>
{showAnswer && (
<>
<div className="border-t border-gray-200" />
<VStack align="center" justify="center" className="p-8 min-h-[20dvh] bg-gray-50 rounded-b-xl">
<div className="text-gray-900 text-xl md:text-2xl text-center whitespace-pre-line">
{displayFront}
</div>
{getBackContent(currentCard)}
</VStack>
</>
)}
</>
) : (
<>
<HStack align="center" justify="center" className="p-8 min-h-[20dvh]">
<div className="text-gray-900 text-xl md:text-2xl text-center whitespace-pre-line">
{displayFront}
</div>
</HStack>
{showAnswer && (
<>
<div className="border-t border-gray-200" />
<VStack align="center" justify="center" className="p-8 min-h-[20dvh] bg-gray-50 rounded-b-xl">
{getBackContent(currentCard)}
</VStack>
</>
)}
</>
)}
</div>
</div>
<HStack justify="center">
{!showAnswer ? (
<LightButton
onClick={handleShowAnswer}
disabled={isPending}
className="px-8 py-3 text-lg rounded-full"
>
{t("showAnswer")}
<span className="ml-2 text-xs opacity-60">Space</span>
</LightButton>
) : isFinished ? (
<VStack align="center" gap={4}>
<div className="text-green-500">
<Check className="w-12 h-12" />
</div>
<p className="text-gray-600">{t("allDoneDesc")}</p>
<HStack gap={2}>
<LightButton onClick={() => router.push("/decks")} className="px-4 py-2">
{t("backToDecks")}
</LightButton>
<LightButton onClick={() => setCurrentIndex(0)} className="px-4 py-2">
{t("restart")}
</LightButton>
</HStack>
</VStack>
) : (
<HStack gap={4}>
<LightButton
onClick={handlePrevCard}
className="px-4 py-2"
>
<ChevronLeft className="w-5 h-5" />
</LightButton>
<span className="text-gray-500 text-sm">
{t("nextCard")}
<span className="ml-2 text-xs opacity-60">Space</span>
</span>
<LightButton
onClick={handleNextCard}
className="px-4 py-2"
>
<ChevronRight className="w-5 h-5" />
</LightButton>
</HStack>
)}
</HStack>
</PageLayout>
);
};
export { Memorize };

View File

@@ -0,0 +1,34 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { actionGetDeckById } from "@/modules/deck/deck-action";
import { Memorize } from "./Memorize";
export default async function LearnPage({
params,
}: {
params: Promise<{ deck_id: string }>;
}) {
const session = await auth.api.getSession({ headers: await headers() });
const { deck_id } = await params;
const deckId = Number(deck_id);
if (!deckId) {
redirect("/decks");
}
const deckInfo = (await actionGetDeckById({ deckId })).data;
if (!deckInfo) {
redirect("/decks");
}
const isOwner = session?.user?.id === deckInfo.userId;
const isPublic = deckInfo.visibility === "PUBLIC";
if (!isOwner && !isPublic) {
redirect("/decks");
}
return <Memorize deckId={deckId} deckName={deckInfo.name} />;
}

View File

@@ -99,18 +99,18 @@
* 定义全局 CSS 变量用于主题切换和动态样式 * 定义全局 CSS 变量用于主题切换和动态样式
*/ */
:root { :root {
/* 主题色 - 默认 Teal */ /* 主题色 - 默认 Mist */
--primary-50: #f0f9f8; --primary-50: #f7f8fa;
--primary-100: #e0f2f0; --primary-100: #eef1f5;
--primary-200: #bce6e1; --primary-200: #dce2eb;
--primary-300: #8dd4cc; --primary-300: #c4cdd9;
--primary-400: #5ec2b7; --primary-400: #a3b0c1;
--primary-500: #35786f; --primary-500: #8594a8;
--primary-600: #2a605b; --primary-600: #6b7a8d;
--primary-700: #1f4844; --primary-700: #596474;
--primary-800: #183835; --primary-800: #4b5360;
--primary-900: #122826; --primary-900: #414850;
--primary-950: #0a1413; --primary-950: #22262b;
/* 基础颜色 */ /* 基础颜色 */
--background: #ffffff; --background: #ffffff;
@@ -126,7 +126,7 @@
/* 边框 */ /* 边框 */
--border: #d1d5db; --border: #d1d5db;
--border-secondary: #e5e7eb; --border-secondary: #e5e7eb;
--border-focus: #35786f; --border-focus: #8594a8;
/* 圆角 - 更小的圆角 */ /* 圆角 - 更小的圆角 */
--radius-xs: 0.125rem; --radius-xs: 0.125rem;
@@ -144,7 +144,7 @@
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
--shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25); --shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
--shadow-primary: 0 4px 14px 0 rgba(53, 120, 111, 0.39); --shadow-primary: 0 4px 14px 0 rgba(133, 148, 168, 0.39);
/* 间距 */ /* 间距 */
--spacing-xs: 0.25rem; --spacing-xs: 0.25rem;
@@ -177,7 +177,7 @@ body {
height: 100%; height: 100%;
margin: 0; margin: 0;
padding: 0; padding: 0;
background: var(--background); background: var(--primary-50);
color: var(--foreground); color: var(--foreground);
font-family: var(--font-geist-sans), -apple-system, BlinkMacSystemFont, system-ui, sans-serif; font-family: var(--font-geist-sans), -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
font-size: 1rem; font-size: 1rem;

View File

@@ -73,7 +73,7 @@ export default async function HomePage() {
color="#dd7486" color="#dd7486"
></LinkArea> ></LinkArea>
<LinkArea <LinkArea
href="/memorize" href="/decks"
name={t("memorize.name")} name={t("memorize.name")}
description={t("memorize.description")} description={t("memorize.description")}
color="#cc9988" color="#cc9988"

View File

@@ -4,6 +4,10 @@ import { nextCookies } from "better-auth/next-js";
import { username } from "better-auth/plugins"; import { username } from "better-auth/plugins";
import { createAuthMiddleware, APIError } from "better-auth/api"; import { createAuthMiddleware, APIError } from "better-auth/api";
import { prisma } from "./lib/db"; import { prisma } from "./lib/db";
import { createLogger } from "./lib/logger";
const log = createLogger("auth");
import { import {
sendEmail, sendEmail,
generateVerificationEmailHtml, generateVerificationEmailHtml,
@@ -24,7 +28,7 @@ export const auth = betterAuth({
html: generateResetPasswordEmailHtml(url, user.name || "用户"), html: generateResetPasswordEmailHtml(url, user.name || "用户"),
}); });
if (!result.success) { if (!result.success) {
console.error("[email] Failed to send reset password email:", result.error); log.error("Failed to send reset password email", { error: result.error });
} }
}, },
}, },
@@ -38,7 +42,7 @@ export const auth = betterAuth({
html: generateVerificationEmailHtml(url, user.name || "用户"), html: generateVerificationEmailHtml(url, user.name || "用户"),
}); });
if (!result.success) { if (!result.success) {
console.error("[email] Failed to send verification email:", result.error); log.error("Failed to send verification email", { error: result.error });
} }
}, },
}, },

View File

@@ -1,254 +0,0 @@
"use client";
import { useState, useRef } from "react";
import { Upload, Download, FileUp, X, Check, Loader2 } from "lucide-react";
import { LightButton, PrimaryButton } from "@/design-system/base/button";
import { Modal } from "@/design-system/overlay/modal";
import { actionPreviewApkg, actionImportApkg } from "@/modules/import/import-action";
import { actionExportApkg } from "@/modules/export/export-action";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
interface ImportExportProps {
deckId?: number;
deckName?: string;
onImportComplete?: () => void;
}
interface PreviewDeck {
id: number;
name: string;
cardCount: number;
}
export function ImportButton({ onImportComplete }: ImportExportProps) {
const t = useTranslations("decks");
const [isModalOpen, setIsModalOpen] = useState(false);
const [step, setStep] = useState<"upload" | "select" | "importing">("upload");
const [file, setFile] = useState<File | null>(null);
const [decks, setDecks] = useState<PreviewDeck[]>([]);
const [selectedDeckId, setSelectedDeckId] = useState<number | null>(null);
const [deckName, setDeckName] = useState("");
const [loading, setLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (!selectedFile) return;
if (!selectedFile.name.endsWith(".apkg")) {
toast.error("Please select an .apkg file");
return;
}
setFile(selectedFile);
setLoading(true);
const formData = new FormData();
formData.append("file", selectedFile);
const result = await actionPreviewApkg(formData);
setLoading(false);
if (result.success && result.decks) {
setDecks(result.decks);
setStep("select");
if (result.decks.length === 1) {
setSelectedDeckId(result.decks[0].id);
setDeckName(result.decks[0].name);
}
} else {
toast.error(result.message);
}
};
const handleImport = async () => {
if (!file || selectedDeckId === null) {
toast.error("Please select a deck to import");
return;
}
setStep("importing");
const formData = new FormData();
formData.append("file", file);
formData.append("deckId", selectedDeckId.toString());
if (deckName) {
formData.append("deckName", deckName);
}
const result = await actionImportApkg(formData);
if (result.success) {
toast.success(result.message);
setIsModalOpen(false);
resetState();
onImportComplete?.();
} else {
toast.error(result.message);
setStep("select");
}
};
const resetState = () => {
setStep("upload");
setFile(null);
setDecks([]);
setSelectedDeckId(null);
setDeckName("");
setLoading(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleClose = () => {
setIsModalOpen(false);
resetState();
};
return (
<>
<LightButton onClick={() => setIsModalOpen(true)}>
<Upload size={18} />
{t("importApkg")}
</LightButton>
<Modal open={isModalOpen} onClose={handleClose}>
<div className="p-6 w-full max-w-md">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">{t("importApkg")}</h2>
<button onClick={handleClose} className="text-gray-400 hover:text-gray-600">
<X size={20} />
</button>
</div>
{step === "upload" && (
<div className="space-y-4">
<div
className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-primary-500 transition-colors"
onClick={() => fileInputRef.current?.click()}
>
<FileUp size={40} className="mx-auto text-gray-400 mb-2" />
<p className="text-gray-600">{t("clickToUpload")}</p>
<p className="text-sm text-gray-400">{t("apkgFilesOnly")}</p>
</div>
<input
ref={fileInputRef}
type="file"
accept=".apkg"
className="hidden"
onChange={handleFileSelect}
/>
{loading && (
<div className="flex items-center justify-center gap-2 text-gray-500">
<Loader2 size={20} className="animate-spin" />
<span>{t("parsing")}</span>
</div>
)}
</div>
)}
{step === "select" && (
<div className="space-y-4">
<p className="text-sm text-gray-600">{t("foundDecks", { count: decks.length })}</p>
<div className="space-y-2 max-h-48 overflow-y-auto">
{decks.map((deck) => (
<div
key={deck.id}
className={`p-3 border rounded-lg cursor-pointer transition-colors ${
selectedDeckId === deck.id
? "border-primary-500 bg-primary-50"
: "border-gray-200 hover:border-gray-300"
}`}
onClick={() => {
setSelectedDeckId(deck.id);
setDeckName(deck.name);
}}
>
<div className="flex items-center justify-between">
<span className="font-medium">{deck.name}</span>
<span className="text-sm text-gray-500">{deck.cardCount} cards</span>
</div>
</div>
))}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t("deckName")}
</label>
<input
type="text"
value={deckName}
onChange={(e) => setDeckName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder={t("enterDeckName")}
/>
</div>
<div className="flex gap-2">
<LightButton onClick={() => setStep("upload")} className="flex-1">
{t("back")}
</LightButton>
<PrimaryButton
onClick={handleImport}
disabled={selectedDeckId === null}
className="flex-1"
>
{t("import")}
</PrimaryButton>
</div>
</div>
)}
{step === "importing" && (
<div className="flex flex-col items-center justify-center py-8">
<Loader2 size={40} className="animate-spin text-primary-500 mb-4" />
<p className="text-gray-600">{t("importing")}</p>
</div>
)}
</div>
</Modal>
</>
);
}
export function ExportButton({ deckId, deckName }: ImportExportProps) {
const t = useTranslations("decks");
const [loading, setLoading] = useState(false);
const handleExport = async () => {
if (!deckId) return;
setLoading(true);
const result = await actionExportApkg(deckId);
setLoading(false);
if (result.success && result.data && result.filename) {
const blob = new Blob([result.data], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = result.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(t("exportSuccess"));
} else {
toast.error(result.message);
}
};
return (
<LightButton onClick={handleExport} disabled={loading}>
{loading ? <Loader2 size={18} className="animate-spin" /> : <Download size={18} />}
{t("exportApkg")}
</LightButton>
);
}

View File

@@ -21,7 +21,25 @@ const COMMON_LANGUAGES = [
{ label: "portuguese", value: "portuguese" }, { label: "portuguese", value: "portuguese" },
{ label: "russian", value: "russian" }, { label: "russian", value: "russian" },
{ label: "other", value: "other" }, { label: "other", value: "other" },
]; ] as const;
type LocaleLabel = typeof COMMON_LANGUAGES[number]["label"];
function getLocaleLabel(t: (key: string) => string, label: LocaleLabel): string {
switch (label) {
case "chinese": return t("translator.chinese");
case "english": return t("translator.english");
case "italian": return t("translator.italian");
case "japanese": return t("translator.japanese");
case "korean": return t("translator.korean");
case "french": return t("translator.french");
case "german": return t("translator.german");
case "spanish": return t("translator.spanish");
case "portuguese": return t("translator.portuguese");
case "russian": return t("translator.russian");
case "other": return t("translator.other");
}
}
interface LocaleSelectorProps { interface LocaleSelectorProps {
value: string; value: string;
@@ -62,7 +80,7 @@ export function LocaleSelector({ value, onChange }: LocaleSelectorProps) {
> >
{COMMON_LANGUAGES.map((lang) => ( {COMMON_LANGUAGES.map((lang) => (
<option key={lang.value} value={lang.value}> <option key={lang.value} value={lang.value}>
{t(`translator.${lang.label}`)} {getLocaleLabel(t, lang.label)}
</option> </option>
))} ))}
</Select> </Select>

View File

@@ -1,414 +0,0 @@
import JSZip from "jszip";
import initSqlJs from "sql.js";
import type { Database } from "sql.js";
import { createHash } from "crypto";
import type {
AnkiDeck,
AnkiNoteType,
AnkiDeckConfig,
AnkiNoteRow,
AnkiCardRow,
AnkiRevlogRow,
} from "./types";
const BASE91_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&()*+,-./:;<=>?@[]^_`{|}~";
function generateGuid(): string {
let guid = "";
const bytes = new Uint8Array(10);
crypto.getRandomValues(bytes);
for (let i = 0; i < 10; i++) {
guid += BASE91_CHARS[bytes[i] % BASE91_CHARS.length];
}
return guid;
}
function checksum(text: string): number {
const hash = createHash("sha1").update(text.normalize("NFC")).digest("hex");
return parseInt(hash.substring(0, 8), 16);
}
function createCollectionSql(): string {
return `
CREATE TABLE col (
id INTEGER PRIMARY KEY,
crt INTEGER NOT NULL,
mod INTEGER NOT NULL,
scm INTEGER NOT NULL,
ver INTEGER NOT NULL DEFAULT 11,
dty INTEGER NOT NULL DEFAULT 0,
usn INTEGER NOT NULL DEFAULT 0,
ls INTEGER NOT NULL DEFAULT 0,
conf TEXT NOT NULL,
models TEXT NOT NULL,
decks TEXT NOT NULL,
dconf TEXT NOT NULL,
tags TEXT NOT NULL
);
CREATE TABLE notes (
id INTEGER PRIMARY KEY,
guid TEXT NOT NULL,
mid INTEGER NOT NULL,
mod INTEGER NOT NULL,
usn INTEGER NOT NULL,
tags TEXT NOT NULL,
flds TEXT NOT NULL,
sfld TEXT NOT NULL,
csum INTEGER NOT NULL,
flags INTEGER NOT NULL DEFAULT 0,
data TEXT NOT NULL DEFAULT ''
);
CREATE TABLE cards (
id INTEGER PRIMARY KEY,
nid INTEGER NOT NULL,
did INTEGER NOT NULL,
ord INTEGER NOT NULL,
mod INTEGER NOT NULL,
usn INTEGER NOT NULL,
type INTEGER NOT NULL,
queue INTEGER NOT NULL,
due INTEGER NOT NULL,
ivl INTEGER NOT NULL,
factor INTEGER NOT NULL,
reps INTEGER NOT NULL,
lapses INTEGER NOT NULL,
left INTEGER NOT NULL,
odue INTEGER NOT NULL DEFAULT 0,
odid INTEGER NOT NULL DEFAULT 0,
flags INTEGER NOT NULL DEFAULT 0,
data TEXT NOT NULL DEFAULT ''
);
CREATE TABLE revlog (
id INTEGER PRIMARY KEY,
cid INTEGER NOT NULL,
usn INTEGER NOT NULL,
ease INTEGER NOT NULL,
ivl INTEGER NOT NULL,
lastIvl INTEGER NOT NULL,
factor INTEGER NOT NULL,
time INTEGER NOT NULL,
type INTEGER NOT NULL
);
CREATE TABLE graves (
usn INTEGER NOT NULL,
oid INTEGER NOT NULL,
type INTEGER NOT NULL
);
CREATE INDEX ix_cards_nid ON cards (nid);
CREATE INDEX ix_cards_sched ON cards (did, queue, due);
CREATE INDEX ix_cards_usn ON cards (usn);
CREATE INDEX ix_notes_csum ON notes (csum);
CREATE INDEX ix_notes_usn ON notes (usn);
CREATE INDEX ix_revlog_cid ON revlog (cid);
CREATE INDEX ix_revlog_usn ON revlog (usn);
`;
}
function mapCardType(type: string): number {
switch (type) {
case "NEW": return 0;
case "LEARNING": return 1;
case "REVIEW": return 2;
case "RELEARNING": return 3;
default: return 0;
}
}
function mapCardQueue(queue: string): number {
switch (queue) {
case "USER_BURIED": return -3;
case "SCHED_BURIED": return -2;
case "SUSPENDED": return -1;
case "NEW": return 0;
case "LEARNING": return 1;
case "REVIEW": return 2;
case "IN_LEARNING": return 3;
case "PREVIEW": return 4;
default: return 0;
}
}
export interface ExportDeckData {
deck: {
id: number;
name: string;
desc: string;
collapsed: boolean;
conf: Record<string, unknown>;
};
noteType: {
id: number;
name: string;
kind: "STANDARD" | "CLOZE";
css: string;
fields: { name: string; ord: number }[];
templates: { name: string; ord: number; qfmt: string; afmt: string }[];
};
notes: {
id: bigint;
guid: string;
tags: string;
flds: string;
sfld: string;
csum: number;
}[];
cards: {
id: bigint;
noteId: bigint;
ord: number;
type: string;
queue: string;
due: number;
ivl: number;
factor: number;
reps: number;
lapses: number;
left: number;
}[];
revlogs: {
id: bigint;
cardId: bigint;
ease: number;
ivl: number;
lastIvl: number;
factor: number;
time: number;
type: number;
}[];
media: Map<string, Buffer>;
}
async function createDatabase(data: ExportDeckData): Promise<Uint8Array> {
const SQL = await initSqlJs({
locateFile: (file: string) => `https://sql.js.org/dist/${file}`,
});
const db = new SQL.Database();
try {
db.run(createCollectionSql());
const now = Date.now();
const nowSeconds = Math.floor(now / 1000);
const defaultConfig = {
dueCounts: true,
estTimes: true,
newSpread: 0,
curDeck: data.deck.id,
curModel: data.noteType.id,
};
const deckJson: Record<string, AnkiDeck> = {
[data.deck.id.toString()]: {
id: data.deck.id,
mod: nowSeconds,
name: data.deck.name,
usn: -1,
lrnToday: [0, 0],
revToday: [0, 0],
newToday: [0, 0],
timeToday: [0, 0],
collapsed: data.deck.collapsed,
browserCollapsed: false,
desc: data.deck.desc,
dyn: 0,
conf: 1,
extendNew: 0,
extendRev: 0,
},
"1": {
id: 1,
mod: nowSeconds,
name: "Default",
usn: -1,
lrnToday: [0, 0],
revToday: [0, 0],
newToday: [0, 0],
timeToday: [0, 0],
collapsed: false,
browserCollapsed: false,
desc: "",
dyn: 0,
conf: 1,
extendNew: 0,
extendRev: 0,
},
};
const noteTypeJson: Record<string, AnkiNoteType> = {
[data.noteType.id.toString()]: {
id: data.noteType.id,
name: data.noteType.name,
type: data.noteType.kind === "CLOZE" ? 1 : 0,
mod: nowSeconds,
usn: -1,
sortf: 0,
did: data.deck.id,
flds: data.noteType.fields.map((f, i) => ({
id: now + i,
name: f.name,
ord: f.ord,
sticky: false,
rtl: false,
font: "Arial",
size: 20,
media: [],
})),
tmpls: data.noteType.templates.map((t, i) => ({
id: now + i + 100,
name: t.name,
ord: t.ord,
qfmt: t.qfmt,
afmt: t.afmt,
bqfmt: "",
bafmt: "",
did: null,
})),
css: data.noteType.css,
latexPre: "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n",
latexPost: "\\end{document}",
latexsvg: false,
req: [[0, "any", [0]]],
},
};
const deckConfigJson: Record<string, AnkiDeckConfig> = {
"1": {
id: 1,
mod: nowSeconds,
name: "Default",
usn: -1,
maxTaken: 60,
autoplay: true,
timer: 0,
replayq: true,
new: {
bury: true,
delays: [1, 10],
initialFactor: 2500,
ints: [1, 4, 7],
order: 1,
perDay: 20,
},
rev: {
bury: true,
ease4: 1.3,
ivlFct: 1,
maxIvl: 36500,
perDay: 200,
hardFactor: 1.2,
},
lapse: {
delays: [10],
leechAction: 0,
leechFails: 8,
minInt: 1,
mult: 0,
},
dyn: false,
},
};
db.run(
`INSERT INTO col (id, crt, mod, scm, ver, dty, usn, ls, conf, models, decks, dconf, tags)
VALUES (1, ?, ?, ?, 11, 0, 0, 0, ?, ?, ?, ?, '{}')`,
[
nowSeconds,
now,
now,
JSON.stringify(defaultConfig),
JSON.stringify(noteTypeJson),
JSON.stringify(deckJson),
JSON.stringify(deckConfigJson),
]
);
for (const note of data.notes) {
db.run(
`INSERT INTO notes (id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, '')`,
[
Number(note.id),
note.guid || generateGuid(),
data.noteType.id,
nowSeconds,
-1,
note.tags || " ",
note.flds,
note.sfld,
note.csum || checksum(note.sfld),
]
);
}
for (const card of data.cards) {
db.run(
`INSERT INTO cards (id, nid, did, ord, mod, usn, type, queue, due, ivl, factor, reps, lapses, left, odue, odid, flags, data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, '')`,
[
Number(card.id),
Number(card.noteId),
data.deck.id,
card.ord,
nowSeconds,
-1,
mapCardType(card.type),
mapCardQueue(card.queue),
card.due,
card.ivl,
card.factor,
card.reps,
card.lapses,
card.left,
]
);
}
for (const revlog of data.revlogs) {
db.run(
`INSERT INTO revlog (id, cid, usn, ease, ivl, lastIvl, factor, time, type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
Number(revlog.id),
Number(revlog.cardId),
-1,
revlog.ease,
revlog.ivl,
revlog.lastIvl,
revlog.factor,
revlog.time,
revlog.type,
]
);
}
return db.export();
} finally {
db.close();
}
}
export async function exportApkg(data: ExportDeckData): Promise<Buffer> {
const zip = new JSZip();
const dbData = await createDatabase(data);
zip.file("collection.anki21", dbData);
const mediaMapping: Record<string, string> = {};
const mediaEntries = Array.from(data.media.entries());
mediaEntries.forEach(([filename, buffer], index) => {
mediaMapping[index.toString()] = filename;
zip.file(index.toString(), buffer);
});
zip.file("media", JSON.stringify(mediaMapping));
return zip.generateAsync({ type: "nodebuffer" });
}

View File

@@ -1,175 +0,0 @@
import JSZip from "jszip";
import initSqlJs from "sql.js";
import type { Database, SqlValue } from "sql.js";
import {
type AnkiDeck,
type AnkiNoteType,
type AnkiDeckConfig,
type AnkiNoteRow,
type AnkiCardRow,
type AnkiRevlogRow,
type ParsedApkg,
} from "./types";
async function openDatabase(zip: JSZip): Promise<Database | null> {
const SQL = await initSqlJs({
locateFile: (file: string) => `https://sql.js.org/dist/${file}`,
});
const anki21b = zip.file("collection.anki21b");
const anki21 = zip.file("collection.anki21");
const anki2 = zip.file("collection.anki2");
const dbFile = anki21b || anki21 || anki2;
if (!dbFile) return null;
const dbData = await dbFile.async("uint8array");
return new SQL.Database(dbData);
}
function parseJsonField<T>(jsonStr: string): T {
try {
return JSON.parse(jsonStr);
} catch {
return {} as T;
}
}
function queryAll<T>(db: Database, sql: string, params: SqlValue[] = []): T[] {
const stmt = db.prepare(sql);
try {
stmt.bind(params);
const results: T[] = [];
while (stmt.step()) {
results.push(stmt.getAsObject() as T);
}
return results;
} finally {
stmt.free();
}
}
function queryOne<T>(db: Database, sql: string, params: SqlValue[] = []): T | null {
const results = queryAll<T>(db, sql, params);
return results[0] ?? null;
}
export async function parseApkg(buffer: Buffer): Promise<ParsedApkg> {
const zip = await JSZip.loadAsync(buffer);
const db = await openDatabase(zip);
if (!db) {
throw new Error("No valid Anki database found in APKG file");
}
try {
const col = queryOne<{
crt: number;
mod: number;
ver: number;
conf: string;
models: string;
decks: string;
dconf: string;
tags: string;
}>(db, "SELECT crt, mod, ver, conf, models, decks, dconf, tags FROM col WHERE id = 1");
if (!col) {
throw new Error("Invalid APKG: no collection row found");
}
const decksMap = new Map<number, AnkiDeck>();
const decksJson = parseJsonField<Record<string, AnkiDeck>>(col.decks);
for (const [id, deck] of Object.entries(decksJson)) {
decksMap.set(parseInt(id, 10), deck);
}
const noteTypesMap = new Map<number, AnkiNoteType>();
const modelsJson = parseJsonField<Record<string, AnkiNoteType>>(col.models);
for (const [id, model] of Object.entries(modelsJson)) {
noteTypesMap.set(parseInt(id, 10), model);
}
const deckConfigsMap = new Map<number, AnkiDeckConfig>();
const dconfJson = parseJsonField<Record<string, AnkiDeckConfig>>(col.dconf);
for (const [id, config] of Object.entries(dconfJson)) {
deckConfigsMap.set(parseInt(id, 10), config);
}
const notes = queryAll<AnkiNoteRow>(
db,
"SELECT id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data FROM notes"
);
const cards = queryAll<AnkiCardRow>(
db,
"SELECT id, nid, did, ord, mod, usn, type, queue, due, ivl, factor, reps, lapses, left, odue, odid, flags, data FROM cards"
);
const revlogs = queryAll<AnkiRevlogRow>(
db,
"SELECT id, cid, usn, ease, ivl, lastIvl, factor, time, type FROM revlog"
);
const mediaMap = new Map<string, Buffer>();
const mediaFile = zip.file("media");
if (mediaFile) {
const mediaJson = parseJsonField<Record<string, string>>(await mediaFile.async("text"));
for (const [num, filename] of Object.entries(mediaJson)) {
const mediaData = zip.file(num);
if (mediaData) {
const data = await mediaData.async("nodebuffer");
mediaMap.set(filename, data);
}
}
}
return {
decks: decksMap,
noteTypes: noteTypesMap,
deckConfigs: deckConfigsMap,
notes,
cards,
revlogs,
media: mediaMap,
collectionMeta: {
crt: col.crt,
mod: col.mod,
ver: col.ver,
},
};
} finally {
db.close();
}
}
export function getDeckNotesAndCards(
parsed: ParsedApkg,
deckId: number
): { notes: AnkiNoteRow[]; cards: AnkiCardRow[] } {
const deckCards = parsed.cards.filter(c => c.did === deckId);
const noteIds = new Set(deckCards.map(c => c.nid));
const deckNotes = parsed.notes.filter(n => noteIds.has(n.id));
return { notes: deckNotes, cards: deckCards };
}
export function getDeckNames(parsed: ParsedApkg): { id: number; name: string; cardCount: number }[] {
const cardCounts = new Map<number, number>();
for (const card of parsed.cards) {
cardCounts.set(card.did, (cardCounts.get(card.did) ?? 0) + 1);
}
const result: { id: number; name: string; cardCount: number }[] = [];
for (const [id, deck] of parsed.decks) {
if (deck.dyn === 0) {
result.push({
id,
name: deck.name,
cardCount: cardCounts.get(id) ?? 0,
});
}
}
return result.sort((a, b) => a.name.localeCompare(b.name));
}

View File

@@ -1,193 +0,0 @@
/**
* Anki APKG format types
* Based on Anki's official database schema
*/
// ============================================
// APKG JSON Configuration Types
// ============================================
export interface AnkiField {
id: number;
name: string;
ord: number;
sticky: boolean;
rtl: boolean;
font: string;
size: number;
media: string[];
description?: string;
plainText?: boolean;
collapsed?: boolean;
excludeFromSearch?: boolean;
tag?: number;
preventDeletion?: boolean;
}
export interface AnkiTemplate {
id: number | null;
name: string;
ord: number;
qfmt: string;
afmt: string;
bqfmt?: string;
bafmt?: string;
did?: number | null;
bfont?: string;
bsize?: number;
}
export interface AnkiNoteType {
id: number;
name: string;
type: 0 | 1; // 0=standard, 1=cloze
mod: number;
usn: number;
sortf: number;
did: number | null;
tmpls: AnkiTemplate[];
flds: AnkiField[];
css: string;
latexPre: string;
latexPost: string;
latexsvg: boolean | null;
req: [number, string, number[]][];
originalStockKind?: number;
}
export interface AnkiDeckConfig {
id: number;
mod: number;
name: string;
usn: number;
maxTaken: number;
autoplay: boolean;
timer: 0 | 1;
replayq: boolean;
new: {
bury: boolean;
delays: number[];
initialFactor: number;
ints: [number, number, number];
order: number;
perDay: number;
};
rev: {
bury: boolean;
ease4: number;
ivlFct: number;
maxIvl: number;
perDay: number;
hardFactor: number;
};
lapse: {
delays: number[];
leechAction: 0 | 1;
leechFails: number;
minInt: number;
mult: number;
};
dyn: boolean;
}
export interface AnkiDeck {
id: number;
mod: number;
name: string;
usn: number;
lrnToday: [number, number];
revToday: [number, number];
newToday: [number, number];
timeToday: [number, number];
collapsed: boolean;
browserCollapsed: boolean;
desc: string;
dyn: 0 | 1;
conf: number;
extendNew: number;
extendRev: number;
reviewLimit?: number | null;
newLimit?: number | null;
reviewLimitToday?: number | null;
newLimitToday?: number | null;
md?: boolean;
}
// ============================================
// APKG Database Row Types
// ============================================
export interface AnkiNoteRow {
id: number;
guid: string;
mid: number;
mod: number;
usn: number;
tags: string;
flds: string;
sfld: string;
csum: number;
flags: number;
data: string;
}
export interface AnkiCardRow {
id: number;
nid: number;
did: number;
ord: number;
mod: number;
usn: number;
type: number; // 0=new, 1=learning, 2=review, 3=relearning
queue: number; // -3=buried(user), -2=buried(sched), -1=suspended, 0=new, 1=learning, 2=review, 3=day learning, 4=preview
due: number;
ivl: number;
factor: number;
reps: number;
lapses: number;
left: number;
odue: number;
odid: number;
flags: number;
data: string;
}
export interface AnkiRevlogRow {
id: number;
cid: number;
usn: number;
ease: number;
ivl: number;
lastIvl: number;
factor: number;
time: number;
type: number;
}
// ============================================
// Parsed APKG Types
// ============================================
export interface ParsedApkg {
decks: Map<number, AnkiDeck>;
noteTypes: Map<number, AnkiNoteType>;
deckConfigs: Map<number, AnkiDeckConfig>;
notes: AnkiNoteRow[];
cards: AnkiCardRow[];
revlogs: AnkiRevlogRow[];
media: Map<string, Buffer>;
collectionMeta: {
crt: number;
mod: number;
ver: number;
};
}
export interface ApkgImportResult {
success: boolean;
deckName: string;
noteCount: number;
cardCount: number;
mediaCount: number;
errors: string[];
}

View File

@@ -14,17 +14,31 @@ export async function generateEntries(
const isWord = inputType === "word"; const isWord = inputType === "word";
const prompt = ` const prompt = `
生成词典条目。词语:"${standardForm}"${queryLang}。用${definitionLang}释义。 你是专业词典编纂专家。为词条"${standardForm}"${queryLang}生成${definitionLang}释义。
返回 JSON 【核心要求】
${isWord ? `{"entries":[{"ipa":"音标","partOfSpeech":"词性","definition":"释义","example":"例句"}]}` : `{"entries":[{"definition":"释义","example":"例句"}]}`} 生成尽可能完整、全面的词典条目,包括:
${isWord ? `- 所有常见词性(名词、动词、形容词、副词等)
- 每个词性下的所有常用义项
- 专业领域含义、口语含义、习语用法` : `- 所有常见含义和用法
- 字面义和引申义
- 不同语境下的解释`}
只返回 JSON JSON格式】
${isWord ? `{"entries":[{"ipa":"国际音标","partOfSpeech":"词性","definition":"详细释义","example":"自然例句"}]}` : `{"entries":[{"definition":"详细释义","example":"自然例句"}]}`}
【质量标准】
- 条目数量:尽可能多,不要遗漏常用义项
- 释义:准确、完整、符合母语者习惯
- 例句:自然、地道、展示实际用法
- IPA使用标准国际音标单词/短语必填)
只返回JSON不要其他内容。
`.trim(); `.trim();
try { try {
const result = await getAnswer([ const result = await getAnswer([
{ role: "system", content: "词典条目生成器,只返回 JSON。" }, { role: "system", content: "专业词典编纂专家返回完整JSON词典数据。" },
{ role: "user", content: prompt }, { role: "user", content: prompt },
]).then(parseAIGeneratedJSON<EntriesGenerationResult>); ]).then(parseAIGeneratedJSON<EntriesGenerationResult>);
@@ -47,6 +61,7 @@ ${isWord ? `{"entries":[{"ipa":"音标","partOfSpeech":"词性","definition":"
} }
} }
log.info("Generated dictionary entries", { count: result.entries.length });
return result; return result;
} catch (error) { } catch (error) {
log.error("Entries generation failed", { error: error instanceof Error ? error.message : String(error) }); log.error("Entries generation failed", { error: error instanceof Error ? error.message : String(error) });

View File

@@ -1,12 +1,5 @@
"use server"; "use server";
import OpenAI from "openai";
const openai = new OpenAI({
apiKey: process.env.ZHIPU_API_KEY,
baseURL: "https://open.bigmodel.cn/api/paas/v4",
});
type Messages = Array< type Messages = Array<
| { role: "system"; content: string } | { role: "system"; content: string }
| { role: "user"; content: string } | { role: "user"; content: string }
@@ -20,13 +13,29 @@ async function getAnswer(prompt: string | Messages): Promise<string> {
? [{ role: "user", content: prompt }] ? [{ role: "user", content: prompt }]
: prompt; : prompt;
const response = await openai.chat.completions.create({ const response = await fetch("https://open.bigmodel.cn/api/paas/v4/chat/completions", {
model: process.env.ZHIPU_MODEL_NAME || "glm-4", method: "POST",
messages: messages as OpenAI.Chat.Completions.ChatCompletionMessageParam[], headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${process.env.ZHIPU_API_KEY}`,
},
body: JSON.stringify({
model: process.env.ZHIPU_MODEL_NAME || "glm-4.6",
messages,
temperature: 0.2, temperature: 0.2,
thinking: {
type: "disabled"
}
}),
}); });
const content = response.choices[0]?.message?.content; if (!response.ok) {
throw new Error(`AI API 请求失败: ${response.status}`);
}
const data = await response.json() as { choices?: Array<{ message?: { content?: string } }> };
const content = data.choices?.[0]?.message?.content;
if (!content) { if (!content) {
throw new Error("AI API 返回空响应"); throw new Error("AI API 返回空响应");
} }

View File

@@ -168,12 +168,12 @@ export async function executeTranslation(
let targetIpa: string | undefined; let targetIpa: string | undefined;
if (needIpa) { if (needIpa) {
log.debug("[Stage 3] Generating IPA"); log.debug("[Stage 3] Generating IPA in parallel");
sourceIpa = await generateIPA(sourceText, detectedLanguage); [sourceIpa, targetIpa] = await Promise.all([
log.debug("[Stage 3] Source IPA", { sourceIpa }); generateIPA(sourceText, detectedLanguage),
generateIPA(translatedText, targetLanguage),
targetIpa = await generateIPA(translatedText, targetLanguage); ]);
log.debug("[Stage 3] Target IPA", { targetIpa }); log.debug("[Stage 3] IPA complete", { sourceIpa, targetIpa });
} }
// Assemble final result // Assemble final result

View File

@@ -74,20 +74,8 @@ export async function repoDeleteUserCascade(dto: RepoInputDeleteUserCascade): Pr
log.info("Starting cascade delete for user", { userId }); log.info("Starting cascade delete for user", { userId });
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await tx.revlog.deleteMany({
where: { card: { note: { userId } } }
});
await tx.card.deleteMany({ await tx.card.deleteMany({
where: { note: { userId } } where: { deck: { userId } }
});
await tx.note.deleteMany({
where: { userId }
});
await tx.noteType.deleteMany({
where: { userId }
}); });
await tx.deckFavorite.deleteMany({ await tx.deckFavorite.deleteMany({
@@ -107,14 +95,6 @@ export async function repoDeleteUserCascade(dto: RepoInputDeleteUserCascade): Pr
} }
}); });
await tx.dictionaryLookUp.deleteMany({
where: { userId }
});
await tx.translationHistory.deleteMany({
where: { userId }
});
await tx.session.deleteMany({ await tx.session.deleteMany({
where: { userId } where: { userId }
}); });

View File

@@ -1,164 +1,54 @@
import z from "zod"; import z from "zod";
import { generateValidator } from "@/utils/validate"; import { generateValidator } from "@/utils/validate";
import type { RepoOutputCard, RepoOutputCardStats, CardMeaning, CardType } from "./card-repository-dto";
export type ActionOutputCard = RepoOutputCard;
export type ActionOutputCardStats = RepoOutputCardStats;
export type { CardMeaning, CardType };
const CardMeaningSchema = z.object({
partOfSpeech: z.string().nullable(),
definition: z.string(),
example: z.string().optional().nullable(),
});
export const schemaActionInputCreateCard = z.object({ export const schemaActionInputCreateCard = z.object({
noteId: z.bigint(),
deckId: z.number().int().positive(), deckId: z.number().int().positive(),
ord: z.number().int().min(0).optional(), word: z.string().min(1),
ipa: z.string().optional().nullable(),
queryLang: z.string().min(1),
cardType: z.enum(["WORD", "PHRASE", "SENTENCE"]),
meanings: z.array(CardMeaningSchema).min(1),
}); });
export type ActionInputCreateCard = z.infer<typeof schemaActionInputCreateCard>; export type ActionInputCreateCard = z.infer<typeof schemaActionInputCreateCard>;
export const validateActionInputCreateCard = generateValidator(schemaActionInputCreateCard); export const validateActionInputCreateCard = generateValidator(schemaActionInputCreateCard);
export const schemaActionInputAnswerCard = z.object({ export const schemaActionInputUpdateCard = z.object({
cardId: z.bigint(), cardId: z.number().int().positive(),
ease: z.union([ word: z.string().min(1).optional(),
z.literal(1), ipa: z.string().optional().nullable(),
z.literal(2), meanings: z.array(CardMeaningSchema).min(1).optional(),
z.literal(3),
z.literal(4),
]),
}); });
export type ActionInputAnswerCard = z.infer<typeof schemaActionInputAnswerCard>; export type ActionInputUpdateCard = z.infer<typeof schemaActionInputUpdateCard>;
export const validateActionInputAnswerCard = generateValidator(schemaActionInputAnswerCard); export const validateActionInputUpdateCard = generateValidator(schemaActionInputUpdateCard);
export const schemaActionInputGetCardsForReview = z.object({ export const schemaActionInputDeleteCard = z.object({
deckId: z.number().int().positive(), cardId: z.number().int().positive(),
limit: z.number().int().min(1).max(100).optional(),
}); });
export type ActionInputGetCardsForReview = z.infer<typeof schemaActionInputGetCardsForReview>; export type ActionInputDeleteCard = z.infer<typeof schemaActionInputDeleteCard>;
export const validateActionInputGetCardsForReview = generateValidator(schemaActionInputGetCardsForReview); export const validateActionInputDeleteCard = generateValidator(schemaActionInputDeleteCard);
export const schemaActionInputGetNewCards = z.object({
deckId: z.number().int().positive(),
limit: z.number().int().min(1).max(100).optional(),
});
export type ActionInputGetNewCards = z.infer<typeof schemaActionInputGetNewCards>;
export const validateActionInputGetNewCards = generateValidator(schemaActionInputGetNewCards);
export const schemaActionInputGetCardsByDeckId = z.object({ export const schemaActionInputGetCardsByDeckId = z.object({
deckId: z.number().int().positive(), deckId: z.number().int().positive(),
limit: z.number().int().min(1).max(100).optional(), limit: z.number().int().min(1).max(100).optional(),
offset: z.number().int().min(0).optional(), offset: z.number().int().min(0).optional(),
queue: z.union([
z.enum(["USER_BURIED", "SCHED_BURIED", "SUSPENDED", "NEW", "LEARNING", "REVIEW", "IN_LEARNING", "PREVIEW"]),
z.array(z.enum(["USER_BURIED", "SCHED_BURIED", "SUSPENDED", "NEW", "LEARNING", "REVIEW", "IN_LEARNING", "PREVIEW"])),
]).optional(),
}); });
export type ActionInputGetCardsByDeckId = z.infer<typeof schemaActionInputGetCardsByDeckId>; export type ActionInputGetCardsByDeckId = z.infer<typeof schemaActionInputGetCardsByDeckId>;
export const validateActionInputGetCardsByDeckId = generateValidator(schemaActionInputGetCardsByDeckId); export const validateActionInputGetCardsByDeckId = generateValidator(schemaActionInputGetCardsByDeckId);
export const schemaActionInputGetCardStats = z.object({ export const schemaActionInputGetRandomCard = z.object({
deckId: z.number().int().positive(), deckId: z.number().int().positive(),
excludeIds: z.array(z.number().int().positive()).optional(),
}); });
export type ActionInputGetCardStats = z.infer<typeof schemaActionInputGetCardStats>; export type ActionInputGetRandomCard = z.infer<typeof schemaActionInputGetRandomCard>;
export const validateActionInputGetCardStats = generateValidator(schemaActionInputGetCardStats); export const validateActionInputGetRandomCard = generateValidator(schemaActionInputGetRandomCard);
export const schemaActionInputDeleteCard = z.object({
cardId: z.bigint(),
});
export type ActionInputDeleteCard = z.infer<typeof schemaActionInputDeleteCard>;
export const validateActionInputDeleteCard = generateValidator(schemaActionInputDeleteCard);
export const schemaActionInputGetCardById = z.object({
cardId: z.bigint(),
});
export type ActionInputGetCardById = z.infer<typeof schemaActionInputGetCardById>;
export const validateActionInputGetCardById = generateValidator(schemaActionInputGetCardById);
export type ActionOutputCard = {
id: string;
noteId: string;
deckId: number;
ord: number;
mod: number;
usn: number;
type: "NEW" | "LEARNING" | "REVIEW" | "RELEARNING";
queue: "USER_BURIED" | "SCHED_BURIED" | "SUSPENDED" | "NEW" | "LEARNING" | "REVIEW" | "IN_LEARNING" | "PREVIEW";
due: number;
ivl: number;
factor: number;
reps: number;
lapses: number;
left: number;
odue: number;
odid: number;
flags: number;
data: string;
createdAt: Date;
updatedAt: Date;
};
export type ActionOutputCardWithNote = ActionOutputCard & {
note: {
id: string;
flds: string;
sfld: string;
tags: string;
};
};
export type ActionOutputCardStats = {
total: number;
new: number;
learning: number;
review: number;
due: number;
};
export type ActionOutputScheduledCard = {
cardId: string;
newType: "NEW" | "LEARNING" | "REVIEW" | "RELEARNING";
newQueue: "USER_BURIED" | "SCHED_BURIED" | "SUSPENDED" | "NEW" | "LEARNING" | "REVIEW" | "IN_LEARNING" | "PREVIEW";
newDue: number;
newIvl: number;
newFactor: number;
newReps: number;
newLapses: number;
nextReviewDate: Date;
};
export type ActionOutputCreateCard = {
success: boolean;
message: string;
data?: {
cardId: string;
};
};
export type ActionOutputAnswerCard = {
success: boolean;
message: string;
data?: {
card: ActionOutputCard;
scheduled: ActionOutputScheduledCard;
};
};
export type ActionOutputGetCards = {
success: boolean;
message: string;
data?: ActionOutputCard[];
};
export type ActionOutputGetCardsWithNote = {
success: boolean;
message: string;
data?: ActionOutputCardWithNote[];
};
export type ActionOutputGetCardStats = {
success: boolean;
message: string;
data?: ActionOutputCardStats;
};
export type ActionOutputDeleteCard = {
success: boolean;
message: string;
};
export type ActionOutputGetCardById = {
success: boolean;
message: string;
data?: ActionOutputCardWithNote;
};

View File

@@ -4,300 +4,131 @@ import { auth } from "@/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
import { ValidateError } from "@/lib/errors"; import { ValidateError } from "@/lib/errors";
import {
ActionInputCreateCard,
ActionInputAnswerCard,
ActionInputGetCardsForReview,
ActionInputGetNewCards,
ActionInputGetCardsByDeckId,
ActionInputGetCardStats,
ActionInputDeleteCard,
ActionInputGetCardById,
ActionOutputCreateCard,
ActionOutputAnswerCard,
ActionOutputGetCards,
ActionOutputGetCardsWithNote,
ActionOutputGetCardStats,
ActionOutputDeleteCard,
ActionOutputGetCardById,
ActionOutputCard,
ActionOutputCardWithNote,
ActionOutputScheduledCard,
validateActionInputCreateCard,
validateActionInputAnswerCard,
validateActionInputGetCardsForReview,
validateActionInputGetNewCards,
validateActionInputGetCardsByDeckId,
validateActionInputGetCardStats,
validateActionInputDeleteCard,
validateActionInputGetCardById,
} from "./card-action-dto";
import { import {
serviceCreateCard, serviceCreateCard,
serviceAnswerCard, serviceUpdateCard,
serviceGetCardsForReview,
serviceGetNewCards,
serviceGetCardsByDeckId,
serviceGetCardsByDeckIdWithNotes,
serviceGetCardStats,
serviceDeleteCard, serviceDeleteCard,
serviceGetCardByIdWithNote, serviceGetCardById,
serviceCheckCardOwnership, serviceGetCardsByDeckId,
serviceGetRandomCard,
serviceGetCardStats,
serviceCheckDeckOwnership,
} from "./card-service"; } from "./card-service";
import { CardQueue } from "../../../generated/prisma/enums"; import type { ActionOutputCard, ActionOutputCardStats } from "./card-action-dto";
import type { CardMeaning, CardType } from "./card-repository-dto";
import {
validateActionInputCreateCard,
validateActionInputUpdateCard,
validateActionInputDeleteCard,
validateActionInputGetCardsByDeckId,
validateActionInputGetRandomCard,
} from "./card-action-dto";
const log = createLogger("card-action"); const log = createLogger("card-action");
function mapCardToOutput(card: { function mapCardToOutput(card: any): ActionOutputCard {
id: bigint;
noteId: bigint;
deckId: number;
ord: number;
mod: number;
usn: number;
type: string;
queue: string;
due: number;
ivl: number;
factor: number;
reps: number;
lapses: number;
left: number;
odue: number;
odid: number;
flags: number;
data: string;
createdAt: Date;
updatedAt: Date;
}): ActionOutputCard {
return { return {
id: card.id.toString(), id: card.id,
noteId: card.noteId.toString(),
deckId: card.deckId, deckId: card.deckId,
ord: card.ord, word: card.word,
mod: card.mod, ipa: card.ipa,
usn: card.usn, queryLang: card.queryLang,
type: card.type as ActionOutputCard["type"], cardType: card.cardType,
queue: card.queue as ActionOutputCard["queue"], meanings: card.meanings,
due: card.due,
ivl: card.ivl,
factor: card.factor,
reps: card.reps,
lapses: card.lapses,
left: card.left,
odue: card.odue,
odid: card.odid,
flags: card.flags,
data: card.data,
createdAt: card.createdAt, createdAt: card.createdAt,
updatedAt: card.updatedAt, updatedAt: card.updatedAt,
}; };
} }
function mapCardWithNoteToOutput(card: {
id: bigint;
noteId: bigint;
deckId: number;
ord: number;
mod: number;
usn: number;
type: string;
queue: string;
due: number;
ivl: number;
factor: number;
reps: number;
lapses: number;
left: number;
odue: number;
odid: number;
flags: number;
data: string;
createdAt: Date;
updatedAt: Date;
note: {
id: bigint;
flds: string;
sfld: string;
tags: string;
};
}): ActionOutputCardWithNote {
return {
...mapCardToOutput(card),
note: {
id: card.note.id.toString(),
flds: card.note.flds,
sfld: card.note.sfld,
tags: card.note.tags,
},
};
}
function mapScheduledToOutput(scheduled: {
cardId: bigint;
newType: string;
newQueue: string;
newDue: number;
newIvl: number;
newFactor: number;
newReps: number;
newLapses: number;
nextReviewDate: Date;
}): ActionOutputScheduledCard {
return {
cardId: scheduled.cardId.toString(),
newType: scheduled.newType as ActionOutputScheduledCard["newType"],
newQueue: scheduled.newQueue as ActionOutputScheduledCard["newQueue"],
newDue: scheduled.newDue,
newIvl: scheduled.newIvl,
newFactor: scheduled.newFactor,
newReps: scheduled.newReps,
newLapses: scheduled.newLapses,
nextReviewDate: scheduled.nextReviewDate,
};
}
async function checkCardOwnership(cardId: bigint): Promise<boolean> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) return false;
return serviceCheckCardOwnership({ cardId, userId: session.user.id });
}
async function getCurrentUserId(): Promise<string | null> { async function getCurrentUserId(): Promise<string | null> {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
return session?.user?.id ?? null; return session?.user?.id ?? null;
} }
export async function actionCreateCard( async function checkDeckOwnership(deckId: number): Promise<boolean> {
input: unknown, const userId = await getCurrentUserId();
): Promise<ActionOutputCreateCard> { if (!userId) return false;
return serviceCheckDeckOwnership({ deckId, userId });
}
export async function actionCreateCard(input: unknown) {
try { try {
const userId = await getCurrentUserId(); const userId = await getCurrentUserId();
if (!userId) { if (!userId) {
return { success: false, message: "Unauthorized" }; return { success: false, message: "Unauthorized" };
} }
const validated = validateActionInputCreateCard(input); const validated = validateActionInputCreateCard(input);
const cardId = await serviceCreateCard(validated); const isOwner = await checkDeckOwnership(validated.deckId);
return {
success: true,
message: "Card created successfully",
data: { cardId: cardId.toString() },
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to create card", { error: e });
return { success: false, message: "An error occurred while creating the card" };
}
}
export async function actionAnswerCard(
input: unknown,
): Promise<ActionOutputAnswerCard> {
try {
const userId = await getCurrentUserId();
if (!userId) {
return { success: false, message: "Unauthorized" };
}
const validated = validateActionInputAnswerCard(input);
const isOwner = await checkCardOwnership(validated.cardId);
if (!isOwner) { if (!isOwner) {
return { success: false, message: "You do not have permission to answer this card" }; return { success: false, message: "You do not have permission to add cards to this deck" };
} }
const result = await serviceCreateCard(validated);
const result = await serviceAnswerCard(validated); return result;
return {
success: true,
message: "Card answered successfully",
data: {
card: mapCardToOutput(result.card),
scheduled: mapScheduledToOutput(result.scheduled),
},
};
} catch (e) { } catch (e) {
if (e instanceof ValidateError) { if (e instanceof ValidateError) {
return { success: false, message: e.message }; return { success: false, message: e.message };
} }
log.error("Failed to answer card", { error: e }); log.error("Failed to create card", { error: e instanceof Error ? e.message : String(e) });
return { success: false, message: "An error occurred while answering the card" }; return { success: false, message: "Failed to create card" };
} }
} }
export async function actionGetCardsForReview( export async function actionUpdateCard(input: unknown) {
input: unknown, try {
): Promise<ActionOutputGetCardsWithNote> { const validated = validateActionInputUpdateCard(input);
const card = await serviceGetCardById(validated.cardId);
if (!card) {
return { success: false, message: "Card not found" };
}
const isOwner = await checkDeckOwnership(card.deckId);
if (!isOwner) {
return { success: false, message: "You do not have permission to update this card" };
}
const result = await serviceUpdateCard(validated);
return result;
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to update card", { error: e instanceof Error ? e.message : String(e) });
return { success: false, message: "Failed to update card" };
}
}
export async function actionDeleteCard(input: unknown) {
try {
const validated = validateActionInputDeleteCard(input);
const card = await serviceGetCardById(validated.cardId);
if (!card) {
return { success: false, message: "Card not found" };
}
const isOwner = await checkDeckOwnership(card.deckId);
if (!isOwner) {
return { success: false, message: "You do not have permission to delete this card" };
}
const result = await serviceDeleteCard(validated);
return result;
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to delete card", { error: e instanceof Error ? e.message : String(e) });
return { success: false, message: "Failed to delete card" };
}
}
export async function actionGetCardsByDeckId(input: unknown) {
try { try {
const userId = await getCurrentUserId(); const userId = await getCurrentUserId();
if (!userId) { if (!userId) {
return { success: false, message: "Unauthorized" }; return { success: false, message: "Unauthorized" };
} }
const validated = validateActionInputGetCardsForReview(input);
const cards = await serviceGetCardsForReview(validated);
return {
success: true,
message: "Cards fetched successfully",
data: cards.map(mapCardWithNoteToOutput),
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to get cards for review", { error: e });
return { success: false, message: "An error occurred while fetching cards" };
}
}
export async function actionGetNewCards(
input: unknown,
): Promise<ActionOutputGetCardsWithNote> {
try {
const userId = await getCurrentUserId();
if (!userId) {
return { success: false, message: "Unauthorized" };
}
const validated = validateActionInputGetNewCards(input);
const cards = await serviceGetNewCards(validated);
return {
success: true,
message: "New cards fetched successfully",
data: cards.map(mapCardWithNoteToOutput),
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to get new cards", { error: e });
return { success: false, message: "An error occurred while fetching new cards" };
}
}
export async function actionGetCardsByDeckId(
input: unknown,
): Promise<ActionOutputGetCards> {
try {
const userId = await getCurrentUserId();
if (!userId) {
return { success: false, message: "Unauthorized" };
}
const validated = validateActionInputGetCardsByDeckId(input); const validated = validateActionInputGetCardsByDeckId(input);
const queue = validated.queue as CardQueue | CardQueue[] | undefined; const isOwner = await checkDeckOwnership(validated.deckId);
const cards = await serviceGetCardsByDeckId({ if (!isOwner) {
...validated, return { success: false, message: "You do not have permission to view cards in this deck" };
queue, }
}); const cards = await serviceGetCardsByDeckId(validated);
return { return {
success: true, success: true,
message: "Cards fetched successfully", message: "Cards fetched successfully",
@@ -307,121 +138,83 @@ export async function actionGetCardsByDeckId(
if (e instanceof ValidateError) { if (e instanceof ValidateError) {
return { success: false, message: e.message }; return { success: false, message: e.message };
} }
log.error("Failed to get cards by deck", { error: e }); log.error("Failed to get cards", { error: e instanceof Error ? e.message : String(e) });
return { success: false, message: "An error occurred while fetching cards" }; return { success: false, message: "Failed to get cards" };
} }
} }
export async function actionGetCardsByDeckIdWithNotes( export async function actionGetCardById(cardId: number) {
input: unknown,
): Promise<ActionOutputGetCardsWithNote> {
try { try {
const userId = await getCurrentUserId(); const userId = await getCurrentUserId();
if (!userId) { if (!userId) {
return { success: false, message: "Unauthorized" }; return { success: false, message: "Unauthorized" };
} }
const card = await serviceGetCardById(cardId);
const validated = validateActionInputGetCardsByDeckId(input); if (!card) {
const queue = validated.queue as CardQueue | CardQueue[] | undefined; return { success: false, message: "Card not found" };
const cards = await serviceGetCardsByDeckIdWithNotes({ }
...validated, const isOwner = await checkDeckOwnership(card.deckId);
queue, if (!isOwner) {
}); return { success: false, message: "You do not have permission to view this card" };
}
return { return {
success: true, success: true,
message: "Cards fetched successfully", message: "Card fetched successfully",
data: cards.map(mapCardWithNoteToOutput), data: mapCardToOutput(card),
};
} catch (e) {
log.error("Failed to get card", { error: e instanceof Error ? e.message : String(e) });
return { success: false, message: "Failed to get card" };
}
}
export async function actionGetRandomCard(input: unknown) {
try {
const userId = await getCurrentUserId();
if (!userId) {
return { success: false, message: "Unauthorized" };
}
const validated = validateActionInputGetRandomCard(input);
const isOwner = await checkDeckOwnership(validated.deckId);
if (!isOwner) {
return { success: false, message: "You do not have permission to view cards in this deck" };
}
const card = await serviceGetRandomCard(validated);
if (!card) {
return { success: false, message: "No cards available" };
}
return {
success: true,
message: "Random card fetched successfully",
data: mapCardToOutput(card),
}; };
} catch (e) { } catch (e) {
if (e instanceof ValidateError) { if (e instanceof ValidateError) {
return { success: false, message: e.message }; return { success: false, message: e.message };
} }
log.error("Failed to get cards by deck with notes", { error: e }); log.error("Failed to get random card", { error: e instanceof Error ? e.message : String(e) });
return { success: false, message: "An error occurred while fetching cards" }; return { success: false, message: "Failed to get random card" };
} }
} }
export async function actionGetCardStats( export async function actionGetCardStats(deckId: number) {
input: unknown,
): Promise<ActionOutputGetCardStats> {
try { try {
const userId = await getCurrentUserId(); const userId = await getCurrentUserId();
if (!userId) { if (!userId) {
return { success: false, message: "Unauthorized" }; return { success: false, message: "Unauthorized" };
} }
const isOwner = await checkDeckOwnership(deckId);
const validated = validateActionInputGetCardStats(input); if (!isOwner) {
const stats = await serviceGetCardStats(validated); return { success: false, message: "You do not have permission to view stats for this deck" };
}
const stats = await serviceGetCardStats(deckId);
return { return {
success: true, success: true,
message: "Card stats fetched successfully", message: "Card stats fetched successfully",
data: stats, data: stats,
}; };
} catch (e) { } catch (e) {
if (e instanceof ValidateError) { log.error("Failed to get card stats", { error: e instanceof Error ? e.message : String(e) });
return { success: false, message: e.message }; return { success: false, message: "Failed to get card stats" };
}
log.error("Failed to get card stats", { error: e });
return { success: false, message: "An error occurred while fetching card stats" };
}
}
export async function actionDeleteCard(
input: unknown,
): Promise<ActionOutputDeleteCard> {
try {
const userId = await getCurrentUserId();
if (!userId) {
return { success: false, message: "Unauthorized" };
}
const validated = validateActionInputDeleteCard(input);
const isOwner = await checkCardOwnership(validated.cardId);
if (!isOwner) {
return { success: false, message: "You do not have permission to delete this card" };
}
await serviceDeleteCard(validated.cardId);
return { success: true, message: "Card deleted successfully" };
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to delete card", { error: e });
return { success: false, message: "An error occurred while deleting the card" };
}
}
export async function actionGetCardById(
input: unknown,
): Promise<ActionOutputGetCardById> {
try {
const userId = await getCurrentUserId();
if (!userId) {
return { success: false, message: "Unauthorized" };
}
const validated = validateActionInputGetCardById(input);
const card = await serviceGetCardByIdWithNote(validated.cardId);
if (!card) {
return { success: false, message: "Card not found" };
}
return {
success: true,
message: "Card fetched successfully",
data: mapCardWithNoteToOutput(card),
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to get card by id", { error: e });
return { success: false, message: "An error occurred while fetching the card" };
} }
} }

View File

@@ -1,104 +1,65 @@
import { CardType, CardQueue } from "../../../generated/prisma/enums"; export type CardMeaning = {
partOfSpeech: string | null;
definition: string;
example?: string | null;
}
export const CardTypeEnum = {
WORD: "WORD",
PHRASE: "PHRASE",
SENTENCE: "SENTENCE",
} as const;
export type CardType = keyof typeof CardTypeEnum;
export interface RepoInputCreateCard { export interface RepoInputCreateCard {
id: bigint;
noteId: bigint;
deckId: number; deckId: number;
ord: number; word: string;
due: number; ipa?: string | null;
type?: CardType; queryLang: string;
queue?: CardQueue; cardType: CardType;
ivl?: number; meanings: CardMeaning[];
factor?: number;
reps?: number;
lapses?: number;
left?: number;
odue?: number;
odid?: number;
flags?: number;
data?: string;
} }
export interface RepoInputUpdateCard { export interface RepoInputUpdateCard {
ord?: number; cardId: number;
mod?: number; word?: string;
usn?: number; ipa?: string | null;
type?: CardType; meanings?: CardMeaning[];
queue?: CardQueue; }
due?: number;
ivl?: number; export interface RepoInputDeleteCard {
factor?: number; cardId: number;
reps?: number;
lapses?: number;
left?: number;
odue?: number;
odid?: number;
flags?: number;
data?: string;
} }
export interface RepoInputGetCardsByDeckId { export interface RepoInputGetCardsByDeckId {
deckId: number; deckId: number;
limit?: number; limit?: number;
offset?: number; offset?: number;
queue?: CardQueue | CardQueue[];
} }
export interface RepoInputGetCardsForReview { export interface RepoInputGetRandomCard {
deckId: number; deckId: number;
limit?: number; excludeIds?: number[];
} }
export interface RepoInputGetNewCards { export interface RepoInputCheckCardOwnership {
deckId: number; cardId: number;
limit?: number; userId: string;
}
export interface RepoInputBulkUpdateCard {
id: bigint;
data: RepoInputUpdateCard;
}
export interface RepoInputBulkUpdateCards {
cards: RepoInputBulkUpdateCard[];
} }
export type RepoOutputCard = { export type RepoOutputCard = {
id: bigint; id: number;
noteId: bigint;
deckId: number; deckId: number;
ord: number; word: string;
mod: number; ipa: string | null;
usn: number; queryLang: string;
type: CardType; cardType: CardType;
queue: CardQueue; meanings: CardMeaning[];
due: number;
ivl: number;
factor: number;
reps: number;
lapses: number;
left: number;
odue: number;
odid: number;
flags: number;
data: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
}; }
export type RepoOutputCardWithNote = RepoOutputCard & {
note: {
id: bigint;
flds: string;
sfld: string;
tags: string;
};
};
export type RepoOutputCardStats = { export type RepoOutputCardStats = {
total: number; total: number;
new: number; }
learning: number;
review: number;
due: number;
};

View File

@@ -1,269 +1,131 @@
import { prisma } from "@/lib/db";
import { createLogger } from "@/lib/logger";
import { import {
RepoInputCreateCard, RepoInputCreateCard,
RepoInputUpdateCard, RepoInputUpdateCard,
RepoInputDeleteCard,
RepoInputGetCardsByDeckId, RepoInputGetCardsByDeckId,
RepoInputGetCardsForReview, RepoInputGetRandomCard,
RepoInputGetNewCards, RepoInputCheckCardOwnership,
RepoInputBulkUpdateCards,
RepoOutputCard, RepoOutputCard,
RepoOutputCardWithNote,
RepoOutputCardStats, RepoOutputCardStats,
CardMeaning,
} from "./card-repository-dto"; } from "./card-repository-dto";
import { CardType, CardQueue } from "../../../generated/prisma/enums"; import { prisma } from "@/lib/db";
import { createLogger } from "@/lib/logger";
const log = createLogger("card-repository"); const log = createLogger("card-repository");
export async function repoCreateCard( export async function repoCreateCard(input: RepoInputCreateCard): Promise<number> {
input: RepoInputCreateCard, log.debug("Creating card", { deckId: input.deckId, word: input.word });
): Promise<bigint> {
log.debug("Creating card", { noteId: input.noteId.toString(), deckId: input.deckId });
const card = await prisma.card.create({ const card = await prisma.card.create({
data: { data: {
id: input.id,
noteId: input.noteId,
deckId: input.deckId, deckId: input.deckId,
ord: input.ord, word: input.word,
due: input.due, ipa: input.ipa,
mod: Math.floor(Date.now() / 1000), queryLang: input.queryLang,
type: input.type ?? CardType.NEW, cardType: input.cardType,
queue: input.queue ?? CardQueue.NEW, meanings: {
ivl: input.ivl ?? 0, create: input.meanings.map((m: CardMeaning) => ({
factor: input.factor ?? 2500, partOfSpeech: m.partOfSpeech,
reps: input.reps ?? 0, definition: m.definition,
lapses: input.lapses ?? 0, example: m.example,
left: input.left ?? 0, })),
odue: input.odue ?? 0, },
odid: input.odid ?? 0,
flags: input.flags ?? 0,
data: input.data ?? "",
}, },
}); });
log.info("Card created", { cardId: card.id.toString() }); log.info("Card created", { cardId: card.id });
return card.id; return card.id;
} }
export async function repoUpdateCard( export async function repoUpdateCard(input: RepoInputUpdateCard): Promise<void> {
id: bigint, log.debug("Updating card", { cardId: input.cardId });
input: RepoInputUpdateCard, await prisma.$transaction(async (tx) => {
): Promise<void> { if (input.word !== undefined) {
log.debug("Updating card", { cardId: id.toString() }); await tx.card.update({
await prisma.card.update({ where: { id: input.cardId },
where: { id }, data: { word: input.word },
data: {
...input,
updatedAt: new Date(),
},
}); });
log.info("Card updated", { cardId: id.toString() }); }
if (input.ipa !== undefined) {
await tx.card.update({
where: { id: input.cardId },
data: { ipa: input.ipa },
});
}
if (input.meanings !== undefined) {
await tx.cardMeaning.deleteMany({
where: { cardId: input.cardId },
});
await tx.cardMeaning.createMany({
data: input.meanings.map((m: CardMeaning) => ({
cardId: input.cardId,
partOfSpeech: m.partOfSpeech,
definition: m.definition,
example: m.example,
})),
});
}
await tx.card.update({
where: { id: input.cardId },
data: { updatedAt: new Date() },
});
});
log.info("Card updated", { cardId: input.cardId });
} }
export async function repoGetCardById(id: bigint): Promise<RepoOutputCard | null> { export async function repoDeleteCard(input: RepoInputDeleteCard): Promise<void> {
const card = await prisma.card.findUnique({ log.debug("Deleting card", { cardId: input.cardId });
where: { id },
});
return card;
}
export async function repoGetCardByIdWithNote(
id: bigint,
): Promise<RepoOutputCardWithNote | null> {
const card = await prisma.card.findUnique({
where: { id },
include: {
note: {
select: {
id: true,
flds: true,
sfld: true,
tags: true,
},
},
},
});
return card;
}
export async function repoGetCardsByDeckId(
input: RepoInputGetCardsByDeckId,
): Promise<RepoOutputCard[]> {
const { deckId, limit = 50, offset = 0, queue } = input;
const queueFilter = queue
? Array.isArray(queue)
? { in: queue }
: queue
: undefined;
const cards = await prisma.card.findMany({
where: {
deckId,
queue: queueFilter,
},
orderBy: { due: "asc" },
take: limit,
skip: offset,
});
log.debug("Fetched cards by deck", { deckId, count: cards.length });
return cards;
}
export async function repoGetCardsByDeckIdWithNotes(
input: RepoInputGetCardsByDeckId,
): Promise<RepoOutputCardWithNote[]> {
const { deckId, limit = 100, offset = 0, queue } = input;
const queueFilter = queue
? Array.isArray(queue)
? { in: queue }
: queue
: undefined;
const cards = await prisma.card.findMany({
where: {
deckId,
queue: queueFilter,
},
include: {
note: {
select: {
id: true,
flds: true,
sfld: true,
tags: true,
},
},
},
orderBy: { id: "asc" },
take: limit,
skip: offset,
});
log.debug("Fetched cards by deck with notes", { deckId, count: cards.length });
return cards;
}
export async function repoGetCardsForReview(
input: RepoInputGetCardsForReview,
): Promise<RepoOutputCardWithNote[]> {
const { deckId, limit = 20 } = input;
const now = Math.floor(Date.now() / 1000);
const todayDays = Math.floor(now / 86400);
const cards = await prisma.card.findMany({
where: {
deckId,
queue: { in: [CardQueue.NEW, CardQueue.LEARNING, CardQueue.REVIEW] },
OR: [
{ type: CardType.NEW },
{
type: { in: [CardType.LEARNING, CardType.REVIEW] },
due: { lte: todayDays },
},
],
},
include: {
note: {
select: {
id: true,
flds: true,
sfld: true,
tags: true,
},
},
},
orderBy: [
{ type: "asc" },
{ due: "asc" },
],
take: limit,
});
log.debug("Fetched cards for review", { deckId, count: cards.length });
return cards;
}
export async function repoGetNewCards(
input: RepoInputGetNewCards,
): Promise<RepoOutputCardWithNote[]> {
const { deckId, limit = 20 } = input;
const cards = await prisma.card.findMany({
where: {
deckId,
type: CardType.NEW,
queue: CardQueue.NEW,
},
include: {
note: {
select: {
id: true,
flds: true,
sfld: true,
tags: true,
},
},
},
orderBy: { due: "asc" },
take: limit,
});
log.debug("Fetched new cards", { deckId, count: cards.length });
return cards;
}
export async function repoDeleteCard(id: bigint): Promise<void> {
log.debug("Deleting card", { cardId: id.toString() });
await prisma.card.delete({ await prisma.card.delete({
where: { id }, where: { id: input.cardId },
}); });
log.info("Card deleted", { cardId: id.toString() }); log.info("Card deleted", { cardId: input.cardId });
} }
export async function repoBulkUpdateCards( export async function repoGetCardById(cardId: number): Promise<RepoOutputCard | null> {
input: RepoInputBulkUpdateCards, const card = await prisma.card.findUnique({
): Promise<void> { where: { id: cardId },
log.debug("Bulk updating cards", { count: input.cards.length }); include: { meanings: { orderBy: { createdAt: "asc" } } },
});
await prisma.$transaction( return card as RepoOutputCard | null;
input.cards.map((item) =>
prisma.card.update({
where: { id: item.id },
data: {
...item.data,
updatedAt: new Date(),
},
}),
),
);
log.info("Bulk update completed", { count: input.cards.length });
} }
export async function repoGetCardStats(deckId: number): Promise<RepoOutputCardStats> { export async function repoGetCardsByDeckId(input: RepoInputGetCardsByDeckId): Promise<RepoOutputCard[]> {
const now = Math.floor(Date.now() / 1000); const { deckId, limit = 50, offset = 0 } = input;
const todayDays = Math.floor(now / 86400); const cards = await prisma.card.findMany({
where: { deckId },
const [total, newCards, learning, review, due] = await Promise.all([ include: { meanings: { orderBy: { createdAt: "asc" } } },
prisma.card.count({ where: { deckId } }), orderBy: { createdAt: "desc" },
prisma.card.count({ where: { deckId, type: CardType.NEW } }), take: limit,
prisma.card.count({ where: { deckId, type: CardType.LEARNING } }), skip: offset,
prisma.card.count({ where: { deckId, type: CardType.REVIEW } }), });
prisma.card.count({ log.debug("Fetched cards by deck", { deckId, count: cards.length });
where: { return cards as RepoOutputCard[];
deckId,
type: { in: [CardType.LEARNING, CardType.REVIEW] },
due: { lte: todayDays },
},
}),
]);
return { total, new: newCards, learning, review, due };
} }
export async function repoGetCardDeckOwnerId(cardId: bigint): Promise<string | null> { export async function repoGetRandomCard(input: RepoInputGetRandomCard): Promise<RepoOutputCard | null> {
const { deckId, excludeIds = [] } = input;
const whereClause = excludeIds.length > 0
? { deckId, id: { notIn: excludeIds } }
: { deckId };
const count = await prisma.card.count({ where: whereClause });
if (count === 0) {
return null;
}
const skip = Math.floor(Math.random() * count);
const cards = await prisma.card.findMany({
where: whereClause,
include: { meanings: { orderBy: { createdAt: "asc" } } },
skip,
take: 1,
});
const card = cards[0];
if (!card) {
return null;
}
log.debug("Got random card", { cardId: card.id, deckId });
return card as RepoOutputCard;
}
export async function repoGetCardDeckOwnerId(cardId: number): Promise<string | null> {
const card = await prisma.card.findUnique({ const card = await prisma.card.findUnique({
where: { id: cardId }, where: { id: cardId },
include: { include: {
@@ -275,35 +137,12 @@ export async function repoGetCardDeckOwnerId(cardId: bigint): Promise<string | n
return card?.deck.userId ?? null; return card?.deck.userId ?? null;
} }
export async function repoGetNextDueCard(deckId: number): Promise<RepoOutputCard | null> { export async function repoCheckCardOwnership(input: RepoInputCheckCardOwnership): Promise<boolean> {
const now = Math.floor(Date.now() / 1000); const ownerId = await repoGetCardDeckOwnerId(input.cardId);
const todayDays = Math.floor(now / 86400); return ownerId === input.userId;
const card = await prisma.card.findFirst({
where: {
deckId,
queue: { in: [CardQueue.NEW, CardQueue.LEARNING, CardQueue.REVIEW] },
OR: [
{ type: CardType.NEW },
{
type: { in: [CardType.LEARNING, CardType.REVIEW] },
due: { lte: todayDays },
},
],
},
orderBy: [
{ type: "asc" },
{ due: "asc" },
],
});
return card;
} }
export async function repoGetCardsByNoteId(noteId: bigint): Promise<RepoOutputCard[]> { export async function repoGetCardStats(deckId: number): Promise<RepoOutputCardStats> {
const cards = await prisma.card.findMany({ const total = await prisma.card.count({ where: { deckId } });
where: { noteId }, return { total };
orderBy: { ord: "asc" },
});
return cards;
} }

View File

@@ -1,126 +1,4 @@
import { CardType, CardQueue } from "../../../generated/prisma/enums"; import type { RepoOutputCard, RepoOutputCardStats } from "./card-repository-dto";
export type ReviewEase = 1 | 2 | 3 | 4; export type ServiceOutputCard = RepoOutputCard;
export type ServiceOutputCardStats = RepoOutputCardStats;
export interface ServiceInputCreateCard {
noteId: bigint;
deckId: number;
ord?: number;
}
export interface ServiceInputAnswerCard {
cardId: bigint;
ease: ReviewEase;
}
export interface ServiceInputGetCardsForReview {
deckId: number;
limit?: number;
}
export interface ServiceInputGetNewCards {
deckId: number;
limit?: number;
}
export interface ServiceInputGetCardsByDeckId {
deckId: number;
limit?: number;
offset?: number;
queue?: CardQueue | CardQueue[];
}
export interface ServiceInputGetCardStats {
deckId: number;
}
export interface ServiceInputCheckCardOwnership {
cardId: bigint;
userId: string;
}
export type ServiceOutputCheckCardOwnership = boolean;
export type ServiceOutputCard = {
id: bigint;
noteId: bigint;
deckId: number;
ord: number;
mod: number;
usn: number;
type: CardType;
queue: CardQueue;
due: number;
ivl: number;
factor: number;
reps: number;
lapses: number;
left: number;
odue: number;
odid: number;
flags: number;
data: string;
createdAt: Date;
updatedAt: Date;
};
export type ServiceOutputCardWithNote = ServiceOutputCard & {
note: {
id: bigint;
flds: string;
sfld: string;
tags: string;
};
};
export type ServiceOutputCardStats = {
total: number;
new: number;
learning: number;
review: number;
due: number;
};
export type ServiceOutputScheduledCard = {
cardId: bigint;
newType: CardType;
newQueue: CardQueue;
newDue: number;
newIvl: number;
newFactor: number;
newReps: number;
newLapses: number;
nextReviewDate: Date;
};
export type ServiceOutputReviewResult = {
success: boolean;
card: ServiceOutputCard;
scheduled: ServiceOutputScheduledCard;
};
export const SM2_CONFIG = {
LEARNING_STEPS: [1, 10],
RELEARNING_STEPS: [10],
GRADUATING_INTERVAL_GOOD: 1,
GRADUATING_INTERVAL_EASY: 4,
EASY_INTERVAL: 4,
MINIMUM_FACTOR: 1300,
DEFAULT_FACTOR: 2500,
MAXIMUM_INTERVAL: 36500,
EASY_BONUS: 1.3,
HARD_INTERVAL: 1.2,
NEW_INTERVAL: 0.0,
INTERVAL_MODIFIER: 1.0,
FACTOR_ADJUSTMENTS: {
1: -200,
2: -150,
3: 0,
4: 150,
},
INITIAL_INTERVALS: {
2: 1,
3: 3,
4: 4,
},
} as const;

View File

@@ -1,497 +1,104 @@
import type {
RepoOutputCard,
RepoOutputCardStats,
CardMeaning,
CardType,
} from "./card-repository-dto";
import type {
RepoInputCreateCard,
RepoInputUpdateCard,
RepoInputDeleteCard,
RepoInputGetCardsByDeckId,
RepoInputGetRandomCard,
RepoInputCheckCardOwnership,
} from "./card-repository-dto";
import { repoGetUserIdByDeckId } from "@/modules/deck/deck-repository";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
import { import {
repoCreateCard, repoCreateCard,
repoUpdateCard, repoUpdateCard,
repoGetCardById,
repoGetCardByIdWithNote,
repoGetCardsByDeckId,
repoGetCardsByDeckIdWithNotes,
repoGetCardsForReview,
repoGetNewCards,
repoGetCardStats,
repoDeleteCard, repoDeleteCard,
repoGetCardsByNoteId, repoGetCardById,
repoGetCardDeckOwnerId, repoGetCardsByDeckId,
repoGetRandomCard,
repoGetCardStats,
repoCheckCardOwnership,
} from "./card-repository"; } from "./card-repository";
import {
RepoInputUpdateCard,
RepoOutputCard,
} from "./card-repository-dto";
import {
ServiceInputCreateCard,
ServiceInputAnswerCard,
ServiceInputGetCardsForReview,
ServiceInputGetNewCards,
ServiceInputGetCardsByDeckId,
ServiceInputGetCardStats,
ServiceInputCheckCardOwnership,
ServiceOutputCard,
ServiceOutputCardWithNote,
ServiceOutputCardStats,
ServiceOutputScheduledCard,
ServiceOutputReviewResult,
ServiceOutputCheckCardOwnership,
ReviewEase,
SM2_CONFIG,
} from "./card-service-dto";
import { CardType, CardQueue } from "../../../generated/prisma/enums";
const log = createLogger("card-service"); const log = createLogger("card-service");
function generateCardId(): bigint { export type { CardMeaning as ServiceCardMeaning, CardType as ServiceCardType };
return BigInt(Date.now());
}
function calculateDueDate(intervalDays: number): number { export type ServiceInputCreateCard = RepoInputCreateCard;
const now = Math.floor(Date.now() / 1000); export type ServiceInputUpdateCard = RepoInputUpdateCard;
const todayStart = Math.floor(now / 86400) * 86400; export type ServiceInputDeleteCard = RepoInputDeleteCard;
return Math.floor(todayStart / 86400) + intervalDays; export type ServiceInputGetCardsByDeckId = RepoInputGetCardsByDeckId;
} export type ServiceInputGetRandomCard = RepoInputGetRandomCard;
export type ServiceInputCheckCardOwnership = RepoInputCheckCardOwnership;
function calculateNextReviewTime(intervalDays: number): Date { export type ServiceInputCheckDeckOwnership = {
const now = Date.now(); deckId: number;
return new Date(now + intervalDays * 86400 * 1000); userId: string;
}
function clampInterval(interval: number): number {
return Math.min(Math.max(1, interval), SM2_CONFIG.MAXIMUM_INTERVAL);
}
function scheduleNewCard(ease: ReviewEase, currentFactor: number): {
type: CardType;
queue: CardQueue;
ivl: number;
due: number;
newFactor: number;
} {
if (ease === 1) {
return {
type: CardType.LEARNING,
queue: CardQueue.LEARNING,
ivl: 0,
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[0] * 60,
newFactor: currentFactor,
}; };
export type ServiceOutputCard = RepoOutputCard;
export type ServiceOutputCardStats = RepoOutputCardStats;
export async function serviceCreateCard(input: ServiceInputCreateCard): Promise<{ success: boolean; cardId?: number; message: string }> {
log.info("Creating card", { deckId: input.deckId, word: input.word });
const cardId = await repoCreateCard(input);
log.info("Card created", { cardId });
return { success: true, cardId, message: "Card created successfully" };
} }
if (ease === 2) { export async function serviceUpdateCard(input: ServiceInputUpdateCard): Promise<{ success: boolean; message: string }> {
if (SM2_CONFIG.LEARNING_STEPS.length >= 2) { log.info("Updating card", { cardId: input.cardId });
const avgStep = (SM2_CONFIG.LEARNING_STEPS[0] + SM2_CONFIG.LEARNING_STEPS[1]) / 2;
return {
type: CardType.LEARNING,
queue: CardQueue.LEARNING,
ivl: 0,
due: Math.floor(Date.now() / 1000) + avgStep * 60,
newFactor: currentFactor,
};
}
return {
type: CardType.LEARNING,
queue: CardQueue.LEARNING,
ivl: 0,
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[0] * 60,
newFactor: currentFactor,
};
}
if (ease === 3) {
if (SM2_CONFIG.LEARNING_STEPS.length >= 2) {
return {
type: CardType.LEARNING,
queue: CardQueue.LEARNING,
ivl: 0,
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[1] * 60,
newFactor: currentFactor,
};
}
const ivl = SM2_CONFIG.GRADUATING_INTERVAL_GOOD;
return {
type: CardType.REVIEW,
queue: CardQueue.REVIEW,
ivl,
due: calculateDueDate(ivl),
newFactor: SM2_CONFIG.DEFAULT_FACTOR,
};
}
const ivl = SM2_CONFIG.EASY_INTERVAL;
const newFactor = SM2_CONFIG.DEFAULT_FACTOR + SM2_CONFIG.FACTOR_ADJUSTMENTS[4];
return {
type: CardType.REVIEW,
queue: CardQueue.REVIEW,
ivl,
due: calculateDueDate(ivl),
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, newFactor),
};
}
function scheduleLearningCard(ease: ReviewEase, currentFactor: number, left: number, isRelearning: boolean): {
type: CardType;
queue: CardQueue;
ivl: number;
due: number;
newFactor: number;
newLeft: number;
} {
const steps = isRelearning ? SM2_CONFIG.RELEARNING_STEPS : SM2_CONFIG.LEARNING_STEPS;
const totalSteps = steps.length;
const cardType = isRelearning ? CardType.RELEARNING : CardType.LEARNING;
if (ease === 1) {
return {
type: cardType,
queue: CardQueue.LEARNING,
ivl: 0,
due: Math.floor(Date.now() / 1000) + steps[0] * 60,
newFactor: currentFactor,
newLeft: totalSteps * 1000,
};
}
const stepIndex = Math.floor(left % 1000);
if (ease === 2) {
if (stepIndex === 0 && steps.length >= 2) {
const step0 = steps[0] ?? 1;
const step1 = steps[1] ?? step0;
const avgStep = (step0 + step1) / 2;
return {
type: cardType,
queue: CardQueue.LEARNING,
ivl: 0,
due: Math.floor(Date.now() / 1000) + avgStep * 60,
newFactor: currentFactor,
newLeft: left,
};
}
const currentStepDelay = steps[stepIndex] ?? steps[0] ?? 1;
return {
type: cardType,
queue: CardQueue.LEARNING,
ivl: 0,
due: Math.floor(Date.now() / 1000) + currentStepDelay * 60,
newFactor: currentFactor,
newLeft: left,
};
}
if (ease === 3) {
if (stepIndex < steps.length - 1) {
const nextStep = stepIndex + 1;
const nextStepDelay = steps[nextStep] ?? steps[0];
return {
type: cardType,
queue: CardQueue.LEARNING,
ivl: 0,
due: Math.floor(Date.now() / 1000) + nextStepDelay * 60,
newFactor: currentFactor,
newLeft: nextStep * 1000 + (totalSteps - nextStep),
};
}
const ivl = SM2_CONFIG.GRADUATING_INTERVAL_GOOD;
return {
type: CardType.REVIEW,
queue: CardQueue.REVIEW,
ivl,
due: calculateDueDate(ivl),
newFactor: SM2_CONFIG.DEFAULT_FACTOR,
newLeft: 0,
};
}
const ivl = SM2_CONFIG.GRADUATING_INTERVAL_EASY;
const newFactor = SM2_CONFIG.DEFAULT_FACTOR + SM2_CONFIG.FACTOR_ADJUSTMENTS[4];
return {
type: CardType.REVIEW,
queue: CardQueue.REVIEW,
ivl,
due: calculateDueDate(ivl),
newFactor: Math.max(SM2_CONFIG.MINIMUM_FACTOR, newFactor),
newLeft: 0,
};
}
function scheduleReviewCard(
ease: ReviewEase,
currentIvl: number,
currentFactor: number,
lapses: number,
): {
type: CardType;
queue: CardQueue;
ivl: number;
due: number;
newFactor: number;
newLapses: number;
} {
if (ease === 1) {
const newFactor = Math.max(SM2_CONFIG.MINIMUM_FACTOR, currentFactor + SM2_CONFIG.FACTOR_ADJUSTMENTS[1]);
const newIvl = Math.max(1, Math.floor(currentIvl * SM2_CONFIG.NEW_INTERVAL));
return {
type: CardType.RELEARNING,
queue: CardQueue.LEARNING,
ivl: newIvl,
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.RELEARNING_STEPS[0] * 60,
newFactor,
newLapses: lapses + 1,
};
}
let newFactor: number;
let newIvl: number;
if (ease === 2) {
newFactor = Math.max(SM2_CONFIG.MINIMUM_FACTOR, currentFactor + SM2_CONFIG.FACTOR_ADJUSTMENTS[2]);
newIvl = Math.floor(currentIvl * SM2_CONFIG.HARD_INTERVAL * SM2_CONFIG.INTERVAL_MODIFIER);
} else if (ease === 3) {
newFactor = currentFactor;
newIvl = Math.floor(currentIvl * (currentFactor / 1000) * SM2_CONFIG.INTERVAL_MODIFIER);
} else {
newIvl = Math.floor(currentIvl * (currentFactor / 1000) * SM2_CONFIG.EASY_BONUS * SM2_CONFIG.INTERVAL_MODIFIER);
newFactor = Math.max(SM2_CONFIG.MINIMUM_FACTOR, currentFactor + SM2_CONFIG.FACTOR_ADJUSTMENTS[4]);
}
newIvl = clampInterval(newIvl);
newIvl = Math.max(currentIvl + 1, newIvl);
return {
type: CardType.REVIEW,
queue: CardQueue.REVIEW,
ivl: newIvl,
due: calculateDueDate(newIvl),
newFactor,
newLapses: lapses,
};
}
function mapToServiceOutput(card: RepoOutputCard): ServiceOutputCard {
return {
id: card.id,
noteId: card.noteId,
deckId: card.deckId,
ord: card.ord,
mod: card.mod,
usn: card.usn,
type: card.type,
queue: card.queue,
due: card.due,
ivl: card.ivl,
factor: card.factor,
reps: card.reps,
lapses: card.lapses,
left: card.left,
odue: card.odue,
odid: card.odid,
flags: card.flags,
data: card.data,
createdAt: card.createdAt,
updatedAt: card.updatedAt,
};
}
export async function serviceCreateCard(
input: ServiceInputCreateCard,
): Promise<bigint> {
log.info("Creating card from note", { noteId: input.noteId.toString(), deckId: input.deckId });
const existingCards = await repoGetCardsByNoteId(input.noteId);
const maxOrd = existingCards.reduce((max, c) => Math.max(max, c.ord), -1);
const ord = input.ord ?? maxOrd + 1;
const cardId = await repoCreateCard({
id: generateCardId(),
noteId: input.noteId,
deckId: input.deckId,
ord,
due: ord,
type: CardType.NEW,
queue: CardQueue.NEW,
});
log.info("Card created", { cardId: cardId.toString() });
return cardId;
}
export async function serviceAnswerCard(
input: ServiceInputAnswerCard,
): Promise<ServiceOutputReviewResult> {
log.info("Answering card", { cardId: input.cardId.toString(), ease: input.ease });
const card = await repoGetCardById(input.cardId); const card = await repoGetCardById(input.cardId);
if (!card) { if (!card) {
throw new Error(`Card not found: ${input.cardId.toString()}`); return { success: false, message: "Card not found" };
}
await repoUpdateCard(input);
log.info("Card updated", { cardId: input.cardId });
return { success: true, message: "Card updated successfully" };
} }
const { ease } = input; export async function serviceDeleteCard(input: ServiceInputDeleteCard): Promise<{ success: boolean; message: string }> {
let updateData: RepoInputUpdateCard; log.info("Deleting card", { cardId: input.cardId });
let scheduled: ServiceOutputScheduledCard; const card = await repoGetCardById(input.cardId);
if (!card) {
if (card.type === CardType.NEW) { return { success: false, message: "Card not found" };
const result = scheduleNewCard(ease, card.factor); }
updateData = { await repoDeleteCard(input);
type: result.type, log.info("Card deleted", { cardId: input.cardId });
queue: result.queue, return { success: true, message: "Card deleted successfully" };
ivl: result.ivl,
due: result.due,
factor: result.newFactor,
reps: card.reps + 1,
left: result.type === CardType.LEARNING
? SM2_CONFIG.LEARNING_STEPS.length * 1000
: 0,
mod: Math.floor(Date.now() / 1000),
};
scheduled = {
cardId: card.id,
newType: result.type,
newQueue: result.queue,
newDue: result.due,
newIvl: result.ivl,
newFactor: result.newFactor,
newReps: card.reps + 1,
newLapses: card.lapses,
nextReviewDate: calculateNextReviewTime(result.ivl),
};
} else if (card.type === CardType.LEARNING || card.type === CardType.RELEARNING) {
const result = scheduleLearningCard(ease, card.factor, card.left, card.type === CardType.RELEARNING);
updateData = {
type: result.type,
queue: result.queue,
ivl: result.ivl,
due: result.due,
factor: result.newFactor,
reps: card.reps + 1,
left: result.newLeft,
mod: Math.floor(Date.now() / 1000),
};
scheduled = {
cardId: card.id,
newType: result.type,
newQueue: result.queue,
newDue: result.due,
newIvl: result.ivl,
newFactor: result.newFactor,
newReps: card.reps + 1,
newLapses: card.lapses,
nextReviewDate: calculateNextReviewTime(result.ivl),
};
} else {
const result = scheduleReviewCard(ease, card.ivl, card.factor, card.lapses);
updateData = {
type: result.type,
queue: result.queue,
ivl: result.ivl,
due: result.due,
factor: result.newFactor,
reps: card.reps + 1,
lapses: result.newLapses,
left: result.type === CardType.RELEARNING
? SM2_CONFIG.RELEARNING_STEPS.length * 1000
: 0,
mod: Math.floor(Date.now() / 1000),
};
scheduled = {
cardId: card.id,
newType: result.type,
newQueue: result.queue,
newDue: result.due,
newIvl: result.ivl,
newFactor: result.newFactor,
newReps: card.reps + 1,
newLapses: result.newLapses,
nextReviewDate: calculateNextReviewTime(result.ivl),
};
} }
await repoUpdateCard(input.cardId, updateData); export async function serviceGetCardById(cardId: number): Promise<ServiceOutputCard | null> {
return repoGetCardById(cardId);
const updatedCard = await repoGetCardById(input.cardId);
if (!updatedCard) {
throw new Error(`Card not found after update: ${input.cardId.toString()}`);
} }
log.info("Card answered and scheduled", { export async function serviceGetCardsByDeckId(input: ServiceInputGetCardsByDeckId): Promise<ServiceOutputCard[]> {
cardId: input.cardId.toString(),
newType: scheduled.newType,
newIvl: scheduled.newIvl,
nextReview: scheduled.nextReviewDate.toISOString(),
});
return {
success: true,
card: mapToServiceOutput(updatedCard),
scheduled,
};
}
export async function serviceGetNextCardForReview(
deckId: number,
): Promise<ServiceOutputCardWithNote | null> {
log.debug("Getting next card for review", { deckId });
const cards = await repoGetCardsForReview({ deckId, limit: 1 });
return cards[0] ?? null;
}
export async function serviceGetCardsForReview(
input: ServiceInputGetCardsForReview,
): Promise<ServiceOutputCardWithNote[]> {
log.debug("Getting cards for review", { deckId: input.deckId });
return repoGetCardsForReview(input);
}
export async function serviceGetNewCards(
input: ServiceInputGetNewCards,
): Promise<ServiceOutputCardWithNote[]> {
log.debug("Getting new cards", { deckId: input.deckId });
return repoGetNewCards(input);
}
export async function serviceGetCardsByDeckId(
input: ServiceInputGetCardsByDeckId,
): Promise<ServiceOutputCard[]> {
log.debug("Getting cards by deck", { deckId: input.deckId }); log.debug("Getting cards by deck", { deckId: input.deckId });
const cards = await repoGetCardsByDeckId(input); return repoGetCardsByDeckId(input);
return cards.map(mapToServiceOutput);
} }
export async function serviceGetCardsByDeckIdWithNotes( export async function serviceGetRandomCard(input: ServiceInputGetRandomCard): Promise<ServiceOutputCard | null> {
input: ServiceInputGetCardsByDeckId, log.debug("Getting random card", { deckId: input.deckId });
): Promise<ServiceOutputCardWithNote[]> { return repoGetRandomCard(input);
log.debug("Getting cards by deck with notes", { deckId: input.deckId });
return repoGetCardsByDeckIdWithNotes(input);
} }
export async function serviceGetCardById( export async function serviceGetCardStats(deckId: number): Promise<ServiceOutputCardStats> {
cardId: bigint, log.debug("Getting card stats", { deckId });
): Promise<ServiceOutputCard | null> { return repoGetCardStats(deckId);
const card = await repoGetCardById(cardId);
return card ? mapToServiceOutput(card) : null;
} }
export async function serviceGetCardByIdWithNote( export async function serviceCheckCardOwnership(input: ServiceInputCheckCardOwnership): Promise<boolean> {
cardId: bigint, log.debug("Checking card ownership", { cardId: input.cardId });
): Promise<ServiceOutputCardWithNote | null> { return repoCheckCardOwnership(input);
return repoGetCardByIdWithNote(cardId);
} }
export async function serviceGetCardStats( export async function serviceCheckDeckOwnership(input: ServiceInputCheckDeckOwnership): Promise<boolean> {
input: ServiceInputGetCardStats, log.debug("Checking deck ownership", { deckId: input.deckId });
): Promise<ServiceOutputCardStats> { const ownerId = await repoGetUserIdByDeckId(input.deckId);
log.debug("Getting card stats", { deckId: input.deckId });
return repoGetCardStats(input.deckId);
}
export async function serviceDeleteCard(cardId: bigint): Promise<void> {
log.info("Deleting card", { cardId: cardId.toString() });
await repoDeleteCard(cardId);
}
export async function serviceCheckCardOwnership(
input: ServiceInputCheckCardOwnership,
): Promise<ServiceOutputCheckCardOwnership> {
log.debug("Checking card ownership", { cardId: input.cardId.toString() });
const ownerId = await repoGetCardDeckOwnerId(input.cardId);
return ownerId === input.userId; return ownerId === input.userId;
} }

View File

@@ -14,7 +14,6 @@ export const schemaActionInputUpdateDeck = z.object({
name: z.string().min(1).max(100).optional(), name: z.string().min(1).max(100).optional(),
desc: z.string().max(500).optional(), desc: z.string().max(500).optional(),
visibility: z.enum(["PRIVATE", "PUBLIC"]).optional(), visibility: z.enum(["PRIVATE", "PUBLIC"]).optional(),
collapsed: z.boolean().optional(),
}); });
export type ActionInputUpdateDeck = z.infer<typeof schemaActionInputUpdateDeck>; export type ActionInputUpdateDeck = z.infer<typeof schemaActionInputUpdateDeck>;
export const validateActionInputUpdateDeck = generateValidator(schemaActionInputUpdateDeck); export const validateActionInputUpdateDeck = generateValidator(schemaActionInputUpdateDeck);
@@ -44,8 +43,6 @@ export type ActionOutputDeck = {
desc: string; desc: string;
userId: string; userId: string;
visibility: "PRIVATE" | "PUBLIC"; visibility: "PRIVATE" | "PUBLIC";
collapsed: boolean;
conf: unknown;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
cardCount?: number; cardCount?: number;

View File

@@ -100,7 +100,6 @@ export async function actionUpdateDeck(input: ActionInputUpdateDeck): Promise<Ac
name: validatedInput.name, name: validatedInput.name,
desc: validatedInput.desc, desc: validatedInput.desc,
visibility: validatedInput.visibility as Visibility | undefined, visibility: validatedInput.visibility as Visibility | undefined,
collapsed: validatedInput.collapsed,
}); });
} catch (e) { } catch (e) {
if (e instanceof ValidateError) { if (e instanceof ValidateError) {

View File

@@ -1,90 +1,74 @@
import { Visibility } from "../../../generated/prisma/enums"; import { Visibility } from "../../../generated/prisma/enums";
import type { DictionaryItemWithEntries } from "@/shared/card-type";
export interface RepoInputCreateDeck { export interface RepoInputCreateDeck {
name: string; name: string;
desc?: string; desc?: string;
userId: string; userId: string;
visibility?: Visibility; visibility?: Visibility;
} }
export interface RepoInputUpdateDeck { export interface RepoInputUpdateDeck {
id: number; id: number;
name?: string; name?: string;
desc?: string; desc?: string;
visibility?: Visibility; visibility?: Visibility;
collapsed?: boolean;
} }
export interface RepoInputGetDeckById { export interface RepoInputGetDeckById {
id: number; id: number;
} }
export interface RepoInputGetDecksByUserId { export interface RepoInputGetDecksByUserId {
userId: string; userId: string;
} }
export interface RepoInputGetPublicDecks { export interface RepoInputGetPublicDecks {
limit?: number; limit?: number;
offset?: number; offset?: number;
orderBy?: "createdAt" | "name"; orderBy?: "createdAt" | "name";
} }
export interface RepoInputDeleteDeck { export interface RepoInputDeleteDeck {
id: number; id: number;
} }
export type RepoOutputDeck = { export type RepoOutputDeck = {
id: number; id: number;
name: string; name: string;
desc: string; desc: string;
userId: string; userId: string;
visibility: Visibility; visibility: Visibility;
collapsed: boolean;
conf: unknown;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
cardCount?: number; cardCount?: number;
}; };
export type RepoOutputPublicDeck = RepoOutputDeck & { export type RepoOutputPublicDeck = RepoOutputDeck & {
userName: string | null; userName: string | null;
userUsername: string | null; userUsername: string | null;
favoriteCount: number; favoriteCount: number;
}; };
export type RepoOutputDeckOwnership = { export type RepoOutputDeckOwnership = {
userId: string; userId: string;
}; };
export interface RepoInputToggleDeckFavorite { export interface RepoInputToggleDeckFavorite {
deckId: number; deckId: number;
userId: string; userId: string;
} }
export interface RepoInputCheckDeckFavorite { export interface RepoInputCheckDeckFavorite {
deckId: number; deckId: number;
userId: string; userId: string;
} }
export interface RepoInputSearchPublicDecks { export interface RepoInputSearchPublicDecks {
query: string; query: string;
limit?: number; limit?: number;
offset?: number; offset?: number;
} }
export interface RepoInputGetPublicDeckById { export interface RepoInputGetPublicDeckById {
deckId: number; deckId: number;
} }
export type RepoOutputDeckFavorite = { export type RepoOutputDeckFavorite = {
isFavorited: boolean; isFavorited: boolean;
favoriteCount: number; favoriteCount: number;
}; };
export interface RepoInputGetUserFavoriteDecks { export interface RepoInputGetUserFavoriteDecks {
userId: string; userId: string;
} }
export type RepoOutputUserFavoriteDeck = RepoOutputPublicDeck & { export type RepoOutputUserFavoriteDeck = RepoOutputPublicDeck & {
favoritedAt: Date; favoritedAt: Date;
}; };

View File

@@ -57,8 +57,6 @@ export async function repoGetDeckById(input: RepoInputGetDeckById): Promise<Repo
desc: deck.desc, desc: deck.desc,
userId: deck.userId, userId: deck.userId,
visibility: deck.visibility, visibility: deck.visibility,
collapsed: deck.collapsed,
conf: deck.conf,
createdAt: deck.createdAt, createdAt: deck.createdAt,
updatedAt: deck.updatedAt, updatedAt: deck.updatedAt,
cardCount: deck._count?.cards ?? 0, cardCount: deck._count?.cards ?? 0,
@@ -84,8 +82,6 @@ export async function repoGetDecksByUserId(input: RepoInputGetDecksByUserId): Pr
desc: deck.desc, desc: deck.desc,
userId: deck.userId, userId: deck.userId,
visibility: deck.visibility, visibility: deck.visibility,
collapsed: deck.collapsed,
conf: deck.conf,
createdAt: deck.createdAt, createdAt: deck.createdAt,
updatedAt: deck.updatedAt, updatedAt: deck.updatedAt,
cardCount: deck._count?.cards ?? 0, cardCount: deck._count?.cards ?? 0,
@@ -116,8 +112,6 @@ export async function repoGetPublicDecks(input: RepoInputGetPublicDecks = {}): P
desc: deck.desc, desc: deck.desc,
userId: deck.userId, userId: deck.userId,
visibility: deck.visibility, visibility: deck.visibility,
collapsed: deck.collapsed,
conf: deck.conf,
createdAt: deck.createdAt, createdAt: deck.createdAt,
updatedAt: deck.updatedAt, updatedAt: deck.updatedAt,
cardCount: deck._count?.cards ?? 0, cardCount: deck._count?.cards ?? 0,
@@ -173,8 +167,6 @@ export async function repoGetPublicDeckById(input: RepoInputGetPublicDeckById):
desc: deck.desc, desc: deck.desc,
userId: deck.userId, userId: deck.userId,
visibility: deck.visibility, visibility: deck.visibility,
collapsed: deck.collapsed,
conf: deck.conf,
createdAt: deck.createdAt, createdAt: deck.createdAt,
updatedAt: deck.updatedAt, updatedAt: deck.updatedAt,
cardCount: deck._count?.cards ?? 0, cardCount: deck._count?.cards ?? 0,
@@ -277,8 +269,6 @@ export async function repoSearchPublicDecks(input: RepoInputSearchPublicDecks):
desc: deck.desc, desc: deck.desc,
userId: deck.userId, userId: deck.userId,
visibility: deck.visibility, visibility: deck.visibility,
collapsed: deck.collapsed,
conf: deck.conf,
createdAt: deck.createdAt, createdAt: deck.createdAt,
updatedAt: deck.updatedAt, updatedAt: deck.updatedAt,
cardCount: deck._count?.cards ?? 0, cardCount: deck._count?.cards ?? 0,
@@ -314,8 +304,6 @@ export async function repoGetUserFavoriteDecks(
desc: fav.deck.desc, desc: fav.deck.desc,
userId: fav.deck.userId, userId: fav.deck.userId,
visibility: fav.deck.visibility, visibility: fav.deck.visibility,
collapsed: fav.deck.collapsed,
conf: fav.deck.conf,
createdAt: fav.deck.createdAt, createdAt: fav.deck.createdAt,
updatedAt: fav.deck.updatedAt, updatedAt: fav.deck.updatedAt,
cardCount: fav.deck._count?.cards ?? 0, cardCount: fav.deck._count?.cards ?? 0,

View File

@@ -12,7 +12,6 @@ export type ServiceInputUpdateDeck = {
name?: string; name?: string;
desc?: string; desc?: string;
visibility?: Visibility; visibility?: Visibility;
collapsed?: boolean;
}; };
export type ServiceInputDeleteDeck = { export type ServiceInputDeleteDeck = {
@@ -43,8 +42,6 @@ export type ServiceOutputDeck = {
desc: string; desc: string;
userId: string; userId: string;
visibility: Visibility; visibility: Visibility;
collapsed: boolean;
conf: unknown;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
cardCount?: number; cardCount?: number;

View File

@@ -58,7 +58,6 @@ export async function serviceUpdateDeck(input: ServiceInputUpdateDeck): Promise<
name: input.name, name: input.name,
desc: input.desc, desc: input.desc,
visibility: input.visibility, visibility: input.visibility,
collapsed: input.collapsed,
}); });
log.info("Deck updated successfully", { deckId: input.deckId }); log.info("Deck updated successfully", { deckId: input.deckId });
return { success: true, message: "Deck updated successfully" }; return { success: true, message: "Deck updated successfully" };

View File

@@ -1,22 +1,25 @@
import { TSharedItem } from "@/shared/dictionary-type";
import { LENGTH_MAX_DICTIONARY_TEXT, LENGTH_MAX_LANGUAGE, LENGTH_MIN_DICTIONARY_TEXT, LENGTH_MIN_LANGUAGE } from "@/shared/constant";
import { generateValidator } from "@/utils/validate";
import z from "zod"; import z from "zod";
import { generateValidator } from "@/utils/validate";
const schemaActionInputLookUpDictionary = z.object({ export const schemaActionLookUpDictionary = z.object({
text: z.string().min(LENGTH_MIN_DICTIONARY_TEXT).max(LENGTH_MAX_DICTIONARY_TEXT), text: z.string().min(1),
queryLang: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE), queryLang: z.string().min(1),
forceRelook: z.boolean(), definitionLang: z.string().min(1),
definitionLang: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE),
userId: z.string().optional()
}); });
export type ActionInputLookUpDictionary = z.infer<typeof schemaActionInputLookUpDictionary>; export type ActionInputLookUpDictionary = z.infer<typeof schemaActionLookUpDictionary>;
export const validateActionInputLookUpDictionary = generateValidator(schemaActionLookUpDictionary);
export const validateActionInputLookUpDictionary = generateValidator(schemaActionInputLookUpDictionary);
export type ActionOutputLookUpDictionary = { export type ActionOutputLookUpDictionary = {
message: string,
success: boolean; success: boolean;
data?: TSharedItem; message: string;
data?: {
standardForm: string;
entries: Array<{
ipa?: string;
definition: string;
partOfSpeech?: string;
example: string;
}>;
};
}; };

View File

@@ -1,30 +1,38 @@
"use server"; "use server";
import { ActionInputLookUpDictionary, ActionOutputLookUpDictionary, validateActionInputLookUpDictionary } from "./dictionary-action-dto"; import { executeDictionaryLookup } from "@/lib/bigmodel/dictionary/orchestrator";
import { ValidateError } from "@/lib/errors";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
import { serviceLookUp } from "./dictionary-service"; import { LookUpError } from "@/lib/errors";
import {
ActionInputLookUpDictionary,
ActionOutputLookUpDictionary,
validateActionInputLookUpDictionary,
} from "./dictionary-action-dto";
const log = createLogger("dictionary-action"); const log = createLogger("dictionary-action");
export const actionLookUpDictionary = async (dto: ActionInputLookUpDictionary): Promise<ActionOutputLookUpDictionary> => { export async function actionLookUpDictionary(
input: unknown,
): Promise<ActionOutputLookUpDictionary> {
try { try {
const validated = validateActionInputLookUpDictionary(input);
const result = await executeDictionaryLookup(
validated.text,
validated.queryLang,
validated.definitionLang
);
return { return {
message: 'success',
success: true, success: true,
data: await serviceLookUp(validateActionInputLookUpDictionary(dto)) message: "Lookup successful",
data: result,
}; };
} catch (e) { } catch (e) {
if (e instanceof ValidateError) { if (e instanceof LookUpError) {
return { return { success: false, message: e.message };
success: false,
message: e.message
};
} }
log.error("Dictionary lookup failed", { error: e instanceof Error ? e.message : String(e) }); log.error("Dictionary lookup failed", { error: e instanceof Error ? e.message : String(e) });
return { return { success: false, message: "Lookup failed" };
success: false, }
message: 'Unknown error occured.'
};
} }
};

View File

@@ -1,58 +0,0 @@
export type RepoInputCreateDictionaryLookUp = {
userId?: string;
text: string;
queryLang: string;
definitionLang: string;
dictionaryItemId?: number;
};
export type RepoOutputSelectLastLookUpResultEntry = {
id: number;
itemId: number;
ipa: string | null;
definition: string;
partOfSpeech: string | null;
example: string;
createdAt: Date;
updatedAt: Date;
};
export type RepoOutputSelectLastLookUpResultItem = {
id: number;
frequency: number;
standardForm: string;
queryLang: string;
definitionLang: string;
createdAt: Date;
updatedAt: Date;
entries: RepoOutputSelectLastLookUpResultEntry[];
};
export type RepoOutputSelectLastLookUpResult = RepoOutputSelectLastLookUpResultItem | null;
export type RepoInputCreateDictionaryItem = {
standardForm: string;
queryLang: string;
definitionLang: string;
};
export type RepoInputCreateDictionaryEntry = {
itemId: number;
ipa?: string;
definition: string;
partOfSpeech?: string;
example: string;
};
export type RepoInputCreateDictionaryEntryWithoutItemId = {
ipa?: string;
definition: string;
partOfSpeech?: string;
example: string;
};
export type RepoInputSelectLastLookUpResult = {
text: string,
queryLang: string,
definitionLang: string;
};

View File

@@ -1,75 +0,0 @@
import { stringNormalize } from "@/utils/string";
import {
RepoInputCreateDictionaryEntryWithoutItemId,
RepoInputCreateDictionaryItem,
RepoInputCreateDictionaryLookUp,
RepoInputSelectLastLookUpResult,
RepoOutputSelectLastLookUpResult,
} from "./dictionary-repository-dto";
import { prisma } from "@/lib/db";
export async function repoSelectLastLookUpResult(dto: RepoInputSelectLastLookUpResult): Promise<RepoOutputSelectLastLookUpResult> {
const result = await prisma.dictionaryLookUp.findFirst({
where: {
normalizedText: stringNormalize(dto.text),
queryLang: dto.queryLang,
definitionLang: dto.definitionLang,
dictionaryItemId: {
not: null
}
},
include: {
dictionaryItem: {
include: {
entries: true
}
}
},
orderBy: {
createdAt: 'desc'
}
});
if (!result?.dictionaryItem) {
return null;
}
return result.dictionaryItem;
}
export async function repoCreateLookUp(content: RepoInputCreateDictionaryLookUp) {
return (await prisma.dictionaryLookUp.create({
data: { ...content, normalizedText: stringNormalize(content.text) }
})).id;
}
export async function repoCreateLookUpWithItemAndEntries(
itemData: RepoInputCreateDictionaryItem,
lookUpData: RepoInputCreateDictionaryLookUp,
entries: RepoInputCreateDictionaryEntryWithoutItemId[]
) {
return await prisma.$transaction(async (tx) => {
const item = await tx.dictionaryItem.create({
data: itemData
});
await tx.dictionaryLookUp.create({
data: {
...lookUpData,
normalizedText: stringNormalize(lookUpData.text),
dictionaryItemId: item.id
}
});
for (const entry of entries) {
await tx.dictionaryEntry.create({
data: {
...entry,
itemId: item.id
}
});
}
return item.id;
});
}

View File

@@ -1,11 +1,9 @@
import { TSharedItem } from "@/shared/dictionary-type"; export type ServiceOutputLookUp = {
standardForm: string;
export type ServiceInputLookUp = { entries: Array<{
text: string, ipa?: string;
queryLang: string, definition: string;
definitionLang: string, partOfSpeech?: string;
forceRelook: boolean, example: string;
userId?: string; }>;
}; };
export type ServiceOutputLookUp = TSharedItem;

View File

@@ -1,79 +0,0 @@
import { executeDictionaryLookup } from "@/lib/bigmodel/dictionary/orchestrator";
import { repoCreateLookUp, repoCreateLookUpWithItemAndEntries, repoSelectLastLookUpResult } from "./dictionary-repository";
import { ServiceInputLookUp } from "./dictionary-service-dto";
import { createLogger } from "@/lib/logger";
import { RepoOutputSelectLastLookUpResultItem } from "./dictionary-repository-dto";
const log = createLogger("dictionary-service");
function transformRawItemToSharedItem(rawItem: RepoOutputSelectLastLookUpResultItem) {
return {
id: rawItem.id,
standardForm: rawItem.standardForm,
entries: rawItem.entries.map(entry => ({
ipa: entry.ipa ?? undefined,
definition: entry.definition,
partOfSpeech: entry.partOfSpeech ?? undefined,
example: entry.example
}))
};
}
export const serviceLookUp = async (dto: ServiceInputLookUp) => {
const {
text,
queryLang,
userId,
definitionLang,
forceRelook
} = dto;
const lastLookUpResult = await repoSelectLastLookUpResult({
text,
queryLang,
definitionLang,
});
if (forceRelook || !lastLookUpResult) {
const response = await executeDictionaryLookup(
text,
queryLang,
definitionLang
);
repoCreateLookUpWithItemAndEntries(
{
standardForm: response.standardForm,
queryLang,
definitionLang
},
{
userId,
text,
queryLang,
definitionLang,
},
response.entries
).catch(error => {
log.error("Failed to save dictionary data", { error: error instanceof Error ? error.message : String(error) });
});
return response;
} else {
const transformedResult = transformRawItemToSharedItem(lastLookUpResult);
repoCreateLookUp({
userId: userId,
text: text,
queryLang: queryLang,
definitionLang: definitionLang,
dictionaryItemId: transformedResult.id
}).catch(error => {
log.error("Failed to save dictionary data", { error: error instanceof Error ? error.message : String(error) });
});
return {
standardForm: transformedResult.standardForm,
entries: transformedResult.entries
};
}
};

View File

@@ -1,134 +0,0 @@
"use server";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { prisma } from "@/lib/db";
import { exportApkg, type ExportDeckData } from "@/lib/anki/apkg-exporter";
import { createLogger } from "@/lib/logger";
const log = createLogger("export-action");
export interface ActionOutputExportApkg {
success: boolean;
message: string;
data?: ArrayBuffer;
filename?: string;
}
export async function actionExportApkg(deckId: number): Promise<ActionOutputExportApkg> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return { success: false, message: "Unauthorized" };
}
try {
const deck = await prisma.deck.findFirst({
where: { id: deckId, userId: session.user.id },
include: {
cards: {
include: {
note: {
include: {
noteType: true,
},
},
},
},
},
});
if (!deck) {
return { success: false, message: "Deck not found or access denied" };
}
if (deck.cards.length === 0) {
return { success: false, message: "Deck has no cards to export" };
}
const firstCard = deck.cards[0];
if (!firstCard?.note?.noteType) {
return { success: false, message: "Deck has invalid card data" };
}
const noteType = firstCard.note.noteType;
const revlogs = await prisma.revlog.findMany({
where: {
cardId: { in: deck.cards.map(c => c.id) },
},
});
const exportData: ExportDeckData = {
deck: {
id: deck.id,
name: deck.name,
desc: deck.desc,
collapsed: deck.collapsed,
conf: deck.conf as Record<string, unknown>,
},
noteType: {
id: noteType.id,
name: noteType.name,
kind: noteType.kind,
css: noteType.css,
fields: (noteType.fields as { name: string; ord: number }[]) ?? [],
templates: (noteType.templates as { name: string; ord: number; qfmt: string; afmt: string }[]) ?? [],
},
notes: deck.cards.map((card) => ({
id: card.note.id,
guid: card.note.guid,
tags: card.note.tags,
flds: card.note.flds,
sfld: card.note.sfld,
csum: card.note.csum,
})),
cards: deck.cards.map((card) => ({
id: card.id,
noteId: card.noteId,
ord: card.ord,
type: card.type,
queue: card.queue,
due: card.due,
ivl: card.ivl,
factor: card.factor,
reps: card.reps,
lapses: card.lapses,
left: card.left,
})),
revlogs: revlogs.map((r) => ({
id: r.id,
cardId: r.cardId,
ease: r.ease,
ivl: r.ivl,
lastIvl: r.lastIvl,
factor: r.factor,
time: r.time,
type: r.type,
})),
media: new Map(),
};
const apkgBuffer = await exportApkg(exportData);
log.info("APKG exported successfully", {
userId: session.user.id,
deckId: deck.id,
cardCount: deck.cards.length,
});
const safeDeckName = deck.name.replace(/[^a-zA-Z0-9\u4e00-\u9fff_-]/g, "_");
return {
success: true,
message: "Deck exported successfully",
data: apkgBuffer.buffer.slice(apkgBuffer.byteOffset, apkgBuffer.byteOffset + apkgBuffer.byteLength) as ArrayBuffer,
filename: `${safeDeckName}.apkg`,
};
} catch (error) {
log.error("Failed to export APKG", { error, deckId });
return {
success: false,
message: error instanceof Error ? error.message : "Failed to export deck",
};
}
}

View File

@@ -1,308 +0,0 @@
"use server";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { parseApkg, getDeckNames, getDeckNotesAndCards } from "@/lib/anki/apkg-parser";
import { prisma } from "@/lib/db";
import { CardType, CardQueue, NoteKind } from "../../../generated/prisma/enums";
import { createLogger } from "@/lib/logger";
import { repoGenerateGuid, repoCalculateCsum } from "@/modules/note/note-repository";
import type { ParsedApkg } from "@/lib/anki/types";
import { randomBytes } from "crypto";
const log = createLogger("import-action");
const MAX_APKG_SIZE = 100 * 1024 * 1024;
export interface ActionOutputPreviewApkg {
success: boolean;
message: string;
decks?: { id: number; name: string; cardCount: number }[];
}
export interface ActionOutputImportApkg {
success: boolean;
message: string;
deckId?: number;
noteCount?: number;
cardCount?: number;
}
async function importNoteType(
parsed: ParsedApkg,
ankiNoteTypeId: number,
userId: string
): Promise<number> {
const ankiNoteType = parsed.noteTypes.get(ankiNoteTypeId);
if (!ankiNoteType) {
throw new Error(`Note type ${ankiNoteTypeId} not found in APKG`);
}
const existing = await prisma.noteType.findFirst({
where: { name: ankiNoteType.name, userId },
});
if (existing) {
return existing.id;
}
const fields = ankiNoteType.flds.map((f) => ({
name: f.name,
ord: f.ord,
sticky: f.sticky,
rtl: f.rtl,
font: f.font,
size: f.size,
media: f.media,
}));
const templates = ankiNoteType.tmpls.map((t) => ({
name: t.name,
ord: t.ord,
qfmt: t.qfmt,
afmt: t.afmt,
bqfmt: t.bqfmt,
bafmt: t.bafmt,
did: t.did,
}));
const noteType = await prisma.noteType.create({
data: {
name: ankiNoteType.name,
kind: ankiNoteType.type === 1 ? NoteKind.CLOZE : NoteKind.STANDARD,
css: ankiNoteType.css,
fields: JSON.parse(JSON.stringify(fields)),
templates: JSON.parse(JSON.stringify(templates)),
userId,
},
});
return noteType.id;
}
function mapAnkiCardType(type: number): CardType {
switch (type) {
case 0: return CardType.NEW;
case 1: return CardType.LEARNING;
case 2: return CardType.REVIEW;
case 3: return CardType.RELEARNING;
default: return CardType.NEW;
}
}
function mapAnkiCardQueue(queue: number): CardQueue {
switch (queue) {
case -3: return CardQueue.USER_BURIED;
case -2: return CardQueue.SCHED_BURIED;
case -1: return CardQueue.SUSPENDED;
case 0: return CardQueue.NEW;
case 1: return CardQueue.LEARNING;
case 2: return CardQueue.REVIEW;
case 3: return CardQueue.IN_LEARNING;
case 4: return CardQueue.PREVIEW;
default: return CardQueue.NEW;
}
}
function generateUniqueId(): bigint {
const bytes = randomBytes(8);
const timestamp = BigInt(Date.now());
const random = BigInt(`0x${bytes.toString("hex")}`);
return timestamp ^ random;
}
async function importDeck(
parsed: ParsedApkg,
ankiDeckId: number,
userId: string,
deckName?: string
): Promise<{ deckId: number; noteCount: number; cardCount: number }> {
const ankiDeck = parsed.decks.get(ankiDeckId);
if (!ankiDeck) {
throw new Error(`Deck ${ankiDeckId} not found in APKG`);
}
const { notes: ankiNotes, cards: ankiCards } = getDeckNotesAndCards(parsed, ankiDeckId);
const result = await prisma.$transaction(async (tx) => {
const deck = await tx.deck.create({
data: {
name: deckName || ankiDeck.name,
desc: ankiDeck.desc,
userId,
collapsed: ankiDeck.collapsed,
conf: {},
},
});
if (ankiNotes.length === 0) {
return { deckId: deck.id, noteCount: 0, cardCount: 0 };
}
const noteTypeIdMap = new Map<number, number>();
const noteIdMap = new Map<number, bigint>();
for (const ankiNote of ankiNotes) {
let noteTypeId = noteTypeIdMap.get(ankiNote.mid);
if (!noteTypeId) {
noteTypeId = await importNoteType(parsed, ankiNote.mid, userId);
noteTypeIdMap.set(ankiNote.mid, noteTypeId);
}
const noteId = generateUniqueId();
noteIdMap.set(ankiNote.id, noteId);
const guid = ankiNote.guid || repoGenerateGuid();
const csum = ankiNote.csum || repoCalculateCsum(ankiNote.sfld);
await tx.note.create({
data: {
id: noteId,
guid,
noteTypeId,
mod: ankiNote.mod,
usn: ankiNote.usn,
tags: ankiNote.tags,
flds: ankiNote.flds,
sfld: ankiNote.sfld,
csum,
flags: ankiNote.flags,
data: ankiNote.data,
userId,
},
});
}
for (const ankiCard of ankiCards) {
const noteId = noteIdMap.get(ankiCard.nid);
if (!noteId) {
log.warn("Card references non-existent note", { cardId: ankiCard.id, noteId: ankiCard.nid });
continue;
}
await tx.card.create({
data: {
id: generateUniqueId(),
noteId,
deckId: deck.id,
ord: ankiCard.ord,
mod: ankiCard.mod,
usn: ankiCard.usn,
type: mapAnkiCardType(ankiCard.type),
queue: mapAnkiCardQueue(ankiCard.queue),
due: ankiCard.due,
ivl: ankiCard.ivl,
factor: ankiCard.factor,
reps: ankiCard.reps,
lapses: ankiCard.lapses,
left: ankiCard.left,
odue: ankiCard.odue,
odid: ankiCard.odid,
flags: ankiCard.flags,
data: ankiCard.data,
},
});
}
return { deckId: deck.id, noteCount: ankiNotes.length, cardCount: ankiCards.length };
});
return result;
}
export async function actionPreviewApkg(formData: FormData): Promise<ActionOutputPreviewApkg> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return { success: false, message: "Unauthorized" };
}
const file = formData.get("file") as File | null;
if (!file) {
return { success: false, message: "No file provided" };
}
if (!file.name.endsWith(".apkg")) {
return { success: false, message: "Invalid file type. Please upload an .apkg file" };
}
if (file.size > MAX_APKG_SIZE) {
return { success: false, message: `File size exceeds ${MAX_APKG_SIZE / (1024 * 1024)}MB limit` };
}
try {
const buffer = Buffer.from(await file.arrayBuffer());
const parsed = await parseApkg(buffer);
const decks = getDeckNames(parsed);
return {
success: true,
message: `Found ${decks.length} deck(s)`,
decks: decks.filter(d => d.cardCount > 0),
};
} catch (error) {
log.error("Failed to preview APKG", { error });
return {
success: false,
message: error instanceof Error ? error.message : "Failed to parse APKG file",
};
}
}
export async function actionImportApkg(
formData: FormData
): Promise<ActionOutputImportApkg> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return { success: false, message: "Unauthorized" };
}
const file = formData.get("file") as File | null;
const deckIdStr = formData.get("deckId") as string | null;
const deckName = formData.get("deckName") as string | null;
if (!file) {
return { success: false, message: "No file provided" };
}
if (!deckIdStr) {
return { success: false, message: "No deck selected" };
}
const ankiDeckId = parseInt(deckIdStr, 10);
if (isNaN(ankiDeckId)) {
return { success: false, message: "Invalid deck ID" };
}
if (file.size > MAX_APKG_SIZE) {
return { success: false, message: `File size exceeds ${MAX_APKG_SIZE / (1024 * 1024)}MB limit` };
}
try {
const buffer = Buffer.from(await file.arrayBuffer());
const parsed = await parseApkg(buffer);
const result = await importDeck(parsed, ankiDeckId, session.user.id, deckName || undefined);
log.info("APKG imported successfully", {
userId: session.user.id,
deckId: result.deckId,
noteCount: result.noteCount,
cardCount: result.cardCount,
});
return {
success: true,
message: `Imported ${result.cardCount} cards from ${result.noteCount} notes`,
deckId: result.deckId,
noteCount: result.noteCount,
cardCount: result.cardCount,
};
} catch (error) {
log.error("Failed to import APKG", { error });
return {
success: false,
message: error instanceof Error ? error.message : "Failed to import APKG file",
};
}
}

View File

@@ -1,106 +0,0 @@
import z from "zod";
import { generateValidator } from "@/utils/validate";
import { NoteKind } from "../../../generated/prisma/enums";
import {
schemaNoteTypeField,
schemaNoteTypeTemplate,
NoteTypeField,
NoteTypeTemplate,
} from "./note-type-repository-dto";
export const LENGTH_MIN_NOTE_TYPE_NAME = 1;
export const LENGTH_MAX_NOTE_TYPE_NAME = 100;
export const LENGTH_MAX_CSS = 50000;
const schemaNoteTypeFieldAction = z.object({
name: z.string().min(1).max(schemaNoteTypeField.name.maxLength),
ord: z.number().int(),
sticky: z.boolean(),
rtl: z.boolean(),
font: z.string().max(schemaNoteTypeField.font.maxLength).optional(),
size: z.number().int().min(schemaNoteTypeField.size.min).max(schemaNoteTypeField.size.max).optional(),
media: z.array(z.string()).optional(),
});
const schemaNoteTypeTemplateAction = z.object({
name: z.string().min(1).max(schemaNoteTypeTemplate.name.maxLength),
ord: z.number().int(),
qfmt: z.string().min(1).max(schemaNoteTypeTemplate.qfmt.maxLength),
afmt: z.string().min(1).max(schemaNoteTypeTemplate.afmt.maxLength),
bqfmt: z.string().max(schemaNoteTypeTemplate.bqfmt.maxLength).optional(),
bafmt: z.string().max(schemaNoteTypeTemplate.bafmt.maxLength).optional(),
did: z.number().int().optional(),
});
export const schemaActionInputCreateNoteType = z.object({
name: z.string().min(LENGTH_MIN_NOTE_TYPE_NAME).max(LENGTH_MAX_NOTE_TYPE_NAME),
kind: z.enum(["STANDARD", "CLOZE"]).optional(),
css: z.string().max(LENGTH_MAX_CSS).optional(),
fields: z.array(schemaNoteTypeFieldAction).min(1),
templates: z.array(schemaNoteTypeTemplateAction).min(1),
});
export type ActionInputCreateNoteType = z.infer<typeof schemaActionInputCreateNoteType>;
export const validateActionInputCreateNoteType = generateValidator(schemaActionInputCreateNoteType);
export const schemaActionInputUpdateNoteType = z.object({
id: z.number().int().positive(),
name: z.string().min(LENGTH_MIN_NOTE_TYPE_NAME).max(LENGTH_MAX_NOTE_TYPE_NAME).optional(),
kind: z.enum(["STANDARD", "CLOZE"]).optional(),
css: z.string().max(LENGTH_MAX_CSS).optional(),
fields: z.array(schemaNoteTypeFieldAction).min(1).optional(),
templates: z.array(schemaNoteTypeTemplateAction).min(1).optional(),
});
export type ActionInputUpdateNoteType = z.infer<typeof schemaActionInputUpdateNoteType>;
export const validateActionInputUpdateNoteType = generateValidator(schemaActionInputUpdateNoteType);
export const schemaActionInputGetNoteTypeById = z.object({
id: z.number().int().positive(),
});
export type ActionInputGetNoteTypeById = z.infer<typeof schemaActionInputGetNoteTypeById>;
export const validateActionInputGetNoteTypeById = generateValidator(schemaActionInputGetNoteTypeById);
export const schemaActionInputDeleteNoteType = z.object({
id: z.number().int().positive(),
});
export type ActionInputDeleteNoteType = z.infer<typeof schemaActionInputDeleteNoteType>;
export const validateActionInputDeleteNoteType = generateValidator(schemaActionInputDeleteNoteType);
export type ActionOutputNoteType = {
id: number;
name: string;
kind: NoteKind;
css: string;
fields: NoteTypeField[];
templates: NoteTypeTemplate[];
userId: string;
createdAt: Date;
updatedAt: Date;
};
export type ActionOutputCreateNoteType = {
success: boolean;
message: string;
data?: { id: number };
};
export type ActionOutputUpdateNoteType = {
success: boolean;
message: string;
};
export type ActionOutputGetNoteTypeById = {
success: boolean;
message: string;
data?: ActionOutputNoteType;
};
export type ActionOutputGetNoteTypesByUserId = {
success: boolean;
message: string;
data?: ActionOutputNoteType[];
};
export type ActionOutputDeleteNoteType = {
success: boolean;
message: string;
};

View File

@@ -1,255 +0,0 @@
"use server";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { createLogger } from "@/lib/logger";
import { ValidateError } from "@/lib/errors";
import {
ActionInputCreateNoteType,
ActionInputUpdateNoteType,
ActionInputDeleteNoteType,
ActionOutputCreateNoteType,
ActionOutputUpdateNoteType,
ActionOutputGetNoteTypeById,
ActionOutputGetNoteTypesByUserId,
ActionOutputDeleteNoteType,
validateActionInputCreateNoteType,
validateActionInputUpdateNoteType,
validateActionInputDeleteNoteType,
} from "./note-type-action-dto";
import {
serviceCreateNoteType,
serviceUpdateNoteType,
serviceGetNoteTypeById,
serviceGetNoteTypesByUserId,
serviceDeleteNoteType,
} from "./note-type-service";
import {
DEFAULT_BASIC_NOTE_TYPE_FIELDS,
DEFAULT_BASIC_NOTE_TYPE_TEMPLATES,
DEFAULT_BASIC_NOTE_TYPE_CSS,
DEFAULT_CLOZE_NOTE_TYPE_FIELDS,
DEFAULT_CLOZE_NOTE_TYPE_TEMPLATES,
DEFAULT_CLOZE_NOTE_TYPE_CSS,
} from "./note-type-repository-dto";
const log = createLogger("note-type-action");
export async function actionCreateNoteType(
input: ActionInputCreateNoteType,
): Promise<ActionOutputCreateNoteType> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return {
success: false,
message: "Unauthorized",
};
}
const validated = validateActionInputCreateNoteType(input);
const id = await serviceCreateNoteType({
name: validated.name,
kind: validated.kind,
css: validated.css,
fields: validated.fields,
templates: validated.templates,
userId: session.user.id,
});
return {
success: true,
message: "Note type created successfully",
data: { id },
};
} catch (e) {
if (e instanceof ValidateError) {
return {
success: false,
message: e.message,
};
}
log.error("Create note type failed", { error: e });
return {
success: false,
message: "Failed to create note type",
};
}
}
export async function actionUpdateNoteType(
input: ActionInputUpdateNoteType,
): Promise<ActionOutputUpdateNoteType> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return {
success: false,
message: "Unauthorized",
};
}
const validated = validateActionInputUpdateNoteType(input);
await serviceUpdateNoteType({
id: validated.id,
name: validated.name,
kind: validated.kind,
css: validated.css,
fields: validated.fields,
templates: validated.templates,
userId: session.user.id,
});
return {
success: true,
message: "Note type updated successfully",
};
} catch (e) {
if (e instanceof ValidateError) {
return {
success: false,
message: e.message,
};
}
log.error("Update note type failed", { error: e });
return {
success: false,
message: "Failed to update note type",
};
}
}
export async function actionGetNoteTypeById(
id: number,
): Promise<ActionOutputGetNoteTypeById> {
try {
const noteType = await serviceGetNoteTypeById({ id });
if (!noteType) {
return {
success: false,
message: "Note type not found",
};
}
return {
success: true,
message: "Note type retrieved successfully",
data: noteType,
};
} catch (e) {
log.error("Get note type failed", { error: e });
return {
success: false,
message: "Failed to retrieve note type",
};
}
}
export async function actionGetNoteTypesByUserId(): Promise<ActionOutputGetNoteTypesByUserId> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return {
success: false,
message: "Unauthorized",
};
}
const noteTypes = await serviceGetNoteTypesByUserId({
userId: session.user.id,
});
return {
success: true,
message: "Note types retrieved successfully",
data: noteTypes,
};
} catch (e) {
log.error("Get note types failed", { error: e });
return {
success: false,
message: "Failed to retrieve note types",
};
}
}
export async function actionDeleteNoteType(
input: ActionInputDeleteNoteType,
): Promise<ActionOutputDeleteNoteType> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return {
success: false,
message: "Unauthorized",
};
}
const validated = validateActionInputDeleteNoteType(input);
await serviceDeleteNoteType({
id: validated.id,
userId: session.user.id,
});
return {
success: true,
message: "Note type deleted successfully",
};
} catch (e) {
if (e instanceof ValidateError) {
return {
success: false,
message: e.message,
};
}
log.error("Delete note type failed", { error: e });
return {
success: false,
message: "Failed to delete note type",
};
}
}
export async function actionCreateDefaultBasicNoteType(): Promise<ActionOutputCreateNoteType> {
return actionCreateNoteType({
name: "Basic Vocabulary",
kind: "STANDARD",
css: DEFAULT_BASIC_NOTE_TYPE_CSS,
fields: DEFAULT_BASIC_NOTE_TYPE_FIELDS,
templates: DEFAULT_BASIC_NOTE_TYPE_TEMPLATES,
});
}
export async function actionCreateDefaultClozeNoteType(): Promise<ActionOutputCreateNoteType> {
return actionCreateNoteType({
name: "Cloze",
kind: "CLOZE",
css: DEFAULT_CLOZE_NOTE_TYPE_CSS,
fields: DEFAULT_CLOZE_NOTE_TYPE_FIELDS,
templates: DEFAULT_CLOZE_NOTE_TYPE_TEMPLATES,
});
}
export async function actionGetDefaultBasicNoteTypeTemplate() {
return {
name: "Basic Vocabulary",
kind: "STANDARD" as const,
css: DEFAULT_BASIC_NOTE_TYPE_CSS,
fields: DEFAULT_BASIC_NOTE_TYPE_FIELDS,
templates: DEFAULT_BASIC_NOTE_TYPE_TEMPLATES,
};
}
export async function actionGetDefaultClozeNoteTypeTemplate() {
return {
name: "Cloze",
kind: "CLOZE" as const,
css: DEFAULT_CLOZE_NOTE_TYPE_CSS,
fields: DEFAULT_CLOZE_NOTE_TYPE_FIELDS,
templates: DEFAULT_CLOZE_NOTE_TYPE_TEMPLATES,
};
}

View File

@@ -1,181 +0,0 @@
import { NoteKind } from "../../../generated/prisma/enums";
// ============================================
// Field Schema (Anki flds structure)
// ============================================
export interface NoteTypeField {
name: string;
ord: number;
sticky: boolean;
rtl: boolean;
font?: string;
size?: number;
media?: string[];
}
export const schemaNoteTypeField = {
name: { minLength: 1, maxLength: 50 },
font: { maxLength: 100 },
size: { min: 8, max: 72 },
};
// ============================================
// Template Schema (Anki tmpls structure)
// ============================================
export interface NoteTypeTemplate {
name: string;
ord: number;
qfmt: string;
afmt: string;
bqfmt?: string;
bafmt?: string;
did?: number;
}
export const schemaNoteTypeTemplate = {
name: { minLength: 1, maxLength: 100 },
qfmt: { maxLength: 10000 },
afmt: { maxLength: 10000 },
bqfmt: { maxLength: 10000 },
bafmt: { maxLength: 10000 },
};
// ============================================
// Repository Input Types
// ============================================
export interface RepoInputCreateNoteType {
name: string;
kind?: NoteKind;
css?: string;
fields: NoteTypeField[];
templates: NoteTypeTemplate[];
userId: string;
}
export interface RepoInputUpdateNoteType {
id: number;
name?: string;
kind?: NoteKind;
css?: string;
fields?: NoteTypeField[];
templates?: NoteTypeTemplate[];
}
export interface RepoInputGetNoteTypeById {
id: number;
}
export interface RepoInputGetNoteTypesByUserId {
userId: string;
}
export interface RepoInputDeleteNoteType {
id: number;
}
export interface RepoInputCheckNotesExist {
noteTypeId: number;
}
// ============================================
// Repository Output Types
// ============================================
export type RepoOutputNoteType = {
id: number;
name: string;
kind: NoteKind;
css: string;
fields: NoteTypeField[];
templates: NoteTypeTemplate[];
userId: string;
createdAt: Date;
updatedAt: Date;
};
export type RepoOutputNoteTypeOwnership = {
userId: string;
};
export type RepoOutputNotesExistCheck = {
exists: boolean;
count: number;
};
// ============================================
// Default Note Types
// ============================================
export const DEFAULT_BASIC_NOTE_TYPE_FIELDS: NoteTypeField[] = [
{ name: "Word", ord: 0, sticky: false, rtl: false, font: "Arial", size: 20 },
{ name: "Definition", ord: 1, sticky: false, rtl: false, font: "Arial", size: 20 },
{ name: "IPA", ord: 2, sticky: false, rtl: false, font: "Arial", size: 20 },
{ name: "Example", ord: 3, sticky: false, rtl: false, font: "Arial", size: 20 },
];
export const DEFAULT_BASIC_NOTE_TYPE_TEMPLATES: NoteTypeTemplate[] = [
{
name: "Word → Definition",
ord: 0,
qfmt: "{{Word}}<br>{{IPA}}",
afmt: "{{FrontSide}}<hr id=answer>{{Definition}}<br><br>{{Example}}",
},
{
name: "Definition → Word",
ord: 1,
qfmt: "{{Definition}}",
afmt: "{{FrontSide}}<hr id=answer>{{Word}}<br>{{IPA}}",
},
];
export const DEFAULT_BASIC_NOTE_TYPE_CSS = `.card {
font-family: Arial, sans-serif;
font-size: 20px;
text-align: center;
color: #333;
background-color: #fff;
}
.card1 {
background-color: #e8f4f8;
}
.card2 {
background-color: #f8f4e8;
}
hr {
border: none;
border-top: 1px solid #ccc;
margin: 20px 0;
}`;
export const DEFAULT_CLOZE_NOTE_TYPE_FIELDS: NoteTypeField[] = [
{ name: "Text", ord: 0, sticky: false, rtl: false, font: "Arial", size: 20 },
{ name: "Extra", ord: 1, sticky: false, rtl: false, font: "Arial", size: 20 },
];
export const DEFAULT_CLOZE_NOTE_TYPE_TEMPLATES: NoteTypeTemplate[] = [
{
name: "Cloze",
ord: 0,
qfmt: "{{cloze:Text}}",
afmt: "{{cloze:Text}}<br><br>{{Extra}}",
},
];
export const DEFAULT_CLOZE_NOTE_TYPE_CSS = `.card {
font-family: Arial, sans-serif;
font-size: 20px;
text-align: center;
color: #333;
background-color: #fff;
}
.cloze {
font-weight: bold;
color: #0066cc;
}`;

View File

@@ -1,151 +0,0 @@
import { prisma } from "@/lib/db";
import { createLogger } from "@/lib/logger";
import {
RepoInputCreateNoteType,
RepoInputUpdateNoteType,
RepoInputGetNoteTypeById,
RepoInputGetNoteTypesByUserId,
RepoInputDeleteNoteType,
RepoInputCheckNotesExist,
RepoOutputNoteType,
RepoOutputNoteTypeOwnership,
RepoOutputNotesExistCheck,
NoteTypeField,
NoteTypeTemplate,
} from "./note-type-repository-dto";
import { NoteKind } from "../../../generated/prisma/enums";
const log = createLogger("note-type-repository");
export async function repoCreateNoteType(
input: RepoInputCreateNoteType,
): Promise<number> {
const noteType = await prisma.noteType.create({
data: {
name: input.name,
kind: input.kind ?? NoteKind.STANDARD,
css: input.css ?? "",
fields: input.fields as unknown as object,
templates: input.templates as unknown as object,
userId: input.userId,
},
});
log.info("Created note type", { id: noteType.id, name: noteType.name });
return noteType.id;
}
export async function repoUpdateNoteType(
input: RepoInputUpdateNoteType,
): Promise<void> {
const updateData: {
name?: string;
kind?: NoteKind;
css?: string;
fields?: object;
templates?: object;
} = {};
if (input.name !== undefined) updateData.name = input.name;
if (input.kind !== undefined) updateData.kind = input.kind;
if (input.css !== undefined) updateData.css = input.css;
if (input.fields !== undefined)
updateData.fields = input.fields as unknown as object;
if (input.templates !== undefined)
updateData.templates = input.templates as unknown as object;
await prisma.noteType.update({
where: { id: input.id },
data: updateData,
});
log.info("Updated note type", { id: input.id });
}
export async function repoGetNoteTypeById(
input: RepoInputGetNoteTypeById,
): Promise<RepoOutputNoteType | null> {
const noteType = await prisma.noteType.findUnique({
where: { id: input.id },
});
if (!noteType) return null;
return {
id: noteType.id,
name: noteType.name,
kind: noteType.kind,
css: noteType.css,
fields: noteType.fields as unknown as NoteTypeField[],
templates: noteType.templates as unknown as NoteTypeTemplate[],
userId: noteType.userId,
createdAt: noteType.createdAt,
updatedAt: noteType.updatedAt,
};
}
export async function repoGetNoteTypesByUserId(
input: RepoInputGetNoteTypesByUserId,
): Promise<RepoOutputNoteType[]> {
const noteTypes = await prisma.noteType.findMany({
where: { userId: input.userId },
orderBy: { createdAt: "desc" },
});
return noteTypes.map((nt) => ({
id: nt.id,
name: nt.name,
kind: nt.kind,
css: nt.css,
fields: nt.fields as unknown as NoteTypeField[],
templates: nt.templates as unknown as NoteTypeTemplate[],
userId: nt.userId,
createdAt: nt.createdAt,
updatedAt: nt.updatedAt,
}));
}
export async function repoGetNoteTypeOwnership(
noteTypeId: number,
): Promise<RepoOutputNoteTypeOwnership | null> {
const noteType = await prisma.noteType.findUnique({
where: { id: noteTypeId },
select: { userId: true },
});
return noteType;
}
export async function repoDeleteNoteType(
input: RepoInputDeleteNoteType,
): Promise<void> {
await prisma.noteType.delete({
where: { id: input.id },
});
log.info("Deleted note type", { id: input.id });
}
export async function repoCheckNotesExist(
input: RepoInputCheckNotesExist,
): Promise<RepoOutputNotesExistCheck> {
const count = await prisma.note.count({
where: { noteTypeId: input.noteTypeId },
});
return {
exists: count > 0,
count,
};
}
export async function repoGetNoteTypeNameById(
noteTypeId: number,
): Promise<string | null> {
const noteType = await prisma.noteType.findUnique({
where: { id: noteTypeId },
select: { name: true },
});
return noteType?.name ?? null;
}

View File

@@ -1,60 +0,0 @@
import { NoteKind } from "../../../generated/prisma/enums";
import { NoteTypeField, NoteTypeTemplate } from "./note-type-repository-dto";
export type ServiceInputCreateNoteType = {
name: string;
kind?: NoteKind;
css?: string;
fields: NoteTypeField[];
templates: NoteTypeTemplate[];
userId: string;
};
export type ServiceInputUpdateNoteType = {
id: number;
name?: string;
kind?: NoteKind;
css?: string;
fields?: NoteTypeField[];
templates?: NoteTypeTemplate[];
userId: string;
};
export type ServiceInputGetNoteTypeById = {
id: number;
};
export type ServiceInputGetNoteTypesByUserId = {
userId: string;
};
export type ServiceInputDeleteNoteType = {
id: number;
userId: string;
};
export type ServiceInputValidateFields = {
fields: NoteTypeField[];
};
export type ServiceInputValidateTemplates = {
templates: NoteTypeTemplate[];
fields: NoteTypeField[];
};
export type ServiceOutputNoteType = {
id: number;
name: string;
kind: NoteKind;
css: string;
fields: NoteTypeField[];
templates: NoteTypeTemplate[];
userId: string;
createdAt: Date;
updatedAt: Date;
};
export type ServiceOutputValidation = {
success: boolean;
errors: string[];
};

View File

@@ -1,272 +0,0 @@
import { createLogger } from "@/lib/logger";
import { ValidateError } from "@/lib/errors";
import {
repoCreateNoteType,
repoGetNoteTypeById,
repoGetNoteTypesByUserId,
repoUpdateNoteType,
repoDeleteNoteType,
repoGetNoteTypeOwnership,
repoCheckNotesExist,
} from "./note-type-repository";
import {
ServiceInputCreateNoteType,
ServiceInputUpdateNoteType,
ServiceInputGetNoteTypeById,
ServiceInputGetNoteTypesByUserId,
ServiceInputDeleteNoteType,
ServiceInputValidateFields,
ServiceInputValidateTemplates,
ServiceOutputNoteType,
ServiceOutputValidation,
} from "./note-type-service-dto";
import { schemaNoteTypeField, schemaNoteTypeTemplate } from "./note-type-repository-dto";
const log = createLogger("note-type-service");
export function serviceValidateFields(
input: ServiceInputValidateFields,
): ServiceOutputValidation {
const errors: string[] = [];
if (!Array.isArray(input.fields) || input.fields.length === 0) {
errors.push("Fields must be a non-empty array");
return { success: false, errors };
}
const seenNames = new Set<string>();
const seenOrds = new Set<number>();
for (let i = 0; i < input.fields.length; i++) {
const field = input.fields[i];
if (!field.name || field.name.trim().length === 0) {
errors.push(`Field ${i}: name is required`);
} else if (field.name.length > schemaNoteTypeField.name.maxLength) {
errors.push(`Field ${i}: name exceeds maximum length of ${schemaNoteTypeField.name.maxLength}`);
}
if (seenNames.has(field.name)) {
errors.push(`Field ${i}: duplicate field name "${field.name}"`);
}
seenNames.add(field.name);
if (typeof field.ord !== "number") {
errors.push(`Field ${i}: ord must be a number`);
} else if (seenOrds.has(field.ord)) {
errors.push(`Field ${i}: duplicate ordinal ${field.ord}`);
}
seenOrds.add(field.ord);
if (typeof field.sticky !== "boolean") {
errors.push(`Field ${i}: sticky must be a boolean`);
}
if (typeof field.rtl !== "boolean") {
errors.push(`Field ${i}: rtl must be a boolean`);
}
if (field.font && field.font.length > schemaNoteTypeField.font.maxLength) {
errors.push(`Field ${i}: font exceeds maximum length`);
}
if (field.size !== undefined && (field.size < schemaNoteTypeField.size.min || field.size > schemaNoteTypeField.size.max)) {
errors.push(`Field ${i}: size must be between ${schemaNoteTypeField.size.min} and ${schemaNoteTypeField.size.max}`);
}
}
return { success: errors.length === 0, errors };
}
export function serviceValidateTemplates(
input: ServiceInputValidateTemplates,
): ServiceOutputValidation {
const errors: string[] = [];
if (!Array.isArray(input.templates) || input.templates.length === 0) {
errors.push("Templates must be a non-empty array");
return { success: false, errors };
}
const fieldNames = new Set(input.fields.map((f) => f.name));
const seenNames = new Set<string>();
const seenOrds = new Set<number>();
const mustachePattern = /\{\{([^}]+)\}\}/g;
for (let i = 0; i < input.templates.length; i++) {
const template = input.templates[i];
if (!template.name || template.name.trim().length === 0) {
errors.push(`Template ${i}: name is required`);
} else if (template.name.length > schemaNoteTypeTemplate.name.maxLength) {
errors.push(`Template ${i}: name exceeds maximum length`);
}
if (seenNames.has(template.name)) {
errors.push(`Template ${i}: duplicate template name "${template.name}"`);
}
seenNames.add(template.name);
if (typeof template.ord !== "number") {
errors.push(`Template ${i}: ord must be a number`);
} else if (seenOrds.has(template.ord)) {
errors.push(`Template ${i}: duplicate ordinal ${template.ord}`);
}
seenOrds.add(template.ord);
if (!template.qfmt || template.qfmt.trim().length === 0) {
errors.push(`Template ${i}: qfmt (question format) is required`);
} else if (template.qfmt.length > schemaNoteTypeTemplate.qfmt.maxLength) {
errors.push(`Template ${i}: qfmt exceeds maximum length`);
}
if (!template.afmt || template.afmt.trim().length === 0) {
errors.push(`Template ${i}: afmt (answer format) is required`);
} else if (template.afmt.length > schemaNoteTypeTemplate.afmt.maxLength) {
errors.push(`Template ${i}: afmt exceeds maximum length`);
}
const qfmtMatches = template.qfmt.match(mustachePattern) || [];
const afmtMatches = template.afmt.match(mustachePattern) || [];
const allMatches = [...qfmtMatches, ...afmtMatches];
for (const match of allMatches) {
const content = match.slice(2, -2).trim();
if (content.startsWith("cloze:")) {
continue;
}
if (content === "FrontSide") {
continue;
}
if (content.startsWith("type:")) {
continue;
}
if (!fieldNames.has(content)) {
log.warn(`Template ${i}: unknown field reference "{{${content}}}"`);
}
}
}
return { success: errors.length === 0, errors };
}
export async function serviceCreateNoteType(
input: ServiceInputCreateNoteType,
): Promise<number> {
const fieldsValidation = serviceValidateFields({ fields: input.fields });
if (!fieldsValidation.success) {
throw new ValidateError(`Invalid fields: ${fieldsValidation.errors.join("; ")}`);
}
const templatesValidation = serviceValidateTemplates({
templates: input.templates,
fields: input.fields,
});
if (!templatesValidation.success) {
throw new ValidateError(`Invalid templates: ${templatesValidation.errors.join("; ")}`);
}
log.info("Creating note type", { name: input.name, userId: input.userId });
return repoCreateNoteType({
name: input.name,
kind: input.kind,
css: input.css,
fields: input.fields,
templates: input.templates,
userId: input.userId,
});
}
export async function serviceUpdateNoteType(
input: ServiceInputUpdateNoteType,
): Promise<void> {
const ownership = await repoGetNoteTypeOwnership(input.id);
if (!ownership) {
throw new ValidateError("Note type not found");
}
if (ownership.userId !== input.userId) {
throw new ValidateError("You do not have permission to update this note type");
}
if (input.fields) {
const fieldsValidation = serviceValidateFields({ fields: input.fields });
if (!fieldsValidation.success) {
throw new ValidateError(`Invalid fields: ${fieldsValidation.errors.join("; ")}`);
}
}
if (input.templates && input.fields) {
const templatesValidation = serviceValidateTemplates({
templates: input.templates,
fields: input.fields,
});
if (!templatesValidation.success) {
throw new ValidateError(`Invalid templates: ${templatesValidation.errors.join("; ")}`);
}
} else if (input.templates) {
const existing = await repoGetNoteTypeById({ id: input.id });
if (existing) {
const templatesValidation = serviceValidateTemplates({
templates: input.templates,
fields: existing.fields,
});
if (!templatesValidation.success) {
throw new ValidateError(`Invalid templates: ${templatesValidation.errors.join("; ")}`);
}
}
}
log.info("Updating note type", { id: input.id });
await repoUpdateNoteType({
id: input.id,
name: input.name,
kind: input.kind,
css: input.css,
fields: input.fields,
templates: input.templates,
});
}
export async function serviceGetNoteTypeById(
input: ServiceInputGetNoteTypeById,
): Promise<ServiceOutputNoteType | null> {
return repoGetNoteTypeById(input);
}
export async function serviceGetNoteTypesByUserId(
input: ServiceInputGetNoteTypesByUserId,
): Promise<ServiceOutputNoteType[]> {
return repoGetNoteTypesByUserId(input);
}
export async function serviceDeleteNoteType(
input: ServiceInputDeleteNoteType,
): Promise<void> {
const ownership = await repoGetNoteTypeOwnership(input.id);
if (!ownership) {
throw new ValidateError("Note type not found");
}
if (ownership.userId !== input.userId) {
throw new ValidateError("You do not have permission to delete this note type");
}
const notesCheck = await repoCheckNotesExist({ noteTypeId: input.id });
if (notesCheck.exists) {
throw new ValidateError(
`Cannot delete note type: ${notesCheck.count} notes are using this type`,
);
}
log.info("Deleting note type", { id: input.id });
await repoDeleteNoteType({ id: input.id });
}

View File

@@ -1,131 +0,0 @@
import { generateValidator } from "@/utils/validate";
import z from "zod";
export const LENGTH_MAX_NOTE_FIELD = 65535;
export const LENGTH_MIN_NOTE_FIELD = 0;
export const LENGTH_MAX_TAG = 100;
export const MAX_FIELDS = 100;
export const MAX_TAGS = 100;
export const schemaActionInputCreateNote = z.object({
noteTypeId: z.number().int().positive(),
fields: z
.array(z.string().max(LENGTH_MAX_NOTE_FIELD))
.min(1)
.max(MAX_FIELDS),
tags: z.array(z.string().max(LENGTH_MAX_TAG)).max(MAX_TAGS).optional(),
});
export type ActionInputCreateNote = z.infer<typeof schemaActionInputCreateNote>;
export const validateActionInputCreateNote = generateValidator(
schemaActionInputCreateNote,
);
export const schemaActionInputUpdateNote = z.object({
noteId: z.bigint(),
fields: z
.array(z.string().max(LENGTH_MAX_NOTE_FIELD))
.min(1)
.max(MAX_FIELDS)
.optional(),
tags: z.array(z.string().max(LENGTH_MAX_TAG)).max(MAX_TAGS).optional(),
});
export type ActionInputUpdateNote = z.infer<typeof schemaActionInputUpdateNote>;
export const validateActionInputUpdateNote = generateValidator(
schemaActionInputUpdateNote,
);
export const schemaActionInputDeleteNote = z.object({
noteId: z.bigint(),
});
export type ActionInputDeleteNote = z.infer<typeof schemaActionInputDeleteNote>;
export const validateActionInputDeleteNote = generateValidator(
schemaActionInputDeleteNote,
);
export const schemaActionInputGetNoteById = z.object({
noteId: z.bigint(),
});
export type ActionInputGetNoteById = z.infer<typeof schemaActionInputGetNoteById>;
export const validateActionInputGetNoteById = generateValidator(
schemaActionInputGetNoteById,
);
export const schemaActionInputGetNotesByNoteTypeId = z.object({
noteTypeId: z.number().int().positive(),
limit: z.number().int().positive().max(1000).optional(),
offset: z.number().int().nonnegative().optional(),
});
export type ActionInputGetNotesByNoteTypeId = z.infer<
typeof schemaActionInputGetNotesByNoteTypeId
>;
export const validateActionInputGetNotesByNoteTypeId = generateValidator(
schemaActionInputGetNotesByNoteTypeId,
);
export const schemaActionInputGetNotesByUserId = z.object({
userId: z.string().min(1),
limit: z.number().int().positive().max(1000).optional(),
offset: z.number().int().nonnegative().optional(),
});
export type ActionInputGetNotesByUserId = z.infer<
typeof schemaActionInputGetNotesByUserId
>;
export const validateActionInputGetNotesByUserId = generateValidator(
schemaActionInputGetNotesByUserId,
);
export type ActionOutputNote = {
id: string;
guid: string;
noteTypeId: number;
mod: number;
usn: number;
tags: string[];
fields: string[];
sfld: string;
csum: number;
flags: number;
data: string;
userId: string;
createdAt: Date;
updatedAt: Date;
};
export type ActionOutputCreateNote = {
message: string;
success: boolean;
data?: {
id: string;
guid: string;
};
};
export type ActionOutputUpdateNote = {
message: string;
success: boolean;
};
export type ActionOutputDeleteNote = {
message: string;
success: boolean;
};
export type ActionOutputGetNoteById = {
message: string;
success: boolean;
data?: ActionOutputNote;
};
export type ActionOutputGetNotes = {
message: string;
success: boolean;
data?: ActionOutputNote[];
};
export type ActionOutputNoteCount = {
message: string;
success: boolean;
data?: {
count: number;
};
};

View File

@@ -1,344 +0,0 @@
"use server";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { createLogger } from "@/lib/logger";
import { ValidateError } from "@/lib/errors";
import {
ActionInputCreateNote,
ActionInputUpdateNote,
ActionInputDeleteNote,
ActionInputGetNoteById,
ActionInputGetNotesByNoteTypeId,
ActionInputGetNotesByUserId,
ActionOutputCreateNote,
ActionOutputUpdateNote,
ActionOutputDeleteNote,
ActionOutputGetNoteById,
ActionOutputGetNotes,
ActionOutputNoteCount,
ActionOutputNote,
validateActionInputCreateNote,
validateActionInputUpdateNote,
validateActionInputDeleteNote,
validateActionInputGetNoteById,
validateActionInputGetNotesByNoteTypeId,
validateActionInputGetNotesByUserId,
} from "./note-action-dto";
import {
serviceCreateNote,
serviceUpdateNote,
serviceDeleteNote,
serviceGetNoteById,
serviceGetNotesByNoteTypeId,
serviceGetNotesByUserId,
serviceCountNotesByUserId,
serviceCountNotesByNoteTypeId,
NoteNotFoundError,
NoteOwnershipError,
} from "./note-service";
const log = createLogger("note-action");
function mapNoteToOutput(note: {
id: bigint;
guid: string;
noteTypeId: number;
mod: number;
usn: number;
tags: string[];
fields: string[];
sfld: string;
csum: number;
flags: number;
data: string;
userId: string;
createdAt: Date;
updatedAt: Date;
}): ActionOutputNote {
return {
id: note.id.toString(),
guid: note.guid,
noteTypeId: note.noteTypeId,
mod: note.mod,
usn: note.usn,
tags: note.tags,
fields: note.fields,
sfld: note.sfld,
csum: note.csum,
flags: note.flags,
data: note.data,
userId: note.userId,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
};
}
async function requireAuth(): Promise<string> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
throw new Error("Unauthorized");
}
return session.user.id;
}
export async function actionCreateNote(
input: unknown,
): Promise<ActionOutputCreateNote> {
try {
const userId = await requireAuth();
const validated = validateActionInputCreateNote(input);
log.debug("Creating note", { userId, noteTypeId: validated.noteTypeId });
const result = await serviceCreateNote({
...validated,
userId,
});
log.info("Note created", { id: result.id.toString(), guid: result.guid });
return {
success: true,
message: "Note created successfully",
data: {
id: result.id.toString(),
guid: result.guid,
},
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
if (e instanceof Error && e.message === "Unauthorized") {
return { success: false, message: "Unauthorized" };
}
log.error("Failed to create note", { error: e });
return { success: false, message: "An unknown error occurred" };
}
}
export async function actionUpdateNote(
input: unknown,
): Promise<ActionOutputUpdateNote> {
try {
const userId = await requireAuth();
const validated = validateActionInputUpdateNote(input);
log.debug("Updating note", { noteId: validated.noteId.toString() });
await serviceUpdateNote({
...validated,
userId,
});
log.info("Note updated", { noteId: validated.noteId.toString() });
return {
success: true,
message: "Note updated successfully",
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
if (e instanceof NoteNotFoundError) {
return { success: false, message: "Note not found" };
}
if (e instanceof NoteOwnershipError) {
return { success: false, message: "You do not have permission to update this note" };
}
if (e instanceof Error && e.message === "Unauthorized") {
return { success: false, message: "Unauthorized" };
}
log.error("Failed to update note", { error: e });
return { success: false, message: "An unknown error occurred" };
}
}
export async function actionDeleteNote(
input: unknown,
): Promise<ActionOutputDeleteNote> {
try {
const userId = await requireAuth();
const validated = validateActionInputDeleteNote(input);
log.debug("Deleting note", { noteId: validated.noteId.toString() });
await serviceDeleteNote({
...validated,
userId,
});
log.info("Note deleted", { noteId: validated.noteId.toString() });
return {
success: true,
message: "Note deleted successfully",
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
if (e instanceof NoteNotFoundError) {
return { success: false, message: "Note not found" };
}
if (e instanceof NoteOwnershipError) {
return { success: false, message: "You do not have permission to delete this note" };
}
if (e instanceof Error && e.message === "Unauthorized") {
return { success: false, message: "Unauthorized" };
}
log.error("Failed to delete note", { error: e });
return { success: false, message: "An unknown error occurred" };
}
}
export async function actionGetNoteById(
input: unknown,
): Promise<ActionOutputGetNoteById> {
try {
const validated = validateActionInputGetNoteById(input);
log.debug("Fetching note", { noteId: validated.noteId.toString() });
const note = await serviceGetNoteById(validated);
if (!note) {
return {
success: false,
message: "Note not found",
};
}
return {
success: true,
message: "Note retrieved successfully",
data: mapNoteToOutput(note),
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to get note", { error: e });
return { success: false, message: "An unknown error occurred" };
}
}
export async function actionGetNotesByNoteTypeId(
input: unknown,
): Promise<ActionOutputGetNotes> {
try {
const validated = validateActionInputGetNotesByNoteTypeId(input);
log.debug("Fetching notes by note type", { noteTypeId: validated.noteTypeId });
const notes = await serviceGetNotesByNoteTypeId(validated);
return {
success: true,
message: "Notes retrieved successfully",
data: notes.map(mapNoteToOutput),
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to get notes by note type", { error: e });
return { success: false, message: "An unknown error occurred" };
}
}
export async function actionGetNotesByUserId(
input: unknown,
): Promise<ActionOutputGetNotes> {
try {
const validated = validateActionInputGetNotesByUserId(input);
log.debug("Fetching notes by user", { userId: validated.userId });
const notes = await serviceGetNotesByUserId(validated);
return {
success: true,
message: "Notes retrieved successfully",
data: notes.map(mapNoteToOutput),
};
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("Failed to get notes by user", { error: e });
return { success: false, message: "An unknown error occurred" };
}
}
export async function actionGetMyNotes(
limit?: number,
offset?: number,
): Promise<ActionOutputGetNotes> {
try {
const userId = await requireAuth();
log.debug("Fetching current user's notes", { userId, limit, offset });
const notes = await serviceGetNotesByUserId({
userId,
limit,
offset,
});
return {
success: true,
message: "Notes retrieved successfully",
data: notes.map(mapNoteToOutput),
};
} catch (e) {
if (e instanceof Error && e.message === "Unauthorized") {
return { success: false, message: "Unauthorized" };
}
log.error("Failed to get user's notes", { error: e });
return { success: false, message: "An unknown error occurred" };
}
}
export async function actionGetMyNoteCount(): Promise<ActionOutputNoteCount> {
try {
const userId = await requireAuth();
log.debug("Counting current user's notes", { userId });
const result = await serviceCountNotesByUserId(userId);
return {
success: true,
message: "Note count retrieved successfully",
data: { count: result.count },
};
} catch (e) {
if (e instanceof Error && e.message === "Unauthorized") {
return { success: false, message: "Unauthorized" };
}
log.error("Failed to count user's notes", { error: e });
return { success: false, message: "An unknown error occurred" };
}
}
export async function actionGetNoteCountByNoteType(
noteTypeId: number,
): Promise<ActionOutputNoteCount> {
try {
log.debug("Counting notes by note type", { noteTypeId });
const result = await serviceCountNotesByNoteTypeId(noteTypeId);
return {
success: true,
message: "Note count retrieved successfully",
data: { count: result.count },
};
} catch (e) {
log.error("Failed to count notes by note type", { error: e });
return { success: false, message: "An unknown error occurred" };
}
}

View File

@@ -1,72 +0,0 @@
// Repository layer DTOs for Note module
// Follows Anki-compatible note structure with BigInt IDs
export interface RepoInputCreateNote {
noteTypeId: number;
fields: string[];
tags?: string[];
userId: string;
}
export interface RepoInputUpdateNote {
id: bigint;
fields?: string[];
tags?: string[];
}
export interface RepoInputGetNoteById {
id: bigint;
}
export interface RepoInputGetNotesByNoteTypeId {
noteTypeId: number;
limit?: number;
offset?: number;
}
export interface RepoInputGetNotesByUserId {
userId: string;
limit?: number;
offset?: number;
}
export interface RepoInputDeleteNote {
id: bigint;
}
export interface RepoInputCheckNoteOwnership {
noteId: bigint;
userId: string;
}
export type RepoOutputNote = {
id: bigint;
guid: string;
noteTypeId: number;
mod: number;
usn: number;
tags: string;
flds: string;
sfld: string;
csum: number;
flags: number;
data: string;
userId: string;
createdAt: Date;
updatedAt: Date;
};
export type RepoOutputNoteWithFields = Omit<RepoOutputNote, "flds" | "tags"> & {
fields: string[];
tagsArray: string[];
};
export type RepoOutputNoteOwnership = {
userId: string;
};
// Helper function types
export type RepoHelperGenerateGuid = () => string;
export type RepoHelperCalculateCsum = (text: string) => number;
export type RepoHelperJoinFields = (fields: string[]) => string;
export type RepoHelperSplitFields = (flds: string) => string[];

View File

@@ -1,283 +0,0 @@
import { prisma } from "@/lib/db";
import { createLogger } from "@/lib/logger";
import { createHash } from "crypto";
import {
RepoInputCreateNote,
RepoInputUpdateNote,
RepoInputGetNoteById,
RepoInputGetNotesByNoteTypeId,
RepoInputGetNotesByUserId,
RepoInputDeleteNote,
RepoInputCheckNoteOwnership,
RepoOutputNote,
RepoOutputNoteWithFields,
RepoOutputNoteOwnership,
} from "./note-repository-dto";
const log = createLogger("note-repository");
const FIELD_SEPARATOR = "\x1f";
const BASE91_CHARS =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&()*+,-./:;<=>?@[]^_`{|}~";
export function repoGenerateGuid(): string {
let guid = "";
const bytes = new Uint8Array(10);
crypto.getRandomValues(bytes);
for (let i = 0; i < 10; i++) {
guid += BASE91_CHARS[bytes[i] % BASE91_CHARS.length];
}
return guid;
}
export function repoCalculateCsum(text: string): number {
const hash = createHash("sha1").update(text.normalize("NFC")).digest("hex");
return parseInt(hash.substring(0, 8), 16);
}
export function repoJoinFields(fields: string[]): string {
return fields.join(FIELD_SEPARATOR);
}
export function repoSplitFields(flds: string): string[] {
return flds.split(FIELD_SEPARATOR);
}
export async function repoCreateNote(
input: RepoInputCreateNote,
): Promise<bigint> {
const now = Date.now();
const id = BigInt(now);
const guid = repoGenerateGuid();
const flds = repoJoinFields(input.fields);
const sfld = input.fields[0] ?? "";
const csum = repoCalculateCsum(sfld);
const tags = input.tags?.join(" ") ?? " ";
log.debug("Creating note", { id: id.toString(), guid, noteTypeId: input.noteTypeId });
await prisma.note.create({
data: {
id,
guid,
noteTypeId: input.noteTypeId,
mod: Math.floor(now / 1000),
usn: -1,
tags,
flds,
sfld,
csum,
flags: 0,
data: "",
userId: input.userId,
},
});
log.info("Note created", { id: id.toString(), guid });
return id;
}
export async function repoUpdateNote(input: RepoInputUpdateNote): Promise<void> {
const now = Date.now();
const updateData: {
mod?: number;
usn?: number;
flds?: string;
sfld?: string;
csum?: number;
tags?: string;
} = {
mod: Math.floor(now / 1000),
usn: -1,
};
if (input.fields) {
updateData.flds = repoJoinFields(input.fields);
updateData.sfld = input.fields[0] ?? "";
updateData.csum = repoCalculateCsum(updateData.sfld);
}
if (input.tags) {
updateData.tags = input.tags.join(" ");
}
log.debug("Updating note", { id: input.id.toString() });
await prisma.note.update({
where: { id: input.id },
data: updateData,
});
log.info("Note updated", { id: input.id.toString() });
}
export async function repoGetNoteById(
input: RepoInputGetNoteById,
): Promise<RepoOutputNote | null> {
const note = await prisma.note.findUnique({
where: { id: input.id },
});
if (!note) {
log.debug("Note not found", { id: input.id.toString() });
return null;
}
return {
id: note.id,
guid: note.guid,
noteTypeId: note.noteTypeId,
mod: note.mod,
usn: note.usn,
tags: note.tags,
flds: note.flds,
sfld: note.sfld,
csum: note.csum,
flags: note.flags,
data: note.data,
userId: note.userId,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
};
}
export async function repoGetNoteByIdWithFields(
input: RepoInputGetNoteById,
): Promise<RepoOutputNoteWithFields | null> {
const note = await repoGetNoteById(input);
if (!note) return null;
return {
...note,
fields: repoSplitFields(note.flds),
tagsArray: note.tags.trim() === "" ? [] : note.tags.trim().split(" "),
};
}
export async function repoGetNotesByNoteTypeId(
input: RepoInputGetNotesByNoteTypeId,
): Promise<RepoOutputNote[]> {
const { noteTypeId, limit = 50, offset = 0 } = input;
log.debug("Fetching notes by note type", { noteTypeId, limit, offset });
const notes = await prisma.note.findMany({
where: { noteTypeId },
orderBy: { id: "desc" },
take: limit,
skip: offset,
});
log.info("Fetched notes by note type", { noteTypeId, count: notes.length });
return notes.map((note) => ({
id: note.id,
guid: note.guid,
noteTypeId: note.noteTypeId,
mod: note.mod,
usn: note.usn,
tags: note.tags,
flds: note.flds,
sfld: note.sfld,
csum: note.csum,
flags: note.flags,
data: note.data,
userId: note.userId,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
}));
}
export async function repoGetNotesByUserId(
input: RepoInputGetNotesByUserId,
): Promise<RepoOutputNote[]> {
const { userId, limit = 50, offset = 0 } = input;
log.debug("Fetching notes by user", { userId, limit, offset });
const notes = await prisma.note.findMany({
where: { userId },
orderBy: { id: "desc" },
take: limit,
skip: offset,
});
log.info("Fetched notes by user", { userId, count: notes.length });
return notes.map((note) => ({
id: note.id,
guid: note.guid,
noteTypeId: note.noteTypeId,
mod: note.mod,
usn: note.usn,
tags: note.tags,
flds: note.flds,
sfld: note.sfld,
csum: note.csum,
flags: note.flags,
data: note.data,
userId: note.userId,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
}));
}
export async function repoGetNotesByUserIdWithFields(
input: RepoInputGetNotesByUserId,
): Promise<RepoOutputNoteWithFields[]> {
const notes = await repoGetNotesByUserId(input);
return notes.map((note) => ({
...note,
fields: repoSplitFields(note.flds),
tagsArray: note.tags.trim() === "" ? [] : note.tags.trim().split(" "),
}));
}
export async function repoDeleteNote(input: RepoInputDeleteNote): Promise<void> {
log.debug("Deleting note", { id: input.id.toString() });
await prisma.note.delete({
where: { id: input.id },
});
log.info("Note deleted", { id: input.id.toString() });
}
export async function repoCheckNoteOwnership(
input: RepoInputCheckNoteOwnership,
): Promise<boolean> {
const note = await prisma.note.findUnique({
where: { id: input.noteId },
select: { userId: true },
});
return note?.userId === input.userId;
}
export async function repoGetNoteOwnership(
input: RepoInputGetNoteById,
): Promise<RepoOutputNoteOwnership | null> {
const note = await prisma.note.findUnique({
where: { id: input.id },
select: { userId: true },
});
if (!note) return null;
return { userId: note.userId };
}
export async function repoCountNotesByUserId(userId: string): Promise<number> {
return prisma.note.count({
where: { userId },
});
}
export async function repoCountNotesByNoteTypeId(
noteTypeId: number,
): Promise<number> {
return prisma.note.count({
where: { noteTypeId },
});
}

View File

@@ -1,60 +0,0 @@
export type ServiceInputCreateNote = {
noteTypeId: number;
fields: string[];
tags?: string[];
userId: string;
};
export type ServiceInputUpdateNote = {
noteId: bigint;
fields?: string[];
tags?: string[];
userId: string;
};
export type ServiceInputDeleteNote = {
noteId: bigint;
userId: string;
};
export type ServiceInputGetNoteById = {
noteId: bigint;
};
export type ServiceInputGetNotesByNoteTypeId = {
noteTypeId: number;
limit?: number;
offset?: number;
};
export type ServiceInputGetNotesByUserId = {
userId: string;
limit?: number;
offset?: number;
};
export type ServiceOutputNote = {
id: bigint;
guid: string;
noteTypeId: number;
mod: number;
usn: number;
tags: string[];
fields: string[];
sfld: string;
csum: number;
flags: number;
data: string;
userId: string;
createdAt: Date;
updatedAt: Date;
};
export type ServiceOutputCreateNote = {
id: bigint;
guid: string;
};
export type ServiceOutputNoteCount = {
count: number;
};

View File

@@ -1,200 +0,0 @@
import { createLogger } from "@/lib/logger";
import {
repoCreateNote,
repoUpdateNote,
repoGetNoteByIdWithFields,
repoGetNotesByNoteTypeId,
repoGetNotesByUserIdWithFields,
repoDeleteNote,
repoCheckNoteOwnership,
repoCountNotesByUserId,
repoCountNotesByNoteTypeId,
} from "./note-repository";
import {
ServiceInputCreateNote,
ServiceInputUpdateNote,
ServiceInputDeleteNote,
ServiceInputGetNoteById,
ServiceInputGetNotesByNoteTypeId,
ServiceInputGetNotesByUserId,
ServiceOutputNote,
ServiceOutputCreateNote,
ServiceOutputNoteCount,
} from "./note-service-dto";
const log = createLogger("note-service");
export class NoteNotFoundError extends Error {
constructor(noteId: bigint) {
super(`Note not found: ${noteId.toString()}`);
this.name = "NoteNotFoundError";
}
}
export class NoteOwnershipError extends Error {
constructor() {
super("You do not have permission to access this note");
this.name = "NoteOwnershipError";
}
}
export async function serviceCreateNote(
input: ServiceInputCreateNote,
): Promise<ServiceOutputCreateNote> {
log.debug("Creating note", { userId: input.userId, noteTypeId: input.noteTypeId });
const id = await repoCreateNote({
noteTypeId: input.noteTypeId,
fields: input.fields,
tags: input.tags,
userId: input.userId,
});
const note = await repoGetNoteByIdWithFields({ id });
if (!note) {
throw new NoteNotFoundError(id);
}
log.info("Note created successfully", { id: id.toString(), guid: note.guid });
return {
id,
guid: note.guid,
};
}
export async function serviceUpdateNote(
input: ServiceInputUpdateNote,
): Promise<void> {
log.debug("Updating note", { noteId: input.noteId.toString() });
const isOwner = await repoCheckNoteOwnership({
noteId: input.noteId,
userId: input.userId,
});
if (!isOwner) {
throw new NoteOwnershipError();
}
await repoUpdateNote({
id: input.noteId,
fields: input.fields,
tags: input.tags,
});
log.info("Note updated successfully", { noteId: input.noteId.toString() });
}
export async function serviceDeleteNote(
input: ServiceInputDeleteNote,
): Promise<void> {
log.debug("Deleting note", { noteId: input.noteId.toString() });
const isOwner = await repoCheckNoteOwnership({
noteId: input.noteId,
userId: input.userId,
});
if (!isOwner) {
throw new NoteOwnershipError();
}
await repoDeleteNote({ id: input.noteId });
log.info("Note deleted successfully", { noteId: input.noteId.toString() });
}
export async function serviceGetNoteById(
input: ServiceInputGetNoteById,
): Promise<ServiceOutputNote | null> {
log.debug("Fetching note by id", { noteId: input.noteId.toString() });
const note = await repoGetNoteByIdWithFields({ id: input.noteId });
if (!note) {
log.debug("Note not found", { noteId: input.noteId.toString() });
return null;
}
return {
id: note.id,
guid: note.guid,
noteTypeId: note.noteTypeId,
mod: note.mod,
usn: note.usn,
tags: note.tagsArray,
fields: note.fields,
sfld: note.sfld,
csum: note.csum,
flags: note.flags,
data: note.data,
userId: note.userId,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
};
}
export async function serviceGetNotesByNoteTypeId(
input: ServiceInputGetNotesByNoteTypeId,
): Promise<ServiceOutputNote[]> {
log.debug("Fetching notes by note type", { noteTypeId: input.noteTypeId });
const notes = await repoGetNotesByNoteTypeId(input);
return notes.map((note) => ({
id: note.id,
guid: note.guid,
noteTypeId: note.noteTypeId,
mod: note.mod,
usn: note.usn,
tags: note.tags.trim() === "" ? [] : note.tags.trim().split(" "),
fields: note.flds.split("\x1f"),
sfld: note.sfld,
csum: note.csum,
flags: note.flags,
data: note.data,
userId: note.userId,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
}));
}
export async function serviceGetNotesByUserId(
input: ServiceInputGetNotesByUserId,
): Promise<ServiceOutputNote[]> {
log.debug("Fetching notes by user", { userId: input.userId });
const notes = await repoGetNotesByUserIdWithFields(input);
return notes.map((note) => ({
id: note.id,
guid: note.guid,
noteTypeId: note.noteTypeId,
mod: note.mod,
usn: note.usn,
tags: note.tagsArray,
fields: note.fields,
sfld: note.sfld,
csum: note.csum,
flags: note.flags,
data: note.data,
userId: note.userId,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
}));
}
export async function serviceCountNotesByUserId(
userId: string,
): Promise<ServiceOutputNoteCount> {
const count = await repoCountNotesByUserId(userId);
return { count };
}
export async function serviceCountNotesByNoteTypeId(
noteTypeId: number,
): Promise<ServiceOutputNoteCount> {
const count = await repoCountNotesByNoteTypeId(noteTypeId);
return { count };
}

View File

@@ -1,20 +0,0 @@
import { z } from "zod";
export const schemaActionInputProcessOCR = z.object({
imageBase64: z.string().min(1, "Image is required"),
deckId: z.number().int().positive("Deck ID must be positive"),
sourceLanguage: z.string().optional(),
targetLanguage: z.string().optional(),
});
export type ActionInputProcessOCR = z.infer<typeof schemaActionInputProcessOCR>;
export interface ActionOutputProcessOCR {
success: boolean;
message: string;
data?: {
pairsCreated: number;
sourceLanguage?: string;
targetLanguage?: string;
};
}

View File

@@ -1,36 +0,0 @@
"use server";
import { headers } from "next/headers";
import { auth } from "@/auth";
import { validate } from "@/utils/validate";
import { ValidateError } from "@/lib/errors";
import { createLogger } from "@/lib/logger";
import { serviceProcessOCR } from "./ocr-service";
import { schemaActionInputProcessOCR } from "./ocr-action-dto";
import type { ActionOutputProcessOCR } from "./ocr-action-dto";
const log = createLogger("ocr-action");
export async function actionProcessOCR(
input: unknown
): Promise<ActionOutputProcessOCR> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
log.warn("Unauthorized OCR attempt");
return { success: false, message: "Unauthorized" };
}
const validatedInput = validate(input, schemaActionInputProcessOCR);
return serviceProcessOCR({
...validatedInput,
userId: session.user.id,
});
} catch (e) {
if (e instanceof ValidateError) {
return { success: false, message: e.message };
}
log.error("OCR action failed", { error: e });
return { success: false, message: "Unknown error occurred." };
}
}

View File

@@ -1 +0,0 @@
export {};

View File

@@ -1 +0,0 @@
export {};

View File

@@ -1,21 +0,0 @@
import { z } from "zod";
export const schemaServiceInputProcessOCR = z.object({
imageBase64: z.string().min(1, "Image is required"),
deckId: z.number().int().positive("Deck ID must be positive"),
sourceLanguage: z.string().optional(),
targetLanguage: z.string().optional(),
userId: z.string().min(1, "User ID is required"),
});
export type ServiceInputProcessOCR = z.infer<typeof schemaServiceInputProcessOCR>;
export interface ServiceOutputProcessOCR {
success: boolean;
message: string;
data?: {
pairsCreated: number;
sourceLanguage?: string;
targetLanguage?: string;
};
}

View File

@@ -1,155 +0,0 @@
import { executeOCR } from "@/lib/bigmodel/ocr/orchestrator";
import { serviceCheckOwnership } from "@/modules/deck/deck-service";
import { serviceCreateNote } from "@/modules/note/note-service";
import { serviceCreateCard } from "@/modules/card/card-service";
import { serviceGetNoteTypesByUserId, serviceCreateNoteType } from "@/modules/note-type/note-type-service";
import { createLogger } from "@/lib/logger";
import type { ServiceInputProcessOCR, ServiceOutputProcessOCR } from "./ocr-service-dto";
import { NoteKind } from "../../../generated/prisma/enums";
const log = createLogger("ocr-service");
const VOCABULARY_NOTE_TYPE_NAME = "Vocabulary (OCR)";
async function getOrCreateVocabularyNoteType(userId: string): Promise<number> {
const existingTypes = await serviceGetNoteTypesByUserId({ userId });
const existing = existingTypes.find((nt) => nt.name === VOCABULARY_NOTE_TYPE_NAME);
if (existing) {
return existing.id;
}
const fields = [
{ name: "Word", ord: 0, sticky: false, rtl: false, font: "Arial", size: 20, media: [] },
{ name: "Definition", ord: 1, sticky: false, rtl: false, font: "Arial", size: 20, media: [] },
{ name: "Source Language", ord: 2, sticky: false, rtl: false, font: "Arial", size: 16, media: [] },
{ name: "Target Language", ord: 3, sticky: false, rtl: false, font: "Arial", size: 16, media: [] },
];
const templates = [
{
name: "Word → Definition",
ord: 0,
qfmt: "{{Word}}",
afmt: "{{FrontSide}}<hr id=answer>{{Definition}}",
},
{
name: "Definition → Word",
ord: 1,
qfmt: "{{Definition}}",
afmt: "{{FrontSide}}<hr id=answer>{{Word}}",
},
];
const css = ".card { font-family: Arial; font-size: 20px; text-align: center; color: black; background-color: white; }";
const noteTypeId = await serviceCreateNoteType({
name: VOCABULARY_NOTE_TYPE_NAME,
kind: NoteKind.STANDARD,
css,
fields,
templates,
userId,
});
log.info("Created vocabulary note type", { noteTypeId, userId });
return noteTypeId;
}
export async function serviceProcessOCR(
input: ServiceInputProcessOCR
): Promise<ServiceOutputProcessOCR> {
log.info("Processing OCR request", { deckId: input.deckId, userId: input.userId });
const isOwner = await serviceCheckOwnership({
deckId: input.deckId,
userId: input.userId
});
if (!isOwner) {
log.warn("Deck ownership mismatch", {
deckId: input.deckId,
userId: input.userId
});
return {
success: false,
message: "You don't have permission to modify this deck"
};
}
let ocrResult;
try {
log.debug("Calling OCR pipeline");
ocrResult = await executeOCR({
imageBase64: input.imageBase64,
sourceLanguage: input.sourceLanguage,
targetLanguage: input.targetLanguage,
});
} catch (error) {
log.error("OCR pipeline failed", { error });
return {
success: false,
message: "Failed to process image. Please try again."
};
}
if (!ocrResult.pairs || ocrResult.pairs.length === 0) {
log.info("No vocabulary pairs extracted from image");
return {
success: false,
message: "No vocabulary pairs could be extracted from the image"
};
}
const sourceLanguage = ocrResult.detectedSourceLanguage || input.sourceLanguage || "Unknown";
const targetLanguage = ocrResult.detectedTargetLanguage || input.targetLanguage || "Unknown";
const noteTypeId = await getOrCreateVocabularyNoteType(input.userId);
let pairsCreated = 0;
for (const pair of ocrResult.pairs) {
try {
const { id: noteId } = await serviceCreateNote({
noteTypeId,
userId: input.userId,
fields: [pair.word, pair.definition, sourceLanguage, targetLanguage],
tags: ["ocr"],
});
await serviceCreateCard({
noteId,
deckId: input.deckId,
ord: 0,
});
await serviceCreateCard({
noteId,
deckId: input.deckId,
ord: 1,
});
pairsCreated++;
} catch (error) {
log.error("Failed to create note/card", {
word: pair.word,
error
});
}
}
log.info("OCR processing complete", {
pairsCreated,
sourceLanguage,
targetLanguage
});
return {
success: true,
message: `Successfully created ${pairsCreated} vocabulary pairs`,
data: {
pairsCreated,
sourceLanguage,
targetLanguage,
},
};
}

View File

@@ -0,0 +1,25 @@
"use server-headers";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { createLogger } from "@/lib/logger";
const log = createLogger("shared-action-utils");
export async function getCurrentUserId(): Promise<string | null> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
log.warn("Unauthenticated access attempt");
return null;
}
return session.user.id;
}
export async function requireAuth(): Promise<string> {
const userId = await getCurrentUserId();
if (!userId) {
log.warn("Authentication required but rejected");
throw new Error("Unauthorized");
}
return userId;
}

View File

@@ -1,50 +1,40 @@
"use server"; "use server";
import { serviceGenIPA, serviceGenLanguage, serviceTranslateText } from "./translator-service";
import { import {
ActionInputTranslateText, ActionInputTranslateText,
ActionOutputTranslateText, ActionOutputTranslateText,
validateActionInputTranslateText, validateActionInputTranslateText,
} from "./translator-action-dto"; } from "./translator-action-dto";
import { ValidateError } from "@/lib/errors";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
import { serviceTranslateText, serviceGenIPA, serviceGenLanguage } from "./translator-service"; import { ValidateError } from "@/lib/errors";
const log = createLogger("translator-action"); const log = createLogger("translator-action");
export const actionTranslateText = async ( export const actionTranslateText = async (
dto: ActionInputTranslateText input: unknown,
): Promise<ActionOutputTranslateText> => { ): Promise<ActionOutputTranslateText> => {
try { try {
const validated = validateActionInputTranslateText(input);
const result = await serviceTranslateText(validated);
return { return {
message: "success",
success: true, success: true,
data: await serviceTranslateText(validateActionInputTranslateText(dto)), message: "Translation completed",
data: result,
}; };
} catch (e) { } catch (e) {
if (e instanceof ValidateError) { if (e instanceof ValidateError) {
return { return { success: false, message: e.message };
success: false,
message: e.message,
};
} }
log.error("Translation action failed", { error: e }); log.error("Translation failed", { error: e instanceof Error ? e.message : String(e) });
return { return { success: false, message: "Translation failed" };
success: false,
message: "Unknown error occurred.",
};
} }
}; };
/** export const genIPA = async (text: string): Promise<string> => {
* @deprecated 保留此函数以支持旧代码text-speaker 功能)
*/
export const genIPA = async (text: string) => {
return serviceGenIPA({ text }); return serviceGenIPA({ text });
}; };
/** export const genLanguage = async (text: string): Promise<string> => {
* @deprecated 保留此函数以支持旧代码text-speaker 功能)
*/
export const genLanguage = async (text: string) => {
return serviceGenLanguage({ text }); return serviceGenLanguage({ text });
}; };

View File

@@ -1,23 +0,0 @@
export type RepoInputSelectLatestTranslation = {
sourceText: string;
targetLanguage: string;
};
export type RepoOutputSelectLatestTranslation = {
id: number;
translatedText: string;
sourceLanguage: string;
targetLanguage: string;
sourceIpa: string | null;
targetIpa: string | null;
} | null;
export type RepoInputCreateTranslationHistory = {
userId?: string;
sourceText: string;
sourceLanguage: string;
targetLanguage: string;
translatedText: string;
sourceIpa?: string;
targetIpa?: string;
};

View File

@@ -1,41 +0,0 @@
import {
RepoInputCreateTranslationHistory,
RepoInputSelectLatestTranslation,
RepoOutputSelectLatestTranslation,
} from "./translator-repository-dto";
import { prisma } from "@/lib/db";
export async function repoSelectLatestTranslation(
dto: RepoInputSelectLatestTranslation
): Promise<RepoOutputSelectLatestTranslation> {
const result = await prisma.translationHistory.findFirst({
where: {
sourceText: dto.sourceText,
targetLanguage: dto.targetLanguage,
},
orderBy: {
createdAt: "desc",
},
});
if (!result) {
return null;
}
return {
id: result.id,
translatedText: result.translatedText,
sourceLanguage: result.sourceLanguage,
targetLanguage: result.targetLanguage,
sourceIpa: result.sourceIpa,
targetIpa: result.targetIpa,
};
}
export async function repoCreateTranslationHistory(
data: RepoInputCreateTranslationHistory
) {
return await prisma.translationHistory.create({
data: data,
});
}

View File

@@ -1,6 +1,5 @@
import { executeTranslation } from "@/lib/bigmodel/translator/orchestrator"; import { executeTranslation } from "@/lib/bigmodel/translator/orchestrator";
import { getAnswer } from "@/lib/bigmodel/llm"; import { getAnswer } from "@/lib/bigmodel/llm";
import { repoCreateTranslationHistory, repoSelectLatestTranslation } from "./translator-repository";
import { import {
ServiceInputTranslateText, ServiceInputTranslateText,
ServiceOutputTranslateText, ServiceOutputTranslateText,
@@ -16,16 +15,8 @@ const log = createLogger("translator-service");
export const serviceTranslateText = async ( export const serviceTranslateText = async (
dto: ServiceInputTranslateText dto: ServiceInputTranslateText
): Promise<ServiceOutputTranslateText> => { ): Promise<ServiceOutputTranslateText> => {
const { sourceText, targetLanguage, forceRetranslate, needIpa, userId, sourceLanguage } = dto; const { sourceText, targetLanguage, sourceLanguage, needIpa } = dto;
// Check for existing translation
const lastTranslation = await repoSelectLatestTranslation({
sourceText,
targetLanguage,
});
if (forceRetranslate || !lastTranslation) {
// Call AI for translation
const response = await executeTranslation( const response = await executeTranslation(
sourceText, sourceText,
targetLanguage, targetLanguage,
@@ -33,19 +24,6 @@ export const serviceTranslateText = async (
sourceLanguage sourceLanguage
); );
// Save translation history asynchronously (don't block response)
repoCreateTranslationHistory({
userId,
sourceText,
sourceLanguage: response.sourceLanguage,
targetLanguage: response.targetLanguage,
translatedText: response.translatedText,
sourceIpa: needIpa ? response.sourceIpa : undefined,
targetIpa: needIpa ? response.targetIpa : undefined,
}).catch((error) => {
log.error("Failed to save translation data", { error });
});
return { return {
sourceText: response.sourceText, sourceText: response.sourceText,
translatedText: response.translatedText, translatedText: response.translatedText,
@@ -54,30 +32,6 @@ export const serviceTranslateText = async (
sourceIpa: response.sourceIpa || "", sourceIpa: response.sourceIpa || "",
targetIpa: response.targetIpa || "", targetIpa: response.targetIpa || "",
}; };
} else {
// Return cached translation
// Still save a history record for analytics
repoCreateTranslationHistory({
userId,
sourceText,
sourceLanguage: lastTranslation.sourceLanguage,
targetLanguage: lastTranslation.targetLanguage,
translatedText: lastTranslation.translatedText,
sourceIpa: lastTranslation.sourceIpa || undefined,
targetIpa: lastTranslation.targetIpa || undefined,
}).catch((error) => {
log.error("Failed to save translation data", { error });
});
return {
sourceText,
translatedText: lastTranslation.translatedText,
sourceLanguage: lastTranslation.sourceLanguage,
targetLanguage: lastTranslation.targetLanguage,
sourceIpa: lastTranslation.sourceIpa || "",
targetIpa: lastTranslation.targetIpa || "",
};
}
}; };
export const serviceGenIPA = async ( export const serviceGenIPA = async (

View File

@@ -1,165 +0,0 @@
/**
* Shared types for Anki-compatible data structures
* Based on Anki's official database schema
*/
import type { CardType, CardQueue, NoteKind, Visibility } from "../../generated/prisma/enums";
// ============================================
// NoteType (Anki: models)
// ============================================
export interface NoteTypeField {
name: string;
ord: number;
sticky: boolean;
rtl: boolean;
font: string;
size: number;
media: string[];
}
export interface NoteTypeTemplate {
name: string;
ord: number;
qfmt: string; // Question format (Mustache template)
afmt: string; // Answer format (Mustache template)
bqfmt?: string; // Browser question format
bafmt?: string; // Browser answer format
did?: number; // Deck override
}
export interface TSharedNoteType {
id: number;
name: string;
kind: NoteKind;
css: string;
fields: NoteTypeField[];
templates: NoteTypeTemplate[];
userId: string;
createdAt: Date;
updatedAt: Date;
}
// ============================================
// Deck (Anki: decks) - replaces Folder
// ============================================
export interface TSharedDeck {
id: number;
name: string;
desc: string;
userId: string;
visibility: Visibility;
collapsed: boolean;
conf: Record<string, unknown>;
createdAt: Date;
updatedAt: Date;
cardCount?: number;
}
// ============================================
// Note (Anki: notes)
// ============================================
export interface TSharedNote {
id: bigint;
guid: string;
noteTypeId: number;
mod: number;
usn: number;
tags: string; // Space-separated
flds: string; // Field values separated by 0x1f
sfld: string; // Sort field
csum: number; // Checksum of first field
flags: number;
data: string;
userId: string;
createdAt: Date;
updatedAt: Date;
}
// Helper to get fields as array
export function getNoteFields(note: TSharedNote): string[] {
return note.flds.split('\x1f');
}
// Helper to set fields from array
export function setNoteFields(fields: string[]): string {
return fields.join('\x1f');
}
// Helper to get tags as array
export function getNoteTags(note: TSharedNote): string[] {
return note.tags.trim().split(' ').filter(Boolean);
}
// Helper to set tags from array
export function setNoteTags(tags: string[]): string {
return ` ${tags.join(' ')} `;
}
// ============================================
// Card (Anki: cards)
// ============================================
export interface TSharedCard {
id: bigint;
noteId: bigint;
deckId: number;
ord: number;
mod: number;
usn: number;
type: CardType;
queue: CardQueue;
due: number;
ivl: number;
factor: number;
reps: number;
lapses: number;
left: number;
odue: number;
odid: number;
flags: number;
data: string;
createdAt: Date;
updatedAt: Date;
}
// Card for review (with note data)
export interface TCardForReview extends TSharedCard {
note: TSharedNote & {
noteType: TSharedNoteType;
};
deck: TSharedDeck;
}
// ============================================
// Review
// ============================================
export type ReviewEase = 1 | 2 | 3 | 4; // Again, Hard, Good, Easy
export interface TSharedRevlog {
id: bigint;
cardId: bigint;
usn: number;
ease: number;
ivl: number;
lastIvl: number;
factor: number;
time: number; // Review time in ms
type: number;
}
// ============================================
// Deck Favorites
// ============================================
export interface TSharedDeckFavorite {
id: number;
userId: string;
deckId: number;
createdAt: Date;
deck?: TSharedDeck;
}

33
src/shared/card-type.ts Normal file
View File

@@ -0,0 +1,33 @@
export type DictionaryItemEntry = {
id: number;
itemId: number;
ipa: string | null;
definition: string;
partOfSpeech: string | null;
example: string;
createdAt: Date;
updatedAt: Date;
};
export type DictionaryItemWithEntries = {
id: number;
frequency: number;
standardForm: string;
queryLang: string;
definitionLang: string;
createdAt: Date;
updatedAt: Date;
entries: DictionaryItemEntry[];
};
export type CardSide = "A" | "B";
export type CardForStudy = {
id: number;
deckId: number;
showSideAFirst: boolean;
sideA: DictionaryItemWithEntries;
sideB: DictionaryItemWithEntries;
createdAt: Date;
updatedAt: Date;
};

View File

@@ -20,3 +20,17 @@ export const LENGTH_MAX_USERNAME = 30;
export const LENGTH_MIN_USERNAME = 3; export const LENGTH_MIN_USERNAME = 3;
export const LENGTH_MAX_PASSWORD = 100; export const LENGTH_MAX_PASSWORD = 100;
export const LENGTH_MIN_PASSWORD = 8; export const LENGTH_MIN_PASSWORD = 8;
export const FIELD_SEPARATOR = "\x1f";
export const DEFAULT_NEW_PER_DAY = 20;
export const DEFAULT_REV_PER_DAY = 200;
export const SECONDS_PER_MINUTE = 60;
export const SECONDS_PER_HOUR = 3600;
export const SECONDS_PER_DAY = 86400;
export const MS_PER_SECOND = 1000;
export const MS_PER_MINUTE = 60000;
export const MS_PER_HOUR = 3600000;
export const MS_PER_DAY = 86400000;

View File

@@ -6,6 +6,7 @@ export type TSharedEntry = {
}; };
export type TSharedItem = { export type TSharedItem = {
id?: number;
standardForm: string, standardForm: string,
entries: TSharedEntry[]; entries: TSharedEntry[];
}; };

View File

@@ -280,7 +280,7 @@ export const THEME_PRESETS: ThemePreset[] = [
}, },
]; ];
export const DEFAULT_THEME = "teal"; export const DEFAULT_THEME = "mist";
export function getThemePreset(id: string): ThemePreset | undefined { export function getThemePreset(id: string): ThemePreset | undefined {
return THEME_PRESETS.find((preset) => preset.id === id); return THEME_PRESETS.find((preset) => preset.id === id);