Compare commits
5 Commits
7ba31a37bd
...
cbb9326f84
| Author | SHA1 | Date | |
|---|---|---|---|
| cbb9326f84 | |||
| 49ad953add | |||
| f1eafa8015 | |||
| 12e502313b | |||
| 13e8f51ada |
@@ -222,7 +222,11 @@
|
|||||||
"days": "{count} Tage",
|
"days": "{count} Tage",
|
||||||
"months": "{count} Monate",
|
"months": "{count} Monate",
|
||||||
"minAbbr": "m",
|
"minAbbr": "m",
|
||||||
"dayAbbr": "T"
|
"dayAbbr": "T",
|
||||||
|
"cardTypeNew": "Neu",
|
||||||
|
"cardTypeLearning": "Lernen",
|
||||||
|
"cardTypeReview": "Wiederholung",
|
||||||
|
"cardTypeRelearning": "Neu lernen"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"unauthorized": "Sie sind nicht berechtigt, auf dieses Deck zuzugreifen"
|
"unauthorized": "Sie sind nicht berechtigt, auf dieses Deck zuzugreifen"
|
||||||
|
|||||||
@@ -213,7 +213,11 @@
|
|||||||
"days": "{count}d",
|
"days": "{count}d",
|
||||||
"months": "{count}mo",
|
"months": "{count}mo",
|
||||||
"minAbbr": "m",
|
"minAbbr": "m",
|
||||||
"dayAbbr": "d"
|
"dayAbbr": "d",
|
||||||
|
"cardTypeNew": "New",
|
||||||
|
"cardTypeLearning": "Learning",
|
||||||
|
"cardTypeReview": "Review",
|
||||||
|
"cardTypeRelearning": "Relearning"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"unauthorized": "You are not authorized to access this deck"
|
"unauthorized": "You are not authorized to access this deck"
|
||||||
|
|||||||
@@ -222,7 +222,11 @@
|
|||||||
"days": "{count}j",
|
"days": "{count}j",
|
||||||
"months": "{count}mois",
|
"months": "{count}mois",
|
||||||
"minAbbr": "m",
|
"minAbbr": "m",
|
||||||
"dayAbbr": "j"
|
"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": "Vous n'êtes pas autorisé à accéder à ce deck"
|
||||||
|
|||||||
@@ -222,7 +222,11 @@
|
|||||||
"days": "{count}g",
|
"days": "{count}g",
|
||||||
"months": "{count}mesi",
|
"months": "{count}mesi",
|
||||||
"minAbbr": "m",
|
"minAbbr": "m",
|
||||||
"dayAbbr": "g"
|
"dayAbbr": "g",
|
||||||
|
"cardTypeNew": "Nuovo",
|
||||||
|
"cardTypeLearning": "Apprendimento",
|
||||||
|
"cardTypeReview": "Ripasso",
|
||||||
|
"cardTypeRelearning": "Riapprendimento"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"unauthorized": "Non sei autorizzato ad accedere a questo mazzo"
|
"unauthorized": "Non sei autorizzato ad accedere a questo mazzo"
|
||||||
|
|||||||
@@ -213,7 +213,11 @@
|
|||||||
"days": "{count}日",
|
"days": "{count}日",
|
||||||
"months": "{count}ヶ月",
|
"months": "{count}ヶ月",
|
||||||
"minAbbr": "分",
|
"minAbbr": "分",
|
||||||
"dayAbbr": "日"
|
"dayAbbr": "日",
|
||||||
|
"cardTypeNew": "新規",
|
||||||
|
"cardTypeLearning": "学習中",
|
||||||
|
"cardTypeReview": "復習",
|
||||||
|
"cardTypeRelearning": "再学習"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"unauthorized": "このデッキにアクセスする権限がありません"
|
"unauthorized": "このデッキにアクセスする権限がありません"
|
||||||
|
|||||||
@@ -222,7 +222,11 @@
|
|||||||
"days": "{count}일",
|
"days": "{count}일",
|
||||||
"months": "{count}개월",
|
"months": "{count}개월",
|
||||||
"minAbbr": "분",
|
"minAbbr": "분",
|
||||||
"dayAbbr": "일"
|
"dayAbbr": "일",
|
||||||
|
"cardTypeNew": "새 카드",
|
||||||
|
"cardTypeLearning": "학습 중",
|
||||||
|
"cardTypeReview": "복습 중",
|
||||||
|
"cardTypeRelearning": "재학습 중"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"unauthorized": "이 덱에 접근할 권한이 없습니다"
|
"unauthorized": "이 덱에 접근할 권한이 없습니다"
|
||||||
|
|||||||
@@ -222,7 +222,11 @@
|
|||||||
"days": "{count} كۈن",
|
"days": "{count} كۈن",
|
||||||
"months": "{count} ئاي",
|
"months": "{count} ئاي",
|
||||||
"minAbbr": "م",
|
"minAbbr": "م",
|
||||||
"dayAbbr": "ك"
|
"dayAbbr": "ك",
|
||||||
|
"cardTypeNew": "يېڭى",
|
||||||
|
"cardTypeLearning": "ئۆگىنىۋاتىدۇ",
|
||||||
|
"cardTypeReview": "تەكرارلاش",
|
||||||
|
"cardTypeRelearning": "قايتا ئۆگىنىش"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"unauthorized": "بۇ دېكنى زىيارەت قىلىش ھوقۇقىڭىز يوق"
|
"unauthorized": "بۇ دېكنى زىيارەت قىلىش ھوقۇقىڭىز يوق"
|
||||||
|
|||||||
@@ -213,7 +213,11 @@
|
|||||||
"days": "{count} 天",
|
"days": "{count} 天",
|
||||||
"months": "{count} 个月",
|
"months": "{count} 个月",
|
||||||
"minAbbr": "分",
|
"minAbbr": "分",
|
||||||
"dayAbbr": "天"
|
"dayAbbr": "天",
|
||||||
|
"cardTypeNew": "新卡片",
|
||||||
|
"cardTypeLearning": "学习中",
|
||||||
|
"cardTypeReview": "复习中",
|
||||||
|
"cardTypeRelearning": "重学中"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"unauthorized": "您无权访问该牌组"
|
"unauthorized": "您无权访问该牌组"
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useTransition } from "react";
|
import { useState, useEffect, useTransition, useCallback } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
import { Layers, Check, Clock } from "lucide-react";
|
import { Layers, Check, Clock, Sparkles } from "lucide-react";
|
||||||
import type { ActionOutputCardWithNote, ActionOutputScheduledCard } from "@/modules/card/card-action-dto";
|
import type { ActionOutputCardWithNote, ActionOutputScheduledCard } from "@/modules/card/card-action-dto";
|
||||||
import { actionGetCardsForReview, actionAnswerCard } from "@/modules/card/card-action";
|
import { actionGetCardsForReview, actionAnswerCard } from "@/modules/card/card-action";
|
||||||
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 { CardType } from "../../../../generated/prisma/enums";
|
||||||
|
import { calculatePreviewIntervals, formatPreviewInterval, type CardPreview } from "./interval-preview";
|
||||||
|
|
||||||
const myFont = localFont({
|
const myFont = localFont({
|
||||||
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
|
src: "../../../../public/fonts/NotoNaskhArabic-VariableFont_wght.ttf",
|
||||||
@@ -70,11 +72,11 @@ const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
|
|||||||
return card.note.flds.split('\x1f');
|
return card.note.flds.split('\x1f');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleShowAnswer = () => {
|
const handleShowAnswer = useCallback(() => {
|
||||||
setShowAnswer(true);
|
setShowAnswer(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleAnswer = (ease: ReviewEase) => {
|
const handleAnswer = useCallback((ease: ReviewEase) => {
|
||||||
const card = getCurrentCard();
|
const card = getCurrentCard();
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
|
|
||||||
@@ -101,7 +103,39 @@ const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
|
|||||||
setError(result.message);
|
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 formatNextReview = (scheduled: ActionOutputScheduledCard): string => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -127,6 +161,36 @@ const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
|
|||||||
return t("months", { count: Math.floor(ivl / 30) });
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
@@ -173,6 +237,14 @@ const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
|
|||||||
const front = fields[0] ?? "";
|
const front = fields[0] ?? "";
|
||||||
const back = fields[1] ?? "";
|
const back = fields[1] ?? "";
|
||||||
|
|
||||||
|
const cardPreview: CardPreview = {
|
||||||
|
type: currentCard.type,
|
||||||
|
ivl: currentCard.ivl,
|
||||||
|
factor: currentCard.factor,
|
||||||
|
left: currentCard.left,
|
||||||
|
};
|
||||||
|
const previewIntervals = calculatePreviewIntervals(cardPreview);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
@@ -180,8 +252,13 @@ const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
|
|||||||
<Layers className="w-5 h-5" />
|
<Layers className="w-5 h-5" />
|
||||||
<span className="font-medium">{deckName}</span>
|
<span className="font-medium">{deckName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500">
|
<div className="flex items-center gap-3">
|
||||||
{t("progress", { current: currentIndex + 1, total: cards.length + currentIndex })}
|
<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>
|
</div>
|
||||||
|
|
||||||
@@ -238,43 +315,51 @@ const Memorize: React.FC<MemorizeProps> = ({ deckId, deckName }) => {
|
|||||||
className="px-8 py-3 text-lg rounded-full"
|
className="px-8 py-3 text-lg rounded-full"
|
||||||
>
|
>
|
||||||
{t("showAnswer")}
|
{t("showAnswer")}
|
||||||
|
<span className="ml-2 text-xs opacity-60">Space</span>
|
||||||
</LightButton>
|
</LightButton>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap justify-center gap-3">
|
<div className="flex flex-wrap justify-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAnswer(1)}
|
onClick={() => handleAnswer(1)}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="flex flex-col items-center px-6 py-3 rounded-xl bg-red-100 hover:bg-red-200 text-red-700 transition-colors disabled:opacity-50"
|
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="font-medium">{t("again")}</span>
|
||||||
<span className="text-xs opacity-75"><1{t("minAbbr")}</span>
|
<span className="text-xs opacity-75">{formatPreviewInterval(previewIntervals.again)}</span>
|
||||||
|
<span className="text-xs opacity-50 mt-1">1</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAnswer(2)}
|
onClick={() => handleAnswer(2)}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="flex flex-col items-center px-6 py-3 rounded-xl bg-orange-100 hover:bg-orange-200 text-orange-700 transition-colors disabled:opacity-50"
|
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="font-medium">{t("hard")}</span>
|
||||||
<span className="text-xs opacity-75">6{t("minAbbr")}</span>
|
<span className="text-xs opacity-75">{formatPreviewInterval(previewIntervals.hard)}</span>
|
||||||
|
<span className="text-xs opacity-50 mt-1">2</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAnswer(3)}
|
onClick={() => handleAnswer(3)}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="flex flex-col items-center px-6 py-3 rounded-xl bg-green-100 hover:bg-green-200 text-green-700 transition-colors disabled:opacity-50"
|
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"
|
||||||
>
|
>
|
||||||
<span className="font-medium">{t("good")}</span>
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-xs opacity-75">10{t("minAbbr")}</span>
|
<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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAnswer(4)}
|
onClick={() => handleAnswer(4)}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="flex flex-col items-center px-6 py-3 rounded-xl bg-blue-100 hover:bg-blue-200 text-blue-700 transition-colors disabled:opacity-50"
|
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="font-medium">{t("easy")}</span>
|
||||||
<span className="text-xs opacity-75">4{t("dayAbbr")}</span>
|
<span className="text-xs opacity-75">{formatPreviewInterval(previewIntervals.easy)}</span>
|
||||||
|
<span className="text-xs opacity-50 mt-1">4</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
84
src/app/(features)/memorize/interval-preview.ts
Normal file
84
src/app/(features)/memorize/interval-preview.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
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): PreviewIntervals {
|
||||||
|
const steps = SM2_CONFIG.LEARNING_STEPS;
|
||||||
|
const stepIndex = Math.floor(left % 1000);
|
||||||
|
|
||||||
|
const again = steps[0];
|
||||||
|
|
||||||
|
let hard: number;
|
||||||
|
if (stepIndex === 0 && steps.length >= 2) {
|
||||||
|
hard = (steps[0] + steps[1]) / 2;
|
||||||
|
} else if (stepIndex < steps.length - 1) {
|
||||||
|
hard = steps[stepIndex];
|
||||||
|
} else {
|
||||||
|
hard = SM2_CONFIG.GRADUATING_INTERVAL_GOOD * 1440;
|
||||||
|
}
|
||||||
|
|
||||||
|
let good: number;
|
||||||
|
if (stepIndex < steps.length - 1) {
|
||||||
|
good = steps[stepIndex + 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:
|
||||||
|
case CardType.RELEARNING:
|
||||||
|
return calculateLearningIntervals(card.left);
|
||||||
|
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`;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
import initSqlJs from "sql.js";
|
import initSqlJs from "sql.js";
|
||||||
import type { Database } from "sql.js";
|
import type { Database } from "sql.js";
|
||||||
|
import { createHash } from "crypto";
|
||||||
import type {
|
import type {
|
||||||
AnkiDeck,
|
AnkiDeck,
|
||||||
AnkiNoteType,
|
AnkiNoteType,
|
||||||
@@ -10,30 +11,21 @@ import type {
|
|||||||
AnkiRevlogRow,
|
AnkiRevlogRow,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const FIELD_SEPARATOR = "\x1f";
|
|
||||||
const BASE91_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&()*+,-./:;<=>?@[]^_`{|}~";
|
const BASE91_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&()*+,-./:;<=>?@[]^_`{|}~";
|
||||||
|
|
||||||
function generateGuid(): string {
|
function generateGuid(): string {
|
||||||
let result = "";
|
let guid = "";
|
||||||
const id = Date.now() ^ (Math.random() * 0xffffffff);
|
const bytes = new Uint8Array(10);
|
||||||
let num = BigInt(id);
|
crypto.getRandomValues(bytes);
|
||||||
|
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
result = BASE91_CHARS[Number(num % 91n)] + result;
|
guid += BASE91_CHARS[bytes[i] % BASE91_CHARS.length];
|
||||||
num = num / 91n;
|
|
||||||
}
|
}
|
||||||
|
return guid;
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function checksum(str: string): number {
|
function checksum(text: string): number {
|
||||||
let hash = 0;
|
const hash = createHash("sha1").update(text.normalize("NFC")).digest("hex");
|
||||||
for (let i = 0; i < str.length; i++) {
|
return parseInt(hash.substring(0, 8), 16);
|
||||||
const char = str.charCodeAt(i);
|
|
||||||
hash = ((hash << 5) - hash) + char;
|
|
||||||
hash = hash & hash;
|
|
||||||
}
|
|
||||||
return Math.abs(hash) % 100000000;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createCollectionSql(): string {
|
function createCollectionSql(): string {
|
||||||
@@ -198,205 +190,208 @@ async function createDatabase(data: ExportDeckData): Promise<Uint8Array> {
|
|||||||
|
|
||||||
const db = new SQL.Database();
|
const db = new SQL.Database();
|
||||||
|
|
||||||
db.run(createCollectionSql());
|
try {
|
||||||
|
db.run(createCollectionSql());
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const nowSeconds = Math.floor(now / 1000);
|
const nowSeconds = Math.floor(now / 1000);
|
||||||
|
|
||||||
const defaultConfig = {
|
const defaultConfig = {
|
||||||
dueCounts: true,
|
dueCounts: true,
|
||||||
estTimes: true,
|
estTimes: true,
|
||||||
newSpread: 0,
|
newSpread: 0,
|
||||||
curDeck: data.deck.id,
|
curDeck: data.deck.id,
|
||||||
curModel: data.noteType.id,
|
curModel: data.noteType.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
const deckJson: Record<string, AnkiDeck> = {
|
const deckJson: Record<string, AnkiDeck> = {
|
||||||
[data.deck.id.toString()]: {
|
[data.deck.id.toString()]: {
|
||||||
id: data.deck.id,
|
id: data.deck.id,
|
||||||
mod: nowSeconds,
|
mod: nowSeconds,
|
||||||
name: data.deck.name,
|
name: data.deck.name,
|
||||||
usn: -1,
|
usn: -1,
|
||||||
lrnToday: [0, 0],
|
lrnToday: [0, 0],
|
||||||
revToday: [0, 0],
|
revToday: [0, 0],
|
||||||
newToday: [0, 0],
|
newToday: [0, 0],
|
||||||
timeToday: [0, 0],
|
timeToday: [0, 0],
|
||||||
collapsed: data.deck.collapsed,
|
collapsed: data.deck.collapsed,
|
||||||
browserCollapsed: false,
|
browserCollapsed: false,
|
||||||
desc: data.deck.desc,
|
desc: data.deck.desc,
|
||||||
dyn: 0,
|
dyn: 0,
|
||||||
conf: 1,
|
conf: 1,
|
||||||
extendNew: 0,
|
extendNew: 0,
|
||||||
extendRev: 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,
|
|
||||||
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: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
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: {
|
"1": {
|
||||||
bury: true,
|
id: 1,
|
||||||
ease4: 1.3,
|
mod: nowSeconds,
|
||||||
ivlFct: 1,
|
name: "Default",
|
||||||
maxIvl: 36500,
|
usn: -1,
|
||||||
perDay: 200,
|
lrnToday: [0, 0],
|
||||||
hardFactor: 1.2,
|
revToday: [0, 0],
|
||||||
|
newToday: [0, 0],
|
||||||
|
timeToday: [0, 0],
|
||||||
|
collapsed: false,
|
||||||
|
browserCollapsed: false,
|
||||||
|
desc: "",
|
||||||
|
dyn: 0,
|
||||||
|
conf: 1,
|
||||||
|
extendNew: 0,
|
||||||
|
extendRev: 0,
|
||||||
},
|
},
|
||||||
lapse: {
|
};
|
||||||
delays: [10],
|
|
||||||
leechAction: 0,
|
const noteTypeJson: Record<string, AnkiNoteType> = {
|
||||||
leechFails: 8,
|
[data.noteType.id.toString()]: {
|
||||||
minInt: 1,
|
id: data.noteType.id,
|
||||||
mult: 0,
|
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]]],
|
||||||
},
|
},
|
||||||
dyn: false,
|
};
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
db.run(
|
const deckConfigJson: Record<string, AnkiDeckConfig> = {
|
||||||
`INSERT INTO col (id, crt, mod, scm, ver, dty, usn, ls, conf, models, decks, dconf, tags)
|
"1": {
|
||||||
VALUES (1, ?, ?, ?, 11, 0, 0, 0, ?, ?, ?, ?, '{}')`,
|
id: 1,
|
||||||
[
|
mod: nowSeconds,
|
||||||
nowSeconds,
|
name: "Default",
|
||||||
now,
|
usn: -1,
|
||||||
now,
|
maxTaken: 60,
|
||||||
JSON.stringify(defaultConfig),
|
autoplay: true,
|
||||||
JSON.stringify(noteTypeJson),
|
timer: 0,
|
||||||
JSON.stringify(deckJson),
|
replayq: true,
|
||||||
JSON.stringify(deckConfigJson),
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
for (const note of data.notes) {
|
|
||||||
db.run(
|
db.run(
|
||||||
`INSERT INTO notes (id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data)
|
`INSERT INTO col (id, crt, mod, scm, ver, dty, usn, ls, conf, models, decks, dconf, tags)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, '')`,
|
VALUES (1, ?, ?, ?, 11, 0, 0, 0, ?, ?, ?, ?, '{}')`,
|
||||||
[
|
[
|
||||||
Number(note.id),
|
|
||||||
note.guid || generateGuid(),
|
|
||||||
data.noteType.id,
|
|
||||||
nowSeconds,
|
nowSeconds,
|
||||||
-1,
|
now,
|
||||||
note.tags || " ",
|
now,
|
||||||
note.flds,
|
JSON.stringify(defaultConfig),
|
||||||
note.sfld,
|
JSON.stringify(noteTypeJson),
|
||||||
note.csum || checksum(note.sfld),
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dbData = db.export();
|
|
||||||
db.close();
|
|
||||||
|
|
||||||
return dbData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportApkg(data: ExportDeckData): Promise<Buffer> {
|
export async function exportApkg(data: ExportDeckData): Promise<Buffer> {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ async function openDatabase(zip: JSZip): Promise<Database | null> {
|
|||||||
const anki21 = zip.file("collection.anki21");
|
const anki21 = zip.file("collection.anki21");
|
||||||
const anki2 = zip.file("collection.anki2");
|
const anki2 = zip.file("collection.anki2");
|
||||||
|
|
||||||
let dbFile = anki21b || anki21 || anki2;
|
const dbFile = anki21b || anki21 || anki2;
|
||||||
if (!dbFile) return null;
|
if (!dbFile) return null;
|
||||||
|
|
||||||
const dbData = await dbFile.async("uint8array");
|
const dbData = await dbFile.async("uint8array");
|
||||||
@@ -36,17 +36,17 @@ function parseJsonField<T>(jsonStr: string): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function queryAll<T>(db: Database, sql: string, params: SqlValue[] = []): T[] {
|
function queryAll<T>(db: Database, sql: string, params: SqlValue[] = []): T[] {
|
||||||
const results: T[] = [];
|
|
||||||
const stmt = db.prepare(sql);
|
const stmt = db.prepare(sql);
|
||||||
stmt.bind(params);
|
try {
|
||||||
|
stmt.bind(params);
|
||||||
while (stmt.step()) {
|
const results: T[] = [];
|
||||||
const row = stmt.getAsObject();
|
while (stmt.step()) {
|
||||||
results.push(row as T);
|
results.push(stmt.getAsObject() as T);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
} finally {
|
||||||
|
stmt.free();
|
||||||
}
|
}
|
||||||
|
|
||||||
stmt.free();
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function queryOne<T>(db: Database, sql: string, params: SqlValue[] = []): T | null {
|
function queryOne<T>(db: Database, sql: string, params: SqlValue[] = []): T | null {
|
||||||
@@ -62,84 +62,85 @@ export async function parseApkg(buffer: Buffer): Promise<ParsedApkg> {
|
|||||||
throw new Error("No valid Anki database found in APKG file");
|
throw new Error("No valid Anki database found in APKG file");
|
||||||
}
|
}
|
||||||
|
|
||||||
const col = queryOne<{
|
try {
|
||||||
crt: number;
|
const col = queryOne<{
|
||||||
mod: number;
|
crt: number;
|
||||||
ver: number;
|
mod: number;
|
||||||
conf: string;
|
ver: number;
|
||||||
models: string;
|
conf: string;
|
||||||
decks: string;
|
models: string;
|
||||||
dconf: string;
|
decks: string;
|
||||||
tags: string;
|
dconf: string;
|
||||||
}>(db, "SELECT crt, mod, ver, conf, models, decks, dconf, tags FROM col WHERE id = 1");
|
tags: string;
|
||||||
|
}>(db, "SELECT crt, mod, ver, conf, models, decks, dconf, tags FROM col WHERE id = 1");
|
||||||
|
|
||||||
if (!col) {
|
if (!col) {
|
||||||
db.close();
|
throw new Error("Invalid APKG: no collection row found");
|
||||||
throw new Error("Invalid APKG: no collection row found");
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const decksMap = new Map<number, AnkiDeck>();
|
const decksMap = new Map<number, AnkiDeck>();
|
||||||
const decksJson = parseJsonField<Record<string, AnkiDeck>>(col.decks);
|
const decksJson = parseJsonField<Record<string, AnkiDeck>>(col.decks);
|
||||||
for (const [id, deck] of Object.entries(decksJson)) {
|
for (const [id, deck] of Object.entries(decksJson)) {
|
||||||
decksMap.set(parseInt(id, 10), deck);
|
decksMap.set(parseInt(id, 10), deck);
|
||||||
}
|
}
|
||||||
|
|
||||||
const noteTypesMap = new Map<number, AnkiNoteType>();
|
const noteTypesMap = new Map<number, AnkiNoteType>();
|
||||||
const modelsJson = parseJsonField<Record<string, AnkiNoteType>>(col.models);
|
const modelsJson = parseJsonField<Record<string, AnkiNoteType>>(col.models);
|
||||||
for (const [id, model] of Object.entries(modelsJson)) {
|
for (const [id, model] of Object.entries(modelsJson)) {
|
||||||
noteTypesMap.set(parseInt(id, 10), model);
|
noteTypesMap.set(parseInt(id, 10), model);
|
||||||
}
|
}
|
||||||
|
|
||||||
const deckConfigsMap = new Map<number, AnkiDeckConfig>();
|
const deckConfigsMap = new Map<number, AnkiDeckConfig>();
|
||||||
const dconfJson = parseJsonField<Record<string, AnkiDeckConfig>>(col.dconf);
|
const dconfJson = parseJsonField<Record<string, AnkiDeckConfig>>(col.dconf);
|
||||||
for (const [id, config] of Object.entries(dconfJson)) {
|
for (const [id, config] of Object.entries(dconfJson)) {
|
||||||
deckConfigsMap.set(parseInt(id, 10), config);
|
deckConfigsMap.set(parseInt(id, 10), config);
|
||||||
}
|
}
|
||||||
|
|
||||||
const notes = queryAll<AnkiNoteRow>(
|
const notes = queryAll<AnkiNoteRow>(
|
||||||
db,
|
db,
|
||||||
"SELECT id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data FROM notes"
|
"SELECT id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data FROM notes"
|
||||||
);
|
);
|
||||||
|
|
||||||
const cards = queryAll<AnkiCardRow>(
|
const cards = queryAll<AnkiCardRow>(
|
||||||
db,
|
db,
|
||||||
"SELECT id, nid, did, ord, mod, usn, type, queue, due, ivl, factor, reps, lapses, left, odue, odid, flags, data FROM cards"
|
"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>(
|
const revlogs = queryAll<AnkiRevlogRow>(
|
||||||
db,
|
db,
|
||||||
"SELECT id, cid, usn, ease, ivl, lastIvl, factor, time, type FROM revlog"
|
"SELECT id, cid, usn, ease, ivl, lastIvl, factor, time, type FROM revlog"
|
||||||
);
|
);
|
||||||
|
|
||||||
const mediaMap = new Map<string, Buffer>();
|
const mediaMap = new Map<string, Buffer>();
|
||||||
const mediaFile = zip.file("media");
|
const mediaFile = zip.file("media");
|
||||||
if (mediaFile) {
|
if (mediaFile) {
|
||||||
const mediaJson = parseJsonField<Record<string, string>>(await mediaFile.async("text"));
|
const mediaJson = parseJsonField<Record<string, string>>(await mediaFile.async("text"));
|
||||||
for (const [num, filename] of Object.entries(mediaJson)) {
|
for (const [num, filename] of Object.entries(mediaJson)) {
|
||||||
const mediaData = zip.file(num);
|
const mediaData = zip.file(num);
|
||||||
if (mediaData) {
|
if (mediaData) {
|
||||||
const data = await mediaData.async("nodebuffer");
|
const data = await mediaData.async("nodebuffer");
|
||||||
mediaMap.set(filename, data);
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
db.close();
|
|
||||||
|
|
||||||
return {
|
|
||||||
decks: decksMap,
|
|
||||||
noteTypes: noteTypesMap,
|
|
||||||
deckConfigs: deckConfigsMap,
|
|
||||||
notes,
|
|
||||||
cards,
|
|
||||||
revlogs,
|
|
||||||
media: mediaMap,
|
|
||||||
collectionMeta: {
|
|
||||||
crt: col.crt,
|
|
||||||
mod: col.mod,
|
|
||||||
ver: col.ver,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDeckNotesAndCards(
|
export function getDeckNotesAndCards(
|
||||||
|
|||||||
@@ -95,12 +95,22 @@ function scheduleNewCard(ease: ReviewEase, currentFactor: number): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (ease === 3) {
|
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 {
|
return {
|
||||||
type: CardType.LEARNING,
|
type: CardType.REVIEW,
|
||||||
queue: CardQueue.LEARNING,
|
queue: CardQueue.REVIEW,
|
||||||
ivl: 0,
|
ivl,
|
||||||
due: Math.floor(Date.now() / 1000) + SM2_CONFIG.LEARNING_STEPS[0] * 60,
|
due: calculateDueDate(ivl),
|
||||||
newFactor: currentFactor,
|
newFactor: SM2_CONFIG.DEFAULT_FACTOR,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,16 +163,24 @@ function scheduleLearningCard(ease: ReviewEase, currentFactor: number, left: num
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (stepIndex < steps.length - 1) {
|
if (stepIndex < steps.length - 1) {
|
||||||
const nextStep = stepIndex + 1;
|
|
||||||
return {
|
return {
|
||||||
type: CardType.LEARNING,
|
type: CardType.LEARNING,
|
||||||
queue: CardQueue.LEARNING,
|
queue: CardQueue.LEARNING,
|
||||||
ivl: 0,
|
ivl: 0,
|
||||||
due: Math.floor(Date.now() / 1000) + steps[nextStep] * 60,
|
due: Math.floor(Date.now() / 1000) + steps[stepIndex] * 60,
|
||||||
newFactor: currentFactor,
|
newFactor: currentFactor,
|
||||||
newLeft: nextStep * 1000 + (totalSteps - nextStep),
|
newLeft: left,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ease === 3) {
|
if (ease === 3) {
|
||||||
|
|||||||
@@ -2,21 +2,24 @@
|
|||||||
|
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { validate } from "@/utils/validate";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { parseApkg, getDeckNames, getDeckNotesAndCards } from "@/lib/anki/apkg-parser";
|
import { parseApkg, getDeckNames, getDeckNotesAndCards } from "@/lib/anki/apkg-parser";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { CardType, CardQueue, NoteKind } from "../../../generated/prisma/enums";
|
import { CardType, CardQueue, NoteKind } from "../../../generated/prisma/enums";
|
||||||
import { createLogger } from "@/lib/logger";
|
import { createLogger } from "@/lib/logger";
|
||||||
|
import { repoGenerateGuid, repoCalculateCsum } from "@/modules/note/note-repository";
|
||||||
import type { ParsedApkg } from "@/lib/anki/types";
|
import type { ParsedApkg } from "@/lib/anki/types";
|
||||||
|
|
||||||
|
import { randomBytes } from "crypto";
|
||||||
|
|
||||||
const log = createLogger("import-action");
|
const log = createLogger("import-action");
|
||||||
|
|
||||||
const schemaImportApkg = z.object({
|
const MAX_APKG_SIZE = 100 * 1024 * 1024;
|
||||||
deckName: z.string().min(1).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ActionInputImportApkg = z.infer<typeof schemaImportApkg>;
|
export interface ActionOutputPreviewApkg {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
decks?: { id: number; name: string; cardCount: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ActionOutputImportApkg {
|
export interface ActionOutputImportApkg {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -26,12 +29,6 @@ export interface ActionOutputImportApkg {
|
|||||||
cardCount?: number;
|
cardCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActionOutputPreviewApkg {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
decks?: { id: number; name: string; cardCount: number }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function importNoteType(
|
async function importNoteType(
|
||||||
parsed: ParsedApkg,
|
parsed: ParsedApkg,
|
||||||
ankiNoteTypeId: number,
|
ankiNoteTypeId: number,
|
||||||
@@ -75,8 +72,8 @@ async function importNoteType(
|
|||||||
name: ankiNoteType.name,
|
name: ankiNoteType.name,
|
||||||
kind: ankiNoteType.type === 1 ? NoteKind.CLOZE : NoteKind.STANDARD,
|
kind: ankiNoteType.type === 1 ? NoteKind.CLOZE : NoteKind.STANDARD,
|
||||||
css: ankiNoteType.css,
|
css: ankiNoteType.css,
|
||||||
fields: fields as unknown as object,
|
fields: JSON.parse(JSON.stringify(fields)),
|
||||||
templates: templates as unknown as object,
|
templates: JSON.parse(JSON.stringify(templates)),
|
||||||
userId,
|
userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -108,103 +105,110 @@ function mapAnkiCardQueue(queue: number): CardQueue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
async function importDeck(
|
||||||
parsed: ParsedApkg,
|
parsed: ParsedApkg,
|
||||||
deckId: number,
|
ankiDeckId: number,
|
||||||
userId: string,
|
userId: string,
|
||||||
deckNameOverride?: string
|
deckName?: string
|
||||||
): Promise<{ deckId: number; noteCount: number; cardCount: number }> {
|
): Promise<{ deckId: number; noteCount: number; cardCount: number }> {
|
||||||
const ankiDeck = parsed.decks.get(deckId);
|
const ankiDeck = parsed.decks.get(ankiDeckId);
|
||||||
if (!ankiDeck) {
|
if (!ankiDeck) {
|
||||||
throw new Error(`Deck ${deckId} not found in APKG`);
|
throw new Error(`Deck ${ankiDeckId} not found in APKG`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const deck = await prisma.deck.create({
|
const { notes: ankiNotes, cards: ankiCards } = getDeckNotesAndCards(parsed, ankiDeckId);
|
||||||
data: {
|
|
||||||
name: deckNameOverride || ankiDeck.name,
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
desc: ankiDeck.desc || "",
|
const deck = await tx.deck.create({
|
||||||
visibility: "PRIVATE",
|
data: {
|
||||||
collapsed: ankiDeck.collapsed,
|
name: deckName || ankiDeck.name,
|
||||||
conf: JSON.parse(JSON.stringify(ankiDeck)),
|
desc: ankiDeck.desc,
|
||||||
userId,
|
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 };
|
||||||
});
|
});
|
||||||
|
|
||||||
const { notes: ankiNotes, cards: ankiCards } = getDeckNotesAndCards(parsed, deckId);
|
return result;
|
||||||
|
|
||||||
if (ankiNotes.length === 0) {
|
|
||||||
return { deckId: deck.id, noteCount: 0, cardCount: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const noteTypeIdMap = new Map<number, number>();
|
|
||||||
const firstNote = ankiNotes[0];
|
|
||||||
if (firstNote) {
|
|
||||||
const importedNoteTypeId = await importNoteType(parsed, firstNote.mid, userId);
|
|
||||||
noteTypeIdMap.set(firstNote.mid, importedNoteTypeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = BigInt(Date.now() + Math.floor(Math.random() * 1000));
|
|
||||||
noteIdMap.set(ankiNote.id, noteId);
|
|
||||||
|
|
||||||
await prisma.note.create({
|
|
||||||
data: {
|
|
||||||
id: noteId,
|
|
||||||
guid: ankiNote.guid,
|
|
||||||
noteTypeId,
|
|
||||||
mod: ankiNote.mod,
|
|
||||||
usn: ankiNote.usn,
|
|
||||||
tags: ankiNote.tags,
|
|
||||||
flds: ankiNote.flds,
|
|
||||||
sfld: ankiNote.sfld,
|
|
||||||
csum: ankiNote.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 prisma.card.create({
|
|
||||||
data: {
|
|
||||||
id: BigInt(ankiCard.id),
|
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function actionPreviewApkg(formData: FormData): Promise<ActionOutputPreviewApkg> {
|
export async function actionPreviewApkg(formData: FormData): Promise<ActionOutputPreviewApkg> {
|
||||||
@@ -222,6 +226,10 @@ export async function actionPreviewApkg(formData: FormData): Promise<ActionOutpu
|
|||||||
return { success: false, message: "Invalid file type. Please upload an .apkg file" };
|
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 {
|
try {
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
const parsed = await parseApkg(buffer);
|
const parsed = await parseApkg(buffer);
|
||||||
@@ -229,14 +237,14 @@ export async function actionPreviewApkg(formData: FormData): Promise<ActionOutpu
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "APKG parsed successfully",
|
message: `Found ${decks.length} deck(s)`,
|
||||||
decks: decks.filter(d => d.cardCount > 0)
|
decks: decks.filter(d => d.cardCount > 0),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to parse APKG", { error });
|
log.error("Failed to preview APKG", { error });
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: error instanceof Error ? error.message : "Failed to parse APKG file"
|
message: error instanceof Error ? error.message : "Failed to parse APKG file",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,22 +269,26 @@ export async function actionImportApkg(
|
|||||||
return { success: false, message: "No deck selected" };
|
return { success: false, message: "No deck selected" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const deckId = parseInt(deckIdStr, 10);
|
const ankiDeckId = parseInt(deckIdStr, 10);
|
||||||
if (isNaN(deckId)) {
|
if (isNaN(ankiDeckId)) {
|
||||||
return { success: false, message: "Invalid deck ID" };
|
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 {
|
try {
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
const parsed = await parseApkg(buffer);
|
const parsed = await parseApkg(buffer);
|
||||||
|
|
||||||
const result = await importDeck(parsed, deckId, session.user.id, deckName || undefined);
|
const result = await importDeck(parsed, ankiDeckId, session.user.id, deckName || undefined);
|
||||||
|
|
||||||
log.info("APKG imported successfully", {
|
log.info("APKG imported successfully", {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
deckId: result.deckId,
|
deckId: result.deckId,
|
||||||
noteCount: result.noteCount,
|
noteCount: result.noteCount,
|
||||||
cardCount: result.cardCount
|
cardCount: result.cardCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -290,7 +302,7 @@ export async function actionImportApkg(
|
|||||||
log.error("Failed to import APKG", { error });
|
log.error("Failed to import APKG", { error });
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: error instanceof Error ? error.message : "Failed to import APKG file"
|
message: error instanceof Error ? error.message : "Failed to import APKG file",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user